ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

蓝桥杯单片机综合题:多传感器时间片调度与中断时序设计

蓝桥杯单片机综合题:多传感器时间片调度与中断时序设计

简介:面向蓝桥杯单片机竞赛备赛者的完整工程资料包,围绕温度显示、超声波测距、光敏D/A显示、红外接收数据四个典型模块展开,并以矩阵键盘S7、S11、S15、S19实现功能切换,适合需要模块化练习和赛题复现的选手使用。压缩包共39个文件、约90KB,包含7个.c源文件、7个.h头文件以及Keil工程文件(.uvproj/.uvopt)、编译生成的.hex烧录文件、.lst列表文件、.obj目标文件和.m51链接映射,能够从源码到烧录完整还原开发环境。对应DS18B20温度采集、IIC通信、红外解码、数码管显示(SMG)、独立/矩阵按键扫描等底层驱动,每个功能模块独立成.c/.h,源码结构清晰,便于对照学习或裁切复用,可配合蓝桥杯开发板进行验证。已有1640余人学习下载,适合需要直接获得可运行工程、快速验证模块效果,并在此基础上进行二次开发的单片机学习者。

1. 蓝桥杯单片机这道综合题,考的其实是“时间片的切割”

蓝桥杯单片机赛题里,把温度显示、超声波测距、光敏D/A显示、红外接收数据显示放进同一道大题,看起来是四个独立外设,实际考的是同一件事:一颗单片机怎么同时伺候四路采集和一路数码管刷新。温度要卡单总线时序,超声波要等回波,红外要靠电平宽度解协议,光敏又要ADC和PWM联动,任何一个环节用 while 死等,另外三个模块就得跟着卡壳。这道题适合两类人:一类是正在备赛蓝桥杯单片机组的学生,另一类是做传感器采集小系统但总被中断和延时折磨的开发者。下面按“先立时序规则,再写采集代码,最后给排错套路”的顺序展开,争取你照着敲完,四个模块能稳定显示在同一块板上。

2. 温度显示与超声波测距:两条并行总线的用时策略

2.1 温度传感器:单总线的复位时序不能被任何中断打断

温度模块最常见的器件是 DS18B20,单总线通信。单总线空闲时靠上拉电阻保持高电平,主机发起一次通信要先把总线拉低至少 480us 产生复位脉冲,从机在 60us 到 240us 之间拉低总线作为应答。接下来的读时序和写时序,每个 slot 只有 60us 到 120us,中间一旦被定时器中断插一脚,脉冲宽度超出器件允许范围,读回来的就是 0xFF 或者 0x50 这种固定错值。

我一般这么处理复位和读温度:

#define DQ P1^0 void delay_us(unsigned int us) { unsigned int i; while (us--) { for (i = 0; i < 12; i++); // 12MHz 1T 单片机,循环约延迟1us,需实测校准 } } bit ds_reset(void) { bit presence; EA = 0; // 关总中断,避免数码管刷新打断复位脉冲 DQ = 0; delay_us(500); // 复位脉冲,480us以上 DQ = 1; // 释放总线,等待上拉 delay_us(70); // 等从机应答窗口 presence = DQ; // 读应答:0=有器件,1=无 delay_us(400); EA = 1; return presence; } void ds18b20_read(void) { unsigned char raw_l, raw_h; int temp; if (ds_reset() == 1) return; // 总线上没器件,直接退出 ds_write_byte(0xCC); // 跳过 ROM 匹配 ds_write_byte(0x44); // 启动温度转换 delay_ms(120); // 比赛常用9位精度,96ms够用 if (ds_reset() == 1) return; ds_write_byte(0xCC); ds_write_byte(0xBE); // 读暂存器 raw_l = ds_read_byte(); raw_h = ds_read_byte(); temp = (signed char)raw_h; // 先按有符号扩展,处理负温度 g_temp = (temp << 4) | (raw_l >> 4); // 忽略小数位,显示整数温度 }

