蓝桥杯嵌入式STM32 G431 HAL库定时器实战:从LED闪烁到精准延时

如果你正在备战蓝桥杯嵌入式竞赛,或者刚刚开始接触STM32的HAL库,那么“定时器”这个概念,大概率是你既爱又恨的存在。它不像点亮一个LED那样直观,也不像串口收发数据那样有立竿见影的反馈。但恰恰是这个看似复杂的模块,是嵌入式系统实现“精准时间控制”的灵魂。无论是让LED以精确的1秒间隔闪烁,还是为传感器数据采集提供毫秒乃至微秒级的延时,都离不开定时器的身影。

很多初学者在CubeMX里配置完定时器参数,生成代码后,面对一堆HAL库函数往往感到无从下手。这篇文章,我们就以蓝桥杯竞赛中常用的STM32G431芯片为平台,彻底拆解HAL库下的定时器应用。我不会仅仅罗列函数,而是带你从最基础的“为什么需要定时器”开始,通过两个最经典的实战案例——周期性的LED闪烁和精准的微秒延时,一步步构建你对定时器的直觉理解。你会发现,一旦掌握了核心的配置逻辑和几个关键函数,定时器就会从拦路虎变成你最得力的工具。

1. 理解定时器:嵌入式系统的“心跳”发生器

在深入代码之前,我们得先搞清楚定时器到底是什么,以及它在STM32,特别是G431这类芯片里扮演什么角色。你可以把单片机的主频(比如STM32G431的80MHz)看作一个不知疲倦、高速奔跑的运动员。而定时器,就是给这个运动员配的秒表和闹钟。

定时器的核心工作模式非常简单:它内部有一个计数器,这个计数器会随着一个时钟信号不断地递增(或递减)。这个时钟信号来源于系统主时钟,但通常会经过一个叫做“预分频器”的部件进行降频。比如,80MHz的时钟经过8000分频,就变成了10kHz的信号,计数器每计一个数就代表过去了0.1毫秒。当计数器累加到我们预设的一个值(自动重装载值)时,就会产生一个“溢出”事件,计数器归零(或从预设值开始递减),然后重新开始计数。这个“溢出”的周期,就是我们能控制的精准时间间隔。

在STM32G431中,定时器资源非常丰富,有基本定时器、通用定时器、高级定时器等。对于蓝桥杯竞赛和大多数基础应用,我们最常打交道的是通用定时器(如TIM2, TIM3, TIM4等)。它们功能全面,支持中断、PWM输出、输入捕获等。

提示:在CubeMX中配置定时器时,你主要就是在和两个参数打交道:PSCARR。PSC决定了计数器“心跳”的快慢,ARR决定了“心跳”多少次算一个周期。整个定时器的溢出频率公式为:溢出频率 = 时钟源频率 / (PSC + 1) / (ARR + 1)

为了更直观地理解不同配置下的定时器行为,我们可以看下面这个对比表格:

应用场景时钟源频率PSC 预分频值ARR 自动重装值计数器频率溢出周期说明
1秒LED闪烁80 MHz7999999910 kHz1 秒经典配置,便于理解分频与重装
10ms系统时基80 MHz799999100 kHz10 毫秒常用于操作系统任务调度
微秒级延时80 MHz79655351 MHz65.535 毫秒计数器每计1次为1微秒,适合短延时
PWM波生成 (1kHz)80 MHz799991 MHz1 毫秒ARR决定PWM周期,CCR决定占空比

这个表格揭示了定时器配置的灵活性。通过调整PSC和ARR,我们可以轻松获得从微秒到数秒的各类时间基准。接下来,我们就从第一个实战案例开始。

2. 实战一:配置定时器中断实现精准LED闪烁

让一个LED闪烁,是嵌入式世界的“Hello World”。但用HAL_Delay实现的闪烁是“阻塞式”的,CPU在延时期间什么也干不了。而用定时器中断实现的闪烁,则是“非阻塞式”的,LED的翻转由硬件定时器在后台自动触发,主循环while(1)可以腾出手来处理其他任务,比如扫描按键、刷新显示等。这对于竞赛中需要多任务并发的场景至关重要。

2.1 CubeMX图形化配置

