1. 项目缘起为什么红外遥控接收模块是嵌入式开发的“必修课”如果你玩过STM32或者任何一款单片机大概率都接触过红外遥控。它可能是你第一个“无线”通信项目也可能是你从点灯、按键之后迈向更复杂外设交互的敲门砖。红外遥控接收模块那个黑色或银色、三只脚的小东西成本不到一块钱但背后却串联了GPIO中断、定时器、协议解码、状态机等一系列嵌入式核心概念。很多人觉得它简单照着网上的代码“抄”一遍就能跑起来但真正要写一个稳定、可靠、能应对各种杂牌遥控器的解码程序里面的坑可一点都不少。我最早接触红外遥控是在一个智能家居的小项目里需要用STM32控制一个改装过的空调。市面上通用的空调遥控器协议五花八门但最基础、最通用的还是NEC编码。当时网上找的例程在实验室里对着开发板配套的遥控器百试百灵一到现场隔着两三米或者角度偏一点就频频解码失败或者误触发。折腾了好几天才把问题定位到硬件消抖、软件滤波和协议容错上。这个过程让我意识到哪怕是一个简单的红外接收从“能跑”到“好用”中间隔着一整套工程实践的细节。所以今天我们不只讲怎么让STM32收到红外信号更想聊聊怎么把它收得准、收得稳。我们会从最基础的硬件原理和NEC协议讲起然后手把手带你用STM32的通用定时器实现一个高精度的解码器最后分享几个我踩过的坑和优化技巧。无论你是刚入门的新手还是想优化现有方案的开发者相信都能从中找到有用的东西。2. 红外遥控的“物理层”硬件连接与信号本质在写代码之前我们必须先搞清楚硬件在干什么。红外遥控系统分为发射端遥控器和接收端我们的模块。发射端是一个红外LED通过快速开关调制来发送信号。接收端则是一个一体化红外接收头比如常见的HS0038、VS1838。这个小小的模块内部其实集成了光电二极管、前置放大器、带通滤波器和解调电路它直接帮我们干了一件最重要的事把调制在38kHz载波上的信号还原成干净的数字电平信号。2.1 接收模块的硬件接口与电气特性我们用的接收模块通常只有三只引脚VCC3.3V/5V、GND和OUT信号输出。它的输出是反相的当没有收到有效的38kHz红外信号时OUT引脚输出高电平当收到信号时输出低电平。这意味着我们最终解码看到的波形是载波被“滤掉”后的包络而且逻辑是反的。连接STM32非常简单将OUT引脚连接到任何一个具有外部中断功能或普通GPIO输入功能的引脚即可。例如我习惯使用PA0并开启其上升沿和下降沿中断以便捕获波形的每一个跳变。注意接收头对电源噪声比较敏感。务必在VCC和GND之间就近放置一个10uF以上的电解电容和一个0.1uF的瓷片电容进行滤波否则极易引入干扰导致解码错误。这是我早期项目中最容易忽略的一点。2.2 NEC编码协议遥控器的“语言”市面上绝大多数消费电子产品的遥控器都兼容NEC协议这是我们的重点。一个完整的NEC帧由以下几部分组成引导码一个9ms的低电平接着是一个4.5ms的高电平。这是帧开始的标志接收器靠它来同步。用户码16位用于区分不同的设备制造商。通常遥控器和接收器会约定一个固定的用户码接收方会校验它。用户反码16位是用户码的按位取反用于纠错。数据码8位代表具体的按键如音量、电源键。数据反码8位是数据码的按位取反。数据位“0”和“1”的表示方式逻辑‘0’560us低电平 560us高电平。逻辑‘1’560us低电平 1690us高电平约为560us的3倍。你会发现所有的位都以一个560us的低电平开始区别在于高电平的持续时间。这种脉宽调制的方式是解码的关键。此外NEC协议还有连发码机制当按住按键不放时发送完一帧完整数据后会每隔110ms发送一个特殊的重复码9ms低电平2.25ms高电平560us低电平直到按键松开。理解了这个波形我们的任务就清晰了用STM32的定时器精确测量两个下降沿或上升沿之间的时间间隔根据这个时间来判断当前是引导码、数据0、数据1还是重复码。3. 解码方案选型为什么我推荐通用定时器捕获模式实现红外解码常见的有三种思路外部中断延时、外部中断基本定时器、外部中断通用定时器输入捕获。我们来逐一分析。方案一外部中断延时这是最原始的方法。在中断服务函数里用while循环或for循环空转来计数根据计数值得出时间。这种方法极度依赖CPU主频且会被其他中断打断精度极差基本不可用。方案二外部中断基本定时器开启一个基本定时器如TIM6/TIM7让它以固定频率比如1MHz计数。在GPIO的外部中断里读取定时器的计数值两次中断的计数值之差乘以计数周期就是时间间隔。这个方法比方案一好但需要处理定时器溢出并且中断函数里进行减法计算代码稍显繁琐。方案三外部中断通用定时器输入捕获推荐这是最专业、最稳定的方案。STM32的通用定时器如TIM2-TIM5自带输入捕获功能。我们可以将红外接收头的OUT引脚连接到定时器的输入捕获通道如PA0对应TIM2_CH1。配置该通道在上升沿和下降沿都触发捕获。当边沿事件发生时硬件会自动将当前定时器的计数值锁存到捕获/比较寄存器CCR中并产生中断。我们只需要在中断里读取CCR的值这个值就是边沿发生的精确时刻。两次捕获值相减再乘以计数周期就得到了高电平或低电平的精确持续时间完全由硬件完成不占用CPU进行软件计时精度最高抗干扰能力最强。我强烈推荐使用方案三。它不仅稳定而且为我们后续扩展比如同时解码多个红外信号、解码其他复杂协议留下了空间。下面我们就以STM32F103C8T6的TIM2_CH1PA0为例详细实现。4. 实战基于TIM2输入捕获的NEC解码实现我们将整个解码过程设计为一个状态机这是处理异步串行通信的经典模式。状态机清晰地将解码过程划分为几个状态避免了在中断函数里堆砌大量的if-else。4.1 硬件与软件初始化首先进行硬件连接和初始化。硬件连接红外接收头OUT引脚 - STM32 PA0引脚。VCC - 3.3V GND - GND。记得在接收头电源脚附近加滤波电容。软件初始化关键步骤GPIO初始化配置PA0为浮空输入或上拉输入因为接收头输出是推挽的。定时器初始化以TIM2为例。时钟源内部时钟APB1。预分频器PSC设置为71。当系统时钟为72MHz时72MHz / (711) 1MHz即计数器每1us递增一次。这个精度足够区分560us和1690us。自动重装载值ARR设置为0xFFFF65535。因为NEC一帧最大时间远小于65.535ms无需考虑溢出。计数模式向上计数。输入捕获通道初始化通道配置为输入捕获模式。捕获边沿先设置为上升沿捕获因为空闲时OUT为高电平第一个边沿是下降沿但我们希望捕获每个边沿所以可以先设为上升沿在中断里切换。更优的做法是配置为双边沿捕获但标准外设库可能不支持直接设置我们可以在中断里动态切换。开启捕获/比较中断CCxI和更新中断UI用于处理溢出本例中可先不开。使能捕获预装载。NVIC中断配置使能TIM2的全局中断和捕获/比较通道中断。启动定时器调用TIM_Cmd(TIM2, ENABLE)和TIM_ITConfig(TIM2, TIM_IT_CC1, ENABLE)。4.2 解码状态机设计与实现我们定义以下几个解码状态typedef enum { IR_IDLE, // 空闲状态等待引导码 IR_LEADER_CODE, // 已收到引导码起始下降沿等待引导码结束 IR_RECEIVING, // 正在接收数据位 IR_REPEAT, // 接收到重复码 IR_ERROR // 解码错误 } IR_DecodeState;同时需要一些全局或结构体变量来保存解码过程中的上下文volatile IR_DecodeState ir_state IR_IDLE; volatile uint32_t ir_raw_data[33]; // 用于存储32个数据位的时间间隔可选用于调试 volatile uint8_t ir_bit_count 0; volatile uint16_t ir_customer_code 0; volatile uint8_t ir_data_code 0; volatile uint8_t ir_repeat_flag 0; volatile uint32_t ir_last_capture 0;核心逻辑在TIM2的捕获/比较中断服务函数中void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { // 清除中断标志 TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); uint32_t current_capture TIM_GetCapture1(TIM2); // 获取当前捕获值 uint32_t pulse_width; // 本次脉冲宽度单位定时器计数1计数1us // 计算与前一次捕获的时间间隔处理计数器溢出 if (current_capture ir_last_capture) { pulse_width current_capture - ir_last_capture; } else { // 发生了溢出ARR65535因为一帧时间远小于65ms所以这里简单处理 pulse_width (0xFFFF - ir_last_capture) current_capture 1; } ir_last_capture current_capture; // 更新上一次捕获值 // 切换下一次捕获的边沿以实现双边沿检测 if (TIM_GetCapture1(TIM2) 0x0001) { // 检查当前是上升沿还是下降沿这里需要根据库函数调整 TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Falling); // 设置为下降沿捕获 } else { TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Rising); // 设置为上升沿捕获 } // 更通用的方法是直接对CCER寄存器的CC1P位取反这里用库函数示意。 // --- 状态机处理 --- switch (ir_state) { case IR_IDLE: { // 空闲状态下我们等待一个下降沿引导码的开始 // 但因为我们切换了边沿这里判断的是脉冲宽度即两个边沿之间的时间 // 第一个有效脉冲应该是9ms左右的低电平接收头输出为高电平注意反相 // 这里容易混淆关键在于接收头输出空闲为高收到38kHz信号时为低。 // 因此引导码的起始是一个下降沿高-低持续9ms后变为上升沿低-高。 // 我们测量的是“电平持续时间”。如果当前是上升沿结束那么刚结束的是一个低电平脉冲。 // 我们需要判断这个低电平脉冲的宽度是否在9ms左右。 if (pulse_width 8500 pulse_width 9500) { // 约9ms的低电平 ir_state IR_LEADER_CODE; ir_bit_count 0; // 可以在这里清空数据缓冲区 } } break; case IR_LEADER_CODE: { // 在引导码状态我们期待一个4.5ms的高电平 if (pulse_width 4000 pulse_width 5000) { // 约4.5ms的高电平 ir_state IR_RECEIVING; ir_bit_count 0; // 准备开始接收数据位 } else { ir_state IR_ERROR; // 不是预期的引导码回到空闲 } } break; case IR_RECEIVING: { // 正在接收数据位。每个数据位以一个560us的低电平开始。 // 我们测量的是高电平的持续时间来判断是0还是1。 // 注意此时pulse_width是上一个边沿到当前边沿的时间即高电平的宽度。 if (pulse_width 1400 pulse_width 1800) { // 约1.69ms的高电平逻辑‘1’ // 存储为1 if (ir_bit_count 32) { // 将数据存入一个32位变量这里需要根据位序处理 // 假设先收到用户码低字节... 实际处理需要按协议顺序组装 // 简化处理我们先存到一个数组里最后再解析 ir_raw_data[ir_bit_count] pulse_width; ir_bit_count; } } else if (pulse_width 400 pulse_width 700) { // 约560us的高电平逻辑‘0’ // 存储为0 if (ir_bit_count 32) { ir_raw_data[ir_bit_count] pulse_width; ir_bit_count; } } else { // 可能是数据帧结束或者出错 // 数据位应该是32个用户码16用户反码16或者提前结束重复码 if (ir_bit_count 32) { // 成功接收32位进行数据解析和校验 if (parse_nec_frame()) { ir_state IR_IDLE; // 解析成功设置一个标志主循环里处理按键值 ir_repeat_flag 0; } else { ir_state IR_ERROR; } } else if (pulse_width 2000 pulse_width 2500) { // 可能是重复码中的2.25ms高电平部分 // 需要结合前面的9ms低电平判断这里状态机需要更精细的设计 // 简化处理如果位数不够32且收到一个特殊宽度的脉冲可能是重复码 // 更严谨的做法是单独一个状态判断重复码 ir_state IR_REPEAT; } else { ir_state IR_ERROR; } } } break; // ... 其他状态处理 case IR_ERROR: { // 发生错误重置状态机 ir_state IR_IDLE; ir_bit_count 0; } break; } } // 如果需要处理更新溢出中断 if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 处理计数器溢出在本例中如果一帧时间超过65ms才需要NEC协议一般不会。 } }上面的代码是一个高度简化的框架重点展示了状态机的思路和脉冲宽度判断。parse_nec_frame()函数需要你根据ir_raw_data数组中存储的32个脉冲宽度代表32个高电平持续时间将其还原成4个字节用户码高8位、低8位、数据码、数据反码并进行反码校验。4.3 数据解析与校验函数示例uint8_t parse_nec_frame(void) { uint32_t decoded 0; // 将32个脉冲宽度转换为32位数据脉宽1200us视为1否则为0 for (int i 0; i 32; i) { decoded 1; // 左移一位先接收的是最高位MSB if (ir_raw_data[i] 1200) { // 阈值可根据实际情况调整 decoded | 0x01; } } uint16_t customer_code (decoded 24) 0xFF; // 假设先收到用户码高8位 customer_code (customer_code 8) | ((decoded 16) 0xFF); // 组合成16位 uint8_t data_code (decoded 8) 0xFF; uint8_t data_code_inv decoded 0xFF; // 校验数据反码 if ((uint8_t)(~data_code) data_code_inv) { ir_customer_code customer_code; ir_data_code data_code; return 1; // 成功 } // 也可以校验用户反码这里省略 return 0; // 失败 }在主循环中你可以不断检查ir_data_code是否被更新或者设置一个“新数据到达”的标志位来获取按下的键值。5. 从“能用”到“稳定”提升解码鲁棒性的关键技巧如果你的代码只是按照上面的框架写在理想环境下可能没问题。但现实环境复杂有日光灯干扰、有其他红外源、遥控器电池电量不足、按键抖动、远距离信号弱等等。下面分享几个让解码更稳定的实战技巧。5.1 硬件滤波与软件“宽容度”设置硬件上前面提到的电源滤波至关重要。此外可以在信号线OUT到MCU引脚上串联一个100欧姆左右的电阻并并联一个20pF-100pF的电容到地构成一个简单的RC低通滤波器滤除一些高频毛刺。软件上不要对脉冲宽度的判断过于“苛刻”。上面的代码用了if (pulse_width 8500 pulse_width 9500)这样的绝对范围。在实际中由于晶振误差、遥控器个体差异、电池电压等因素这个范围应该放宽。我常用的经验值是±15%-20%。例如引导码低电平判断if (pulse_width 7200 pulse_width 10800)// 9ms ±20%引导码高电平判断if (pulse_width 3600 pulse_width 5400)// 4.5ms ±20%位“0”高电平判断if (pulse_width 450 pulse_width 670)// 560us ±20%位“1”高电平判断if (pulse_width 1350 pulse_width 2030)// 1690us ±20%放宽阈值可以大大提高对不同遥控器的兼容性。5.2 状态机的超时与错误恢复机制一个健壮的状态机必须有超时机制。如果卡在某个状态比如IR_RECEIVING一直收不到下一个边沿程序就会死等。我们需要在定时器更新中断或另一个定时器中实现超时判断。例如在进入IR_LEADER_CODE状态时启动一个超时计数器比如设定为15ms。在定时器更新中断里如果该计数器不为零则递减减到零时强制将解码状态重置为IR_IDLE。同样在接收数据位时每个位之间也应该有一个超时比如2.5ms超过这个时间就认为本帧接收失败复位状态机。// 在某个定时器中断如1ms中断中 if (ir_state ! IR_IDLE) { ir_timeout_counter; if (ir_timeout_counter IR_TIMEOUT_MS) { // 例如超时设为15ms ir_state IR_IDLE; ir_timeout_counter 0; // 其他变量复位 } } else { ir_timeout_counter 0; }5.3 应对连发码与按键去抖NEC的连发码是一个特殊的短脉冲。在状态机中我们需要增加一个IR_REPEAT状态。当收到一个9ms低电平2.25ms高电平560us低电平的组合时就判定为重复码。重复码意味着同一个按键被持续按住。在应用中我们通常希望区分“短按”和“长按”。可以在主循环中这样处理if (ir_new_data_flag) { // 有新的完整帧数据 if (ir_data_code last_key_code (current_time - last_key_time 150)) { // 如果收到的键码与上一次相同且时间间隔很短150ms很可能是连发码中的一帧可以忽略或视为长按 // 处理长按逻辑 ir_repeat_count; } else { // 新的按键或者间隔较长视为新的短按 // 处理短按逻辑 ir_repeat_count 0; } last_key_code ir_data_code; last_key_time current_time; ir_new_data_flag 0; }5.4 调试与问题排查心得红外解码出问题多半是时序不对。最好的调试工具是逻辑分析仪可以直接抓取接收头OUT引脚上的波形和你代码里测量的脉冲宽度进行对比。如果没有逻辑分析仪可以用以下“土办法”打印脉冲宽度在中断里将每次捕获到的pulse_width通过串口打印出来。对比打印出的时间和NEC协议规定的时间就能看出是哪个环节的判断条件太严格或太宽松。使用LED指示状态在不同的解码状态IDLE, RECEIVING, ERROR点亮不同的LED可以直观看到解码过程在哪里卡住或出错。检查中断优先级确保红外解码定时器中断的优先级设置合理不要被其他长时间的中断如串口接收中断打断否则会丢失边沿。可以将红外解码中断优先级设为较高。我遇到过最诡异的一个问题是解码偶尔会错一位。最后发现是ir_raw_data数组没有用volatile修饰编译器优化导致在中断和主函数中访问的数据不一致。所以所有在中断和主循环共享的变量务必加上volatile关键字。6. 进阶与扩展不止于NEC当你稳定实现了NEC解码后这个框架可以很容易地扩展到其他红外协议比如Philips RC-5、RC-6、Sony SIRC等。这些协议的区别主要在于载波频率不一定都是38kHz、引导码格式、数据位的编码方式可能是脉宽调制也可能是相位调制。你需要做的是研究新协议的波形规范。调整定时器的预分频值以适应不同的时间精度要求例如对于更短或更长的脉冲。修改状态机的判断条件和状态转移逻辑。调整数据解析函数。例如RC-5协议使用双相调制曼彻斯特编码它的位“0”和“1”不是靠高电平脉宽区分而是靠电平翻转的位置来区分。这时你的解码逻辑就需要从测量“脉冲宽度”转变为检测“边沿间隔”和“电平变化”。更进一步你还可以尝试用STM32的定时器PWM输出功能自己制作一个红外发射器实现学习型遥控器或者红外中继的功能。发射的关键在于用38kHz的载波去调制你想要发送的数字波形这需要另一个定时器工作在PWM模式并动态改变其输出比较值来生成不同的脉冲序列。红外遥控接收模块虽然小但它像一把钥匙打开了嵌入式系统中“时间测量”、“中断处理”、“状态机设计”、“协议解析”这几扇大门。把它吃透以后再面对UART、I2C、单总线这些更复杂的通信协议时你会发现自己已经有了坚实的内功。最后别忘了把完整的、带注释的工程代码保存好它将成为你未来项目中的一个可靠“轮子”随时可以拿来复用。