BACK_TO_BASE
蓝桥杯嵌入式省赛代码模块和cubemx配置思路整理
Engineering Notebook // Build Log
/
21:13:50
/
NOTEBOOK_ENTRY

蓝桥杯嵌入式省赛代码模块和cubemx配置思路整理

这篇博客主要是用来帮助我宿友备赛蓝桥杯嵌入式 也同时是我用来复习查漏补缺 蓝桥杯嵌入式比赛模板思路总结 目录 整体架构 整体架构 模块详解 模块详解 1. LED模块 1 led模块 2. KEY模块 2 key模块 3. LCD模块 3 lcd模块 4. UART模块 4 uart模块 5. ADC模块 5 adc模块 6. I2C模块 6 i2c模块 7. RTC模块 7 rtc模块 8. PWM模块 8 pwm模块 9. 频率与占…

Notebook Time
6 min
Image Frames
1
View Tracks
175
STM32
FIELD_GUIDE

FIELD GUIDE

Use the guide rail to jump between sections.

                这篇博客主要是用来帮助我宿友备赛蓝桥杯嵌入式

                       也同时是我用来复习查漏补缺

蓝桥杯嵌入式比赛模板思路总结

目录


整体架构

硬件平台

本模板基于 STM32G431RBT6,使用 HAL库 进行开发。

程序架构:时间片轮询

蓝桥杯嵌入式的程序框架不使用 RTOS,而是用**时间片轮询(Super Loop)**架构。

// main.c 的 while(1) 中这样写:
while(1)
{
    LED_proc();   // 100ms 刷新一次
    KEY_proc();   // 20ms  扫描一次(消抖)
    LCD_proc();   // 100ms 刷新一次
    ADC_proc();   // 100ms 采样一次
    RTC_proc();   // 100ms 读取一次
    PWM_proc();   // 100ms 更新一次
    Rx_proc();    // 50ms  处理一次
}

为什么用时间片轮询,而不是 HAL_Delay?

HAL_Delay(100) 会让 CPU 空转等待 100ms,在这段时间内什么事情都做不了,按键扫描、串口接收等全部卡住。时间片轮询的思路是:CPU 一直在跑循环,每个模块自己检查"离上次执行是否超过了规定时间",到时间了就执行,没到时间就直接跳过,CPU 完全不浪费。

核心机制:uwTick 系统滴答

uwTick 是 HAL 库提供的全局变量,由 SysTick 中断每 1ms 自动加一。利用它实现非阻塞定时:

u32 xxx_tick = 0;       // 记录上次执行的时间点(单位 ms)

void XXX_proc()
{
    if(uwTick - xxx_tick < 100)  // 距上次执行不足 100ms → 直接返回
        return;
    xxx_tick = uwTick;            // 更新时间戳
    // --- 真正的业务逻辑写在这里 ---
}

为什么用减法而不是直接比较大小?

uwTick - xxx_tick 是无符号数相减,即使 uwTick 溢出归零(约 49 天后),减法结果仍然正确,这是一种防溢出的经典写法。


模块详解


1. LED模块

硬件背景

开发板上的 8 个 LED 并不是直接接在 STM32 的 IO 口上的,而是通过一片 74HC573 锁存器 驱动。74HC573 的控制逻辑如下:

  • LE(锁存使能)引脚PD2:LE=1 时,输出跟随输入透明传输;LE=0 时,输出锁存当前值不再变化。
  • 数据输入引脚(D0~D7)PC8~PC15:对应 8 个 LED。
  • LED 是共阳极接法:IO 输出低电平(0)→ LED 亮;IO 输出高电平(1)→ LED 灭。

操作步骤(每次刷新 LED 都要执行这 4 步)

