第15届国赛是近年蓝桥杯单片机赛道难度天花板之一。这道题综合了串口通信、超声波避障、NE555频率测量、运动轨迹模拟、DAC输出和多界面数码管显示,几乎把CT107D板子上所有外设都考了一遍。

我手里的满分代码来自"西风模板"版本,整份代码只有一个 main.c 文件(约530行),业务逻辑全部在里面。驱动层复用西风模板的标准驱动。这篇文章先讲赛题分析、变量设计和串口协议,下一篇讲运动算法和状态机。


赛题需求逆向分析

这套代码没有附赛题原文,我是通过代码逻辑逆向推断出赛题要求的。核心功能如下:

智能运动控制系统:
1. 上位机通过串口发送目标坐标 (x, y)
2. 单片机根据 NE555 频率和参数 R、B 计算运动速度
3. 超声波检测前方障碍物距离
4. 模拟从当前位置运动到目标位置的二维轨迹
5. 运行中遇到障碍物自动暂停,障碍清除后手动恢复
6. DAC 输出对应光强电压(白天/夜晚模式,由光敏电阻判断)
7. 到达目标位置后 LED4 亮3秒提示
8. 三界面数码管显示 + 多按键交互

运行模式状态机

这道题的核心是一个三态状态机:

    S4(空闲且已收到坐标)        S4(运行中)
    ┌──────────────────────┐     ┌──────────────────┐
    │                      ▼     │                  │
┌───┴───┐   S4(已清障)  ┌───┴───┐  到达目标   ┌──────┐
│ 空闲  │──────────────→│ 运行  │────────────→│ 空闲  │
│ Mode0 │               │ Mode2 │            │ Mode0 │
└───────┘               └───┬───┘            └──────┘
                           │
                           │ S4 或 障碍物<30cm
                           ▼
                        ┌──────┐
                        │ 暂停  │
                        │ Mode1 │
                        └──────┘

三个状态,一个按键 S4 控制,逻辑清晰。空闲时按S4启动运行,运行时按S4暂停,暂停时按S4恢复(前提是障碍物已清除)。到达目标自动回到空闲。


全局变量设计——看满分代码怎么组织变量

/* 类型别名 —— 全文通用 */
typedef unsigned char uc;
typedef unsigned int ui;

/* === 系统基础变量 === */
idata uc ucLed[8] = {0};      // LED状态数组
idata uc Key_Val, Key_Old, Key_Down, Key_Up;  // 按键三态
idata uc Seg_Pos, Seg_Buf[8] = {10,10,10,10,10,10,10,10};  // 数码管
idata unsigned long int uwTick;  // 1ms系统滴答

/* === 串口接收变量 === */
pdata uc Uart_Buf[16] = {0};   // 16字节接收缓冲
idata uc Uart_Recv_Index;       // 写入指针
idata uc Uart_Recv_Time;        // 超时计数(ms)
idata uc Uart_Recv_Flag;        // 接收中标志

/* === 模式与界面 === */
idata uc Seg_Disp_Mode = 0;     // 数码管界面:0=坐标 1=速度 2=参数
idata uc Running_Mode = 0;      // 运行模式:0=空闲 1=暂停 2=运行
idata bit Uart_Get_Flag = 0;    // 是否已收到坐标
idata bit Barrier_Clean_Flag = 0;  // 障碍物是否清除
idata bit Param_Set_Pos = 0;    // 参数编辑位置:0=R 1=B
idata bit Time_Flag = 0;        // 白天/夜晚标志

/* === 坐标与运动 === */
idata ui Pos_Now_X = 0, Pos_Now_Y = 0;         // 当前位置
idata ui Pos_Goal_X = 0, Pos_Goal_Y = 0;       // 目标位置
idata ui Pos_Goal_x_Old = 0, Pos_Goal_y_Old = 0;  // 目标旧值(回滚用)
idata ui Speed_Now_10x;                          // 当前速度×10
idata uc Distance_Now;                            // 障碍物距离

/* === 参数 === */
idata uc Param_R_10x_Show = 10, Param_R_10x_Set = 10;  // R×10,范围10~20
idata char Param_B_Show = 0, Param_B_Set = 0;          // B,范围-90~90
idata ui Freq = 0;                                     // NE555频率

/* === 运动计算辅助 === */
pdata int Running_Distance_x = 0;     // x方向运动距离(有符号)
pdata int Running_Distance_y = 0;     // y方向运动距离(有符号)
pdata float Running_Tang_Angle = 0;   // 运动方向正切值 tan(θ)
idata bit Running_Arrive_Judge = 0;   // 到达标志
pdata float Pos_Now_X_Float = 0;      // 浮点坐标
pdata float Pos_Now_Y_Float = 0;

