旧版底层 vs 新版底层
一、核心差异总览(一眼定乾坤)
| 对比维度 | 旧版底层 | 新版底层 | 为什么要改 |
|---|---|---|---|
| 初始化写法 | 直接写 P2 = P2 & 0x1f | 0x80,一行搞定 |
用 temp 临时变量分三步操作,注释详细 |
旧版是“一步到位”但可读性差,新版是“分步操作”更安全,防止P2口其他位被意外修改 |
| LED控制 | Led_Disp(addr, enable) 控制单个LED |
Led_Disp(unsigned char *ucLed) 控制8个LED数组 |
新版支持批量控制,一次调用刷新全部LED,效率更高 |
| 按键扫描 | 简单粗暴,直接读P3口 | 增加了串口冲突检测 + 组合键检测 | P3.0同时是串口RXD引脚,新版防止把串口数据误判成按键,这是大坑! |
| 蜂鸣器/继电器 | 代码被注释掉了,没启用 | 独立成函数 Beep()、Relay()、Motor(),状态缓存防重复刷新 |
旧版“留了坑没填”,新版填好了直接用 |
| 数码管 | 段码表在RAM里,没优化 | 用 pdata 关键字存段码表,消隐步骤更严谨 |
新版显示更稳定,减少鬼影 |
二、硬件初始化篇——从“黑盒”到“说明书”
旧版 Init.c:一行流,简洁但吓人
void System_Init()
{
// LED
P0 = 0xff;
P2 = P2 & 0x1f | 0x80; // ← 这一行小白直接懵
P2 &= 0X1f;
// 蜂鸣器、继电器
P0 = 0x00;
P2 = P2 & 0x1f | 0xa0; // ← 为什么是0xa0?
P2 &= 0X1f;
}
问题:
P2 = P2 & 0x1f | 0x80这行代码,没有注释,小白看到0x1f、0x80直接原地去世。- 没有用临时变量,直接操作P2口,容易把P2口的低5位状态搞乱(虽然实际影响不大,但代码风格不严谨)。
新版 init.c:分步走,注释拉满
void System_Init()
{
unsigned char temp; // ← 多了个临时变量
// 关闭所有LED
P0 = 0xff;
temp = P2 & 0x1f; // 1. 先读出P2低5位
temp = temp | 0x80; // 2. 再设置高3位为100(选中Y4)
P2 = temp; // 3. 最后写回去
temp = P2 & 0x1f; // 4. 取消选中,锁存数据
P2 = temp;
// 关闭继电器、蜂鸣器(同理,用0xa0选中Y5)
P0 = 0x00;
temp = P2 & 0x1f;
temp = temp | 0xa0;
P2 = temp;
temp = P2 & 0x1f;
P2 = temp;
}
比喻:
- 旧版:像蒙着眼睛换灯泡——手伸进去一通操作,亮了就行,但不知道摸到了什么。
- 新版:像按操作手册换灯泡——先断电、再拆旧、再装新、再通电,每一步都有记录。
为什么要改?
| 旧版问题 | 新版改进 |
|---|---|
| 直接修改P2口,代码“一步到位”但不可读 | 用 temp 分步操作,逻辑清晰,一看就懂 |
| 注释基本没有 | 每一行都有注释,连为什么要 &0x1f 都解释了 |
新手容易把 0x80 和 0xa0 记反,导致LED和蜂鸣器串扰 |
注释里明确写了 Y4控制LED,Y5控制蜂鸣器/继电器 |
三、按键扫描篇——从“莽夫”到“带脑子扫描”
旧版 Key.c:四个按键的简单扫描
unsigned char Key_Read()
{
unsigned char temp = 0;
P44 = 0; P42 = 1; P35 = 1; P34 = 1;
if(P33 == 0) temp = 4;
if(P32 == 0) temp = 5;
if(P31 == 0) temp = 6;
if(P30 == 0) temp = 7;
// ... 后面还有三行扫描
return temp;
}
问题:
- 完全没有防抖,按键按下去可能读到乱码。
- 没有串口冲突检测——P3.0是串口RXD引脚,如果串口正在接收数据,P3.0被拉低,会被误判成按键S7按下!
新版 key.c:加了“智能检测”
unsigned char Key_Read()
{
unsigned char temp=0;
// 扫描第一行
P44=1;P42=1;P35=1; // 先全部置高
// *** 关键:串口冲突检测 ***
if(P30==0) return 0; // ← P3.0被拉低?说明串口在忙,直接退出!
P44=0;P42=1;P35=1;
if(P30==0) temp=7;
if(P31==0) temp=6;
if(P32==0) temp=5;
if(P33==0) temp=4;
// ... 后续行扫描同理
// 特殊功能:组合键检测
if((P32==0)&&(P33==0)) return 89; // ← S9和S8同时按下返回89
return temp;
}
比喻:
- 旧版:像超市收银员——不管顾客有没有准备好,上来就扫码,扫错也不管。
- 新版:像机场安检——先确认通道没人(串口不忙),再放人通过(扫描按键),还支持“双人同时过”(组合键检测)。
为什么要改?
| 旧版问题 | 新版改进 |
|---|---|
| P3.0和串口共用,串口数据会被当成按键 | 每次扫描前检查 if(P30==0) return 0;,串口忙就主动退出 |
| 没有防抖,按键容易误触 | 虽然没有软件防抖,但配合定时器扫描(后续在main里处理)可以做到 |
| 只返回单一按键值 | 支持 组合键检测,比如 S9+S8 同时按下返回 89 |
| P3.4被占用用于频率测量,但代码没注释说明 | 注释明确写了 P3.4用于频率测量,所以第四行扫描被注释掉 |
四、LED控制篇——从“单个控制”到“批量刷新”
旧版 Led.c:控制单个LED
void Led_Disp(unsigned char addr, unsigned char enable)
{
static unsigned char temp = 0x00;
static unsigned char temp_old = 0xff;
if(enable)
temp |= 0x01 << addr; // 点亮第addr个LED
else
temp &= ~(0x01 << addr); // 熄灭
if(temp != temp_old) // 状态变化才刷新硬件
{
P0 = ~temp;
P2 = P2 & 0x1f | 0x80;
P2 &= 0x1f;
temp_old = temp;
}
}
用法:想控制LED1亮,调用 Led_Disp(0, 1);想控制LED3灭,调用 Led_Disp(2, 0)。
问题:每次只能控制一个LED,要同时控制多个LED就得调用多次,效率低。
新版 led.c:控制8个LED数组
void Led_Disp(unsigned char *ucLed) // ucLed是8个元素的数组
{
// 把8个0/1合并成一个字节
temp_1 = (ucLed[0] << 0) | (ucLed[1] << 1) | ... | (ucLed[7] << 7);
if (temp_1 != temp_1_old) // 状态变化才刷新
{
P0 = ~temp_1;
// 选中Y4锁存器...
temp_1_old = temp_1;
}
}
用法:
unsigned char led_status[8] = {1, 0, 1, 0, 0, 0, 0, 0}; // LED1亮、LED3亮
Led_Disp(led_status); // 一次调用全部刷新
为什么要改?
| 旧版问题 | 新版改进 |
|---|---|
| 一次只能控制1个LED,控制8个要调8次 | 一次传入8个状态,一次刷新全部 |
| 每个LED状态变化都要操作硬件 | 数组整体变化才操作硬件,减少IO操作次数 |
| 没有“全部关闭”函数 | 新增 Led_Off() 函数,一键全灭 |
比喻:
- 旧版:像单个开关——要关8个灯得来回跑8趟。
- 新版:像总控面板——一拨开关全部搞定。
五、数码管篇——旧版少了什么?
旧版 Seg.c:
unsigned char Seg_Dula[] = {0xc0,0xf9,...}; // 段码表
unsigned char Seg_Wela[] = {0x01,0x02,...}; // 位选表
void Seg_Disp(unsigned char wela, dula, point)
{
P0 = 0xff; // 消隐
P2 = P2 & 0x1f | 0xe0; // 选中段选锁存器
P2 &= 0x1f;
P0 = Seg_Wela[wela]; // 位选
P2 = P2 & 0x1f | 0xc0;
P2 &= 0x1f;
P0 = Seg_Dula[dula]; // 段选
if(point) P0 &= 0x7f;
P2 = P2 & 0x1f | 0xe0;
P2 &= 0x1f;
}
问题:段码表存在 RAM 里,没做存储优化。
新版 seg.c:
pdata unsigned char seg_dula[]={0xc0,0xf9,...}; // ← 加了 pdata 关键字
改进:
pdata关键字把段码表存到 分页外部RAM,不占用宝贵的内部RAM空间。- 消隐步骤更严谨:先消隐、再位选、再段选,减少鬼影。
比喻:
- 旧版:把菜谱放在厨房台面(内部RAM)上,占地方。
- 新版:把菜谱放在储物柜(外部RAM)里,要用再拿,台面更宽敞。
六、蜂鸣器/继电器篇——从“注释”到“成品”
新版 led.c:
void Beep(bit enable)
{
if (enable) temp_2 |= 0x40;
else temp_2 &= (~0x40);
if (temp_2 != temp_2_old)
{
P0 = temp_2;
// 选中Y5锁存器...
temp_2_old = temp_2;
}
}
void Relay(bit enable) { /* 同理,bit 4控制 */ }
void Motor(bit enable) { /* 同理,bit 5控制 */ }
改进:
- 状态缓存机制:只有状态变化才刷新硬件,减少IO操作。
比喻:
- 旧版:给你一套零件。
- 新版:直接给你成品,插电即用。
七、实战避坑指南(针对小白)
新手用新底层最容易犯的3个错误
错误1:在 Led_Disp() 里传错数组大小
// ❌ 错误写法
unsigned char led[5] = {1,1,1,1,1};
Led_Disp(led); // 只传了5个,但函数要读8个!读到垃圾数据!
正确做法:数组必须是8个元素,不够就补0:
unsigned char led[8] = {1,1,1,1,1,0,0,0}; // 前5个亮,后3个灭
Led_Disp(led);
错误2:在按键扫描里加延时防抖
// ❌ 错误写法——把延时写在Key_Read()里
unsigned char Key_Read()
{
// ...
if(P30==0) {
Delay_ms(10); // ← 千万不能在这里延时!
temp=7;
}
// ...
}
后果:按键扫描通常放在定时器中断或主循环快速调用,在里面加延时会导致整个程序卡住,数码管闪烁。
正确做法:在 main.c 里用状态机或计数变量做防抖,Key_Read() 只负责读取当前状态,不负责延时。
错误3:数码管显示时忘了“动态扫描”
// ❌ 错误写法——只调用一次Seg_Disp()
void main()
{
System_Init();
Seg_Disp(0, 5, 0); // 只显示第0位为5
while(1);
}
后果:只有一位数码管亮,其他全灭——因为数码管是动态扫描的,需要定时刷新每一位。
正确做法:在定时器中断里轮流刷新8位数码管:
// 定时器中断(1ms一次)
void Timer0_Isr() interrupt 1
{
static unsigned char pos = 0;
Seg_Disp(pos, Seg_Buf[pos], 0); // Seg_Buf里存着要显示的数字
pos = (pos + 1) % 8;
}
一句话总结
新底层 = 旧底层的“完全体”:
解决了 P3.0串口冲突 这个大坑
LED支持批量控制,效率更高
数码管段码表优化存储,省内部RAM
旧版主函数 vs 新版主函数
一、核心差异总览(一眼定乾坤)
| 对比维度 | 旧版 main.c |
新版 main.c |
为什么要改 |
|---|---|---|---|
| 定时器选择 | 使用 定时器0(中断号1) | 使用 定时器1(中断号3) | 定时器0留给更高级的功能(如红外遥控、PWM输入捕获),定时器1专门做系统心跳 |
| 中断服务函数命名 | Timer0Server(非标准命名) |
Timer1_Isr(标准命名) |
新版命名更规范,一看就知道是定时器1的中断服务函数 |
| LED刷新方式 | Led_Disp(Seg_Pos, ucLed[Seg_Pos]) 每次只刷新1个LED |
Led_Disp(ucLed) 一次刷新全部8个LED |
新版批量刷新更高效,而且配合PWM调光 |
| 数码管显示逻辑 | 直接用 Seg_Buf[Seg_Pos] 显示 |
增加了小数点判断技巧:数字>20时点亮小数点 | 新版封装了小数点自动处理,更智能 |
| 变量存储类型 | 普通变量(默认在data区) | 加了 pdata、idata 关键字 |
新版优化内存分配,把大数组放外部RAM,关键变量放内部RAM |
| PWM调光功能 | 没有 | 有——软件PWM控制LED亮度 | 国赛常考PWM调光,新版提前把框架搭好了 |
| 减速计数器 | 按键10ms、数码管500ms | 按键10ms、数码管20ms | 新版数码管刷新更快更流畅,500ms太慢了会闪烁 |
二、变量声明篇——从“随便放”到“精打细算”
旧版:变量全堆在data区
unsigned char Key_Slow_Down; // 默认存在内部RAM(data区)
unsigned char Key_Val,Key_Old,Key_Down,Key_Up;
unsigned int Seg_Slow_Down; // ← unsigned int 占2字节
unsigned char Seg_Pos;
unsigned char Seg_Buf[8] = {...}; // 8字节,占data区
unsigned char Seg_Point[8] = {...}; // 又8字节
unsigned char ucLed[8] = {...}; // 再8字节
问题:STC15的内部RAM(data区)只有128字节,这么多个数组+变量一放,快爆了!而且 Seg_Slow_Down 用 unsigned int 纯粹浪费(计数到500就够了,unsigned char 足矣)。
新版:变量按需分配存储位置
pdata unsigned char ucLed[8] = {0,...}; // 存到分页外部RAM(pdata区)
pdata unsigned char Seg_Buf[8] = {10,...}; // 存到分页外部RAM
idata unsigned char Seg_Pos = 0; // 存到内部RAM(idata区),访问速度最快
idata unsigned char Seg_Slow_Down; // 内部RAM
idata unsigned char Key_Val, Key_Old, Key_Up, Key_Down;
idata unsigned char Key_Slow_Down;
idata unsigned int AD_1_Data_100x; // AD值确实需要unsigned int
比喻:
- 旧版:把所有东西都塞进随身口袋(data区),口袋快撑破了。
- 新版:把大件行李放托运舱(pdata区),随身只带贵重物品(idata区),空间分配合理。
存储类型速查表(小白必存)
| 关键字 | 存储位置 | 容量 | 访问速度 | 适合放什么 |
|---|---|---|---|---|
data(默认) |
内部RAM低128字节 | 128B | 最快 | 频繁使用的变量(循环变量、标志位) |
idata |
内部RAM 256字节 | 256B | 快 | 需要快速访问的变量 |
pdata |
外部RAM分页(一页256B) | 256B | 较慢 | 大数组(LED状态、数码管缓冲) |
xdata |
外部RAM 全部 | 最大 | 最慢 | 超大数组(不常用) |
三、定时器篇——从“定时器0”到“定时器1”
旧版:定时器0做系统心跳
void Timer0_Init(void) // 1毫秒@12.000MHz
{
AUXR &= 0x7F; // 12T模式
TMOD &= 0xF0; // 模式1(16位定时器)
TL0 = 0x18; TH0 = 0xFC;
TR0 = 1; // 启动定时器0
ET0 = 1; // 使能定时器0中断
EA = 1;
}
void Timer0Server() interrupt 1 // ← 中断号1
{
// 刷新数码管、LED
}
新版:定时器1做系统心跳
void Timer1_Init(void) // 1毫秒@12.000MHz
{
AUXR &= 0xBF; // 12T模式(注意:0xBF清的是T1的位)
TMOD &= 0x0F; // 清空T1的模式位
TL1 = 0x18; TH1 = 0xFC;
TR1 = 1; // 启动定时器1
ET1 = 1; // 使能定时器1中断
EA = 1;
}
void Timer1_Isr(void) interrupt 3 // ← 中断号3
{
// 刷新数码管、LED、PWM
}
为什么要从定时器0换到定时器1?
| 原因 | 解释 |
|---|---|
| 预留定时器0 | 定时器0可以用于红外遥控解码、频率测量、PWM输入捕获等高级功能,这些功能在国赛省赛里经常考。把定时器0空出来,以后扩展更方便 |
| 中断优先级管理 | 定时器1的中断优先级默认比定时器0低,但系统心跳不需要最高优先级,把高优先级留给更紧急的中断(如串口接收) |
| 代码规范 | Timer1_Isr 这个命名一看就知道是定时器1的中断,而 Timer0Server 命名不规范(Server是服务端的意思,跟中断没关系) |
比喻:
- 旧版:让总经理(定时器0)每天打卡签到(做系统心跳),大材小用。
- 新版:让前台(定时器1)负责打卡签到,总经理(定时器0)留着处理紧急事务(频率测量/红外解码)。
四、中断服务函数篇——从“大杂烩”到“精工细作”
旧版中断:什么活都干
void Timer0Server() interrupt 1
{
if(++Key_Slow_Down == 10) Key_Slow_Down = 0; // 按键减速
if(++Seg_Slow_Down == 500) Seg_Slow_Down = 0; // 数码管减速(500ms!太慢了)
if(++Seg_Pos == 8) Seg_Pos = 0; // 位置循环
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], Seg_Point[Seg_Pos]); // 刷新数码管
Led_Disp(Seg_Pos, ucLed[Seg_Pos]); // 刷新单个LED
}
问题:
Seg_Slow_Down计数到500才清零——也就是每500ms才处理一次数码管显示数据,但数码管刷新是每1ms都在刷新的!这意味着你改了Seg_Buf里的数据,最多要等500ms才能看到变化!Led_Disp(Seg_Pos, ucLed[Seg_Pos])每次只刷新1个LED,要8ms才能刷完一轮,PWM调光?不存在的。
新版中断:分工明确,功能丰富
void Timer1_Isr(void) interrupt 3
{
// 1. 减速计数器自增(只做++,不判断清零,由各Proc函数判断)
Seg_Slow_Down++;
Key_Slow_Down++;
// 2. 数码管动态扫描(每1ms刷一位)
Seg_Pos = (++Seg_Pos) % 8; // 更简洁的写法
if (Seg_Buf[Seg_Pos] > 20)
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos] - ',', 1); // 显示数字+小数点
else
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], 0);
// 3. LED软件PWM调光(10级亮度)
pwm_period = (++pwm_period) % 10;
if (pwm_period < pwm_compare)
Led_Disp(ucLed); // 点亮
else
Led_Off(); // 熄灭
}
改进点:
| 改进 | 解释 |
|---|---|
| 减速计数器只++不判断 | 把“是否达到阈值”的判断放到 Key_Proc() 和 Seg_Proc() 里,逻辑更清晰 |
| 数码管刷新极快 | 每1ms刷新一位,8ms一轮,显示丝滑无闪烁 |
| 小数点自动处理 | Seg_Buf[Seg_Pos] > 20 这个技巧很骚——存数字时加个逗号的ASCII码(44),用来标记“这个位要点亮小数点” |
| 软件PWM调光 | 在中断里做10级PWM,LED亮度可调,国赛必考 |
新版的“小数点技巧”详解(小白必看)
// 在 Seg_Proc() 里设置数据时:
Seg_Buf[0] = 5; // 正常显示数字5,不带小数点
Seg_Buf[1] = 5 + 44; // 显示数字5,带小数点(44是逗号','的ASCII码)
// 在中断里显示时:
if (Seg_Buf[Seg_Pos] > 20) // 如果大于20,说明这个数字被加了44
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos] - ',', 1); // 减掉44得到真实数字,点亮小数点
else
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], 0); // 正常显示
比喻:就像在快递盒子上贴个红色标签(加44),分拣员(中断)看到红色标签就知道这单要加急(点亮小数点)——不用额外开个数组记录小数点状态,省内存又高效!
五、主循环篇——结构几乎一样,但细节有差
旧版 main():
void main()
{
System_Init();
Timer0_Init();
while(1)
{
Key_Proc();
Seg_Proc();
Led_Proc();
}
}
新版 main():
void main()
{
System_Init();
Timer1_Init(); // 换成定时器1
while(1)
{
Key_Proc();
Seg_Proc();
Led_Proc();
}
}
结构上一模一样,只是定时器换成了1号。这说明新版完全向下兼容,你以前写的业务逻辑(Key_Proc、Seg_Proc)不用改,只是底层驱动更强大了。
六、减速机制篇——从“中断判断”到“函数判断”
旧版:在中断里判断减速阈值
// 中断里
if(++Key_Slow_Down == 10) Key_Slow_Down = 0;
// 主循环里
void Key_Proc()
{
if(Key_Slow_Down) return; // 不为0就退出
Key_Slow_Down = 1; // 手动置1
// ... 执行按键逻辑
}
流程:中断计数到10→清零→主循环检测到0→执行→手动置1→中断继续计数…
新版:中断只负责++,主循环判断阈值
// 中断里
Key_Slow_Down++; // 只++,不清零
// 主循环里
void Key_Proc()
{
if (Key_Slow_Down < 10) return; // 没到10ms就退出
Key_Slow_Down = 0; // 到了10ms,清零重新计数
// ... 执行按键逻辑
}
比喻:
- 旧版:闹钟响了(中断清零)→ 你醒来干活(执行Key_Proc)→ 干完关灯继续睡(手动置1)——流程复杂,容易搞混。
- 新版:你看着秒表(Key_Slow_Down++)→ 到了10秒就干活(判断>=10)→ 干完秒表归零(=0)——逻辑清晰,一眼就懂。
七、PWM调光篇(新版独有)
新版在中断里加了一段软件PWM代码,这是旧版完全没有的:
// PWM周期10ms,占空比由 pwm_compare 控制(0-9)
pwm_period = (++pwm_period) % 10;
if (pwm_period < pwm_compare)
Led_Disp(ucLed); // 亮
else
Led_Off(); // 灭
原理:
pwm_period从0数到9,一共10ms一个周期。pwm_compare是亮度阈值,范围0-9。- 比如
pwm_compare = 6:前6ms亮,后4ms灭,亮度60%。 - 改变
pwm_compare就能调光——国赛经常考“呼吸灯”或“PWM调光”。
比喻:就像开关灯——1秒内开0.6秒、关0.4秒,人眼看到的就是60%亮度。
八、实战避坑指南(针对小白)
用新模板最容易犯的3个错误
错误1:Seg_Buf里直接放大于20的数,忘记减44
// ❌ 错误写法
Seg_Buf[0] = 44; // 想显示数字0带小数点,但44 = ',' 逗号!
正确写法:
Seg_Buf[0] = 0 + 44; // 显示0带小数点
Seg_Buf[1] = 5 + 44; // 显示5带小数点
Seg_Buf[2] = 10; // 显示全灭(10是'熄灭',不是小数点)
错误2:在Led_Proc里调用Led_Disp,跟中断抢资源
// ❌ 错误写法
void Led_Proc()
{
ucLed[0] = 1;
Led_Disp(ucLed); // ← 中断里已经在调用Led_Disp和Led_Off了,你再调一次会打架!
}
后果:LED显示乱闪,PWM调光失效。
正确做法:Led_Proc() 只修改 ucLed[] 数组,不调用Led_Disp,让中断自动刷新。
// ✅ 正确写法
void Led_Proc()
{
ucLed[0] = 1;
ucLed[1] = 0;
// 只修改数组,让中断去刷新硬件
}
错误3:忘记初始化 pwm_compare,导致LED不亮
新版默认 pwm_compare = 6,但如果你在 Led_Proc 里不小心把它改成0,LED就全灭了。
// ❌ 错误写法
void Led_Proc()
{
pwm_compare = 0; // 占空比0%,LED全灭!
ucLed[0] = 1; // 设置了也没用
}
正确做法:pwm_compare 在变量声明时初始化好,不要在Led_Proc里改它(除非题目要求调光)。
九、总结:以后怎么“抄作业”最省力?
旧版→新版迁移对照表
| 旧版写法 | 新版写法 | 改完有什么好处 |
|---|---|---|
Timer0_Init() |
Timer1_Init() |
把定时器0留给更高级的功能 |
Timer0Server() interrupt 1 |
Timer1_Isr() interrupt 3 |
命名规范,一看就懂 |
Seg_Point[8] 数组存小数点 |
Seg_Buf[x] + 44 标记小数点 |
省8字节RAM,代码更简洁 |
Led_Disp(addr, enable) 单灯控制 |
Led_Disp(ucLed) 批量控制 |
一次刷新全部,效率更高 |
中断里 if(++x==10)x=0 |
中断里 x++,主循环 if(x>=10) |
逻辑分离,更清晰 |
| 无PWM功能 | 中断里自带软件PWM | 国赛常考功能,框架已搭好 |
新模板“填空式”代码框架
┌─────────────────────────────────────────────────┐
│ 需要你手动添加的内容(你的业务逻辑) │
├─────────────────────────────────────────────────┤
│ Seg_Proc() { │
│ Seg_Buf[0] = 1; // 第0位显示1 │
│ Seg_Buf[1] = 2 + 44; // 第1位显示2. │
│ Seg_Buf[7] = 10; // 第7位熄灭 │
│ } │
│ │
│ Led_Proc() { │
│ ucLed[0] = 1; // LED1亮 │
│ ucLed[1] = 0; // LED2灭 │
│ // 改 pwm_compare 可以调亮度(0-9) │
│ pwm_compare = 6; // 60%亮度 │
│ } │
│ │
│ Key_Proc() { │
│ if (Key_Down == 7) { // S7被按下 │
│ // 写你的按键逻辑 │
│ } │
│ } │
├─────────────────────────────────────────────────┤
│ 绝对不能动的地方(底层框架) │
├─────────────────────────────────────────────────┤
│ ✅ Timer1_Init() 初始化 │
│ ✅ Timer1_Isr() interrupt 3 中断服务 │
│ ✅ main() 的 while(1) 调用顺序 │
│ ✅ Key_Read() 底层驱动 │
│ ✅ Seg_Disp() 底层驱动 │
└─────────────────────────────────────────────────┘
一句话终极建议
放弃旧模板,拥抱新模板! 新模板:
解决了P3.0串口冲突
优化了内存分配
自带PWM调光框架
数码管显示更稳定
调度器代码深度分析——从“手工减速”到“自动调度”
一、调度器本质:把“人工计时”变成“闹钟自动提醒”
旧方式(手工减速)
// 中断里
if(++Key_Slow_Down == 10) Key_Slow_Down = 0;
// 主循环里
void Key_Proc()
{
if(Key_Slow_Down) return; // ← 每次进函数都要手动检查
Key_Slow_Down = 1;
// 干活...
}
问题:每个任务都要自己管自己的计时器,代码重复、容易写漏、维护麻烦。
新方式(调度器统一管理)
// 任务列表——把所有任务的周期统一登记在册
task_t Scheduler_Task[] = {
{Led_Proc, 1, 0}, // LED任务,每1ms执行一次
{Key_Proc, 10, 0}, // 按键任务,每10ms执行一次
{Seg_Proc, 20, 0}, // 数码管任务,每20ms执行一次
// ... 想加任务就加一行
};
// 调度器统一检查:到时间了就自动调用
void Scheduler_Run()
{
for (i = 0; i < task_num; i++) {
if (now_time >= Scheduler_Task[i].rate_ms + Scheduler_Task[i].last_ms) {
Scheduler_Task[i].task_func(); // ← 自动执行!
}
}
}
比喻:
- 旧方式:每个员工自己看表——“到10点了吗?到了我就干活”——容易有人偷懒或看错时间。
- 新方式:公司统一打卡系统——“滴,到点了,你该干活了”——公平、准确、省心。
核心优势总结
| 对比维度 | 手工减速 | 调度器 |
|---|---|---|
| 代码重复度 | 每个任务都要写减速变量+判断 | 统一管理,一行数组搞定 |
| 添加新任务 | 要加变量、加中断代码、加判断 | 数组里加一行 {新函数, 周期, 0} |
| 修改周期 | 全局搜索改代码 | 数组里直接改数字 |
| 可读性 | 逻辑分散在中断和主循环 | 一看任务列表就知道所有任务的执行频率 |
二、你的调度器代码逐行审查
结构体定义——规范
typedef struct
{
void (*task_func)(void); // 任务函数指针
unsigned long int rate_ms; // 任务执行周期(毫秒)
unsigned long int last_ms; // 上次执行的时间戳
} task_t;
评价:标准写法,没毛病。unsigned long int 能计到49天(2^32 / 1000 / 3600 / 24 ≈ 49.7天),比赛绰绰有余。
任务列表——清晰
idata task_t Scheduler_Task[] = {
{Led_Proc, 1, 0}, // 1ms ——最快
{Key_Proc, 10, 0}, // 10ms ——按键消抖
{Seg_Proc, 20, 0}, // 20ms ——显示刷新
{Get_Time, 100, 0}, // 100ms ——RTC读取
{Get_Temperature, 300, 0},// 300ms ——温度读取(DS18B20比较慢)
{AD_DA, 150, 0}, // 150ms ——AD/DA转换
{Get_Distance, 120, 0}, // 120ms ——超声波测距
{Uart_Proc, 10, 0}, // 10ms ——串口处理
};
评价:周期分配非常合理!
| 任务 | 周期 | 为什么合理 |
|---|---|---|
Led_Proc |
1ms | 配合PWM调光,响应快,亮度变化丝滑 |
Key_Proc |
10ms | 标准按键消抖周期,10ms检测一次刚刚好 |
Seg_Proc |
20ms | 数码管显示内容变化不需要太快,20ms够用 |
Get_Temperature |
300ms | DS18B20转换一次最长750ms,300ms读一次不阻塞系统 |
Get_Distance |
120ms | 超声波测距一次几十ms,120ms周期给足时间 |
Scheduler_Run 核心逻辑——正确
void Scheduler_Run()
{
unsigned char i;
for (i = 0; i < task_num; i++)
{
unsigned long int now_time = uwTick; // ← 获取系统心跳
if (now_time >= (Scheduler_Task[i].rate_ms + Scheduler_Task[i].last_ms))
{
Scheduler_Task[i].last_ms = now_time;
Scheduler_Task[i].task_func();
}
}
}
三、
潜在坑点排查(90%的人会踩)
坑点1:uwTick 在哪里更新???
你的调度器依赖 uwTick 这个全局变量来获取当前时间。代码里没看到它的定义和更新逻辑。
你必须确保:在1ms定时器中断里,有 uwTick++ 这行代码!
// 在 Timer1_Isr() 里必须加上这一行!!!
void Timer1_Isr(void) interrupt 3
{
uwTick++; // ← 没有这一行,调度器就是死的!
// ... 其他代码
}
如果没有:now_time 永远是0,now_time >= rate + last 永远为真,所有任务每1ms全部执行一遍——CPU直接跑满,比裸奔还差!
坑点2:Led_Proc 周期是1ms,但它里面做了什么?
如果你的 Led_Proc 里面直接操作硬件(调用 Led_Disp 或 P0 = ...),那问题大了:
// ❌ 危险写法——如果 Led_Proc 是这样
void Led_Proc()
{
Led_Disp(ucLed); // ← 直接在1ms任务里操作硬件!
}
后果:定时器中断里已经在做 Led_Disp 和 Led_Off 的PWM交替了,你再在任务里调用 Led_Disp,两个地方同时抢硬件,LED显示会乱闪!
正确写法:
// ✅ 安全写法——Led_Proc只改数组,不动硬件
void Led_Proc()
{
// 只修改 ucLed 数组,让中断里的PWM去刷新硬件
ucLed[0] = 1;
ucLed[1] = 0;
// 不调用 Led_Disp()!!!
}
坑点3:Seg_Proc 周期20ms,但数码管刷新在中断里
你的数码管硬件刷新在 Timer1_Isr 里(每1ms刷一位),而 Seg_Proc 只是往 Seg_Buf 里填数据。
// ✅ 正确分工
// 中断里(1ms):硬件刷新
Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], ...); // 读 Seg_Buf
// Seg_Proc(20ms):填数据
Seg_Buf[0] = 5; // 写 Seg_Buf
只要 Seg_Proc 不直接调用 Seg_Disp,就没问题。
坑点4:任务数量计算
task_num = sizeof(Scheduler_Task) / sizeof(task_t);
没问题,这是标准写法。但注意 sizeof 在编译时计算,不会在运行时额外开销。
坑点5:时间戳溢出问题
uwTick 是 unsigned long,溢出时间约49天。比赛连续运行最多几小时,不用担心。
但如果想做到绝对完美,可以改成:
// 安全写法——防止时间戳溢出导致任务跳过
if ((now_time - Scheduler_Task[i].last_ms) >= Scheduler_Task[i].rate_ms)
{
Scheduler_Task[i].last_ms = now_time;
Scheduler_Task[i].task_func();
}
用差值比较而不是 now_time >= last + rate,可以避免 last + rate 溢出。(不过你跑的时长不会溢出,这属于锦上添花)
坑点6:调度器轮询会不会太慢?
你的任务数 = 8个,Scheduler_Run 每次主循环遍历8次。
- 每次遍历 = 几次比较 + 可能调用函数
- 主循环频率 ≈ 几万次/秒(无延时)
- 8个任务的遍历开销 ≈ 微秒级
结论:完全没问题,51单片机轻松驾驭。
四、调度器 + 中断 = 完美的“双引擎”架构
你的代码现在是这个结构:
┌─────────────────────────────────────────────────────────────┐
│ 定时器1中断(1ms) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 1. uwTick++(给调度器提供时间基准) │ │
│ │ 2. 数码管动态扫描(每1ms刷一位) │ │
│ │ 3. LED PWM调光(10ms周期,软件PWM) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↑
提供时间基准
│
┌─────────────────────────────────────────────────────────────┐
│ 主循环 while(1) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Scheduler_Run() { │ │
│ │ 遍历8个任务,判断 uwTick 是否到达执行时间 │ │
│ │ ├─ Led_Proc(1ms) → 改 ucLed[] │ │
│ │ ├─ Key_Proc(10ms) → 读按键,更新Key_Down │ │
│ │ ├─ Seg_Proc(20ms) → 改 Seg_Buf[] │ │
│ │ ├─ Get_Time(100ms) → 读RTC │ │
│ │ ├─ Get_Temperature(300ms)→ 读DS18B20 │ │
│ │ ├─ AD_DA(150ms) → 读AD/写DA │ │
│ │ ├─ Get_Distance(120ms)→ 读超声波 │ │
│ │ └─ Uart_Proc(10ms) → 处理串口收发数据 │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
这个架构的牛逼之处:
- 中断负责高频、硬实时的工作(显示刷新、PWM)——不能有任何延迟
- 主循环负责低频、软实时的工作(按键、传感器读取、串口)——偶尔延迟几十ms没关系
- 调度器作为桥梁,把时间基准(
uwTick)转换成各个任务的执行节奏
这其实就是一个小型RTOS(实时操作系统)的雏形!