51单片机红外遥控解码与波形显示:外部中断+定时器时序解析
简介面向单片机初学者与电子设计课程实践者的红外遥控解码学习包围绕51单片机AT89C51、STC89C52RC等接收并解析红外遥控信号、再将解码波形送LCD显示这一主线解决红外时序捕获、协议解码与显示联调中的常见难点适合课程设计、毕业设计与自学练手等场景。压缩包共38个文件、约9.68MB包含4份C源程序与头文件、1份Altium Designer原理图红外波形.SCHDOC、元件清单.xls、Keil工程文件与编译生成的hex/M51/LST等中间文件另配设计文档说明.doc、答辩常见问题解答与技巧文档、芯片资料PDF以及Proteus仿真报错的图文提示硬件、代码与文档相互对应。已有260人学习下载。资源价值在于把硬件连接、程序实现和说明文档配套齐全可对照原理图与清单搭电路借源码理解NEC类协议的脉宽计时与按键识别流程按提示排除HEX文件路径、DLL缺失等仿真问题并借答辩资料梳理设计思路完整走通从搭建到验证的流程。1. 一块 51 板子怎么把遥控器的按键“看”出来上周帮朋友调一块 51 单片机的最小系统板他的需求很朴素手边一堆旧遥控器想拆出按键值接到自己的板子上顺便在电脑上看看红外信号到底长什么样。接上接收头、写完外部中断、打开串口助手按下按键屏幕上蹦出00 FF 00 FF—— 校验能过键值却全是 0。这就是红外遥控解码的典型开局波形看着简单代码写完却全是玄学。这套基于 51 单片机的红外遥控解码与波形显示方案做的就是把这件“看不见摸不着”的事变成可复现的工程38kHz 载波怎么被一体化接收头解调、9ms 引导码怎么被定时器量出来、560us 和 1690us 怎么被判成 0 和 1、最后怎么把这段微秒级的跳变画成一张人能看懂的方波图。适合刚上手 51、想啃明白时序类中断编程的人也适合要把它改成学习型遥控的熟手。2. 系统架构一体化接收头、51 引脚与显示模块怎么连2.1 一条信号的完整链路从遥控器到 P3.2红外遥控这条链路分成四段每一段的“语言”都不一样这是初学者最容易犯晕的地方。第一段是遥控器内部按键按下后一颗专用编码芯片把键值编成 32 位串行码再用 38kHz 方波去调制它——有载波代表“发”无载波代表“不发”。第二段是空气940nm 的红外光带着这个调制信号飞过来。第三段是一体化接收头它内部集成了光电二极管、前置放大、带通滤波和解调输出的已经是去掉载波后的基带信号而且电平是反的平时输出高收到 38kHz 载波时拉低。第四段才轮到单片机它要做的只有一件事——量出这串高低电平每一段有多长。所以接线本身非常简单难的是理解“反相”和“载波已去掉”这两个前提。接收头三个脚OUT 接 P3.2外部中断 0 的固定引脚VCC 接 5VGND 接地。VCC 那一脚我一般串一个 100Ω 电阻再并一个 100uF 电解电容到地靠近接收头焊这个 RC 不是可选项后面避坑章会讲为什么。遥控器按键 └─ 编码芯片 → 32 位串行码 → 38kHz 载波调制 → 红外发射管 ↓ 940nm 红外光 一体化接收头HS0038 一类 └─ 光电转换 → 放大 → 38kHz 带通 → 解调 → 输出反相基带 ↓ 51 单片机 P3.2 / INT0 └─ 外部中断记边沿 → 定时器量宽度 → 状态机拼位 → 反码校验 → 键值 ↓ LCD1602 显示 / 串口上传波形最小系统那一侧的连线是这样12MHz 晶振跨接 XTAL1、XTAL2两只 22pF 瓷片电容对地复位脚用 10uF 电解电容串 10k 电阻做上电复位再并一个轻触按键做手动复位EA 脚必须接 VCC否则片内程序不执行这是新手第一次烧录失败的高频原因。显示部分用 LCD1602数据口 D0~D7 接 P0P0 要加 10k 排阻上拉51 的 P0 是开漏不加排阻背光一亮字符就糊了RS、RW、E 分别接 P2.5、P2.6、P2.7对比度脚 VO 接 10k 电位器中点。串口那一路如果要做波形上传P3.0 和 P3.1 经过一个 USB 转 TTL 模块接到电脑就够了不需要额外的电平转换芯片——现在这类模块便宜又稳比焊一套传统串口电路省事得多。2.2 资料包拆开原理图、元件清单、设计说明各管什么拿到一个“程序原理图元件清单设计说明”的压缩包先别急着双击工程文件。按经验这类资料包内部通常长这样我按自己能快速上手的方式重新组织一下IR51_Project/ ├── Doc/ 设计文档先看这个 │ ├── 设计说明.pdf 整体方案、时序推导、流程图 │ ├── 原理图.pdf 网络标号级别的连线 │ └── 元件清单.xlsx 型号、封装、数量 ├── Firmware/ 源码按模块分文件 │ ├── main.c 初始化 主循环 显示刷新 │ ├── ir_decode.c/.h 外部中断 定时器 状态机 │ ├── lcd1602.c/.h 显示驱动 │ ├── uart.c/.h 串口初始化和发送 │ └── key_map.c/.h 键值到功能名的映射表 ├── Project/ │ └── IR51.uvproj 编译工程 └── Tools/ └── wave_view.py 上位机波形还原脚本四个部分的分工要拎清楚。原理图是电气真相重点看三处接收头的 OUT 到底落在哪个引脚很多板子不是 P3.2而是 P1.x 然后软件查询那样测不了精确宽度、晶振频率是多少直接决定定时器的一个计数是几微秒、LCD 的 RS/RW/E 有没有用译码器。元件清单是采购依据重点确认接收头的型号后缀——不同后缀的载波频率和输出极性一致但引脚顺序可能不同焊反了就是“上电就发热”。设计说明是最容易被跳过、但价值最高的一份里面一般有时序参数的推导过程比如“引导码 9ms 判定阈值取 6ms数据位 0/1 判定阈值取 1.7ms”这类结论看懂了就不用自己在示波器前反复试。至于程序别从头读 main。直接搜interrupt 0从外部中断服务函数切进去那才是这个项目的心脏其余都是配角。2.3 时序参数表所有判断阈值都是从这张表推出来的不管源码里写了多少魔数它们背后都是同一张表。把这几个数记住任何一个阈值你都能自己算出来该取多少段落低电平时长高电平时长相邻下降沿间隔判据引导码9000us4500us13500us 10000us重复码9000us2250us11250us 10000us与引导码同判数据位 0560us560us1120us 1700us数据位 1560us1690us2250us 1700us帧间隔高电平—约 108ms不参与判位关键在最后一列的“相邻下降沿间隔”。因为外部中断只配了下降沿触发中断进来时量到的宽度是上一个下降沿到这一个下降沿之间的时间也就是“低电平 高电平”的总和。引导码那一段是 9ms 低加 4.5ms 高合计 13.5ms数据位 0 是 560us 低加 560us 高合计 1120us数据位 1 是 560us 低加 1690us 高合计 2250us。0 和 1 之间差了整整一倍分界点放在 1700us容错余量有 ±500us 以上这就是为什么这颗接收头的载波频率标称 38kHz、实际可能偏到 37kHz 也不影响解码。提示如果你手头的资料包里用的是“上升沿下降沿双边沿”的做法那量出来的就是单一电平宽度判据要改成 560us 与 1690us 比阈值取 1.1ms 附近。两种做法的本质一样别把两套阈值混着抄这是移植时最常见的翻车点。这张表里还有个容易被忽略的数帧间隔约 108ms。它意味着按住不放时遥控器大约每 108ms 发一次重复码这个特性在第六章做长按连发时能用上。3. 解码核心外部中断 定时器把 32 位码抠出来3.1 定时器初始化为什么 12MHz 晶振下 1 个计数就是 1 微秒定时器 0 在这个项目里不产生中断它只当一个自由运行的微秒计数器用。51 的定时器在 12T 模式下计数脉冲来自机器周期而机器周期 12 / 晶振频率。12MHz 晶振时机器周期正好 1us于是 TH0、TL0 拼起来的 16 位数字就是一个以微秒为单位、从 0 数到 65535 然后自然回绕的时间戳。#include reg52.h /* * T0 只作时间戳用方式 116 位自由运行不开中断。 * 12MHz 晶振 / 12T 模式 - 每计数一次 1us16 位满量程 65.5ms * 足够覆盖 13.5ms 的引导码回绕问题靠“无符号相减”解决。 */ void timer0_init(void) { TMOD 0xF0; /* 保留 T1 的配置只清 T0 的 4 个控制位 */ TMOD | 0x01; /* T0 方式 116 位定时器 */ TH0 0x00; TL0 0x00; /* 从 0 起跑之后不再手动清零 */ ET0 0; /* 不用 T0 中断纯粹读计数值 */ TR0 1; /* 启动一直跑 */ }这段里最关键的是ET0 0和“不再手动清零”两件事。很多教程的做法是每次进中断就把 TH0/TL0 归零再读一个“从零到现在的差值”这样写出来的代码量出来的宽度永远只有一半——因为你在中断里清零的同时引脚上的下一个电平已经开始了。正确做法是让它自由跑每次进中断把当前值存下来和上一次存的值相减得到间隔。而且这个减法必须用无符号变量16 位无符号相减的特性是即使定时器已经回绕过一圈now - last的结果依然是正确的间隔值只要这个间隔小于 65.5ms。这一招省掉了一堆“溢出标志判断”的代码是写所有时间戳类中断的通用技巧血泪经验值得记牢。3.2 中断服务函数只做三件事外部中断 0 的配置只有一行IT0 1下降沿触发。之所以选下降沿是因为每个数据位都以 560us 的低电平开头下降沿恰好落在“一段新编码的开始”量出来的宽度天然就是完整的“低高”。中断服务函数里我只做三件事读时间戳、算间隔、把间隔翻译成一个比特。别在中断里做显示、别在中断里发串口、别在中断里算除法51 的栈很小一个函数调用嵌套进去返回地址就压爆了。#include reg52.h sbit IR_PIN P3^2; /* 接收头 OUT外部中断 0 固定引脚 */ volatile unsigned int g_t_last 0; /* 上一个下降沿的时间戳单位 us */ volatile unsigned char g_bit_cnt 0; /* 本帧已解出的位数0~32 */ volatile unsigned char g_code[4] {0}; /* 地址、地址反码、命令、命令反码 */ volatile bit g_done 0; /* 一帧 32 位收满的标志 */ void ex0_isr(void) interrupt 0 { unsigned int now, width; now ((unsigned int)TH0 8) | TL0; /* 读 16 位计数值 */ width now - g_t_last; /* 无符号相减自动处理回绕 */ g_t_last now; /* 情况一间隔超过 10ms只可能是引导码(13.5ms)或重复码(11.25ms) */ if (width 10000) { g_bit_cnt 0; g_code[0] 0; g_code[1] 0; g_code[2] 0; g_code[3] 0; return; /* 这一段不参与判位 */ } if (g_bit_cnt 32) return; /* 帧已满多余的边沿丢掉 */ /* 情况二数据位。低位先发所以先右移再补最高位 */ g_code[g_bit_cnt 3] 1; if (width 1700) { g_code[g_bit_cnt 3] | 0x80; /* 2250us判为 1 */ } if (g_bit_cnt 32) { g_done 1; /* 32 位齐了交给主循环校验 */ } }逐段解释一下。读时间戳那一行把 TH0 和 TL0 拼起来注意必须强制转成unsigned int再左移否则TH0 8会在 8 位里溢出成 0这是 51 上的经典坑。算宽度的减法不用判大小now比g_t_last小的时候结果照样正确。第一个分支处理引导码。这里用 10000us 而不是精确的 13500us是因为接收头输出有几十微秒的抖动晶振也有误差卡太死反而容易漏。这个分支把四个字节清零、位数归零等于“重新开一帧”重复码进来时也会走这条路被自然丢弃——按住不放时连续收到重复码主循环拿不到g_done正好符合预期。第二个分支是判位。g_code[g_bit_cnt 3]是“第几位除以 8”即第几个字节 1配合| 0x80的组合是把先收到的位逐渐往低位挤最后第 1 个收到的位落在 bit0、第 8 个收到的位落在 bit7。这是因为该协议规定低位先发如果你的遥控器是高字节先发这两行的顺序就得反过来写解码出来才是对的。3.3 位判定与反码校验0 和 1 的分水岭放在 1700us主循环拿到g_done之后不能直接信。因为红外是单向广播日光灯、等离子屏、太阳光里都夹着红外成分接收头随时可能吐出半截噪声。真正让这套解码变可靠的是协议自带的反码校验32 位里第 1 字节是地址第 2 字节是地址按位取反第 3 字节是命令第 4 字节是命令按位取反。/* 主循环里的一次取码读完立刻清标志避免下一帧覆盖 */ if (g_done) { unsigned char addr, cmd; EA 0; /* 拷贝期间关中断防止 ISR 改 g_code */ addr g_code[0]; cmd g_code[2]; g_done 0; EA 1; /* 两组反码必须同时成立才认这一帧 */ if (((addr ^ g_code[1]) 0xFF) ((cmd ^ g_code[3]) 0xFF)) { show_key(cmd); /* 送显示 */ last_key cmd; /* 记下来给长按判断用 */ } }EA 0那几行的位置有讲究只需要保护“读四个字节”这个动作把这个窗口压到最短绝不能把show_key()也包进去——LCD1602 一次刷屏动辄几毫秒这期间关着中断下一帧的引导码就丢了表现出来就是“按一下偶尔没反应”。关中断最小化的原则在 51 这种没有中断嵌套保护的单片机上是必须刻进肌肉记忆的。剩下的就是键值映射表。用code关键字把表放在 Flash 里别占那可怜的 128 字节 RAM/* 放在 code 区不占 RAM键值必须用解码出来的实际值不能照抄别人的 */ code unsigned char KEY_CODE[] {0x45, 0x46, 0x47, 0x44, 0x40, 0x43, 0x07, 0x15}; code char * const KEY_NAME[] {PWR, MENU, UP, DOWN, LEFT, RIGHT, OK, BACK}; void show_key(unsigned char cmd) { unsigned char i; for (i 0; i sizeof(KEY_CODE); i) { if (cmd KEY_CODE[i]) { lcd_write_str(1, 0, KEY_NAME[i]); /* 第二行显示功能名 */ return; } } lcd_write_hex(1, 0, cmd); /* 表里没有就直接显示原始码 */ }表里没有的码直接显示十六进制这个“兜底”看着不起眼实际调试时非常救命它能立刻告诉你“这个键是解出来了只是没配名字”还是“压根没解出来”——这两种情况在屏幕上都表现为“没反应”但原因天差地别。4. 波形显示把微秒级跳变变成人能看懂的图4.1 两条路线等间隔采样 vs 边沿时间戳“波形显示”四个字听着高级落到 51 上其实就两条路选错了会卡在 RAM 上出不来。第一条是等间隔采样用定时器每隔固定时间读一次 P3.2 的电平存进数组最后把数组当成波形画出来。这条路直观、不依赖中断但代价很硬——要看清 560us 的低电平采样周期至少得 100us一帧 13.5ms 引导码加 32 位数据约 67ms就需要 670 个采样点。51 的片内 RAM 只有 128 或 256 字节光这个数组就把内存撑爆了。除非外扩存储或者换型号否则这条路在标准 51 上走不通。第二条是边沿时间戳既然已经有一个在量的定时器了那就把每次中断量到的间隔顺手存进数组33 个边沿只需要 33 个unsigned int也就是 66 字节。这个方案不需要额外中断、不需要额外定时器解码和显示共用同一份数据是标准 51 上唯一现实的选择。我一般默认走第二条并且在原理图上把“波形输出”这件事定位成“事后复盘”而不是“实时示波器”。它不能替你在调试时抓毛刺但能把“这个遥控器到底发了什么”完整地记录下来——对二次开发来说这比实时性重要得多。4.2 边沿记录33 个 unsigned int 怎么塞进 51 的 RAM缓冲区我开 32 个对应“引导码 32 位数据”的下降沿序列用完就环形覆盖。写入必须在中断里完成但要加一个开关变量让这个功能可以关掉——平时解码不需要存波形多一次数组写入就多一份抖动风险。#define EDGE_MAX 32 volatile unsigned int g_edge[EDGE_MAX]; /* 每个元素是一次下降沿的间隔单位 us */ volatile unsigned char g_edge_cnt 0; /* 当前写到哪里 */ volatile bit g_wave_on 0; /* 波形记录开关默认关 */ /* 在 ex0_isr 里算完 width 之后紧接着插入这几行 */ if (g_wave_on) { g_edge[g_edge_cnt] width; if (g_edge_cnt EDGE_MAX) { g_edge_cnt 0; /* 环形覆盖只保留最近一帧 */ } }注意插入位置必须放在g_t_last now;之后、任何return之前否则引导码那一段会被漏掉——引导码是整张图上最显眼的那根长条漏了它波形就失去了参考系你没法判断后面那串窄脉冲到底是数据位还是噪声。g_wave_on这个开关的意义在于解码的稳定性和波形记录的完整性是矛盾的。开着记录一次中断要多写两个字、多做一次比较极端情况下可能让某一位的宽度测量偏出阈值。所以我的习惯是——调协议时开着跑产品时关着需要抓一次就按一下学习键临时打开。4.3 串口上行协议帧头、长度、大端数据要传出去串口是最省事的路。但“省事”的前提是帧格式定死不然上位机收到一堆字节根本不知道该从哪开始解析。我用的格式非常简单两字节帧头0xAA 0x55、一字节边沿个数、若干个两字节间隔高字节在前、一字节0x0D收尾。/* * 一帧格式[0xAA][0x55][n][w0_H][w0_L]...[wn-1_H][wn-1_L][0x0D] * 先把 g_edge 拷到局部数组再发避免发送期间被 ISR 改写。 */ void uart_send_wave(void) { unsigned int buf[EDGE_MAX]; unsigned char i, n; EA 0; n g_edge_cnt; for (i 0; i n; i) buf[i] g_edge[i]; EA 1; SBUF 0xAA; while (!TI); TI 0; SBUF 0x55; while (!TI); TI 0; SBUF n; while (!TI); TI 0; for (i 0; i n; i) { SBUF (unsigned char)(buf[i] 8); while (!TI); TI 0; SBUF (unsigned char)(buf[i] 0xFF); while (!TI); TI 0; } SBUF 0x0D; while (!TI); TI 0; }串口初始化里有一件事必须提前确认波特率。定时器 1 工作在方式 28 位自动重装TH1 0xFD配 9600 波特率这个值是给 11.0592MHz 晶振准备的。如果你用的是 12MHz 晶振同一条命令会跑出 8.5% 的误差串口助手收到的就是一堆乱码。这两个频率天生矛盾——12MHz 让定时器好算微秒11.0592MHz 让串口不翻车。我的做法是定时器那边把时间基准做成宏晶振换频率时只改一处而不是把阈值散落在代码各处。/* 时间基准换算先算出一个计数代表多少 ns再统一换算回 us 判据 */ #define FOSC_HZ 12000000UL /* 换成 11059200 时改这里 */ #define TICK_NS (12000000UL / (FOSC_HZ / 1000UL)) /* 12T 模式下每计数多少 ns */ #define US(x) ((unsigned int)((x) * 1000UL / TICK_NS)) /* 用的时候写 if (width US(1700)) ... 而不是写 1700 */这套宏看着有点绕但换过一次晶振就知道值了。那些把1700硬编码在七八个地方的项目换频率时基本等于重写一遍。4.4 上位机还原从间隔数组到一张能看的方波图上位机这头用一段 Python 就够串口读数据加 matplotlib 画图几十行。核心难点不在画图在怎么把“间隔数组”还原成“方波折线”——因为数组里存的只是相邻下降沿的时间差中间的高电平有多长需要按协议再拆一次。import serial import matplotlib.pyplot as plt def read_frame(ser): 阻塞等待一帧返回间隔数组单位 us while True: if ser.read(1) ! b\xaa: continue if ser.read(1) ! b\x55: continue n ser.read(1)[0] raw ser.read(n * 2) ser.read(1) # 吃掉 0x0D return [(raw[2 * i] 8) | raw[2 * i 1] for i in range(n)] def to_waveform(widths): 每段间隔 下降沿开始 - 固定低电平 - 剩余高电平 - 下一个下降沿 引导码/重复码的低电平是 9000us数据位是 560us t, xs, ys 0.0, [0.0], [1] # 空闲时接收头输出高电平 for w in widths: low 9000 if w 10000 else 560 t low / 1000.0 xs.append(t); ys.append(0) # 下降沿拉低 t (w - low) / 1000.0 xs.append(t); ys.append(1) # 上升沿回到高 return xs, ys ser serial.Serial(COM3, 9600, timeout2) xs, ys to_waveform(read_frame(ser)) fig, ax plt.subplots(figsize(12, 3)) ax.plot(xs, ys, linewidth1.2) ax.set_xlabel(time / ms); ax.set_ylabel(level) ax.set_title(IR demodulated waveform) ax.grid(alpha0.3) plt.tight_layout(); plt.show()to_waveform里那句low 9000 if w 10000 else 560是整个还原的灵魂。因为按协议不管哪一段都是“先固定低电平、再用高电平长度编码”只有引导码的低电平是 9ms数据位都是 560us。用宽度本身反推低电平长度看着像循环论证实际很稳——13.5ms 和 1.12ms 差着一个数量级不会判错。跑出来的图长这样最左边一根超长低电平像悬崖右边跟着一串密集的窄脉冲宽度明显分成两档。图上量一量窄的那档约 0.56ms宽的那档约 1.69ms和第一章那张表一一对上。等到你能在图上一眼数出 32 个脉冲、并且照着它把键值手算出来这个项目就算真正吃透了。5. 避坑与排查五个让波形“变形”的常见问题这套东西代码量不大能出问题的地方却出奇地集中。下面五条是我自己踩过、也帮别人排查过的按“现象 → 原因 → 解决”记下来遇到问题时对着看比漫无目的地改阈值快得多。5.1 十次里有一次解出全 0反码校验居然还能过现象连续按键大约每十次有一帧解出来的四个字节全是0x00而且0x00 ^ 0x00不等于0xFF校验本该拦住屏幕上却什么都没显示误以为“按键没反应”。原因主循环某处关中断的时间太长。最常见的是 LCD 刷屏函数里为了时序稳定加了EA 0一次刷十几个字符关闭时间超过 5ms正好把下一帧的引导码吃掉。中断没进g_bit_cnt停在半路下一次进来的第一个边沿被当成第 n 位拼进去整个字节就错位了。解决把 LCD 驱动里的EA 0全部删掉1602 的时序靠delay凑就行不需要关总中断如果确实需要保护用“关—改—开”三段夹住单个字节的读写别夹住整个刷屏流程。改完再测全 0 帧消失。5.2 换到另一块板子键值整体偏移一位现象同一个遥控器在老板上按“1”显示0x45在新板上显示0x46所有键都错开一位。原因新板用了 11.0592MHz 晶振一个计数变成 1.085us所有按 1us 算出来的阈值整体偏大 8.5%。1700us 那条分界线刚好落在 1 位宽度2250us × 1.085 2441us和 0 位1120us × 1.085 1215us之间还算安全但引导码判据 10000us 对应实际 10850us勉强还行真正出问题的是判位——如果代码里把阈值写成了 1120那么 0 位因为偏大被误判成 1整个字节自然全错。解决把所有硬编码的时间值换成 4.3 节那套US()宏让它们随FOSC_HZ一起变。改完之后同一份源码在两种晶振下都能跑这才是能移植的写法。5.3 用新型号单片机引导码测出来只有 1.1ms现象代码没动只换了颗引脚兼容的新型号13.5ms 的引导码在串口里显示成 1100us 左右所有数据位缩成几十微秒解码全乱。原因新型号默认工作在 1T 模式一个机器周期等于一个时钟周期12MHz 下每计数 0.083us也就是比 12T 模式快 12 倍。所有时间值缩小 12 倍13.5ms 自然变成 1.1ms 左右。解决在初始化里把时序切回 12T不同型号的寄存器位名不一样查手册里的“时钟分频”或“机器周期”章节或者干脆保留 1T、把TICK_NS宏改成 83 并接受 16 位定时器满量程只有 5.4ms 的事实——后者不行因为 13.5ms 的引导码会溢出。所以必须切回 12T。5.4 波形上传时好时坏上位机偶尔收到半帧现象串口助手里能看到AA 55后面的数据字节数比预期少一半偶尔还夹着乱码。原因两个可能同时存在。一是发送前没有关中断拷贝缓冲区g_edge_cnt在发送过程中被 ISR 改掉长度和内容对不上二是晶振是 12MHz 却用了 9600 波特率8.5% 的误差已经超出 UART 的容忍范围一般要求 3% 以内连续收几个字节就会帧错。解决发送前像 4.3 节那样用EA 0拷一份局部副本副本拷完立刻开中断波特率要么换 11.0592MHz 晶振配TH1 0xFD要么在 12MHz 下降速到 4800误差约 0.2%可以接受。两条都做问题一次消。5.5 没按遥控器串口一直往外吐数据现象上电后什么都不做串口每隔几毫秒就有一帧g_edge里填满了几十到几百微秒的碎间隔用手机摄像头对着接收头看能看到它自己在闪。原因接收头的供电没滤波。它内部是 pins 级别的高增益放大器VCC 上几十毫伏的纹波就能让它误判成载波。最常见的干扰源是同一个 5V 电源上的 LED 灯或者开关电源100Hz 的闪烁正好落在它的敏感区。另一个可能是接收头正对着窗户或白炽灯阳光里的红外成分被直接放大。解决VCC 脚就近串 100Ω 再并 100uF 到地这个 RC 能把纹波压到接收头能忍的量级软件上再加一道“反码校验必须过”和“引导码后必须收满 32 位”的双保险噪声帧会被自然丢弃。物理上把接收头挪开强光源或者给它套一段黑色热缩管做个遮光罩效果比改代码还立竿见影。6. 二次开发把固定键值表改成可学习的遥控器固定键值表最大的问题是“换个遥控器就得重编一次程序”这在教具和量产场景里都是硬伤。真正让这个项目变得好用的改造是让它能学——按下学习键对着任意遥控器按一下把码记下来存进 EEPROM下次开机直接读。6.1 键值表从 code 区搬到 EEPROM存储这块51 的标准做法是外挂一颗 I2C 的 EEPROM容量 2Kbit 就够存几十个键。地址规划很简单0x00 放一个魔数0x5A做“已初始化”标记0x01 放已学键数量从 0x02 开始每个键占 2 字节——1 字节命令码、1 字节功能编号。/* * 每条记录 2 字节[命令码][功能编号] * 0x00: 魔数 0x5A表示这块 EEPROM 已经格式化过 * 0x01: 已学习条数 n0~100 * 0x02 i*2: 第 i 条记录 */ unsigned char learn_find(unsigned char cmd) { unsigned char i, buf[2]; for (i 0; i learn_count; i) { eeprom_read(0x02 i * 2, buf, 2); if (buf[0] cmd) return buf[1]; /* 命中返回功能编号 */ } return 0xFF; /* 未学习 */ } unsigned char learn_add(unsigned char cmd, unsigned char func) { if (learn_count 100) return 0; /* 满了 */ eeprom_write(0x02 learn_count * 2, cmd, 1); eeprom_write(0x03 learn_count * 2, func, 1); learn_count; eeprom_write(0x01, learn_count, 1); return 1; }这段的要点在learn_find的命中判断同时比对地址字节和命令字节会更严谨因为不同遥控器的地址不同、命令可能撞车。如果你手上只有一只遥控器光比命令码就行一旦要接两只以上的遥控器就必须把g_code[0]也一起存进去记录变成 3 字节。这种“先跑通再补全”的取舍我在做小工具时几乎是默认节奏——先让功能可用再补鲁棒性别一上来就设计一个完美的表结构最后连第一个功能都没验证。6.2 长按连发用重复码做加减速第二章那张表里埋了一个数帧间隔约 108ms。按住不放时遥控器不再发完整的 32 位数据而是发一个 9ms 低加 2.25ms 高的重复码。这给了一个免费的长按信号源——收到重复码就说明“还是刚才那个键还按着”。具体做法是主循环里加一个计时判断收到完整帧时记下last_key和当前时间之后每收到一次重复码就把计时器清零如果超过 200ms 没收到重复码就认为松手了。这样按住不放时last_key保持有效配合一个自增的步进量就能做加减速——按住 1 秒内每 300ms 触发一次1 秒后改成每 100ms 一次手感立刻就对了。/* 主循环里调用idle_ms 由定时器每毫秒自增一次 */ void scan_repeat(void) { static unsigned int idle_ms 0; static unsigned char step 0; if (g_repeat_flag) { /* ISR 收到重复码时置位 */ g_repeat_flag 0; idle_ms 0; step; /* 前 3 次慢速之后加速再封顶防止刷太快 */ if (step 3 (step % 2)) return; if (idle_ms 200) do_key_action(last_key); } else if (idle_ms 200) { step 0; /* 超过 200ms 无重复码视为松手 */ idle_ms 200; } }idle_ms由定时器 0 每毫秒自增一次的做法需要把 T0 从“自由跑的时间戳”改成“开中断的 1ms 节拍”这会和第三章的时间戳逻辑打架。我的处理是保留 T0 做时间戳另开定时器 1 的一个通道做毫秒节拍——代价是 T1 不能再用去做波特率发生器得改串口配置。这种资源冲突在小容量的 51 上躲不掉改之前先画一张资源占用表比改到一半发现串本文还有配套的精品资源点击获取