/* === LED定时控制 === */
idata bit Flash_Judge = 0;     // 暂停闪烁(0.1秒切换)
idata uc Led_SlowDown = 0;     // 闪烁减速计数
idata bit Led_4_Enable = 0;    // LED4到达提示使能
idata ui Led_Enable_Time = 0;  // LED4亮灯计时

几个值得注意的设计:

1. Show/Set 双变量设计

参数 R 和 B 各有两套变量:_Show 和 _Set。进入参数界面时把 Set 值拷贝到 Show 变量用于显示和编辑,退出参数界面时再把 Show 值拷回 Set 变量使其生效。这是一种延迟确认机制——在参数界面调整的时候不会影响正在运行的逻辑。

2. 有符号距离

Running_Distance_x 和 Running_Distance_y 声明为 int(有符号),因为运动方向可能为正也可能为负(目标在当前位置的左边或上面)。

3. Old 变量用于回滚

串口解析坐标时,先保存旧值,解析失败后恢复。这保证了串口收到非法数据时不会破坏当前状态:

Pos_Goal_x_Old = Pos_Goal_X;          // 先备份
Pos_Goal_y_Old = Pos_Goal_Y;
if((sscanf(Uart_Buf, "(%u,%u)", &Pos_Goal_X, &Pos_Goal_Y)) == 2) {
    // 解析成功,使用新坐标
} else {
    Pos_Goal_X = Pos_Goal_x_Old;      // 解析失败,回滚
    Pos_Goal_Y = Pos_Goal_y_Old;
}

串口协议设计

通信协议

串口通信格式是 9600bps, 8N1。上位机发送的命令有三种:

发送内容 含义 单片机回复
(x,y) 设置目标坐标 Got it / Busy
# 查询当前坐标 (x,y)
? 查询运行状态 Idle / Wait / Busy
其他 无效命令 Error

初始化代码

void Uart_Init(void)  // 9600bps@12.000MHz
{
    SCON = 0x50;     // 8位UART,允许接收
    AUXR |= 0x01;    // 串口1选择Timer2做波特率发生器
    AUXR &= 0xFB;    // Timer2时钟12T模式
    T2L = 0xE6;      // Timer2低字节初值
    T2H = 0xFF;      // Timer2高字节初值
    AUXR |= 0x10;    // 启动Timer2
    ES = 1;          // 开串口中断
    EA = 1;          // 开总中断
}

中断接收

void Uart_Server() interrupt 4 {
    if(RI) {
        Uart_Recv_Time = 0;          // 有新数据,超时计数清零
        Uart_Recv_Flag = 1;          // 标记接收中
        Uart_Buf[Uart_Recv_Index++] = SBUF;  // 存入缓冲区
        RI = 0;                       // 清接收标志
        if(Uart_Recv_Index > 16) {    // 防溢出(注意:无等号,因为前面++了)
            Uart_Recv_Index = 0;
            memset(Uart_Buf, 0, 16);
        }
    }
}

缓冲区大小16字节,够存 (999,999) + 结束符。溢出保护里 > 16 而不是 >= 16,因为前面已经 ++ 了,所以实际判断的是 Uart_Recv_Index 自增后是否超过16。

超时帧检测与命令解析

void Uart_Proc() {
    if(Uart_Recv_Index == 0) return;    // 没有数据直接返回

    if(Uart_Recv_Time >= 10) {          // 10ms无新数据 = 一帧结束
        Uart_Recv_Time = 0;
        Uart_Recv_Flag = 0;

        // 备份旧坐标(回滚机制)
        Pos_Goal_x_Old = Pos_Goal_X;
        Pos_Goal_y_Old = Pos_Goal_Y;

        // 解析命令
        if((sscanf(Uart_Buf, "(%u,%u)", &Pos_Goal_X, &Pos_Goal_Y)) == 2) {
            if(Running_Mode == 0) {
                printf("Got it");       // 空闲时接收坐标
                Uart_Get_Flag = 1;
            } else {
                printf("Busy");         // 运行/暂停中拒绝
                Pos_Goal_X = Pos_Goal_x_Old;  // 回滚
                Pos_Goal_Y = Pos_Goal_y_Old;
            }
        }
        else if(strcmp(Uart_Buf, "#") == 0) {
            printf("(%u,%u)", Pos_Now_X, Pos_Now_Y);  // 回传当前位置
        }
        else if(strcmp(Uart_Buf, "?") == 0) {
            switch(Running_Mode) {
                case 0: printf("Idle");  break;
                case 1: printf("Wait");  break;
                case 2: printf("Busy");  break;
            }
        }
        else {
            printf("Error");            // 无法识别的命令
        }

        // 清空缓冲区
        memset(Uart_Buf, 0, Uart_Recv_Index);
        Uart_Recv_Index = 0;
    }
}

