简介:面向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为0,1.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 载波生成方案对比:PWM+DMA 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帧总时长约67ms,38kHz载波的半周期约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是38kHz,Sony SIRC是40kHz,飞利浦RC5是36kHz,载波频率本身就能提供关键的区分信息。
码库的存储格式我建议采用“时长序列”而不是“位序列”。时长序列的好处是协议无关,不管是NEC还是RC5,都能统一表示为一串脉宽值,放Flash里效率也不低——一个键平均60个时长值,每个值用uint16_t存储,占120字节,放100个键也就12KB,对F103来说剩余空间充足。匹配时用“先比较帧总时长,再逐段比较脉宽误差范围”的算法,允许±15%的容差,实测对不同品牌遥控器的兼容性比较理想。
最后说一说做这套东西我学到的最深一课的教训:软件逻辑正确不等于硬件能跑,红外这个方向尤其如此。你花三天写出来的解码算法,可能因为一个退耦电容没贴就变得完全不可用。从最开始就养成“硬件先行”的调试习惯,先逻辑分析仪确认波形,再写代码,能省掉大量定位时间。如果你正准备做基于STM32的红外项目,从本文的解码流程和避坑清单出发,一晚上跑通基本盘是完全可能的。希望帮到你。
本文还有配套的精品资源,点击获取