void LED_disp(u8 led)
{
    // 第1步:开启锁存器,让输出随输入变化(LE=1)
    HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_SET);

    // 第2步:把 PC8~PC15 全部拉高,意味着 8 个 LED 全灭
    //        0xFF00 = 0b1111111100000000,对应 PC8~PC15
    HAL_GPIO_WritePin(GPIOC, 0xFF00, GPIO_PIN_SET);

    // 第3步:把要点亮的 LED 对应引脚拉低
    //        led 是一个 8 位掩码,bit0 对应 LED1,bit7 对应 LED8
    //        先左移8位,让 bit0~bit7 对齐到 PC8~PC15
    //        RESET 表示拉低,即点亮
    HAL_GPIO_WritePin(GPIOC, led << 8, GPIO_PIN_RESET);

    // 第4步:关闭锁存器,把当前 LED 状态锁住(LE=0)
    //        此后即使 PC8~PC15 电平变化,LED 输出也不会改变
    HAL_GPIO_WritePin(GPIOD, GPIO_PIN_2, GPIO_PIN_RESET);
}

为什么要锁存? 锁存的目的是防止在设置 PC8~PC15 的过程中,LED 出现短暂闪烁。先把 LE 拉高(透明),完成全部数据设置后再把 LE 拉低(锁存),保证 LED 状态一次性切换,不会有中间状态。

刷新函数

u32 led_tick = 0;
u8 led_num = 0;   // 控制哪些 LED 亮,比赛中根据题目逻辑修改这个变量

void LED_proc()
{
    if(uwTick - led_tick < 100)  // 100ms 刷新一次,防止 CPU 被 LED 占满
        return;
    led_tick = uwTick;
    LED_disp(led_num);           // 用当前 led_num 刷新显示
}

比赛中如何使用:在按键或其他逻辑中修改 led_num 的值,LED_proc() 会自动在下一个 100ms 周期将其显示出来。例如让 LED1 和 LED3 亮:led_num = 0x05;0b00000101)。


2. KEY模块

硬件背景

开发板上有 4 个按键,分别接在:

  • B1 → PB0
  • B2 → PB1
  • B3 → PB2
  • B4 → PA0

按键按下时 IO 读到低电平(0),松开时读到高电平(1)。

消抖设计

机械按键在按下和松开瞬间会有几毫秒的电平抖动。解决方案:每 20ms 才扫描一次按键状态。这样单次扫描之间间隔足够长,抖动期已经结束,读到的是稳定电平。

按键值的编码方式

u8 key_value = 0;   // 当前按键编号(1~4,无按键为0)
u8 key_down  = 0;   // 这次扫描刚刚"按下"的键(边沿检测)
u8 key_up    = 0;   // 这次扫描刚刚"松开"的键(边沿检测)
u8 key_old   = 0;   // 上一次扫描的 key_value,用于边沿检测
void KEY_read()
{
    // 读取当前按下的键,编号为 1~4
    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;   // 没有键按下,必须赋值 0,否则松开后 key_value 保持旧值

    // 边沿检测算法(核心)
    key_down = key_value & (key_value ^ key_old);    // 按下事件
    key_up   = ~key_value & (key_value ^ key_old);   // 松开事件
    key_old  = key_value;   // 保存本次状态供下次比较
}

边沿检测算法解析

以 key_value 从 0 变为 2(按下 B2)为例:

变量二进制含义
key_value200000010当前 B2 被按下
key_old000000000上次没有键按下
key_value ^ key_old200000010状态发生了变化的位
key_value & (...)200000010只保留当前为"有键"的变化 → 这就是 key_down
~key_value & (...)000000000当前没有"变为无键"的位 → key_up 为0

以 key_value 从 2 变为 0(松开 B2)为例:

变量二进制含义
key_value000000000无键
key_old200000010上次 B2 按着
key_value ^ key_old200000010状态发生了变化
key_value & (...)000000000key_down 为 0
~key_value & (...)200000010保留"变为无键"的位 → 这就是 key_up

比赛中的用法

void KEY_proc()
{
    if(uwTick - key_tick < 20) return;
    key_tick = uwTick;
    KEY_read();

    if(key_down == 1) { /* B1 刚按下时执行一次 */ }
    if(key_down == 2) { /* B2 刚按下时执行一次 */ }
    if(key_up   == 3) { /* B3 刚松开时执行一次 */ }
}

