上篇文章讲完了第15届国赛代码的"基础设施"------串口协议、传感器读取和界面显示。这篇讲这道题真正的核心:运动轨迹算法。怎么在8位单片机上用浮点数模拟二维运动?速度怎么分解?怎么判断到达?以及按键交互的完整逻辑。
运动轨迹算法------整道题的灵魂
问题建模
这道题本质上是一个物理题:给定起点坐标 (Xnow,Ynow)(Xnow,Ynow)、终点坐标 (Xgoal,Ygoal)(Xgoal,Ygoal) 和速度 VV,模拟匀速直线运动直到到达终点。
看似简单,但在8051单片机上做这件事有不少坑:
- 8051没有硬件浮点运算单元,
float运算很慢(一次乘法几十微秒) - 坐标范围 0~999,但运动过程中坐标可能是小数
- 运动方向不是简单的水平或垂直,需要向量分解
启动运动:计算方向和参数
按下S4从空闲切换到运行时,代码做了一系列初始化:
cs
case 0: // 空闲 → 运行
if(Uart_Get_Flag == 1) { // 确保已收到目标坐标
Running_Mode = 2;
// 1. 计算运动距离分量(有符号整数)
Running_Distance_x = Pos_Goal_X - Pos_Now_X;
Running_Distance_y = Pos_Goal_Y - Pos_Now_Y;
// 2. 用浮点数初始化当前位置
Pos_Now_X_Float = (float)Pos_Now_X;
Pos_Now_Y_Float = (float)Pos_Now_Y;
// 3. 计算运动方向的正切值
if(Running_Distance_x != 0)
Running_Tang_Angle = (float)Running_Distance_y
/ Running_Distance_x;
}
break;
关键变量说明:
Running_Distance_x/y:用int(有符号),值为正表示目标在正方向,负表示反方向Running_Tang_Angle:tan(θ)=ΔY/ΔXtan(θ)=ΔY/ΔX,后续用这个值做向量分解- 如果 ΔX=0ΔX=0(纯垂直运动),不计算正切值,运动函数里会特殊处理
向量分解原理
速度 VV 需要分解为 VxVx 和 VyVy 两个分量。根据向量分解:
V_x = V × cos(θ)
V_y = V × sin(θ)
其中:
cos(θ) = ΔX / √(ΔX² + ΔY²) = 1 / √(1 + tan²(θ))
sin(θ) = ΔY / √(ΔX² + ΔY²) = tan(θ) / √(1 + tan²(θ))
代码中只需要预先计算 tan(θ)tan(θ),后续每帧都能从它推导出 cos(θ)cos(θ) 和 sin(θ)sin(θ),不需要存储 ΔXΔX 和 ΔYΔY(它们的符号已经编码在了 Running_Distance_x/y 里)。
位置更新算法
Run_Calc() 每500ms被调度器调用一次(通过 Scheduler_Task 中的 {Run_Calc, 500, 0}),每次移动"速度×0.5"个单位:
cs
void Run_Calc() {
if(Uart_Get_Flag == 1 && Running_Mode == 2) {
// ===== 一般情况:x方向不为零 =====
if(Running_Distance_x != 0) {
// 分解速度为x方向分量
float v_x = (float)(Speed_Now_10x / 10.0)
/ sqrt(1 + Running_Tang_Angle * Running_Tang_Angle);
if(Running_Distance_x < 0)
// 目标在左,x减小
Pos_Now_X_Float = Pos_Now_X_Float - v_x * 0.5;
else
// 目标在右,x增大
Pos_Now_X_Float = Pos_Now_X_Float + v_x * 0.5;
if(Running_Distance_y < 0)
// 目标在上,y减小
Pos_Now_Y_Float = Pos_Now_Y_Float
- v_x * 0.5 * Running_Tang_Angle;
else
// 目标在下,y增大
Pos_Now_Y_Float = Pos_Now_Y_Float
+ v_x * 0.5 * Running_Tang_Angle;
}
// ===== 特殊情况:纯y方向运动 =====
else {
if(Running_Distance_y > 0)
Pos_Now_Y_Float = Pos_Now_Y_Float + (float)(Speed_Now_10x / 10.0) * 0.5;
else
Pos_Now_Y_Float = Pos_Now_Y_Float - (float)(Speed_Now_10x / 10.0) * 0.5;
}
// ===== 到达判断 =====
if((Pos_Now_X_Float >= Pos_Goal_X && Running_Distance_x >= 0)
|| (Pos_Now_X_Float <= Pos_Goal_X && Running_Distance_x <= 0)) {
if((Pos_Now_Y_Float >= Pos_Goal_Y && Running_Distance_y >= 0)
|| (Pos_Now_Y_Float <= Pos_Goal_Y && Running_Distance_y <= 0)) {
// 两个方向都到达
Pos_Now_X = Pos_Goal_X; // 整数坐标精确对齐目标
Pos_Now_Y = Pos_Goal_Y;
Running_Mode = 0; // 回到空闲
Uart_Get_Flag = 0;
Led_4_Enable = 1; // LED4亮3秒
Running_Arrive_Judge = 1;
return; // 直接返回,不更新浮点坐标
}
}
// 未到达,更新整数显示坐标
Pos_Now_X = (ui)Pos_Now_X_Float;
Pos_Now_Y = (ui)Pos_Now_Y_Float;
}
}
算法中的关键细节
1. 为什么乘0.5?
Run_Calc 每500ms调用一次。速度的单位是 cm/s("每秒移动多少厘米"),500ms就是0.5秒,所以每次移动的距离 = 速度 × 0.5。这等价于"每秒移动 V cm,每0.5秒更新一次位置"。
如果想提高精度,可以把调度周期改为100ms,那乘的系数就是0.1。但500ms是一个合理的平衡点------更频繁的更新意味着更多的浮点运算(每次都需要 sqrt()),而8051的浮点运算性能有限。
2. 到达判断为什么这么写?
到达判断不是简单的 Pos_Now_X_Float == Pos_Goal_X,因为浮点数几乎不可能精确相等。代码用的是方向性判断:
cs
// x方向到达条件(向右运动时)
(Pos_Now_X_Float >= Pos_Goal_X && Running_Distance_x >= 0)
// x方向到达条件(向左运动时)
(Pos_Now_X_Float <= Pos_Goal_X && Running_Distance_x <= 0)
向右运动时(Running_Distance_x > 0),只要当前x坐标超过或等于目标x坐标,就算到达。向左运动时,当前x坐标小于或等于目标就算到达。两个方向必须同时到达才判定运动完成。
这种写法有一个潜在问题:如果速度很快,一帧可能"穿过"目标。比如目标在x=100,当前x=99,速度=5cm/帧,下一帧x=104------已经超过了。但上面的判断能正确处理这种情况(104 >= 100 → 到达)。
3. 到达后整数坐标精确对齐
cs
Pos_Now_X = Pos_Goal_X; // 不是 Pos_Now_X = (ui)Pos_Now_X_Float;
Pos_Now_Y = Pos_Goal_Y;
到达后直接把整数坐标设为目标值,而不是浮点坐标的截断值。这避免了"到达了但显示的坐标和目标差1"的问题。
4. 为什么用浮点数而不是整数放大?
理论上可以用"速度×1000"的方式做纯整数运算,避免浮点。但代码选择了浮点,原因是:
- 向量分解需要
sqrt()和除法,整数近似误差会累积 - 8051的Keil C51编译器对浮点有软件库支持,虽然慢但可用
- 500ms更新一次,运算量不大,性能够用
按键交互完整逻辑
cs
void Key_Proc() {
Key_Val = Key_Read();
Key_Down = Key_Val & (Key_Val ^ Key_Old);
Key_Up = ~Key_Val & (Key_Val ^ Key_Old);
Key_Old = Key_Val;
switch(Key_Down) {
case 4: // S4:运行控制
case 5: // S5:复位
case 8: // S8:界面切换
case 9: // S9:参数位置切换
case 12: // S12:参数增大
case 13: // S13:参数减小
}
}
S4------运行/暂停/恢复
上面已经详细分析过状态机,这里再补充一个细节。从暂停恢复运行时,不会重新计算运动方向:
cs
case 1: // 暂停 → 运行
if(Barrier_Clean_Flag == 1) // 只检查障碍物
Running_Mode = 2;
break;
这意味着暂停后恢复,运动会沿着原来的方向继续,从暂停的位置接着走。这是合理的------如果你在运动过程中暂停,恢复后当然应该继续原来的轨迹,而不是重新计算方向。
S5------位置复位
cs
case 5:
if(Running_Mode == 0) { // 只在空闲时允许
Pos_Now_X = 0;
Pos_Now_Y = 0;
}
break;
只在空闲状态才能复位。这是安全设计------如果运行中允许复位,当前位置突然变回(0,0),运动轨迹会完全错乱。
S8------界面切换 + 参数确认
cs
case 8:
Seg_Disp_Mode = (++Seg_Disp_Mode) % 3; // 0→1→2→0循环
if(Seg_Disp_Mode == 2) { // 进入参数界面
Param_B_Show = Param_B_Set; // Set → Show
Param_R_10x_Show = Param_R_10x_Set;
}
if(Seg_Disp_Mode == 0) { // 退出参数界面
Param_B_Set = Param_B_Show; // Show → Set(生效)
Param_R_10x_Set = Param_R_10x_Show;
}
break;
这个设计我在上篇文章提到过。三个界面循环切换:0(坐标) → 1(速度) → 2(参数) → 0(坐标)。从坐标切到参数时拷贝值,从参数切回坐标时确认值。
注意:速度界面(界面1)没有这个机制,因为速度是实时计算的,不需要用户确认。
S9------参数位置切换
cs
case 9:
if(Seg_Disp_Mode == 2) // 只在参数界面有效
Param_Set_Pos = !Param_Set_Pos; // 0(R) ↔ 1(B)
break;
数码管上当前编辑的参数位置通过某种方式高亮显示(比如闪烁),按S9在R和B之间切换。
S12/S13------参数增减
cs
case 12: // 参数增大
if(Seg_Disp_Mode == 2) {
if(Param_Set_Pos == 0) { // 编辑R
if(++Param_R_10x_Show > 20)
Param_R_10x_Show = 20; // 上限2.0
} else { // 编辑B
Param_B_Show += 5;
if(Param_B_Show > 90)
Param_B_Show = 90; // 上限90
}
}
break;
case 13: // 参数减小
if(Seg_Disp_Mode == 2) {
if(Param_Set_Pos == 0) { // 编辑R
if(--Param_R_10x_Show < 10)
Param_R_10x_Show = 10; // 下限1.0
} else { // 编辑B
Param_B_Show -= 5;
if(Param_B_Show < -90)
Param_B_Show = -90; // 下限-90
}
}
break;
R参数步进1(对应0.1),B参数步进5。两个参数都用 if 做边界保护,不允许超出范围。修改的是 _Show 变量,只有切回坐标界面时才会拷贝到 _Set 生效。
暂停闪烁的中断实现
cs
/* Timer1 中断中 */
if(Running_Mode == 1) { // 暂停状态
if(++Led_SlowDown == 100) { // 每100ms
Led_SlowDown = 0;
Flash_Judge = !Flash_Judge; // 翻转闪烁标志
}
} else {
Led_SlowDown = 0;
Flash_Judge = 0; // 非暂停状态熄灭
}
Flash_Judge 在 Led_Proc() 里用于控制 LED1:ucLed[0] = (Running_Mode == 2) || (Flash_Judge == 1)。运行时常亮(Running_Mode==2),暂停时闪烁(Flash_Judge每100ms翻转)。
这里有个小细节:退出暂停状态时(进入空闲或运行),需要同时清零 Led_SlowDown 和 Flash_Judge。否则下次进入暂停时,Led_SlowDown 可能已经是一个很大的值,导致闪烁节奏不对。代码里用 else 分支处理了这个。
完整的初始化流程
cs
void main() {
System_Init(); // 关闭蜂鸣器和继电器
Uart_Init(); // 串口9600bps
Timer0Init(); // NE555计数器
Scheduler_Init(); // 计算任务数量
Timer1Init(); // 1ms系统时基
while(1) {
Scheduler_Run();
}
}
初始化完成后,系统进入主循环。8个任务按各自周期运行。没有任何 delay(),整个系统是纯事件驱动的。
第一次上电时数码管显示什么?
上电时 Seg_Buf 初始化为 {10,10,10,10,10,10,10,10}(10=全灭),所以数码管不显示任何内容。直到串口收到第一个坐标,坐标界面才会开始显示数据。
但实际上,赛题通常会要求上电后有一个默认界面。如果赛题要求上电就显示某些内容,需要在 main() 里初始化 Seg_Buf 或者调用一次 Seg_Proc()。
这道题的难点在哪?
回顾整个代码,我觉得第15届国赛的难点主要在三个地方:
-
串口协议设计:需要设计一个简单但可靠的命令系统,包括坐标设置、状态查询、位置查询三种命令,还要处理运行中拒绝坐标的逻辑。
-
运动轨迹算法:在8位单片机上做浮点向量分解,需要考虑方向(有符号)、到达判断(方向性比较)、特殊情况(纯y方向运动)。
-
状态机复杂度:三个运行模式 × 三个显示界面 × 参数编辑模式,状态之间的转换条件需要仔细设计。特别是参数编辑的"预览/确认"机制,容易漏掉。
但代码本身的结构非常清晰------西风模板把驱动层和调度器封装好了,选手只需要在 main.c 里写业务逻辑。这也印证了一个观点:蓝桥杯单片机比的不是谁的代码写得多,而是谁的模板用得熟。