做过嵌入式的人都知道一句话:点灯是入门,点灯的方式才是分水岭。用HAL_Delay()写个死循环延时,灯也能闪,但你永远不知道系统里有多少个“等一会儿”在排队;用定时器中断,把翻转 LED 的动作挂到硬件心跳上,CPU 该睡觉睡觉、该干活干活,灯的节奏却分毫不差。这篇文章就用 STM32G0 的 TIM2 做一个精准 1 秒翻转 LED 的完整实战,全程基于 HAL 库,从 CubeMX 配置、时钟计算、中断链路到回调函数写法,逐行拆解,最后给出可以直接抄作业的完整代码。
这套东西适合谁?刚接触 STM32 想搞懂定时器中断的同学,被 HAL 库各种封装绕晕的新手,或者已经会点灯但想摸清 TIM2 溢出周期怎么算的老哥们。看完你会明白一个核心问题:为什么你写出来的 1 秒总是不准,以及怎么用 TIM2 这种 32 位定时器把 1 秒算得明明白白。
1. 项目拆解:为什么选定时器中断,为什么是 TIM2
1.1 定时器中断解决的三个痛点
先聊最本质的问题:为什么不用HAL_Delay()?很多入门教程教的就是 Delay 翻转,我承认它简单,但它有三个硬伤。
第一,HAL_Delay()是忙等。CPU 在while里空转,什么事都干不了。你以为你在“精准延时”,实际上是把整个 MCU 锁死在一个循环里。第二,HAL_Delay()依赖 SysTick 中断,如果系统里别的地方把 SysTick 关了,或者优先级配错了,延时会直接失灵。第三,延时函数的精度受中断抢占影响,外部中断一多,这个 1 秒可能变成 1.2 秒,你根本察觉不到。
定时器中断的思路完全相反:TIM2 自己数时钟,数到预设值就触发一次中断,CPU 在中断里翻转一下 LED,然后该干嘛干嘛。MCU 的核心频率、外设运算、通信处理都不受影响,LED 的节奏完全由硬件定时器保证。这正是工业上做周期性采样的标准姿势,也是多任务调度的雏形。
1.2 为什么选 TIM2:32 位计数的优势
STM32G0 系列里的 TIM2 是 32 位通用定时器。32 位意味着什么?它的自动重载寄存器 ARR 最大可以写到 4294967295。这个数字对做“精准 1 秒”太友好了。你可以直接把计数器周期配成 999999,让定时器整数溢出在 1 秒这个点上,不需要软件里再计数一次。
举个例子感受一下:如果用的是 16 位定时器,ARR 最大只有 65535,在 64 MHz 时钟下,即便把预分频拉到 65535,计数时间也就一秒钟上下,非常局促。你要么牺牲计数精度,要么在中断里做软件累加。TIM2 就没这个问题,32 位的计数范围让你能把“1 秒”直接写进硬件配置,代码干净、逻辑直白。
当然 TIM2 不是白送的,它占用的中断向量和资源在 G0 内部属于通用定时器组,不跟高级定时器 TIM1 冲突,很适合当系统的“心跳时钟”来用。
1.3 TIM2 和 SysTick 的分工千万别搞混
这里有个容易踩坑的点:HAL 库自己有一个时基(Timebase),默认用 SysTick 实现,HAL_Delay()、各种超时判断都靠它。你在应用层用 TIM2,不等于要把 SysTick 替换掉。
正确姿势是:SysTick 继续给 HAL 库当心跳,TIM2 专心做你的应用时基。两者各管一段,互不干扰。如果你非要手贱去把 HAL 的时基改成 TIM2,那代码里所有依赖HAL_Delay()的地方都会乱套,排查起来极其痛苦。我见过有人为了省一个定时器,把HAL_InitTick指到 TIM6,结果整个工程的超时逻辑全部失效,当场血压拉满。
2. 环境准备与 CubeMX 配置的关键参数
2.1 开发环境与硬件清单
先交代一下我这边的环境,你照着配大概率不会出问题。
- 开发板:Nucleo-G071RB,板载 STM32G071RB,主频最高 64 MHz
- 开发环境:STM32CubeIDE 1.13 以上版本,自带 CubeMX 图形化配置
- HAL 库版本:随固件包自动拉取,我用的是 STM32Cube FW_G0 V1.6.1
- 板载 LED:PA5,低电平点亮(Nucleo 板上 LD2 默认就是这么接的)
如果没有 Nucleo 板,用市面上任意一块 STM32G0 核心板也能复现,只要把 LED 引脚改成你自己的 GPIO 就行。我的配置是 PA5 推挽输出,这个后面会写清楚。
2.2 CubeMX 里 TIM2 的图形化配置
打开 CubeMX,新建项目选对芯片型号,然后分三步配置。
第一步,配置 RCC。在 System Core 的 RCC 选项里,HSE 选Crystal/Ceramic Resonator或BYPASS Clock Source。如果你的板子上没有外部晶振,也可以直接用内部高速时钟 HSI16,后面配合 PLL 锁到 64 MHz。Nucleo 板上默认有外部晶振,所以 HSE 直接选 Crystal。
第二步,配置时钟树。这是整个工程精度最关键的一环。确保 SYSCLK 拉到 64 MHz,然后看 APB 总线分频。这里有个容易被忽略的知识点:STM32 的定时器时钟并不总是等于总线时钟,当 APB 预分频器大于 1 时,定时器内部时钟会翻倍。G0 上如果你把 APB1 设置成 DIV1,那么 TIM2 的时钟就等于 64 MHz,干干净净。
第三步,配置 TIM2 参数。在 Timers 列表里选中 TIM2,勾选Activated,然后设置以下参数:
| 参数 | 数值 | 说明 |
|---|---|---|
| Prescaler (PSC) | 63 | 64 分频,得到 1 MHz 计数时钟 |
| Counter Mode | Up | 向上计数模式 |
| Counter Period (ARR) | 999999 | 计满 100 万个脉冲,触发中断 |
| Auto Reload Preload | Enable | 使能自动重载预装载 |
| NVIC 中断 | TIM2 global interrupt | 勾选并给一个合适的优先级 |
这个配置的含义是:64 MHz 先除以 64,得到 1 MHz,也就是计数器每 1 微秒走一步;再走 100 万个步数,就是整整 1 秒。公式随手一算:
$$中断频率 = \frac{64\text{ MHz}}{(63+1) \times (999999+1)} = 1 \text{ Hz}$$
用 CubeMX 的 Clock Configuration 页面把 SYSCLK 调到 64 MHz 后,你可以在 TIM2 的配置界面里直接看到这个计算结果。如果参数填错了,CubeMX 甚至会弹警告,这个细节对新手很友好。
2.3 时钟源选择:内部时钟配置到底怎么回事
有同学问过“TIM2 为什么是 internal clock”这类问题,这里统一解释一下。
在 CubeMX 的 Clock Configuration 视图里,TIM2 的时钟源会显示为“Internal Clock”,前缀是灰色锁定的。它指的是定时器的计数时钟不来自外部引脚,而是来自 APB 总线提供的内部时钟。这是绝大多数场景下的默认配置,也正是我们这个项目需要的:让定时器跟着系统主频走,精度可预测、可计算。
TIM2 其实也支持外部时钟模式,可以从外部引脚输入脉冲,用在测频、编码器计数这些场景。普通应用根本不用碰这个选项,你就记着:项目里只做定时、点灯、周期采集,选 Internal Clock 就够了。
配置完成后,点击GENERATE CODE,生成的工程里会看到TIM2初始化和中断向量,但 HAL 库的核心逻辑还没有自动启动,我们得在main.c里手动开启中断。
3. HAL 库定时器中断的完整链路
3.1 HAL 库的封装结构:从硬件中断到回调函数
很多人学 HAL 库时觉得“像个黑盒”,因为 CubeMX 生成了几个函数,一时看不懂背后干了什么。其实链路非常简单,就三层。
第一层是初始化。CubeMX 生成的MX_TIM2_Init()会把htim2这个句柄填好,包括我们在图形界面里配的 PSC、ARR、计数模式这些字段,然后调用HAL_TIM_Base_Init(),把参数写进 TIM2 的硬件寄存器。这个函数在stm32g0xx_hal_tim.c里,是整个定时器驱动的地基。
第二层是中断服务函数。当你使能 NVIC 后,TIM2 溢出时会发生一次中断,CPU 跳转到stm32g0xx_it.c里的TIM2_IRQHandler()。这个函数什么都没干,就调用了HAL_TIM_IRQHandler(&htim2)。HAL 库在这里检查是哪种事件(更新、捕获、比较等),然后进入第三层。
第三层就是你自己写的回调函数HAL_TIM_PeriodElapsedCallback()。HAL 库在中断上下文里会帮你调用这个函数,你不需要去操作任何寄存器,也不需要看中断状态位,直接在回调里翻转 LED 就行。
理解了这三层,你就知道 HAL 库多出一个“句柄 + 回调”的抽象层,是为了让你把业务逻辑跟硬件细节解耦。代价是中断响应多了几个函数调用,但我们这个是 1 秒级别的低速中断,完全无所谓。
3.2 回调函数的覆盖时机,千万不要乱加东西
HAL_TIM_PeriodElapsedCallback()是弱函数(weak),HAL 库默认给了个空实现。你要在自己的用户代码区重写这个函数,这样链接时你的版本就能覆盖掉默认版本。
这里有个细节:这个回调是所有定时器共用的。如果你的工程里同时用了 TIM2 和 TIM6,中断触发后都会进同一个回调函数。所以函数开头一定要判断句柄是谁:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }不看句柄直接翻转 LED 的写法,在只有一个定时器的时候没问题,但一旦以后加了别的定时器,就会出奇奇怪怪的逻辑错误。养成判断Instance的习惯,是资深从业者的基本素养。
3.3 要精细控制就用 LL 库?别急着下结论
很多老工程师喜欢在中断场景用 LL 库,理由是 LL 更接近寄存器,没有回调封装,代码执行更快。我不否认,LL 库确实轻量,比如用 LL 库写定时器溢出中断,直接在中断服务函数里判断标志位即可,少了两层函数调用。
但做项目不是只考虑速度,还要考虑可维护性和团队协作。HAL 库的封装虽大,但逻辑统一,换芯片平台时大部分代码可以平移。而且到了 1 秒、甚至 1 毫秒这个量级的中断,HAL 库多出来的那几微秒开销根本感知不到。真到了对时序极端苛刻的场景,比如高速 PWM 或高频采样,那时候再混用 LL 调整关键寄存器也不迟。HAL 和 LL 不是二选一的仇人,GE 工程里完全可以共存,网上那些“HAL 垃圾”的说法听听就好。
4. 代码实现与逐行解析
4.1 初始化代码背后的事
CubeMX 生成的初始化流程里,main()会依次调用HAL_Init()、SystemClock_Config()、MX_GPIO_Init()、MX_TIM2_Init()。这里面有个很多人忽略的函数,HAL_Init()里最关键的动作就是配置 SysTick,并启动了一个 1 ms 的时基中断。
HAL_StatusTypeDef HAL_Init(void) { HAL_InitTick(); ... }如果你把这条链捋顺了,就不会问“为什么我什么都没干,SysTick_Handler 也在跑”这种问题。因为 HAL 库本身就需要一个节拍。TIM2 的初始化在这里只是个“配寄存器”,真正开始计时还得靠我们手动调HAL_TIM_Base_Start_IT()。
启动函数放哪里?最稳妥的位置是在main()里,所有外设初始化完之后:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); HAL_TIM_Base_Start_IT(&htim2); // 启动TIM2定时器中断 while (1) { // 主循环里什么都不用干,LED翻转全部交给中断 // 想看效果就加个低功耗模式,或者做别的事 } }4.2 中断回调中的 LED 翻转
回调函数我放在main.c的 USER CODE 区域,CubeMX 重新生成代码时不会覆盖。翻转 GPIO 用 HAL 库的HAL_GPIO_TogglePin()即可,参数就是我们配好的LED_GPIO_Port和LED_Pin。
完整回调:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }有人会问,直接操作寄存器翻转是不是更快?比如GPIOA->ODR ^= GPIO_PIN_5;。确实快一点,但 HAL 库里HAL_GPIO_TogglePin内部也只是做异或操作,差别微乎其微。优先用库函数,代码更统一;真到了要优化的阶段,再考虑寄存器操作不迟。
4.3 完整代码清单与编译验证
下面把核心代码完整贴出来,基于 CubeMX 生成的项目,我只额外加了三处:
第一处,配置 TIM2 的 PSC 为 63、ARR 为 999999,这个在图形界面完成,生成的代码会对应如下:
static void MX_TIM2_Init(void) { htim2.Instance = TIM2; htim2.Init.Prescaler = 63; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999999; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } }第二处,在main()里加启动函数:
HAL_TIM_Base_Start_IT(&htim2);第三处,在用户代码区加回调函数:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }编译下载后,你会看到 PA5 上的 LED 以 1 Hz 频率翻转,亮 1 秒、灭 1 秒。用示波器量 PA5 引脚,可以看到一个精确的方波,周期 2 秒,高电平 1 秒,低电平 1 秒。如果你只有逻辑分析仪,也能看到这个规律。
5. 精准计时的实战经验:常见问题与排查技巧
5.1 为什么你写的 1 秒总是不准
很多人在这个例程上翻车,原因五花八门,最集中的几个我列一下。
第一,时钟树配错了。CubeMX 默认生成的工程 SYSCLK 不一定是 64 MHz。如果用默认的 HSI16 而没有配置 PLL,主频只有 16 MHz,那你的 TIM2 即使 PSC=63,计数时钟也只有 250 kHz,1 秒的周期会变成 4 秒。改完时钟树一定要看 Clock Configuration 页面的确认,确保 SYSCLK 显示 64 MHz。
第二,APB 分频器配高了。前面提过,STM32 定时器时钟在 APB 预分频大于 1 时会自动乘 2。如果你在这里搞混了,计数器时钟就不是你以为的那个数,周期自然不准。有个土办法验证:直接在 CubeMX 的 TIM2 配置界面右键能看到计算后的溢出频率,填完参数瞄一眼就心里有数了。
第三,用了 16 位计数思路。有人把 TIM2 当 16 位定时器用,看到 ARR 填 999999 就担心溢出了。其实 TIM2 在这个系列的 G0 上是 32 位定时器,放心填。但如果换到 TIM3,就未必是 32 位,需要查数据手册确认。
5.2 中断服务函数里的隐形杀手
再来谈谈质量隐患。定时器中断进入后,代码执行时间越短越好,这是铁律。
很多新手会在回调函数里写HAL_Delay(),想着“翻转后等 500 毫秒再来一次”。这是一个典型的死锁陷阱。因为HAL_Delay()依赖 SysTick 中断,而你的程序现在正处在 TIM2 中断上下文里。如果 SysTick 中断优先级和 TIM2 相同或更高,倒还有可能正常运行;一旦优先级配置不当,或者延迟时间跨过 SysTick 的中断周期,整个调度就会乱成一锅粥,轻则闪烁异常,重则直接卡死。
正确思路是:中断里只做“标记”和“耗时极短的操作”,比如翻转 LED、置一个标志位。真正复杂的逻辑放到主循环里处理。我们这次的例程简单,回调里直接翻转没问题。以后你要是拿定时器做按键扫描、数据处理,一定把大部分工作挪出中断。
5.3 排查手法:示波器、变量监视与逻辑分析仪
如果 LED 完全不动,先看三个方向。第一,HAL_TIM_Base_Start_IT有没有调用,光有 CubeMX 生成的初始化不够,没启动定时器就不会产生中断。第二,NVIC 里有没有勾选 TIM2 global interrupt,CubeMX 其实已经帮你配好了,但手动改代码时容易丢。第三,回调函数有没有被正确覆盖,如果你的回调函数名打错了一个字母,HAL 库的默认空实现就会接管,你的代码永远执行不到。
如果 LED 在动但周期不对,直接用示波器量 PA5,看波形周期是多少。测出来不是 2 秒,就用调试器在回调函数里打断点,看htim2.Instance和当前计数值__HAL_TIM_GET_COUNTER(&htim2)。这个方法能快速锁定是配置问题还是时钟问题。
我个人调试这类东西习惯在回调里加一个volatile uint32_t tick,每次进来tick++,然后在调试器里观察这个变量每秒增量是否等于 1。如果等于 1,说明硬件没问题,是 LED 或者其他外设的锅。这个小技巧能帮你把问题隔离得很干净。
6. 扩展:一个定时器能玩出多少花样
6.1 1 ms 节拍计数实现多任务时基
有朋友看完直接照抄上面的回调,然后问:我现在想做一个“按键按下后 3 秒后执行动作”,怎么做?简单,把 TIM2 改成 1 ms 中断一次,PSC 保持 63,ARR 改成 999。这样每秒进 1000 次中断,每次进去只做一个tick++,主循环里判断 tick 的差值,实现非阻塞延时。
volatile uint32_t tick_ms = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { tick_ms++; } }然后随便写个超时判断:
uint32_t start = tick_ms; while ((tick_ms - start) < 3000) { // 这段循环不会卡死其他逻辑 }这个模式本质上是把定时器升级成软件的“心跳”,后面做状态机、事件调度都用得上。
6.2 定时器触发 ADC 采集与 DMA 配合
TIM2 还有一个隐藏技能:作为 ADC 的触发源。ADC 的采样需要固定间隔,如果用软件轮询,采样时间容易抖动;用 TIM2 输出触发事件,ADC 就能在完全确定的时间点启动转换。配合 DMA,可以实现“定时器触发 ADC → DMA 搬运结果 → CPU 处理”的整套数据流。网上常说的“定时器中断触发 ADC + HAL”,大方向就是这个思路。
操作上,CubeMX 里把 TIM2 的Trigger Output (TRGO)配置成Update Event,然后在 ADC 配置里把触发源选成TIM2 Trigger Out event。生成代码后启动顺序变成:先启动 ADC,再启动 TIM2,TIM2 每次溢出更新就自动触发一次 ADC 采样。整个过程不需要 CPU 干预,采样频率极其稳定。这就是定时器的威力,它不只是点灯工具,它是整个系统的节拍器。
6.3 把定时器搬到其他芯片上要注意什么
最后提一句迁移问题。同样是 STM32 系列,F1、F4、G0 的 HAL 库 API 基本兼容,但定时器资源差异很大。比如 F103 的 TIM2 叫通用定时器,但主频、时钟树、APB 分频规则都不同。你在这篇例程里写的 PSC=63、ARR=999999,换到 72 MHz 的 F103 上,周期就会变成 0.89 秒左右,必须重新计算。
所以凡是涉及“精准”两个字的项目,都要养成果断检查时钟树的习惯。芯片型号一变,时钟频率一变,所有定时参数都得重新推导。这也是嵌入式开发和纯软件最大的不同:你的代码不是跑在一台理想机器上,而是跑在一个有具体时钟、具体寄存器的裸机上。
我自己做的项目多了以后,最大的体会就是:定时器中断不是什么高深技巧,但它考验的是你对时钟、外设、中断优先级、HAL 库封装逻辑的整体理解。把这 1 秒 LED 做透了,后面做 PWM、捕获、编码器、ADC 触发,都是顺理成章的事。你在这个例程里养成的每一步排查习惯,会在今后的每一个 Bug 现场救你一命。