1. 从“万能遥控器”到嵌入式感知:红外遥控的工程价值
你手边是不是总有几个闲置的遥控器?电视的、空调的、机顶盒的,它们躺在抽屉里,除了偶尔按一下,似乎没什么存在感。但如果你手里有一块STM32开发板,这些不起眼的红外遥控器,瞬间就能变成一个绝佳的“输入设备”和“通信模块”学习入口。红外遥控,这个听起来有点“古老”的技术,恰恰是嵌入式开发中一个极佳的练手项目。它麻雀虽小,五脏俱全:涉及GPIO输入捕获、外部中断、定时器精准计时、协议解码、状态机设计,甚至还能延伸到简单的无线通信概念。通过解码一个遥控器,你不仅能控制一个LED的亮灭,更能理解一套完整的、从物理信号到逻辑指令的数据链路是如何被打通和解析的。这远比单纯点个灯、读个温度传感器要来得系统。今天,我们就以STM32为平台,彻底拆解红外遥控,从硬件连接到协议分析,再到代码实现与调试,手把手带你把这个“黑盒子”变成你项目里听话的“遥控指挥官”。
2. 红外遥控系统的硬件拼图:不只是接收头那么简单
很多人拿到红外接收头(比如常见的HS0038、VS1838),直接往板子上一插,就开始写代码,结果发现要么收不到数据,要么数据全是乱的。问题往往出在第一步——对硬件系统的理解不够完整。一个完整的红外遥控系统,是发射端与接收端的协同。
2.1 发射端:遥控器的“语言”编码
我们手里的遥控器,其核心是一个红外发射二极管。当你按下按键时,遥控器内部的编码芯片(通常是定制ASIC)会生成一组特定的数字脉冲序列,并用一个约38kHz的载波进行调制。为什么要调制?目的是为了提高抗干扰能力。环境中有大量的红外噪声(如日光灯、白炽灯),如果不加调制,接收端很难从噪声中分辨出有效信号。38kHz的载波就像给数据穿上了“制服”,接收头只认这个频率的信号。
常见的编码协议有NEC、RC-5、RC-6、Sony SIRC等。其中,NEC协议因其简单和普及,成为我们学习的首选。它的一个完整帧包括:引导码、用户码、用户反码、按键码、按键反码。反码用于校验,提高可靠性。引导码是一个9ms的低电平和4.5ms的高电平,用来告诉接收头:“注意,数据要来了!”
2.2 接收端:HS0038的“翻译”工作
红外接收头(如HS0038)是一个集成化的模块,它内部包含了光电二极管、前置放大器、带通滤波器和解调电路。它的任务很明确:只接收中心频率为38kHz的红外信号,并将其解调,还原出发射端送出的原始数字脉冲序列(即基带信号),然后通过OUT引脚输出给MCU。
这里有几个关键硬件连接细节:
- 供电与接地:务必稳定。VCC通常接3.3V或5V(查看数据手册),GND必须可靠接地。电源噪声会导致接收异常。
- OUT引脚连接:连接到STM32的任何一个GPIO口,但强烈建议选择具有外部中断和定时器输入捕获功能的引脚。因为解码的核心是测量脉冲宽度,用中断+定时器是最精准高效的方式。
- 上拉电阻:大部分接收头OUT引脚内部已有上拉,但为了稳定,可以在外部再接一个4.7kΩ - 10kΩ的上拉电阻到VCC,确保空闲时为高电平。
- 布局与滤波:接收头应远离MCU的晶振、数字开关电源等噪声源。在VCC和GND引脚附近,并联一个10uF的电解电容和一个0.1uF的瓷片电容进行滤波,能极大改善接收稳定性。
注意:接收头输出的信号是反向的。即当收到38kHz载波时,OUT输出低电平;无载波时,输出高电平。所以我们在示波器上看到的,是一个低电平有效的信号。这一点在解码逻辑中至关重要。
3. NEC协议解码的核心:定时器与状态机的精准舞蹈
理解了硬件信号,接下来就是如何用STM32的软件“听懂”这种语言。核心武器是定时器(TIM)和有限状态机(FSM)。
3.1 定时器配置:捕获时间的尺子
我们需要一把高精度的“尺子”来测量每一个脉冲(高电平或低电平)的宽度。STM32的通用定时器(如TIM2, TIM3, TIM4)的输入捕获功能完美胜任。
配置步骤如下:
- 定时器时钟初始化:将定时器时钟源设为内部时钟,预分频系数(PSC)设置为(系统时钟频率 / 1MHz - 1)。这样,计数器每计数一次代表1微秒(us),方便我们直接读取时间值。例如,72MHz系统时钟,PSC设为71。
- 自动重装载值(ARR):设为最大值(如0xFFFF),因为我们只关心脉冲间的相对时间差。
- 输入捕获通道配置:将连接接收头OUT的GPIO引脚配置为对应定时器的输入捕获通道(如TIMx_CHx)。模式设置为“上升沿和下降沿捕获”,这样任何一个边沿变化都会触发中断。
- 中断使能:开启定时器的捕获/比较中断(TIM_IT_CCx)和更新中断(可选)。
3.2 状态机设计:解码的逻辑骨架
NEC协议的数据帧是串行的,我们必须按顺序识别出引导码、用户码、数据码。一个清晰的状态机让代码逻辑一目了然。
我们可以定义以下几个状态:
typedef enum { IR_IDLE, // 空闲状态,等待引导码 IR_LEADER_CODE, // 已检测到引导码起始沿,等待引导码结束 IR_DATA, // 正在接收数据位 IR_REPEAT, // 重复码检测(长按按键) IR_FINISHED // 一帧数据接收完成 } IR_State_t;解码流程的状态迁移如下:
- IR_IDLE:持续监测。当捕获到下降沿(因为信号反向,物理引导码起始9ms低电平对应OUT上升沿,但我们通常监测下降沿更简单)时,记录当前定时器计数T1。这是一个可能的引导码开始。
- IR_LEADER_CODE:在中断中,等待下一个上升沿(引导码结束)。记录时间T2。计算脉宽(T2 - T1)。如果脉宽在8.5ms ~ 9.5ms(低电平)和4.0ms ~ 5.0ms(随后的高电平)范围内,则确认是引导码,状态跳转到IR_DATA,并初始化数据位索引和存储变量。
- IR_DATA:此后,每一个下降沿代表一位数据的开始。记录时间T_start。等待下一个上升沿,记录时间T_end。计算高电平脉宽(T_end - T_start)。
- 如果脉宽约为560us,则判定为逻辑‘0’。
- 如果脉宽约为1.69ms,则判定为逻辑‘1’。
- 将判断出的位数据存入缓冲区。接收完32位(或16位,根据协议变种)后,状态跳转到IR_FINISHED。
- IR_FINISHED:进行校验(用户码与用户反码是否互补,按键码与按键反码是否互补)。校验通过,则解码成功,将有效的用户码和按键码存入全局变量,供主循环查询。状态重置为IR_IDLE。
- IR_REPEAT:NEC协议中,长按按键时,发送完一帧完整数据后,会每隔约110ms发送一个特殊的重复码(9ms低电平+2.25ms高电平+560us低电平)。我们需要单独识别这个模式,以实现长按连续触发功能。
实操心得:定时器的计数器是循环计数的(溢出后从0开始)。在计算时间差时,必须考虑计数器溢出的情况。使用
(uint16_t)(T_end - T_start)的方式计算,即使发生一次溢出,只要两次捕获间隔小于计数器周期,结果仍是正确的。这是嵌入式时间测量中的经典技巧。
4. 代码实现与调试:从逻辑到现实的跨越
理论清晰后,我们用代码将其实现。这里以STM32 HAL库为例,展示核心代码片段和调试技巧。
4.1 GPIO与定时器初始化
首先,进行硬件初始化。
// infrared.c void IR_Receiver_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; TIM_HandleTypeDef htimx; TIM_IC_InitTypeDef sConfigIC = {0}; // 1. 使能时钟 __HAL_RCC_GPIOx_CLK_ENABLE(); __HAL_RCC_TIMx_CLK_ENABLE(); // 2. 配置GPIO为复用功能,上拉 GPIO_InitStruct.Pin = IR_IN_PIN; GPIO_InitStruct.Mode = GPIO_MODE_AF_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(IR_IN_PORT, &GPIO_InitStruct); // 3. 配置定时器基础:1us计数 htimx.Instance = TIMx; htimx.Init.Prescaler = 71; // 72MHz / (71+1) = 1MHz htimx.Init.CounterMode = TIM_COUNTERMODE_UP; htimx.Init.Period = 0xFFFF; htimx.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_IC_Init(&htimx); // 4. 配置输入捕获通道:双边沿触发 sConfigIC.ICPolarity = TIM_ICPOLARITY_BOTHEDGE; sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; sConfigIC.ICFilter = 0x00; // 不滤波,或根据噪声情况设置 HAL_TIM_IC_ConfigChannel(&htimx, &sConfigIC, TIM_CHANNEL_x); // 5. 启动捕获并开启中断 HAL_TIM_IC_Start_IT(&htimx, TIM_CHANNEL_x); HAL_NVIC_SetPriority(TIMx_IRQn, 0, 0); HAL_NVIC_EnableIRQ(TIMx_IRQn); }4.2 中断服务程序与状态机实现
这是解码的核心,在定时器的捕获中断回调函数中实现状态机。
// infrared.c volatile IR_State_t ir_state = IR_IDLE; volatile uint32_t ir_raw_data = 0; volatile uint8_t ir_data_bit_idx = 0; volatile uint8_t ir_repeat_flag = 0; volatile uint16_t ir_last_capture = 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint16_t curr_capture = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_x); uint16_t pulse_width; // 计算脉冲宽度,处理计数器溢出 pulse_width = (curr_capture >= ir_last_capture) ? (curr_capture - ir_last_capture) : (0xFFFF - ir_last_capture + curr_capture + 1); switch (ir_state) { case IR_IDLE: // 检测到下降沿(可能是引导码或重复码开始) if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CCx) != RESET) { if (pulse_width > 8000 && pulse_width < 10000) { // ~9ms 低电平 ir_state = IR_LEADER_CODE; } else if (pulse_width > 2000 && pulse_width < 2500) { // ~2.25ms 重复码高电平 ir_state = IR_REPEAT; } } break; case IR_LEADER_CODE: // 检测到上升沿(引导码结束) if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CCx) != RESET) { if (pulse_width > 4000 && pulse_width < 5000) { // ~4.5ms 高电平 // 引导码确认,准备接收数据 ir_state = IR_DATA; ir_raw_data = 0; ir_data_bit_idx = 0; } else { // 不是合法的引导码,回到空闲 ir_state = IR_IDLE; } } break; case IR_DATA: // 每一位数据:下降沿是开始,上升沿是结束并判断逻辑值 if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CCx) != RESET) { // 上升沿,位结束 if (pulse_width > 400 && pulse_width < 700) { // 逻辑‘0’ // 不左移,因为先收到的是LSB } else if (pulse_width > 1500 && pulse_width < 1800) { // 逻辑‘1’ ir_raw_data |= (1UL << ir_data_bit_idx); } else { // 脉宽异常,数据错误,重置 ir_state = IR_IDLE; break; } ir_data_bit_idx++; if (ir_data_bit_idx >= 32) { // NEC标准为32位 ir_state = IR_FINISHED; } } // 下降沿在IDLE或LEADER状态已处理,此处只需记录时间,逻辑在上升沿判断 break; case IR_REPEAT: // 重复码处理:检测到后续的560us低电平后,置位重复标志 if (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_CCx) != RESET) { if (pulse_width > 400 && pulse_width < 700) { ir_repeat_flag = 1; } ir_state = IR_IDLE; } break; case IR_FINISHED: // 一帧接收完成,状态机在主循环中处理校验和状态重置 break; default: ir_state = IR_IDLE; break; } ir_last_capture = curr_capture; __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_CCx); }4.3 主循环中的数据处理
中断只负责收集原始脉冲宽度和状态迁移,繁重的校验和数据处理放在主循环,避免中断阻塞。
// main.c uint8_t ir_user_code = 0; uint8_t ir_key_code = 0; while (1) { if (ir_state == IR_FINISHED) { // 提取数据:假设raw_data低32位为接收到的数据 uint8_t user_code = (ir_raw_data >> 24) & 0xFF; uint8_t user_code_inv = (ir_raw_data >> 16) & 0xFF; uint8_t key_code = (ir_raw_data >> 8) & 0xFF; uint8_t key_code_inv = ir_raw_data & 0xFF; // NEC协议校验:原码与反码按位取反后应相等 if (((uint8_t)(~user_code_inv) == user_code) && ((uint8_t)(~key_code_inv) == key_code)) { ir_user_code = user_code; ir_key_code = key_code; printf("解码成功!用户码: 0x%02X, 按键码: 0x%02X\n", user_code, key_code); // 根据按键码执行相应操作 switch (key_code) { case 0x45: HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); break; // 示例:按键1控制LED // ... 其他按键处理 default: break; } } else { printf("校验失败!\n"); } // 处理完成,状态重置 ir_state = IR_IDLE; } if (ir_repeat_flag) { printf("重复键按下\n"); // 执行重复按键动作,如持续增加/减少数值 ir_repeat_flag = 0; } // ... 其他任务 }5. 实战调试与排坑指南:让信号无处可逃
代码写好了,但很可能第一次上电什么都没收到。别急,按以下步骤系统化排查。
5.1 硬件诊断:确保物理链路畅通
- 供电检查:用万用表测量接收头VCC和GND之间的电压,确保在额定范围内(如5V±0.25V)。电压不稳是头号杀手。
- 信号探测:最直观的方法是用示波器。将探头接在接收头的OUT引脚和GND之间。
- 无按键时:应该是一条稳定的高电平线(约VCC电压),可能有微小毛刺。
- 按下遥控器按键:你应该能看到一串清晰的、高低变化的脉冲波形。如果看不到,可能是遥控器没电、接收头损坏、或接收头方向不对(红外接收有方向性)。
- 观察引导码:放大时间轴,你应该能清晰看到约9ms的低电平和4.5ms的高电平。这是协议解码的“锚点”。
5.2 软件逻辑调试:从波形到数据
如果硬件波形正常,但代码解不出数据,问题就在软件。
- 打印脉冲宽度:在中断回调函数最开始,将计算出的
pulse_width通过串口打印出来。按下按键,观察输出。你应该能看到一系列有规律的数字:大约9000,4500,560,560,1690... 对应引导码和后续的数据位。如果数字杂乱无章或明显不对,说明定时器配置(预分频PSC)有误,或者中断处理中时间差计算没考虑溢出。 - 状态机跟踪:在状态机每个
case的入口和出口,打印状态变量。观察状态迁移是否符合预期:IDLE -> LEADER_CODE -> DATA -> FINISHED。如果卡在某个状态,说明该状态的判断条件(脉宽范围)设置得太严格或太宽松,需要调整阈值。 - 阈值微调:协议中的9ms、4.5ms、560us、1690us是理论值。实际中,不同遥控器、不同接收头、环境干扰都会造成偏差。你需要根据打印出的实际脉宽,适当放宽判断条件。例如,引导码低电平判断可以设为
if (pulse_width > 8000 && pulse_width < 10000)。 - 抗干扰处理:环境光干扰可能导致误触发。可以采取两种措施:
- 硬件滤波:增加接收头前的遮光罩(如热缩管),或调整接收头方向避开强光直射。
- 软件滤波:在GPIO初始化时,启用内部弱上拉/下拉。或者在中断中,对于极短(<200us)的脉冲直接忽略,这很可能是噪声。
5.3 进阶优化与功能扩展
当基础解码稳定后,可以考虑以下优化:
- 低功耗设计:如果设备是电池供电,可以让红外解码部分在大部分时间休眠,仅由接收头OUT引脚的电平变化触发外部中断来唤醒MCU。在中断服务例程中快速完成解码后,再次进入休眠。
- 多协议兼容:你的代码目前只针对NEC协议。可以抽象出一个协议层,定义统一的解码接口。然后为RC-5、Sony等协议实现不同的解码器,根据引导码特征自动选择协议。
- 红外发射学习:让STM32不仅会收,还会发。利用一个红外发射管和另一个定时器(PWM模式),生成38kHz载波,并按照NEC协议格式调制数据发出。这样你就可以制作一个“学习型遥控器”或实现设备间的红外通信。
- 应用集成:将解码出的按键码映射为具体的功能命令。例如,结合OLED屏幕做一个红外遥控菜单系统,或者用遥控器控制一个步进电机、舵机云台。这能将学习成果快速转化为有趣的项目。
红外遥控解码是一个典型的“信号采集-协议解析-应用响应”嵌入式系统案例。它涉及硬件接口、定时器精确定时、中断服务、状态机软件设计等多个核心知识点。通过这个项目,你收获的不仅仅是如何控制一个设备,更是一套处理异步串行数字通信的通用方法论。下次当你再拿起电视遥控器,你看到的将不再是一个黑盒子,而是一串在空气中飞舞的、由精密定时脉冲构成的数字指令集。