STM32 NEC红外遥控解码实战:从反相波形到中断时间戳实现 第一次用STM32做红外遥控解码时我一度怀疑自己买到了假接收头。遥控器明明对着板子按示波器上却看到一大串低电平脉冲和网上NEC协议图解里高电平脉冲完全对不上。后来才明白红外接收头输出的是解调后的反相电平空闲时输出高收到38kHz载波时才拉低。想通这一点后续的解码逻辑就顺多了。这篇内容围绕STM32上的NEC红外遥控器解码展开把协议帧结构、硬件连接方式、中断加时间戳的解码方案、完整代码和调试排错一次讲清楚。适合正在做智能家居、小车遥控、DIY家电控制这类项目的朋友直接参考后续想自己做红外发射或者兼容其他遥控协议也能顺着这套思路往下走。1. NEC协议到底长什么样帧结构与时序拆解1.1 38kHz载波与接收头的反相输出先理清一个最容易绕晕的概念红外遥控器发出的信号本质上是把数据调制成38kHz的载波再通过红外LED发射出去。遥控器发送的是有载波和无载波两种状态有载波代表逻辑低无载波代表逻辑高。接收头内部完成放大、检波、整形之后输出端却把极性反转了平时没有红外信号时输出高电平收到38kHz载波时输出低电平。所以你在STM32的GPIO脚上读到的波形和协议文档里画的发射波形正好相反。文档里画9ms的高电平引导脉冲你在接收头输出端看到的是9ms的低电平。刚开始做解码时很多人拿着发射端波形图去比对接收端数据怎么看怎么不对劲其实就是这一步极性没有转过来。1.2 一帧NEC数据引导码、32位数据、停止位NEC协议一帧完整数据由引导码、地址码、地址反码、命令码、命令反码和停止位组成。按照接收头输出端的电平来看时序参数如下表信号低电平时间高电平时间含义引导码9ms4.5ms帧头告诉接收端数据要开始了逻辑0562.5µs562.5µs二进制0逻辑1562.5µs1.6875ms二进制1重复码9ms2.25ms表示按键持续按住停止位562.5µs高电平结束一帧引导码之后是32位数据顺序通常为8位地址码、8位地址反码、8位命令码、8位命令反码。反码存在的意义是校验地址码和地址反码相加应该等于0xFF命令码和命令反码相加也应该等于0xFF。这样能有效防止传输过程中的误码导致设备误动作。这里要注意一个关键点NEC协议发送数据时是LSB先行也就是先发送最低位。很多人在解析时发现地址对不上是因为没有把位顺序反转过来。比如遥控器发送地址0x00波形上先出现的应该是8个逻辑0如果你按正常顺序去读读出来就是0x00本身这没问题。但如果发送0x01低电平在前、高电平在后直接按顺序收进变量就变成0x80了。所以解析时要记得按位逆序处理或者干脆用移位方式逐位组合先收到的位放在最低位。1.3 重复码与按键连发NEC协议的重复码是个很容易被忽略但很实用的机制。当你按住遥控器按键不放时遥控器不会持续重复发送完整数据帧而是每隔约110ms发送一次重复码9ms低电平加2.25ms高电平。这样设计可以降低功耗、减少红外LED的发热同时让接收端知道按键仍然被按住。解码时如果只关注按键值可以把重复码当作一个单独的标志位。需要实现长按连续调节音量、亮度这类功能时重复码就是现成的判断依据收到重复码并且上一次按键值有效就在这个重复码周期内继续执行上一次的命令。后面第6章我会展开说这个用法。2. 硬件准备选对接收头电路能少踩一半坑2.1 接收头怎么选市面上最常见的NEC兼容接收头是VS1838B另外还有TSOP38238、TSOP4838、IRM-3638T等型号。命名里的38基本都代表中心频率38kHz和NEC协议的标准载波频率一致。这些接收头内部已经把放大、滤波、解调做完了输出端直接给单片机TTL电平外部只需要很少的外围元件。选型时注意两点一是供电电压范围VS1838B这类接收头通常支持3.3V到5V供电。如果你用的是STM32F103这类3.3V系统直接3.3V供电即可输出高电平约等于供电电压GPIO能正常识别。二是输出极性几乎所有常见接收头都是低电平有效有载波时输出低买模块时看一下商家给的资料确认就好。2.2 最小电路三根线其实就行接收头的典型封装是三个引脚OUT、GND、VCC。焊接和接线时最精简的电路就是三根线VCC接3.3V或5VGND接地OUT接STM32的一个GPIO。如果你的接收头输出类型比较特殊有些是开漏输出需要在OUT和VCC之间加一个10kΩ上拉电阻否则高电平会浮空导致误触发。实际使用中我建议在VCC和GND之间加一个0.1µF的陶瓷电容做去耦靠近接收头引脚放置。红外接收头内部有高增益放大器电源纹波大的时候容易产生误码加个电容能明显改善稳定性。这个电容不是必需的我第一次接的时候偷懒没加在电机启动瞬间就出现过解码失败加上电容后问题消失。2.3 GPIO选择要支持外部中断最好选5V容忍脚STM32解码NEC信号GPIO需要配置为外部中断输入或者至少支持输入捕获。选引脚时先查芯片的数据手册确认该引脚支持EXTI中断线。以STM32F103C8T6为例PA0到PA15以及PB0到PB15大部分都支持EXTI但同一组EXTI线只能在多个引脚中选一个使用比如PA0和PB0共用EXTI0不能同时都用。这里有一个容易踩的坑如果板子上已经用了PA0做其他事情你想着换到PB0结果发现两个引脚共享同一根EXTI线配置起来很别扭。我一般习惯单独挑一个不常用的引脚比如PB5避免和其他外设冲突。另外如果接收头用5V供电GPIO一定要选5V容忍的引脚否则直接损坏芯片。STM32F103的PB口基本都标5V tolerantPA口部分引脚也是具体以手册引脚定义表为准。3. 解码方案怎么选外部中断加时间戳是我最常用的方式3.1 三条路轮询、输入捕获、外部中断NEC解码在STM32上的实现方式大致分为三种。第一种是阻塞式轮询用定时器延时不断扫描GPIO电平配合状态机解析。这种写法简单能在一两百行代码内完成但缺点非常明显解码期间CPU被占死其他任务全部卡住。如果只是做纯遥控灯控勉强能接受如果项目里还有显示刷新、传感器采集、通信任务就很容易出问题。第二种是定时器输入捕获模式把接收头输出接到定时器的捕获通道上硬件自动记录边沿时刻。这种方式精度高、CPU负担小但受限于定时器通道和引脚的复用关系比如TIM2的CH1通常固定映射到PA0引脚选择不够灵活。第三种是外部中断加时间戳模式GPIO产生下降沿中断时读取一个自由运行的定时器计数器的当前值做时间差。这种方式代码简单、引脚选择灵活精度完全够用。我后续项目基本都用这个方案下面重点展开。3.2 只看下降沿的判定方法NEC信号在接收头输出端的特点是每个脉冲的低电平持续时间相对固定逻辑0和逻辑1的低电平时长相同都是562.5µs变化的是高电平持续时间。基于这个特点可以只检测下降沿通过测量相邻两个下降沿之间的时间间隔来区分信号。来看具体计算逻辑0从下降沿开始低电平562.5µs后转为高电平高电平再持续562.5µs后到达下一个下降沿间隔大约1.125ms。逻辑1的低电平同样是562.5µs但高电平持续1.6875ms因此相邻下降沿间隔约2.25ms。引导码是9ms低电平加4.5ms高电平间隔13.5ms。重复码是9ms低电平加2.25ms高电平间隔11.25ms。这样一来解码逻辑就大大简化了不需要在中断里去切换上升沿下降沿检测也不用记录两个方向的时刻只守着下降沿中断统计间隔时间就能判断信号类型。状态机的状态转移也变得非常清晰间隔约13.5ms判断为引导码开始接收32位数据间隔约1.125ms记一位逻辑0间隔约2.25ms记一位逻辑1间隔约11.25ms判断为重复码这种只看下降沿的思路本质上是利用NEC协议低电平时长恒定的特点把二维的边沿测量问题降成了一维的时间间隔测量。理解了这个之后你会发现有很多类似协议都可以用这套思路处理。3.3 定时器时钟源与计数范围时间戳需要用一个定时器提供基准。推荐用基本定时器TIM6或TIM7配置成1MHz计数频率也就是计数器每1µs加1。STM32F103的系统时钟通常是72MHzAPB1总线时钟36MHz定时器时钟在未倍频配置下是36MHz。要让计数器变成1MHz预分频器设置为36-1即可。如果你用的芯片主频是84MHz可以根据实际时钟树算出对应的分频值。测量NEC协议最长的间隔是引导码的13.5ms最短的是逻辑0的1.125ms。用16位计数器最大计数65535完全满足需求1MHz计数频率下能覆盖65.5ms的范围。不过要注意如果你用16位定时器并且时间差接近65535µs需要检查溢出处理NEC协议里用不到这么大间隔所以实际应用中不用太担心。这里有一个非常隐蔽的细节定时器初始化后要立即清一次计数器并启动不要在中断回调里再去读计数器的值之前忘记记录上次的时间戳。每次进入下降沿中断先读出当前计数器值减去上一次记录的值得到时间差然后再更新记录。时序上稍微乱一下状态机就会误判。4. 代码实现从初始化到解析出一帧完整按键值4.1 GPIO、EXTI、定时器初始化下面是一段基于HAL库的初始化代码芯片以STM32F103为例接收头输出接PB5系统时钟72MHz。定时器用TIM6频率配置为1MHz。/* 红外接收引脚定义 */ #define IR_IN_Pin GPIO_PIN_5 #define IR_IN_Port GPIOB /* TIM6 初始化1MHz 计数频率每 1us 计数器加 1 */ void IR_Tim_Init(void) { TIM_HandleTypeDef htim6 {0}; __HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance TIM6; htim6.Init.Prescaler 36 - 1; // 72MHz / 36 2MHz, 再除2 1MHz htim6.Init.Period 0xFFFF; // 16位最大计数范围 HAL_TIM_Base_Init(htim6); HAL_TIM_Base_Start(htim6); __HAL_TIM_SET_COUNTER(htim6, 0); } /* GPIO 初始化下降沿触发外部中断内部上拉 */ void IR_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin IR_IN_Pin; GPIO_InitStruct.Mode GPIO_MODE_IT_FALLING; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(IR_IN_Port, GPIO_InitStruct); HAL_NVIC_SetPriority(EXTI9_5_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn); }GPIO配置成下降沿触发是因为我们采用只看下降沿的判定方案。内部上拉加上去更保险即使接收头输出为开漏类型也能读到可靠的高电平空闲态。PB5对应的是EXTI9_5中断线在中断服务函数里把引脚事件分发到HAL库回调函数即可。4.2 中断回调与边沿时间戳记录在stm32f1xx_it.c或者自己的中断文件里需要把EXTI9_5_IRQn的中断服务函数补上void EXTI9_5_IRQHandler(void) { HAL_GPIO_EXTI_IRQHandler(IR_IN_Pin); }然后实现HAL库的回调函数。时间戳记录的逻辑是读当前计数器值计算与上一次下降沿之间的间隔然后调用解析函数最后更新记录值。volatile uint32_t ir_last_edge_time 0; volatile uint16_t ir_tick_overflow 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin IR_IN_Pin) { uint32_t now __HAL_TIM_GET_COUNTER(htim6); uint32_t interval now - ir_last_edge_time; ir_last_edge_time now; IR_ParseInterval(interval); } }这里有个技巧用无符号32位变量做时间差即使计数器回绕到0也不会出错。因为减法结果在模运算下是正确的只要两次读数间隔不超过2^32个计数周期差值和实际经过的时间一致。4.3 状态机把时间间隔翻译成位核心的解析函数是一个简单的状态机把时间间隔分类成引导码、逻辑0、逻辑1和重复码#define IR_INTERVAL_GUIDE_MIN 12500 // 12.5ms #define IR_INTERVAL_GUIDE_MAX 14500 // 14.5ms #define IR_INTERVAL_REPEAT_MIN 10500 // 10.5ms #define IR_INTERVAL_REPEAT_MAX 11900 // 11.9ms #define IR_INTERVAL_ONE_MIN 1900 // 1.9ms #define IR_INTERVAL_ONE_MAX 2600 // 2.6ms #define IR_INTERVAL_ZERO_MIN 850 // 0.85ms #define IR_INTERVAL_ZERO_MAX 1400 // 1.4ms volatile uint8_t ir_bit_count 0; volatile uint32_t ir_frame 0; volatile uint8_t ir_repeat_flag 0; volatile uint8_t ir_frame_ready 0; void IR_ParseInterval(uint32_t interval_us) { if (interval_us IR_INTERVAL_GUIDE_MIN interval_us IR_INTERVAL_GUIDE_MAX) { /* 收到引导码开始接收一帧数据 */ ir_bit_count 0; ir_frame 0; ir_repeat_flag 0; } else if (interval_us IR_INTERVAL_REPEAT_MIN interval_us IR_INTERVAL_REPEAT_MAX) { /* 重复码说明按键仍在按住 */ ir_repeat_flag 1; } else if (ir_bit_count 32) { if (interval_us IR_INTERVAL_ONE_MIN interval_us IR_INTERVAL_ONE_MAX) { ir_frame (ir_frame 1) | 1; ir_bit_count; } else if (interval_us IR_INTERVAL_ZERO_MIN interval_us IR_INTERVAL_ZERO_MAX) { ir_frame (ir_frame 1) | 0; ir_bit_count; } else { /* 间隔超出范围判定为噪声重新等待引导码 */ ir_bit_count 0; ir_frame 0; } if (ir_bit_count 32) { ir_frame_ready 1; } } else { /* 已经收满32位忽略多余信号等待下次引导码 */ } }状态机的核心逻辑就是先等引导码收到引导码之后开始连续收集32个时间间隔每个间隔根据长短翻译成0或1。任何一次间隔不在合理范围内立即放弃当前帧重新等待引导码。这种宁可丢帧也不误判的思想在处理红外这种易受干扰的信号时非常实用。时间窗口的取值我都给了余量因为不同遥控器的振荡电阻精度会导致时序有偏差实测下来有些遥控器的逻辑1高电平时间能到1.8ms有些只有1.6ms。如果阈值卡得太死换个品牌遥控器就会解码失败。4.4 帧校验与串口打印收满32位之后在主循环里做帧校验和结果打印。为了测试方便可以先把原始帧打印出来再用校验公式判断数据是否有效void IR_Frame_Decode(uint32_t frame) { uint8_t addr frame 0xFF; uint8_t addr_inv (frame 8) 0xFF; uint8_t cmd (frame 16) 0xFF; uint8_t cmd_inv (frame 24) 0xFF; if ((uint8_t)(addr addr_inv) 0xFF (uint8_t)(cmd cmd_inv) 0xFF) { printf(NEC OK: addr0x%02X, cmd0x%02X\r\n, addr, cmd); } else { printf(NEC BAD: raw0x%08lX\r\n, (unsigned long)frame); } }这里需要注意一个常见误区我在前面提到NEC是LSB先行发送上面的代码直接按移位方式逐位收进frame先收到的位最终落在低字节。如果遥控器地址是0x00、命令是0x45原始波形先发的是命令码0x45的最低位最终frame的低8位确实是0x45。但如果遥控器厂家做了位序反转或者用了扩展NEC协议你需要把frame的每一位做逆序处理后才能得到真实值。遇到解析结果和遥控器标注不一致时先怀疑位序问题再怀疑反码校验。串口初始化这里就不重复贴代码了注意波特率要和电脑终端一致我用的是115200 8N1。调试阶段建议把原始间隔值也打印出来方便和逻辑分析仪波形互相对照。5. 调试实录解码失败最常见的五个原因5.1 波形对了但数据全错我遇到过一种情况逻辑分析仪抓到的间隔时间完全符合NEC标准但解析出来的地址码和命令码全是乱的。后来发现是位序处理的问题。NEC先发LSB但有些资料里的示例代码是按MSB方式组合数据位的代码里ir_frame (ir_frame 1) | bit;和ir_frame | (bit ir_bit_count);两种写法结果完全不一样。调试建议打印原始frame的十六进制值同时用逻辑分析仪把波形数据导出来按顺序手工比对前几位。另外还有一种可能遥控器用的是NEC扩展协议地址码是16位而不是8位帧结构变成引导码加16位地址加16位命令。这种协议同样有两个反码字段但地址部分不再是单一字节。遇到原厂遥控器解码结果和标注不一致时先想办法确认它用的是标准NEC还是扩展NEC。5.2 时好时坏中断优先级与临界区问题解码偶尔丢帧最常见的原因是中断被更高优先级的中断打断导致时间戳读取不及时。特别是调用HAL_Delay这种阻塞延时会直接卡住整个系统。我的经验是红外解码中断优先级至少要设为2以下并且解码中断回调里绝不做耗时操作只记录时间和位数真正的解析和打印放到主循环做。还有一种情况如果你用了多个外设中断比如串口中断、定时器中断IRQ优先级配置不当会导致IR解码中断被反复抢占。STM32的NVIC有抢占优先级和子优先级之分需要把这套机制理解清楚。简单做法是给红外解码一个较高的抢占优先级串口和普通定时器优先级放低。5.3 打印输出乱码串口打印乱码不一定是解码问题先检查STM32的时钟配置和串口初始化。HAL库工程里SystemClock_Config如果配错了PLL参数实际波特率和预设值会偏差很大。F103常见配置是HSE 8MHz外部晶振PLL倍频9倍得到72MHz系统时钟。如果你的板子实际晶振是12MHz代码却按8MHz配置串口必然乱码。这个坑我栽过一次换了一块板子后发现原来正常的代码打印全乱排查半天是晶振频率和工程配置不一致。所以调试第一步永远是确认时钟树和串口参数不要先怀疑红外接收头。5.4 距离近行距离远失效遥控器靠近接收头能解码离远一点就完全没反应。这种情况有几种可能一是接收头供电电压偏低VS1838B在3.3V供电时灵敏度会有所下降如果项目允许可以改用5V供电加电平匹配电路二是接收头前面有遮挡物比如塑料外壳对红外有衰减作用三是周围有强红外干扰源比如阳光直射、白炽灯、其他红外设备。还有一种原因是遥控器载波频率偏移。很多杂牌遥控器的振荡电阻精度不高实际载波可能在36kHz到40kHz之间接收头虽然有带宽但中心频率偏移太远时灵敏度会显著下降。这种属于遥控器自身问题用示波器看发射波形就能确认。5.5 逻辑分析仪的正确用法调试红外解码逻辑分析仪比示波器更方便。接线时把逻辑分析仪的通道接到接收头OUT引脚采样率不用太高2MHz以上就够用因为NEC协议的脉冲宽度最小只有562.5µs2MHz采样率每个脉冲能采到1000多个点足够还原波形。抓波形时把遥控器对着接收头按一下分析仪上会显示一串脉冲。用软件的量测功能量一下相邻下降沿的间隔13.5ms左右是引导码2.25ms是逻辑11.125ms是逻辑0。如果实测间隔和标准值偏差超过20%说明是遥控器时序偏差需要调整代码里的判定窗口。这里分享一个高效技巧把逻辑分析仪导出的间隔数据复制到Excel里用公式自动判断每个间隔属于0还是1再对照代码打印的raw值。两个结果一致说明解码逻辑没问题问题出在校验或位序两个结果不一致问题就在中断和时间戳部分。6. 进阶玩法重复码利用、多遥控识别与发射逆向6.1 重复码的长按处理上一章说了解码逻辑第1章提到过重复码的作用。真正常按按键时遥控器会隔一段时间发一次重复码而不会重新发完整的32位数据帧。所以要实现按住音量键连续加减需要在主循环里检测重复码标志并配合定时器计时if (ir_frame_ready) { IR_Frame_Decode(ir_frame); ir_frame_ready 0; last_cmd ir_cmd; key_hold_count 0; } if (ir_repeat_flag) { /* 连续收到重复码超过50ms执行长按逻辑 */ key_hold_count; if (key_hold_count 5) { ExecuteLongPress(last_cmd); key_hold_count 0; } ir_repeat_flag 0; }重复码在连续发送时仍然是大约110ms一次所以判断长按不需要用系统RTC直接用一个计数器累加即可。这里要注意重复码标志位要在主循环清掉否则同一帧重复码会被多次处理。用一个简单的标志位加一次清零操作比用复杂的队列更省心。6.2 16位扩展地址有些NEC变种协议支持16位地址码帧结构变成引导码加16位地址加16位命令每个字段依然是原码加反码的配对方式只是地址从1字节变成2字节。解析时把32位数据拆成两个16位字段分别校验uint16_t addr16 (frame 0xFFFF); uint16_t cmd16 (frame 16) 0xFFFF; uint16_t addr_inv16 (addr16 8) 0xFF;这种扩展格式在空调遥控器里比较常见因为空调需要识别更多型号和功能码。标准NEC能覆盖的范围不够时厂家就在地址字段上做扩展。如果你在项目里需要兼容多种空调遥控器建议先把捕获到的raw值打出来看分布规律再决定按标准NEC还是扩展NEC解析。6.3 从解码到发射逆向过程搞定了接收解码很多人自然会想做红外发射。NEC发射比接收更简单用定时器生成38kHz载波输出比较模式控制红外LED按协议时序发送引导码和数据位即可。STM32可以用定时器PWM输出38kHz然后用一个GPIO控制载波的通断实现调制。发射端的时序参数和接收端正好相反发射时高电平代表有载波低电平代表无载波。逻辑0是562.5µs载波加562.5µs空闲逻辑1是562.5µs载波加1.6875ms空闲。如果把发射和解码放在同一个项目里就做成了一个简易的红外学习遥控器先接收并保存按键码再通过发射红外信号控制其他设备。做完这个项目之后我的体会是NEC协议本身并不复杂难点在于把时序、极性、位序和中断这几件事在STM32上组合好。只要理解了接收头输出是反相的以及用下降沿间隔识别逻辑0和逻辑1这两个核心点剩下的就是状态机的体力活。另外还想提醒一句红外接收头模块的来源和质量参差不齐我遇到过同一批次模块里有几个输出波形毛刺特别多的情况调试排查了很久才发现是器件本身问题。所以试验阶段多买几个不同品牌的接收头做对比能帮你省下不少排错时间。这个项目做完之后建议你自己再动手做一个红外发射配合着玩一收一发全部打通对这类时序型协议的理解会完全不一样。