蓝桥杯嵌入式省赛代码模块和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. 频率与占…
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_value | 2 | 00000010 | 当前 B2 被按下 |
key_old | 0 | 00000000 | 上次没有键按下 |
key_value ^ key_old | 2 | 00000010 | 状态发生了变化的位 |
key_value & (...) | 2 | 00000010 | 只保留当前为"有键"的变化 → 这就是 key_down |
~key_value & (...) | 0 | 00000000 | 当前没有"变为无键"的位 → key_up 为0 |
以 key_value 从 2 变为 0(松开 B2)为例:
| 变量 | 值 | 二进制 | 含义 |
|---|---|---|---|
key_value | 0 | 00000000 | 无键 |
key_old | 2 | 00000010 | 上次 B2 按着 |
key_value ^ key_old | 2 | 00000010 | 状态发生了变化 |
key_value & (...) | 0 | 00000000 | key_down 为 0 |
~key_value & (...) | 2 | 00000010 | 保留"变为无键"的位 → 这就是 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_down 和 key_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
注意事项汇总
| 序号 | 场景 | 陷阱 | 正确做法 |
|---|---|---|---|
| 1 | RTC | 只调用 GetTime | 必须同时调 GetTime + GetDate |
| 2 | EEPROM 写 | 写完立刻读 | 写完后 HAL_Delay(5) 再操作 |
| 3 | ADC | 忘记浮点转换 | / 4096.0f 必须带 f 后缀,否则整数除法结果恒为 0 |
| 4 | 按键 | 无键时忘记赋 0 | else key_value = 0; 必须写 |
| 5 | UART | 忘记开启首次中断 | main() 里调一次 HAL_UART_Receive_IT() |
| 6 | PWM | 忘记 Start | HAL_TIM_PWM_Start() 写在 while(1) 之前 |
| 7 | 频率捕获 | 忘记重启中断 | 回调末尾重新调 HAL_TIM_IC_Start_IT() |
| 8 | LCD | 切换界面残影 | 切界面时先调 LCD_Clear(Black) |
| 9 | 占空比 | 极性切换漏写 | 每次状态转换时都要切换 CAPTUREPOLARITY |
| 10 | I2C | HAL_Delay 太长 | EEPROM 等 5ms 即可,不要用更大的值 |
快速查找索引
| 功能 | 关键函数 / 操作 | 刷新周期 | 依赖的 HAL 外设 |
|---|---|---|---|
| LED 控制 | LED_disp() + LED_proc() | 100ms | GPIO |
| 按键扫描 | KEY_proc() → key_down | 20ms | GPIO |
| LCD 显示 | LCD_proc() + sprintf | 100ms | FSMC/8080 |
| 串口发送 | printf() → fputc | 按需 | USART1 |
| 串口接收 | 中断 + Rx_proc() | 50ms 超时 | USART1 IT |
| ADC 采样 | ADC_proc() | 100ms | ADC1 / ADC2 |
| EEPROM 读 | EEP_read(addr) | 按需 | 软件 I2C |
| EEPROM 写 | EEP_write(addr,data) | 按需 | 软件 I2C |
| 电位器设置 | MCP_write(dat) | 按需 | 软件 I2C |
| RTC 时间 | RTC_proc() | 100ms | RTC |
| PWM 输出 | PWM_proc() | 100ms | TIM2/TIM3 |
| 频率测量 | HAL_TIM_IC_CaptureCallback | 中断驱动 | TIM16/TIM8 |