这段代码的关键在EA = 0和EA = 1这对开关。复位脉冲不像普通的 GPIO 输出,它要求在 480us 到 960us 之间持续拉低,如果你把复位写到一半被数码管刷新中断打断,低电平时间被延长,器件会当成一次新的复位请求,后面所有读写全部错位。关中断只包住一次复位时序,读字节的每个 slot 我也同样处理。整个温度转换过程里最耗时的是delay_ms(120),这段不关中断,因为器件在转换期间不需要总线通信,中断随便进出。

delay_ms(120)对应 9 位分辨率。DS18B20 出厂默认 12 位精度,转换要 750ms,赛场上一秒钟都等不起,所以要在初始化时写配置寄存器,把精度降到 9 位或 10 位。降精度影响的只是 0.5 摄氏度的小数位,整数温度完全不受影响。如果读回来的温度一直是 0,先检查是不是没降精度,再看是不是每次复位都成功。

2.2 超声波测距:Echo 的脉冲宽度是测距的全部

超声波模块的原理比 DS18B20 简单得多:给 Trig 脚一个大于 10us 的高电平脉冲,模块内部发出 8 个 40kHz 脉冲,碰到障碍物返回,Echo 脚输出一个高电平,高电平持续时间就是声音往返的总时间。声速大约 340m/s,距离等于时间乘以声速再除以二,换算下来就是时间(us)除以 58 约等于厘米数。

