简介:一套基于单片机控制的遥控心形灯项目资料,面向电子爱好者、单片机初学者,以及希望通过真实项目理解LED显示与遥控解码的开发者。压缩包共18个文件,主要涵盖C语言源代码、hex烧录文件、电路原理图PDF、遥控使用说明与焊接注意事项图片、元件清单Excel以及Keil工程配置文件,整体仅628KB,目录结构清晰,便于按需查找;目前已有323人学习。内容围绕心形灯从硬件到软件的实现流程展开,主程序、中断服务函数、遥控解码模块、LED驱动与PWM亮度控制等关键代码一应俱全,同时附有原理图与实物图说明,能够辅助完成电路连接、程序烧录和整机调试。除了核心源码与硬件设计文件外,还保留了编译调试生成的lst、m51、obj等中间文件,方便对照理解编译链接过程。对想动手做智能小制作或入门单片机开发的读者来说,这是一套可直接参考的完整案例。
1. 遥控心形灯不是“点亮LED”那么简单
做遥控心形灯整个流程里,最不值钱的是买材料,最值钱的是把单片机程序和遥控信号接起来。很多资料给一张原理图、一段能跑的流水灯,就宣称“完整”,结果你焊好板子、下载完程序,灯确实亮了,遥控器按了却没反应。问题几乎都出在单片机程序里的红外解码段。这篇文章用 STC89C52RC 这颗最常见的 51 单片机,把心形灯从硬件驱动、NEC 红外解码到多模式灯效的完整链路讲清楚。适合正在做电子课程设计、或者想在一个具体项目里把 51 单片机程序源码逐段读明白的读者。文中参数表按 12MHz 晶振给出,可以直接套用。
2. 心形灯硬件搭建:LED矩阵与驱动电路选型
2.1 心形LED阵列的矩阵化布局
心形灯不能每颗 LED 单独占一个 IO,一颗 LED 正极接高、负极接低才能点亮,独立控制 40 颗以上 LED 就要 80 多个引脚,51 单片机根本没有这个资源。所以第一步就是把灯排成矩阵。
最容易采购的现成板子是 8×8 点阵,共阳或共阴都有,焊成心形后每 8 个 LED 共享一行或一列。以共阳阵列为例子,行引脚接 LED 正极,列引脚接负极;反过来共阴也一样。扫描时一次只让一行导通,把这一行对应的 8 列数据送到 IO 口,快速扫完 8 行,利用视觉暂留效果形成完整画面。
定义一个心形图案,用 8 个字节就能表示,每一字节代表一行,置 1 的位表示该位置的点亮:
code unsigned char heart_shape[8] = { 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x3C, 0x18 }; // 0x18 = 00011000,表示这一行中间两颗晶亮这套数组是心形灯所有灯效的基础。流水灯、闪烁、呼吸灯,最终都是去改这 8 个字节里的内容,而不是直接控制 60 多颗 LED。
2.2 驱动电路:ULN2003、74HC595还是三极管
矩阵本身不解决电流问题。51 单片机每个 IO 只能提供大约 20mA,8 颗 LED 同时点亮就接近上限,而且整片 LED 亮度会塌下去。常见方案有三种。
| 方案 | 接线复杂度 | 单路电流 | 适用场景 |
|---|---|---|---|
| 74HC595 串转并 | 中,需 3 根控制线 | 20mA | 引脚紧缺、灯点多的场合 |
| ULN2003 达林顿管 | 低,直接接行脚 | 500mA | 共阳阵列,行驱动首选 |
| S8050 三极管阵列 | 中,每行一个基极电阻 | 200mA | 发光管功率较大的自制板 |
我一般会用 ULN2003 做行驱动、P0 口直接驱动列。ULN2003 内部自带续流二极管,驱动感性负载反而要注意,LED 矩阵是纯阻性,更不用担心。74HC595 的优点是只用 3 根线就能扩展 8 路输出,适合引脚已经被占满的 STC15 系列,但它的刷新需要移位时钟,代码量会多一段,对新手不算友好。
选择原则一句话:行列扫描的刷新率要大于 50Hz,否则人眼能看到闪烁;驱动方案只要保证单行 8 颗灯同时点亮时不掉压,什么方案都成立。
2.3 引脚分配与点亮第一颗LED的最小代码
整套系统的最小引脚分配如下,这张表建议贴在调试电脑上:
| 模块 | 信号名称 | 单片机引脚 | 说明 |
|---|---|---|---|
| 心形灯行驱动 | R1~R8 | P2.0~P2.7 | 接 ULN2003 输入,低电平选中 |
| 心形灯列驱动 | C1~C8 | P0.0~P0.7 | 接 LED 阴极,高电平点亮 |
| 红外接收头 | IR_OUT | P3.2 | 接 HS0038 OUT 脚,必须使用 INT0 |
| 工作指示灯 | LED_SYS | P1.0 | 上电自检闪烁用 |
先写一个最小点亮函数确认硬件没有虚焊。共阳阵列里,行线由 ULN2003 反相驱动,给 P2 写 0xFE 表示选通第一行,P0 写图案字节,这一行的 8 颗灯就会亮起来:
void display_line(unsigned char line, unsigned char col_data) { P2 = ~(0x01 << line); // 选中第 line 行,低电平有效 P0 = col_data; // 该行 8 列的电平状态 delay_us(100); // 短延时保持亮度 } void test_heart(void) { unsigned char i; for (i = 0; i < 8; i++) { display_line(i, heart_shape[i]); } }delay_us(100)是扫描的关键参数:8 行全部扫一遍要用 800us,刷新率约 1.2kHz,远超 50Hz 闪烁下限,还留有余量。如果把这个延时加大到 2ms,整体刷新率会掉到 62Hz 左右,眼睛敏感的人会看到轻微闪烁。实际调参时优先调这个延时,不要动主循环频率。
3. 单片机程序核心:NEC红外解码与按键映射
3.1 NEC协议帧结构与时间阈值
市售遥控器绝大多数走 NEC 协议,载波 38kHz,经 HS0038 接收后从输出引脚得到的是解调后的高低电平。一个完整的 NEC 帧由这几段组成:9ms 引导码,4.5ms 空闲,随后是 32 位数据;最后还有 8ms 左右的停止位。
数据位靠高低电平宽度区分。逻辑 0 是 560us 低电平加 560us 高电平,总共约 1.12ms;逻辑 1 是 560us 低电平加 1.69ms 高电平,总共约 2.25ms。遥控器按住按键不放时发送的是重复码,只有 9ms 引导码和 2.25ms 空闲,不携带数据。
单片机程序解码的关键就是测量相邻下降沿之间的时间差。HS0038 输出在无信号时是高电平,收到载波时低电平。32 位数据的每一位都以一个下降沿开始,所以定时器只要不停记录下降沿间隔,大于阈值判定为 1,小于阈值判定为 0,整个 NEC 帧就拆解完成了。
3.2 用INT0下降沿捕获做逐位解码
让 P3.2 同时作为 INT0 输入,下降沿触发中断,再把定时器 0 配成 16 位模式,读取每次中断时的定时器计数值。两次计数的差值就是位宽的微秒数,12MHz 晶振下机器周期 1us,定时器计数值直接对应微秒,省去换算。
下面是一段可直接编译的 51 单片机程序,完成引导码检测和 32 位逐位移入:
// NEC遥控解码:INT0下降沿 + 定时器0计时 #include <reg52.h> sbit IR_IN = P3^2; bit ir_ok = 0; // 一帧数据接收完成 unsigned char ir_key; // 解码出来的键码 unsigned char ir_addr; // 用户码 unsigned long ir_buffer; // 32位原始数据 static unsigned char ir_bit_cnt; static unsigned char ir_state = 0; static unsigned long ir_tmp; void int0_isr(void) interrupt 0 { unsigned int cnt = (TH0 << 8) | TL0; // 读取当前计数值 TH0 = 0; TL0 = 0; if (cnt > 11000) { // 超过11ms,判定是引导码,准备收32位数据 ir_state = 1; ir_bit_cnt = 0; ir_tmp = 0; return; } if (ir_state == 1) { ir_tmp <<= 1; if (cnt > 1700) { ir_tmp |= 1; // 2.25ms左右为逻辑1 } // cnt在1.1ms左右时什么都不做,ir_tmp按0处理 ir_bit_cnt++; if (ir_bit_cnt == 32) { ir_state = 0; ir_buffer = ir_tmp; ir_key = (unsigned char)(ir_tmp >> 16); ir_addr = (unsigned char)(ir_tmp >> 24); ir_ok = 1; } } } void timer0_init(void) { TMOD = 0x01; // 定时器0,16位模式 TH0 = 0; TL0 = 0; TR0 = 1; } void main(void) { timer0_init(); EX0 = 1; // 开INT0中断 IT0 = 1; // 下降沿触发 EA = 1; while (1) { if (ir_ok) { ir_ok = 0; // 灯效切换逻辑写在这里 } } }cnt > 11000是引导码判定阈值。NEC 引导码加空闲总长约 13.5ms,但定时器从下降沿才开始计时,第一个边沿到数据位第一个边沿之间正好经历 9ms 载波加 4.5ms 空闲。考虑到中断晚到、时钟偏差,11ms 作为阈值可以稳妥地区分引导码和普通数据位。逻辑判定阈值取 1700us,因为逻辑 1 的完整周期 2.25ms、逻辑 0 只有 1.12ms,中间有 1ms 左右的裕量,不会误判。
还有一个值得注意的点:这段代码不处理重复码。按住遥控器不放时,重复码的间隔远超过 16 位定时器溢出周期,需要额外计数器扩展。多数灯效场景不需要连续动作,不处理重复码反而更可控。
3.3 反码校验与真实遥控器按键映射
NEC 协议为了避免误码,32 位数据里包含了用户码、用户码反码、键码、键码反码。正常做法是校验反码再响应,否则两个数据位出错时灯效会乱跳。
继续用 3.2 节的解码结果,校验和映射放在主循环里:
void process_ir(void) { unsigned char key; if (!ir_ok) return; // 检查反码:用户码和键码都应互为反码 if (((unsigned char)(ir_buffer & 0xFF)) != (unsigned char)(~(ir_buffer >> 8))) return; key = (unsigned char)((ir_buffer >> 16) & 0xFF); if (key != (unsigned char)(~(ir_buffer >> 24))) return; switch (key) { case 0x45: mode = 1; break; // 遥控器数字键1 case 0x46: mode = 2; break; // 数字键2 case 0x47: mode = 3; break; // 数字键3 case 0x44: mode = 4; break; // 数字键4 case 0x40: mode = 5; break; // 数字键5 default: break; } }0x45、0x46这类键值是最常见的一款 NEC 遥控器的编码,但不同遥控器键码不一样。拿到新遥控器时先写一个把ir_key原样送到 PWM 灯或串口打印的测试程序,把每个按键的实际码值记录下来,再倒推映射表。这步看着琐碎,却能省掉后面数个小时的无效调试。
4. 心形灯灯效程序:流水灯、呼吸灯与模式切换
4.1 一个显示函数驱动整片心形阵列
灯效代码不应该直接操作 P0 和 P2,那样每换一个图案就要重复写一遍扫描逻辑。把扫描函数固定下来,主循环只负责改显示缓冲区,这是单片机程序源码里最常见的结构。
定义二维数组frame当作显存,显示循环不停地把frame里的 8 字节刷到 LED 上:
unsigned char frame[8] = {0,0,0,0, 0,0,0,0}; void display_refresh(void) { unsigned char i; for (i = 0; i < 8; i++) { P2 = ~(0x01 << i); P0 = frame[i]; delay_us(120); } P0 = 0xFF; // 消隐:关闭所有列,防止拖影 }末尾的P0 = 0xFF是很多人会漏的一步。如果不消隐,下一行切换瞬间上一行的残余数据会串到当前行,高亮图案边缘会出现淡淡的拖影。显示刷新被主循环循环调用即可,不要放在延时函数里递归调用。
4.2 流水灯效果:帧动画与位移动
流水灯单片机程序最直观的做法不是写死 8 帧,而是用一个左移循环生成动态帧。心形灯沿轮廓流动的效果,可以把frame里的位按顺序循环移动,每移动一次刷新一定次数再移位,这样速度可控。
void led_flow_run(unsigned char speed) { unsigned char i, j; for (j = 0; j < 32; j++) { // 连续移动32次,覆盖一轮 for (i = 0; i < 8; i++) { frame[i] = (heart_shape[i] << j) | (heart_shape[i] >> (8 - j)); } // 用计数方式实现速度调节 for (i = 0; i < speed; i++) { display_refresh(); } } }speed参数控制每帧保持的时间,数值越大流动越慢。这里用循环嵌套调用display_refresh(),而不是在display_refresh里加长延时,原因很实在:显示函数要保持固定刷新频率,灯效速度是另一层时间控制,混在一起时不同灯效之间的速度感会完全失控。
4.3 呼吸灯效果与软件PWM实现
呼吸灯要用 PWM 调亮度,51 单片机硬件 PWM 资源少,网上流传的软件方案大多是延时调占空比。精度要求不高时用计时器定时 2ms 周期,在中断里翻转 IO 电平,简单且不阻塞主循环。
一个可行的呼吸实现是让定时器中断周期为 2ms,中断里每次增减一个步长值,步长跑完一个周期后改变方向:
unsigned char pwm_cnt = 0; unsigned char pwm_step = 5; bit pwm_dir = 0; // 0表示渐亮,1表示渐暗 void timer1_isr(void) interrupt 3 { pwm_cnt += pwm_step; if (pwm_cnt >= 100) { pwm_direction = 1; } else if (pwm_cnt <= 5) { pwm_direction = 0; } if (pwm_direction == 1) { P0 = 0x00; // 暗 } else { P0 = heart_shape[0]; // 亮 } } void breath_start(unsigned char step) { pwm_step = step; TMOD &= 0x0F; TMOD |= 0x10; // 定时器1,16位模式 TH1 = (65536 - 2000) >> 8; // 12MHz下2ms TL1 = (65536 - 2000); ET1 = 1; TR1 = 1; }这段代码把整片 LED 当单灯来呼吸,如果要心形轮廓内逐点呼吸,需要把P0的赋值换成按灰度级查表刷新。pwm_step决定单次亮度增量,取值 5 时一个呼吸周期约 200 个中断周期、0.4s;取 10 时约 0.2s。建议最小 3、最大 20,超出这个区间肉眼会明显感觉到跳变或拖沓。
4.4 模式切换主循环与参数说明
所有灯效共用一套状态机,模式由变量mode控制,遥控键直接写mode。主循环结构固定为:刷新显示、检查遥控标志、按模式执行对应的效果函数。
| 模式 | 遥控按键 | 效果 | 推荐参数 |
|---|---|---|---|
| 0 | 任意键待机 | 全灭 | 无 |
| 1 | 1 | 心形常亮 | 亮度上限 80% |
| 2 | 2 | 流水灯 | speed = 80 |
| 3 | 3 | 呼吸灯 | pwm_step = 5 |
| 4 | 4 | 闪烁(1Hz) | 亮 400ms / 灭 400ms |
| 5 | 5 | 全部灯效循环 | 每 5 秒切换 |
实现时注意一个细节:灯效函数不能有死循环阻塞。led_flow_run内部的 32 次循环要设计成可以被mode修改打断的状态机,否则按遥控器换模式时,必须等当前 32 帧跑完才响应,观感会很差。做法是在每次循环开头检查一个mode_changed标志,被遥控器触发后立即return。
5. 调试技巧与几个必踩的坑
5.1 先看波形再改代码
遥控解码调不通,八成不是代码逻辑错,而是接收端电路或时序阈值问题。先拿逻辑分析仪夹在 P3.2 和 GND 上,按遥控器按键,观察波形里有没有完整的 NEC 格式脉冲串。如果连 9ms 引导码都看不到,检查 HS0038 的供电是否 5V、OUT 脚是否接反;如果波形正常但解码无效,重点检查中断触发方式和定时器初值。逻辑分析仪采样率 500kS/s 以上足够,不需要买高档设备。
5.2 STC下载与电源细节
STC89C52RC 下载程序需要冷启动,点“下载”后在软件提示的 5 秒内断电再上电,没反应时把 USB 转串口的 DTR/RTS 信号线反过来插一次。硬件上 P0 口必须接 10k 排阻上拉,否则点亮 LED 后电平被拉低,单片机程序运行会时好时坏。矩阵扫描时瞬时电流能达到 300mA 以上,用 USB 线直供容易复位,至少保证 1A 电源,或在电源两端并一个 100uF 电解电容。
5.3 移植到其他单片机时怎么改
这套代码结构换到 STC15 或 STM32 时,只需要把INT0_ISR改成对应外部中断回调,把pwm_cnt换成硬件定时器更新事件。需要注意的只有阈值:3.2 节的两个阈值基于 12MHz 机器周期计算,STC15 的 1T 模式或 11.0592MHz 晶振下,cnt > 11000和cnt > 1700必须按实际时钟缩放,否则引导码和逻辑位全部误判。改完时钟先跑一遍“按一次键亮一个灯”的最简实验,再上整套灯效,问题定位会快很多。
本文还有配套的精品资源,点击获取