第15届国赛满分代码解析(上)—— 赛题分析、变量设计与串口协议
第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分量,如何在浮点数上模拟二维运动,以及如何判断"到达目标"。这才是这道国赛题真正的难点。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)