蓝桥杯单片机底层驱动——LED、数码管、蜂鸣器、继电器的完整实现
这四个外设是蓝桥杯每年必考的基础内容,而且它们有一个共同点:全部通过P0口+74HC573锁存器控制。搞懂了锁存器,这四个外设就都通了。
74HC573锁存器:CT107D硬件设计的灵魂
先把原理搞清楚。CT107D板子上LED灯、数码管段选、数码管位选、蜂鸣器、继电器一共需要20+个IO来控制,但P0口只有8个引脚。解决办法是用74HC573锁存器做"分时复用"——P0口同一时刻只驱动一个模块,数据锁存后就保持输出不变,P0口再切给下一个模块用。
P2口高3位选择打开哪个锁存器:
P2.7 P2.6 P2.5 | 选通器件
1 0 0 | U8 → LED灯 (0x80)
1 0 1 | U9 → 蜂鸣器+继电器 (0xA0)
1 1 0 | U7 → 数码管位选 (0xC0)
1 1 1 | U6 → 数码管段选 (0xE0)
标准的"开门关门"操作模板(所有锁存器操作都长这样):
unsigned char Temp;
P0 = 需要输出的数据; // 第1步:放数据到P0
Temp = P2 & 0x1f; // 第2步:保护P2低5位
Temp |= 0x80; // 第3步:设置高3位(改这个值切换锁存器)
P2 = Temp; // 第4步:开门,数据被锁存
Temp = P2 & 0x1f; // 第5步:关门
P2 = Temp;
这里有一个非常容易犯的错:忘记 P2 & 0x1f。P2.0和P2.1分别连接着I2C的SCL和SDA线,如果你不保护低5位,操作锁存器的时候会改变I2C引脚的电平,导致I2C通信出错。这个问题非常隐蔽,我一开始调I2C的时候纠结了半天才发现是这个原因。
LED驱动:低电平点亮 + 脏检测优化
CT107D的8个LED(L1~L8)是共阳接法,低电平点亮。这意味着你要让某个LED亮,需要输出0而不是1。
项目里的完整LED驱动代码(来自 Driver/Led.c):
#include <Led.h>
idata unsigned char Temp_0_Val, Temp_0_Old;
void Led_Disp(unsigned char* ucLed) {
unsigned char Temp;
Temp_0_Val = 0x00;
// 把数组中的8个LED状态合并为1个字节
Temp_0_Val = ucLed[0] | (ucLed[1]<<1) | (ucLed[2]<<2) | (ucLed[3]<<3)
| (ucLed[4]<<4) | (ucLed[5]<<5) | (ucLed[6]<<6) | (ucLed[7]<<7);
// 脏检测:数据没变就不操作锁存器
if(Temp_0_Val != Temp_0_Old) {
P0 = ~Temp_0_Val; // 取反!低电平点亮
Temp = P2 & 0x1f;
Temp |= 0x80; // 选通LED锁存器
P2 = Temp;
Temp = P2 & 0x1f;
P2 = Temp;
Temp_0_Old = Temp_0_Val;
}
}
void Led_Off() {
unsigned char Temp;
P0 = 0xff; // 全灭(低电平点亮,全1=全灭)
Temp = P2 & 0x1f;
Temp |= 0x80;
P2 = Temp;
Temp = P2 & 0x1f;
P2 = Temp;
Temp_0_Old = 0x00;
}
几个值得注意的设计细节:
1) 数组化接口:业务层用 ucLed[8] 数组控制每个LED,ucLed[0]=1 就是点亮L1。Led_Disp函数内部把数组转成一个字节再操作锁存器。这样上层代码不需要关心位操作。
2) 脏检测:if(Temp_0_Val != Temp_0_Old) 这一行是锁存器操作的重要优化。Led_Proc在调度器里每1ms执行一次,但LED状态可能几百毫秒才变一次。如果不做脏检测,每1ms都操作一次锁存器完全是浪费。
3) ~Temp_0_Val 取反:因为LED是低电平点亮。这个取反很容易忘,忘的话LED的亮灭逻辑就全反了。
蜂鸣器和继电器:高电平有效,共用一个锁存器
蜂鸣器和继电器跟LED不同,它们是高电平有效的。而且它们共用U9锁存器(0xA0):
- P0.4 → 继电器
- P0.6 → 蜂鸣器
项目代码里的实现:
idata unsigned char Temp_1_Val = 0x00, Temp_1_Old = 0xff; // 一定要赋初值!
void Relay(bit Enable) {
unsigned char Temp;
if(Enable)
Temp_1_Val |= 0x10; // P0.4 置1
else
Temp_1_Val &= ~(0x10); // P0.4 清0
if(Temp_1_Val != Temp_1_Old) {
P0 = Temp_1_Val; // 注意:不取反!高电平有效
Temp = P2 & 0x1f;
Temp |= 0xa0; // 蜂鸣器/继电器锁存器
P2 = Temp;
Temp = P2 & 0x1f;
P2 = Temp;
Temp_1_Old = Temp_1_Val;
}
}
void Beep(bit Enable) {
unsigned char Temp;
if(Enable)
Temp_1_Val |= 0x40; // P0.6 置1
else
Temp_1_Val &= ~(0x40); // P0.6 清0
if(Temp_1_Val != Temp_1_Old) {
P0 = Temp_1_Val;
Temp = P2 & 0x1f;
Temp |= 0xa0;
P2 = Temp;
Temp = P2 & 0x1f;
P2 = Temp;
Temp_1_Old = Temp_1_Val;
}
}
这里有一个非常巧妙的设计:继电器和蜂鸣器共用 Temp_1_Val 这个状态变量,通过位操作(|= 和 &=~)独立控制各自对应的位,互不干扰。比如你同时开继电器和蜂鸣器,Temp_1_Val = 0x50(P0.4和P0.6都为1),操作一次锁存器就能同时控制两个外设。
还有一个关键细节:Temp_1_Old = 0xff 的初始值。Temp_1_Val 初始为 0x00(全部关闭),Temp_1_Old 初始为 0xff。这样第一次调用 Relay 或 Beep 时,0x00 != 0xff 成立,脏检测通过,锁存器会被正确写入。如果你把 Temp_1_Old 也初始化为 0x00,那第一次调用时脏检测不通过,锁存器不会被写入——蜂鸣器和继电器的初始状态就不确定了。这个bug非常隐蔽,比赛时踩到基本就得跪。
LED/蜂鸣器/继电器的高/低电平对比:
| 外设 | 有效电平 | P0数据 | 初始化值 |
|---|---|---|---|
| LED | 低电平 | ~Temp_0_Val |
0xFF(全灭) |
| 继电器 | 高电平 | Temp_1_Val |
0x00(全关) |
| 蜂鸣器 | 高电平 | Temp_1_Val |
0x00(全关) |
数码管驱动:消影是关键
数码管是共阳极的,和LED一样低电平点亮。但跟LED不同的是,数码管需要动态扫描——每次只亮一位,8位轮流显示,利用人眼的视觉暂留效应看起来就像同时亮着。
消影问题是数码管驱动中最容易出bug的地方。先看看不做消影会怎样:
时刻1: 位选=第0位, 段选="3" → 第0位显示"3"
时刻2: 位选=第1位, 段选="5" → 但是在切换位选的瞬间,
第0位还没来得及灭掉,段选已经变成了"5"
→ 第0位短暂地闪烁了"5"(重影!)
解决办法就是消影——先关所有段选,再切换位选,最后开新的段选:
code unsigned char Seg_Dula[] = {
0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90, // 0~9
0xff, // 10: 全灭
0xC7, // 11: L
0xBF, // 12: -
0x86, // 13: E
0x8C // 14: P
};
void Seg_Disp(Wela, Dula, Point) {
unsigned char Temp;
/* 第一步:消影 —— 关闭所有段选 */
P0 = 0xff;
Temp = P2 & 0x1f;
Temp |= 0xe0; // 段选锁存器
P2 = Temp;
Temp &= 0x1f;
P2 = Temp;
/* 第二步:位选 —— 选哪一位 */
P0 = 0x01 << Wela;
Temp = P2 & 0x1f;
Temp |= 0xc0; // 位选锁存器
P2 = Temp;
Temp &= 0x1f;
P2 = Temp;
/* 第三步:段选 —— 显示什么 */
P0 = Seg_Dula[Dula];
if(Point) P0 &= 0x7f; // 显示小数点(清除最高位)
Temp = P2 & 0x1f;
Temp |= 0xe0; // 段选锁存器
P2 = Temp;
Temp &= 0x1f;
P2 = Temp;
}
消影三步曲:关段选 → 切位选 → 开段选。顺序不能乱。
小数点编码的巧妙设计
数码管缓冲区 Seg_Buf[8] 里的每个元素本应该存0~14的数字索引。但显示小数点怎么办?代码用了一个很巧妙的办法:
// 显示小数点:在数字上加上 ',' 的ASCII码(44)
Seg_Buf[6] = Speed_Now_10x / 10 % 10 + ',';
// 在中断里解码
if(Seg_Buf[Seg_Pos] >= ',') // >= 44 说明要显示小数点
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos] - ',', 1); // 减去44得到数字,Point=1
else
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], 0); // 不带小数点
这样就不用额外维护一个"小数点标志位数组"了,一个数组同时编码了数字值和小数点标志。
动态扫描在中断里完成
void Time1_Server() interrupt 3 {
// ... 其他处理 ...
Seg_Pos = (++Seg_Pos) % 8; // 每次+1,0~7循环
if(Seg_Buf[Seg_Pos] >= ',')
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos] - ',', 1);
else
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], 0);
}
Timer1每1ms中断一次,每次中断刷新一位,8次中断完成一轮扫描,刷新率 = 1000/8 = 125Hz,远超人眼50Hz的临界闪烁频率,所以看起来完全不会闪烁。
系统初始化
上电时必须正确初始化所有外设。Init.c 里的代码:
void System_Init() {
unsigned char Temp = 0x00;
/* LED初始化 —— 全部熄灭(低电平点亮,0xFF=全灭)*/
P0 = 0xff;
Temp = P2 & 0x1f;
Temp |= 0x80; // LED锁存器
P2 = Temp;
Temp = P2 & 0x1f;
P2 = Temp;
/* 蜂鸣器/继电器初始化 —— 全部关闭(高电平有效,0x00=全关)*/
P0 = 0x00;
Temp = P2 & 0x1f;
Temp |= 0xa0; // 蜂鸣器/继电器锁存器
P2 = Temp;
Temp = P2 & 0x1f;
P2 = Temp;
}
注意LED和蜂鸣器/继电器的初始化P0值正好相反——LED是 0xFF(全灭),蜂鸣器/继电器是 0x00(全关),因为它们的极性不同。
7474HC573、LED驱动、HC573、LED驱动、踩坑总结
- LED取反忘写:LED低电平点亮,P0要取反。这个错犯了至少三次。
- P2低5位没保护:
P2 & 0x1f不能省,否则I2C通信会出问题。 - 数码管没消影:必须是"关段选→切位选→开段选",直接切位选会重影。
Temp_1_Old初始值:必须是0xff,否则第一次操作锁存器不会生效。- 蜂鸣器不取反:蜂鸣器/继电器是高电平有效,P0直接赋值,不要取反!跟LED相反。
锁存器操作口诀:P0放数据,P2开门,P2关门。每个细节都不能错。
更多推荐



所有评论(0)