unsigned int hc_sr04_get_cm(void) { unsigned int tick; TRIG = 0; delay_us(2); TRIG = 1; delay_us(12); // 触发脉冲保持12us,大于10us即可 TRIG = 0; while (ECHO == 0); // 等回波前沿 T0 = 0; // 超声波专用定时器清零 while (ECHO == 1) { // 等回波后沿 if (T0 > 40000) { // 超过40ms判定超时,防卡死 tick = 0; break; } } tick = T0; if (tick == 0) return 0; // 超时返回0 return tick / 58; // us/58约等于cm }

这里必须有一个独立的定时器专门给回波计时,不能跟数码管的刷新定时器混用。T0 配成 16 位定时器,不重装,清零后让它自己跑,Echo 变低立刻读出计数值。超时保护是关键,如果测量范围内没有障碍物,Echo 会一直保持低电平,while (ECHO == 0)会死等;如果障碍物太远,回波超过 40ms 还没回来,模块已经进入下一次测量周期,不保护就会卡死主循环。40ms 对应的最大测距范围大约 6.8 米,对桌面级赛题完全够用。声速会随温度变化,0℃ 时约 331m/s,40℃ 时约 354m/s,两米距离能差出 3 厘米左右。如果题目要求高精度,可以拿 DS18B20 读到的温度去做声速补偿,一般赛题不追究这点。

2.3 两个传感器交替巡检:不阻塞数码管刷新的时间片写法

DS18B20 转换要几十毫秒,超声波回波最长要 20 多毫秒,两个模块如果各用各的阻塞延时,叠在一起就是上百毫秒的空白。这段时间里数码管会暗掉,按键会失灵,红外帧也可能被漏掉。

我习惯把两个传感器放在同一个 200ms 时间片里交替执行,奇数拍读温度,偶数拍测距离:

void main_loop(void) { static unsigned char measure_count = 0; if (ms_tick - last_tick >= 200) { // ms_tick由定时器1每次中断加1 last_tick = ms_tick; if (measure_count & 1) { ds18b20_read(); // 奇数拍读温度 display_buffer[0] = g_temp / 10; display_buffer[1] = g_temp % 10; } else { g_dist = hc_sr04_get_cm(); // 偶数拍测距离 display_buffer[2] = g_dist / 10; display_buffer[3] = g_dist % 10; } measure_count++; } show_digit(); // 数码管动态刷新,无条件执行 }

温度每隔 200ms 读一次,DS18B20 有足够时间完成转换,不需要在代码里额外 delay 等待。超声波同样隔 200ms 测一次,相邻两次测量间隔远大于模块的 60ms 测量周期,数据不会重叠。这样安排之后,数码管刷新函数每毫秒执行一次,四个传感器各自占用自己的时间片,谁也不会卡住谁。

3. 光敏 D/A 显示:ADC 读取与 PWM 输出的映射陷阱

3.1 ADC 读光敏:读到的不是“亮度”而是“分压”

光敏电阻本身是一个可变电阻,光照越强阻值越低。它要和一只固定电阻组成分压电路,把电阻变化转换成电压变化,ADC 才能真正读进来。注意接法决定了映射方向:如果光敏电阻在上面、固定电阻在下面接地,光照强时 ADC 输入电压低;反过来接,光照强时电压高。不同开发板接法不一样,我自己就翻过一次车,光敏值变化方向跟题目要求的相反,一遮光数值反而变大。

读取 ADC 的代码本身很直接:

unsigned int adc_read(unsigned char ch) { unsigned int val; ADC_CONTR = 0x80 | 0x08 | (ch & 0x07); // 启动ADC,选择通道 delay_us(20); // 采样保持时间 while (!(ADC_CONTR & 0x10)); // 等ADC_FLAG置1 ADC_CONTR &= ~0x10; // 清标志位 val = (ADC_RES << 8) | ADC_RESL; // 10位结果合成 return val; }

ADC 转换完成后标志位必须手动清零,否则下次启动时状态判断会出错。ADC_RES和ADC_RESL两个寄存器怎么拼,要看 ADC 配置成左对齐还是右对齐。左对齐时ADC_RES是高 8 位,ADC_RESL是低 2 位放到高两位,合成公式就是(ADC_RES << 8) | ADC_RESL。右对齐时结果要换成(ADC_RES << 2) | (ADC_RESL >> 6)。蓝桥杯赛题常见设置是左对齐,因为只取高 8 位时可以直接读ADC_RES。如果发现 ADC 值永远只有 0 或 1023 这种极端值,先查分压电阻是否接对,再查 ADC 参考电压,下一步才怀疑代码。

3.2 PWM 模拟 DAC:占空比决定电压,RC 低通滤波决定纹波

赛题要求的“光敏 D/A 显示”,难点在 D/A 不在 A/D。很多增强型 8051 单片机内部没有真正的 DAC 模块,输出模拟电压要靠 PWM 加 RC 低通滤波。PWM 的占空比越高,滤波后的平均电压越接近 VCC,PWM 频率必须远高于 RC 滤波器的截止频率,否则输出纹波大到万用表读数抖个不停。

void pwm_set_duty(unsigned int duty) { if (duty > PWM_PERIOD) duty = PWM_PERIOD; // 钳位 PWM_DUTY = duty; // 写比较寄存器 } void light_to_pwm(void) { unsigned int adc_val, duty; adc_val = adc_read(LIGHT_CH); // 读光敏通道 // 光敏电阻上拉、固定电阻下地时,光照强→ADC值小 // 所以占空比与ADC值成反比,先取反再映射 duty = (unsigned long)(1024 - adc_val) * PWM_PERIOD / 1024; pwm_set_duty(duty); }

这里最容易出问题的是整数溢出。8 位单片机的 int 是 16 位,(1024 - adc_val) * PWM_PERIOD在 1024 乘以一个比较大的周期值时已经溢出,必须把第一个操作数强转成unsigned long,让乘法在 32 位空间里完成。PWM 周期值怎么定取决于单片机时钟和 PWM 模块分频,一般让 PWM 频率落在 4kHz 到 10kHz 之间。频率太低,RC 滤波器需要更大的电容电阻,响应慢;频率太高,PWM 翻转损耗和开关噪声上升。

RC 低通滤波参数可以按这个经验值起步:

参数推荐取值说明
PWM 频率4kHz ~ 10kHz低于 1kHz 时 RC 体积太大,响应太慢
滤波电阻1kΩ ~ 10kΩ电阻太小时输出带载能力差,太大时压降明显
滤波电容1uF ~ 10uF和电阻共同决定截止频率,越大纹波越小
截止频率PWM 频率的 1/10 以下例如 5kHz PWM 配 500Hz 截止频率,衰减足够

以 1kΩ 电阻配 10uF 电容为例,截止频率约 16Hz,对 5kHz 的 PWM 有很强的衰减,但输出电压爬升也慢,光强突变时要半秒才能稳定。想快就减小电容,想平滑就加大电容,看题目要求响应速度还是稳定度。

3.3 D/A 显示的非线性映射:赛题的“显示”到底显示什么

题目里“光敏 D/A 显示”的具体要求一般分两段:数码管显示一个光照等级,同时 PWM 输出一个模拟电压。如果只是显示等级,把 ADC 值线性映射到 0 到 99 就够了。但光敏电阻的阻值和光照强度不是线性关系,暗光时阻值变化剧烈,亮光时变化平缓,如果直接线性映射,暗处等级跳动很大,亮处半天不动。

要做平滑映射,我通常用分段查表,把 ADC 的 10 位范围砍成 16 段,每段一个权重:

code unsigned char light_map[16] = { 0, 8, 16, 24, 30, 36, 42, 48, 55, 62, 70, 78, 85, 90, 95, 99 }; duty = (unsigned long)light_map[adc_val >> 6] * PWM_PERIOD / 100;

adc_val >> 6把 0 到 1023 的 ADC 值压缩到 0 到 15,查表得到 0 到 99 的等级,再映射到 PWM 占空比。查表法比算公式直观,赛场上改起来也快,每个断点的数值用手一摸就知道对不对。如果题目要求“输出 0 到 5V 模拟电压”,就把 PWM 周期值按 5V 对应 100% 占空比换算,等级值直接乘 PWM_PERIOD 再除 100,代码结构相同。

4. 红外接收数据显示:NEC 协议中断解析,别在中断里做除法

4.1 一体化接收头输出:一串宽度不等的低电平脉冲

红外接收头内部已经做了载波解调,输出脚直接给单片机一串脉冲。NEC 协议下空闲时为高电平,收到一帧数据时先来一段 9ms 低电平加 4.5ms 高电平的引导码,然后跟 32 位数据,每位由 560us 低电平开头,后面接高电平,高电平持续 1.69ms 表示逻辑 1,持续 560us 表示逻辑 0。32 位数据里高 16 位是地址码和地址反码,第 17 到 24 位是键值码,低 8 位是键值反码。校验方法很朴素:键值码加上反码等于 0xFF。

接收头输出的波形是反相的,解调后的脉冲边沿正好能用来触发外部中断。把接收头输出脚接到 INT0,配成下降沿触发,每次电平跳变进一次中断,在中断里读出当前定时器计数值,和上一次进中断的时刻作差,就得到一个时间间隔。这个间隔落在哪个区间,就对应哪一位。

4.2 定时器加状态机解析:不在等待脉冲,而是测量脉冲

解析 NEC 最忌讳的是用while (pin == 0)去等电平变化。一个数据位短则 1.125ms,长则 2.25ms,加上引导码一帧接近 70ms,如果用阻塞等待,CPU 全耗在红外上,温度和超声波全线崩溃。正确做法是把定时器配成自由计数,每次下降沿进中断,记下间隔,然后用状态机判断。

unsigned long ir_data = 0; unsigned char ir_bit = 0; unsigned char ir_state = 0; bit ir_done = 0; void ir_int0_isr() interrupt 0 { unsigned int gap; gap = (TH0 << 8) | TL0; // 读出两次下降沿间隔 TH0 = 0; TL0 = 0; if (ir_state == 0) { // 等待引导码 if (gap > 10000 && gap < 16000) { // 约13.5ms引导码 ir_state = 1; ir_bit = 0; ir_data = 0; } return; } if (gap > 10000 && gap < 16000) { // 中途来重复帧 ir_state = 1; // 重新解析 ir_bit = 0; ir_data = 0; return; } if (ir_bit < 32) { if (gap > 1900 && gap < 2600) // 约2.25ms → 逻辑1 ir_data |= (1UL << ir_bit); else if (gap > 900 && gap < 1400) // 约1.125ms → 逻辑0 ; // 逻辑0不用置位 else { ir_state = 0; // 非法间隔,整帧作废 return; } ir_bit++; } if (ir_bit == 32) { if (((ir_data >> 8) & 0xFF) == (unsigned char)~ir_data) ir_done = 1; // 数据码与反码校验通过 ir_state = 0; } }

阈值 10000 到 16000、1900 到 2600、900 到 1400 是按定时器 1MHz 计数频率算的,对应 13.5ms、2.25ms、1.125ms 加 20% 容差。不同晶振下这些数要换算,比如 12MHz 晶振配 12 分频,定时器计数频率是 1MHz,直接用;如果是直通时钟不分频,计数频率 12MHz,阈值全部乘 12。改完必须实测遥控器重复按同一个键,看解析出来的值是否稳定。

中断服务程序里只做了比较和移位,没有做除法。8051 内核做 16 位除法要几十个周期,在中断里跑除法会让中断服务时间拉长,引发其他时序问题。uint 的间隔用区间判断替代等值判断,抗干扰能力也更好。1UL << ir_bit这里必须用 unsigned long 移位,直接写1 << ir_bit在 ir_bit 等于 16 时结果已经错了。

4.3 把接收数据映射到数码管

红外数据收完要做两件事:校验、显示。校验通过后才更新显示缓冲区,校验失败保留上一帧数据。这样遥控器没对准或者中途被遮挡时,数码管上不会跳出一堆乱码。

void ir_show(void) { unsigned char key; if (!ir_done) return; ir_done = 0; key = ir_data & 0xFF; // 取低8位键值码 display_buffer[4] = key >> 4; // 十六进制高位 display_buffer[5] = key & 0x0F; // 十六进制低位 }

按十六进制显示键值比十进制直观,遥控器按键码本身就是十六进制定义的,比如 0x45、0x46 这种。如果题目要求显示十进制,用key / 10和key % 10拆两位就够。数码管上 D4、D5 两个位显示红外键值,显示缓冲区统一由主循环里的show_digit()刷新,中断只置标志,不在中断里直接改显示缓冲区,避免和主循环打架。

5. 蓝桥杯单片机综合调试避坑清单:现象、原因与修复

5.1 温度读数固定在 0xFF 或 0x50,换了传感器也一样

现象是数码管温度一直显示 255 或者 80,怎么重新上电都不变。最容易忽略的是复位时序被中断打断,但很多人关掉了数码管刷新中断温度就正常了,一开中断又坏。原因在于ds_reset()里虽然关中断,但ds_write_byte和ds_read_byte的内部时序段没有关,读 slot 被定时器中断从中间切开,脉冲超过 120us 上限。解决办法是把读写单字节的函数也用同样的方式保护起来,或者把温度、超声波、红外这几个模块的中断优先级调成比定时器刷新更高,让温度时序不被数码管打断。

还有一个隐蔽原因:上拉电阻没接。单总线需要外部上拉到 VCC,如果开发板上的跳线帽没插到位,总线空闲时浮空,复位时序根本跑不起来。用万用表量一下 DQ 引脚空闲电压,正常应该是接近 VCC,只有 0V 就是上拉缺失。

5.2 超声波距离偶尔跳变 5cm 以上,连续测量不稳定

现象是距离显示大部分时间正常,偶尔跳出一个明显偏大的值,或者回波超时返回 0。原因多半是 Echo 脉冲测量期间,定时器计数值被其他代码干扰。T0 如果同时被红外解析占用,或者测距函数里用了while (ECHO == 1)但中间开了中断,中断服务程序消耗的时间被算进回波宽度里。解决方法是给超声波一个专用定时器,回波测量的循环里不要执行任何可能耗时超过 100us 的操作。我还会连续测三次取中位数,三个值排序去中间那个,能有效滤掉偶发干扰。测距间隔保持在 50ms 以上,模块内部电容放电需要时间,间隔太短第二次数值会偏小。

5.3 红外同一按键按两次,数码管显示值不同

现象是第一次按遥控器显示 0x45,第二次同一按键显示 0x46 或 0x42,偶尔还直接不更新。原因基本是阈值判定边界卡得太紧,或者定时器计数频率换算出错。900 到 1400 这个区间对应逻辑 0,如果晶振实际频率偏高,1.125ms 实际计数值可能超过 1400,就会落入“非法间隔”分支,整帧作废。解决方法是先用实测方法确认定时器计数频率,再重新算区间:按住遥控器不放,观察重复码的间隔计数值,把测到的数值中心值正负 20% 作为判定区间。中断里也不要做除法,除法会把中断服务时间拉长到几十微秒,期间积压的 GPIO 跳变事件处理不及时,后续间隔全部错位。

5.4 PWM 输出电压和理论值偏差大,万用表读数乱跳

现象是光敏变化时 PWM 输出变化方向对,但电压值比理论低 0.3V 以上,或者万用表最后一位一直跳。先确认 PWM 频率是否在 4kHz 以上,频率太低的 PWM 用万用表测量会有明显的指针摆动。再看 RC 滤波器的截止频率,如果截止频率落在 PWM 频率附近,纹波根本没有被滤掉。解决方法是把 RC 截止频率压到 PWM 频率的十分之一以下,比如 PWM 5kHz,RC 截止频率 500Hz,1kΩ 加 0.33uF 就能做到。还要注意 VCC 实际值,如果单片机的供电是 4.8V,那 100% 占空比输出就是 4.8V 而不是 5V,换算关系要按实物电压修正。没有示波器时,把 PWM 输出接到 LED 上看亮度变化,亮暗清晰的说明占空比在正常变化,只有数值差异大才查电压换算。

5.5 四个模块全部开启后数码管乱跳,单模块测试都正常

现象是单独测温度正常,单独测超声波也正常,合成一个工程后数码管数字互相串位,按什么都不对。这多半是显示缓冲区被并发修改。单片机的中断可以在任何一条指令执行时插入,如果主循环里正在把温度数据写入显示缓冲区,红外中断刚好修改了同一片内存,缓冲区里的数就是两个数据拼出来的。解决方法是每个传感器只写自己专属的缓存变量,主循环统一从这些变量生成显示帧,中断里的红外解析只置标志位,主循环看到标志后再更新显示。跨中断共享的变量都要加volatile关键字,防止编译器优化时把变量带到寄存器里,导致中断改写了内存但主循环还在用旧副本。这一步做完,合体测试才真正有意义。

6. 把四个模块收拢进一个时间片循环:用“调试页面”验证每个模块

6.1 数码管当调试终端:按键切换数据页面

四个模块单测都过了,合体之后怎么快速定位问题?我的习惯是把数码管做成一个多页面调试终端,预留两个按键切页面,一页显示一个模块的原始数据。这样不用接串口线,不用仿真器,直接看着数码管判断每个模块在自己的时间片里是否正常工作。

页面切换逻辑很简单:

void key_scan(void) { static unsigned char page = 0; if (K1按下) page = (page + 1) % 5; // K1切换页面 switch (page) { case 0: show_temp_page(); break; // 温度整数 case 1: show_ultra_page(); break; // 超声波距离cm case 2: show_light_page(); break; // 光敏ADC原始值 case 3: show_pwm_page(); break; // 当前占空比 case 4: show_ir_page(); break; // 红外键值 } }

调试页面比直接显示最终结果更能暴露问题:切到光敏页时,用手遮住光敏电阻,ADC 原值应该向一个方向平滑变化,如果数值跳变剧烈,说明 ADC 采样周期太短或滤波电容偏大造成滞后;切到 PWM 页时,应该看到占空比数字和 ADC 值严格反向,这时拿万用表量输出脚,电压应该稳定在某个值附近;切到红外页,按住遥控器同一个键不放,显示值要每次都一样。超声波页用手掌慢慢靠近模块,距离值应连续减小,不许跳变,超过约 4 米稳定返回 0。

我调这类赛题养成的习惯是:主函数里不铺一层延时。每加一个阻塞式 delay,就是在给下一个要调的外设埋一颗雷。先把时间片切好,把调试页面做好,等四路数据都在数码管上稳定跳动了,再回头优化单项性能。按页面逐项排查完,这道综合题就只剩一个雷要踩:你自己会不会忘记注释掉调试页面,把代码精简上交。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进