STM32红外解码与编码实战:NEC协议解析及避坑指南 简介面向STM32红外功能开发的完整参考工程适合嵌入式初学者入门以及进阶者快速搭建红外收发模块。资源以红外遥控代码为核心搭配LCD液晶驱动ILI93xx、触摸屏touch与usmart串口调试组件可覆盖信号解码、按键处理、显示反馈等常见环节。压缩包共156个文件、约7.84MB以127个C/H源码文件为主体另含Keil工程配置文件、启动文件s、hex可烧录文件、PDF技术文档与工程清理脚本bat目录结构清楚便于对照学习或直接移植到自研板卡二次开发。目前已有1927人学习下载工程从驱动层到应用层均有代码支撑尤其适合需要处理红外协议解析、调试与功能扩展的STM32开发者配套的液晶与触摸驱动、串口调试组件还能帮助读者减少底层适配工作量将精力更多放在业务逻辑上。1. 基于STM32的红外代码程序你在做遥控器还是在做解码器做嵌入式红外开发的人第一反应通常是“我要做个遥控器”——用STM32驱动红外发射管按键按下把数据发出去。但真正上手后你会发现这个标题的难点从来不在发射而在解码。市面上你能买到的红外遥控器协议五花八门NEC、RC5、Sony SIRC、Sharp、还有各家空调自定义的帧格式。你的STM32要做的第一件事不是发码而是把对方发过来的波形准确读出来搞清楚每一位的时序然后才能在内存里重建这个“代码”再决定是转发、存储还是转成其他协议发出去。这篇文章要讲的就是这件事用STM32做一套红外收发系统核心是NEC协议的解码与编码同时给出硬件连接的选型理由、定时器配置的关键参数以及我在调试中踩过的几个真坑。适合正在做智能家居控制、万能遥控器、或者嵌入式外设控制的开发者。新手能照着把最小系统跑起来熟手可以直接跳过基础部分去看避坑章节——里面有几个问题逻辑分析仪不挂上去你很难定位。2. NEC协议时序拆解为什么说红外解码本质上是“用定时器量脉宽”2.1 NEC协议帧结构引导码、地址码、数据码的位定义NEC协议是红外遥控里最常用的格式原因很简单帧结构规整、容错强、绝大多数家电遥控器都兼容。一个完整的NEC帧包括引导码9ms高电平 4.5ms低电平、8位地址码、8位地址反码、8位命令码、8位命令反码最后是一个560us的结束位。地址码和命令码各自带反码接收端做校验时只需要判断“原码 反码 0xFF”就能确认这一帧有效不用做复杂的CRC计算这在MCU上非常友好。逻辑“1”和逻辑“0”的区分是NEC协议的精髓它们的高电平脉宽都是560us区别只在低电平的持续时间。逻辑1的低电平是1.69ms逻辑0的低电平是560us。所以解码的时候你不需要去测量整个位的周期只需要关心两次下降沿之间的低电平宽度——这个特性让解码算法变得非常简单捕获下降沿启动定时器等待下一个下降沿到来时读取定时器值根据这个时间差判断是0还是1。接收端的波形要特别注意红外接收头比如最常见的VS1838B输出的信号是反相的。它空闲时输出高电平收到载波时输出低电平。也就是说发射端发9ms载波接收头输出的是9ms低电平。这点搞反了后面所有时序判断都会出错。我刚开始调试时直接用逻辑分析仪看接收头输出发现波形和协议手册上完全相反一度以为买到了假芯片。2.2 STM32的硬件资源选型定时器输入捕获还是外部中断解码NEC协议STM32上有两种常见做法外部中断 软件延时轮询或者定时器输入捕获。外部中断的做法是每个下降沿触发一次中断在中断里启动一个微秒级的定时器然后等下一个下降沿到来时读取定时器计数值。这样做逻辑简单但中断频率很高一个完整帧大约有70次左右的跳变如果系统里还有其他实时任务很容易丢失边沿。我推荐用定时器输入捕获模式。STM32的定时器输入捕获可以在硬件层面记录边沿到来的时刻不需要软件干预。配置为下降沿捕获捕获事件发生时硬件自动把当前计数值锁存到捕获寄存器同时触发中断。你在中断里只需要读取两次捕获值的差就能得到脉宽CPU开销极小。对于NEC协议这种脉冲宽度在560us到4.5ms范围的信号定时器时钟配置为1MHz计数值分辨率是1us精度完全足够。具体到STM32F103这种资源丰富的芯片我习惯用TIM2作为捕获定时器配置为外部时钟模式把红外接收头的输出引脚直接接到定时器的通道输入上。这样连外部中断都省了纯硬件捕获。对F103的72MHz主频来说1MHz的定时器时钟可以通过预分频器轻松得到不存在精度问题。如果你用的是资源更紧的芯片至少也要保证定时器计数频率不低于500kHz否则560us的脉冲宽度会引入超过2%的量化误差叠加起来会让解码偶尔失败。2.3 硬件连接接收头、发射管、限流电阻的电气参数红外接收头的连接方式VS1838B的电源引脚接3.3V地接GND信号输出引脚直接连STM32的定时器输入捕获引脚。不要在信号线上加下拉电阻接收头本身是开漏输出内部已经有上拉外加下拉会破坏波形边沿的陡峭度。电源引脚旁边放一个10uF电解电容和100nF陶瓷电容做去耦否则空调电机启动时电压跌落会导致解码总在同一个位置出错。红外发射部分要稍微小心一点。NEC协议的载波是38kHz占空比约1/3峰峰值电流可以到100mA。STM32的GPIO直接驱动发射管是不够的需要加一个NPN三极管或者N-MOS管做开关。我常用的是S8050三极管基极串联1kΩ电阻接到GPIO集电极接红外发射管负极发射管正极接3.3V电源。注意发射管VF值一般在1.2V到1.5V限流电阻阻值按3.3V - VF/ 峰值电流计算100mA峰值对应约20Ω到22Ω。供电和地线的处理是我反复翻车的地方。发射瞬间100mA的电流脉冲会造成地弹如果接收头和发射管共用地线解码时收到的波形会出现毛刺。我的习惯是接收头和发射管的电源走线在PCB上分叉各自去主电源电容不要串联走线。飞线搭面包板调试时这个问题尤其严重——发射管一工作解码头输出波形就开始抖。3. 用定时器输入捕获解码NEC帧从电平跳变到完整命令3.1 最小解码代码捕获中断、超时判断、帧校验直接给一套能用的解码实现。下面的代码基于STM32标准外设库定时器TIM2配置为输入捕获模式捕获通道映射到红外接收头输出引脚。#define IR_INPUT_PIN GPIO_Pin_0 #define IR_INPUT_PORT GPIOA #define IR_TIM TIM2 #define IR_TIM_CHANNEL TIM_Channel_1 volatile uint32_t ir_last_capture 0; volatile uint32_t ir_pulse_width 0; volatile uint8_t ir_frame_data[4] {0}; volatile uint8_t ir_frame_index 0; void IR_TIM_IRQHandler(void) { if (TIM_GetITStatus(IR_TIM, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(IR_TIM, TIM_IT_CC1); uint32_t current_capture TIM_GetCapture1(IR_TIM); uint32_t diff current_capture - ir_last_capture; ir_last_capture current_capture; ir_pulse_width diff; // 引导码4.5ms低电平对应约4500us if (diff 4000 diff 5200) { ir_frame_index 0; ir_frame_data[0] 0; ir_frame_data[1] 0; ir_frame_data[2] 0; ir_frame_data[3] 0; return; } // 数据位560us为01.69ms为1 if (ir_frame_index 32) { if (diff 1000 diff 2200) { ir_frame_data[ir_frame_index / 8] | (0x80 (ir_frame_index % 8)); } ir_frame_index; } } }这段代码的逻辑核心是利用两次输入捕获值的差来判断脉宽。需要理解的是定时器配置为下降沿捕获每次捕获中断发生时读取的就是上一次下降沿到当前下降沿的时间差。NEC协议中引导码是9ms高电平加4.5ms低电平但从接收头输出看这个9ms是低电平4.5ms才是高电平。捕获下降沿的时间差测到的是低电平宽度所以引导码的判断条件应该是4000~5200us对应发射端的4.5ms低电平段。数据位的判断逻辑同样围绕低电平宽度展开。逻辑0的低电平是560us逻辑1的低电平是1.69ms所以阈值设在1000us处即可区分。代码里当脉宽落在1000~2200us区间时判定这一位为1否则为0。注意这里没有区分引导码和数据位之间的固定560us间隔但在实际解码中引导码结束后第一个数据位的低电平脉宽判断不会受太大影响因为间隔时间远小于阈值下限。3.2 定时器初始化配置预分频、捕获极性与溢出处理定时器配置是整个解码稳定性的基础。下面给出初始化代码注释写清楚了每个参数的作用void IR_Timer_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 开启TIM2和GPIOA时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA0复用为TIM2_CH1输入 GPIO_InitStructure.GPIO_Pin IR_INPUT_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(IR_INPUT_PORT, GPIO_InitStructure); // 定时器时钟72MHz预分频72计数频率1MHz TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(IR_TIM, TIM_TimeBaseStructure); // 输入捕获下降沿触发直接映射到TI1 TIM_ICInitStructure.TIM_Channel IR_TIM_CHANNEL; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Falling; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; TIM_ICInit(IR_TIM, TIM_ICInitStructure); // 开启捕获中断 TIM_ITConfig(IR_TIM, TIM_IT_CC1, ENABLE); // 中断优先级配置 NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(IR_TIM, ENABLE); }这里有几个参数值得展开说明。TIM_Period设为0xFFFF是因为NEC帧最长的一段时间是引导码4.5ms加上高电平9ms总共约13.5ms超出16位定时器在1MHz计数频率下的最大溢出时间65.5ms。设为最大值可以让定时器在两次捕获之间不容易溢出但这并不能完全避免溢出问题——如果两次下降沿间隔超过65.5ms比如遥控器按键释放后长时间没有新信号定时器会回绕计算出的差值就错了。TIM_ICFilter这个参数容易被忽略但我强烈建议设到0x0F。输入捕获滤波器本质上是一个数字低通滤波器它的作用是剔除信号线上的窄脉冲毛刺。红外接收头在恶劣环境下输出的信号边沿可能带有振铃如果这个毛刺宽度小于滤波器的采样窗口就会被硬件直接滤掉不会触发错误的捕获中断。代价是信号边沿的响应会延迟几个时钟周期但对NEC协议这种us级别的脉宽来说这个延迟可以忽略。3.3 状态机设计如何区分“长按键重复码”和短脉冲干扰NEC协议有一个特殊机制叫重复码当遥控器按键持续按住时不会重复发送完整帧而是每隔110ms左右发送一个引导码加结束位。如果把重复码当成新数据帧处理智能家居场景里长按键会变成连续触发多次逻辑上是错的。正确的做法是在解码状态机里增加一个状态收到引导码后如果接下来的脉宽是560us结束位而不是数据位就判定为重复码此时保留上一帧的命令码。常见做法是设计一个简单的状态机IDLE态等待引导码DATA态解析32位数据REPEAT态处理重复码COMPLETE态向上层提交命令。我自己的实现里用了一个单字节状态变量不引入复杂的状态机框架。核心逻辑是在引导码判定之后设置一个数据位计数器如果计数器还没开始增加就收到了结束位脉宽说明是重复码此时直接置位一个“重复帧”标志应用层读到这个标志时沿用上一次的命令值。这种短脉冲干扰的处理依赖超时机制。如果解码头附近有电机或者开关电源可能产生与NEC脉宽相似的杂波。一个比较有效的策略是在两次捕获之间设置一个5ms的软件超时。如果超过5ms没有新的捕获中断就把状态机重置回IDLE。这样杂散脉冲不会累积成假帧因为NEC完整帧的总时长不会超过70ms任何超过这个时间跨度的“帧”都应该被丢弃。4. 编码与发射DMA加PWM生成38kHz载波告别阻塞延时4.1 载波生成方案对比PWMDMA vs 定时器翻转NEC发射需要产生38kHz的载波调制方式是把数据位的电平信息叠加在载波上逻辑1就是560us的38kHz载波加1.69ms低电平逻辑0就是560us的载波加560us低电平。最直接的做法是用定时器PWM输出38kHz方波然后用另一个定时器控制PWM的使能时长。这种方法简单但有一个问题根据数据不同载波的持续时长是变化的需要不断地重新配置定时器的比较值。更好用的方案是PWM DMA。先准备好一段数据缓冲区缓冲区里每个元素代表一个时间段内PWM是否输出。利用STM32定时器的DMA请求功能当定时器的更新事件发生时自动从缓冲区加载下一个值到比较寄存器实现载波段的自动切换。这个过程完全不需要CPU干预CPU只需要在发射前把整个帧的数据按照NEC时序翻译成一段电平序列即可。两种方案的对比很清晰定时器翻转方案占用两个定时器而且每个电平边沿都要进中断对于长命令帧来说频繁的中断会影响系统实时性。PWM DMA方案只占一个定时器和一个DMA通道发射一个完整NEC帧只需要一次DMA传输配置后续完全硬件自动化。如果你的STM32型号资源够用我优先推荐后者因为代码里的阻塞延时可以被彻底消除系统可以在发射过程中继续处理其他任务。4.2 用DMA搬运电平序列缓冲区的数据结构设计发射端的核心是构造一段精确的电平序列。NEC帧的每一位拆开来就是“一段载波”加“一段静默”载波的时间是固定的560us静默时间区分0和1。所以缓冲区里存的不应该是简单的定时长度而应该是一个描述“载波使能/禁止”的二维数组typedef struct { uint16_t carrier_time_us; // 载波持续时间 uint16_t silent_time_us; // 静默持续时间 } NEC_BIT_SEGMENT; const NEC_BIT_SEGMENT nec_bit_one {560, 1690}; const NEC_BIT_SEGMENT nec_bit_zero {560, 560}; const NEC_BIT_SEGMENT nec_lead_code {9000, 4500}; const NEC_BIT_SEGMENT nec_end_pulse {560, 0}; // 组装帧数据地址0x00命令0x45 NEC_BIT_SEGMENT frame[34]; frame[0] nec_lead_code; for (int i 0; i 8; i) { if (address (0x80 i)) frame[1 i] nec_bit_one; else frame[1 i] nec_bit_zero; } // ... 组装反码、命令码、反码 frame[33] nec_end_pulse;每次DMA传输的数据不是这个结构体而是定时器比较寄存器的具体值。比较值的含义是当定时器计数到这个值时翻转PWM输出。38kHz载波对应周期约26.3us占空比1/3时比较值设为周期值的1/3即可。载波段的比较值维持高频翻转静默段的比较值设为0或超过周期值让PWM输出恒为低电平。通过把帧数据翻译成一段连续的比较值数组DMA就能连续改变PWM的输出状态。这里的关键是DMA传输方向。定时器的更新事件触发DMA请求DMA从内存读取比较值写入定时器的CCR寄存器。整个数组大小取决于帧长度和载波频率一个NEC帧总时长约67ms38kHz载波的半周期约13us每个载波脉冲需要2次比较值切换高到低、低到高一帧大约需要几千次DMA搬运对DMA来说负担不大。发射结束后关闭DMA和PWM输出释放总线。4.3 发射时序参数标定为什么实测载波频率要留1%余量设计好发射代码后载波频率的准确性直接影响遥控距离。红外接收头内部的带通滤波器中心频率是38kHz带宽通常只有±2kHz左右。如果载波频率偏差超过1kHz接收灵敏度会明显下降实测表现就是遥控距离从8米缩到3米。我用示波器测量PWM输出时发现代码里计算出的38kHz实际输出是37.4kHz原因是定时器时钟源的误差和预分频取整引入的量化误差。解决办法不是提高代码精度而是在硬件上留出余量。把载波频率目标设为37.5kHz到38.5kHz之间尽量接近接收头的中心频率。同时注意红外发射管的导通电流不同输出光功率的峰值波长会有细微偏移和接收头的匹配度也会变化。我一般会先用示波器确认PWM的占空比确实是1/3——占空比过大浪费功耗过小则发射距离下降这个比例在NEC协议里是标准要求。调幅深度也需要留意。有些便宜的电路板为了省三极管直接用GPIO推挽输出驱动LED没有真正实现载波调制只是通过翻转GPIO来模拟。这样做的后果是发射管的导通时间变长平均电流上升但接收效果反而不如专用载波调制的好。正确的做法始终是让载波信号通过三极管开关来控制LED的导通GPIO输出的电平直接控制基极不要用GPIO模拟载波。5. 红外代码调试避坑逻辑分析仪看到的波形和协议手册对不上5.1 接收头输出反相导致解码错乱现象用逻辑分析仪抓接收头输出解码出的一帧数据全是0xFF或者随机跳变完全匹配不上遥控器按键。原因NEC协议手册里定义的波形是发射端的电气波形而红外接收头输出的数字信号是反相的。第一次做的人基本都会在这个地方卡一晚上。解决把捕获极性从下降沿改成上升沿或者把解码逻辑里的脉宽判断反过来。我自己的习惯是用一个调好的遥控器发射引导码观察逻辑分析仪上引导码低电平的宽度如果看到的是9ms而不是4.5ms直接翻转捕获极性。5.2 定时器溢出导致长间隔误判现象接收正常遥控器按键时偶发错过帧完整帧只有开头一部分被解析到。原因两次捕获的时间差超过了定时器的最大周期计数值回绕后计算差值得到的是一个很小的数而不是一个大数状态机把这个“小间隔”当成了数据位。解决不要在单次差值里判断所有情况而是给状态机增加一个节拍概念——当一个完整帧超过70ms还没结束时主动判定为异常并中止解码。同时可以把定时器预分频调大让计数频率降到500kHz把最大测量时间拉长到131ms。5.3 38kHz载波噪声串入解码通道现象遥控器没有按键但系统偶尔触发解码成功事件。原因环境中的红外噪声尤其是阳光、节能灯、空调的遥控串扰经过接收头的带通滤波器后可能产生与NEC数据位相似的脉宽。解决软件层面增加帧校验地址码反码和命令码反码必须同时通过“原码反码0xFF”的校验才提交数据。硬件层面把TIM_ICFilter从0x0F提到最大并考虑在接收头电源引脚并联更大的电容。如果还需要进一步抑制可以在解码状态机的空闲态要求连续收到至少两次有效的引导码间隔大于80ms才允许进入数据接收态。5.4 发射端无波形输出但代码逻辑看起来没问题现象用逻辑分析仪接GPIO发射脚完全看不到PWM波形但定时器初始化代码执行没有报错。原因大概率是GPIO复用功能没有正确配置或者定时器的主输出使能MOE没有开启。STM32的高级定时器TIM1/TIM8默认主输出是关闭的必须设置TIM_CtrlPWMOutputs(TIMx, ENABLE)才能让PWM信号从引脚输出。解决第一步用示波器量引脚电平如果一直是低电平优先检查GPIO的AFIO重映射和主输出使能然后再查通道输出极性。5.5 板子上电后接收头输出一直为低现象新打样的板子接收头输出引脚直接接地没有任何动作。原因接收头的电源接反或者电压过高导致芯片损坏是最常见的情况其次是信号输出引脚被单片机配置成了推挽输出把接收头的开漏输出拉死。解决先断开单片机引脚用万用表量接收头输出引脚的静态电平正常悬空时应为高电平。如果为低直接换接收头这种器件损坏率比想象中高一次买几十个做调试不心疼。确认接收头没问题后再查MCU引脚配置确保是浮空输入模式。6. 把单个按键解码扩展成无线遥控器学习型红外码库的存储与匹配做到这一步你已经能解码一个按键、发射一段自定义编码了。但要做一个真正能用的万能遥控器需要解决两个问题多个按键的码值怎么组织存储以及接收到一个未知遥控器的信号时怎么识别它属于哪种协议。我的做法是在解码完成之后通过一次载波周期测量来判别协议类型——NEC是38kHzSony SIRC是40kHz飞利浦RC5是36kHz载波频率本身就能提供关键的区分信息。码库的存储格式我建议采用“时长序列”而不是“位序列”。时长序列的好处是协议无关不管是NEC还是RC5都能统一表示为一串脉宽值放Flash里效率也不低——一个键平均60个时长值每个值用uint16_t存储占120字节放100个键也就12KB对F103来说剩余空间充足。匹配时用“先比较帧总时长再逐段比较脉宽误差范围”的算法允许±15%的容差实测对不同品牌遥控器的兼容性比较理想。最后说一说做这套东西我学到的最深一课的教训软件逻辑正确不等于硬件能跑红外这个方向尤其如此。你花三天写出来的解码算法可能因为一个退耦电容没贴就变得完全不可用。从最开始就养成“硬件先行”的调试习惯先逻辑分析仪确认波形再写代码能省掉大量定位时间。如果你正准备做基于STM32的红外项目从本文的解码流程和避坑清单出发一晚上跑通基本盘是完全可能的。希望帮到你。本文还有配套的精品资源点击获取