BACK_TO_BASE
蓝桥杯嵌入式第六届省赛笔记
Engineering Notebook // Build Log
/
22:10:15
/
NOTEBOOK_ENTRY

蓝桥杯嵌入式第六届省赛笔记

一、赛题功能总览 本届省赛要求实现以下功能: | 模块 | 功能描述 | | | | | LCD显示 | 双页面UI:主界面显示电压/k值/LED状态/时间;设置界面配置起始时间 | | 按键 | B1切换LED模式,B2切换页面,B3在设置页面选择时/分/秒,B4对选中项加1 | | LED | 当ADC电压 3.3×k 且LED模式开启时,LD1闪烁 | | ADC | 读取R37电位器电压(0~3.3V) | | RTC | 实时…

Notebook Time
3 min
Image Frames
1
View Tracks
72
嵌入式
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
  • 公式:V=ADCraw×Vref212=ADCraw×3.34096V = \frac{ADC_{raw} \times V_{ref}}{2^{12}} = \frac{ADC_{raw} \times 3.3}{4096}

为什么用 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
}

为什么必须先 GetTimeGetDate

  • 这是 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_readEEP_write 两个函数
  • GPIO模拟方式更稳定、移植性好,不受硬件I2C的复杂配置影响

四、关键做题技巧总结

4.1 时间管理模式(非阻塞轮询)

┌─────────────────────────────────────────────┐
│  while(1)                                    │
│  {                                           │
│     if(uwTick - xxx_tick < 周期) return;     │ ← 非阻塞定时
│     xxx_tick = uwTick;                       │
│     // 执行任务...                            │
│  }                                           │
└─────────────────────────────────────────────┘

各任务推荐执行周期:

任务周期原因
KEY_proc20ms按键消抖的标准时间
RTC_proc100ms时间精度无需太高,减少HAL调用开销
ADC_proc100ms电位器值变化慢,100ms足够
LCD_proc200msLCD刷新太快会闪烁,200ms视觉效果好
LED_proc200ms200ms翻转 → 400ms闪烁周期,人眼可见
RX_proc50ms超时判断帧结束,需小于实际传输间隔
TX_proc1000ms秒级定时发送,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_writeHAL_Delay(5)
串口接收用阻塞方式用中断 HAL_UART_Receive_IT,回调中重新开启
按键用 HAL_Delay 消抖用三行按键法 + 周期轮询实现消抖
LCD页面切换残留切换时 LCD_Clear(Black) 清屏
小数存EEPROM乘以倍数转整数再存,读取时除回来
key_value 忘记默认赋0else 分支必须写 key_value = 0
分钟/秒钟上限写错分钟 059,秒钟 059(代码中写的60其实是个bug,应为59)

五、代码中的一个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
  • 公式:fRTC=fLSI(AsynchPrediv+1)(SynchPrediv+1)=3200032×1000=1Hzf_{RTC} = \frac{f_{LSI}}{(AsynchPrediv+1)(SynchPrediv+1)} = \frac{32000}{32 \times 1000} = 1\text{Hz}

USART1

  • 波特率:9600
  • 数据位:8bit,停止位:1bit,无校验
  • 引脚:PA9(TX) / PA10(RX)

GPIO

  • LED:PC8~PC15(输出,低有效),PD2(锁存器控制)
  • 按键:PB0(B1)、PB1(B2)、PB2(B3)、PA0(B4)(输入,无上拉)