我们以通用定时器TIM1为例,目标是实现一个精确的1秒周期,让LED每秒翻转一次。

  1. 打开时钟树:确保你的系统时钟(HCLK)正确配置为80MHz。这是所有定时器的时钟源头。
  2. 找到TIM1:在左侧的引脚分配图或设备列表中找到TIM1。
  3. 选择时钟源:在TIM1的配置界面,Clock Source选择Internal Clock(内部时钟)。
  4. 配置参数
    • Prescaler (PSC - 16 bits value): 输入 7999。这意味着将80MHz的时钟进行 (7999+1)=8000 分频,得到 80,000,000 / 8000 = 10,000 Hz (10 kHz) 的计数器时钟。
    • Counter Mode: 选择 Up(向上计数模式)。
    • Counter Period (ARR - 16 bits value): 输入 9999。这意味着计数器从0开始,计到9999(总共10000个数)后溢出。溢出时间 = 计数次数 / 计数器频率 = 10000 / 10,000 Hz = 1 秒。
    • auto-reload preload: 建议使能(Enable),这可以确保ARR值在下次更新事件时才被载入,避免在计时中途修改ARR产生毛刺。
  5. 使能中断:切换到NVIC Settings标签页,勾选TIM1 update interrupt(更新中断)。这样,每次计数器溢出时,就会产生一个中断请求。

配置完成后,点击Generate Code,CubeMX会自动为你生成定时器的初始化代码MX_TIM1_Init(),并配置好NVIC中断控制器。

2.2 编写中断服务与回调函数

生成的代码只是搭好了舞台,演员还没上场。我们需要自己编写中断触发后要执行的逻辑。在HAL库中,我们不需要直接编写复杂的中断服务函数,而是重写一个回调函数

打开工程中的main.c文件,在/* USER CODE BEGIN 0 *//* USER CODE END 0 */之间,声明一个用于控制LED的全局变量,并定义回调函数:

/* USER CODE BEGIN 0 */
// 定义一个标志位,用于在中断中翻转LED状态
uint8_t led_toggle_flag = 0;

// 定时器周期溢出回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
  // 判断是否是TIM1触发的中断
  if (htim->Instance == TIM1)
  {
    // 翻转标志位
    led_toggle_flag = !led_toggle_flag;
    // 根据标志位控制LED(假设你的LED控制函数是 LED_Set(1, state) )
    // 这里需要替换为你实际板子的LED控制代码
    HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, led_toggle_flag ? GPIO_PIN_SET : GPIO_PIN_RESET);
  }
}
/* USER CODE END 0 */

注意HAL_TIM_PeriodElapsedCallback是一个弱定义函数。你在用户代码区重新定义它,就完成了“重写”。当任何定时器的更新中断发生时,HAL库的中断处理程序会自动调用这个函数,并通过htim参数告诉你哪个定时器“喊你”。

2.3 启动定时器并观察效果

最后,在main函数的while(1)循环之前,启动定时器中断模式。

int main(void)
{
  // ... 系统初始化,包括CubeMX生成的 MX_TIM1_Init()
  HAL_Init();
  SystemClock_Config();
  MX_GPIO_Init();
  MX_TIM1_Init();
  // ...

  // 启动TIM1,以中断模式运行
  HAL_TIM_Base_Start_IT(&htim1);

  while (1)
  {
    // 主循环现在完全自由了!可以处理其他任务
    // 例如:按键扫描、显示刷新、算法计算等
    // LED的闪烁完全由定时器中断在后台管理,无需在此操心
  }
}

编译下载程序,你应该能看到LED以极其精确的1秒间隔闪烁。你可以尝试修改CubeMX中的PSC或ARR值,重新生成代码,观察LED闪烁频率的变化,这会让你对公式的理解更加深刻。

3. 实战二:构建高精度微秒延时函数

在嵌入式开发中,我们经常需要短时间的精确等待,例如驱动某些需要特定时序的传感器(如DHT11温湿度传感器、DS18B20温度传感器),或者进行软件模拟通信协议(如I2C、单总线)。HAL库提供的HAL_Delay()函数基于系统滴答定时器,精度通常在毫秒级,且会阻塞整个程序。这时,我们就需要自己动手,利用一个定时器来打造一个非阻塞式精准阻塞式的微秒延时函数。

3.1 原理与配置:为什么是1MHz?

我们的目标是实现一个Delay_us(uint16_t us)函数。思路很简单:启动一个定时器,将其计数器频率配置为1MHz,这样计数器每增加1,就代表过去了1微秒。我们只需要在延时开始时清零计数器,然后轮询等待计数器值达到我们设定的微秒数即可。

在STM32G431上,我们选用另一个通用定时器,比如TIM4。

  1. CubeMX配置TIM4
    • 时钟源同样选择Internal Clock
    • Prescaler (PSC): 输入 79。计算:80MHz / (79+1) = 1,000,000 Hz = 1 MHz。完美!
    • Counter Period (ARR): 设置为最大值 65535(对于16位定时器)。因为我们做延时函数时,ARR只是计数器的上限,我们实际关心的是计数器的瞬时值。设为最大值可以让我们获得最长的单次延时时间(65.535毫秒)。
    • 这次不需要使能NVIC中断,因为我们采用轮询方式,而不是中断方式。

