STM32红外接收解码实战:基于定时器输入捕获的NEC协议实现 📅 发布时间:2026/9/14 15:10:52 👁 浏览次数: 简介这是一套面向STM32初学者的红外接收Keil工程资料包。工程可在MDK-ARM开发环境中直接打开主要用途是从红外遥控器发出的信号中解析出按键指令进而控制小灯、电机等外围设备。压缩包共十九个文件包括五个头文件、四个源文件、两个文本说明、两个分散加载描述、一个工程文件另有初始化配置、编译选项和启动代码等辅助文件整个包仅二十八KB文件职责划分清晰适合按模块阅读和移植。目前已有三百一十人学习。工程覆盖红外接收头硬件接入、输入输出引脚初始化、外部中断或定时器捕获、常见遥控编码协议解码等关键环节支持NEC、RC5、SIRC等编码方式并附有简明调试说明。头文件负责数据结构和寄存器声明源文件负责具体信号处理与解码逻辑文档则给出使用指引。通过这套资料可以快速搭建一个可运行的红外接收示例验证遥控器编码格式并在修改引脚或定时器参数后适配其他硬件平台同时加深对单片机外设配置、中断处理与协议解析机制的理解。1. 一个被反复提起的红外接收工程为什么值得自己写一遍一个遥控器按键按下去不到 20 毫秒红外接收头就把电信号变成一串低电平脉冲送进 STM32 引脚。很多人做这个题目是为了交差以为难点在 NEC 协议可真正动手以后发现大部分时间都耗在 Keil 里找不到设备、定时器输入捕获中断不触发、解出来的数据一直跳字。下面这套工程把 STM32 红外接收从头写一遍用 Keil STM32 的定时器输入捕获识别 NEC 遥控器按键最小系统只占一个定时器通道适合刚接触单片机的同学也适合想复用红外遥控信号的嵌入式开发者。文章不会只贴代码还会把输入捕获的频率选择、状态机边界、反码校验和常见到逻辑分析仪才看得见的坑一起讲清楚。2. STM32红外接收的理论基础从NEC协议到定时器输入捕获红外遥控的基本链路是发射管把 38kHz 载波和按键编码叠加成红外光接收头把这个信号放大、检波、整形后从 OUT 脚输出高低电平。也就是说到了 STM32 引脚这一级已经没有“红外载波”这个概念了只剩下宽度不同的方波。STM32 要做的事只有一件测出每一次电平跳变的时间间隔再按 NEC 协议把间隔翻译成 0 和 1。这一步是后面所有代码的根结构没理解透在 Keil 里调多久都只会看到一堆随机数。2.1 NEC协议如何把按键编码成脉冲NEC 协议是消费级红外遥控里最常见的一种帧长固定 32 位先发低字节。一个完整的按键帧由引导码、8 位地址码、8 位地址反码、8 位命令码和 8 位命令反码组成。发射端用载波脉冲的有无表示逻辑电平接收头解调后输出低电平有效的波形接收头空载时 OUT 脚是高电平收到信号时出现低电平脉冲。NEC 对时间差的定义非常宽松但时间基准仍然是整个解码的核心。信号段接收头输出电平典型时长含义引导码低电平 9ms 高电平 4.5ms13.5ms帧头部逻辑 0低电平 560us 高电平 560us1.12ms代表 0逻辑 1低电平 560us 高电平 1.69ms2.25ms代表 1重复码低电平 9ms 高电平 2.25ms11.25ms长按信号这里的“低电平 9ms 高电平 4.5ms”是从接收头 OUT 脚观察到的形态。用定时器输入捕获连续记录两次下降沿的间隔引导码是 13.5ms数据 0 是 1.12ms数据 1 是 2.25ms重复码是 11.25ms。这样不用关心上升沿电平只数下降沿就能完整还原整帧数据状态机要跳转的判断条件也只需要上面四个区间。2.2 为什么选择定时器输入捕获而不是外部中断外部中断加 SysTick 计数也能完成同样的测量但这个方案在 Keil 调试时很容易出问题NVIC 的优先级处理不对串口中断或定时器更新中断就会打断测量中断之间的延迟直接变成了脉冲宽度的误差。NEC 最窄的低电平是 560us主频 72MHz 时一次中断延迟原本只要几微秒看起来影响很小但一旦使用 HAL_Delay 等长阻塞函数或者调试器在数据位中间停在断点上外部中断里读到的 SysTick 值就会偏差解码也就跟着错。定时器输入捕获可以由硬件在跳变发生时把 CNT 值锁存到 CCR 寄存器中断晚几十微秒响应读到的仍然是准确的边沿时间。所以一个可靠一点的方案会把脉冲测量交给定时器。CubeMX 中把定时器通道配置成 Input Capture direct 模式Prescaler 设置为 71在 72MHz 的定时器时钟下得到 1MHz 计数频率CNT 每个计数等于 1us溢出周期 65535us也就是约 65.5ms。一帧 NEC 数据包含引导码和 32 个数据位大约 30ms不会溢出。有人问为什么不用 PWM 输入捕获模式因为 PWM 输入模式会同时锁存两组边沿到两个 CCR 寄存器但对单个状态机来说维护两个寄存器反而增加理解成本。单通道输入捕获配合回调里翻转捕获极性可以逐段测量低电平和高电平这与后续解码的思路一致。下面这段代码是计算两次捕获间隔的标准写法输入捕获中断每次被调用都会读到当前 CNT 值然后算出和上一次的差值/* 定时器计数频率1MHzpulse_width的单位是us */ static uint32_t last_cnt 0; void IR_TimCallback(uint32_t now_cnt) { uint32_t period 65536; /* 16位定时器溢出周期 */ uint32_t gap; if (now_cnt last_cnt) gap now_cnt - last_cnt; else gap now_cnt period - last_cnt; last_cnt now_cnt; IR_ProcessGap(gap); /* 把间隔交给后面的状态机 */ }逻辑说明当读取到的 CCR 值小于上一次时说明计数器发生过回绕所以要加上一个溢出周期再相减。实际 NEC 解码时 gap 在 1ms 到 14ms 之间65ms 内不会回绕但后续如果要改接更长时间的低功耗遥控协议回绕处理就必须存在。参数建议是预分频不要小于 71计数分辨率 1us 对 NEC 足够预分频如果设成 0分辨率 13.8ns16 位计数器 11us 就会溢出会引发频繁的溢出中断预分频如果太大引导码在计数器里只占很小的计数判断区间变宽误触发率也会上升。3. 在Keil里把红外接收工程跑起来CubeMX配置和最小工程验证搭建环境的步骤并不复杂但红外接收最明显的坑在于“看起来配置对了实际没有捕获”。这一章直接给出一个能用串口和 LED 验证的最小工程目标是按下遥控器任意键PB1 上的 LED 能跟着红外信号闪烁说明定时器输入捕获链路已经打通。3.1 在CubeMX里选好引脚和时钟树以 STM32F103C8T6 为例接收头 OUT 接 PA6PA6 复用为 TIM3_CH1。时钟树里 HSI 或 HSE 都可以最终让 APB2 为 72MHz给 TIM3 提供 72MHz 定时器时钟。PA6 在 GPIO 配置中设为输入模式Pull-up 打开如果 Pull 保持 NOPULL有的红外接收头输出低电平时没关系但空闲高电平时 PA6 只有弱上拉示波器上能看到毛刺输入捕获会被误触发。在 CubeMX 中找 TIM3Channel1 选择“Input Capture direct mode”。生成的MX_TIM3_Init里默认把IC1Polarity设为上升沿但 NEC 信号是负逻辑需要改成下降沿捕获。不要在 CubeMX 里设完极性又在 Keil 里用寄存器改两边会互相覆盖。更稳妥的做法是直接在 CubeMX 的 TIM3 Paramter Settings 里把 Input Capture Channel 1 的 Polarity 选成 Falling Edge这样生成代码里的htim3.Init.IC1Polarity已经是下降沿Keil 端不再手改 CCER。3.2 定时器参数表与中断优先级TIM3 参数设置值说明Prescaler7172MHz / (711) 1MHz每计数 1usCounter Period6553516 位最大周期满足帧间隔auto-reload preloadEnableARR 影子寄存器生效避免帧中间改 ARRInput Filter0接收头自带滤波定时器无需额外数字滤波IC1PolarityFalling Edge用下降沿作为每次测量的起始点IC1SelectionDirect TI1直接选择 TI1 输入Input Filter建议保持 0除非环境有较强的红外干扰。某些接收头在大电流工作下输出波形有毛刺这时可以开启 1/4 分频加 N2 的滤波但这会引入几百纳秒延迟对 NEC 的 560us 影响很小加上也无妨。NVIC 优先级可以设为PreemptionPriority2, SubPriority0低于串口发送高于主循环里的其他定时器更新避免捕获时机被长任务打断。3.3 用HAL库启动输入捕获并验证中断CubeMX 生成的代码不会自动启动捕获需要在main里调用HAL_TIM_IC_Start_IT并在回调里翻灯。不要自己写一个TIM3_IRQHandler去覆盖 CubeMX 生成的版本HAL_TIM_IRQHandler会被自动调用然后再转给HAL_TIM_IC_CaptureCallback。正确写法是volatile uint32_t g_edge_count 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { g_edge_count; HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_1); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_UART4_Init(); /* 如果后续要做调试打印提前初始化 */ MX_TIM3_Init(); HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1); while (1) { /* 简单验证g_edge_count 每秒增量在几百以上才算正常 */ HAL_Delay(1000); } }逻辑说明红外接收头空闲时 PA6 是高电平按下遥控器产生下降沿捕获中断每来一个下降沿就翻转一次 PB1。若遥控器离接收头十几厘米内按键LED 应快速闪烁。完全不闪时按四点排查一是 PA6 有没有内部上拉二是接收头供电脚附近缺电容三是遥控器电池电压低四是 PB1 在 CubeMX 里没配置成输出且起始电平为高。如果接收头是 5V 供电的型号OUT 脚高电平也可能接近 5V不要把引脚直接接到 PA6串一个 10k 电阻再进 STM32避免长时间运行烧坏 IO 口。现在多数贴片接收头支持 3.3V 供电先用 3.3V 能省掉电平转换的问题。4. 红外接收STM32的NEC解码实现状态机、超时判断和按键映射输入捕获已经把 NEC 的间隔测量出来但单独的一串间隔还说明不了任何问题。需要用一个状态机把这些间隔按协议拼成帧。这里的状态机只依赖下降沿之间的“整段电平时长”所以前面不用关心单独的上升沿逻辑也紧凑。4.1 用下降沿间隔做状态机根据 2.1 的时序表引导码的下降沿间隔为 13.5ms数据 0 为 1.12ms数据 1 为 2.25ms重复码为 11.25ms。状态机初始在IR_IDLE看到一个接近 13.5ms 的间隔后进入IR_BITS状态然后连续读 32 个数据位。每个间隔如果在 0.9ms 到 1.3ms 范围内判为逻辑 0在 2.0ms 到 2.6ms 范围内判为逻辑 1。如果中间出现不匹配的间隔就丢弃当前帧重新等引导码。/* 检测区间范围单位 us */ #define NEC_GUARD_MIN 12500 #define NEC_GUARD_MAX 14500 #define NEC_BIT0_MIN 900 #define NEC_BIT0_MAX 1300 #define NEC_BIT1_MIN 2000 #define NEC_BIT1_MAX 2600 #define NEC_REPEAT_MIN 10000 #define NEC_REPEAT_MAX 12000 typedef enum { IR_IDLE 0, IR_BITS } IR_State; typedef struct { uint8_t addr; uint8_t addr_inv; uint8_t cmd; uint8_t cmd_inv; } NEC_Packet; static IR_State ir_state IR_IDLE; static uint32_t ir_frame_bits; static uint32_t ir_bit_count; static volatile uint8_t ir_frame_ready; static volatile uint8_t ir_repeat_flag; static NEC_Packet ir_last_pkt; /* NEC 先发 LSB收完后把字节内的位反转 */ static uint8_t IR_ReverseBits(uint8_t v) { v (v 0xF0) 4 | (v 0x0F) 4; v (v 0xCC) 2 | (v 0x33) 2; v (v 0xAA) 1 | (v 0x55) 1; return v; } void IR_ProcessGap(uint32_t gap_us) { switch (ir_state) { case IR_IDLE: if (gap_us NEC_GUARD_MIN gap_us NEC_GUARD_MAX) { ir_state IR_BITS; ir_frame_bits 0; ir_bit_count 0; } else if (gap_us NEC_REPEAT_MIN gap_us NEC_REPEAT_MAX) { /* 长按重复帧是否置位由上层判断上一帧是否成功 */ ir_repeat_flag 1; } break; case IR_BITS: if (gap_us NEC_BIT0_MIN gap_us NEC_BIT0_MAX) { ir_frame_bits (ir_frame_bits 1); /* 收到 0 */ ir_bit_count; } else if (gap_us NEC_BIT1_MIN gap_us NEC_BIT1_MAX) { ir_frame_bits (ir_frame_bits 1) | 1; /* 收到 1 */ ir_bit_count; } else { /* 间隔不在合理范围丢弃当前帧 */ ir_state IR_IDLE; ir_bit_count 0; } if (ir_bit_count 32) { /* 先把 32 位按发送顺序拆成 4 个字节再反转每个字节的位序 */ ir_last_pkt.addr IR_ReverseBits((uint8_t)(ir_frame_bits 24)); ir_last_pkt.addr_inv IR_ReverseBits((uint8_t)(ir_frame_bits 16)); ir_last_pkt.cmd IR_ReverseBits((uint8_t)(ir_frame_bits 8)); ir_last_pkt.cmd_inv IR_ReverseBits((uint8_t)(ir_frame_bits)); ir_frame_ready 1; ir_state IR_IDLE; } break; default: ir_state IR_IDLE; break; } }逻辑说明IR_ReverseBits把字节内的位序反转因为 NEC 先发最低位。如果省去这一步地址和命令码会变成逐位取反后的顺序按 0x45 这种键值表去查按键对应关系全是乱的。状态机里没有对重复码做复杂处理只是在IR_IDLE中识别 11.25ms 间隔并置位ir_repeat_flag实际按键长按时上层可以用这个标志去响应重复事件。ir_frame_ready和ir_repeat_flag都加了volatile是因为它们在中断回调里被赋值在主循环里被查询。4.2 按键映射与反码校验NEC 的地址反码和命令反码用于错误检查。在一个抗干扰要求较高的环境里可以把解码结果带入下面这个函数做校验uint8_t IR_CheckFrame(const NEC_Packet *pkt) { uint8_t ok 1; if ((uint8_t)(~pkt-addr) ! pkt-addr_inv) ok 0; if ((uint8_t)(~pkt-cmd) ! pkt-cmd_inv) ok 0; return ok; }校验通过后把ir_last_pkt.cmd作为按键索引查表映射到业务动作。下面是一份常见 NEC 遥控器的键值对应关系实际使用前必须先用串口把 CMD 打印出来扫一遍不同遥控器的键值差别很大CMD 字节遥控器按键业务动作0x45电源切换继电器0x46菜单切换工作模式0x47上步进电机加一档0x48下步进电机减一档查表用switch-case把按键码放在单个 case 里比写一大串if (cmd 0x45)可读性好。主循环里可以这样查询if (ir_frame_ready) { ir_frame_ready 0; if (IR_CheckFrame(ir_last_pkt)) { switch (ir_last_pkt.cmd) { case 0x45: relay_toggle(); break; case 0x46: mode_switch(); break; default: break; } } }一个高频坑是很多初学者在中断回调里直接做查表和业务动作比如打印或者控制继电器。NEC 解码的间隔是毫秒级中断里加HAL_UART_Transmit这类阻塞调用会让后续捕获延迟导致下一帧判断错乱。所以中断里只置ir_frame_ready查表和动作放到主循环执行。5. Keil 工程排错定时器溢出、串口键值为空和波形验证技巧5.1 从现象反推配置问题串口键值一直为空先到 Keil 里看定时器计数器实际有没有在跑。把htim3.Instance-CNT加入 Watch 窗口按下遥控器时 CNT 应不停跳变。如果 CNT 恒为零查 TIM3 的时钟是否开启或者 CubeMX 没有调用MX_TIM3_Init。另一个常见现象是键值跳变但反码校验总失败这多半是把捕获极性设成了上升沿导致间隔刚好差了一个低电平宽度。解决办法是把捕获极性统一为下降沿再把状态机里的判断区间放宽到 2.6ms 左右。5.2 用串口和逻辑分析仪完成闭环验证调试阶段通过串口把每个gap打印出来比只看最终键值要直观得多。但中断里不要直接调printf建议把捕获到的间隔放进一个数组在主循环里批量打印。接收完一帧后将地址、命令、反码按十六进制打印void IR_PrintPacket(const NEC_Packet *pkt) { char buf[24]; sprintf(buf, %02X %02X %02X\r\n, pkt-addr, pkt-cmd, pkt-cmd_inv); HAL_UART_Transmit(huart1, (uint8_t *)buf, strlen(buf), 100); }如果串口一行显示45 47 BA0x45 是地址0x47 是命令0xBA 是命令反码。地址和命令反码能对上说明解码基本正确。如果全显示FF FF FF先去测 PA6 空闲电平空闲高电平才对空闲低说明接收头接反或 GPIO 没开上拉。逻辑分析仪如果只有一个通道就把探头放在 PA6抓一段按下按键时的波形。正常一个引导码是 9ms 低电平加 4.5ms 高电平往后是 32 个 1.12ms 或 2.25ms 的脉冲。看到引导码低电平只有 5ms是接收头供电不足补一个 100uF 电解电容到电源脚。看到引导码正常但数据位全是统一宽度则是寄存器配置里输入滤波或捕获通道选错了等于在测量一个固定频率的信号。5.3 一个可直接复用的调参技巧输入捕获的范围判断不要写死给 0 和 1 都留裕量0 位判断用 1ms 到 1.4ms1 位判断用 1.8ms 到 2.8ms。这个裕量超过 NEC 协议规定的正负 20%足够覆盖多数廉价遥控器的时钟偏差。另一个技巧是在状态机里单独统计异常间隔次数超过 10 次就强制回到 IR_IDLE防止半帧数据把后续按键认成乱码。这里提到的解码过程不依赖 38kHz 载频只要接收头能解调出方波这套输入捕获加状态机的方案就能继续用。本文还有配套的精品资源点击获取