单片机红外遥控解码:从外部中断到NEC协议解析的完整实现

📅 2026/7/30 5:10:43
单片机红外遥控解码:从外部中断到NEC协议解析的完整实现
1. 项目概述从“按一下”到“执行命令”的跨越玩单片机的朋友估计都绕不开红外遥控这个经典项目。它太常见了从家里的空调、电视到一些玩具小车、智能灯几乎无处不在。但很多新手朋友在接触时往往会被一堆概念搞晕外部中断、NEC编码、定时器捕获、38kHz载波……听起来很复杂感觉要配置一大堆寄存器才能让单片机“看懂”遥控器按了什么键。其实这个项目的核心逻辑非常清晰把一个物理世界的时间信号通过一系列标准化的“翻译”过程变成单片机内部一个确切的数字键值。我这次的学习笔记就是聚焦于使用“外部中断”这个最直观的方式来入门红外解码。为什么用外部中断因为它最符合我们的直觉——遥控器按下产生一个下降沿脉冲就像按门铃一样单片机立刻被“打断”去处理这个事件响应实时性最好代码结构也清晰。虽然更高效、更专业的做法可能是用定时器的输入捕获功能但外部中断无疑是理解整个红外通信流程的最佳起点。这个项目适合所有已经点亮过LED、操作过按键的51或STM32单片机初学者。通过它你不仅能学会如何驱动一个红外接收头更能深入理解外部中断的应用、定时器作为精密“秒表”的用法以及如何解析一种常见的通信协议。你会发现原来协议解码并不神秘就是按照既定的“规则手册”如NEC编码格式去测量和判断一系列高电平、低电平的时间长度而已。下面我就把自己从硬件连接到软件解码再到最后成功打印出键值的完整过程、踩过的坑和总结的技巧详细记录下来。2. 红外遥控系统核心原理拆解在动手写代码之前我们必须先把红外遥控是怎么一回事彻底搞明白。这就像打仗前要看懂地图一样否则代码里的每一个判断都会让你迷茫。2.1 红外通信的本质光脉冲编码红外遥控并非直接发送我们理解的“0”和“1”数字信号。它采用的是幅度调制ASK。发送端遥控器内部有一个红外发光二极管它会以固定的频率通常是38kHz闪烁。这个38kHz就是“载波”。当我们按下按键时遥控器芯片会根据协议生成一串特定的二进制编码用这串编码去控制38kHz载波的“有”和“无”。有载波的时候红外二极管高强度闪烁代表一种状态通常是逻辑0无载波的时候红外二极管不发光代表另一种状态通常是逻辑1。接收端我们用的那个黑色或透明的小接收头如HS0038、VS1838内部集成了光电二极管、放大器、带通滤波器和解调电路。它的任务非常专一只检测38kHz附近的光信号。当它检测到38kHz的闪烁光时就输出低电平检测不到时就输出高电平。这样一来接收头输出的信号就已经是把38kHz载波“过滤”掉的解调后的数字波形了。我们单片机要处理的正是这个波形。注意接收头输出的电平逻辑是反相的。即收到38kHz红外光对应发送端“有载波”时输出低电平空闲时输出高电平。这一点对理解波形至关重要。2.2 NEC协议最流行的“语言规则”红外遥控协议有多种就像人类有不同的语言。NEC协议因其简单、可靠而成为最广泛使用的一种。我们的学习就以它为标准。一个完整的NEC协议码由以下几部分组成引导码一个9ms的低电平紧接着一个4.5ms的高电平。这相当于一次通信的“开场白”告诉接收方“注意我要开始发数据了”接收头会在引导码开始时输出9ms低电平然后输出4.5ms高电平。用户码16位用于区分不同的设备厂家。比如你的电视遥控器和空调遥控器的用户码就不同防止误操作。用户反码16位是用户码的按位取反用于校验用户码是否正确。数据码8位即具体的按键值。数据反码8位是数据码的按位取反用于校验数据码。数据“0”和“1”的表示逻辑0560us的低电平 560us的高电平。逻辑1560us的低电平 1690us的高电平。发现规律了吗区分0和1的关键在于高电平的持续时间。低电平时间都是560us左右。所以解码的核心任务就是精确测量每一个“高电平脉冲”的宽度。2.3 外部中断与定时器的角色分工理解了协议我们就要选择工具来实现解码。这里用到两个核心外设外部中断担任“哨兵”角色。我们将红外接收头的输出引脚连接到单片机的外部中断引脚如51单片机的INT0/INT1STM32的EXTI线。并配置为下降沿触发。为什么是下降沿因为接收头输出常态为高电平当引导码开始的9ms低电平到来时会产生一个下降沿。这个下降沿立刻触发中断通知单片机“有信号来了快开始记录”定时器担任“高精度秒表”角色。在外部中断触发后我们启动一个定时器如51的Timer0STM32的TIMx将其配置为每计数一次代表一个固定的微秒时间例如12MHz晶振的51单片机配置定时器每50us中断一次那么计数值乘以50就是时间。然后在外部中断引脚的电平变化期间通过轮询或外部中断的上升沿不断读取这个“秒表”的数值从而测量出每一个高电平或低电平持续了多长时间。分工流程下降沿外部中断触发表示信号开始。在中断服务函数里启动定时器“秒表”清零并开始计时。随后通过不断检测引脚电平并记录定时器值依次测量出引导码低电平时间应~9ms引导码高电平时间应~4.5ms后续每一位数据的高低电平时间从而判断是0还是1。3. 硬件连接与软件框架设计3.1 最小系统搭建硬件非常简单你需要单片机最小系统板如STC89C52、STM32F103C8T6核心板红外接收头如HS0038常见三脚VCC、GND、OUT红外遥控器任意常见的是NEC协议杜邦线若干连接方式接收头VCC - 单片机VCC5V或3.3V视接收头型号而定接收头GND - 单片机GND接收头OUT - 单片机的外部中断引脚如P3^2对应INT0P3^3对应INT1为了验证解码结果你还需要一个串口模块将解码出的键值打印到电脑串口助手显示。3.2 软件解码思路与状态机直接写代码容易混乱我们需要一个清晰的解码思路。我推荐使用状态机的编程思想它能让逻辑变得非常清晰。解码过程可以划分为几个状态空闲状态等待引导码下降沿。引导码验证状态收到下降沿后测量接下来的9ms低电平和4.5ms高电平。如果时间在允许误差范围内则进入数据接收状态否则复位状态机。数据接收状态循环32次用户码16用户反码16数据码8数据反码8每次测量一位数据的高低电平时间判断是0还是1并存入数组。解码完成状态数据接收完毕进行校验用户反码与用户码取反对比数据反码与数据码取反对比。校验通过则解码成功输出键值校验失败则丢弃本次数据。在代码中我们可以用一个变量如ir_state来记录当前状态在主循环或中断服务函数中根据状态执行不同的操作。3.3 核心代码模块规划基于以上思路我们可以规划出几个核心函数初始化函数IR_Init()配置外部中断下降沿触发、定时器用于计时、以及必要的全局变量。外部中断服务函数IR_ISR_Handler()在这里处理下降沿事件改变状态机状态启动/停止/读取定时器。定时器中断服务函数TIMx_IRQHandler()可选如果使用定时器溢出中断来累加时间基准则在此函数中累加一个时间戳变量。更常用的方法是将定时器配置为自由运行模式不中断只在需要时读取其计数寄存器值来计算时间差这样更精确不占用中断资源。数据解析函数IR_Decode()根据状态机在适当的时候如电平变化时调用执行测量和判断逻辑。主函数main()初始化后在一个循环中不断检查是否有解码成功标志成功则通过串口发送键值。4. 基于51单片机的详细实现步骤我们以经典的STC89C52工作频率11.0592MHz为例使用外部中断0INT0P3.2引脚和定时器0来实现。4.1 初始化配置#include reg52.h #include intrins.h // 定义红外接收引脚 sbit IR_IN P3^2; // 定义全局变量 unsigned char ir_buf[4] {0}; // 存放4字节数据用户码高8位、低8位、数据码、数据反码 bit ir_flag 0; // 解码成功标志位 unsigned int ir_time 0; // 用于记录时间的高位定时器0溢出次数 unsigned char ir_state 0; // 状态机状态0-空闲1-收到引导码下降沿2-接收数据中... void IR_Init(void) { // 1. 配置外部中断0 IT0 1; // 设置INT0为下降沿触发 EX0 1; // 使能INT0中断 // 2. 配置定时器0为16位定时模式不中断仅作计数器用 TMOD 0xF0; // 清零T0的控制位 TMOD | 0x01; // T0设为模式116位定时器 TR0 0; // 先不启动 TF0 0; // 清除溢出标志 ET0 0; // 禁止T0中断我们采用查询方式或读取TH0/TL0 // 3. 初始化变量 ir_flag 0; ir_state 0; // 4. 开启总中断 EA 1; }这里的关键点定时器0不开启中断。我们将用它作为一个精准的“秒表”。当需要开始计时时将TR0置1并清零TH0和TL0当需要读取时间时直接读取TH0和TL0的值结合可能的溢出次数ir_time来计算总时间。这种方式比在定时器中断里累加计数器更直接误差更小。4.2 外部中断服务函数与状态机实现这是解码的核心逻辑所在。void Int0_ISR(void) interrupt 0 { unsigned int time_cnt 0; static unsigned char i 0; // 数据位计数器 static unsigned char j 0; // 字节内位数计数器 // 读取当前定时器值计算从上次电平变化到现在的间隔 // 注意进入中断时引脚已经是低电平下降沿 time_cnt (ir_time * 65536) (TH0 8) TL0; // 计算总计时值 switch(ir_state) { case 0: // 空闲状态等待引导码下降沿 // 启动定时器“秒表” TH0 0; TL0 0; ir_time 0; TR0 1; // 开始计时 ir_state 1; // 进入状态1准备测量引导码低电平 i 0; j 0; ir_buf[0] 0; ir_buf[1] 0; ir_buf[2] 0; ir_buf[3] 0; break; case 1: // 已收到下降沿正在测量引导码低电平 // 此时time_cnt是引导码低电平的持续时间 if((time_cnt 8500) (time_cnt 9500)) // 大约9ms留出误差范围 { // 低电平时间符合清零定时器准备测量高电平 TH0 0; TL0 0; ir_time 0; ir_state 2; // 进入状态2测量引导码高电平 } else { // 时间不对不是有效的引导码复位状态 TR0 0; ir_state 0; } break; case 2: // 正在测量引导码高电平 if((time_cnt 4000) (time_cnt 5000)) // 大约4.5ms { // 引导码完全正确进入数据接收状态 TH0 0; TL0 0; ir_time 0; ir_state 3; // 进入状态3开始接收数据位 } else { TR0 0; ir_state 0; } break; case 3: // 正在接收数据位32位 // 每次进入此状态都是因为引脚发生了下降沿即一位数据的开始 // 我们需要根据前一个高电平的持续时间来判断是0还是1 // 但注意第一次进入case 3时time_cnt测量的是第一个数据位开始前的低电平560us // 我们需要先记录这个低电平时间然后等待下一个下降沿即下一位的开始来判断前一位。 // 这需要更复杂的状态设计。一个更清晰的方法是在状态3里我们只处理下降沿并启动测量。 // 而判断0/1的逻辑放在主循环中通过轮询引脚电平并配合定时器来实现。 // 为了简化中断服务程序我们采用另一种常见方法在中断里只记录时间点在主循环解码。 // 鉴于篇幅和清晰度这里展示一种在中断内完成的简化逻辑实际可能因单片机速度不够而丢失数据 // 本示例暂停在此更稳健的方法见下一小节。 TR0 0; ir_state 0; // 简化处理直接复位 break; } }上面的代码在case 3处遇到了难题。在低速的51单片机如12T模式上在中断函数内完成32位数据的逐位判断可能时间太紧容易丢失后续的中断。因此更稳健、更通用的方法是“中断记录边沿时间主循环解码”。4.3 优化方案中断记录时间戳主循环解码我们修改思路外部中断下降沿和上升沿都触发或只下降沿但配合查询用于记录信号边沿发生的时刻。主循环中根据记录的时间间隔判断电平宽度进而解码。首先修改初始化使能上升沿和下降沿触发如果单片机支持或者只使用下降沿但在中断中读取引脚电平判断当前是上升沿还是下降沿。这里以STC89C52为例它只支持下降沿或低电平触发。我们采用下降沿触发并在中断中通过查询引脚电平来判断状态。我们需要一个缓冲区来存储时间戳。#define IR_DATA_BUF_SIZE 64 unsigned int ir_time_stamp[IR_DATA_BUF_SIZE]; unsigned char ir_edge_index 0; // 时间戳索引 bit ir_receiving 0; // 正在接收标志 void Int0_ISR(void) interrupt 0 { unsigned int current_time; // 获取当前定时器的总计时值单位取决于定时器配置假设为us current_time (ir_time * 65536) (TH0 8) TL0; if(ir_receiving 0) { // 如果之前不在接收状态说明可能是引导码开始 // 检查当前引脚是否为低电平下降沿触发时进入中断后引脚是低 if(IR_IN 0) { // 重新启动定时器 TH0 0; TL0 0; ir_time 0; TR0 1; ir_receiving 1; ir_edge_index 0; ir_time_stamp[ir_edge_index] current_time; // 记录第一个下降沿时间其实是0 } } else { // 正在接收中记录每一个下降沿的时间点 if(ir_edge_index IR_DATA_BUF_SIZE) { ir_time_stamp[ir_edge_index] current_time; } else { // 缓冲区溢出复位 ir_receiving 0; TR0 0; } } }然后在主循环中我们定期检查ir_receiving标志和ir_edge_index。当ir_receiving变为0可能因为接收结束或超时或者ir_edge_index达到一定数量我们就开始处理ir_time_stamp数组。主循环解码逻辑伪代码计算相邻两个时间戳的差值得到电平持续时间。第一个差值stamp[1]-stamp[0]应该是引导码低电平~9ms。第二个差值stamp[2]-stamp[1]应该是引导码高电平~4.5ms。从第三个差值开始两两一组第一个差值是560us的低电平第二个差值是高电平时间用于判断数据位560us为01690us为1。循环接收32个数据位。进行校验。这种方法将耗时的解码过程移出了中断服务函数保证了系统的实时性是更可靠的工程实现。4.4 定时器0溢出处理由于我们使用16位定时器最大计数值65535。在11.0592MHz下即使定时器时钟为1/12机器周期约1.085us计满一次也需要约71ms。对于测量9ms和4.5ms绰绰有余但为了代码健壮性我们仍需处理溢出。void Timer0_ISR(void) interrupt 1 { ir_time; // 定时器0溢出次数加1 // 注意定时器0的中断需要被使能ET01才会进入此函数 // 如果采用查询方式则不需要此中断但需要在主循环中检查TF0并累加ir_time。 }如果采用“主循环解码”方案且测量时间不会超过65ms可以不处理溢出或者使用定时器自动重装模式模式2来获得更精确的短时间测量。5. 关键参数测量与调试技巧红外解码的成败关键在于时间测量的准确性。以下是几个核心参数和调试方法5.1 时间容错范围的设定在代码中我们判断时间是否在预期范围内。这个范围不能设得太死因为不同遥控器、不同接收头、单片机时钟误差都会导致时间有微小偏差。引导码9ms低电平 4.5ms高电平。容错范围可以设为 ±0.5ms 到 ±1ms。例如低电平 (8ms ~ 10ms)高电平 (4ms ~ 5ms)。数据位560us低电平是固定的。高电平560us0或1690us1。容错范围可以设为 ±100us ~ 150us。例如判断逻辑1的高电平时间 1500us逻辑0的高电平时间 1000us中间值视为错误。5.2 使用逻辑分析仪或示波器抓取波形这是最直接的调试手段。将逻辑分析仪的探头连接到红外接收头的OUT引脚和地线。按下遥控器按键。观察捕获到的波形。你应该能看到清晰的引导码和后续的数据脉冲。测量引导码的低电平、高电平时间以及数据位0和1的高电平时间用实测值来校准你代码中的判断阈值。同时可以观察单片机解码时输出的调试信号比如用一个IO口在解码成功后翻转与红外波形对齐看解码逻辑是否在正确的时间点触发。实操心得没有逻辑分析仪怎么办可以用单片机的串口打印原始时间差值。在中断中每记录一个时间戳就通过串口发送出来注意要用高速串口并做好格式化防止丢数据。然后在电脑上整理这些时间数据手工绘制或分析波形同样能定位问题。5.3 处理连发码NEC协议支持按键连发。当按住遥控器不放时在发送完一个完整码之后会每隔约110ms发送一个特殊的“重复码”。重复码由9ms低电平和2.25ms高电平再加一个560us的低电平结束脉冲构成。 在解码程序中需要增加对重复码的识别。如果检测到9ms低电平2.25ms高电平的组合就认为是一次重复可以重复上一次有效的按键值而不是重新解码32位数据。5.4 抗干扰与软件去抖红外接收容易受到环境光特别是日光灯、太阳光的干扰可能产生误触发。硬件上尽量让接收头远离强光直射有些接收头自带屏蔽罩效果更好。软件上除了严格的时间校验还可以增加“连续成功解码次数”的判断。例如要求连续2次解码出的键值完全相同才认为是有效按键否则丢弃。这能滤除大部分偶然干扰。6. 移植到STM32HAL库的要点将上述51的思维移植到STM32上会更容易因为STM32的外设功能更强大特别是其定时器直接支持输入捕获功能更适合做红外解码。但用外部中断EXTI的方式原理相通。6.1 使用EXTI和基本定时器配置引脚和EXTI将红外接收头OUT引脚连接的GPIO如PA0配置为外部中断模式下降沿触发。配置一个基本定时器如TIM6/TIM7将其时钟源设为内部时钟预分频器设置到产生1us或10us的计数周期。这个定时器不产生中断只作为自由运行的计数器。在EXTI中断服务函数中读取定时器计数器的当前值__HAL_TIM_GET_COUNTER(htimx)作为时间戳存入数组。如果是第一个下降沿引导码开始则清零定时器计数器__HAL_TIM_SET_COUNTER(htimx, 0)。主循环解码与51方案类似处理时间戳数组。6.2 更优方案使用定时器输入捕获模式推荐这是STM32解码红外最专业的方式。以通用定时器如TIM2的一个通道为例配置定时器向上计数预分频到1MHz1us计数一次自动重装载值设为最大值0xFFFF。配置通道为输入捕获模式捕获边沿设为上升沿和下降沿。开启捕获中断。在捕获中断回调函数中检查是上升沿还是下降沿触发。读取捕获比较寄存器CCR的值这个值就是边沿发生时定时器的计数值。计算本次边沿与上一次边沿的时间差注意处理定时器溢出。根据时间差和当前电平直接进行协议解码判断引导码、数据0/1。解码完成后通过串口输出键值。这种方式硬件自动记录时间精度高CPU占用率极低。6.3 STM32代码示例HAL库EXTI方式// 全局变量 volatile uint32_t ir_timestamps[64]; volatile uint8_t ir_ts_index 0; volatile uint8_t ir_recv_active 0; TIM_HandleTypeDef htim2; // 假设使用TIM2作为自由计时器 // EXTI中断回调 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if(GPIO_Pin GPIO_PIN_0) { uint32_t current_tick __HAL_TIM_GET_COUNTER(htim2); if(!ir_recv_active) { if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { __HAL_TIM_SET_COUNTER(htim2, 0); ir_recv_active 1; ir_ts_index 0; ir_timestamps[ir_ts_index] 0; } } else { if(ir_ts_index 64) { ir_timestamps[ir_ts_index] current_tick; } else { ir_recv_active 0; } } } } // 主循环中调用解码函数 void IR_Decode_From_Timestamps(void) { if(ir_recv_active ir_ts_index 32) { // 类似51的逻辑根据ir_timestamps计算时间差并解码 // ... // 解码完成后清除标志 ir_recv_active 0; ir_ts_index 0; } }7. 常见问题与排查实录在实际操作中你几乎一定会遇到下面这些问题。这里是我的排查记录和解决方案。7.1 问题一完全解不出码串口无任何输出检查硬件连接这是第一步也是最容易出错的一步。用万用表测量接收头VCC和GND电压是否正确5V或3.3V。测量OUT引脚在无遥控信号时的电压应为高电平接近VCC。按下遥控器时电压应有剧烈波动。检查单片机中断配置确认外部中断引脚配置正确触发方式下降沿设置正确中断服务函数名与向量表对应51单片机尤其注意interrupt关键字后的数字。检查定时器配置确认定时器的时钟源、分频系数、计数模式正确。如果使用定时器中断来累加ir_time确保中断使能且优先级设置无误。使用示波器或逻辑分析仪直接观察接收头OUT引脚波形。如果按下遥控器没有任何波形变化可能是接收头损坏、遥控器没电或协议不匹配不是38kHz NEC协议。7.2 问题二能解出码但键值不对或者时对时错时间阈值不准确这是最常见的原因。用逻辑分析仪实测引导码和数据位的高低电平时间更新代码中的判断阈值。不同晶振、不同型号的单片机指令周期不同计算出的时间会有差异。中断嵌套或响应不及时如果系统中还有其他高优先级中断可能会打断红外解码中断导致丢失边沿。尝试提高红外外部中断的优先级或者在解码期间暂时关闭其他不紧急的中断。没有处理定时器溢出如果测量一个高电平的时间超过了定时器的最大计数值如65ms而代码没有处理溢出时间计算就会出错。确保你的ir_time溢出次数变量在定时器溢出中断中得到更新并且在计算总时间时被正确使用。软件去抖不足环境光干扰导致产生错误的边沿。尝试在记录时间戳前进行简单的软件滤波例如连续读取几次引脚电平确认一致后再认为是有效边沿。7.3 问题三接收距离很短稍微远一点就失灵遥控器电池电量不足更换新电池。红外接收头性能不佳或方向性太强尝试更换一个接收头或者调整接收头与遥控器的相对角度。有些接收头接收角度较窄需要对准。环境光干扰太强特别是太阳光和白炽灯光含有丰富的红外成分。尽量避免强光直射接收头或者给接收头加一个深色的滤光罩。单片机电源噪声确保单片机电源稳定在VCC和GND之间靠近芯片处并联一个100nF和10uF的电容进行退耦。7.4 问题四按住按键不放只能识别一次不能识别连发没有实现重复码解码逻辑检查代码是否只处理了标准引导码9ms4.5ms。当检测到9ms低电平2.25ms高电平的重复码时应该重复上一次的按键值而不是尝试解码后续不存在的32位数据。状态机没有正确处理重复码后的状态收到重复码并处理完后状态机应回到一个可以等待下一次下降沿可能是重复码的结束脉冲也可能是新的标准引导码的状态而不是卡死。红外遥控解码是一个融合了硬件感知、中断处理、定时器应用和协议解析的综合性项目。通过这个项目你不仅能掌握一个实用的通信技能更能深刻理解单片机是如何与物理世界进行精确时序交互的。从最开始的毫无头绪到后来能稳定解码各种遥控器这个过程本身就是对嵌入式开发者解决问题能力的一次绝佳锻炼。我建议你在成功实现基础解码后可以尝试挑战一下用定时器输入捕获模式重新实现一遍体验一下硬件辅助带来的简洁和高效或者尝试解码其他协议如RC5、Sony SIRC等比较它们与NEC协议的异同。这些延伸学习会让你的理解更加透彻。