STM32红外遥控解码实战:NEC协议定时器输入捕获与状态机实现
我不止一次帮人调试STM32的红外遥控解码遇到的问题从一个键都解不出来到松手了还在触发重复码各种情况的坑基本都踩过一遍。NEC协议看着简单但真正在STM32上用定时器输入捕获做解码时引导码、数据位、重复码的时间窗口怎么判代码里状态机怎么设计这些都是有讲究的。这篇文章把我实际用的思路、代码、以及调试过程中总结出来的问题清单完整写出来给做遥控器项目、电子设计竞赛或者毕设的同学一个可以直接复现的参考。1. 项目背景为什么选NEC协议做解码1.1 NEC协议基础NEC协议是红外遥控里最常见的一种像大量DVD、电视、机顶盒遥控器都基于它。它的几个关键时间参数必须记牢引导码由9ms的低电平载波和4.5ms的高电平间隔组成逻辑0是0.56ms低电平加0.56ms高电平逻辑1是0.56ms低电平加1.69ms高电平。数据帧一共32位依次是8位地址码、8位地址反码、8位命令码、8位命令反码发送时低位在前。值得强调的是一体化红外接收头输出的电平是反相的。接收头在没有38kHz载波时输出高电平收到载波时输出低电平。所以接收端看到的是空闲时为高引导码是一段9ms的低电平接着4.5ms高电平然后每个数据位以0.56ms低电平开头后面跟着不同宽度的高电平。判断NEC解码是否成功关键是反码校验地址码和地址反码按位取反后应该等于0xFF命令码和命令反码同理。实际项目中有些遥控器发送的是16位地址的NEC扩展帧没有反码这时候要单独处理。1.2 方案选型的关键对比STM32解码NEC红外信号常见的实现路径有三种纯GPIO外部中断加延时测量、定时器输入捕获、外部中断加定时器计数。单纯用外部中断加延时测量最简单例如在中断里翻转引脚并开启定时器轮询但对时序精度要求高代码耦合重尤其是解码过程中一旦被其他中断打断9ms引导码和1.69ms位高电平的测量误差就会累积导致整个帧解码失败。我实际推荐的是定时器输入捕获方案利用STM32定时器硬件记录边沿时间戳CPU只在中断里做差值计算精度高且占用资源少。但如果项目里定时器通道被占用或者RX引脚比较固定则可以用外部中断加定时器计数的替代方案灵活性和输入捕获相当。这篇文章两种方案都会给出主要代码以定时器输入捕获为准。2. 硬件准备与接收电路2.1 红外接收头选型目前最常用的一体化红外接收头是VS1838B和TSOP38238内部集成了光敏二极管、放大器和解调电路输出直接是解调后的TTL电平。选型时重点看中心频率NEC协议一般工作在38kHz接收头要选载波频率匹配的型号偏差过大时遥控距离会明显缩短甚至完全收不到。引脚定义上VS1838B从左向右依次是输出OUT、电源GND、电源VCC而TSOP系列一般是OUT、GND、VCC不同封装方向需要对照规格书确认焊接前如果不查手册接反的情况非常常见。接收头的OUT引脚通常内部有上拉能力但为了抗干扰和电平可靠性外部再并联一个10kΩ上拉电阻到3.3V或者5V。2.2 电路连接和注意事项推荐电路连接如下接收头VCC接3.3V或5VGND接公共地OUT接STM32的某个GPIO输入引脚旁边放一个100nF陶瓷电容在VCC和GND之间做电源滤波。如果使用STM32F103C8T6这样的常用芯片建议把接收头OUT接到支持外部中断且带定时器复用功能的引脚上例如PA6对应TIM3_CH1、PA0对应TIM2_CH1这样可以同时支持两种解码方案。供电电压方面接收头接5V时驱动能力更强接收距离通常比3.3V时好但OUT高电平也会变为5V如果STM32是3.3V供电FT引脚可以容忍5V输入普通引脚则不建议直接接5V。我的习惯是接收头VCC用3.3V然后把遥控距离控制在5米以内实测足够稳定。接线时还有一个容易被忽略的要点接收头要尽量远离电机、继电器和开关电源特别是做小家电控制板时电机启动瞬间的电磁干扰会直接串进红外接收电路导致误触发。PCB布线时接收头旁边不要走大电流线必要时在OUT上串一个1kΩ电阻再进MCU。3. 方案一定时器输入捕获实现解码3.1 捕获原理与时序分析定时器输入捕获的本质是引脚出现指定边沿时硬件自动把当前计数器值锁存到捕获寄存器同时产生中断。我们要解码NEC信号就需要测量每个电平的持续时间所以思路很直接配置TIM的某个通道让它先捕获上升沿捕获完成后立刻在中断里把捕获极性翻转成下降沿下一次捕获下降沿再翻转成上升沿如此交替。每次捕获中断里读取当前计数器的值减去上一次捕获值就得到一个电平宽度。1MHz计数频率下计数值1对应1微秒。因为NEC的最长电平是9ms的引导低电平加上4.5ms间隔总共也不超过14ms所以把定时器周期设成0xFFFF也就是65535完全不会溢出。这里有个关键细节读取差值时必须用无符号减法。例如当前值小于上次值说明发生了回绕但(uint16_t)(current - last)在C语言里依然能得到正确的环形差值这是依赖无符号整数回绕特性不需要额外的溢出判断。另外必须使用volatile修饰捕获时间戳和状态变量否则编译器优化可能带来预料之外的错误。3.2 定时器与GPIO初始化以STM32F103标准库为例配置TIM3_CH1到PA6开启1MHz计数频率和上升沿捕获。时钟来自APB172MHz主频预分频71计数频率为1MHz。ICFilter滤波设为0x0F可以滤除短毛刺对NEC这种毫秒级电平没有影响反而能抑制环境噪声误触发捕获。void NEC_Tim3_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_ClearFlag(TIM3, TIM_FLAG_CC1 | TIM_FLAG_Update); TIM_ITConfig(TIM3, TIM_IT_CC1 | TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(TIM3, ENABLE); }GPIO配置成上拉输入的原因前面说过即使接收头内部有上拉外部上拉一下更稳妥。TIM_ICPSC_DIV1表示不分频每次有效边沿都触发捕获NEC的电平宽度在微秒级别不分频完全来得及处理。3.3 解码状态机实现解码的核心是状态机。我把状态拆成NEC_IDLE、NEC_LEAD、NEC_DATA三个每个边沿到来时根据当前状态和测得的电平宽度决定跳转或者拒绝。状态转移的判定条件全部围绕NEC协议的时间窗展开9ms引导低电平、4.5ms间隔高电平、0.56ms数据低电平、0.56ms/1.69ms数据高电平。先看中断处理函数它负责读捕获值、算宽度、翻转捕获极性static uint16_t last_capture 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_CC1) ! RESET) { uint16_t capture; uint16_t diff; uint8_t level; TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); capture TIM_GetCapture1(TIM3); diff (uint16_t)(capture - last_capture); last_capture capture; level GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6); if (level Bit_SET) { // 刚捕获到上升沿diff 是低电平宽度 NEC_HandleLowDiff(diff); TIM_OC1PolarityConfig(TIM3, TIM_ICPolarity_Falling); } else { // 刚捕获到下降沿diff 是高电平宽度 NEC_HandleHighDiff(diff); TIM_OC1PolarityConfig(TIM3, TIM_ICPolarity_Rising); } } if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); } }每次捕获后翻转极性时要注意标准库里TIM_OC1PolarityConfig函数虽然名字带OC但同样适用于输入捕获模式下的极性设置实际操作中这是正常的用法。然后是两个电平处理函数和状态机typedef enum { NEC_IDLE 0, NEC_LEAD, NEC_DATA } NEC_State; static NEC_State nec_state NEC_IDLE; static uint32_t nec_raw 0; static uint8_t nec_bit_count 0; volatile uint8_t nec_data_ready 0; volatile uint8_t nec_repeat_flag 0; volatile uint32_t nec_raw_value 0; void NEC_ResetState(void) { nec_state NEC_IDLE; nec_bit_count 0; nec_raw 0; } void NEC_HandleLowDiff(uint16_t diff) { // 低电平宽度单位us if (nec_state NEC_IDLE) { if ((diff 8000) (diff 10000)) { nec_state NEC_LEAD; } } else if (nec_state NEC_LEAD) { // 引导低电平已经确认电平由低转高的瞬间不额外处理 } else if (nec_state NEC_DATA) { // 数据位以0.56ms低电平开头 if ((diff 300) || (diff 800)) { NEC_ResetState(); } } } void NEC_HandleHighDiff(uint16_t diff) { // 高电平宽度单位us if (nec_state NEC_LEAD) { if ((diff 4000) (diff 5000)) { nec_state NEC_DATA; nec_bit_count 0; nec_raw 0; } else if ((diff 2000) (diff 3000)) { // 重复码按键持续按下 nec_repeat_flag 1; NEC_ResetState(); } else { NEC_ResetState(); } } else if (nec_state NEC_DATA) { if (nec_bit_count 32) { NEC_ResetState(); return; } if ((diff 400) (diff 800)) { // 逻辑0 nec_raw | (0UL nec_bit_count); nec_bit_count; } else if ((diff 1400) (diff 2000)) { // 逻辑1 nec_raw | (1UL nec_bit_count); nec_bit_count; } else { NEC_ResetState(); return; } if (nec_bit_count 32) { nec_raw_value nec_raw; nec_data_ready 1; NEC_ResetState(); } } else { // IDLE 状态下的高电平忽略 } }这里要注意增量式移位寄存器和位填充的区别。第0个到达的数据位是地址码的bit0所以我用nec_raw | (1UL nec_bit_count)把第0位放进bit0最后raw的低8位就是整数形式的地址。如果接收完再右移拼接还要做位反转容易绕晕写成位填充思路最直观。还有一个细节数据位低电平的判定我放在NEC_HandleLowDiff里面只检查宽度是否在合理范围内真正的0/1判定在高电平时做。这样状态机的逻辑是低电平负责确认还在数据区高电平负责决定这一位是0还是1各司其职。3.4 主循环中读取解码结果解码完成后数据放在nec_raw_value通过nec_data_ready标志通知主循环。主循环里取出数据后要做反码校验确认帧不是噪声if (nec_data_ready) { uint32_t value nec_raw_value; uint8_t addr value 0xFF; uint8_t addr_inv (value 8) 0xFF; uint8_t cmd (value 16) 0xFF; uint8_t cmd_inv (value 24) 0xFF; nec_data_ready 0; if ((addr ^ addr_inv) 0xFF (cmd ^ cmd_inv) 0xFF) { printf(ADDR0x%02X CMD0x%02X\n, addr, cmd); } else { printf(NEC frame invalid\n); } } if (nec_repeat_flag) { nec_repeat_flag 0; // 长按处理例如音量连续减小 }校验通过后cmd就是当前按下按键的命令码可以直接做成switch-case按键映射。重复码比较特殊它不携带任何数据只在长按时周期性出现所以在主循环里单独用一个标志处理适合做音量连续步进或者光标连续移动这类交互。4. 方案二外部中断加定时器计数4.1 适用场景与思路事情不是每次都那么顺利。有些板子在设计时没有把红外接收头接到定时器输入捕获引脚上或者TIM2/TIM3已经被其他功能占用这时方案一就用不了。替代思路是把任意一个空闲定时器配成1MHz自由计数模式然后把接收头输出接到支持外部中断的引脚每次边沿触发EXTI中断在中断里读取CNT值并算出两次边沿的时间差。这个方案在原理上完全等价于输入捕获只是少了硬件锁存功能。副作用是中断响应延迟可能影响时间精度但STM32F103响应中断最多也就几十个时钟周期换算成微秒大约在1us以内对NEC这种毫秒级电平来说可以接受。4.2 关键实现细节定时器初始化时不做输入捕获配置只把TIM4设置为1MHz向上计数计数器周期0xFFFF。外部中断配置中GPIO用上拉输入EXTI触发模式先设成上升沿触发。PA6对应的EXTI线是EXTI6注意同一组GPIO的多个引脚会共用同一条EXTI线如果多个引脚同时开外部中断需要在中断里判断是哪个引脚触发的。void EXTI6_IRQHandler(void) { static uint16_t last_cnt 0; uint16_t now; uint16_t diff; uint8_t level; if (EXTI_GetITStatus(EXTI_Line6) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line6); now TIM_GetCounter(TIM4); diff (uint16_t)(now - last_cnt); last_cnt now; level GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6); if (level Bit_SET) { NEC_HandleLowDiff(diff); EXTI_TriggerConfig(EXTI_Line6, EXTI_Trigger_Falling); } else { NEC_HandleHighDiff(diff); EXTI_TriggerConfig(EXTI_Line6, EXTI_Trigger_Rising); } } }EXTI_TriggerConfig是标准库函数可以运行时改变触发边沿。因为外部中断每次只触发一个边沿所以必须在中断里手动切换成下一次要捕获的边沿。如果忘记切换就只能捕获同一类边沿时间差含义就全变了解码会连续失败。这种方案的优点是不依赖特定定时器通道TIM4空闲就能用PA6换成PB0、PC13这些支持EXTI的引脚也一样。缺点是CPU负载比方案一略高一点而且中断应答延迟会造成微小的宽度误差但实测下来解码成功率几乎不受影响。5. 实测遇到的问题与排查心得5.1 常见问题速查表我把这几个项目里踩过的坑整理成一张速查表很多问题症状类似但原因完全不同。遇到解码失败时建议按这个表逐项排查现象可能原因解决办法完全收不到任何信号接收头引脚接反或供电异常对照规格书确认OUT/GND/VCC用万用表量接收头输出静态电平正常应为高收到信号但解码总是失败捕获极性没有交替翻转检查中断里是否每次边沿后都切换了ICPolarity或EXTI触发方式能解出部分按键熄灯附近按键错乱解码阈值窗口过窄或过宽把9ms判据放宽到8000到10000us数据高电平0/1判定放在400到800和1400到2000之间按下一次却连触发多次把环境噪声当成了有效帧加反码校验连续两份帧相同才认定有效或者严格限制引导码时间窗长按时没有连续响应没有处理重复码在NEC_LEAD状态增加2.25ms高电平识别并置重复标志近距离正常远距离失灵接收头供电不足或电源纹波大给接收头VCC加100nF电容尝试更强力的接收头型号比如带AGC的38kHz类型写这套阈值时我是刻意留了一定余量的。NEC协议标称9ms引导码但实际遥控器发射时引导低电平可能在8.8到9.2ms之间波动如果阈值卡得太死不同的遥控器兼容性就会很差。0.56ms和1.69ms的判定也同理留出上下30%的余量是比较实践经验值。5.2 排查工具和调试技巧排查红外解码问题逻辑分析仪是最好用的工具。把接收头OUT接到逻辑分析仪按下遥控器按键立刻就能看到一段波形9ms低电平、4.5ms高电平然后一串0.56ms低电平和高电平数据。如果用逻辑分析仪的协议分析功能甚至可以直接解出NEC帧省去自己数波形的过程。没有逻辑分析仪时可以在中断里用串口打印时间差但要注意printf本身非常耗时不能在中断里直接调用否则时序完全被打乱。正确做法是在中断里缓存时间差到环形缓冲区主循环空闲时再批量打印这样能边观察边排查还不会干扰解码。还要提一个很容易踩但又特别隐蔽的问题用USART打印调试时printf默认走阻塞式发送如果波特率较低发送一个字符要几十微秒恰好期间来了红外中断就会造成捕获时间戳跳动。所以打印调试时建议提高波特率到115200甚至更高或者解码完成后再打印解码过程中不打印。另一个容易忽视的问题是中断优先级。TIM3捕获中断在NVIC里如果被配置成低于其他频繁触发的中断比如ADC中断或者串口空闲中断那么红外边沿被打断后捕获寄存器虽然仍然由硬件锁存但读取时间和实际边沿时刻的差距会变大。最安全的方式是把捕获中断优先级提到最高档保证时间差数据足够准确。6. 解码之后还能做什么发送与场景延伸6.1 红外发送基础解码和发送其实是同一枚硬币的两面。STM32做红外发送时通常用定时器PWM输出产生38kHz载波然后用另一个IO控制载波的包络。发送NEC帧就是把要发的内容按NEC编码规则组织成高低电平序列9ms载波加4.5ms间隔数据位按逻辑0或1生成对应的0.56ms与0.56ms/1.69ms组合。我之前做过一个红外转发器接收头解出遥控器的按键命令码后通过按键映射在本地存储一份码表再在另一个房间用红外发射管把命令码重新发出去实测距离三米内稳定转发延迟几乎无感。这个场景很适合做多设备集中控制把一堆遥控器解出的码统一存入一张映射表一个STM32就能集中管理多个家电的红外指令。6.2 从解码到智能控制的落地企业里做红外学习型遥控器很常见核心功能就两个学习模式和发射模式。学习模式就是本文讲的解码把命令码加上地址码存进Flash或EEPROM发射模式则通过码表反向调制38kHz载波发出去。只要NEC解码这一步的时间阈值可靠整个学习遥控器项目的底盘就稳了。再往下延伸可以把命令码通过串口发给上位机做成红外转串口网关。也可以用几个GPIO模拟其他总线把解码结果转成LED状态指示或继电器控制信号。网上有人把红外遥控和HomeAssistant联动虽然要额外接一个WiFi模块但思路是一样的STM32负责实时性要求高的解码WiFi模块负责网络交互串口对接成本非常低。我个人在实际项目中最受益的一条经验是不要一开始就追求把所有键值都解出来先固定按一个音量键把这个键的波形完整打印出来对照NEC时序图确认引导码和第一个数据位的宽度再跑通一整帧。只要第一个帧能解通后面32位数据位只是重复循环状态机跑起来只是时间问题。调好一个键之后再把不同厂商的遥控器拿来验证阈值余量你会发现当初留的宽容阈值在这种兼容性测试中能省掉很多返工时间。