ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

51单片机红外遥控解码与波形显示:外部中断+定时器时序解析

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 接 5V,GND 接地。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 接 P0,P0 要加 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 只作时间戳用:方式 1(16 位),自由运行,不开中断。 * 12MHz 晶振 / 12T 模式 -> 每计数一次 = 1us,16 位满量程 65.5ms, * 足够覆盖 13.5ms 的引导码,回绕问题靠“无符号相减”解决。 */ void timer0_init(void) { TMOD &= 0xF0; /* 保留 T1 的配置,只清 T0 的 4 个控制位 */ TMOD |= 0x01; /* T0 = 方式 1,16 位定时器 */ 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 工作在方式 2(8 位自动重装),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, timeout=2) xs, ys = to_waveform(read_frame(ser)) fig, ax = plt.subplots(figsize=(12, 3)) ax.plot(xs, ys, linewidth=1.2) ax.set_xlabel('time / ms'); ax.set_ylabel('level') ax.set_title('IR demodulated waveform') ax.grid(alpha=0.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: 已学习条数 n(0~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 上躲不掉,改之前先画一张资源占用表,比改到一半发现串

本文还有配套的精品资源,点击获取

返回列表