注意 key_downkey_up 每次调用 KEY_read() 都会重新计算,所以它们天然就是"边沿触发,只执行一次"的效果,不需要额外的 flag 变量。


3. LCD模块

初始化(在 main 函数 while 之前)

LCD_Init();              // 初始化 LCD 控制器
LCD_Clear(Black);        // 清屏为黑色背景
LCD_SetBackColor(Black); // 设置字符背景色为黑色
LCD_SetTextColor(White); // 设置字符颜色为白色

为什么要先 Clear 再 SetBackColor?
LCD_Clear() 是整屏填充颜色。之后 SetBackColor/SetTextColor 是设置后续文字渲染时用的颜色。两者相互独立,都要设置。

显示函数

// Line0~Line9 共 10 行,每行可以显示约 20 个字符
LCD_DisplayStringLine(Line0, (u8 *)"    Hello World   ");

注意:每行字符数建议凑满(用空格填充到约 20 字符),否则旧内容可能残留在屏幕上,因为每次只刷新写入的字符,不清除该行。

界面切换

ui 变量管理当前界面,在 LCD_proc() 里根据 ui 值显示不同内容:

u8 ui = 0;          // 当前界面编号
u8 lcd_buf[30];     // 字符串缓冲区
u32 lcd_tick = 0;

void LCD_proc()
{
    if(uwTick - lcd_tick < 100) return;
    lcd_tick = uwTick;

    if(ui == 0)   // 界面0:显示数据页
    {
        LCD_DisplayStringLine(Line0, (u8 *)"     Data View    ");
        sprintf((char *)lcd_buf, "  Volt: %.2fV     ", adc1_volt);
        LCD_DisplayStringLine(Line2, lcd_buf);
    }
    else if(ui == 1)   // 界面1:显示参数设置页
    {
        LCD_DisplayStringLine(Line0, (u8 *)"     Settings     ");
        // ...
    }
}

切换界面时记得清屏,否则两个界面的内容会叠加在一起:

// 在 KEY_proc 里:
if(key_down == 1)
{
    ui = (ui == 0) ? 1 : 0;   // 切换界面
    LCD_Clear(Black);           // 清屏,防止残影
}

4. UART模块

printf 重定向

HAL 库不自动把 printf 输出到串口,需要重定向 fputc()

// 在 main.c 顶部添加(注意不能放在 C++ 文件里)
struct __FILE { int handle; };
FILE __stdout;

int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 50);
    return ch;
}

这样之后 printf("hello %d\r\n", 42); 就会通过 UART1 发送出去。\r\n 是 Windows 串口工具的换行格式,建议统一加上。

中断接收模式

不能用轮询方式接收(HAL_UART_Receive() 是阻塞的,会卡死程序),要用中断方式:

初始化(在 main.c 的 while 之前):

// 开启第一次接收中断,每次只接收 1 个字节存入 rx_data
HAL_UART_Receive_IT(&huart1, &rx_data, 1);

中断回调(每收到 1 个字节自动调用):

u8 rx_buff[30];     // 接收缓冲区
u8 rx_data;         // 每次中断只接收1个字节
u8 rx_pointer = 0;  // 当前已接收的字节数(写入位置)
u32 rx_tick = 0;    // 最后一次收到数据的时间戳

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    rx_tick = uwTick;                        // 更新最后接收时间
    rx_buff[rx_pointer++] = rx_data;         // 把新字节存入缓冲区
    HAL_UART_Receive_IT(&huart1, &rx_data, 1); // 重新开启下一次中断,准备接收下一字节
}

为什么每次只接收 1 字节?
因为我们不知道对方会发多少字节,每次接收 1 字节并立刻开启下一次中断,可以接收任意长度的数据,结束判断交给超时逻辑来处理。

超时判断(数据帧处理)