这里的超时检测机制值得仔细看。在中断里每1ms递增 Uart_Recv_Time,但只有收到新数据时才清零。所以在主循环里检查:如果 Uart_Recv_Time >= 10,说明已经10ms没有收到新数据了,一帧数据接收完毕。

10ms的超时时间是基于串口波特率选择的:9600bps下,每个字节约1ms,加上上位机发送间隔,10ms的超时能可靠地分隔两帧数据。

printf 重定向

extern char putchar(char ch) {
    SBUF = ch;
    while(TI == 0);   // 等待发送完成(阻塞式)
    TI = 0;
    return ch;
}

sscanf 解析坐标、printf 回传状态——标准库函数直接用在单片机上,省去了手写字符串解析的麻烦。printf 是阻塞式的,所以只能在主循环(调度器任务)里调用,不能在中断里用。


光敏电阻与白天/夜晚判断

void AD_DA() {
    float Volatge = 0;
    Volatge = Ad_Read(0x41) / 51.0;   // 读取光敏电阻通道
    if(Volatge > 1.2)
        Time_Flag = 0;   // 电压>1.2V → 白天
    else
        Time_Flag = 1;   // 电压<=1.2V → 夜晚
}

PCF8591 的通道1是光敏电阻,ADC值为0~255,对应的电压是 value / 51.0(因为255对应5V,255/5=51)。电压阈值1.2V对应的ADC值约为 1.2 * 51 = 61

这个标志影响两个地方:LED1在夜晚且运行时点亮(作为"夜间运行指示"),以及 DAC 可能在不同模式下输出不同的值。


频率读取与速度计算

NE555 连在 P3.4(Timer0外部计数引脚),Timer0 配置为计数器模式:

void Timer0Init(void) {    // 计数器模式
    AUXR &= 0x7F;          // Timer0时钟12T模式
    TMOD &= 0xF0;          // 清除Timer0模式位
    TMOD |= 0x05;          // 设置为16位计数器模式(不自动重装)
    TL0 = 0x00;
    TH0 = 0x00;
    TF0 = 0;
    TR0 = 1;               // 启动计数
}

TMOD |= 0x05 = TMOD |= 0000_0101,即 C/T=1(计数器模式),M1M0=01(16位模式)。Timer0 不需要产生中断,纯粹是被动计数NE555的脉冲数。

void Read_Freq() {
    ui temp;
    Freq = TL0 | (TH0 << 8);   // 读取1秒内的脉冲计数
    TH0 = 0;                    // 清零计数器
    TL0 = 0;

    // 速度公式:V = π × R × F + B
    // 代码中用整数运算模拟:V×10 = 3.14 × R×10 × F / 100 + B × 10
    temp = (ui)(3.14 * Param_R_10x_Set * Freq / 100.0 + Param_B_Set * 10);
    Speed_Now_10x = (temp >= 0) ? temp : Speed_Now_10x;  // 不允许负速度
}

这个函数每1000ms被调度器调用一次。因为调度周期刚好是1秒,所以 Timer0 计数器的值就等于 NE555 的频率(Hz)。

速度公式 V = πRF + B 中,R 的单位是 cm(1.02.0),F 是频率(Hz),B 是偏移量(-9090)。代码把所有计算放大10倍后用浮点运算,最终 Speed_Now_10x 存储的是速度的10倍值,避免使用浮点数做后续运算。


超声波避障逻辑

void Get_Distance() {
    unsigned char temp;
    temp = U_Wave_Data();        // PCA测距,返回距离值(单位cm)
    Distance_Now = temp;

    // 避障判断
    if(Distance_Now < 30) {      // 障碍物距离<30cm
        Barrier_Clean_Flag = 0;  // 标记障碍物未清除
        if(Running_Mode == 2)
            Running_Mode = 1;    // 运行中 → 暂停
    } else {
        Barrier_Clean_Flag = 1;  // 标记障碍物已清除
    }
}

避障逻辑很直接:距离小于30cm就暂停,大于等于30cm就标记为已清除。但不会自动恢复运行——必须手动按 S4 才能从暂停恢复。这是一个安全设计:万一只是短暂遮挡(比如手经过),自动恢复可能导致意外运动。

