第16届省赛(第二次)的这道题很有意思,它不像第15届国赛那样堆砌各种外设,而是把超声波测距这一个外设玩出了花样------单次测量、累加测量、温度补偿、DAC电压映射,全部围绕超声波展开。
更特别的是,这套满分代码没有使用西风模板的调度器,而是采用了中断驱动的架构。对比两套架构的差异,能让人对蓝桥杯的代码设计有更深的理解。
赛题需求分析
通过逆向分析代码,赛题核心需求如下:
超声波智能测距系统:
1. 两种测量模式:单次测量 / 累加测量
2. DS18B20 温度补偿(修正超声波声速)
3. DAC 输出:将测距结果映射为 1~5V 电压
4. 三个参数界面:测距结果、时间间隔设置、累加次数设置
5. 超限报警:结果 < 20cm 或 > 100cm 时 L2 点亮
6. LED3 在累加测量进行中以 0.1 秒间隔闪烁
7. 上电数码管熄灭,完成首次有效测量后点亮
测量流程
单次测量(S4):
┌────────┐ S4 ┌────────────┐
│ 空闲/ │────→│ 立即测一次 │→ 显示结果 + DAC更新
│ 累加中 │ └────────────┘
└────────┘ (累加中的话,会被强制终止)
累加测量(S5):
┌────────┐ S5 ┌───────────────────────┐
│ 空闲 │────→│ 立即测第1次 + 启动定时 │
└────────┘ │ 每隔N秒自动测一次 │
│ 共测M次,距离累加 │
│ 完成后更新DAC │
└───────────────────────┘
变量设计
cs
/* 状态标志 */
unsigned char seg_display_mode = 1; // 1=测距 2=时间间隔 3=累加次数
bit measure_mode = 0; // 0=单次 1=累加
bit measure_start_flag = 0; // 累加进行中
bit quench_flag = 1; // 数码管熄灭标志(上电熄灭)
/* 测量参数 */
float temperature; // DS18B20温度值
unsigned int distance; // 超声波测距结果
unsigned char measure_count; // 累加剩余次数
/* 可选参数(用数组+索引实现轮换) */
unsigned char time_interval_par = 2; // 时间间隔:2s
unsigned char set_time_par[4] = {2, 4, 6, 4}; // 可选值
unsigned char set_time_par_index;
unsigned char times_count_par = 3; // 累加次数:3次
unsigned char set_times_par[4] = {3, 5, 7, 5}; // 可选值
unsigned char set_times_par_index;
/* 计时变量 */
unsigned int measure_timer; // 累加测量计时(ms)
unsigned char led_timer100ms; // LED3闪烁计时
几个设计要点:
1. 参数轮换数组
cs
unsigned char set_time_par[4] = {2, 4, 6, 4};
注意数组最后一个元素是 4 而不是 8。这样按 S9 循环时:2→4→6→4→2→...,形成"4是默认回归值"的效果。这不是代码bug,而是有意为之的设计------循环到最后回到常用值。
2. distance 用 unsigned int
超声波的最大量程约400cm,加上累加后可能超过255,所以用 unsigned int(0~65535)而不是 unsigned char。
温度补偿超声波测距
核心公式
cs
distance = (Sonic_Read() / 340.0) * (330 + 0.6 * temperature);
这个公式值得仔细拆解。
超声波模块内部默认用 v=340m/sv=340m/s 计算距离,返回值是 Sonic_Read():
raw_distance = time × 340 / 2 (time是超声波往返时间)
但实际声速受温度影响:v(T)=331.4+0.6Tv(T)=331.4+0.6T(简化为 330+0.6T330+0.6T)。
温度补偿的原理:超声波模块返回的"原始距离"是基于340m/s计算的,而真实距离应该是基于实际声速计算的。两者的比值就是补偿系数:
real_distance = raw_distance × v(T) / 340
= raw_distance × (330 + 0.6T) / 340
= (raw_distance / 340) × (330 + 0.6T)
代码中 (Sonic_Read() / 340.0) 就是把原始距离反算回时间(除以声速),再乘以实际声速 (330 + 0.6 * temperature) 得到真实距离。
补偿效果
| 温度 | 实际声速(m/s) | 补偿系数 | 不补偿误差 |
|---|---|---|---|
| 0°C | 331.4 | 0.971 | -2.9% |
| 10°C | 337.4 | 0.988 | -1.2% |
| 20°C | 343.4 | 1.006 | +0.6% |
| 25°C | 346.4 | 1.015 | +1.5% |
| 40°C | 355.4 | 1.041 | +4.1% |
在25°C(常见室温)下,不补偿的误差约1.5%。对于比赛来说,这个误差可能不影响功能得分,但如果赛题有精度要求(比如"误差不超过2%"),温度补偿就是必要的。
DAC输出映射
cs
void DAC_Transition(unsigned int distance) {
if(distance <= 20)
DA_Output(1); // ≤20cm → 1V
else if(distance >= 100)
DA_Output(5); // ≥100cm → 5V
else
DA_Output(((distance - 20) / 20.0) + 1); // 20~100cm 线性映射
}
映射关系:
- 20cm以下:固定输出1V(下限钳位)
- 100cm以上:固定输出5V(上限钳位)
- 20~100cm之间:线性映射,
V = 1 + (distance - 20) / 20
每20cm增加1V:20cm→1V, 40cm→2V, 60cm→3V, 80cm→4V, 100cm→5V。
PCF8591 的 DAC 接口在底层驱动里这样实现:
cs
void DA_Output(float voltage) {
I2CStart();
I2CSendByte(0x90); // PCF8591写地址
I2CWaitAck();
I2CSendByte(0x40); // 控制字节:DAC使能
I2CWaitAck();
EA = 0; // 关中断保护
I2C_Delay(40); // 等待DAC输出稳定
EA = 1;
I2CSendByte((unsigned char)(voltage * 51)); // 电压→数字量
I2CWaitAck();
I2CStop();
}
voltage * 51:5V对应数字量255,所以 255/5 = 51。输入电压乘以51得到PCF8591的DAC数字量。
中断安全:为什么要重定义一个DAC函数?
代码里有一个很有意思的设计------DAC_Transition() 和 DAC_Transition_IRQ() 是两个逻辑完全相同的函数:
cs
void DAC_Transition(unsigned int distance) {
if(distance <= 20) DA_Output(1);
else if(distance >= 100) DA_Output(5);
else DA_Output(((distance - 20) / 20.0) + 1);
}
void DAC_Transition_IRQ(unsigned int distance) { // 逻辑完全一样!
if(distance <= 20) DA_Output(1);
else if(distance >= 100) DA_Output(5);
else DA_Output(((distance - 20) / 20.0) + 1);
}
为什么要这么做?原作者的注释说得很清楚:
"因为一个函数在中断内和中断外都有调用的话,有可能会导致函数内部的数据出现错误,所以重复定义一个函数"
具体来说,DAC_Transition() 在按键处理中被调用(单次测量),DAC_Transition_IRQ() 在定时器中断中被调用(累加测量完成时)。如果两者共用同一个函数,可能出现这样的场景:
主循环正在执行 DAC_Transition() → DA_Output() → I2C通信中...
↓ 此时定时器中断触发
↓ 中断里又调用 DAC_Transition() → DA_Output() → 再次启动I2C通信
↓ I2C总线被两个通信过程同时操作 → 数据错乱
虽然 Keil C51 的函数调用本身不是可重入的(普通函数不能安全地在中断中被调用),但如果两个函数调用之间有时间间隔(主循环的调用已经完成,中断才触发),那就没问题。问题出在恰好同时在执行的时候。
用两个独立的函数名,虽然不能完全解决重入问题(两个函数内部都调用 DA_Output()),但至少让代码逻辑更清晰------看到 _IRQ 后缀就知道这个调用来自中断,需要特别注意时序。实际上,DA_Output() 内部已经用 EA=0/EA=1 做了中断保护,所以这里的安全性问题其实已经被底层处理了。
按键处理逻辑
S4------单次测量
cs
case 4:
if(seg_display_mode == 1) { // 只在测距界面有效
measure_start_flag = 0; // 强制终止累加测量
measure_mode = 0; // 切到单次模式
distance = (Sonic_Read() / 340.0) * (330 + 0.6 * temperature);
DAC_Transition(distance); // 更新DAC
quench_flag = 0; // 点亮数码管
led_buf[1] = (distance < 20 || distance > 100); // 超限报警
}
break;
关键设计:单次测量可以强制终止正在进行的累加测量 。measure_start_flag = 0 让中断里的累加逻辑停止运行。这意味着单次测量的优先级高于累加测量。
S5------启动累加测量
cs
case 5:
if(seg_display_mode == 1) {
measure_timer = 0;
measure_mode = 1;
// 立即执行首次测距
distance = (Sonic_Read() / 340.0) * (330 + 0.6 * temperature);
quench_flag = 0;
measure_count = times_count_par - 1; // 减去首次
measure_start_flag = 1; // 启动累加
}
break;
为什么 measure_count = times_count_par - 1?因为按下S5时已经立即执行了第一次测距,后续只需要在定时中断里再测 N-1 次。如果设为 times_count_par,总次数就多了一次。
S8------界面切换
cs
case 8:
if(measure_start_flag == 0 && ++seg_display_mode == 4)
seg_display_mode = 1;
break;
两个限制条件:
- 累加测量进行中不允许切换界面(
measure_start_flag == 0) - 界面在1/2/3之间循环
累加测量中锁定界面,防止用户切换到参数界面后修改参数导致逻辑混乱。
S9------参数轮换
cs
case 9:
if(seg_display_mode == 2) {
if(++set_time_par_index == 4) set_time_par_index = 0;
time_interval_par = set_time_par[set_time_par_index];
}
else if(seg_display_mode == 3) {
if(++set_times_par_index == 4) set_times_par_index = 0;
times_count_par = set_times_par[set_times_par_index];
}
break;
在对应的参数界面下按S9,参数在预定义的数组中循环。{2, 4, 6, 4} 这个设计我在变量设计部分已经解释了。
S12------清除
cs
case 12:
if(measure_start_flag == 0) { // 累加中不能清除
measure_mode = 0;
seg_display_mode = 1; // 返回测距界面
quench_flag = 1; // 熄灭数码管
distance = 0; // 清零距离
}
break;
累加测量的中断实现
这是这套代码最核心的部分------累加测量的精确定时是通过定时器中断实现的:
cs
void Timer1_Server() interrupt 3 {
/* ===== 累加测量定时 ===== */
if(measure_start_flag == 1) {
// 到达时间间隔?
if(++measure_timer >= time_interval_par * 1000) {
measure_timer = 0;
// 执行一次测距并累加到distance
distance += (Sonic_Read() / 340.0) * (330 + 0.6 * temperature);
if(--measure_count == 0) { // 累加完成
DAC_Transition_IRQ(distance); // 更新DAC
led_buf[1] = (distance < 20 || distance > 100);
measure_start_flag = 0; // 停止累加
}
}
// LED3 闪烁(0.1秒间隔)
if(++led_timer100ms >= 100) {
led_timer100ms = 0;
led_buf[2] ^= 1; // 异或翻转:0→1→0→1...
}
} else {
led_buf[2] = led_timer100ms = 0; // 非累加状态熄灭LED3
}
/* ===== 数码管动态扫描 ===== */
if(seg_buf[seg_pos] > 20)
Seg_Display(seg_pos, seg_buf[seg_pos] - '.', 1);
else
Seg_Display(seg_pos, seg_buf[seg_pos], 0);
/* ===== LED刷新 ===== */
Led_Display(led_buf, 1);
/* ===== 减速变量递增 ===== */
if(++seg_pos == 8) seg_pos = 0;
key_scan_slow++;
seg_scan_slow++;
}
为什么累加测量必须放在中断里?
累加测量要求每隔N秒精确测一次距离。如果把定时逻辑放在主循环里,主循环的执行时间不确定------特别是 Sonic_Read() 本身就很耗时(需要等待超声波回波,最大约30ms)。这意味着:
主循环方式:
measure_timer 在主循环里检查 →
如果上次测距花了30ms →
这30ms内 uwTick 一直在中断里递增 →
但 measure_timer 的递增也在主循环里 →
可能漏掉几毫秒 → 时间间隔不精确
中断方式:
measure_timer 在中断里递增 → 每1ms精确+1 →
时间间隔精确到毫秒级 →
测距函数虽然在主循环执行,但定时不受影响
等等,仔细看代码,Sonic_Read() 是在中断里直接调用的:
cs
distance += (Sonic_Read() / 340.0) * (330 + 0.6 * temperature);
这行代码在中断服务函数中!Sonic_Read() 内部有 while 等待循环(等待超声波回波,最大约30ms)。在中断里阻塞等待30ms是一个危险操作------在这30ms内,主循环和其他中断(如果优先级更低)都被阻塞了。
但这道题只有 Timer1 一个中断在用(没有串口中断、没有其他定时器中断),所以虽然阻塞了30ms,但不会导致其他问题。数码管扫描也在同一个中断里,所以这30ms内数码管确实会停止扫描------但30ms只跳过30位扫描,人眼几乎察觉不到(8位数码管一周期8ms,30ms约3.75个周期,最多导致一个短暂的闪烁)。
这再次说明:中断驱动架构不是银弹。如果赛题同时需要串口通信和精确定时累加,这套架构就不太合适了。第16届省赛恰好不需要串口,所以这套架构工作良好。
DS18B20的85°C陷阱
cs
void main(void) {
System_Init();
while((temperature = Read_Temperature()) == 85); // 等待有效温度
Timer1_Init();
EA = 1;
while(1) {
Key_Proc();
Seg_Proc();
Led_Proc();
}
}
while(temperature == 85) 这行代码是DS18B20的经典用法。DS18B20上电后的默认温度寄存器值是85°C(0x0550),这个值不是真实测量结果。第一次温度转换需要约750ms,在此期间读取会得到85°C。
如果直接用85°C做温度补偿,声速会被计算为 330+0.6×85=381330+0.6×85=381 m/s,比实际值高12%左右,测距结果偏差很大。
while 循环会持续调用 Read_Temperature() 直到返回一个不等于85的值(说明第一次转换已完成)。在实际运行中,这个等待时间约0.75~1秒,用户几乎感觉不到。
与西风模板的架构对比
| 维度 | 西风模板 | 第16届省赛 |
|---|---|---|
| 主循环 | Scheduler_Run() |
Key_Proc() + Seg_Proc() + Led_Proc() |
| 定时方式 | 调度器 + uwTick | 减速变量(key_scan_slow / seg_scan_slow) |
| 数码管扫描 | Timer1中断 | Timer1中断(相同) |
| 累加定时 | 调度器任务 | Timer1中断(精确) |
| LED刷新 | 调度器任务(1ms) | Timer1中断(1ms) |
| 温度读取 | 调度器任务 | 主循环减速变量(30次/100次) |
| 代码量 | main.c约230行 | main.c约260行 |
实际上两者的代码量差不多。西风模板的优势在于结构更清晰------任务周期一目了然,加减任务只需要改配置表。而第16届省赛的方式把所有逻辑分散在中断和主循环各处,阅读和理解起来需要更多上下文。
但第16届省赛的方式也有优势:没有函数指针数组,RAM占用更少 。在 STC15F2K60S2 上,8个任务的 task_c 结构体数组大约需要 8×6=48 字节(idata),对于内存紧张的嵌入式项目来说,这不是一个可以忽略的数字。
赛题PDF中的一些细节
- 上电熄灭 :
quench_flag = 1初始为1,数码管熄灭直到完成首次测量 - DAC清零 :未完成有效测量时
DA_Output(0),防止DAC输出随机值 - LED1指示 :
led_buf[0] = (seg_display_mode == 1),在测距界面时点亮
这些都是"比赛得分点"------看起来是小细节,但每个可能值2~3分,累积起来就是10分左右的差距。
总结
第16届省赛的满分代码展示了几种重要的竞赛技巧:
- 温度补偿:用DS18B20实时修正超声波声速,把测量精度从"够用"提升到"精确"
- 中断安全:函数重定义避免中断重入,虽然两个函数逻辑相同,但意图明确
- 状态互锁:累加测量中禁止界面切换和清除,防止逻辑冲突
- 参数轮换:数组+索引的参数选择方式,比逐次+1更不易出错
- 85°C过滤 :
while(temperature == 85)避免上电假值
这套代码最有价值的地方在于,它展示了什么时候不用调度器。当赛题需要精确定时(累加测量的间隔必须精确到毫秒级)且不涉及复杂的多任务协调时,中断驱动反而更合适。掌握两种架构,比赛时才能灵活选择。