STM32红外遥控NEC协议解码实战:外部中断+定时器状态机

发布时间:2026/10/3 6:48:27
STM32红外遥控NEC协议解码实战:外部中断+定时器状态机
手头正好在调一个STM32的红外遥控解码项目顺手把NEC协议的整个解码流程、硬件电路、代码状态机和调试坑整理一遍。如果你正准备用STM32给自家遥控器破解按键码或者想把红外遥控接入自己的智能家居小项目这篇东西应该能帮你少走不少弯路。NEC协议是红外遥控里最常用的一种帧格式电视、机顶盒、风扇、空调遥控器里到处都是它的身影。STM32做红外解码说难不难说简单也不简单关键卡在对NEC时序的理解、边沿采集的方式、以及状态机的容错设计上。我先后用外部中断加定时器时间戳、输入捕获两种方案做过对比最后稳定运行的是EXTI边沿中断 32位定时器时间戳 主循环解析状态机这套组合。1. NEC红外协议到底在传什么1.1 一帧数据长什么样NEC协议属于脉冲宽度调制PWM编码载波频率是38kHz载波以约1/3占空比发射。接收头把38kHz载波解调成数字电平所以在单片机眼里NEC协议就是一串高低电平脉冲。一帧完整的NEC数据由引导码加32位数据组成32位数据依次是8位地址码、8位地址反码、8位命令码、8位命令反码。顺序是LSB first也就是最低位先发。比如地址码0x00发送时实际线路上先发的是8个0的位流命令码0x45则要先发1 0 1 0 0 0 1 0这个反过来的顺序。数据发送完以后一帧结束。如果遥控器按键持续按住NEC协议会每隔约110ms发送一个重复码告诉接收端这个键还在按着。这个设计很聪明既省电又能让接收端实现音量连续加减这类操作。1.2 位编码和时序参数这是解码的关键NEC协议里每一位bit都由一段载波脉冲 空闲间隔组成载波脉冲宽度固定为0.56ms实际规格书是562.5us但空闲间隔长度不同正是这个差异区分逻辑0和逻辑1。字段载波脉冲宽度空闲间隔宽度位周期逻辑00.56ms0.56ms约1.125ms逻辑10.56ms1.69ms约2.25ms引导码9ms4.5ms13.5ms重复码9ms2.25ms11.25ms后接0.56ms载波引导码是整个帧的起点9ms的长脉冲加4.5ms的空闲没有任何一个数据位会长成这样所以先识别引导码是解码的第一步。位周期的差异让测量空闲间隔宽度成了判0还是判1的核心手段。因为载波脉冲都是0.56ms唯一变化的是高电平空闲时间。所以我的解码思路是先确认一段0.56ms的载波脉冲再测量后续空闲间隔短间隔记0长间隔记1。这个思路后续贯穿整个状态机设计。2. 硬件这块儿接收头选型与最小电路2.1 常用的接收头怎么选市面上最常见的NEC接收头是VS1838B、TSOP38238、TSOP2238以及各种一体化红外接收头。它们内部集成了光敏二极管、放大电路、解调电路和输出整形电路直接输出TTL电平使用非常简单。选型时要注意三点载波频率38kHz、工作电压范围、输出极性。载波频率必须选38kHz和遥控器发射端一致。虽然有些接收头标称36.7kHz或38kHz都能收到但用38kHz最稳妥。工作电压方面VS1838B规格书标称2.7V到5.5V可以直接用3.3V给接收头供电这样输出高电平约3.3V和STM32电平完美匹配。如果手里只有5V供电的接收头输出高电平可能接近5V需要确认STM32的引脚是否为FT5V容忍引脚否则建议加电阻分压或者电平转换。输出极性基本都一致空闲时输出高电平收到38kHz载波时输出低电平也叫低电平有效。个别拆机件或特殊型号可能反相拿到手先看波形是最保险的。2.2 电路与布局的几点建议很多现成模块上已经集成了上拉电阻和滤波电容但如果是自己画板或者用面包板搭电路这几点值得注意接收头VCC与GND之间必须加滤波电容10uF电解电容并联0.1uF瓷片电容尽量靠近接收头引脚。红外接收头内部放大增益很高电源纹波会直接耦合到输出信号上不加电容容易误码。输出脚是否需要外部上拉取决于具体型号。多数一体化接收头内部有上拉输出在空闲时能稳定在高电平。但拆机件或部分定制型号可能是开漏输出这时需要外部加4.7k到10k上拉电阻到3.3V。接收头的光敏面有一个半球形凸起这个凸起应该朝向遥控器方向。安装时如果被外壳挡住或者离透光窗太远接收距离会急剧下降。我实测过离透光窗口3mm以内且轴线对准接收距离最远。因为接收头解调后的信号是数字电平直接接到STM32的GPIO即可。注意走线尽量短不要和PWM电机线、继电器线并行走太长距离。3. STM32解码方案我为什么选外部中断加定时器时间戳3.1 几种常见方案对比STM32实现红外解码有几种主流方案我实际都试过各有利弊方案原理优点缺点EXTI外部中断加定时器时间戳GPIO边沿触发中断中断中读取自由运行定时器的计数值作为时间戳主循环解析思路直观易调试适用于各种变种协议中断服务函数要精简边沿间隔需自己处理定时器输入捕获用定时器通道捕获上升沿/下降沿硬件记录捕获值CPU占用低边沿时间由硬件打点配置较复杂捕获溢出和边沿切换容易出错变通性差EXTI加延时轮询下降沿触发后循环延时采样电平代码最简单中断里延时浪费CPU容易丢码不推荐正式使用DMA加定时器多通道捕获多个通道DMA搬运边沿时间适合多路红外或高精度场合资源占用多新手很难一次调通我的结论是EXTI加32位定时器时间戳最合适。NEC一帧最长也就80ms左右边沿频率不高每次中断只做读CNT、记录时间戳两件事完全来得及。而且这套方法可以扩展到Sony SIRC、RC5等其他协议只要把解析逻辑换成对应协议就行。核心原理是让一个定时器自由运行计数器每1us加1。每次GPIO边沿到来中断服务函数里读一次CNT前后两个时间戳相减就是边沿间隔。TIM2在F103上是32位定时器计满需要约4295秒完全不用考虑溢出回绕问题直接用uint32_t相减即可。3.2 外设初始化EXTI、GPIO和定时器我以STM32F103标准库为例。接收头输出接PA0TIM2做1us自由运行计数器。定时器初始化void tim2_init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseInitTypeDef tim; TIM_TimeBaseStructInit(tim); tim.TIM_Prescaler 72 - 1; // 72MHz / 72 1MHz即1us计数一次 tim.TIM_CounterMode TIM_CounterMode_Up; tim.TIM_Period 0xFFFFFFFF; // 32位自由运行不回绕 tim.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, tim); TIM_Cmd(TIM2, ENABLE); }TIM2在F103上是32位定时器这是选它的最大理由。计数器以1MHz递增每1us加1读一次CNT就拿到了微秒级时间戳。两次CNT差值直接强制转uint32_t即使从0xFFFFFFFE跳到0x00000002差值依然正确这是无符号减法自动回绕的特性。GPIO和EXTI初始化void ir_gpio_init(void) { GPIO_InitTypeDef gpio; EXTI_InitTypeDef exti; NVIC_InitTypeDef nvic; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); gpio.GPIO_Pin GPIO_Pin_0; gpio.GPIO_Mode GPIO_Mode_IPU; // 输入上拉空闲稳定在高电平 gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); exti.EXTI_Line EXTI_Line0; exti.EXTI_Mode EXTI_Mode_Interrupt; exti.EXTI_Trigger EXTI_Trigger_Rising_Falling; // 双边沿触发 exti.EXTI_LineCmd ENABLE; EXTI_Init(exti); nvic.NVIC_IRQChannel EXTI0_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); }EXTI配置成上升沿和下降沿都触发这样每个电平段结束时都会进入一次中断两个时间戳之间的差值就是刚结束的这段电平的持续时间。中断优先级这里给到抢占优先级1如果项目里还有UART、定时器等中断建议EXTI的抢占优先级设得比它们高防止高频中断打断边沿采样。我的项目里串口打印频率很高一开始优先级设低了红外解码就间歇性失灵后来把EXTI优先级提到最高才稳定。4. 解码状态机的设计与代码实现4.1 边沿时间戳怎么解析成位流每次EXTI中断记录两个信息一个时间戳和一个电平标志。时间戳用TIM2-CNT电平标志则在中断里读取当前GPIO电平。当前电平为高说明刚结束的是低电平段载波脉冲当前电平为低说明刚结束的是高电平段空闲间隔。中断服务函数#define IR_EDGE_BUF_SIZE 64 volatile uint32_t ir_edge_time[IR_EDGE_BUF_SIZE]; volatile uint8_t ir_edge_level[IR_EDGE_BUF_SIZE]; volatile uint8_t ir_edge_head 0; volatile uint8_t ir_edge_tail 0; void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { uint8_t next (uint8_t)((ir_edge_head 1) % IR_EDGE_BUF_SIZE); if (next ! ir_edge_tail) { ir_edge_time[ir_edge_head] TIM2-CNT; ir_edge_level[ir_edge_head] GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); ir_edge_head next; } EXTI_ClearITPendingBit(EXTI_Line0); } }用环形缓冲区暂存边沿信息是防止主循环解析来不及处理导致边沿丢失的关键。中断只写不读主循环只读不写单生产者单消费者模型不需要额外加锁。缓冲区设为64NEC一帧最多有2乘32加若干引导边沿约70个边沿64个会满吗实测不会因为NEC位0周期1.125ms、位1周期2.25ms主循环解析速度远快于边沿产生速度缓冲区很少有超过20条的时候。但如果主循环里做了阻塞延时缓冲区就可能填满所以主循环里不要加长时间延时。主循环里成对消耗边沿typedef struct { uint32_t low_us; uint32_t high_us; } ir_pair_t; // 取两个相邻边沿返回0成功返回1缓冲区不足 static uint8_t ir_pair_pop(ir_pair_t *pair) { if (ir_edge_head ir_edge_tail) return 1; uint8_t tail ir_edge_tail; uint32_t t0 ir_edge_time[tail]; uint8_t lv0 ir_edge_level[tail]; tail (uint8_t)((tail 1) % IR_EDGE_BUF_SIZE); if (ir_edge_head tail) return 1; uint32_t t1 ir_edge_time[tail]; uint8_t lv1 ir_edge_level[tail]; ir_edge_tail (uint8_t)((tail 1) % IR_EDGE_BUF_SIZE); if (lv0 Bit_RESET lv1 Bit_SET) { // 当前由低变高刚结束的是低电平段 pair-low_us (uint32_t)(t1 - t0); pair-high_us 0; } else if (lv0 Bit_SET lv1 Bit_RESET) { // 当前由高变低刚结束的是高电平段 pair-low_us 0; pair-high_us (uint32_t)(t1 - t0); } else { return 1; } return 0; }这里我把一对边沿拆成低电平持续宽度和高电平持续宽度。引导码的识别条件是低电平约9ms后面紧跟高电平约4.5ms。数据位的识别条件是低电平约0.56ms后面紧跟的高电平约0.56ms判0、约1.69ms判1。这样成对解析逻辑非常清晰。4.2 帧校验、重复码与超时处理有了成对的低高电平数据接下来就是状态机。我定义了解析状态IR_STATE_IDLE、IR_STATE_LEAD、IR_STATE_BIT_LOW、IR_STATE_BIT_HIGH。解析函数每次处理一个pair#define IR_LOW_LEAD_MIN 8000 #define IR_LOW_LEAD_MAX 10000 #define IR_HIGH_LEAD_MIN 4000 #define IR_HIGH_LEAD_MAX 5000 #define IR_LOW_BIT_MIN 300 #define IR_LOW_BIT_MAX 800 #define IR_HIGH_BIT0_MIN 400 #define IR_HIGH_BIT0_MAX 950 #define IR_HIGH_BIT1_MIN 1300 #define IR_HIGH_BIT1_MAX 2000 #define IR_REPEAT_HIGH_MIN 1800 #define IR_REPEAT_HIGH_MAX 2800 volatile uint8_t ir_frame_bytes[4]; volatile uint8_t ir_bit_cnt; volatile uint8_t ir_decoder_state; volatile uint8_t ir_key_code; volatile uint8_t ir_addr_code; volatile uint8_t ir_new_frame_flag; volatile uint8_t ir_repeat_flag; void ir_reset(void) { ir_decoder_state 0; ir_bit_cnt 0; ir_frame_bytes[0] 0; ir_frame_bytes[1] 0; ir_frame_bytes[2] 0; ir_frame_bytes[3] 0; } void ir_parse_pair(ir_pair_t *pair) { if (pair-low_us 0) { // 刚结束的是高电平段 if (ir_decoder_state IR_STATE_LEAD) { if (pair-high_us IR_HIGH_LEAD_MIN pair-high_us IR_HIGH_LEAD_MAX) { ir_decoder_state IR_STATE_BIT_LOW; ir_bit_cnt 0; } else if (pair-high_us IR_REPEAT_HIGH_MIN pair-high_us IR_REPEAT_HIGH_MAX) { ir_repeat_flag 1; ir_reset(); } else { ir_reset(); } } else if (ir_decoder_state IR_STATE_BIT_HIGH) { if (pair-high_us IR_HIGH_BIT0_MIN pair-high_us IR_HIGH_BIT0_MAX) { ir_frame_bytes[ir_bit_cnt / 8] | (uint8_t)(0 (ir_bit_cnt % 8)); ir_bit_cnt; } else if (pair-high_us IR_HIGH_BIT1_MIN pair-high_us IR_HIGH_BIT1_MAX) { ir_frame_bytes[ir_bit_cnt / 8] | (uint8_t)(1 (ir_bit_cnt % 8)); ir_bit_cnt; } else { ir_reset(); return; } if (ir_bit_cnt 32) { if (ir_frame_bytes[1] (uint8_t)(~ir_frame_bytes[0]) ir_frame_bytes[3] (uint8_t)(~ir_frame_bytes[2])) { ir_addr_code ir_frame_bytes[0]; ir_key_code ir_frame_bytes[2]; ir_new_frame_flag 1; } ir_reset(); } else { ir_decoder_state IR_STATE_BIT_LOW; } } else { ir_reset(); } } else { // 刚结束的是低电平段 if (ir_decoder_state IR_STATE_IDLE) { if (pair-low_us IR_LOW_LEAD_MIN pair-low_us IR_LOW_LEAD_MAX) { ir_decoder_state IR_STATE_LEAD; } } else if (ir_decoder_state IR_STATE_BIT_LOW) { if (pair-low_us IR_LOW_BIT_MIN pair-low_us IR_LOW_BIT_MAX) { ir_decoder_state IR_STATE_BIT_HIGH; } else { ir_reset(); } } else { ir_reset(); } } }这段代码的核心逻辑不复杂但要特别留意ir_frame_bytes[ir_bit_cnt / 8] | bit (ir_bit_cnt % 8)这行。因为NEC是LSB first收到第一个bit就是字节的最低位bit_cnt % 8正好把bit放到对应位最后ir_frame_bytes[0]就是正常打印的地址码字节不用再做位反转调试时非常直观。帧校验用反码校验这是NEC协议自带的纠错机制。地址字节和地址反码按位取反后必须相等命令字节和命令反码同理。如果环境干扰导致某一位翻转反码校验几乎一定能识别出来。校验不过就直接丢弃绝不能让错误按键码进入后续处理逻辑。重复码的识别需要单拎出来说。重复码的波形是9ms低电平加2.25ms高电平加0.56ms低电平。状态机在IR_STATE_LEAD状态收到9ms低电平后紧接着读到2.25ms高电平就置ir_repeat_flag。我之所以在2.25ms高电平结束后就返回IDLE是因为重复码最后那0.56ms低电平对按键处理没有意义早一点复位状态机可以更早应对下一次按键。超时处理是另一个容易踩的坑。如果遥控器按键按到一半松开或者红外信号被遮挡状态机可能卡在某个中间状态。解决办法是在主循环里定期检查:TIM2-CNT和最后一次边沿时间戳的差值超过50ms没有任何边沿就把状态机复位。NEC协议帧间间隔通常大于40ms50ms这个阈值足够区分帧结束和真的没信号。主循环调用示例int main(void) { // 初始化时钟、串口、定时器、GPIO/EXTI tim2_init(); ir_gpio_init(); uart_init(115200); while (1) { // 超时复位 if ((uint32_t)(TIM2-CNT - ir_last_edge_tick) 50000) { if (ir_decoder_state ! IR_STATE_IDLE) ir_reset(); } ir_pair_t pair; if (ir_pair_pop(pair) 0) { ir_parse_pair(pair); } if (ir_new_frame_flag) { ir_new_frame_flag 0; printf(addr0x%02X key0x%02X\r\n, ir_addr_code, ir_key_code); } if (ir_repeat_flag) { ir_repeat_flag 0; printf(repeat\r\n); } } }ir_last_edge_tick需要在中断里持续更新也就是每次EXTI中断都记录一下。超时检查放在主循环里不影响中断实时性。5. 调试中遇到的坑与排查思路5.1 现象按键偶尔没反应这是最典型的脏波形问题。我遇到的情况是遥控器电池电量偏低实际发出的引导码宽度从9ms掉到8ms左右数据位高电平宽度也从1.69ms掉到1.5ms附近。如果容差范围写得太死比如高电平1.69ms只允许正负0.1ms误差电池一低就解码失败。解决办法是放宽容差但要保证0和1不重叠。逻辑0高电平0.56ms逻辑1高电平1.69ms两者差距很大中间有超过1ms的判空区。我把0的判定范围设为400~950us1的判定范围设为1300~2000us引导码9ms设为8000~10000us4.5ms设为4000~5000us。这样即使电池电压波动导致时序偏移依然能稳定识别。实测旧电池遥控器从2.2V到3.3V都能正常解码。5.2 现象收到一堆乱码乱码通常有两类原因。第一类是硬件电源问题接收头电源纹波大导致信号边沿抖动解码出的位流错乱。解决方法是靠近接收头加滤波电容或者把接收头供电改成独立的3.3V LDO输出不要和继电器、电机共用电源。第二类是中断优先级和缓冲区问题。如果有高频中断打断EXTI边沿时间戳会延迟读取两个边沿的时间差会偏大导致状态机误判。我调试时用串口以115200波特率打印调试信息串口中断频繁导致红外解码随机出错。把EXTI抢占优先级提到最高、串口降到2之后问题立刻消失。如果缓冲区太小边沿被丢弃同样会出现无法进入引导码状态的现象。可以临时把缓冲区改成256并把ir_pair_pop里的打印打开观察是否经常出现缓冲区满的情况。5.3 现象长按失效和重复码按键长按时第一帧解析正常但后续重复码如果不识别按键效果就变成按一次执行一次而不是连续执行。这个问题几乎都是重复码判别条件不对导致的。NEC重复码的高电平间隔是2.25ms和数据位1的高电平1.69ms非常接近容易误判。我在状态机里单独处理重复码在IR_STATE_LEAD状态读到的2.25ms高电平只置repeat标志不进数据解析流程。这样长按音量键时主循环能收到一串repeat标志就可以实现音量连续递增。还有一个小经验如果第一帧和重复码之间的时间间隔过短状态机在解析完第一帧后还没完全复位就收到重复码的引导脉冲导致重复码被当成新帧的引导码。解决方法是解析完一帧后立即复位状态机并且帧完成标志置位后主循环要尽快处理不要在循环里做长延时。5.4 现象只有某些按键能解码NEC帧里如果某一位是1对应的高电平间隔是1.69ms如果某一位是0对应高电平间隔是0.56ms。如果代码里把1和0的判定范围搞反了就会出现固定按键失灵、其他按键正常的情况。可以先找一个按键比如播放/暂停键用它发一个已知命令码然后对着串口打印出来的字节检查是否和遥控器说明书一致。我实测一个电视遥控器播放/暂停键的命令码是0x45二进制是01000101。如果解码出来是0xA2说明位序反了。0xA2的二进制是10100010正好是0x45逐位取反后的逆序。遇到这种情况检查一下位填充逻辑是用的| bit (bit_cnt % 8)还是| bit (7 - bit_cnt % 8)以及填充后字节是否需要整体反转。6. 从解码到应用红外遥控还能怎么玩6.1 红外发射与学习型遥控器解码只是第一步把NEC帧格式搞清楚以后做红外发射就是顺理成章的事。发射电路很简单一个38kHz有源晶振或者用STM32定时器产生38kHz PWM再通过一个NPN三极管驱动红外发射管。调制方式就是发射NEC帧时让PWM输出空闲时关闭PWM。更实用的玩法是做学习型遥控器。用这套解码程序把遥控器按键码读进Flash然后按需发射原格式红外信号替代原装遥控器。很多智能家居中控就是这么实现的。需要注意的是发射管限流电阻不能省一般串联10到22欧姆红外发射管峰值电流可以到100mA甚至更高但平均电流要控制在规格书范围内。6.2 接入串口、Wi-Fi与智能家居解码得到按键码后最简单的应用就是通过串口把按键码发给其他设备。我做过一个小项目STM32解码电视遥控器按键然后通过串口把对应操作转发给ESP8266ESP8266再通过MQTT上报给巴法云或者其他IoT平台手机端就能实时看到哪个按键被按下了。如果再配合一个继电器模块就能实现用原装遥控器控制非红外设备的效果。如果项目上了FreeRTOS可以把红外解析做成一个独立任务用信号量或者消息队列把按键事件传给其他任务。解码中断里只往缓冲区写边沿数据解析任务在循环里调ir_parse_pair按键响应任务阻塞等待队列消息。这样整个系统解耦很干净后续加蓝牙、Wi-Fi、屏幕都不会影响解码实时性。NEC解码这套思路还可以平移到其他红外协议。Sony的SIRC协议用的是脉宽编码RC5/RC6协议用的是双相编码核心的边沿时间戳加状态机解析框架完全通用只需要改协议参数和状态转换逻辑。这也是我推荐大家不要用现成解码库、而是自己写一遍的原因搞懂协议本身的时序特征比单纯会调库有价值得多。最后分享一个调试小技巧写解析代码之前先用串口把所有边沿间隔打印出来格式随意比如L:9000 H:4500 L:560 H:560...对着Logic分析仪或者规格书手工比对一遍确认波形和时间完全对上再动手写状态机。这一步能省掉至少半天的盲目调试时间。我现在每次拿到新遥控器都是先打印边沿数据再写解析逻辑基本一次就能调通。