蓝桥杯单片机学习笔记(十二):V2026 大模板构建(上)
温馨提示:本文内容源自米醋电子工作室培训课,由本人学习、整理并补充说明。
本文面向已经学过"调度器大模板"的同学,也尽量照顾刚入门的小白。你可以把这篇笔记理解成一次"模板升级":原来的模板像一辆能跑的自行车,V2026 模板则是在保留车架的基础上,换上了更适合国赛复杂路况的变速器和刹车系统。
这里附上调度器模板的链接:---调度器模板教学
目录
- 一、前言:为什么要升级模板
- 二、LED、蜂鸣器、继电器、电机
- 三、数码管显示
- [四、初始化底层 Init](#四、初始化底层 Init)
- [五、按键底层 Key:从"单键值"升级为"16 位状态图"](#五、按键底层 Key:从“单键值”升级为“16 位状态图”)
- [六、NE555 频率测量:屏蔽 P34 与缩短测量周期](#六、NE555 频率测量:屏蔽 P34 与缩短测量周期)
- [七、PWM 调光:把 LED 处理合并到显示处理思路中](#七、PWM 调光:把 LED 处理合并到显示处理思路中)
- [八、温度底层 onewire:DS18B20 读取与分辨率设置](#八、温度底层 onewire:DS18B20 读取与分辨率设置)
- [九、本篇小结:V2026 模板的核心变化](#九、本篇小结:V2026 模板的核心变化)
一、前言:为什么要升级模板
这次的 V2026 大模板 是在之前"调度器模板"的基础上做的进一步优化,主要目标是应对蓝桥杯国赛中更复杂、更容易"卡边界"的题目。
如果说省赛模板更像一套"标准厨房工具"------锅、铲、刀都能用,做一般菜完全够用;那么国赛模板更像"专业后厨工作台"------不仅工具更全,还要考虑多人同时操作、火候更细、出菜更快。
换到单片机模板里,就是:
- 按键不再只考虑"一个键按下",而是要能表示 多个按键同时按下;
- NE555 测频不再只追求"能测出来",还要考虑测评系统的时间窗口;
- 温度读取不再只写基础函数,还要考虑上电初值、分辨率和稳定性;
- LED、数码管、按键、外设控制之间的调度关系要更加清晰。
本文会重点标注相较旧模板的变化。如果你已经学过之前的调度器大模板,可以优先看 "改动" 部分;如果你是小白,也可以从头阅读,把每个模块当成一块积木来理解。
二、LED、蜂鸣器、继电器、电机
本部分较之前模板基本无变化,仍然使用锁存器控制外设。
可以把 P0 想象成"数据传送带",P2 的高三位则像"仓库门牌号":
我们先把数据放到 P0 上,再通过 P2 选择对应的锁存器,把数据送进正确的"仓库"。
蓝桥杯单片机开发板常用的几个锁存器地址大致可以这样记:
| 外设 | 常见锁存器选择 | 作用理解 |
|---|---|---|
| LED | 0x80 |
把 P0 上的数据锁存到 LED 控制端 |
| 蜂鸣器/继电器/电机等 | 0xa0 |
把 P0 上的数据锁存到 ULN2003 等驱动控制端 |
| 数码管位选 | 0xc0 |
选择第几个数码管亮 |
| 数码管段选 | 0xe0 |
选择数码管显示什么内容 |
小白提示 :
P2 = P2 & 0x1f | 0x80;的意思是:保留 P2 低 5 位,把高 3 位改成锁存器地址。因为 C 语言中
&的优先级高于|,所以它等价于:
P2 = (P2 & 0x1f) | 0x80;
代码
c
//Led.c
#include "Led.h"
idata unsigned char temp_1 = 0x00;
idata unsigned char temp_1_old = 0xff;
void Led_Disp(unsigned char *ucLed)
{
temp_1 = 0x00;
// 将 Led_Buf[0] ~ Led_Buf[7] 这 8 个开关状态压缩成一个字节
// ucLed[0] 对应最低位,ucLed[7] 对应最高位
temp_1 = ucLed[0] << 0 | ucLed[1] << 1 | ucLed[2] << 2 | ucLed[3] << 3 |
ucLed[4] << 4 | ucLed[5] << 5 | ucLed[6] << 6 | ucLed[7] << 7;
// 只有当 LED 状态发生变化时才刷新锁存器,减少无意义写入
if(temp_1 != temp_1_old)
{
P0 = ~temp_1; // LED 通常为低电平点亮,所以这里取反
P2 = P2 & 0x1f | 0x80; // 选择 LED 锁存器
P2 &= 0x1f; // 关闭锁存器选择
temp_1_old = temp_1; // 保存本次状态
}
}
void Led_Off()
{
P0 = 0xff; // 全部熄灭
P2 = P2 & 0x1f | 0x80;
P2 &= 0x1f;
temp_1_old = 0x00;
}
idata unsigned char temp_2 = 0x00;
idata unsigned char temp_2_old = 0x00;
// 注意:此处的 temp_2_old 由 0xff 修改为 0x00
// 这样上电后 temp_2 与 temp_2_old 初值一致,避免无必要的第一次刷新
//蜂鸣器
void Beep(bit enable)
{
if(enable)
temp_2 |= 0x40; // 需要蜂鸣器时,将对应位置 1
else
temp_2 &= ~0x40; // 不需要蜂鸣器时,将对应位清 0
//需要某一位,把某一位或1,不需要某一位,把某一位与0
if(temp_2 != temp_2_old)
{
P0 = temp_2;
P2 = P2 & 0x1f | 0xa0;
P2 &= 0x1f;
temp_2_old = temp_2;
}
}
//继电器
void Relay(bit enable)
{
if(enable)
temp_2 |= 0x10;
else
temp_2 &= ~0x10;
//需要某一位,把某一位或1,不需要某一位,把某一位与0
if(temp_2 != temp_2_old)
{
P0 = temp_2;
P2 = P2 & 0x1f | 0xa0;
P2 &= 0x1f;
temp_2_old = temp_2;
}
}
//电机
void MOTOR(bit enable)
{
if(enable)
temp_2 |= 0x20;
else
temp_2 &= ~0x20;
//需要某一位,把某一位或1,不需要某一位,把某一位与0
if(temp_2 != temp_2_old)
{
P0 = temp_2;
P2 = P2 & 0x1f | 0xa0;
P2 &= 0x1f;
temp_2_old = temp_2;
}
}
//Led.h
#include <STC15F2K60S2.H>
void Led_Disp(unsigned char *ucLed);
void Led_Off();
void Beep(bit enable);
void Relay(bit enable);
void MOTOR(bit enable);
本模块理解重点
1. temp_1 和 temp_1_old 的作用
这两个变量像"当前照片"和"上一张照片"。
temp_1:本次准备显示的 LED 状态;temp_1_old:上一次已经显示出去的 LED 状态。
如果两张照片一样,就没必要重新拍一遍、传一遍;如果不一样,才重新刷新 LED。
这样写的好处是:减少对锁存器的重复操作,让程序更稳定、更省时间。
2. 蜂鸣器、继电器、电机共用 temp_2
蜂鸣器、继电器和电机都挂在同一组驱动控制位上,所以不能每次只管自己,否则容易出现"开蜂鸣器时把继电器状态冲掉"的问题。
temp_2 就像一个总开关面板,每一位控制一个设备:
| 位值 | 含义 |
|---|---|
0x10 |
继电器 |
0x20 |
电机 |
0x40 |
蜂鸣器 |
使用 |= 是"只打开某一个开关,不影响其他开关";使用 &= ~ 是"只关闭某一个开关,不影响其他开关"。
三、数码管显示
本部分较之前模板无变化。
数码管显示可以理解成"快速轮流点亮":人的眼睛有视觉暂留,只要刷新足够快,看起来就像 8 个数码管同时亮着。
数码管显示通常分为两步:
- 位选:选择第几个数码管亮;
- 段选:选择这个数码管显示什么数字或符号。
就像在教室里点名:先叫"第 3 排第 2 个同学",再告诉他"你举数字 8 的牌子"。
代码
c
//Seg.c
#include "Seg.h"
// 共阳数码管段码表
// 0~9 分别显示数字 0~9,最后一个 0xff 表示空白
pdata unsigned char Seg_Dula[] = {0xc0,0xf9,0xa4,0xb0,0x99,0x92,0x82,0xf8,0x80,0x90,0xff
};
void Seg_Disp(unsigned char wela,unsigned char dula,bit point)
{
//消影
// 切换数码管之前,先让段码全灭,避免上一位的残影带到下一位
P0 = 0xff;
P2 = P2 & 0x1f | 0xe0;
P2 &= 0x1f;
// 位选:选择第 wela 个数码管
P0 = 0x01 << wela;
P2 = P2 & 0x1f | 0xc0;
P2 &= 0x1f;
// 段选:决定当前数码管显示的内容
P0 = Seg_Dula[dula];
if(point)
P0 &= 0x7f; // 小数点通常对应最高位,清 0 点亮小数点
P2 = P2 & 0x1f | 0xe0;
P2 &= 0x1f;
}
//Seg.h
#include <STC15F2K60S2.H>
void Seg_Disp(unsigned char wela,unsigned char dula,bit point);
本模块理解重点
1. 为什么要"消影"
如果不先消影,数码管从一位切到下一位时,上一位的段码可能还残留在总线上。
这就像投影仪换 PPT 时上一页还没完全消失,下一页已经出现,会看到一瞬间的重影。
所以模板里先执行:
c
P0 = 0xff;
P2 = P2 & 0x1f | 0xe0;
P2 &= 0x1f;
这一步相当于先把黑板擦干净,再写下一位的内容。
2. Seg_Dula[10] = 0xff 的意义
0xff 表示所有段都不亮,也就是空白。
在实际题目中,如果某一位不想显示内容,可以把 Seg_Buf[i] 设置为 10,让该位熄灭。
四、初始化底层 Init
初始化函数的任务很简单:
上电后先把所有外设恢复到安全状态。
小白可以把它理解成开机前的"整理桌面":
- LED 全灭;
- 蜂鸣器不响;
- 继电器不吸合;
- 电机不转。
这样做可以避免单片机刚上电时,因为锁存器状态不确定导致外设乱动。
代码
c
//Init.c
#include "Init.h"
void System_Init()
{
// 关闭 LED
P0 = 0xff;
P2 = P2 & 0x1f | 0x80;
P2 &= 0x1f;
// 关闭蜂鸣器、继电器、电机等外设
P0 = 0x00;
P2 = P2 & 0x1f | 0xa0;
P2 &= 0x1f;
}
//Init.h
#include <STC15F2K60S2.H>
void System_Init();
本模块理解重点
初始化函数建议放在 main() 的最开始执行。
一般结构可以理解为:
c
void main()
{
System_Init(); // 先关掉不该乱动的外设
Timer_Init(); // 再打开定时器等核心功能
while(1)
{
Key_Proc();
Seg_Proc();
// 其他任务函数
}
}
它就像比赛开始前检查工具箱:
如果一开始工具就是乱的,后面再精妙的逻辑也容易出问题。
五、按键底层 Key:从"单键值"升级为"16 位状态图"
本部分是 V2026 模板的重要改动之一。
1. 为什么要改按键底层
旧模板中,按键通常返回一个单独的键值,例如:
- S4 返回 4;
- S5 返回 5;
- S15 返回 15。
这种写法适合"每次只按一个键"的场景。
但是国赛题目可能出现更复杂的要求,例如:
- 检测两个键同时按下;
- 判断不同行、不同列按键组合;
- 用组合键进入设置界面;
- 用长按 + 短按实现不同功能。
这时候,如果仍然只返回一个键值,就像只给你一张"单人照片",无法表达"合影"。
V2026 模板改成返回 unsigned int,本质上就是给 16 个按键拍一张"全家福":哪个键按下,哪个位置就标 1。
2. 16 位按键状态的理解
按键一共有 16 个,而 C51 中 unsigned int 通常为 16 位,所以刚好可以用 16 个二进制位表示 16 个按键:
text
0000 0000 0000 0000
在新模板中,这串数字 从右往左 对应:
text
S4 S5 S6 S7 S8 S9 S10 S11 S12 S13 S14 S15 S16 S17 S18 S19
也就是说:
| 按键 | 对应位 | 二进制权值 | 十进制值 |
|---|---|---|---|
| S4 | bit0 | 1 << 0 |
1 |
| S5 | bit1 | 1 << 1 |
2 |
| S6 | bit2 | 1 << 2 |
4 |
| S7 | bit3 | 1 << 3 |
8 |
| S8 | bit4 | 1 << 4 |
16 |
| S9 | bit5 | 1 << 5 |
32 |
| S10 | bit6 | 1 << 6 |
64 |
| S11 | bit7 | 1 << 7 |
128 |
| S12 | bit8 | 1 << 8 |
256 |
| S13 | bit9 | 1 << 9 |
512 |
| S14 | bit10 | 1 << 10 |
1024 |
| S15 | bit11 | 1 << 11 |
2048 |
| S16 | bit12 | 1 << 12 |
4096 |
| S17 | bit13 | 1 << 13 |
8192 |
| S18 | bit14 | 1 << 14 |
16384 |
| S19 | bit15 | 1 << 15 |
32768 |
例如:
text
0000 1000 0000 0000
代表 bit11 为 1,也就是 S15 被按下 ,换算成十进制就是 2048。
如果 S15 和 S4 同时按下:
text
0000 1000 0000 0001
换算成十进制就是:
text
2048 + 1 = 2049
这就是多键同按的核心思想。
重要提醒 :
如果键码值可能大于 255,那么
Key_Val、Key_Down、Key_Up、Key_Old这几个按键专用变量都应该使用unsigned int。如果仍然使用
unsigned char,高 8 位会丢失,S12~S19 这部分按键就可能判断错误。
3. 示例写法说明
如果想判断 S15 是否刚刚按下,可以写:
c
if(Key_Down == 2048) // 此行代表按下 S15
{
// 写你按键需要操作的语句
}
如果想判断 S15 和 S4 是否同时刚刚按下,可以写:
c
if(Key_Down == 2049) // S15 + S4 同时按下
{
// 写组合键对应的操作
}
如果想让代码更直观,也可以使用位移写法:
c
if(Key_Down == (1 << 11)) // S15
{
// S15 按下
}
if(Key_Down == ((1 << 11) | (1 << 0))) // S15 + S4
{
// S15 和 S4 同时按下
}
这种写法不需要自己手算 2048、2049,可读性更好。
4. 底层代码
c
//Key.c
#include "Key.h"
unsigned int Key_Read()
{
unsigned int Key_State = 0;
// 第一列扫描:拉低 P44,其余列拉高
P44 = 0;P42 = 1;P35 = 1;P34 = 1;
if(P33 == 0) Key_State |= (1 << 0);
if(P32 == 0) Key_State |= (1 << 1);
if(P31 == 0) Key_State |= (1 << 2);
if(P30 == 0) Key_State |= (1 << 3);
// 第二列扫描:拉低 P42
P44 = 1;P42 = 0;P35 = 1;P34 = 1;
if(P33 == 0) Key_State |= (1 << 4);
if(P32 == 0) Key_State |= (1 << 5);
if(P31 == 0) Key_State |= (1 << 6);
if(P30 == 0) Key_State |= (1 << 7);
// 第三列扫描:拉低 P35
P44 = 1;P42 = 1;P35 = 0;P34 = 1;
if(P33 == 0) Key_State |= (1 << 8);
if(P32 == 0) Key_State |= (1 << 9);
if(P31 == 0) Key_State |= (1 << 10);
if(P30 == 0) Key_State |= (1 << 11);
// 第四列扫描:拉低 P34
P44 = 1;P42 = 1;P35 = 1;P34 = 0;
if(P33 == 0) Key_State |= (1 << 12);
if(P32 == 0) Key_State |= (1 << 13);
if(P31 == 0) Key_State |= (1 << 14);
if(P30 == 0) Key_State |= (1 << 15);
// 扫描结束后恢复空闲状态
P44 = 1;P42 = 1;P35 = 1;P34 = 1;
return Key_State;
}
5. Key_Proc() 中的按键边沿判断
c
//Main中的Key_Proc:
/* 按键处理函数 */
void Key_Proc()
{
if(Key_Slow_Down < 10) return;
Key_Slow_Down = 0;
if(P30 == 0) return;
Key_Val = Key_Read();
if(P30 == 0) return;
Key_Down = Key_Val & (Key_Val ^ Key_Old);
Key_Up = ~Key_Val & (Key_Val ^ Key_Old);
Key_Old = Key_Val;
if(Key_Down == 1)//按键4
Seg_Buf[0] = 4;
if(Key_Down == 32768)
Seg_Buf[0] = 10;
}
6. Key_Down、Key_Up、Key_Old 怎么理解
这三行是按键处理中非常经典的"边沿检测":
c
Key_Down = Key_Val & (Key_Val ^ Key_Old);
Key_Up = ~Key_Val & (Key_Val ^ Key_Old);
Key_Old = Key_Val;
可以把它理解成比较"这一帧照片"和"上一帧照片"。
| 变量 | 含义 | 类比 |
|---|---|---|
Key_Val |
当前按键状态 | 现在拍到的照片 |
Key_Old |
上一次按键状态 | 上一张照片 |
Key_Down |
新按下的键 | 照片里新出现的人 |
Key_Up |
新松开的键 | 照片里刚离开的人 |
Key_Val ^ Key_Old 用来找变化的位;
再与 Key_Val 相与,就能找到"从 0 变成 1"的位,也就是新按下的按键;
再与 ~Key_Val 相与,就能找到"从 1 变成 0"的位,也就是刚松开的按键。
六、NE555 频率测量:屏蔽 P34 与缩短测量周期
本部分也是 V2026 模板中的重点改动。
1. 为什么 NE555 要屏蔽 P34
在蓝桥杯单片机开发板中,NE555 测频通常会用到 T0 计数输入,而相关信号会接到 P34。
如果矩阵键盘扫描时仍然反复操作 P34,就会干扰 NE555 的计数。
这就像你正在数门口进来了几个人,旁边有人不断开关门、拍门、挡门,那么你数出来的人数就很容易不准。
因此,在使用 NE555 测频时,需要把按键扫描中涉及 P34 的部分屏蔽掉。
2. 屏蔽 P34 后的按键扫描代码
c
#include "Key.h"
unsigned int Key_Read()
{
unsigned int Key_State = 0;
// 使用 NE555 测频时,P34 要留给 T0 计数输入,因此不再参与矩阵键盘扫描
P44 = 0;P42 = 1;P35 = 1;//P34 = 1;
if(P33 == 0) Key_State |= (1 << 0);
if(P32 == 0) Key_State |= (1 << 1);
if(P31 == 0) Key_State |= (1 << 2);
if(P30 == 0) Key_State |= (1 << 3);
P44 = 1;P42 = 0;P35 = 1;//P34 = 1;
if(P33 == 0) Key_State |= (1 << 4);
if(P32 == 0) Key_State |= (1 << 5);
if(P31 == 0) Key_State |= (1 << 6);
if(P30 == 0) Key_State |= (1 << 7);
P44 = 1;P42 = 1;P35 = 0;//P34 = 1;
if(P33 == 0) Key_State |= (1 << 8);
if(P32 == 0) Key_State |= (1 << 9);
if(P31 == 0) Key_State |= (1 << 10);
if(P30 == 0) Key_State |= (1 << 11);
//
// P44 = 1;P42 = 1;P35 = 1;//P34 = 0;
// if(P33 == 0) Key_State |= (1 << 12);
// if(P32 == 0) Key_State |= (1 << 13);
// if(P31 == 0) Key_State |= (1 << 14);
// if(P30 == 0) Key_State |= (1 << 15);
P44 = 1;P42 = 1;P35 = 1;//P34 = 1;
return Key_State;
}
3. 屏蔽 P34 后有什么影响
屏蔽 P34 后,矩阵键盘中与 P34 相关的那一列按键就不再扫描。
也就是说,原本 16 个按键中有一列会暂时无法使用。
在比赛中这并不一定是问题,因为题目通常不会要求同时使用所有矩阵按键和 NE555 频率测量。
但写程序时要有这个意识:
使用 NE555 测频时,P34 更像"专用跑道",不要再让按键扫描也来占用这条跑道。
4. 为什么从 1s 测频改成约 200ms 测频
旧模板可能使用 1s 测一次频率。
这样做的优点是思路简单:1 秒内数到多少个脉冲,频率就是多少 Hz。
但是在 4T 测评环境下,测评系统对响应时间要求更严格。
如果你 1s 才更新一次频率,可能出现这种情况:
- 测评系统已经开始检查答案;
- 你的程序还在等 1s 计数周期结束;
- 结果显示值还没刷新出来;
- 测评就判定错误。
因此,新模板将测量窗口缩短到约 200ms。
200ms 内数到的脉冲数乘以 5,就近似得到 1s 内的脉冲数,也就是频率值。
5. 变量声明
c
//变量声明区域
pdata unsigned int Freq = 0;
pdata unsigned char Timer_200ms = 0;
这里需要两个变量:
| 变量 | 作用 |
|---|---|
Freq |
存放最终频率值 |
Timer_200ms |
用于累计定时器中断次数,形成约 200ms 的时间窗口 |
6. 定时器 1 中断中的频率测量
c
/* 定时器1中断服务函数 */
void Timer1_Isr(void) interrupt 3
{
//频率测量相关内容
if(++Timer_200ms > 198)
{
Freq = (TH0 << 8) | TL0;
Timer_200ms = 0;
Freq *= 5;
TH0 = TL0 = 0;
}
}
7. 这段代码的两个关键点
关键点一:为什么是 > 198,不是 >= 200
如果定时器 1 是 1ms 进入一次中断,那么理论上 200 次就是 200ms。
但是中断函数内部不可能只有这一段频率测量代码,前后可能还有数码管刷新、PWM、计时变量更新等内容。
也就是说,程序走到这段测频代码时,可能已经消耗了一点时间。
把判断条件写成 > 198,是为了给这部分执行时间留一点余量。
这就像赶火车:
如果车票写着 10:00 发车,你最好 9:58 就到站台,而不是 10:00:00 才冲进检票口。
关键点二:为什么先赋给 Freq,再 Freq *= 5
代码写成:
c
Freq = (TH0 << 8) | TL0;
Freq *= 5;
而不是直接写成:
c
Freq = ((TH0 << 8) | TL0) * 5;
这样写的目的,是让计数值先进入 unsigned int 类型的 Freq 中,再进行乘法。
在 C51 里,类型宽度比较敏感,如果表达式在较小类型中运算,可能有溢出风险。
补充提醒 :
如果频率特别高,200ms 内的计数值乘以 5 后仍然可能超过
unsigned int的范围。不过蓝桥杯常见 NE555 测频范围通常不会无限大,模板这样写主要是为了兼顾速度和够用性。
如果以后做更高频率测量,可以考虑使用更大范围的数据类型,或者缩短测量窗口。
8. 硬件连接别忘了
测量 NE555 频率时,一定要记住:
把 P34 和 SIGNAL 用跳线帽接在一起。
如果没有接跳线帽,程序再正确也读不到信号。
这就像你写好了收音机程序,但天线没接上,自然收不到电台。
七、PWM 调光:把 LED 处理合并到显示处理思路中
在新模板中,Led_Proc() 合并到了 Seg_Proc() 中。
这样做的原因是:Seg_Proc() 通常负责获取和整理显示相关的信息,而 LED 的显示状态往往依赖这些信息。
既然 LED 状态要在信息处理之后才能确定,就没有必要再单独开一个函数,合并后结构更紧凑。
可以这样理解:
Seg_Proc()像"总调度员",负责整理当前界面、数据显示、状态变量;- LED 显示像"指示灯",通常根据这些状态变量亮灭;
- 因此把 LED 状态更新放在
Seg_Proc()里,是比较自然的。
PWM 的基本思想
PWM 调光的核心不是改变 LED 的电压,而是改变"亮的时间占比"。
比如一个周期分成 10 份:
Pwm_Compare |
亮的份数 | 灭的份数 | 亮度感觉 |
|---|---|---|---|
| 0 | 0 | 10 | 全灭 |
| 2 | 2 | 8 | 较暗 |
| 5 | 5 | 5 | 中等 |
| 8 | 8 | 2 | 较亮 |
| 10 | 10 | 0 | 全亮 |
这就像水龙头开关:
不是水压变了,而是一段时间内"打开"的比例变了。打开时间越长,平均流出来的水越多;LED 亮的时间越长,人眼感觉就越亮。
代码
c
//定时器1中断处理函数部分
if(++Pwm_Count == 10) Pwm_Count = 0;
if(Pwm_Compare > Pwm_Count)
Led_Disp(Led_Buf);
else
Led_Off();
本模块理解重点
1. Pwm_Count
Pwm_Count 是周期内的计数器。
它从 0 数到 9,一共 10 个小格子,然后重新回到 0。
2. Pwm_Compare
Pwm_Compare 是比较值,也就是亮度等级。
当:
c
Pwm_Compare > Pwm_Count
成立时,LED 点亮;否则熄灭。
如果 Pwm_Compare = 7,那么在 0~9 这 10 个计数中,有 7 个计数时间 LED 是亮的,亮度大约就是 70%。
3. 为什么 PWM 放在定时器中断里
PWM 需要稳定的时间节拍。
如果放在 while(1) 主循环里,主循环执行快慢会受到按键、显示、传感器读取等影响,亮度就容易抖。
放在定时器中断里,相当于让节拍器来控制亮灭,节奏会更稳。
八、温度底层 onewire:DS18B20 读取与分辨率设置
本部分是新模板另一个重点改动。
DS18B20 是蓝桥杯中常见的 1-Wire 温度传感器。
它的通信方式可以理解成"单线传纸条":主机和传感器只通过一根数据线交流,所以时序要求比较严格。
1. 温度读取底层改动
c
float Temperature_Read()
{
unsigned char high1,low1,high2,low2;
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0x44);
do{
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0xbe);
low1 = Read_DS18B20();
high1 = Read_DS18B20();
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0xbe);
low2 = Read_DS18B20();
high2 = Read_DS18B20();
}while(high1 != high2 || low1 != low2);
return (float)(high1 << 8 | low1) * 0.0625f;
}
2. 为什么旧模板可能第一次显示 85°C
很多同学第一次调 DS18B20 时,会遇到上电后第一次读取显示 85°C ,第二次才正常。
更准确地说,85°C 常见于 DS18B20 上电后温度寄存器的默认值。如果程序发起温度转换后没有等转换完成,就立刻读取温度寄存器,就可能读到这个默认值或上一次的旧值。
这就像你刚让同学去量体温,结果他温度计还没拿回来,你就先翻了他的旧记录本,自然可能读到旧数据。
本模板的思路是连续读取两次 scratchpad 中的温度低字节和高字节,只有两次读数一致时才返回结果。
这样做可以在一定程度上规避读数跳变或通信不稳定问题。
重要补充 :
这段代码"连续两次读到相同值再返回",可以提高读数稳定性,但它并不完全等价于"等待本次温度转换完成"。
如果想写得更加严格,可以把"启动转换"和"读取结果"拆成两个步骤,或者按照当前分辨率等待足够的转换时间。
3. 0xcc、0x44、0xbe 分别是什么意思
| 指令 | 名称 | 作用 |
|---|---|---|
0xcc |
Skip ROM | 跳过 ROM 匹配,适合总线上只有一个 DS18B20 的情况 |
0x44 |
Convert T | 启动温度转换 |
0xbe |
Read Scratchpad | 读取暂存器数据 |
通俗理解:
text
0xcc:这里就你一个传感器,我不点名了。
0x44:你现在去测温。
0xbe:把你暂存器里的结果发给我。
4. 温度换算为什么乘 0.0625f
在 12 位分辨率下,DS18B20 的最低有效位对应 0.0625°C。
所以读取到的原始值需要乘以 0.0625f,才能得到摄氏温度。
例如原始值为 0x0191,十进制是 401:
text
401 × 0.0625 = 25.0625°C
这也是代码最后一行的含义:
c
return (float)(high1 << 8 | low1) * 0.0625f;
小提醒 :
如果读取负温度,DS18B20 的温度数据采用二进制补码表示。普通比赛题目多数测常温,影响不大;如果题目明确涉及负温度,建议对符号位和补码转换再做专门处理。
5. 温度分辨率设置
新模板新增了温度分辨率设置函数。
DS18B20 支持 9~12 位分辨率。分辨率越高,温度变化看起来越细腻,但转换时间也越长。
这就像拍照片:
- 低分辨率:拍得快,但细节少;
- 高分辨率:细节多,但需要更长时间。
| 位数 | 分辨率 | 理论最大转换时间 | 适用场景 |
|---|---|---|---|
| 9 位 | 0.5°C | 约 93.75ms | 对速度要求高,精度要求低 |
| 10 位 | 0.25°C | 约 187.5ms | 折中 |
| 11 位 | 0.125°C | 约 375ms | 较高精度 |
| 12 位 | 0.0625°C | 约 750ms | 最高精度,转换最慢 |
纠错说明 :
常见说法里有人会写"DS18B20 出厂默认 9 位",但更严谨的说法是:DS18B20 支持 9~12 位可编程分辨率,官方资料中默认上电分辨率为 12 位 。
不过,因为分辨率配置可以写入 EEPROM,不同模块或被别人改写过的芯片可能表现不同。比赛和练习中,最好在初始化时主动设置自己需要的分辨率,减少不确定性。
6. 分辨率设置代码
c
//温度精度设置
void ds18b20_setResoluation(unsigned char res)
{
unsigned char config;
switch(res)
{
case 0://9位
config = 0x1f;
break;
case 1://10位
config = 0x3f;
break;
case 2://11位
config = 0x5f;
break;
default://12位
config = 0x7f;
break;
}
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0x4e);
Write_DS18B20(0x00);
Write_DS18B20(0x00);
Write_DS18B20(config);
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0x48);
}
7. 这段代码在做什么
第一步:根据 res 生成配置字节
c
switch(res)
{
case 0://9位
config = 0x1f;
break;
case 1://10位
config = 0x3f;
break;
case 2://11位
config = 0x5f;
break;
default://12位
config = 0x7f;
break;
}
config 中的关键是 R1、R0 两个位。
它们决定分辨率:
| R1 | R0 | 分辨率 |
|---|---|---|
| 0 | 0 | 9 位 |
| 0 | 1 | 10 位 |
| 1 | 0 | 11 位 |
| 1 | 1 | 12 位 |
因此:
config |
含义 |
|---|---|
0x1f |
9 位 |
0x3f |
10 位 |
0x5f |
11 位 |
0x7f |
12 位 |
第二步:写入 Scratchpad
c
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0x4e);
Write_DS18B20(0x00);
Write_DS18B20(0x00);
Write_DS18B20(config);
这里的 0x4e 是 Write Scratchpad 指令。
它后面通常要写 3 个字节:
- TH 高温报警值;
- TL 低温报警值;
- 配置寄存器。
模板中 TH 和 TL 都写成 0x00,重点是写入第三个字节 config。
第三步:复制到 EEPROM
c
init_ds18b20();
Write_DS18B20(0xcc);
Write_DS18B20(0x48);
0x48 是 Copy Scratchpad 指令。
它会把 Scratchpad 中的配置复制到 EEPROM 中。
EEPROM 掉电不丢失,所以设置一次后,下次上电仍然可以保持该配置。
命名小提示 :
函数名
ds18b20_setResoluation中的Resoluation疑似是Resolution的拼写笔误。为了保持原工程一致,本文不改函数名。实际写工程时,只要
.c和.h中声明、定义、调用保持一致,函数名拼写不会影响编译;但为了可读性,后续新工程建议统一成ds18b20_setResolution。
九、本篇小结:V2026 模板的核心变化
本篇主要完成了 V2026 大模板中若干底层模块的整理。
整体来看,模板升级的方向可以概括为一句话:
从"能用"升级到"更适合复杂题、更适合测评环境、更适合组合逻辑"。
1. 没有明显变化的模块
| 模块 | 状态 | 说明 |
|---|---|---|
| LED | 基本不变 | 仍使用锁存器控制,增加状态缓存减少刷新 |
| 蜂鸣器 | 基本不变 | 与继电器、电机共用 temp_2 |
| 继电器 | 基本不变 | 通过位操作独立控制 |
| 电机 | 基本不变 | 通过位操作独立控制 |
| 数码管 | 基本不变 | 位选 + 段选 + 消影 |
| Init | 基本不变 | 上电关闭外设,保证初始安全 |
2. 重点变化的模块
| 模块 | 改动点 | 目的 |
|---|---|---|
| 按键 Key | 返回值改为 unsigned int |
支持 16 位按键状态与多键同按 |
| NE555 | 屏蔽 P34 | 避免按键扫描干扰频率计数 |
| NE555 | 约 200ms 测一次并乘 5 | 降低 4T 测评等待风险 |
| PWM | LED 处理合并进显示/信息处理流程 | 减少函数分散,让逻辑更集中 |
| DS18B20 | 两次读取一致再输出 | 降低上电初值或通信不稳造成的异常显示 |
| DS18B20 | 增加分辨率设置函数 | 主动控制精度与转换时间 |
3. 学习建议
如果你是小白,建议按下面顺序理解:
- 先理解锁存器:知道 P0 是数据,P2 是选择;
- 再理解数码管动态扫描:位选和段选分开;
- 再理解按键位图:16 个按键对应 16 个二进制位;
- 再理解定时器中断:定时器是整个模板的节拍器;
- 最后理解 NE555、PWM、DS18B20:它们都是建立在定时器和底层 IO 思路之上的应用模块。
模板不是死记硬背的代码堆,而是一套"模块化工具箱"。
比赛时真正重要的不是把每一行都背下来,而是知道:
- 哪个模块负责什么;
- 哪些变量必须用
unsigned int; - 使用 NE555 时为什么要让出 P34;
- PWM 为什么要放在定时器中断里;
- DS18B20 为什么不能刚启动转换就急着读结果;
- 出现异常显示时应该从硬件连接、跳线帽、定时器、变量类型、刷新周期这些方向排查。
只要把这些逻辑想清楚,模板就不再是一大坨代码,而是一张清晰的地图。
遇到题目时,你只需要在地图上找到对应模块,再根据题目要求拼装即可。