第15届国赛满分代码解析(下)—— 运动轨迹算法、按键交互与完整状态机

上篇文章讲完了第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_JudgeLed_Proc() 里用于控制 LED1:ucLed[0] = (Running_Mode == 2) || (Flash_Judge == 1)。运行时常亮(Running_Mode==2),暂停时闪烁(Flash_Judge每100ms翻转)。

这里有个小细节:退出暂停状态时(进入空闲或运行),需要同时清零 Led_SlowDownFlash_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届国赛的难点主要在三个地方:

  1. 串口协议设计:需要设计一个简单但可靠的命令系统,包括坐标设置、状态查询、位置查询三种命令,还要处理运行中拒绝坐标的逻辑。

  2. 运动轨迹算法:在8位单片机上做浮点向量分解,需要考虑方向(有符号)、到达判断(方向性比较)、特殊情况(纯y方向运动)。

  3. 状态机复杂度:三个运行模式 × 三个显示界面 × 参数编辑模式,状态之间的转换条件需要仔细设计。特别是参数编辑的"预览/确认"机制,容易漏掉。

但代码本身的结构非常清晰------西风模板把驱动层和调度器封装好了,选手只需要在 main.c 里写业务逻辑。这也印证了一个观点:蓝桥杯单片机比的不是谁的代码写得多,而是谁的模板用得熟

相关推荐
Navigator_Z1 小时前
LeetCode //C - 1096. Brace Expansion II
c语言·算法·leetcode
luj_17681 小时前
FreeDOS vs MS-DOS PC-DOS 对比解析
服务器·c语言·开发语言·经验分享·算法
笨笨没好名字2 小时前
Leetcode刷题python版第一周
python·算法·leetcode
Cthy_hy2 小时前
斯特林数:组合划分的递归经典,一二两类全解
python·算法·斯特林数
不忘不弃2 小时前
计算pi的近似值
算法
码云骑士2 小时前
12-GIL不是性能杀手(下)-绕过GIL的三种方案与决策树
算法·决策树·机器学习
一只齐刘海的猫2 小时前
【Leetcode】无重复字符的最长子串
算法·leetcode·职场和发展
行智科技2 小时前
FAST-LIVO2 源码精读(二):环境搭建与编译避坑
算法·ubuntu·自动驾驶·slam
插件开发2 小时前
vs2015 cuda c++ cdpSimplePrint范例,递归功能实现演示
linux·c++·算法