第十五届单片机国赛:复盘
Writer: 你还真是满奈子想着你的篮球杯呢
导语
本文档完整记录了本项目从"基础功能实现"到"底层架构重构"的全过程。按照实际开发中遇到的问题顺序,从基础逻辑、串口通信、坐标运动算法,一路打磨到系统底层与状态机设计。
阶段一:基础功能与逻辑控制陷阱
问题 1:LED 反复亮灭(电平触发引发的无限循环)
目标功能
设备到达目标坐标后,LED 亮 3 秒,然后熄灭(且只触发一次)。
初始错误代码:
if(Time_3s < 3000) {
Reach_Flag = (X_Equipment == X_Destination && Y_Equipment == Y_Destination);
}
// 中断中:
if(++Time_3s == 3000) { Reach_Flag = 0; }
现象与分析:
这是一个电平触发(Level Trigger)。只要坐标相等的条件成立,系统就会不断把 Reach_Flag 置 1。导致现象为:置1 → 计时3秒 → 清0 → 下一轮又被置1 → 无限循环反复亮灭。
改进方案(边沿触发):
引入当前状态与上一次状态的对比,只在"未到达 → 到达"的瞬间触发一次。
问题 2:边沿触发失效(作用域错误)
初始错误代码:
idata bit Last_Reach; // 写在函数内部
现象与分析:
每次函数调用都会重新定义变量,值不被保存(默认为 0)。导致 Current = 1, Last = 0 永远成立,还是重复触发。
改进方案:
状态变量必须是全局变量或静态变量。
idata bit Last_Reach = 0; // 必须全局且赋初值
问题 3:计时逻辑高耦合(标志位职责混乱)
问题分析:
Reach_Flag 同时承担了"控制 LED"和"控制计时"两个角色,耦合过高,逻辑混乱。
改进方案(信号解耦):
将信号拆分为 Reach_Trig(单次触发)和 Reach_Show(持续显示)。
idata bit Reach_Trig; // 触发信号(一次)
idata bit Reach_Show; // 显示信号(3秒)
// 1. 到达检测 (上升沿)
Current_Reach = (X_Equipment==X_Destination && Y_Equipment==Y_Destination);
if(Current_Reach && !Last_Reach) {
Reach_Trig = 1;
Time_3s = 0;
Status = 3; // 推荐加入完整状态机设计:3-到达
}
Last_Reach = Current_Reach;
// 2. 定时器中断执行
if(Reach_Trig) {
Reach_Show = 1;
if(++Time_3s >= 3000) {
Reach_Show = 0;
Reach_Trig = 0;
Status = 0; // 回到空闲
}
}
// 3. LED控制
ucLed[2] = Reach_Show;
核心总结
这几步本质上解决了**“如何把一个持续条件,变成一次事件”**的核心痛点。
阶段二:串口通信与数据解析问题
问题 4:命令解析不完整与负数下溢
问题分析:
原代码只能解析坐标 (x,y)。且解析负数 B 参数时,使用 %bu 会导致解析错误。
改进方案:
增加多分支解析,并利用 %bd 处理有符号 8 位整数。
// 解析坐标:(100,233)
if(sscanf(Uart_Rx_Buf, "(%bu,%bu)", &x, &y) == 2) {
X_Destination = x; Y_Destination = y;
}
// 解析状态:S0 S1 S2
else if(sscanf(Uart_Rx_Buf, "S%bu", &x) == 1 && x <= 2) {
Status = x;
}
// 解析R参数:R15
else if(sscanf(Uart_Rx_Buf, "R%bu", &x) == 1 && x >= 10 && x <= 20) {
R_Para = x;
}
// 解析B参数(支持负数):B-45 或 B30
else if(sscanf(Uart_Rx_Buf, "B%bd", &x) == 1) {
B_Para = (char)x;
}
附录:STC15 printf/sscanf 格式符对照表
| 格式符 | 对应类型 | 说明 |
|---|---|---|
%bd |
char |
有符号8位整数(可解析负数) |
%bu |
unsigned char |
无符号8位整数 |
%d / %u |
int / unsigned int |
16位整数(有符号/无符号) |
%f |
float |
浮点数(sscanf支持不完整,慎用) |
%s |
char * |
字符串 |
%bx |
unsigned char |
十六进制无符号8位 |
注:b 前缀是 Keil C51 特有,表示 byte。
阶段三:坐标模拟运动开发的阵痛与重构
问题 5:坐标计算的类型下溢与方向写反
初始错误代码:
idata unsigned char DetaX; // 错误类型
X_Equipment -= step_x; // 方向写反
现象与分析:
当目标小于当前坐标时,unsigned char 下溢变成大正数(-5 变 251)。方向用减法导致向正方向时坐标乱跳(0→255→254)。
改进方案:
改用有符号 int,更新坐标使用加法。
问题 6:步进叠加导致越走越快
初始错误代码:
unsigned char step_x = (unsigned char)((reduce * DetaX) / Init_Distance);
X_Equipment += step_x; // 错误:每次把累积量又加了一遍
现象与分析:
由于 step_x 是从起点算起的"总位移",每次用 += 叠加会导致误差成倍放大。
阶段性改进方案(记录起点法):
X_Equipment = X_Equipment_Origin + (long)Walked_Distance * DetaX / Init_Distance;
问题 7(致命):1 秒跳跃跳出轨迹(核心物理模型重构)
问题深度分析:
上述方案采用"每 1 秒在定时器里跳跃一次" + “按累计距离判断到达”,存在三大致命缺陷:
-
不平滑:1 秒更新一次,小车走走停停。 -
易偏离:累计距离法只要方向稍微算错,累计距离够了但坐标对不上。 -
无修正:全程沿用初始计算的 DetaX/Y,无实时修正能力。
终极重构方案(1ms 连续微小步进):
将运动逻辑移入 1ms 调度的 Ctrl_Proc,每次计算"当前到目标的剩余距离"和实时方向向量。
idata unsigned long int Last_Tick = 0;
idata bit First_Flag = 0;
pdata float X_Float = 0, Y_Float = 0; // 必须用浮点防精度截断
pdata float X_Dest_Float = 0, Y_Dest_Float = 0;
void Ctrl_Proc() {
idata unsigned long int Dt_Ms;
idata float Dt, Dx, Dy, Dist, Step;
if(Status != 1) return;
// 1. 首次启动记录浮点起点与时间基准
if(First_Flag == 0) {
X_Float = (float)X_Equipment; Y_Float = (float)Y_Equipment;
X_Dest_Float = (float)X_Destination; Y_Dest_Float = (float)Y_Destination;
Last_Tick = uwTick;
First_Flag = 1;
return;
}
// 2. 计算本次时间差 dt
Dt_Ms = uwTick - Last_Tick;
Last_Tick = uwTick;
if(Dt_Ms == 0) return;
Dt = (float)Dt_Ms / 1000.0f;
// 3. 实时计算剩余距离与方向
Dx = X_Dest_Float - X_Float;
Dy = Y_Dest_Float - Y_Float;
Dist = sqrt(Dx*Dx + Dy*Dy);
if(Dist <= 0) return;
// 4. 计算本次应走距离
Step = ((float)Speed_10x / 10.0f) * Dt;
// 5. 到达判断(剩余距离不足一步)
if(Dist <= Step) {
X_Float = X_Dest_Float; Y_Float = Y_Dest_Float;
X_Equipment = X_Destination; Y_Equipment = Y_Destination;
Status = 0; Speed_10x = 0; Start_Move_Flag = 0; First_Flag = 0;
Reach_Flag = 1;
} else {
// 沿向量走一小步
X_Float += (Dx / Dist) * Step;
Y_Float += (Dy / Dist) * Step;
X_Equipment = (int)X_Float;
Y_Equipment = (int)Y_Float;
}
}
阶段四:系统底层与状态机填坑(解决卡死玄学)
问题 8:Timer0 初始化瘫痪
错误代码:
AUXR &= 0x80;(清空了其他所有位,且若初始为0,bit7 也没置位)。
改进方案:
永远使用或运算置位 AUXR |= 0x80;。
问题 9:上电卡死或自动跳界面(未初始化 idata)
现象与分析:
STC 系列单片机 idata 上电不清零。Status、Seg_Mode 初值随机,导致程序走入异常 switch 分支。
改进方案:
所有关键状态变量必须显式赋初值。
idata unsigned char Status = 0;
idata unsigned char Seg_Mode = 0;
问题 10:栈溢出危机(未使用的 idata 变量)
现象与分析:
定义了 temp_Speed、Key_Down_data 等多个没用到的 idata 变量。8051 的 idata 仅有 256 字节,挤压栈空间后,一旦调用 sqrt 或 sscanf 极易引发栈溢出,导致各种玄学 Bug(如切换不了界面)。
改进方案:
果断删除所有未使用的变量,释放内存。
问题 11:到达判断"早产"与卡死在运行模式不动
现象与错误代码:
刚启动就立刻触发到达,或者速度为 0 时永远卡死在运行。
if(Dist <= 0.5f) return; // 错误:目标=当前时,直接return不触发到达
if(Speed_10x == 0) return; // 错误:速度为0时直接跳过,丧失了更新机制
改进方案:
去掉提前 return,统一用 if(Dist > 0) 包裹移动逻辑。速度为 0 时 Step 算出来自然是 0,不需要额外打断流转。
问题 12:负 B 值导致速度溢出崩溃
现象与分析:
B_Ctrl 为负,直接强转 unsigned int 会变成极大的正数。
改进方案:
分正负处理。
if(B_Ctrl < 0)
Speed_10x = (unsigned int)((float)(3.14*R_Ctrl*Freq)/100.0f - (float)(-B_Ctrl)*10);
else
Speed_10x = (unsigned int)((float)(3.14*R_Ctrl*Freq)/100.0f + (float)B_Ctrl*10);
阶段五:按键业务流转(彻底告别死锁)
问题 13:彻底卡死在运行模式(缺失退出机制)
现象与分析:
一旦进入运行模式,如果速度一直为 0(无频率且 B=0),小车永远走不到终点。此时按 S4 没反应,因为 Key_Proc 漏写了运行中切回等待的处理。
改进方案:
补全状态流转。
case 4:
if(Status == 0 && Get_Coordinate_Flag) {
Status = 1; First_Flag = 0;
Reach_Flag = 0; // ✅ 修复幽灵数据:进入时清除旧标志
Get_Coordinate_Flag = 0; // ✅ 清除坐标标志
}
else if(Status == 1) { // ✅ 修复卡死根因:补充运行->等待
Status = 2;
}
else if(Status == 2 && Clear_Rubbish_Flag) {
Status = 1; First_Flag = 0;
}
break;
问题 14:参数界面外被"悄悄篡改"
现象与分析:
在坐标/速度界面按 S12/S13,不知不觉把 R/B 参数改掉了。
改进方案:
控制类按键必须与 UI 界面绑定。
case 12:
if(Seg_Mode == 2) { // 必须限制在参数界面
if(Para_Select==0) R_Para = (R_Para>=20)?20:R_Para+1; // 修复边界保护逻辑
else B_Para = (B_Para>=90)?90:B_Para+5;
}
break;
总结
题目不能只看文字描述 由于只看S4的功能描述导致漏掉了S4切换运行切换成等待模式的漏洞
idata 定义的变量不用 即时删除 不然内存爆满连数码管都显示不了
距离判断 用每一次前进是否已经抵达终点判断 比已前进距离和应前进距离的判断好
因为Speed的时序跟Ctrl_Proc()时序不一致 加入if(Dist>0) 和 if(Speed==0)的阻拦可以保障启动
每次启动时收到坐标标志位和初始化位置标志位 在按键层要置
关于速度的浮点计算 最后如果要显示要强转回uint 需要参加后续计算则不用