U_Wave_Data() 返回的是 unsigned char(0255cm),超声波模块在CT107D上的量程一般2cm400cm,但 unsigned char 最大255,所以超过255cm的距离会被截断。不过30cm的避障阈值远在255以内,所以不影响避障功能。


LED控制与定时提示

void Led_Proc() {
    bit Realy_Enable = 0;
    if(Running_Mode == 2)
        Realy_Enable = 1;          // 运行时继电器吸合
    else
        Realy_Enable = 0;

    ucLed[0] = (Running_Mode == 2) || (Flash_Judge == 1);  // 运行或暂停闪烁
    ucLed[1] = (Time_Flag && (Running_Mode == 2));          // 夜晚+运行
    ucLed[2] = Led_4_Enable;                                  // 到达提示

    Relay(Realy_Enable);
    Led_Disp(ucLed);
}

LED1 在运行时常亮,暂停时闪烁(Flash_Judge 在中断中每100ms翻转一次)。LED2 在夜晚且运行时点亮。LED2 是一个比较巧妙的设计——它把光敏电阻的判断结果通过 LED 直观地展示出来,方便调试验证白天/夜晚判断是否正确。

LED4 的3秒到达提示是在中断里计时的:

/* 在 Timer1 中断中 */
if(Running_Arrive_Judge == 1) {
    if(++Led_Enable_Time >= 3000) {
        Running_Arrive_Judge = 0;
        Led_4_Enable = 0;
        Led_Enable_Time = 0;
    }
}

到了3000ms就自动熄灭。这种"触发 → 定时 → 自动复位"的模式在竞赛中很常见,值得记住。


数码管界面设计

三个界面,S8循环切换:

界面0——坐标(显示 L 后跟当前/目标坐标):

空闲时显示当前位置:L 0 0 0 - 0 0 0
运行时显示目标位置:L 1 2 3 - 4 5 6

代码里用条件判断实现高位消零:

Seg_Buf[1] = (Pos_Now_X >= 100) ? Pos_Now_X / 100 % 10 : 10;  // 10=全灭
Seg_Buf[2] = (Pos_Now_X >= 10)  ? Pos_Now_X / 10 % 10  : 10;
Seg_Buf[3] = Pos_Now_X % 10;                                     // 个位始终显示

这种写法比 if-else 更紧凑,但有个问题:当坐标为0时,三个位都显示全灭,看起来不太直观。赛题如果要求0也要显示,就需要改判断条件。不过题目通常会给出默认显示格式,按赛题要求来就行。

界面1——速度/距离/状态

运行时:E 1 . x x x x x    (E=速度界面,1.=速度×10的值)
空闲时:E 2 . - - - - -    (全横线)
暂停时:E 3 . x x x x x    (显示障碍物距离)

速度值用了小数点编码技巧:Seg_Buf[6] = Speed_Now_10x / 10 % 10 + ',',把小数点信息编码进段码值里。这是西风模板的招牌技巧,之前的文章里详细讲过。

界面2——参数

显示:P _ x . x _ y y y
        R值      B值

B参数可以为负数,所以显示时需要处理负号:

if(Param_B_Show >= 0) {
    Seg_Buf[5] = 10;  // 全灭(正数不显示符号)
    Seg_Buf[6] = (Param_B_Show >= 10) ? Param_B_Show / 10 % 10 : 10;
    Seg_Buf[7] = Param_B_Show % 10;
} else if(Param_B_Show < 0 && Param_B_Show > -10) {
    Seg_Buf[5] = 10;  // 十位全灭
    Seg_Buf[6] = 12;  // 显示负号
    Seg_Buf[7] = -Param_B_Show;  // 取绝对值
} else {  // Param_B_Show <= -10
    Seg_Buf[5] = 12;  // 十位显示负号
    Seg_Buf[6] = -Param_B_Show / 10 % 10;
    Seg_Buf[7] = -Param_B_Show % 10;
}

三种情况:正数(个位和十位独立显示)、负数绝对值<10(负号在十位)、负数绝对值>=10(负号在百位)。这种逐位控制虽然代码长,但逻辑清晰,不容易出bug。


下一篇文章预告

这篇文章覆盖了第15届国赛代码的"静态"部分——变量、串口协议、传感器读取和界面显示。下一篇讲最核心的运动轨迹算法——如何把一个速度分解为x/y分量,如何在浮点数上模拟二维运动,以及如何判断"到达目标"。这才是这道国赛题真正的难点。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