void Rx_proc()
{
    // 距上次收到数据超过 50ms,认为一帧数据接收完毕
    if(uwTick - rx_tick < 50) return;
    rx_tick = uwTick;

    // 防止 rx_pointer == 0 时误触发(上次已处理过,什么也没收到)
    if(rx_pointer == 0) return;

    // 根据协议解析数据
    if(rx_pointer == 1 && rx_buff[0] == '#')
    {
        // 处理 '#' 命令
    }
    else if(rx_pointer == 1 && rx_buff[0] == '$')
    {
        // 处理 '$' 命令
    }
    else
    {
        printf("数据格式错误: %s\r\n", rx_buff);
    }

    // 处理完毕后清空缓冲区,等待下一帧
    rx_pointer = 0;
    memset(rx_buff, '\0', sizeof(rx_buff));
}

超时机制的原理:串口数据之间不一定有结束符(比如题目只发 #$ 等单字节命令)。通过时间来判断"一帧是否发完":收到最后一个字节后,超过 50ms 没有新数据进来,就认为这一帧收完了,然后处理它。50ms 的值可根据波特率和数据长度调整。


5. ADC模块

工作模式选择

开发板 ADC 使用软件触发、单次转换模式(在 CubeMX 里:Continuous Conversion Mode = Disabled)。每次想要一个新值,手动调用一次 HAL_ADC_Start()

为什么不用连续转换模式?
连续模式会不间断地进行 AD 转换消耗功耗,并且在多通道扫描时容易产生各通道之间的相位问题。手动触发更加可控。

多通道采样(Scan Mode,ADC1 双通道)

在 CubeMX 中把 ADC1 配置为 Scan Mode,Rank1 和 Rank2 分别对应两个通道。每次 HAL_ADC_Start + HAL_ADC_GetValue 只读一个通道,需要连续调用两次:

void ADC_proc()
{
    if(uwTick - adc_tick < 100) return;
    adc_tick = uwTick;

    // 读 ADC1 的 Rank1(比如接 MCP4017 输出)
    HAL_ADC_Start(&hadc1);
    mcp_value = HAL_ADC_GetValue(&hadc1);
    mcp_volt  = mcp_value / 4096.0f * 3.3f;

    // 读 ADC1 的 Rank2(比如接 R37 分压)
    HAL_ADC_Start(&hadc1);
    adc1_value = HAL_ADC_GetValue(&hadc1);
    adc1_volt  = adc1_value / 4096.0f * 3.3f;

    // 读 ADC2(另一路独立通道)
    HAL_ADC_Start(&hadc2);
    adc2_value = HAL_ADC_GetValue(&hadc2);
    adc2_volt  = adc2_value / 4096.0f * 3.3f;
    HAL_ADC_Stop(&hadc2);  // 可选,不写也没问题,写了稍微省点电
}

电压转换公式

  • ADC 分辨率 12 位,最大值为 2^12 = 4096。
  • 参考电压 VREF+ 接 3.3V。
  • 结果单位为伏特(V)。

6. I2C模块

开发板使用软件模拟 I2C(GPIO 模拟时序),由底层库 i2c.c 提供以下原子操作:I2CStart()I2CStop()I2CSendByte()I2CWaitAck()I2CReceiveByte()I2CSendNotAck()

EEPROM(AT24C02)读操作(13 行)

AT24C02 的 I2C 地址:写地址 0xA0,读地址 0xA1

uint8_t EEP_read(uint8_t addr)
{
    uint8_t data;
    I2CStart();             // 产生起始信号
    I2CSendByte(0xA0);      // 发送写地址,告知 EEPROM:我要操作你
    I2CWaitAck();           // 等待 EEPROM 应答
    I2CSendByte(addr);      // 发送要读的内部寄存器地址(0~255)
    I2CWaitAck();
    I2CStart();             // 重复起始(不发 Stop,直接重新 Start)
    I2CSendByte(0xA1);      // 发送读地址,告知 EEPROM:接下来我要读
    I2CWaitAck();
    data = I2CReceiveByte(); // 读取一个字节
    I2CSendNotAck();         // 发送 NACK(告诉 EEPROM 只读这一个字节,不需要更多了)
    I2CStop();              // 产生停止信号
    return data;
}

为什么要两次 Start?
第一次 Start + 写地址是为了告诉 EEPROM 要读的位置(相当于"寻址");重复 Start 后切换为读模式,EEPROM 就会从刚才寻址的位置开始输出数据。这是 I2C 的"随机读"时序,AT24C02 规定必须这样做。

EEPROM 写操作(9 行)

void EEP_write(uint8_t addr, uint8_t data)
{
    I2CStart();
    I2CSendByte(0xA0);      // 写地址
    I2CWaitAck();
    I2CSendByte(addr);      // 内部存储地址
    I2CWaitAck();
    I2CSendByte(data);      // 要写入的数据
    I2CWaitAck();
    I2CStop();
    HAL_Delay(5);           // AT24C02 内部写入需要约 5ms,必须等待!
}

为什么写完必须延时 5ms?
AT24C02 写入 Flash 存储单元需要时间(内部编程时序),这段时间内它不会响应 I2C。如果立刻再次读/写,EEPROM 不会应答(WaitAck 一直等),导致程序卡死或数据出错。5ms 是数据手册规定的最大写入时间。

注意:这里用了 HAL_Delay(5) 是可以接受的,因为每次写 EEPROM 并不频繁(通常只在参数改变时写一次)。

MCP4017 数字电位器写操作

MCP4017 是一款通过 I2C 控制阻值的数字电位器,写入 0~127 对应最小到最大阻值。固定设备地址 0x5E(已经包含了方向位,只有写操作)。

void MCP_write(uint8_t dat)   // dat 范围 0~127
{
    I2CStart();
    I2CSendByte(0x5E);   // MCP4017 的固定地址(含写方向位)
    I2CWaitAck();
    I2CSendByte(dat);    // 电位器档位值
    I2CWaitAck();
    I2CStop();
}

MCP4017 通过改变内部电阻来调节 ADC 输入电压,在题目中常需要"写入某个档位后测量电压",注意写入后留一点时间(几十 ms)让电压稳定再采 ADC。


7. RTC模块

初始化(CubeMX 中配置)

在 CubeMX 的 RTC 选项中设置好初始时间和日期(比赛题目会给定一个初始时间),生成代码后 HAL 会自动在 MX_RTC_Init() 里配置。

读取时间和日期

RTC_TimeTypeDef T;   // 时间结构体:T.Hours, T.Minutes, T.Seconds
RTC_DateTypeDef D;   // 日期结构体:D.Year, D.Month, D.Date, D.WeekDay
u32 rtc_tick = 0;

void RTC_proc()
{
    if(uwTick - rtc_tick < 100) return;
    rtc_tick = uwTick;

    // ⚠️ 必须先调用 GetTime,再调用 GetDate,两个都调!
    HAL_RTC_GetTime(&hrtc, &T, RTC_FORMAT_BIN);
    HAL_RTC_GetDate(&hrtc, &D, RTC_FORMAT_BIN);
}

为什么两个函数必须都调用?
STM32 的 RTC 硬件有一个"影子寄存器"机制:调用 HAL_RTC_GetTime() 时,硬件会把当前时间和日期同时锁存到影子寄存器;调用 HAL_RTC_GetDate() 才完成整个读取流程,解锁影子寄存器。如果只调用 GetTime 不调用 GetDate,下次 GetTime 读到的还是上次锁存的值(数据不更新)。

显示

sprintf((char *)lcd_buf, "   %02d:%02d:%02d    ", T.Hours, T.Minutes, T.Seconds);
LCD_DisplayStringLine(Line8, lcd_buf);
sprintf((char *)lcd_buf, " %02d-%02d-%d    ", D.Date, D.Month, D.WeekDay);
LCD_DisplayStringLine(Line9, lcd_buf);

%02d 表示最少显示 2 位,不足时补 0(防止 "9" 显示成 "9" 而不是 "09")。


8. PWM模块

CubeMX配置

  • 选择一个定时器(如 TIM2),通道选 PWM Generation CH4
  • Prescaler(预分频):如果系统时钟是 80MHz,设 Prescaler = 79,则计数频率 = 80MHz / (79+1) = 1MHz
  • Counter Period(ARR):先随便填一个值(如 999),后面代码里动态修改
  • PWM Mode:选 PWM Mode 1(计数值 < CCR 时输出高电平)

动态修改频率和占空比

不要在 CubeMX 里固定 ARR 和 CCR,用代码动态计算:

u16 pa3_frq  = 1000;  // TIM2_CH4 的目标频率,单位 Hz
u8  pa3_duty = 50;    // TIM2_CH4 的目标占空比,单位 %

void PWM_proc()
{
    if(uwTick - pwm_tick < 100) return;
    pwm_tick = uwTick;

    // ARR = 计数频率 / 目标频率 - 1
    // 计数频率 = 1MHz = 1,000,000
    __HAL_TIM_SetAutoreload(&htim2, 1000000 / pa3_frq - 1);

    // CCR = ARR × 占空比% = (1MHz / 频率) × (duty / 100)
    __HAL_TIM_SetCompare(&htim2, TIM_CHANNEL_4, (1000000 / pa3_frq) * pa3_duty / 100);
}

公式推导

  • 计数频率 = 1MHz,每计一个数用时 1μs
  • 要产生频率 f 的 PWM,周期 = 1/f 秒 = 1,000,000/f 个计数单位
  • 所以 ARR = 1,000,000/f - 1(ARR+1 是完整周期)
  • 高电平持续 duty% 的时间,所以 CCR = (ARR+1) × duty / 100

启动(在 while(1) 之前)

HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_4);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);

