
简介面向电子类课程设计、毕业设计与单片机入门进阶者的红外遥控解码与波形显示实战资料包围绕51单片机接收并解析遥控器脉冲、在LCD1602等显示设备上还原波形的完整流程展开。压缩包共38个文件、约9.68MB主要由C语言源程序、Keil工程与HEX文件、Altium原理图、元件清单、设计说明文档及仿真排错文本组成覆盖硬件连接、信号接收整形、时钟恢复、协议解码与显示输出等环节其中C源码与工程文件便于编译下载原理图和元件清单可用于硬件搭建设计说明与答辩资料则帮助梳理实现思路。已有260人学习。读者可据此理解NEC等红外协议的脉冲宽度判断方法对照原理图搭建电路借助现成工程调试波形显示并通过仿真报错说明、答辩常见问题与论文答辩技巧材料完成课程设计或毕设的方案复盘与查漏补缺。1. 从遥控器按下去到屏幕亮一下中间发生了什么拿一个电视遥控器对着 HS0038 接收头按一下接收头 OUT 脚上出现的不是一串方波数据而是「9ms 低、4.5ms 高、再一串长短不一的低高电平」的包络。这套基于 51 单片机的红外遥控解码与波形显示方案做的就是两件事用 INT0 下降沿加定时器把这段包络还原成 32 位 NEC 帧再把接收头输出的原始电平按 50us 一个点采进缓冲区在 12864 上画成一条能看出 0/1 宽度的波形。适合刚学完定时器中断、想找一个「硬件看得见、代码量又不至于劝退」的课设或入门项目的同学也适合需要给老设备补一个红外遥控入口的开发者拿去做原型。2. 项目定位与技术选型一颗 8 位 MCU 怎么扛住 38kHz 解调先把一件事说清楚38kHz 的载波解调根本不用单片机管。红外接收头内部已经把 38kHz 载波检波、放大、整形做完了送到 P3.2 上的就是一个干净的低电平有效包络。51 要做的只是「测宽度」这也是为什么一颗 12MHz 的 STC89C52 就能稳稳接住 NEC 帧不需要上 ARM。选型上有几个绕不开的取舍。晶振用 12MHz 而不是 11.0592MHz11.0592 在串口波特率上更准但 12MHz 下一个机器周期正好 1us定时器计数值直接等于微秒数调试时心里有数。代价是串口要传波形时波特率误差偏大9600 勉强能用115200 基本没戏所以串口上传只作为备选方案主显示还是走 12864。2.1 元件清单背后的信号链路把原理图翻开其实就四条线值得盯电源、地、OUT 到 P3.2、以及接收头 VCC 脚上那个 RC。元件清单里最容易被忽略的是 100Ω 电阻和 10uF 电解电容组成的那组滤波很多人省掉它结果遥控距离从 8 米掉到 1 米。器件型号/参数作用接线要点主控STC89C52RC / AT89S52解码与显示控制最小系统需 12MHz 晶振 30pF×2红外接收头HS0038B / VS1838B38kHz 解调输出反相包络OUT→P3.2VCC 串 100Ω 并 10uF 到地显示屏LCD12864ST7920波形与键值显示并口 8 位PSB 接高电平备用显示LCD1602只显示键值不画波形RS/RW/E 接 P2.5~P2.7串口CH340 USB-TTL波形上传到上位机TXD→P3.0RXD→P3.1复位10uF 10kΩ上电复位RST 脚高电平复位接收头的输出是反相的有 38kHz 载波时 OUT 拉低没有载波时 OUT 拉高。所以 NEC 的「9ms 引导码」在示波器上看是 9ms 的低电平接着 4.5ms 的高电平。这个反相关系一定要在脑子里定死后面所有判据都是基于它推出来的一旦搞反解码永远出不来。2.2 工程目录与编译环境压缩包里的程序部分是 Keil C51 工程.uvproj双击就能打开编译前确认 Output 里勾了「Create HEX File」。目录结构大致是这样IR_Decode_Wave/ ├── Project/ │ └── IR.uvproj # Keil C51 工程文件 ├── User/ │ ├── main.c # 主循环初始化、调度、显示刷新 │ ├── ir.c / ir.h # INT0 TIM0 解码状态机全在这 │ ├── lcd12864.c / .h # ST7920 并口驱动 显存刷屏 │ ├── wave.c / wave.h # TIM1 采样、波形渲染、坐标轴 │ ├── uart.c / uart.h # 串口上传波形帧 │ └── delay.c # 软延时只给 LCD 初始化用 ├── Doc/ │ ├── 原理图.pdf │ ├── 元件清单.xlsx │ └── 设计说明.docx └── Hex/ └── IR.hex # 可直接烧录的固件delay.c里只留微秒级和毫秒级两个软延时函数且明确注释「只允许在 LCD 初始化阶段调用」。这是有意的约束主循环里一旦出现delay_ms(10)红外边沿就会被漏掉解码会变成玄学。2.3 中断分工为什么不能用 delay 硬等最直观的写法是「检测到低电平就开始计数等它变高算出宽度」用while(IR_PIN);死等。这个写法在仿真里能跑上板就翻车。原因很直接一个 NEC 帧要传 108ms其中每个位的宽度只有 1.12ms 或 2.25ms你在死等的时候什么都干不了屏幕没法刷、按键没法扫而且一旦错过一个边沿整帧就废了。合格的拆法是三层INT0P3.2下降沿触发只做一件事把定时器的计数值读出来、清零、重新启动然后丢进全局变量。中断里不做任何判断、不调函数、不碰 LCD。TIM0 模式 116 位自由跑1us 一个计数专门给 INT0 当秒表用。16 位最大计到 65.5ms 溢出而 NEC 最长的一段间隔是引导码的 13.5ms余量充足。主循环等g_frame_rdy置位做反码校验、键值映射、屏幕刷新。这些慢活全部挪出中断。提示INT0 中断服务函数里必须先TR0 0再读 TH0/TL0读完立刻TR0 1。51 的定时器是 8 位分两次读的如果读到 TH0 之后、读 TL0 之前恰好发生进位读出来就是一个错得离谱的值这种错误一天可能只出现几次最难查。3. NEC 解码链路从下降沿到 32 位帧的完整时序NEC 帧的结构是9ms 低 4.5ms 高引导码然后 8 位地址 8 位地址反码 8 位命令 8 位命令反码共 32 位低位先发。每一位都是 560us 低电平开头后面跟 560us 高电平表示逻辑 0跟 1690us 高电平表示逻辑 1。关键技巧在于「测什么」。如果中断设在下降沿那么两次下降沿之间的间隔恰好是「本位的低电平 本位的高电平」逻辑 0560us 560us ≈ 1.12ms逻辑 1560us 1690us ≈ 2.25ms引导码9ms 4.5ms 13.5ms重复码9ms 2.25ms 11.25ms四段区间互不重叠判据就非常干净阈值取 1.7ms 就能把 0 和 1 分开取 12ms 附近就能把引导码和重复码分开。这种「下降沿到下降沿」的测法比双边沿切换触发简单得多也不用在中断里改IT0是实际项目里最常用的一种。3.1 全局变量与中断服务函数先看中断里发生了什么。这段代码是整个解码模块的心脏短但每一行都有讲究。#include reg52.h sbit IR_PIN P3^2; /* 接收头 OUT同时也是 INT0 引脚 */ volatile unsigned int g_width; /* 相邻两个下降沿之间的宽度单位 us */ volatile unsigned char g_bit_cnt; /* 当前帧已收到的位数0~32 */ volatile unsigned long g_raw; /* 正在拼装的 32 位原始帧低位先入 */ volatile unsigned char g_frame_rdy; /* 1 主循环有一帧可取 */ volatile unsigned char g_repeat; /* 1 收到一次重复码 */ void ext0_isr(void) interrupt 0 { unsigned int w; TR0 0; /* 先停表锁住计数值 */ w (TH0 8) | TL0; TH0 0; TL0 0; TR0 1; /* 立刻重新开跑别耽误下一段 */ g_width w; if (w 12500 w 14500) { /* 13.5ms引导码清状态重新收 */ g_bit_cnt 0; g_raw 0; return; } if (w 10500 w 12000) { /* 11.25ms重复码长按连发用 */ g_repeat 1; return; } if (g_bit_cnt 32) return; /* 帧已满多余边沿直接丢 */ if (w 1700) { /* 2.25ms 判 11.12ms 判 0 */ g_raw | ((unsigned long)1 g_bit_cnt); } if (g_bit_cnt 32) { g_frame_rdy 1; g_bit_cnt 0; } }g_raw这里用的是| (1 g_bit_cnt)而不是左移累加因为 NEC 是低位先发这样第 0 个收到的位天然落在 bit0收满 32 位后地址、命令的位置正好和协议对齐主循环里不用再做位反转。g_bit_cnt在中断里自增到 32 后立刻清零是为了防止主循环还没取走数据时下一个引导码来了导致的混乱配合主循环里的g_frame_rdy判断构成一个最小的双缓冲。参数上唯一要动的是那两个阈值。如果手里这颗接收头的响应偏慢1.12ms 测出来可能是 1.2ms 甚至 1.3ms把 0 的判据放宽到w 1700仍然安全因为 1 的实测值通常不低于 2.1ms。真正要小心的是引导码13.5ms 的窗口给了 ±1ms 的容差如果你的晶振不是 12MHz 而是 11.0592MHz机器周期变成 1.085us13.5ms 会变成约 12442 个计数就掉出 12500 的下限了这时候必须重新标定窗口。3.2 主循环里的帧解析与反码校验中断只负责搬运解析放在主循环好处是校验失败时可以有返回值、可以打日志、可以驱动蜂鸣器提示。unsigned char g_key; /* 最终解析出来的命令字 */ bit g_key_new; /* 1 有新键值待处理 */ void nec_task(void) { unsigned char addr, addr_inv, cmd, cmd_inv; if (!g_frame_rdy !g_repeat) return; if (g_repeat) { /* 长按沿用上一次的键值 */ g_repeat 0; g_key_new 1; return; } g_frame_rdy 0; addr (unsigned char)(g_raw 0xFF); /* bit0~7 地址 */ addr_inv (unsigned char)((g_raw 8) 0xFF); /* bit8~15 地址反码 */ cmd (unsigned char)((g_raw 16) 0xFF); /* bit16~23 命令 */ cmd_inv (unsigned char)((g_raw 24) 0xFF); /* bit24~31 命令反码 */ if ((unsigned char)(addr ^ addr_inv) ! 0xFF) return; /* 地址校验失败 */ if ((unsigned char)(cmd ^ cmd_inv ) ! 0xFF) return; /* 命令校验失败 */ g_key cmd; g_key_new 1; }反码校验这一步千万别省。红外是单线异步传输干扰大的时候经常出现某一位被拉长或缩短拼出来的帧看起来「有值」实际上完全是错的。加上addr ^ addr_inv 0xFF和cmd ^ cmd_inv 0xFF两重校验能挡掉绝大多数误码。真正做过项目的都知道没有校验的解码器在日光灯下面会自己跳舞。g_repeat的处理也值得说一下。NEC 遥控器长按某个键时并不会重复发完整帧而是每 108ms 发一个 9ms2.25ms 的重复码主循环识别到重复码就直接沿用上次的g_key。如果想让长按有「连续加减」的效果就在这里累加一个计数器超过 3 次之后开始用 50ms 的间隔重复触发业务逻辑。3.3 一帧数据的验证路径烧录之后别急着接屏幕先验证解码本身。最省事的办法是在nec_task()里加一行串口打印void uart_send_frame(void) { SBUF (unsigned char)(g_raw 24); while (!TI); TI 0; SBUF (unsigned char)(g_raw 16); while (!TI); TI 0; SBUF (unsigned char)(g_raw 8); while (!TI); TI 0; SBUF (unsigned char)(g_raw ); while (!TI); TI 0; }打开串口助手十六进制显示按一下遥控器的「1」键你应该看到类似00 FF 30 CF这样的四字节。这里的00是地址用户码FF是地址反码30是命令CF是命令反码。不同遥控器的用户码不一样但「第二字节是第一字节的反码、第四字节是第三字节的反码」这条规律永远成立。如果四个字节看起来毫无规律说明位宽判据错了如果只有前两个字节正确、后两个错位通常是收满 32 位之后多受了一个边沿干扰检查一下g_bit_cnt 32那个提前返回有没有生效。4. 波形显示模块把 6.4ms 的包络画进 12864解码解决了「这个键是什么」波形显示解决的是「为什么是这个键」。很多时候解码出来是错的你得回头看接收头实际输出了什么是引导码没认出来还是某一位被拉宽了。这个模块的本质是一个 38ksps 的简易逻辑分析仪只不过采样深度只有 128 点。采样率定在 50us 一点128 点正好覆盖 6.4ms 的时间窗。为什么不采更密因为要覆盖 13.5ms 的整个引导码需要 270 个点12864 横向也只有 128 个像素采得再密也得抽稀不如一开始就把窗口卡在「引导码 前两位数据」上看起来最直观。要观察完整的 32 位帧就把采样间隔改成 500us窗口拉长到 64ms牺牲时间分辨率换全局视野。4.1 采样缓冲区与定时器 1 配置缓冲区放在 xdataSTC89C52RC 有 512 字节片内扩展 RAM128 字节的波形数组完全放得下不用外扩。#define WAVE_LEN 128 volatile unsigned char xdata g_wave[WAVE_LEN]; volatile unsigned char xdata g_wave_idx; volatile bit g_wave_full; void timer1_isr(void) interrupt 3 { /* 12MHz、12T 模式1 个机器周期 1us50us 就是 50 个计数 */ TH1 (65536 - 50) / 256; TL1 (65536 - 50) % 256; if (!g_wave_full) { g_wave[g_wave_idx] (unsigned char)IR_PIN; /* 只存 0 或 1 */ if (g_wave_idx WAVE_LEN) { g_wave_full 1; /* 采满即停等主循环取 */ } } }采样只存 0/1不存时间戳省内存。采满 128 点后停止采样而不是循环覆盖是为了保证画出来的波形在时间轴上是连续的、有确定起点的。重启采样的时机放在主循环刷新完屏幕之后把g_wave_idx 0; g_wave_full 0;清掉就形成「采样 → 画 → 清 → 再采样」的节奏。这样屏幕上永远显示最近一次采到的 6.4ms而不是一锅乱炖。4.2 显存画点、Bresenham 连线与坐标轴直接往 12864 的 GDRAM 里一个点一个点写效率低而且容易花屏。常规做法是在 RAM 里开一块 128×32 的显存按「页-列」组织和屏幕字节结构一一对应画完再整屏刷过去。#define WAVE_W 128 #define WAVE_H 32 unsigned char xdata g_vram[WAVE_W * WAVE_H / 8]; /* 512 字节 */ void vram_clear(void) { unsigned int i; for (i 0; i sizeof(g_vram); i) g_vram[i] 0x00; } void vram_dot(unsigned char x, unsigned char y) { unsigned int idx; if (x WAVE_W || y WAVE_H) return; idx (unsigned int)y * (WAVE_W / 8) (x 3); g_vram[idx] | (unsigned char)(1 (y 0x07)); /* 置位不擦除 */ }vram_dot只做置位不做异或所以同一位置重复画不会闪烁适合把采样点连成折线。接下来把相邻采样点用直线连起来不然 128 个点分散在 128 列上会有断点。void vram_line(unsigned char x0, unsigned char y0, unsigned char x1, unsigned char y1) { int dx (x1 x0) ? (x1 - x0) : (x0 - x1); int dy (y1 y0) ? (y1 - y0) : (y0 - y1); int sx (x1 x0) ? 1 : -1; int sy (y1 y0) ? 1 : -1; int err dx - dy; while (1) { vram_dot(x0, y0); if (x0 x1 y0 y1) break; if ((err 1) -dy) { err - dy; x0 sx; } if ((err 1) dx) { err dx; y0 sy; } } }Bresenham 里的dx、dy最大 128err 1最大 256用 C51 的 16 位int完全够不会溢出。在主循环里把 128 个采样点映射成 y 坐标电平为高画在上半区y 4为低画在下半区y 24相邻两点之间调一次vram_line6.4ms 的方波就出来了。坐标轴可以偷懒在 x0 和 x127 各画一条竖线当边框再在 y14 画一条虚线当零轴。时间刻度更简单128 点、50us 一点每 20 点画一个小竖线标 1ms、2ms、3ms 就够了用 8×8 字库的lcd_show_str()打上去。4.3 串口上传波形给没有 12864 的板子留后路手上只有 1602 或者干脆没有屏的时候串口上传是最省事的替代方案。协议不用复杂帧头 长度 数据 校验四段足够void wave_upload(void) { unsigned char i, sum 0; if (!g_wave_full) return; SBUF 0x55; while (!TI); TI 0; /* 帧头 1 */ SBUF 0xAA; while (!TI); TI 0; /* 帧头 2 */ SBUF WAVE_LEN; while (!TI); TI 0; /* 长度 */ for (i 0; i WAVE_LEN; i) { SBUF g_wave[i] ? 0x01 : 0x00; while (!TI); TI 0; sum g_wave[i]; } SBUF sum; while (!TI); TI 0; /* 累加和校验 */ }波特率建议 960012MHz 晶振下定时器 1 的初值是0xFD误差约 8%接收端用带容错的串口助手还能正常显示。上位机不用自己写找一个能按字节画柱状图的串口工具把 0x55 0xAA 之后的数据映射成两条水平线效果和 12864 上的波形是同构的。这条路径我在几个只有最小系统板的场合用过比临时飞线接屏幕靠谱得多。5. 调试避坑从「一直是高电平」到第一帧正确键值这一节是整篇里代价最高的部分。下面五条都是我在不同板子上真实遇到过的按「现象 → 原因 → 解决」列出来遇到问题时按顺序排查基本能定位。5.1 硬件层接收头压根没工作现象一示波器夹在 OUT 脚上按遥控器毫无反应电平恒为 5V。原因通常有两个。一是接收头的引脚定义搞错了HS0038B 和 VS1838B 的引脚排列并不完全相同有的批次是「OUT / GND / VCC」有的是「VCC / GND / OUT」插反了不但不工作还可能烧掉。二是 VCC 上的限流电阻选得太大有人照着网上随手抓了个 10kΩ接收头根本起不来。解决上电先用万用表量接收头三个脚VCC 脚必须是 4.8V 以上GND 脚对地必须是 0Ω。确认供电没问题再量 OUT静默时应该是 4.5V 以上的高电平。如果只有 2~3V说明接收头内部已经被 38kHz 干扰源比如 nearby 的开关电源持续触发把 100Ω 10uF 的 RC 滤波补上问题通常就消失了。现象二遥控距离只有几十厘米靠近才能解出键值。原因是接收头的供电没有去耦或者板子上有继电器、电机之类的大电流负载电源纹波把微弱的红外信号淹了。另一个常被忽略的点是接收头前方的面板材料深色亚克力对 940nm 红外的衰减非常厉害。解决在接收头 VCC 和 GND 之间就近并一个 0.1uF 瓷片电容再把 10uF 电解加上面板换成红外透过率高的深红色材料或者在面板上开一个小孔让接收头露出来。这几步做完通常能把距离拉回到 6~8 米。5.2 软件层有波形但解不对现象三串口打印出来的四字节第二个字节不是第一个字节的反码。这说明位宽判据错了或者边沿计数错了位。最常见的根因是定时器 0 的初值没清干净第一次进中断时读到的是上次溢出后的残留值导致第一位的宽度永远偏大。解决在main()里初始化 TIM0 时除了设TMOD和初值还要手动清一次TH0 0; TL0 0;再TR0 1同时在 INT0 的引导码分支里把g_bit_cnt和g_raw都清零。这两处缺一不可我当时只清了后者多花了一晚上。现象四解码偶尔正确连续按键时错帧率很高。原因在中断优先级。如果系统里还开了串口中断或者定时器 1 中断且它们的优先级不低于 INT0那么红外边沿就可能被延迟响应1.12ms 的位宽被拉长成 1.3ms 以上判据就失效了。解决把 INT0 设成高优先级IP 0x01;并在其他中断服务函数里尽量缩短执行时间。如果必须做长耗时操作改成置标志位、主循环处理。还有一个反直觉的点INT0 里读定时器的那几行代码本身就是临界区如果编译器优化级别开得高可能把TR0 1重排到读TL0之前加个volatile或者干脆把这几行写成汇编块能彻底杜绝。现象五12864 上波形画出来了但屏幕每隔几秒闪一次像被人擦了一下。这是刷屏策略的问题。如果每帧都先lcd_clear()再重画屏幕必然闪。ST7920 的整屏刷新本身就要几毫秒加上清屏的时间视觉上就是一次白闪。解决去掉清屏改成「先在g_vram里memset清零再画新内容最后整屏一次性写入 GDRAM」。这等于用 RAM 做双缓冲屏幕只会看到最终结果中间过程不可见。代价是刷新期间要占用大约 3ms 的主循环时间对红外解码没有影响因为解码在中断里跑。注意ST7920 进入扩展指令集0x34之后才能访问 GDRAM刷完记得切回基本指令集0x30否则下一次写字符会乱码。这个切换顺序写反了不会报错只会让屏幕内容变成一堆随机点。6. 二次开发把写死的键值改成可自学习的映射表原始程序里键值是硬编码的switch(g_key)里 case 一堆魔数换一个遥控器就得重编译。真要拿去用第一步就是把「键值」和「业务动作」解耦做成一张可以掉电保存的映射表。#include intrins.h #define KEY_MAX 8 typedef struct { unsigned long code; /* NEC 帧低 16 位地址 命令 */ unsigned char action; /* 业务动作编号0 表示空槽 */ } keymap_t; keymap_t xdata g_keymap[KEY_MAX]; /* STC 内部 EEPROM 扇区擦除地址按实际型号调整 */ void eeprom_erase(unsigned int addr) { ISP_ADDRH addr 8; ISP_ADDRL addr 0xFF; ISP_CONTR 0x81; /* 使能 IAP 擦除 */ _nop_(); _nop_(); _nop_(); _nop_(); }这张表的关键设计是code只存低 16 位地址 命令不存反码因为反码在解码阶段已经校验过了存下来纯属浪费。action用 0 表示空槽学习模式下扫到第一个action 0的位置就写入比维护一个计数器省事。EEPROM 写入必须整扇区擦除STC89C52RC 的扇区大小是 512 字节所以一张 8 项的映射表占不到一个扇区但擦除时整个扇区都会丢如果同一扇区还放了别的参数得先把它们读出来再一起写回去。学习流程大致是这样长按板载按键 3 秒进入学习态屏幕提示「请按键」nec_task()每收到一个新键值就调用一次keymap_save(code, action)把当前待绑定的动作编号写进去然后自动退到下一个动作。整个过程不需要 PC 介入遥控器拿来就能用。验证方法也很直接加一个调试命令把g_keymap里 8 项全部通过串口打印出来对照遥控器实测的键值逐个比对。我一般会额外加一条「同一 code 出现两次」的检查学习阶段如果用户重复按了同一个键直接覆盖旧槽位而不是新建避免表被写满。改造时有两点值得提前想清楚。第一code用低 16 位意味着无法区分「相同地址 相同命令」的两个遥控器家用场景下这没关系但如果是多设备共存的场合得把完整 32 位存下来。第二EEPROM 的擦写次数是有限的十万次量级学习模式下要加防抖别让一个抖动按键在 200ms 内触发十几次写入。说个自己踩过的坑最早我把映射表放在idata里编译能过运行也正常但一断电全丢用户每次上电都得重新学一遍。后来改成 xdata 加上电时从 EEPROM 读回才算真正能用。从那以后我每次做掉电保存都会先拔一次电源、再重新上电、再按一遍按键确认键值真的还在才敢说这个功能做完了。完整的程序、原理图、元件清单和设计说明都在压缩包里解压后先对着第 2 章的目录把工程打开编译一遍出 HEX 烧进去串口看到那四字节反码对称的数据这个项目就通了。希望帮到你。本文还有配套的精品资源点击获取