3.2 编写精准延时函数

main.c/* USER CODE BEGIN 0 */区域,添加以下函数:

/* USER CODE BEGIN 0 */
// 微秒级延时函数(阻塞式)
void Delay_us(uint16_t us)
{
  // 启动定时器(非中断模式)
  HAL_TIM_Base_Start(&htim4);
  // 将定时器计数器清零
  __HAL_TIM_SET_COUNTER(&htim4, 0);

  // 等待计数器值增长到指定的微秒数
  while (__HAL_TIM_GET_COUNTER(&htim4) < us)
  {
    // 空循环,等待时间到达
  }

  // 延时结束,停止定时器以节省功耗
  HAL_TIM_Base_Stop(&htim4);
}
/* USER CODE END 0 */

这个函数的工作流程非常清晰:

  • HAL_TIM_Base_Start(&htim4):让TIM4的计数器开始跑起来。
  • __HAL_TIM_SET_COUNTER(&htim4, 0):把计数器归零,从零开始计时。
  • while (__HAL_TIM_GET_COUNTER(&htim4) < us):不断地读取当前计数值,直到它大于等于我们需要的微秒数。
  • HAL_TIM_Base_Stop(&htim4):时间到了,关掉定时器。

3.3 应用测试与注意事项

你可以在主循环里测试这个函数:

while (1)
{
  // LED亮
  HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_SET);
  Delay_us(500000); // 延时500毫秒
  // LED灭
  HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_RESET);
  Delay_us(500000); // 延时500毫秒
}

你会发现LED以1秒周期闪烁,和之前用中断实现的效果一样,但原理不同。这里主循环被Delay_us阻塞了。

重要提示

  1. 精度:这种轮询方式的延时在us级别非常精准,因为它几乎完全由硬件计数器决定,软件开销极小。但中断的开启可能会带来少许抖动。
  2. 阻塞性Delay_us阻塞函数,在延时期间CPU无法执行其他代码。因此它仅适用于短时间延时或对时序要求极其严格的场景。
  3. 定时器冲突:确保你用来做延时函数的定时器(这里是TIM4)没有在其他地方被用作他途(如PWM、输入捕获等),否则会产生冲突。
  4. 替代方案:对于更长的、非阻塞的延时,依然推荐使用定时器中断+状态机的方式,这才是嵌入式系统设计的精髓。

4. 进阶:定时器中断与轮询模式深度辨析

通过上面两个案例,我们已经接触到了HAL库中启动定时器的两种核心方式:HAL_TIM_Base_Start_ITHAL_TIM_Base_Start。理解它们之间的区别,是能否灵活运用定时器的关键。

很多新手会在这里混淆。让我们抛开函数名,从它们实际的行为来理解:

  • HAL_TIM_Base_Start_IT (中断模式): 你告诉定时器:“开始跑吧,等跑到终点(溢出)的时候,大声喊我一下(触发中断),我再来处理事情。” 然后你就可以去忙别的事了。这是一种“事件驱动”模型。CPU的介入是异步的、被动的。它非常适合执行周期性的固定任务,如系统心跳、数据采样、扫描队列等,不占用主循环时间。

  • HAL_TIM_Base_Start (轮询模式): 你告诉定时器:“开始跑吧。” 然后你自己不停地盯着它的里程表(计数器),心里默念“还没到...还没到...”。这是一种“主动查询”模型。CPU需要持续参与等待过程。它通常用于实现阻塞延时,或者测量一个脉冲的宽度(输入捕获),在这些场景下,你需要同步地、精确地知道时间点。

为了帮你更好地根据项目需求做选择,可以参考下面的决策思路:

  1. 你的任务是否需要严格的、周期性的执行?
    • -> 优先选择中断模式。例如,每10ms读取一次ADC,每1秒刷新一次显示屏。定时器就像你的精准闹钟。
  2. 你的任务是一次性的短时间等待,还是需要知道某个操作的精确耗时?
    • -> 可以使用轮询模式。例如,模拟I2C协议时,需要产生一个5us的低电平脉冲;或者测量一个高电平信号的持续时间。定时器在这里更像一个精准的秒表。
  3. 你的系统对实时性要求高吗?主循环是否有很多紧急任务要处理?
    • 实时性高,主循环忙 -> 中断模式是必须的,它能确保定时任务不被主循环的繁忙所延迟。
    • 主循环很闲,或者任务简单 -> 两种模式都可以,轮询模式代码更简单直观。