注意:PWM 输出通道在 CubeMX 里要映射到正确的 GPIO 引脚,查开发板原理图确认(比赛题目一般会告知使用哪个引脚)。


9. 频率与占空比捕获模块

这是蓝桥杯嵌入式常考的模块,有两种方案,根据题目要求选用。


方案 A:只测频率(简洁版,Reset 模式)

CubeMX 配置

  • 选 TIM,输入捕获通道:Input Capture Direct Mode
  • Trigger(从模式):选 Reset Mode,触发源选该通道的 TI
  • 这样每次上升沿到来时,TIM 会自动把 CNT 清零并触发捕获中断
uint ccr_val = 0;
uint frq = 0;

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if(htim->Instance == TIM2)
    {
        // CNT 被自动清零的同时,CCR 里存的是上一个周期的计数值
        ccr_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
        // 频率 = 计数频率 / 周期计数值
        // 若定时器时钟 80MHz,预分频 79,则计数频率 = 1MHz = 1,000,000
        frq = 1000000 / ccr_val;
        // 不需要手动清零 CNT,硬件自动完成
    }
}

为什么 Reset 模式下不用手动 SetCounter(0)?
从模式的 Reset 模式会在捕获事件触发的同时让硬件自动将 CNT 清零,节省一条指令,且时间更精确(没有软件延迟)。


