1. 为什么定时器是STM32入门的分水岭
搞STM32开发的人都有一个共识:GPIO点灯只是幼儿园,能把定时器玩明白才算真正踏进了嵌入式的大门。我见过太多人卡在定时器这一关,不是被那一堆寄存器搞晕,就是被CubeMX里密密麻麻的配置项劝退。但说实话,定时器恰恰是STM32最强大、最值得花时间啃下来的外设,没有之一。
你想想,一个通用定时器能干什么?精确定时中断、PWM输出、输入捕获测频率、编码器接口、触发ADC采样、驱动DHT11这种单总线传感器、给OLED刷新提供时间基准……几乎你后面要做的每一个项目,都绕不开定时器。这也是为什么我把它放在入门系列的第九篇来讲——前面GPIO、串口、中断都铺垫完了,现在正是啃硬骨头的时候。
这篇内容面向的是刚接触STM32 HAL库、能用CubeMX生成基础工程但还没系统用过定时器的朋友。我会用TIM3做一个1Hz的定时中断,每秒钟翻转一次LED,同时串口打印计数。别小看这个例子,里面涉及的分频系数计算、中断优先级配置、HAL库回调机制,都是后面做复杂项目的地基。你把这套流程走通了,换成TIM1、TIM2、TIM4,或者改成PWM模式、捕获模式,都是换汤不换药的事。
我个人的习惯是,每学一个新外设,先用最简配置把它跑起来,看到现象之后再回头抠细节。这篇就按这个思路来,先跑通,再讲透。
2. 定时器定时中断的整体设计思路
2.1 为什么选通用定时器TIM3而不是滴答定时器
STM32里能产生定时中断的东西不少,SysTick滴答定时器、基本定时器TIM6/TIM7、通用定时器TIM2~TIM5、高级定时器TIM1/TIM8,都能干这活。那为什么入门阶段我强烈建议用TIM3?
SysTick虽然简单,但它是给操作系统和HAL库的延时函数用的,你拿它做业务定时,容易和HAL_Delay打架,而且它只有一个,没法同时做多路不同频率的定时。基本定时器TIM6/TIM7功能太单一,只能定时,不能输出PWM也不能捕获,学它性价比低。高级定时器TIM1功能最强但配置最复杂,还涉及刹车、死区这些电机控制才用的东西,入门阶段没必要。
TIM3属于通用定时器,功能全、资料多、引脚不冲突,F103系列上几乎每块开发板都引出来了。你把它学会了,后面换任何一款STM32,定时器的操作逻辑都是通的。这就是我选它的理由——学一个,通一片。
2.2 定时中断的本质:计数器溢出
很多人被“预分频”“自动重装载”这些词吓住,其实定时中断的原理特别朴素。你可以把定时器想象成一个水桶,时钟信号就是往桶里滴水,滴一滴计数器加一。桶满了(计数器达到自动重装载值),水就溢出来,触发一次中断,然后桶自动清空重新开始滴。
这里有两个关键旋钮:预分频器(Prescaler)控制滴水的速度,自动重装载寄存器(ARR)决定桶有多大。时钟源频率除以(预分频+1)就是滴水的频率,再乘以(ARR+1)就是桶满一次需要多少个时钟周期。这两个参数一配合,你就能得到任意想要的定时周期。
拿F103来说,TIM3挂在APB1总线上。APB1的时钟频率通常是36MHz,但因为APB1预分频系数不为1,定时器时钟会被倍频,实际TIM3的时钟是72MHz。这个细节很多人会搞错,后面算参数的时候我会详细说。
2.3 HAL库回调机制的设计哲学
标准库时代,你得自己写中断服务函数,在里面判断是哪个中断源、清标志位、写业务代码,一不小心就漏了清标志导致中断反复触发。HAL库把这套流程封装了,中断服务函数里只调用HAL_TIM_IRQHandler,它会自动帮你判断中断源、清标志,然后调用对应的回调函数。
你要做的只有一件事:重写回调函数。定时更新中断对应的是HAL_TIM_PeriodElapsedCallback。这个设计的好处是业务代码和底层中断处理解耦了,你只管在回调里写逻辑,不用操心标志位。坏处是初学者容易找不到回调函数在哪定义,或者忘了它是个弱函数需要自己重写。
注意:
HAL_TIM_PeriodElapsedCallback是所有定时器共用的回调,如果你同时开了TIM2和TIM3的更新中断,进这个回调后必须先判断是哪个定时器触发的,否则会出乱子。判断方法是用htim->Instance == TIM3这样的条件。
3. CubeMX配置定时器的完整实操
3.1 时钟树配置:先把72MHz搞定
打开CubeMX新建工程,选好芯片型号(我以STM32F103C8T6为例),第一件事是配时钟树。在RCC里把HSE设为Crystal/Ceramic Resonator,也就是外部晶振。然后进Clock Configuration页面,把HCLK拉到72MHz,CubeMX会自动帮你算好各总线的分频。
这里要盯一眼APB1 Prescaler,它通常是除以2,所以APB1时钟是36MHz。但注意看定时器时钟那一栏,TIM3的时钟显示的是72MHz,这就是前面说的倍频效果。记住这个72MHz,等下算分频要用。
3.2 TIM3参数配置:算出1Hz的定时周期
在左侧外设列表里找到TIM3,把Clock Source设为Internal Clock。然后进Parameter Settings页面,重点配这几个参数:
- Prescaler(预分频系数):填7199
- Counter Mode:Up(向上计数)
- Counter Period(ARR自动重装载值):填9999
- auto-reload preload:Enable
- Trigger Event Selection:Reset(默认)
为什么是这两个数?算给你看。定时器时钟72MHz,预分频填7199意味着实际分频系数是7199+1=7200,分频后的计数频率是72MHz/7200=10kHz。也就是说计数器每0.1ms加一。ARR填9999意味着计数到9999溢出,一共数10000次,耗时10000×0.1ms=1000ms=1秒。刚好1Hz。
这里有个坑:CubeMX里填的Prescaler和ARR都是“值减一”的语义,因为它内部会加一。你要是直接填7200和10000,实际得到的是7201和10001,定时就不准了。这个细节标准库和HAL库都一样,新手特别容易栽。
3.3 开启定时中断:NVIC里的优先级设置
参数配完还不算完,必须去NVIC Settings标签页把TIM3 global interrupt勾上,否则中断根本不会触发。勾上之后可以设抢占优先级和响应优先级。
我的建议是:如果工程里中断不多,TIM3的抢占优先级给个中等偏上的值,比如1或者2。别给0,0留给最紧急的中断比如看门狗或者故障保护。也别给太低,否则被其他中断频繁打断,定时精度会受影响。响应优先级保持默认0就行,它只在抢占优先级相同时才起作用。
3.4 生成工程时的几个关键选项
Project Manager页面里,Toolchain选MDK-ARM或者STM32CubeIDE看你自己习惯。Code Generator标签页里,我强烈建议勾上“Generate peripheral initialization as a pair of .c/.h files”,这样每个外设的初始化代码会单独成文件,工程结构清晰,后面加外设不会全堆在main.c里。
还有“Copy only necessary library files”也勾上,不然工程体积会很大。生成代码后打开工程,先编译一遍确认没报错,再开始写业务逻辑。
4. 代码实现与中断回调的写法
4.1 启动定时器:HAL_TIM_Base_Start_IT
CubeMX生成的代码里,MX_TIM3_Init()已经把定时器配好了,但它不会自动启动。你需要在main函数的初始化区域手动调用启动函数。注意是带_IT后缀的那个:
HAL_TIM_Base_Start_IT(&htim3);这个函数干了两件事:使能定时器计数,同时使能更新中断。如果你只调HAL_TIM_Base_Start,定时器会跑但不会进中断,这个区别要记牢。我见过有人调了半天发现不进中断,最后发现是启动函数用错了。
调用位置放在MX_TIM3_Init()之后、while(1)之前就行。如果你用的是CubeIDE,注意它生成的代码有USER CODE BEGIN和END注释块,你的代码要写在块里面,否则重新生成代码会被覆盖掉。
4.2 重写回调函数:业务逻辑写在这里
回调函数写在main.c里,位置放在USER CODE BEGIN 4和USER CODE END 4之间。函数名必须和HAL库里的弱函数完全一致:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); printf("Timer tick: %d\r\n", tick_count++); } }这里做了两件事:翻转PC13上的LED,串口打印计数。htim->Instance == TIM3这个判断很重要,养成习惯,以后多个定时器共用回调时不会出问题。
printf重定向到串口需要额外写fputc函数,这个在串口那篇讲过,不重复了。如果你不想用printf,直接操作串口发送寄存器也行,但调试阶段printf最方便。
4.3 中断优先级的实际影响测试
配好之后下载运行,你应该能看到LED每秒闪一次,串口每秒打印一个递增的数字。如果LED闪烁频率明显不对,比如快得看不清或者几秒才闪一次,八成是分频参数算错了。
我实测下来,用上面7200和10000这组参数,串口打印的时间戳和秒表对得上,误差在毫秒级。如果你要更精确的定时,可以把ARR调小、预分频调大,让计数频率低一点,这样中断响应延迟带来的误差占比更小。但代价是中断频率变高,CPU开销增大。这是个权衡,看你的项目需求。
5. 常见问题排查与避坑经验
5.1 中断不触发的五种可能
这是新手遇到最多的问题,我按排查优先级列一下:
| 排查项 | 检查方法 | 常见错误 |
|---|---|---|
| 启动函数 | 确认调用了HAL_TIM_Base_Start_IT | 误用HAL_TIM_Base_Start |
| NVIC使能 | CubeMX里勾选TIM3 global interrupt | 忘了勾,或生成后没重新编译 |
| 回调函数名 | 核对拼写和参数类型 | 拼错成PeriodElapsedCallback |
| 回调位置 | 确认在USER CODE块内 | 写在块外被重新生成覆盖 |
| 时钟配置 | 确认TIM3时钟是72MHz | 时钟树没配,跑在默认8MHz |
5.2 定时周期不准的原因分析
如果你发现定时周期和理论值有偏差,先别怀疑芯片,按这个顺序查:
第一,确认外部晶振频率和CubeMX里填的一致。有些板子焊的是8MHz晶振,但CubeMX默认填的也是8MHz,这个一般没错。但如果你用的是内部RC振荡器(HSI),那频率偏差就大了,HSI精度只有1%左右,做精确定时必须用HSE。
第二,检查预分频和ARR有没有减一。前面反复强调了,填7200和10000得到的是7201和10001,周期会偏大一点点。1秒的定时偏差几毫秒你可能看不出来,但做波特率或者PWM的时候就会出问题。
第三,中断响应本身有延迟。从计数器溢出到进中断执行第一条指令,中间有硬件压栈、取向量、跳转的开销,通常是十几个时钟周期。对于1秒的定时这可以忽略,但如果你做微秒级定时,这个延迟就必须算进去。
5.3 多个定时器共用回调的冲突处理
当你同时开了TIM2和TIM3的更新中断,两个都会进HAL_TIM_PeriodElapsedCallback。如果你不判断htim->Instance,TIM2触发时也会执行TIM3的业务代码,LED就会乱闪。
正确的写法是用if-else if或者switch把不同定时器的逻辑分开:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // TIM2的业务 } else if (htim->Instance == TIM3) { // TIM3的业务 } }这个习惯一定要从入门阶段就养成,后面项目复杂了能省很多调试时间。
5.4 中断里能不能放延时和打印
严格来说,中断服务函数里应该尽量短小快,不要放HAL_Delay这种阻塞延时,也不建议放printf这种耗时操作。因为中断执行期间,同优先级和低优先级的中断都被阻塞了,你打印一句话可能几毫秒,这期间其他中断全卡住。
但入门阶段为了看现象,在回调里printf一下问题不大,毕竟1秒才触发一次,占用几毫秒无所谓。等你做实际项目,正确做法是在中断里置个标志位,主循环里检测标志位再处理。这个思路后面讲RTOS的时候会展开。
6. 从定时中断延伸出去的实战玩法
6.1 用定时中断做软件RTC
把定时周期改成1秒,在回调里维护一个结构体记录年月日时分秒,就是一个最简单的软件实时时钟。配合OLED或者串口输出,能做一个桌面时钟。这个项目虽然简单,但涵盖了定时中断、结构体操作、显示驱动三个知识点,适合练手。
要注意的是,软件RTC的精度完全依赖晶振,普通无源晶振一天可能差几秒。要准的话得用带温度补偿的RTC晶振,或者定期用外部时间源校准。
6.2 定时中断触发ADC采样
很多传感器比如NTC热敏电阻、光敏电阻,需要周期性采样。你可以用定时中断每隔100ms触发一次ADC转换,转换完成中断里读结果。这样采样率稳定,不会因为主循环里其他任务耗时导致采样间隔忽长忽短。
配置方法是把TIM3的Trigger Event Selection改成Update Event,然后在ADC配置里把External Trigger Conversion Source设为TIM3 Trigger Out event。这样定时器溢出时硬件自动触发ADC,完全不占CPU。
6.3 定时中断驱动DHT11单总线读取
DHT11这种单总线传感器对时序要求很严,微秒级的延时必须准。用定时中断做时间基准,配合DWT或者NOP延时,能稳定读出温湿度。我实测过,用TIM3做1ms基准,DHT11读取成功率比用HAL_Delay高很多,因为HAL_Delay在中断里会被打断,时序就乱了。
具体做法是在定时中断里维护一个微秒计数器,需要延时的时候读这个计数器做忙等。这样即使主循环被其他中断打断,延时精度也不受影响。
6.4 定时中断配合状态机做按键消抖
按键消抖传统做法是延时20ms再读,但这样阻塞主循环。更好的方式是用定时中断每10ms扫描一次按键,连续三次读到相同状态才确认。这样消抖不占主循环时间,还能同时处理多个按键。
这个思路其实就是状态机的雏形。你在回调里维护每个按键的状态变量,主循环只管读最终结果。代码量不大,但思维方式的转变很重要,是从“阻塞式编程”到“事件驱动编程”的跨越。
7. 我踩过的坑和给你的建议
第一个坑是CubeMX版本差异。不同版本的CubeMX在TIM3配置界面上选项位置可能不一样,比如auto-reload preload这个选项,有的版本叫ARPE,有的版本藏在高级设置里。遇到找不到的选项,先确认你的CubeMX版本,然后查对应版本的文档。别照着老教程死磕,工具在更新,教程不一定跟得上。
第二个坑是HAL库版本。F1系列的HAL库和F4系列的HAL库在定时器回调函数名上是一致的,但有些细节不同。比如F4的HAL库在HAL_TIM_Base_Start_IT里会检查状态,如果定时器已经启动了会返回错误。F1的库就没这个检查。跨系列移植代码的时候要注意这些差异。
第三个坑是中断优先级分组。CubeMX默认用的是NVIC_PRIORITYGROUP_4,也就是4位全给抢占优先级,响应优先级没有。如果你后面要加FreeRTOS,它要求用GROUP_4,这个默认值刚好合适。但如果你用标准库的习惯去配,可能会设成GROUP_2,那就和RTOS冲突了。入门阶段就用默认的GROUP_4,别乱改。
第四个坑是调试时打断点。在回调函数里打断点,程序停住的时候定时器还在跑,中断标志会累积。你继续运行的时候可能会连续进好几次中断,现象就乱了。调试定时器相关代码,建议用串口打印或者翻转IO接示波器,少用断点。
最后说个心得:定时器这东西,看十遍不如动手配一遍。你把预分频和ARR改几个值,观察LED闪烁频率的变化,比看任何教程都管用。我当初就是反复改参数、用示波器量波形,才真正把定时器时钟树的关系搞明白的。现在网上资源很多,但嵌入式这东西,手感和直觉只能靠实操喂出来。