
蓝桥杯嵌入式第六届省赛笔记
一、赛题功能总览 本届省赛要求实现以下功能: | 模块 | 功能描述 | | | | | LCD显示 | 双页面UI:主界面显示电压/k值/LED状态/时间;设置界面配置起始时间 | | 按键 | B1切换LED模式,B2切换页面,B3在设置页面选择时/分/秒,B4对选中项加1 | | LED | 当ADC电压 3.3×k 且LED模式开启时,LD1闪烁 | | ADC | 读取R37电位器电压(0~3.3V) | | RTC | 实时…
FIELD_GUIDE
FIELD GUIDE
Use the guide rail to jump between sections.
一、赛题功能总览
本届省赛要求实现以下功能:
| 模块 | 功能描述 |
|---|---|
| LCD显示 | 双页面UI:主界面显示电压/k值/LED状态/时间;设置界面配置起始时间 |
| 按键 | B1切换LED模式,B2切换页面,B3在设置页面选择时/分/秒,B4对选中项加1 |
| LED | 当ADC电压 > 3.3×k 且LED模式开启时,LD1闪烁 |
| ADC | 读取R37电位器电压(0~3.3V) |
| RTC | 实时时钟显示,初始时间23:59:55 |
| 串口通信 | 接收格式 k0.X\n 修改k值;定时发送触发时刻的数据 |
| EEPROM | 掉电保存k值,上电恢复 |
二、代码架构设计
2.1 整体架构:轮询式多任务调度
while (1)
{
LED_proc(); // LED控制任务
LCD_proc(); // LCD显示任务
KEY_proc(); // 按键扫描任务
RTC_proc(); // RTC时间读取任务
ADC_proc(); // ADC采集任务
RX_proc(); // 串口接收处理任务
TX_proc(); // 串口定时发送任务
}
为什么这样设计?
- 蓝桥杯嵌入式比赛禁止使用RTOS,因此采用经典的**"前后台系统"**(裸机轮询 + 中断)
- 每个
xxx_proc()函数内部通过 时间戳差值 控制执行频率,避免阻塞:
if(uwTick - lcd_tick < 200) return; // 每200ms执行一次
lcd_tick = uwTick;
核心思想:用
uwTick(HAL库自带的1ms系统时钟)做差值判断,取代HAL_Delay()的阻塞式延时,是蓝桥杯嵌入式比赛的必备技巧。
2.2 文件组织
Src/
├── main.c → 主循环 + LCD/KEY/LED处理逻辑
├── user.c → LED驱动、按键读取、ADC/RTC/UART处理逻辑
├── i2c_hal.c → EEPROM读写(GPIO模拟I2C)
├── adc.c → ADC2外设初始化(CubeMX生成)
├── rtc.c → RTC外设初始化(CubeMX生成)
├── usart.c → USART1外设初始化(CubeMX生成)
├── gpio.c → GPIO外设初始化(CubeMX生成)
└── lcd.c → LCD底层驱动(官方提供)
Inc/
├── user.h → 用户模块头文件(extern全局变量 + 函数声明)
├── i2c_hal.h → I2C/EEPROM接口声明
└── ... → CubeMX生成的头文件
为什么将处理逻辑放在 user.c 而不全放 main.c?
main.c中有 CubeMX 的USER CODE BEGIN/END标记区域,代码重新生成时可能被覆盖- 单独的
user.c更好维护,也是比赛中常见的模块化做法
三、各模块详解
3.1 LED控制模块
驱动原理
void LED_disp(u8 led)
{
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_SET); // 打开锁存器
HAL_GPIO_WritePin(GPIOC, 0xFF00, GPIO_PIN_SET); // 先全灭(PC8~PC15置高)
HAL_GPIO_WritePin(GPIOC, led << 8, GPIO_PIN_RESET); // 点亮目标LED(低有效)
HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_RESET); // 关闭锁存器
}
为什么要操作 PD2(锁存器)?
- CT117E板子的LED通过74HC573锁存器连接到PC8~PC15
- PD2 是锁存器的 LE(Latch Enable)引脚
- 操作流程:开锁存 → 写数据 → 关锁存,防止中间状态导致LED闪烁
0xFF00先全灭再点亮,避免 LED 状态叠加
闪烁逻辑
void LED_proc()
{
if(uwTick - led_tick < 200) return;
led_tick = uwTick;
if(r37_volt > 3.3f * k_value && led_mode == 1)
led_num ^= 0x01; // 异或翻转bit0 → LD1闪烁
else
led_num &= ~0x01; // 清除bit0 → LD1熄灭
LED_disp(led_num);
}
为什么用异或 ^= 实现闪烁?
led_num ^= 0x01每次翻转最低位,配合200ms周期,形成400ms一个完整闪烁周期- 用位运算可以独立控制每个LED而不影响其他LED的状态
3.2 按键扫描模块
三行按键核心代码
u8 key_value, key_down, key_up, key_old = 0;
void KEY_read()
{
// 1. 读取当前按键状态
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == 0) key_value = 1;
else if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) == 0) key_value = 2;
else if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_2) == 0) key_value = 3;
else if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == 0) key_value = 4;
else key_value = 0;
// 2. 边沿检测(三行按键法)
key_down = key_value & (key_value ^ key_old); // 按下瞬间为非0
key_up = ~key_value & (key_value ^ key_old); // 松开瞬间为非0
key_old = key_value; // 更新历史状态
}
为什么用"三行按键法"?
这是嵌入式竞赛中最经典的按键边沿检测算法:
| 表达式 | 含义 | 原理 |
|---|---|---|
key_value ^ key_old | 状态变化检测 | 异或:不同为1,相同为0 |
key_value & (变化) | 按下边沿 | 当前按下 且 状态发生了变化 |
~key_value & (变化) | 抬起边沿 | 当前未按 且 状态发生了变化 |
配合 KEY_proc() 中的20ms轮询周期,天然实现了消抖功能。
按键功能分配
void KEY_proc()
{
if(uwTick - key_tick < 20) return; // 20ms消抖周期
key_tick = uwTick;
KEY_read();
if(key_down == 1 && ui == 0) led_mode = !led_mode; // B1: 切换LED开关
else if(key_down == 2) { ui = !ui; LCD_Clear(Black); ... } // B2: 切换页面
else if(key_down == 3 && ui == 1) { time_line++; ... } // B3: 切换编辑位
else if(key_down == 4) { /* 对选中位+1 */ } // B4: 数值递增
}
为什么 B2 切换页面时要 LCD_Clear(Black)?
- 两个页面的显示内容布局完全不同
- 如果不清屏,切换后会出现上一页面残留内容
为什么 B2 切换时保存 T_valid = T_start?
T_start是用户正在编辑的时间T_valid是"已确认"的有效时间,用于串口定时发送判断- 只有退出设置页面时才生效,避免编辑过程中的中间值干扰定时发送
3.3 LCD显示模块
双页面UI设计
void LCD_proc()
{
if(uwTick - lcd_tick < 200) return; // 200ms刷新率
lcd_tick = uwTick;
if(ui == 0) // ========= 页面1:主显示 =========
{
sprintf(lcd_buff, " V1:%4.2fV ", r37_volt); // 电压
sprintf(lcd_buff, " k:%.1f ", k_value); // k值
sprintf(lcd_buff, " LED:%s ", led_mode ? "ON" : "OFF"); // LED状态
sprintf(lcd_buff, " T:%02d-%02d-%02d ", T.Hours, T.Minutes, T.Seconds); // 时间
LCD_DisplayStringLine(Line9, " 1"); // 页码标识
}
else // ========= 页面2:设置页面 =========
{
// 当前编辑位闪烁效果
if(time_light == 1) {
// 正常显示完整时间
} else {
// 隐藏当前编辑位(实现闪烁)
}
LCD_DisplayStringLine(Line9, " 2"); // 页码标识
}
}
为什么用 time_light 变量实现闪烁?
if(time_light == 1) {
time_light = 0;
// 显示完整时间
} else {
time_light = 1;
// 隐藏当前选中位
}
- 每次进入
LCD_proc()(200ms),time_light在0和1之间切换 - 选中位交替显示/隐藏,形成400ms周期的闪烁效果
- 未选中的位始终显示,直观告诉用户当前正在编辑哪一位
3.4 ADC采集模块
void ADC_proc()
{
if(uwTick - adc_tick < 100) return; // 100ms采集一次
adc_tick = uwTick;
HAL_ADC_Start(&hadc2); // 启动ADC转换
r37_value = HAL_ADC_GetValue(&hadc2); // 读取原始值(0~4095)
r37_volt = r37_value * 3.3 / 4096.0; // 转换为电压值
}
为什么用 3.3 / 4096.0?
- ADC配置为 12位分辨率(
ADC_RESOLUTION_12B),范围 0~4095 - 参考电压为 3.3V
- 公式:
为什么用 ADC2 的 Channel 15?
- R37电位器连接在 PB15 引脚上(
main.h中定义R37_Pin = GPIO_PIN_15) - PB15 对应 ADC2 的 Channel 15
3.5 RTC实时时钟模块
初始化
sTime.Hours = 0x23; // BCD格式:23时
sTime.Minutes = 0x59; // 59分
sTime.Seconds = 0x55; // 55秒
HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BCD);
为什么初始时间设为 23:59:55?
- 赛题要求验证日期翻转能力(从23:59:59跳到00:00:00)
- 这是出题者的典型考点
读取
void RTC_proc()
{
if(uwTick - rtc_tick < 100) return;
rtc_tick = uwTick;
HAL_RTC_GetTime(&hrtc, &T, RTC_FORMAT_BIN); // 必须先读Time
HAL_RTC_GetDate(&hrtc, &D, RTC_FORMAT_BIN); // 再读Date
}
为什么必须先 GetTime 再 GetDate?
- 这是 STM32 HAL 库的硬性要求:读取时间后,日期寄存器会被锁定
- 如果不读日期,RTC时间会停止更新(这是一个经典坑!)
- 使用
RTC_FORMAT_BIN直接获取十进制数值,方便sprintf格式化显示
3.6 串口通信模块
接收机制:中断接收 + 延时处理
// 中断回调:每收到1字节触发一次
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
rx_tick = uwTick; // 记录最后接收时间
HAL_UART_Receive_IT(&huart1, &rx_data, 1); // 重新开启接收
rx_buff[rx_pointer++] = rx_data; // 存入缓冲区
}
void RX_proc()
{
if(uwTick - rx_tick < 50) return; // 50ms内无新数据,认为一帧结束
rx_tick = uwTick;
// 解析协议...
rx_pointer = 0;
memset(rx_buff, 0, sizeof(rx_buff)); // 清空缓冲区
}
为什么用"50ms超时"判断帧结束?
- 串口波特率9600bps,传输1字节约1ms
- 一帧数据(如
k0.5\n共5字节)约5ms传完 - 50ms超时可以确保一帧数据完全接收完毕后再处理
- 这种方式比判断特定结束符更鲁棒,是蓝桥杯比赛的常用方案
协议解析
#define ENCODE_MODE1 (rx_pointer == 5 && rx_buff[0] == 'k' && \
rx_buff[1] == '0' && rx_buff[2] == '.' && rx_buff[4] == '\n')
为什么用宏定义做协议匹配?
- 协议格式固定:
k0.X\n(5字节) - 用宏定义将匹配条件内联化,代码简洁
rx_buff[3]就是关键的数字字符,k_value = (rx_buff[3] - '0') / 10.0
定时发送
void TX_proc()
{
if(uwTick - tx_tick < 1000) return; // 1秒检查一次
tx_tick = uwTick;
if(T.Hours == T_valid.Hours && T.Minutes == T_valid.Minutes
&& T.Seconds == T_valid.Seconds)
{
printf("%.2f+%.1f+%02d-%02d-%02d\r\n",
r37_volt, k_value, T_valid.Hours, T_valid.Minutes, T_valid.Seconds);
}
}
为什么用 T_valid 而不是 T_start?
T_start是用户正在编辑中的值,可能随时在变T_valid是按下B2确认后的快照,只有退出设置页面才更新- 确保"定时触发只在用户确认后生效"
printf重定向
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (u8 *)&ch, 1, 50);
return ch;
}
为什么要重写 fputc?
- Keil MDK 中,
printf底层调用fputc - 重定向到 UART1,就可以直接用
printf进行串口输出,极大简化代码
3.7 EEPROM存储模块
上电恢复逻辑
if(EEP_read(123) != 123) // 首次上电检测
{
EEP_write(123, 123); // 写入标志位
k_value = 0.1; // 设置默认值
EEP_write(0x01, k_value * 10); // 保存到EEPROM
}
else
k_value = EEP_read(0x01) / 10.0; // 从EEPROM恢复
为什么用地址123存储标志位?
- EEPROM初始状态为
0xFF(全1) - 地址123写入值123作为**"已初始化"标志**
- 上电时读取该地址:
- 不等于123 → 首次运行,初始化默认值
- 等于123 → 非首次运行,读取已保存的k值
- 地址选择无特殊要求,只要不和数据地址冲突即可
为什么 k_value * 10 再存?
- EEPROM 是按字节(uint8_t)存储的,无法直接存浮点数
k_value范围 0.00.9,乘以10后变为整数 09,可以用1字节表示- 读取时再
/10.0还原,这是比赛中处理小数存储的常用技巧
I2C通信(GPIO模拟)
uint8_t EEP_read(uint8_t add)
{
I2CStart();
I2CSendByte(0xa0); // 写地址(AT24C02设备地址 + 写标志)
I2CWaitAck();
I2CSendByte(add); // 发送存储地址
I2CWaitAck();
I2CStart(); // 重复起始条件
I2CSendByte(0xa1); // 读地址(设备地址 + 读标志)
I2CWaitAck();
data = I2CReceiveByte();
I2CSendNotAck();
I2CStop();
return data;
}
为什么用GPIO模拟I2C而不用硬件I2C?
- 蓝桥杯官方提供了 GPIO 模拟 I2C 的底层代码(
i2c_hal.c) - 比赛中只需要掌握
EEP_read和EEP_write两个函数 - GPIO模拟方式更稳定、移植性好,不受硬件I2C的复杂配置影响
四、关键做题技巧总结
4.1 时间管理模式(非阻塞轮询)
┌─────────────────────────────────────────────┐
│ while(1) │
│ { │
│ if(uwTick - xxx_tick < 周期) return; │ ← 非阻塞定时
│ xxx_tick = uwTick; │
│ // 执行任务... │
│ } │
└─────────────────────────────────────────────┘
各任务推荐执行周期:
| 任务 | 周期 | 原因 |
|---|---|---|
| KEY_proc | 20ms | 按键消抖的标准时间 |
| RTC_proc | 100ms | 时间精度无需太高,减少HAL调用开销 |
| ADC_proc | 100ms | 电位器值变化慢,100ms足够 |
| LCD_proc | 200ms | LCD刷新太快会闪烁,200ms视觉效果好 |
| LED_proc | 200ms | 200ms翻转 → 400ms闪烁周期,人眼可见 |
| RX_proc | 50ms | 超时判断帧结束,需小于实际传输间隔 |
| TX_proc | 1000ms | 秒级定时发送,1秒检查精度够用 |
4.2 变量组织习惯
// user.h 中用 extern 声明所有共享变量
extern float r37_volt;
extern float k_value;
extern _Bool led_mode;
extern RTC_TimeTypeDef T, T_start, T_valid;
// user.c 中定义变量
float r37_volt = 0;
float k_value = 0.1;
_Bool led_mode = 0;
为什么这样做?
extern声明放头文件,任何#include "user.h"的文件都可以访问- 实际定义放
.c文件,避免重复定义
4.3 蓝桥杯嵌入式做题流程
1. CubeMX配置外设 → 生成代码框架
↓
2. 创建 user.c / user.h → 编写业务逻辑
↓
3. 先完成 LED 和按键 → 验证基础IO正常
↓
4. 逐步添加 LCD / ADC / RTC / UART / EEPROM
↓
5. 最后整合各模块交互逻辑
4.4 常见易错点
| 易错点 | 正确做法 |
|---|---|
| RTC只读时间不读日期 | 必须 GetTime + GetDate 配对调用 |
| LED控制忘了操作锁存器 | PD2 SET → 写数据 → PD2 RESET |
| EEPROM连续读写不加延时 | EEP_write 后 HAL_Delay(5) |
| 串口接收用阻塞方式 | 用中断 HAL_UART_Receive_IT,回调中重新开启 |
按键用 HAL_Delay 消抖 | 用三行按键法 + 周期轮询实现消抖 |
| LCD页面切换残留 | 切换时 LCD_Clear(Black) 清屏 |
| 小数存EEPROM | 乘以倍数转整数再存,读取时除回来 |
key_value 忘记默认赋0 | else 分支必须写 key_value = 0 |
| 分钟/秒钟上限写错 | 分钟 0 |
五、代码中的一个Bug分析
在 KEY_proc() 的 B4 按键处理中:
if(T_start.Minutes > 60) T_start.Minutes = 0; // ❌ 应该是 > 59
if(T_start.Seconds > 60) T_start.Seconds = 0; // ❌ 应该是 > 59
分钟和秒钟的合法范围是 0~59,上限判断应为 >= 60 或 > 59,而不是 > 60。当前代码会允许分钟/秒钟短暂出现60的值。小时的 > 23 是正确的。
六、外设配置要点(CubeMX)
ADC2
- 通道:Channel 15(PB15,即R37电位器)
- 分辨率:12bit
- 触发方式:软件触发(
ADC_SOFTWARE_START) - 模式:单次转换
RTC
- 时钟源:LSI(内部低速振荡器)
- 时间格式:24小时制
- 异步预分频:31 → 同步预分频:999
- 公式:
USART1
- 波特率:9600
- 数据位:8bit,停止位:1bit,无校验
- 引脚:PA9(TX) / PA10(RX)
GPIO
- LED:PC8~PC15(输出,低有效),PD2(锁存器控制)
- 按键:PB0(B1)、PB1(B2)、PB2(B3)、PA0(B4)(输入,无上拉)