一个常见的误区:试图在中断回调函数HAL_TIM_PeriodElapsedCallback里进行长时间的延时或复杂运算。这是大忌!中断服务函数应该尽可能快地执行完毕,否则会影响其他中断的响应,甚至导致系统异常。如果中断里需要处理复杂任务,正确的做法是设置一个标志位,然后在主循环中检查这个标志位并执行具体任务。

5. 避坑指南与竞赛实战技巧

掌握了基本操作,想要在蓝桥杯竞赛或实际项目中玩转定时器,还需要了解一些“坑”和技巧。这些都是从过往项目经验中总结出来的,能让你少走很多弯路。

5.1 定时器资源分配策略

STM32G431的定时器很多,但到了竞赛中,外设需求也多(LED、按键、LCD、ADC、PWM、串口等)。提前规划好定时器用途至关重要。

  • TIM1/TIM8:高级定时器,功能最强,常用来产生复杂的PWM控制电机(如驱动舵机、直流电机)。如果项目用不到,可以释放出来做普通定时器。
  • TIM2/TIM3/TIM4/TIM5:通用定时器,主力部队。建议:
    • 分配一个给系统时基(如1ms中断),用于软件计时、任务调度。
    • 分配一个给精准延时函数Delay_us)。
    • 分配一个给特定功能(如超声波测距的输入捕获、蜂鸣器PWM发声)。
  • TIM6/TIM7:基本定时器,只能用于简单的定时中断,适合做系统时基。

建议:在CubeMX工程初始化时,就为所有可能用到的定时器做好基础配置(时钟源、分频),即使暂时不用也先配置好,避免后期添加时发现引脚冲突或资源被占。

5.2 中断优先级管理与响应延迟

当你的系统中有多个中断(定时器、串口、外部中断等)时,就需要考虑中断优先级。在CubeMX的NVIC Configuration中,可以设置每个中断的Preemption Priority(抢占优先级)和Sub Priority(子优先级)。

  • 系统时基定时器中断(如1ms滴答):优先级可以设得高一些,确保系统心跳不被过度延迟。
  • 电机控制PWM定时器中断:如果用于闭环控制,也需要高优先级以保证控制频率稳定。
  • 用户界面相关定时器(如按键扫描、显示刷新):优先级可以设得低一些

即使设置了优先级,也要意识到中断响应本身有延迟(中断延迟、现场保护/恢复时间)。对于要求极其严格的时间控制(如生成特定频率的方波),单纯依赖中断可能产生抖动。这时,可以考虑使用定时器的硬件自动输出功能(如PWM模式、单脉冲模式),将时间控制完全交给硬件,软件只负责触发,这样可以达到最高的时间精度。

5.3 调试定时器:当代码不按预期运行时

你的LED闪烁快慢不对劲?延时函数好像不准?别慌,按以下步骤排查:

  1. 检查时钟树:首先确认SystemClock_Config()是否正确,你的HCLK是不是真的跑在80MHz?可以在main函数初始化后,用SystemCoreClock变量打印出来看看。
  2. 复查CubeMX配置:双击打开.ioc文件,确认TIMx的PSC和ARR值是否计算正确。尤其注意单位,PSC和ARR都是16位无符号整数,别超出65535。
  3. 确认中断是否使能:对于中断模式,检查HAL_TIM_Base_Start_IT是否被调用,以及NVIC中对应的中断是否勾选。
  4. 使用调试器:这是最强大的工具。在Keil或STM32CubeIDE中,进入调试模式:
    • HAL_TIM_PeriodElapsedCallback函数内设置断点,看中断是否真的进入。
    • 查看TIMx->CNT寄存器的值,看它是否在规律地循环计数。
    • 查看TIMx->SR(状态寄存器)的UIF位(更新中断标志),看溢出标志是否被置起又清除。
  5. 检查全局中断开关:确保没有其他地方不小心调用了__disable_irq()关闭了全局中断,导致所有中断失效。

定时器是STM32 HAL库中最能体现“硬件抽象”魅力的模块之一。它封装了底层寄存器操作的复杂性,让我们通过几个直观的函数和图形化配置就能驾驭强大的硬件定时功能。从让LED精准闪烁开始,到为整个系统提供稳定的时间基准,再到实现复杂的电机控制和信号测量,定时器都是你嵌入式开发生涯中不可或缺的伙伴。多动手实验,多思考不同模式下的应用场景,你会逐渐发现,精准控制时间,原来是如此有成就感的一件事。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