第16届省赛真题满分代码解析——超声波温度补偿、累加测量与中断驱动架构

第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. distanceunsigned 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;

两个限制条件:

  1. 累加测量进行中不允许切换界面(measure_start_flag == 0
  2. 界面在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中的一些细节

  1. 上电熄灭quench_flag = 1 初始为1,数码管熄灭直到完成首次测量
  2. DAC清零 :未完成有效测量时 DA_Output(0),防止DAC输出随机值
  3. LED1指示led_buf[0] = (seg_display_mode == 1),在测距界面时点亮

这些都是"比赛得分点"------看起来是小细节,但每个可能值2~3分,累积起来就是10分左右的差距。


总结

第16届省赛的满分代码展示了几种重要的竞赛技巧:

  1. 温度补偿:用DS18B20实时修正超声波声速,把测量精度从"够用"提升到"精确"
  2. 中断安全:函数重定义避免中断重入,虽然两个函数逻辑相同,但意图明确
  3. 状态互锁:累加测量中禁止界面切换和清除,防止逻辑冲突
  4. 参数轮换:数组+索引的参数选择方式,比逐次+1更不易出错
  5. 85°C过滤while(temperature == 85) 避免上电假值

这套代码最有价值的地方在于,它展示了什么时候不用调度器。当赛题需要精确定时(累加测量的间隔必须精确到毫秒级)且不涉及复杂的多任务协调时,中断驱动反而更合适。掌握两种架构,比赛时才能灵活选择。