方案 B:测频率 + 占空比(三次捕获状态机)

CubeMX 配置

  • 输入捕获通道:Direct Mode,起始极性选 Rising Edge
  • 不使用从模式(手动管理 CNT)
  • Prescaler 设置使计数频率为 1MHz

原理:捕获上升沿 → 下降沿 → 再次上升沿,共三次:

  • 第1次上升沿到第2次下降沿之间 = 高电平时间 high_time
  • 第2次下降沿到第3次上升沿之间 = 低电平时间 low_time
  • 频率 = 1,000,000 / (high_time + low_time)
  • 占空比 = high_time × 100 / (high_time + low_time)
// 以 TIM16 为例
uint32_t uwIC2Value1 = 0, uwIC2Value2 = 0, uwIC2Value3 = 0;
uint32_t high_time = 0, low_time = 0;
uint16_t uhCaptureIndex = 0;   // 状态:0=等上升沿 1=等下降沿 2=等上升沿
uint32_t uwFrequency = 0;
uint8_t  uwDuty = 0;

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if(htim == &htim16 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1)
    {
        if(uhCaptureIndex == 0)   // 状态0:捕获到第一个上升沿
        {
            uwIC2Value1 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
            uhCaptureIndex = 1;
            // 切换为下降沿触发,准备捕获下降沿
            __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1,
                                          TIM_INPUTCHANNELPOLARITY_FALLING);
        }
        else if(uhCaptureIndex == 1)   // 状态1:捕获到下降沿(高电平结束)
        {
            uwIC2Value2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
            // 计算高电平时间,需要处理 CNT 溢出(0xFFFF 是 16 位定时器上限)
            if(uwIC2Value2 >= uwIC2Value1)
                high_time = uwIC2Value2 - uwIC2Value1;
            else
                high_time = (0xFFFF - uwIC2Value1) + uwIC2Value2 + 1;
            uhCaptureIndex = 2;
            // 切换回上升沿触发,准备捕获第二个上升沿
            __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1,
                                          TIM_INPUTCHANNELPOLARITY_RISING);
        }
        else if(uhCaptureIndex == 2)   // 状态2:捕获到第二个上升沿(低电平结束)
        {
            uwIC2Value3 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
            // 计算低电平时间,同样处理溢出
            if(uwIC2Value3 >= uwIC2Value2)
                low_time = uwIC2Value3 - uwIC2Value2;
            else
                low_time = (0xFFFF - uwIC2Value2) + uwIC2Value3 + 1;

            uhCaptureIndex = 0;   // 回到状态0,循环

            // 计算频率和占空比
            if((high_time + low_time) > 0)
            {
                uwFrequency = 1000000 / (high_time + low_time);
                uwDuty = 100 * high_time / (high_time + low_time);
            }
        }
    }
}

