搞了好几年的STM32,我一直觉得定时器是最容易“自以为懂了”的外设。PWM能输出、中断能触发、捕获能测频,看起来都正常。可一旦你追问一句“定时器现在数的是哪个时钟的脉冲”,不少人都得愣一下。更麻烦的是,当你把同样的配置从F103挪到F407,或者把系统主频从72MHz拉到168MHz,定时器的时间突然全变了,甚至串口波特率都跟着跑偏——问题往往就出在你没搞清楚时间基准到底从哪来。
这篇文章我就把这条链路彻底捋一遍。从时钟树怎么把晶振的频率送到定时器,到预分频器和自动重装载寄存器到底在干什么,再聊到抖动的来源、外部时钟模式、SysTick的分工。写这些不是给你背寄存器,而是让下一次你配置定时器时,能自己在心里把“频率→分频→计数值→时间”这条换算路径画出来。适合刚接触STM32定时器、被PWM或者定时中断搞晕过的朋友,也适合想从HAL库的封装里跳出来、真正理解硬件逻辑的人。
1. 时间基准的源头:时钟树与RCC,先于定时器存在的第一条路
定时器本身没有任何“时间概念”。它只是一个不断累加的计数器,每一个计数脉冲到来,它就加一。所以真正决定“时间走得准不准”的,是喂给它脉冲的那个源头。这个源头不是某个神秘的系统心跳,而是你在RCC(Reset and Clock Control,复位与时钟控制)里配置的那棵时钟树。
很多人用STM32CubeMX生成工程之后,只在图形界面里选中“TIM3”然后填一个预分频值,从没打开过Clock Configuration页签看看APB1总线旁边那个不起眼的“×2”选项。实际上,这个选项就是定时器时间基准的第一个决定性节点。
1.1 时钟树从哪条支路走到定时器
以最常见的STM32F103为例。外部高速晶振(HSE,通常是8MHz)经过PLL倍频,最高能把系统时钟SYSCLK拉到72MHz。SYSCLK向下分发,一路给Cortex-M3内核,一路经过AHB预分频器,再向下分成APB1和APB2两条总线。这里有个关键设置:APB1的最高运行频率被限制在36MHz,所以当SYSCLK是72MHz时,APB1预分频器必须配置为2分频,APB1外设(包括USART2/3、I2C、以及TIM2~TIM7这些通用定时器)得到的就是36MHz。
但注意,定时器有个特别的设计:当APB1预分频系数不是1时,TIMx的时钟会自动变成APB1时钟的2倍。也就是说,虽然挂在APB1上的串口拿到的是36MHz,但TIM2~TIM7这些定时器的时钟却是36MHz×2=72MHz。这个“倍频”是芯片内部硬接线的结果,不是在某个寄存器里写的“×2”开关,但它在重装定时器时必须算进去。
这就是为什么同样一套定时器初始化代码,改一下总线时钟就全部失效。你以为TIM3的时钟是36MHz,实际它拿的是72MHz,最后算出来的溢出时间少了一半。
1.2 从寄存器视角看时钟配置:HAL库帮你做了什么
使用HAL库时,你看到的往往是这样的代码:
RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; HAL_RCC_OscConfig(&RCC_OscInitStruct);这段配置的含义是“8MHz HSE晶振,PLL倍频9倍,得到72MHz SYSCLK”。然后还有一段:
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;AHB不分频,HCLK为72MHz;APB1二分频,PCLK1为36MHz;APB2不分频,PCLK2为72MHz。这时你如果去查参考手册的时钟树图,顺着APB1再往前走两步,就能看到“TIMxCLK = PCLK1 × 2 = 72MHz”的标注。所以,TIM2~TIM7在F103上的默认定时器时钟就是72MHz。
1.3 我踩过的坑:以为把PSC改成7199就能得到1ms,结果频率翻倍
早期我用标准外设库做项目的时候,直接抄了一段别人的定时器初始化,把预分频寄存器设为7199,自动重装载值设为999,然后开定时器中断。理论计算:
- 定时器时钟:72MHz
- 预分频后:72MHz / 7200 = 10kHz
- 重装载999次后溢出:10kHz / 1000 = 10Hz,周期100ms
但实测中断周期是50ms。查了半天才发现,那段代码的时钟配置里SYSCLK是36MHz,APB1预分频器是1分频(不分频),此时APB1上的定时器时钟就是36MHz,没有2倍频。而我的计算却用了72MHz,自然差了一倍。
这里有个非常重要的结论:定时器时钟 = PCLK1或PCLK2,只有在APB预分频系数为1时才成立;预分频系数大于1时,定时器时钟翻倍。每个型号在参考手册的“Clock tree”章节都有明确标注,中文版手册一般写成“如果APBx预分频器为1,定时器时钟频率等于APBx的频率;否则为APBx频率的2倍”。这不是只在F103上有效,F4、F7、L4也都是这个规律。
2. 计数器在数什么:预分频器与自动重装载寄存器
搞清楚频率从哪来之后,就该看定时器内部怎么处理这些脉冲了。很多人把PSC和ARR当成两个“随手填的参数”,只用CubeMX自动生成。但实际调试时,PWM频率不对、中断周期偏大偏小,都得靠手算这几行字。这块值得认真讲透。
2.1 预分频器:不是让你数得慢点,而是让每个计数脉冲对应一个更宏观的时间单元
假如定时器时钟是72MHz,也就是每秒进来72,000,000个脉冲。如果直接把脉冲送进计数器,计数器每加一代表的时间是1/72MHz ≈ 13.89纳秒。这个分辨率很精细,但大多数应用根本不需要这么细。你做一个LED闪烁或者电机速度环,根本没必要让计数器每13.89纳秒动一次,因为你的处理频率根本跟不上。
预分频器的作用就是在计数器前面加一道“减速闸门”。它由PSC寄存器控制,实际分频系数是PSC+1。为什么是PSC+1?因为PSC写0时,预分频器不分频,脉冲一对一通过。PSC写71时,每72个输入脉冲才输出1个计数脉冲,计数器每加一代表1微秒。
这个“+1”的细节特别容易在初学阶段把人坑到。我见过有人想得到1kHz的计数频率,输入72MHz直接写PSC=72000,结果算上+1后是72MHz/72001,差了十万八千里。正确的算法是PSC=72000-1。
2.2 自动重装载:数到头就回零,顺便给你一个中断
计数器CNT从0开始加,每来一个计数脉冲就+1。当CNT等于自动重装载寄存器ARR里的值时,这一轮计数结束。下一轮回到0重新开始。这个“回零”的动作在递增计数模式下就叫溢出(Update事件),溢出时可以触发中断,也可以触发DMA,还能让PWM输出引脚自动翻转电平。
所以一个完整的溢出周期是:
溢出时间 = (PSC + 1) × (ARR + 1) / 定时器时钟频率
注意,ARR这里也要+1。因为计数器是从0开始数的,ARR=999时实际数了0到999共1000个脉冲。这一点和PSC的逻辑一模一样。
举个例子:定时器时钟72MHz,想要一个1ms周期的定时中断。
TIM_HandleTypeDef htim3; htim3.Instance = TIM3; htim3.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz,计数脉冲周期1us htim3.Init.Period = 1000 - 1; // 每1000个脉冲溢出一次,周期1ms htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim3);配上HAL库的初始化函数,中断周期正好是1ms。这里PSC=71得到的是1MHz的计数频率,每微秒一个脉冲;ARR=999得到1000微秒即1ms的溢出周期。整个计算的唯一难点,就是别忘记寄存器值都要减1。
2.3 PWM频率怎么算:同样的公式换个说法
用到PWM输出时,同一个公式换个视角就变成了:
PWM频率 = 定时器时钟 / ((PSC + 1) × (ARR + 1))
占空比则由比较寄存器CCR决定。CNT小于CCR时输出高,大于等于CCR时输出低。比如定时器时钟72MHz,PSC=71,ARR=999,那么PWM频率是72MHz/72000=1kHz,CCR=500时占空比50%。这也是网上那么多“1kHz的PWM怎么配置”的答案里,为什么清一色PSC=71、ARR=999的原因。
不过真到了做电机控制或者调光场景,你会发现光调PSC和ARR还不够。那时你可能需要16位定时器装不下的大分频范围,或者要更高分辨率。16位定时器的PSC和ARR都只有16位,最大值65535。如果你想要一个超低频率的PWM,比如0.1Hz,那PSC×ARR必须等于720,000,两个16位数的乘积最大能到42亿,这倒不是问题。真正的问题是占空比分辨率——ARR越大,相邻占空比档位之间的间隔越细。想让LED调光细腻,ARR就不能太小。
3. 计数并非精确均匀:抖动的来源与校准思路
分配好时钟、填好PSC和ARR,定时器的“理论时间”就有了。但很多人在示波器上实测时会发现,PWM波形频率是对的,但定时中断的间隔偶尔会跳一下;高精度测频场景下,相邻两个周期之间会有几个纳秒到微秒级的抖动。这不是你配置错了,而是定时器计数这个“绝对均匀”的模型,在真实系统里做不到。
3.1 中断延迟:最容易被忽视的抖动源
定时器溢出后,硬件把更新事件标志位置1、触发中断请求,这个过程是硬件同步的,几乎不耗时间。但从“中断被触发”到你的ISR(中断服务函数)真正跑起来,中间隔着一大段不可控延迟。要等当前指令执行完、压栈、跳转到中断向量表,再进入HAL_TIM_PeriodElapsedCallback回调。如果主循环正在跑一个耗时操作,或者另一个更高优先级的中断正在处理,这个延迟还会更长。
这就意味着,你在中断里做的“定时时间到了该干活了”这个动作,和实际溢出时刻之间,存在一个几十到几百周期不等的延迟。如果你在中断服务里读CNT寄存器,会发现计数器已经跑过好几千个数了。我曾经用逻辑分析仪抓过一个100Hz定时中断的任务,ISR里翻转一个GPIO,结果实测翻转间隔在9.95ms到10.08ms之间波动,平均是10ms,但每一次都不一样。这就是实时系统的正常表现——中断不是无缝的定时器,它天生带有调度抖动。
3.2 硬件层面的抖动:计数脉冲本身也不是绝对均匀
你可能会想,外部晶振的8MHz经过PLL倍频到72MHz,频率精度应该很高吧?确实,石英晶振的频率精度能做到±20ppm甚至更好,温漂也小。但HSE的起振和PLL锁相环都有一个稳定过程,上电初期频率可能有微小偏移;系统内部还有RC震荡器(HSI)可供选择,它的精度就差很多了,尤其随温度变化能偏出几个百分比。
所以如果你看到同一个定时器在不同温度下实测频率有细微变化,不要奇怪。标称“72MHz”只是设计值,实际频率取决于晶振本身、负载电容的匹配程度、PCB布局等。对一般应用来说这完全够用,但在做高精度计时、需要和其他设备长时间同步的场合,就要考虑是不是该用外部更精确的时钟源,或者预留软件校淮。
3.3 如何在实际项目里校准定时器的基准
校准的思路很简单:拿一个你信得过的参考源,让定时器累计跑一段时间,数出误差比例,然后把PSC或ARR乘以一个修正系数。
比如我用一个GPS模块的1PPS脉冲做过校准。1PPS每秒一个上升沿,精度来自卫星原子钟,非常可靠。让定时器工作在外部时钟模式,测两段1PPS之间的计数值,理想情况下应该正好等于1秒对应的脉冲数。实测下来发现少了30多个脉冲,说明实际频率比理论值低约0.0003%。然后把ARR的上限乘以0.999997修正,误差就被拉平了。
这样做有个前提,你的定时中断应用要能接受ARR不是整数。幸好ARR本来就是整数,直接把修正后的值四舍五入取整,长期计时误差就能控制在很小范围内。
4. 除了内部时钟,定时器还能从引脚“借时间”
前面说的都是定时器吃内部的PCLK。但其实STM32的定时器还支持外部时钟模式,也就是说,喂给计数器的脉冲可以从引脚进来。很多玩法因此变得简单:测外部方波频率、判断两路信号的时间差、统计脉冲个数。
这也是“STM32定时器捕获测频率”这类问题背后的底层机制。用输入捕获也行,但纯计数场景下,外部时钟模式往往更直接、更省CPU。
4.1 外部时钟模式1:让引脚上跳沿自己来触发计数
以TIM3为例,它的通道1引脚PA6可以作为外部时钟输入。配置成外部时钟模式1后,每次引脚上出现一个上升沿,计数器就加一。计数器值除以时间,就能得到频率。
这里有个容易踩的细节:外部时钟模式下,输入信号要先经过一个同步电路,避免亚稳态。芯片内部会自动做采样,通常要求输入信号的宽度大于定时器时钟的两个周期。如果被测信号频率太高或者脉冲太窄,可能出现漏计数。
实际使用时还要注意引脚是否有上下拉电阻、输入电平是否匹配。外部信号如果是开漏输出,最好配置内部上拉;如果是差分配对,注意共模电压范围。
4.2 外部时钟模式2:ETR引脚的硬件滤波与分频
外部时钟模式2走的是ETR引脚,硬件上多了一个可编程滤波器和分频器。输入信号先经过一个数字滤波器,连续采样到N个相同的电平才认为有效,可以滤掉毛刺。再经过预分频器,可选1、2、4、8分频。
这在测速编码器场景里很有用。编码器的A/B相输出频率很高,直接进计数器可能因为信号毛刺导致多计数。开启滤波和分频后,稳健性会好很多。滤波器的采样频率由定时器内部时钟决定,采样频率越高,滤掉的毛刺越窄,但对窄脉冲信号的通过性也越好。
4.3 从外部时钟模式到输入捕获:测频率的两种思路对比
很多人会纠结,测频率到底用外部时钟模式还是输入捕获。我的经验是按需求分:
- 外部时钟模式适合“连续测频”,计数器时钟由外部信号提供,你只需要定时读取CNT寄存器的差值,就能推算出频率。因为计数脉冲是硬件的,不需要CPU频繁介入,鲁棒性高。
- 输入捕获适合“测单个周期”或者“测脉宽”。它记录的是某个边沿来临时CNT寄存器的值,前后两次相减得到这个信号的周期计数值,再除以定时器时钟就能得到时间。
两种思路在实际项目里我都用过。测电机转速用外部时钟模式更省事;测遥控接收头的脉宽,则必须用输入捕获。选哪种,取决于你要的是连续平均频率,还是单次周期精度。
5. SysTick、通用定时器与时间基准的分工协作
聊到时间基准,还有一个绕不开的角色:SysTick,系统嘀嗒定时器。它是Cortex-M内核自带的简单定时器,不是STM32外设。很多人学完通用定时器后,看到HAL库里到处用HAL_GetTick(),会疑惑:这货和TIM到底是啥关系?为什么叫“时间基准”,而不是直接用TIM?
SysTick的本质也是一个递减计数器,和TIM最大的区别是:它专属于内核,和芯片厂商无关。不管你是STM32、GD32还是NXP的MCU,只要用Cortex-M内核,都带一个SysTick。所以RTOS和HAL库都拿它做统一的“心跳”:每毫秒中断一次,维护一个全局的tick计数。
5.1 SysTick和通用定时器的时间基准不是一回事
SysTick做的是“绝对时间”,或者说“墙上时间”的近似值。它每毫秒递增一次,程序里随时可以调用HAL_GetTick()拿到“上电后过了多少毫秒”。这个时间基准是系统的全局标尺。
通用定时器TIM做的则是“事件时间”,它更关注“到这个时刻要发生什么”,输出PWM、捕获外部信号、触发ADC转换。TIM的周期可以设成1微秒、1秒、甚至1分钟,而SysTick通常只老老实实做1ms。
两者经常协同工作。比如HAL库的HAL_Delay(),实现原理就是不断读SysTick的毫秒计数,判断有没有走到目标时间。而一个电机控制任务,60度换向或者PID周期控制,则由TIM的溢出中断来推动。
5.2 用SysTick做软件延时的注意事项
网上很多函数用SysTick做阻塞延时,直接操作SysTick的LOAD寄存器,示例代码是这样的:
void delay_us(uint32_t nus) { uint32_t ticks = nus * (SystemCoreClock / 1000000); SysTick->LOAD = ticks - 1; SysTick->VAL = 0; SysTick->CTRL = SysTick_CTRL_ENABLE_Msk | SysTick_CTRL_CLKSOURCE_Msk; while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)); SysTick->CTRL = 0; }这段代码看着简单,但如果你在RTOS环境或者中断里调用,问题很大。SysTick标志位和HAL的tick计数器共用同一资源,你把LOAD改了再清零VAL,全局的tick时间会跳变,HAL_Delay和不少依赖tick计数的组件可能直接乱掉。
这种延时函数我早期写过,后来凡是沾RTOS的项目一律不用SysTick做阻塞延时。要么用DWT(数据观察点与跟踪单元)的CYCCNT计数器,要么干脆用TIM做单次计时。SysTick还是老老实实作为全局时基最稳妥。
5.3 深度优先级问题:TIM中断和SysTick中断打架
TIM溢出中断和SysTick中断的优先级设置也值得聊一句。SysTick的中断优先级在HAL库初始化时被设为一个固定值,很多情况下是最高优先级或者次高优先级。当TIM中断触发后进入ISR,如果此时SysTick也触发,会发生嵌套抢占,SysTick抢先执行,TIM的ISR被临时挂起。
如果你在TIM的ISR里做时间敏感操作,比如从编码器读取计数值,或者对PWM波形的特定相位做处理,被SysTick打断的这段时间就可能引入误差。处理办法是视情况调整优先级,但这个不是无脑调。SysTick优先级越高,HAL_GetTick()越不容易因为别的中断卡顿,延时越准;TIM优先级越高,PWM或捕获操作越少被打断。两者只能按项目的核心时序需求取舍。我在一个无感电机控制项目里,把TIM中断优先级提到SysTick之前,换来的是换向时刻更稳定,代价是HAL_Delay在极端负载下偶尔会偏慢几百微秒——因为控制任务本来也不依赖HAL_Delay,所以值得。
6. 实践中的几个高频问题与排查方法
最后把平时群里、论坛上总被问到的几个问题集中起来,做一个总结性梳理。这些问题的根子几乎都在于时间基准没搞清楚。
6.1 为什么改了主频之后,定时器中断和PWM全乱了
这是最常见的排查现场。你用CubeMX把系统时钟从72MHz改成168MHz,或者把HSE从8M改成25M,然后发现自己定时器的时间全变了。
原因其实就一句话:定时器时钟跟随总线时钟变化了。改主频时APB1/APB2的预分频值也被CubeMX自动改了,定时器的PCLK倍数也随之变化。你在TIM配置里填的PSC和ARR是给原频率算的,频率一变,时间自然跟着变。
解决办法不是“把PSC和ARR改成一样的比例”,而是先明确目标时间,比如固定要1ms,然后用新的定时器时钟反推PSC和ARR。更稳妥的做法是先在CubeMX的Clock Configuration页签里确认APB1/APB2频率,再回头填TIM参数。还有别忘了我前面提过的APB预分频不为1时定时器时钟翻倍的情况。
6.2 定时器不工作/不进中断,首先查哪个寄存器
如果定时器完全没反应,直接进调试器查几个关键位:
- CTRL寄存器的CEN位(bit0):定时器是否使能
- 时钟使能寄存器里对应位:比如APB1ENR里的TIM3EN,这个位没置1,定时器根本没时钟,寄存器写什么都没反应
- SR寄存器的UIF位:有没有溢出标志,如果UIF被置位但就是没进中断,检查NVIC的对应通道有没有使能
这三个查完,90%的问题都定位了。剩下的10%集中在GPIO复用上,PWM输出没波形,先看引脚是不是被配置成analog mode或者根本没有锁定到复用功能。对STM32来说,PWM输出不等于简单地在代码里置IO高电平,那个引脚必须配置为AF(Alternate Function)模式,并选择到对应定时器通道。
6.3 为什么定时器的值在调试器里狂跳,看着像没配好
用调试器在线查看CNT寄存器的值时,经常看到CNT一直在变:从0加到满、溢出回0、再加。很多人以为这是Bug,其实CNT本来就在跑,它变就对了。定时器一旦使能,硬件就持续计数,不会因为调试器打开寄存器窗口就停止。
需要注意的是,如果你在调试模式下暂停程序,CNT是否会继续增长取决于DBGMCU寄存器里的配置。STM32默认情况下,暂停在调试器时定时器是会停的。但如果你关掉了DBGMCU里的冻结控制,或者用一些非标准调试方式,定时器可能在你暂停程序时仍然继续走,这会让单步调试时的逻辑分析结果失真。
真正调试定时器逻辑时,建议给它设一个比较小的ARR值,比如100,这样溢出一轮速度很快,调试不会等太久。如果ARR设成65535,你会看着一个数从0慢慢爬到65535再回0,等得人发慌。
6.4 一个多次复用的排查小抄:符号、值、含义
把定时器没反应时最常看的几个寄存器整理成一个表,方便将来对照。
- CR1: 定时器控制寄存器1,CEN位是总开关,UDIS位禁用更新事件,URS位选择更新事件来源
- DIER: 中断使能寄存器,UIE位使能更新中断
- SR: 状态寄存器,UIF位是更新中断标志
- CNT: 计数器当前值,它会一直变
- PSC: 预分频器值,写的是实际分频系数减一
- ARR: 自动重装载值,写的是目标计数值减一
- CCRx: 比较/捕获寄存器,PWM占空比或捕获值
- NVIC: 中断控制器,对应定时器通道使能
- GPIO配置:引脚复用功能选择,对应AFR寄存器
如果你在调试中看到CNT不变、UIF也不置位,先查CEN有没有置位、时钟树配置有没有问题。如果UIF置位但中断不触发,查DIER里的UIE和NVIC——这两个经常被忘掉。
7. 最后一个实际体会:把“时间基准”当成一个从晶振到中断的流水线
写到这里,我觉得最值得留下的不是某个具体寄存器怎么配,而是一套思考方式。拿到一块新的STM32,第一件事永远是看时钟树配好了没有,而不是急着调外设。定时器所有的时间表现,都是这棵时钟树决定的。时钟树的频率变了,定时器的一切都会跟着变。
早期我做项目,碰到定时器问题就到处搜代码,改PSC、改ARR、使能NVIC,碰运气成分很大。后来开始养成习惯:每个定时器初始化之前,先在纸上把“晶振频率→PLL倍频→AHB分频→APB分频→定时器时钟×2与否→PSC+1→ARR+1→溢出周期”这条链路完整算一遍。算完再写代码,一次成功率明显上升,调试时间也大大缩短。
个人经验还有一个细节想多说一句:不管用标准外设库、HAL库还是LL库,定时器的时间公式永远是同一个。库只是帮你包装了寄存器操作,它不会替你改数学。所以哪怕你某天换到GD32、AT32甚至其他Cortex-M芯片,这套“找到时钟源、沿分频链路逐级计算”的方法依然适用。定时器的时间基准,本质就是一条从晶振到计数器之间脉冲传输的流水线,理解这条流水线上的每个节点,比背一百个寄存器都管用。