
简介一份基于STM32微控制器实现红外遥控收发功能的完整工程源码适合嵌入式初学者及进阶开发者用于学习红外通信协议、定时器中断、PWM信号编解码以及标准库/HAL库开发思路。压缩包共95个文件以43个C源文件与39个头文件为主另含工程配置文件、链接映射与备份文件整体约384KB源码按SYSTEM、HARDWARE等目录组织模块覆盖IR、KEY、TFT、FLASH、PWM、串口等结构清晰便于按功能定位与移植。已有4863人学习下载。工程内容包含红外接收解码与发送编码两大核心部分实现了常见NEC等协议的脉冲捕获、数据解析、校验处理及中断调度并集成按键交互、LED指示和显示输出代码注释较完整可直接在MDK环境中编译烧录。通过阅读这套程序可深入理解STM32定时器输入捕获与输出比较的配合、GPIO驱动红外收发头的方法以及嵌入式项目从模块划分到调试调优的整体流程适合作为毕业设计、课程实验或岗位技能提升的参考案例。 如果你正在找一个“麻雀虽小、五脏俱全”的STM32练手项目红外遥控器解码绝对排得上号。它不涉及复杂的外设协议栈却能把定时器输入捕获、外部中断、状态机、通信协议解析、按键映射这几块嵌入式基本功串起来。做完这个项目你对“时序测量”和“协议解析”这两件事的理解会比刷十遍教程都深刻。这篇文章我会按我自己实际开发时的思路来写从NEC协议本身、解码方案的选型到定时器输入捕获的配置细节、状态机代码怎么写再到我踩过的那些坑。内容基于STM32F103系列、标准外设库来展开但思路完全适用于HAL库和其他型号的单片机。无论你是刚学完GPIO和定时器、想找个项目练手还是已经在做毕设、产品原型需要接入红外遥控功能这篇文章都值得你从头到尾读完。1. 整体设计与思路拆解先别急着写代码把方案定下来很多人拿到这个项目第一反应是“红外接收头接到单片机检测电平变化然后解码”。这个方向没错但如果你真的直接用GPIO不断读引脚电平大概率会得到一个“看似能用、实则在崩溃边缘”的程序。所以动手之前我建议先把三件事想清楚。1.1 为什么选NEC协议而不是其他红外协议市面上常见的红外遥控协议有NEC、Sony SIRC、Philips RC5/RC6等。不做产品化、只是自己玩或者做毕业设计我强烈建议从NEC入手。原因很直接。第一NEC协议的市场占有率极高家里随便找个遥控器电视、机顶盒、风扇大概率就是NEC协议的硬件获取成本几乎为零。第二NEC的时序特征是“宽窄分明”——引导码长达9ms数据位的0和1在脉冲宽度上差异明显这种特征对初学者极其友好解码容错率高哪怕你定时器配置得不是特别精准依然能正确解出数据。第三NEC的帧结构清晰引导码 8位地址码 8位地址反码 8位命令码 8位命令反码总共32位数据校验逻辑简单直观。相比之下Sony SIRC协议用脉冲个数表示数据RC5用相位变化表示数据解析起来都要绕一些弯子。所以别给自己添堵练手项目就选NEC把核心逻辑跑通以后想扩其他协议那就是水到渠成的事。1.2 解码方案对比定时器输入捕获是首选红外的本质是“用脉冲宽度编码信息”所以解码的核心就一个字量。量高电平持续了多久、低电平持续了多久再根据宽度判断是引导码、0还是1。测量脉冲宽度常见思路有这么几种。外部中断 变量计时GPIO下降沿触发中断进入中断后开启一个计时变量比如SysTick滴答计数下一个上升沿再记录一下时间。这种方式实现简单但你得保证中断响应足够快、计时变量足够准一旦系统里还有其他中断在跑时间测量就容易漂移。我第一版就是这么干的实测下来偶尔会莫名解错键排查起来非常痛苦。定时器输入捕获这是STM32硬件自带的功能。引脚上的边沿信号到来时定时器会把当前计数值硬件锁存到捕获寄存器同时触发中断。整个过程不需要CPU参与测量精度是硬件级保证。我们只需要在中断里把两次捕获值相减就能得到精确的脉冲宽度。专用红外解码芯片比如TL1838加解码IC电路更简单但灵活性差你无法自定义协议而且基本学不到东西不适合做项目。毫无疑问方案二定时器输入捕获是兼顾可靠性和可玩性的最优解。它的配置也不复杂后面我会详细拆。整体架构一句话就能说清楚红外接收头把38kHz载波信号解调成高低电平输出给STM32的定时器输入捕获引脚定时器精确测量每个电平的持续时间中断服务函数里跑一个状态机把电平宽度翻译成二进制位最后拼装成一帧完整数据通过命令映射表转成具体动作。1.3 这个项目到底能解决什么问题别小看这个“小项目”它其实回答了嵌入式开发里两个很重要的命题怎么准确测量外部信号的时序以及怎么把看似无序的数据流规整成协议帧。做完它你可以轻松实现“用遥控器控制Led开关/亮度”“遥控器控制舵机角度”“遥控器触发继电器开关”等一大票应用而且这套“信号测量 状态机解析 命令映射”的架构以后做无线遥控、解码其他协议同样适用代码框架可以直接复用。学完这个项目你再去接触蓝牙、WIFI等更复杂的协议解析会轻松很多。2. 核心细节解析与实操要点NEC时序和定时器配置方案定了接下来就要处理细节。这个项目的甜区也是最容易翻车的地方集中在两块NEC协议的时序特征以及定时器输入捕获的配置。我一个个说。2.1 NEC协议时序特征你必须烂熟于心NEC协议使用38kHz载波调制这一点由硬件红外一体接收头负责解调我们单片机看到的是解调后的电平信号所以代码层面不关心载波只关心脉冲宽度。一体接收头输出的电平逻辑是反相的没有红外信号时输出高电平收到载波时输出低电平。每发一个数据位实际表现是一段低电平脉冲表示载波持续时间和一段高电平间隔。NEC的完整帧结构是这样的先发一个引导码9ms低电平 4.5ms高电平然后依次发送8位地址码、8位地址反码、8位命令码、8位命令反码总共32位数据。每一位的发送顺序是LSB最低位在前。数据位0的组成是560微秒低电平 560微秒高电平总周期1.125ms数据位1的组成是560微秒低电平 1.69毫秒高电平总周期2.25ms。如果按键一直按住遥控器会每隔110ms左右发送一次“重复码”重复码的特征是9ms低电平 2.25ms高电平 560微秒低电平。注意一个关键点区分“0”和“1”靠的不是低电平宽度都是560us而是高电平宽度560us还是1690us。所以你解码时核心度量对象是高电平持续时间。2.2 定时器输入捕获的初始化配置与计算过程以STM32F103为例我习惯用TIM2的通道1引脚PA0当然TIM3_CH1PA6、TIM4_CH1PB6也都可以关键是查清楚这个定时器挂在哪个总线上、时钟源多少。F103的APB1定时器时钟最高是72MHz注意APB1外设时钟可能是36MHz但定时器时钟在分频系数不为1时会自动倍频到72MHz这里容易混淆建议直接查参考手册对应的时钟树确认别凭感觉来。配置的核心目标是让计数频率刚好等于1MHz这样每个计数单位正好代表1微秒后续计算脉宽时直接拿差值当微秒用。计算过程不复杂。定时器输入时钟72MHz想要1MHz的计数频率预分频器PSC取71即可实际分频系数 PSC 1 72。计数器ARR建议设为0xFFFF65535这样单个计数周期最长可测量65.535ms覆盖NEC协议里9ms的引导码绰绰有余。在这个配置下读取捕获寄存器CCR1的值再换算脉宽得到的数单位就是“微秒”。配置时还有几个不起眼但决定成败的选项。第一引脚要复用为输入捕获功能同时开启内部上拉否则接收头输出高阻时引脚电平会飘。第二输入捕获要设置为“上升沿和下降沿都捕获”——这就是经典的双边沿捕获上升沿记录一次CCR值下降沿再记录一次两次相减就是一个完整的脉冲宽度或者半周期宽度具体看你怎么处理。第三输入滤波器建议开启可以设置一个适中的滤波长度比如2个周期用来滤掉窄毛刺但别太大否则窄脉冲560us会被吃掉。2.3 中断优先级与实时性这里有个隐形坑红外解码对实时性要求不低但不是那种“必须分秒不差”的硬实时。最怕的是你在解码过程中被其他高优先级中断卡了很久导致捕获寄存器被覆盖或者没及时读取。我的经验是把红外解码所用定时器的中断优先级设为较高档位比如抢占优先级1子优先级0但要低于系统滴答定时器SysTick——否则你在解码中断里调用HAL_Delay这类依赖SysTick的函数会直接死等。还有一点中断服务函数里只做“读捕获值、记录状态机、计算宽度”千万别在里面干延时、打印、驱动LED这类耗时操作。串口打印放到主循环里做否则一个printf下去后续几个脉冲全丢了。这个原则是这类项目的保命法则。3. 实操过程与核心环节实现从硬件连接到状态机代码理论部分说完了接下来是动手环节。这部分的代码是基于标准外设库写的用HAL库的话逻辑完全一致只是API名字换一下。3.1 硬件连接与材料清单你需要准备这些东西STM32F103核心板或者其他任意型号的板子、一体化红外接收头我用的VS1838B几毛钱一个非常常见、一个红外遥控器最好是NEC协议的不确定的话可以先解码看数据下面会讲、面包板加杜邦线若干、一个LED用来验证解码结果、一个逻辑分析仪强烈建议有没有的话串口打印也能凑合。连接关系极其简单接收头VCC接单片机3.3V部分型号支持5V但3.3V更稳GND共地OUT引脚接到PA0也就是TIM2_CH1。这里强调一下PA0是默认的TIM2_CH1复用引脚如果你选其他定时器通道务必确认引脚是否支持对应的复用功能参考手册里的AFIO映射表别想当然。LED的正极串一个220欧电阻后接PA1或者直接用板载LED负极接GND。3.2 代码实现初始化、捕获中断、状态机解析先写定时器初始化。这里要配置GPIO复用和定时器输入捕获通道。void IR_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. PA0配置为浮空输入但配合内部上拉使用此处以标准库实际配置为准 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_SET); // 使能内部上拉 // 3. 定时器时基配置72MHz / 72 1MHz即1us计数一次 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 4. 输入捕获配置捕获上升沿和下降沿 TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; // 先用上升沿下面会处理双边沿 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x02; // 输入滤波过滤窄毛刺 TIM_ICInit(TIM2, TIM_ICInitStructure); // 5. 开启捕获比较中断和定时器 TIM_ITConfig(TIM2, TIM_IT_CC1, ENABLE); TIM_Cmd(TIM2, ENABLE); // 6. NVIC配置 NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }这段配置里最需要理解的是第4步。我先配的是上升沿捕获实际在中断处理函数里每次进入中断后根据当前引脚电平或者上一次的边沿状态动态翻转捕获极性从而让上升沿和下降沿都能被捕获。这种做法在STM32做脉宽测量时很常见比直接配“双边沿触发”更可控。再来看中断处理函数这算是整个项目的灵魂。我用一个简单的状态机来解析脉冲序列。状态枚举大概是空闲等待引导码、等待引导码结束、接收32位数据、校验完成输出结果。#define IR_GUID_LOW_MAX 11000 #define IR_GUID_LOW_MIN 8000 #define IR_GUID_HIGH_MAX 5500 #define IR_GUID_HIGH_MIN 3500 #define IR_REPEAT_HIGH_MAX 3000 #define IR_REPEAT_HIGH_MIN 1800 #define IR_DATA_ZERO_MAX 900 #define IR_DATA_ZERO_MIN 400 #define IR_DATA_ONE_MAX 2200 #define IR_DATA_ONE_MIN 1300 typedef enum { IR_STATE_IDLE 0, IR_STATE_GUID, IR_STATE_DATA, IR_STATE_DONE } IR_State; static uint16_t s_last_capture 0; static uint16_t s_pulse_width 0; static uint32_t s_ir_data 0; static uint8_t s_bit_count 0; static uint8_t s_ir_key_value 0; static volatile uint8_t s_ir_data_ready 0; static IR_State s_state IR_STATE_IDLE; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); uint16_t current_capture TIM_GetCapture1(TIM2); s_pulse_width (current_capture s_last_capture) ? (current_capture - s_last_capture) : (0xFFFF - s_last_capture current_capture); s_last_capture current_capture; // 根据当前电平状态翻转捕获极性下次捕获另一条边沿 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) Bit_SET) { TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Falling); } else { TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Rising); } // 状态机 switch (s_state) { case IR_STATE_IDLE: // 检测到引导码低电平段9ms左右 if (s_pulse_width IR_GUID_LOW_MIN s_pulse_width IR_GUID_LOW_MAX) { s_state IR_STATE_GUID; } break; case IR_STATE_GUID: // 引导码高电平段4.5ms表示正常帧2.25ms表示重复码 if (s_pulse_width IR_GUID_HIGH_MIN s_pulse_width IR_GUID_HIGH_MAX) { s_bit_count 0; s_ir_data 0; s_state IR_STATE_DATA; } else if (s_pulse_width IR_REPEAT_HIGH_MIN s_pulse_width IR_REPEAT_HIGH_MAX) { // 重复码沿用上一次按键值或者置一个长按标志 s_state IR_STATE_IDLE; s_ir_data_ready 1; // 标记为一次有效按键可加长按判断 } else { s_state IR_STATE_IDLE; } break; case IR_STATE_DATA: // 进入数据位阶段利用高电平宽度区分0和1 if (s_pulse_width IR_DATA_ZERO_MIN s_pulse_width IR_DATA_ZERO_MAX) { // 0高电平约560us s_ir_data 1; s_bit_count; } else if (s_pulse_width IR_DATA_ONE_MIN s_pulse_width IR_DATA_ONE_MAX) { // 1高电平约1690us s_ir_data 1; s_ir_data | 1; s_bit_count; } else if (s_pulse_width IR_GUID_LOW_MIN s_pulse_width IR_GUID_LOW_MAX) { // 数据阶段又来了一个9ms引导码说明是重复帧直接丢弃当前帧 s_state IR_STATE_GUID; } else { // 宽度异常复位状态机 s_state IR_STATE_IDLE; } if (s_bit_count 32) { s_state IR_STATE_DONE; } break; case IR_STATE_DONE: { // 32位接收完毕拆包和校验 uint8_t addr s_ir_data 0xFF; uint8_t addr_inv (s_ir_data 8) 0xFF; uint8_t cmd (s_ir_data 16) 0xFF; uint8_t cmd_inv (s_ir_data 24) 0xFF; if ((addr (uint8_t)~addr_inv) (cmd (uint8_t)~cmd_inv)) { s_ir_key_value cmd; s_ir_data_ready 1; } s_state IR_STATE_IDLE; break; } default: s_state IR_STATE_IDLE; break; } } }这个状态机的设计思路是空闲时只盯9ms低电平引导码引导码后面的高电平宽度决定是正常帧还是重复码数据阶段逐个累积32位最后统一校验。这里有个容易被忽视的坑NEC发送数据是LSB在前所以移位方向是“左移”最高位反而是最后收到的位你在拆包时对应的字节顺序要跟硬件发送顺序对上否则地址和命令会整体颠倒。数据析取完成后主循环中就可以处理s_ir_data_ready标志和s_ir_key_value了。这一步业务逻辑很自由你可以映射到点灯、控制舵机、发串口指令等。我这里展示一个最简单的按键控制LED的例子。// 命令映射表具体的键值以你手头遥控器实测为准 void IR_HandleKey(uint8_t key_value) { switch (key_value) { case 0x45: // 某个按键对应的NEC命令码 GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_RESET); // 点亮LED break; case 0x46: GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_SET); // 熄灭LED break; default: break; } } int main(void) { SystemInit(); LED_GPIO_Init(); // 初始化PA1输出 IR_Init(); while (1) { if (s_ir_data_ready) { s_ir_data_ready 0; IR_HandleKey(s_ir_key_value); } // 其他业务代码放这里 } }3.3 活用逻辑分析仪和串口调试效率翻倍代码写完不要直接接上遥控器就完事。我强烈建议先干一件事用逻辑分析仪抓PA0的波形对着NEC协议标准时序核对一遍。你就知道你的遥控器到底是什么协议、每个脉冲宽度是不是符合预期。这一步能筛掉一大半“代码没问题但硬件信号就长这样”的疑问。如果没有逻辑分析仪也完全可以靠串口来调试。我在调试阶段会把每次捕获到的s_pulse_width通过串口打印出来前提是printf放在主循环别放在中断里然后按一下遥控器按键看打印的脉宽序列跟理论值对得上对不上。这个方法简陋但有效尤其是能看到异常帧到底是哪里开始歪掉的。4. 常见问题与排查技巧实录这个项目我前前后后给别人排查过不少次网络上也看到过大量类似提问。下面这些问题是最常遇到的我把排查思路整理成了一份速查表优先按出现频率排你照着排查基本能解决九成的问题。现象概率排查思路与操作完全解不到数据遥控器按了没反应高1. 用万用表/示波器测接收头OUT引脚空闲时应该接近VCC高电平按遥控器时应该有脉冲跳动。2. 确认接收头VCC接3.3V还是5V有的型号必须5V供电。3. 确认PA0是否被其他外设复用占用。偶尔解错键有时候按A出来是B中1. 排查中断里是否有printf、延时等耗时操作。2. 查看输入滤波是否过强导致窄脉冲丢失。3. 确认定时器溢出有没有处理如果两次捕获之间计数溢出重装宽度会算错。按键只能触发一次长按不生效低NEC长按发的是“重复码”不是重新发一遍完整帧。你的代码必须识别重复码并复现上一次的按键动作否则长按无效。地址码总是0xFF而不是0x00中很多遥控器的地址码不是0x00尤其学习型遥控器。你在校验之前先把收到的地址、命令原样打印出来看看实际情况不要用“想当然”的键值去套。上电后程序跑飞或卡死中检查NVIC优先级红外定时器中断优先级如果抢占了SysTick而你在中断里用了依赖SysTick的延时函数必卡死。另有一种典型情况Keil里开了优化状态机局部变量被优化出了bug建议Debug下先不开优化测试。除了表格里的这几点我再单独讲一个特别隐蔽、但中招率极高的坑定时器溢出处理。输入捕获计数到0xFFFF后会回绕到0如果你配置的ARR不是0xFFFF或者频率不是1MHz溢出时间就短两次捕获之间一旦跨越溢出点差值计算就是错的。我推荐的公式是s_pulse_width (current_capture s_last_capture) ? (current_capture - s_last_capture) : (0xFFFF - s_last_capture current_capture);这个公式可以正确处理单次溢出但如果是多次溢出你还是得配合溢出计数器来处理否则宽度会偏大很多。我自己在项目里为了省事直接把ARR设成0xFFFF再配合1MHz计数频率这样最长测量时间就是65.535msNEC正常帧也就70ms左右基本够用但还是建议你在中断里加一个溢出计数做彻底。另外还有一个跟“STM32 ST-LINK utility找不到芯片”相关的高频问题。很多人烧录程序后第二次再连ST-Link就报“No STM32 target found”原因往往是代码里把SWD引脚复用成普通IO了比如PA13/PA14被初始化成别的功能。解决办法按住复位键的同时点击连接在芯片复位瞬间擦除Flash或者把BOOT0拉高让芯片从系统存储器启动这时再连接下载器就能恢复。这个坑虽然不是红外程序特有的但我见过不少人在这个项目上第一次踩到它之后长期对调试器产生心理阴影提一下免得你慌。最后再补充一个关于一体化接收头的选型细节。VS1838B这类接收头分“有输出高电平有效”和“输出低电平有效”吗其实是同一个东西输出逻辑都是“无信号高、有信号低”但不同厂家的灵敏度、供电范围有差异最直观的差异是接收距离。我的建议是买封装正规、字迹清晰的别买那种拆机件或者散新件否则你可能花了半天调代码最后发现是接收头灵敏度太差距离稍微远一点就解不到。5. 扩展思路与我的几点实战心得做完基础版点灯这个项目能扩展的方向相当多。最常见的是把命令映射成继电器控制实现遥控开关插座、控制舵机模拟遥控云台、控制直流电机PWM调速等。你还可以把“解码 命令映射”做成一张表通过上位机或者串口动态配置这样一套代码就能适配不同遥控器不用每次改Key值。再进阶一点可以把红外解码和ESP8266/WiFi模块结合起来实现局域网内手机App联动遥控或者做一个“学习型红外遥控器”——通过接收并存储其他遥控器的编码再定时重放就能用STM32统一控制家里所有红外设备。这一套方案的难点就不在解码了而在“发射”环节你需要用定时器PWM产生38kHz载波同时在PWM引脚上按NEC时序调制高低电平思路与解码正好相反工程量不大但挺有意思。根据我个人的实操体会我最后想掏心窝子分享三个经验。第一核心解码逻辑千万别用延时函数去“猜时间”。占空比测试或者临时验证用SysTick延时轮询引脚能跑通但一上量比如按键按得快、系统里任务多了绝对出问题。正经做就用输入捕获一次配置到位后面省心。第二中断里能做的事越少越好。状态机的迁移和脉宽判断放中断里是必要的但一旦收到完整帧立刻置标志位退出所有业务逻辑放主循环。这个设计思路适用于几乎所有单片机项目程序员和工程师的区别往往就体现在这种地方。第三如果你用的是HAL库输入捕获的中断回调是HAL_TIM_IC_CaptureCallback配置方式跟标准库有很大区别但只要理解了“边沿捕获 脉宽计算”的本质迁移起来绝不费劲。不要被库函数封装吓住底层原理通了换什么库都一样。做这个项目最让我上头的瞬间是第一次用逻辑分析仪抓出完美匹配NEC协议的波形时的那种踏实感。希望你看完这篇文章也能亲手体验一把。下一次等你的朋友拿着一把不知名遥控器问你“能不能帮我识别一下键值”的时候你就可以微微一笑掏出STM32开发板三分钟给他把键值表列出来——这就是做这个项目最实在的成就感。本文还有配套的精品资源点击获取