溢出处理解析

定时器 CNT 是 16 位(0 ~ 0xFFFF = 65535)。如果两次捕获之间 CNT 溢出归零一次:

例:Value1 = 60000,Value2 = 5000(溢出了)
正常差值 = 5000 - 60000 = -55000(错误)
正确计算:(65535 - 60000) + 5000 + 1 = 10536(从 60000 数到 65535 再从 0 数到 5000)

这就是 (0xFFFF - value1) + value2 + 1 公式的含义。

启动捕获(在 while(1) 之前)

HAL_TIM_IC_Start_IT(&htim16, TIM_CHANNEL_1);
HAL_TIM_IC_Start_IT(&htim8,  TIM_CHANNEL_1);

方案 C:测频率 + 占空比(直接操作 CCER 寄存器,更简洁)

适合对寄存器操作有把握的情况:

// CCER 寄存器中,CC1P 位(bit1)控制通道1的捕获极性
// CC1P = 0:上升沿触发
// CC1P = 1:下降沿触发

u8 tim2_state = 0;
u32 tim2_cnt1 = 0, tim2_cnt2 = 0;
u32 f_out = 0;
float duty_out = 0;

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if(htim == &htim2)
    {
        if(tim2_state == 0)             // 状态0:第一个上升沿来了
        {
            __HAL_TIM_SetCounter(&htim2, 0);  // 清零计数器,从这里开始计时
            TIM2->CCER |= 0x02;               // CC1P=1,切换为下降沿触发
            tim2_state = 1;
        }
        else if(tim2_state == 1)        // 状态1:下降沿来了(高电平结束)
        {
            tim2_cnt1 = __HAL_TIM_GetCounter(&htim2);  // 读高电平时间(μs)
            TIM2->CCER &= ~0x02;                        // CC1P=0,切换回上升沿触发
            tim2_state = 2;
        }
        else if(tim2_state == 2)        // 状态2:第二个上升沿(完整周期结束)
        {
            tim2_cnt2 = __HAL_TIM_GetCounter(&htim2);  // 读完整周期时间(μs)
            f_out    = 1000000 / tim2_cnt2;              // 频率 Hz
            duty_out = tim2_cnt1 * 100.0f / tim2_cnt2;  // 占空比 %
            tim2_state = 0;
        }
        // 重新开启捕获中断(如果用的是 Start_IT 方式)
        HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);
    }
}

方案 B 与方案 C 的区别

  • 方案 B 用 HAL 库的 __HAL_TIM_SET_CAPTUREPOLARITY 宏切换极性,移植性好
  • 方案 C 直接操作 CCER 寄存器,速度更快,代码更短,但需要知道寄存器位定义

CubeMX配置要点

LED / KEY / LCD

  • LED:PC8~PC15 配置为 GPIO_Output,PD2 也配置 GPIO_Output
  • KEY:PB0/PB1/PB2/PA0 配置为 GPIO_Input,上拉(Pull-Up)
  • LCD:使用官方提供的驱动库,不需要在 CubeMX 里做额外配置,直接调用 LCD_Init()

UART

  • 选择 USART1,Mode = Asynchronous
  • Baud Rate = 9600(或题目要求的波特率)
  • Word Length = 8 bits,Stop Bits = 1,Parity = None
  • NVIC 中打开 USART1 global interrupt(必须!否则回调不会触发)

ADC

  • 选 ADC1,Rank1 和 Rank2 分别绑定两个通道引脚(如 PA1、PA2)
  • Scan Conversion Mode = Enable(多通道扫描)
  • Continuous Conversion Mode = Disable(手动触发)
  • Data Alignment = Right(右对齐)
  • 分辨率 = 12 Bits

I2C(软件模拟)

  • 将 PB6(SCL)、PB7(SDA)配置为 GPIO_Output,Open Drain,无上拉(外接上拉电阻)
  • 真正的 I2C 时序由 i2c.c 软件实现

TIM(频率捕获)

  • 选 TIM16,Channel1 = Input Capture Direct Mode
  • Prescaler = 79(80MHz / 80 = 1MHz 计数频率)
  • Counter Period = 0xFFFF(最大计数范围,防止溢出过快)
  • NVIC 中打开该定时器中断

TIM(PWM 输出)

  • 选 TIM2,Channel4 = PWM Generation CH4
  • Prescaler = 79(同样 1MHz 计数频率)
  • Counter Period 随便填(代码动态修改)
  • PWM Mode 1,Fast Mode = Disable

RTC

  • 激活 RTC(Clock Source = LSE 或 LSI)
  • 在 Calendar 里设置初始时间(时分秒)和初始日期(年月日星期)
  • 激活 RTC Clock

注意事项汇总

序号场景陷阱正确做法
1RTC只调用 GetTime必须同时调 GetTime + GetDate
2EEPROM 写写完立刻读写完后 HAL_Delay(5) 再操作
3ADC忘记浮点转换/ 4096.0f 必须带 f 后缀,否则整数除法结果恒为 0
4按键无键时忘记赋 0else key_value = 0; 必须写
5UART忘记开启首次中断main() 里调一次 HAL_UART_Receive_IT()
6PWM忘记 StartHAL_TIM_PWM_Start() 写在 while(1) 之前
7频率捕获忘记重启中断回调末尾重新调 HAL_TIM_IC_Start_IT()
8LCD切换界面残影切界面时先调 LCD_Clear(Black)
9占空比极性切换漏写每次状态转换时都要切换 CAPTUREPOLARITY
10I2CHAL_Delay 太长EEPROM 等 5ms 即可,不要用更大的值

快速查找索引

功能关键函数 / 操作刷新周期依赖的 HAL 外设
LED 控制LED_disp() + LED_proc()100msGPIO
按键扫描KEY_proc()key_down20msGPIO
LCD 显示LCD_proc() + sprintf100msFSMC/8080
串口发送printf()fputc按需USART1
串口接收中断 + Rx_proc()50ms 超时USART1 IT
ADC 采样ADC_proc()100msADC1 / ADC2
EEPROM 读EEP_read(addr)按需软件 I2C
EEPROM 写EEP_write(addr,data)按需软件 I2C
电位器设置MCP_write(dat)按需软件 I2C
RTC 时间RTC_proc()100msRTC
PWM 输出PWM_proc()100msTIM2/TIM3
频率测量HAL_TIM_IC_CaptureCallback中断驱动TIM16/TIM8