一个挺有意思的现象:很多人用 STM32 定时器做 1ms 中断、做 PWM、做输入捕获,配置流程背得滚瓜烂熟,但你要是突然问一句"你的定时器到底在数什么",他反而会愣住。我之前带过几个新人,遇到定时时间不准、周期漂移、甚至延时卡死,排查到最后基本都是同一个根源——没搞懂定时器的"时间基准"到底是从哪条路来的。这篇我就把这个问题彻底拆开:从时钟树到预分频,从 SysTick 到输入捕获,再到低功耗下的 LPTIM,把 STM32 定时器的时间基准这条线捋顺。看完你不仅能算准每一次溢出时间,还能自己排查那些让人抓狂的定时器怪毛病。适合刚开始接触 STM32 定时器的同学,也适合写了不少代码但一直没深究时钟路径的朋友。
1. 定时器并不认识"秒",它只认识脉冲
1.1 计数器的本质:数脉冲边沿
打开 STM32 参考手册,定时器那一章开头都会写一句话:定时器是一个 16 位(或 32 位)的计数器。注意"计数器"这个词,它其实完全不理解什么是毫秒、什么是微秒。它唯一做的事就是:每来一个脉冲,计数器寄存器 CNT 就加 1(或者减 1,或者先加后减)。所以"定时器到底在数什么"这个问题的第一层答案,就是在数脉冲的边沿。
那脉冲从哪来?这就是"时间基准"的核心。如果脉冲的频率是稳定的、已知的,那我们就能用"数了多少个脉冲"反推出"过了多少时间"。比如 72MHz 的脉冲,每秒钟来 72000000 个边沿,计数 7200 个就是 0.1ms。所以整个定时器定时的精度,完全取决于输入时钟源的稳定性。
这块儿经常有个误解:以为定时器的时间基准就是系统主频,比如 STM32F103 跑到 72MHz,就认为定时器时钟也是 72MHz。这个想法在 F103 上碰巧大多数时候是对的,但在很多其他型号上会栽跟头。下一节细说。
1.2 时钟树:定时器时钟可能比外设总线还要快
每一颗 STM32 芯片内部都有一棵复杂的时钟树。外部晶振(HSE)或者内部 RC(HSI)先作为源头,经过锁相环 PLL 倍频,得到系统时钟 SYSCLK,再经过 AHB 预分频器分给各个总线。定时器挂在哪条总线上呢?通常挂在 APB1 或 APB2 总线上。但这里有一个 STM32 特有的"福利":当 APBx 预分频值不为 1 时,定时器时钟不是 APBx 的时钟,而是 APBx 时钟的 2 倍。
拿最经典的 STM32F103C8T6 举例:系统时钟 72MHz,APB1 预分频为 2,所以 APB1 外设时钟是 36MHz。但挂在 APB1 上的 TIM2、TIM3、TIM4、TIM5 等,它们的时钟不是 36MHz,而是 36MHz×2=72MHz。同理,APB2 预分频如果不为 1,挂在 APB2 上的 TIM1、TIM8 也是 72MHz。为什么要有这个"2 倍补回来"的设计?简单说,这是芯片设计时为了给定时器一个不低于系统时钟的节拍,宁可多给一个倍频器。如果你在配置 CubeMX 时手动改过 APB1 分频,就需要格外注意:定时器时钟会和外设总线时钟差一倍。
到了 STM32F4 系列,这个规则同样存在,但上限变了。比如 STM32F407 在系统时钟 168MHz 下,APB1 定时器时钟最高 84MHz,APB2 定时器时钟最高 168MHz。你如果照搬 F103 的配置,把 TIM2 的时钟当成 168MHz 去算,时间就会差一倍。
实操中怎么确认定时器时钟?最稳的办法是看 CubeMX 生成的代码。初始化定时器时,HAL_TIM_Base_Init 里会调 HAL_RCC_GetPCLK1Freq() 或 HAL_RCC_GetPCLK2Freq(),如果是那类"分频不为 1 则乘 2"的芯片,HAL 库底层会自动帮你把倍频算进去。所以很多时候你不需要手动乘,但必须知道 HAL 库帮你做了这个事,否则你打印出来看时钟频率时会一头雾水。
提示:改 APB 预分频之后,一定要去重新核对定时器时钟。以前我在一个项目里把 APB1 从 /2 改成 /4 去降功耗,结果定时器 1ms 中断变成了 2ms,整套逻辑全部错位。查了一个晚上才发现不是代码问题,是时钟树被自己改了。
2. 预分频器和自动重装载:怎么把高频脉冲变成你要的秒
2.1 PSC、ARR、CNT 三者的关系
搞清楚了时钟来源,接下来就是定时器内部怎么把"高频脉冲"变成"慢悠悠的时间"。这就要说到三个寄存器:预分频器 PSC、计数器 CNT、自动重装载寄存器 ARR。
可以把它类比成一个齿轮组。外部输入的高频脉冲先经过 PSC 这个"减速齿轮":每来(PSC+1)个原始脉冲,计数器 CNT 才走一步。为什么是 PSC+1?因为 PSC=0 时不分频,输入 72MHz,CNT 每 1/72MHz 秒加一次;PSC=71 时,72MHz ÷ 72 = 1MHz,CNT 每秒走一百万步。然后 CNT 从 0 一直走到 ARR,走满后触发一次更新事件(overflow),CNT 归零重新开始。所以一次溢出的时间就是:
溢出时间 = (PSC+1) × (ARR+1) / 定时器时钟频率
注意 ARR 也要加 1,因为 CNT 是从 0 数到 ARR,总共是 ARR+1 个数。比如 ARR=999,那就是数了 1000 下才溢出。
这个公式是定时器一切应用的基础。做延时、做 PWM、做软定时器,全都在用它。忘记这条公式的人,配置出来的定时器时间几乎肯定不准。
2.2 一个 1ms 定时中断的完整演算
我们手算一个 F103 上最常见的需求:TIM3,定时器时钟 72MHz,想要 1ms 溢出一次。
先把 1ms 换算成脉冲数:72MHz × 1ms = 72000 个时钟脉冲。那怎么拆成 PSC 和 ARR?
一种经典的拆法:PSC = 71,这样定时器计数频率 = 72MHz ÷ 72 = 1MHz,也就是每 1 微秒计 1。然后 ARR = 999,那么溢出周期 = 1000 × 1μs = 1ms。两个数凑一起,完美。
另一种拆法:PSC = 719,计数频率变成 100kHz,ARR = 99,同样 100 × 10μs = 1ms。所以你会发现 PSC 和 ARR 的组合并不唯一。但在实际工程里,一般先定计数分辨率(也就是 PSC 决定的"每一格是多少时间"),再定 ARR 决定总周期。比如做电机控制,通常把计数频率定在 1MHz 或更高,保证 PWM 和捕获的分辨率足够。
上面是向上计数模式。STM32 定时器还有向下计数(CNT 从 ARR 倒数到 0)和中心对齐模式(先向上再向下)。中心对齐模式下,溢出事件发生在顶部和底部各一次,计算周期时要小心,同一个 ARR 下,更新中断频率会翻倍。这一点在电机 FOC 中尤其容易踩坑——你以为是 20kHz 的 PWM 中断,结果中心对齐模式上下各触发一次,变成 40kHz,CPU 开销直接翻倍。
2.3 实操经验:不要直接裸写 PSC/ARR 常数
我看到很多初学者写定时器初始化时,直接htim.Init.Prescaler = 359;、htim.Init.Period = 999;这种魔法数字,也不注释是给哪个时钟频率用的。如果以后换芯片或者改时钟树,这些数字全都要重新算一遍,而且极易算错。
建议在代码里写成这样:
#define TIM_CLK_HZ 72000000UL #define TIME_US 1000UL // 需要定时 1000us = 1ms uint32_t psc = TIM_CLK_HZ / 1000000UL - 1; // 计数频率 1MHz uint32_t arr = TIME_US - 1; // 计数 1000 次 htim.Init.Prescaler = psc; htim.Init.Period = arr;这样改定时时间只需要改 TIME_US,改时钟频率只需要改 TIM_CLK_HZ,其他人看代码也一目了然。做产品时,我还会单独加一个TIM_CLK_HZ宏定义,并且写上注释"来源:CubeMX 时钟树 / APB1×2",防止几个月后自己都忘了这个数怎么来的。
注意:不要以为配置了
Prescaler寄存器,PSC 立刻生效。预分频器是实际运行时才生效的——你在初始化时写入 PSC,但计数器的时钟切换是在发生更新事件之后才完成。HAL 库的HAL_TIM_Base_Init里已经处理好了这一层,但你用寄存器直接写的时候,要记得先让定时器产生一次更新事件,让影子寄存器把新值装进去。
3. SysTick:那份"看不见的时间基准"为什么有时会卡死
3.1 系统滴答和通用定时器的分工
除了 TIM1~TIM8 这类通用/高级定时器,STM32 里还有一个非常特殊的时间基准——SysTick,滴答定时器。它是 Cortex-M 内核自带的 24 位递减计数器,不是芯片厂商外设。作用就是给操作系统(RTOS)和 HAL 库提供一个周期性的"心跳"。
在 HAL 库工程里,HAL_Delay()依赖的就是 SysTick 的中断,默认 1ms 一次,每次中断里把系统时钟变量uwTick加 1。HAL_GetTick()拿到的就是这数。所以如果你发现这个变量走得不对,先不要怀疑定时器,去查 SysTick 的中断是否正常触发。
SysTick 和通用定时器最大的区别在于:它不经历 PSC 和 ARR 这套"齿轮组",而是用一个 24 位的重装载值 LOAD,每来一个内核时钟脉冲就减 1,减到 0 就触发中断并自动重装。它省事,但也限制了精度——你很难用 SysTick 做高精度的 PWM 或者捕捉外部波形,这些东西还是得交给 TIM。
3.2 延时卡死的根因:SysTick 中断被"饿死"了
热词里有"stm32延时函数delay卡死",这个问题我遇到过太多次。大部分情况不是 SysTick 坏了,而是 SysTick 中断优先级被设置得太低,或者干脆被更高优先级的长中断卡住,导致uwTick长时间不更新,HAL_Delay死循环一直出不来。
比如你在一个外部中断回调里调用了HAL_Delay(10),而外部中断的优先级比 SysTick 高。如果这个外部中断服务函数里又有长时间阻塞的操作,SysTick 中断永远无法得到执行,uwTick停止增长,HAL_Delay的 while 循环自然永远等不到目标时间。这就叫"中断互相踩脚"。
解决思路有两条:
- 第一条,只在主循环或优先级较低、耗时短的中断里调用
HAL_Delay,不要在硬中断里睡大觉。 - 第二条,精确配置中断优先级:SysTick 优先级要高于那些你会在中断里调
HAL_Delay的外设中断,至少不能低于它们。
如果你用的是 RTOS(比如 FreeRTOS),SysTick 会被操作系统接管,HAL_Delay就可能失效。很多移植了 RTOS 的同学说"我的HAL_Delay突然卡死",多半就是这个原因。换成操作系统的vTaskDelay就好。
3.3 为什么系统里需要"多个时间基准"
系统里其实经常同时存在好几套时间基准:SysTick 给系统调度用,TIM2 可能给某个传感器做精确延时,TIM3 在做 PWM,TIM4 做编码器计数。它们各有各的时钟源、各有各的分频,彼此独立。
这样设计的好处是:就算 SysTick 被 RTOS 占用了,你的 TIM2 依然能用自己独立的时钟跑,不会受影响。坏处则是:调试时你需要时刻清楚当前这一段代码消耗的是哪个定时器的时间。我见过有同事把 TIM2 的更新中断当成系统时基来维护一个"100ms 调度表",后来系统加入低功耗 Stop 模式,TIM2 直接停止计数,那个调度表就彻底乱了。这就是因为 TIM2 在低功耗模式下没有时钟输入,和 SysTick 完全不同。
所以设计早期就应该确定:系统级时基用 SysTick 或专门一个定时器(不少人称为tick_timer),外设功能用各自的 TIM,不要混用。宁可多配一个定时器,也别把两个功能强行压到同一个定时器上,后面调起来会疯。
4. 输入捕获:怎么用定时器去"量"外部信号的频率
4.1 捕获的本质:在某个瞬间把 CNT 快照下来
前面讲的都是定时器自己闷头数脉冲。但定时器还有另一个角色:监听外面来的信号。这就是输入捕获模式。
原理其实很简单:当外部引脚出现设定的边沿(上升沿或下降沿)时,硬件会自动把计数器 CNT 的当前值复制到捕获/比较寄存器(CCR),同时触发中断(如果开启)。这时你在中断里读 CCR,拿到的就是"这一瞬间 CNT 的刻度"。
那怎么测一个方波的频率?经典做法是测两个相邻上升沿之间的距离。假设定时器计数频率是 1MHz,外部信号第一个上升沿时捕获 CCR1=1000,第二个上升沿时 CCR2=2000,那这两个上升沿之间走过 1000 个计数,每个计数 1μs,于是信号周期 = 1000μs,频率 = 1kHz。
我们把这个过程写成公式:
信号周期 = (CCR2 - CCR1) / 计数频率
信号频率 = 1 / 信号周期
如果 CCR2 比 CCR1 小,说明计数器中途溢出过了(CNT 从 65535 跳回 0),这时要在差值上加上溢出次数 × (ARR+1)。工程上做稳一点,会在更新中断里维护一个overflow_count。
4.2 一个完整的频率计实例:从配置到计算
我们用 TIM2 的通道 1(PA0)做输入捕获,目标测一个 10Hz~100kHz 范围的方波。分几步走:
第一步,配置计数器时钟和溢出周期。为了让测低频时不至于溢出太快,可以把计数频率设成 100kHz,PRL=99,ARR=65535,这样计数器满量程 65536 个计数代表 655.36ms,可以覆盖 1.5Hz 以上的信号;测高频时,如果两个上升沿之间只相差几十个计数,误差会偏大,所以要求不高的话,100kHz 计数频率基本够用。
第二步,开启输入捕获上升沿检测。如果用标准库,大概长这样:
TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 上升沿捕获 TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; // 不分频,每个边沿都捕获 TIM_ICInitStructure.TIM_ICFilter = 0x00; // 不使用滤波 TIM_ICInit(TIM2, &TIM_ICInitStructure);第三步,在捕获中断里,读取两次 CCR 并计算差值。HAL 库的做法是开启HAL_TIM_IC_CaptureChannel_IT,然后在回调里轮流记录IC_Value1和IC_Value2。
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { if (IC_State == 0) { IC_Value1 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); IC_State = 1; } else { IC_Value2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); if (IC_Value2 > IC_Value1) { IC_Diff = IC_Value2 - IC_Value1; } else { IC_Diff = (65536 - IC_Value1) + IC_Value2; } IC_State = 0; IC_Ready = 1; } } }最后在主循环里,检测到IC_Ready后,频率 = 计数频率 / IC_Diff。比如计数频率 100kHz,IC_Diff 是 1000,那么频率 = 100000/1000 = 100Hz。注意一次测量只解决一个频率点,测完要马上重新准备下一轮捕获。
4.3 捕获测频率的两个大坑
第一个坑是信号抖动。外部信号如果没有整形,比如传感器输出的波形上升沿很缓,可能会在一个边沿上触发出多次捕获。STM32 的输入捕获带了一个数字滤波器,可以在捕获之前对信号进行若干次采样确认,能滤掉毛刺。脉冲宽度太窄或者频率太高时,滤波会损失细节,所以滤波系数的选择要参考你信号的宽度。
第二个坑是低频信号的溢出计数。前面说了,如果两个上升沿之间定时器翻了好几次,只存两个 CCR 值是不够的,必须叠加溢出次数。否则会算出非常离谱的频率。我在项目里测一个 2Hz 的转速脉冲时,忘记处理溢出,结果算出来居然是 6.5kHz,当时差点以为是传感器坏了。
经验:输入捕获更适合测周期或频率相对稳定的信号。如果你要测的频率范围很宽(从 1Hz 到 1MHz),建议分档切换计数频率,或者直接用定时器门的"脉冲计数"思路。还有,捕获中断尽量只做"存值"这件事,所有计算放到主循环,避免中断里做除法,不然 CPU 会被拖垮。
5. 用定时器输出"时间":PWM 波形和高级定时器的硬实力
5.1 PWM 不过是定时器的一种比较输出
如果说输入捕获是把外部边沿变成内部计数,PWM 就是反过来,把内部计数变成可控的波形。原理也不复杂:CNT 从 0 一直往上数,到了 ARR 就溢出归零;与此同时,比较寄存器 CCR 里存了一个阈值。CNT 小于 CCR 时,输出高电平;CNT 大于等于 CCR 时,输出低电平。这样一来,引脚就输出了一串周期由 ARR 决定、占空比由 CCR 决定的方波。
PWM 频率 = 定时器时钟频率 / (PSC+1) / (ARR+1)
占空比 = CCR / (ARR+1)
以呼吸灯为例:PSC=71,ARR=999,计数频率 1MHz,PWM 频率 = 1MHz / 1000 = 1kHz。然后慢慢改变 CCR,灯的亮度就跟着变。你看着灯从暗到亮,其实定时器一直在高速地输出同一频率的方波,只是在调节高电平比例。
5.2 高级定时器到底多了什么:互补通道、死区和刹车
在 PWM 这条线上,普通通用定时器(TIM2/TIM3/TIM4/TIM5)已经够用了。但如果你想做电机驱动、全桥逆变、H 桥控制,事情就没那么简单。因为上下桥臂的 MOS 管不能同时导通,否则电源直接短路。所以高端定时器(TIM1、TIM8)专门设计了互补输出、死区插入和刹车输入这些功能。
互补输出就是在一个通道输出 PWM 的同时,另一个引脚(通道的反相端)输出逻辑反相的 PWM。比如 CH1 输出高电平时,CH1N 输出低电平,这样正好驱动半桥的上下管。死区插入则是在两个管子切换之间,专门插入一段两路都为低电平的时间,防止开关管关断延迟导致直通。死区时间可编程,典型设置 0.5μs~2μs,具体值要看功率管子的关断延时。
如果你用普通定时器自己软件翻转 GPIO 来模拟互补输出,死区时间很难控制到微妙级,而且还要额外占用 CPU。高级定时器这套硬件模块做出来就是给电机控制用的。热词里"foc pwm波形和定时器"说的正是这个——FOC 矢量控制里通常需要三相互补 PWM,加上死区,再加上 ADC 触发同步,TIM1 这类高级定时器几乎是为这个场景量身定做的。
5.3 配置高级定时器时最容易被忽略的三个细节
第一,TIM1/TIM8 属于 APB2 总线,时钟频率上限和 TIM2 等 APB1 上的定时器不一样,特别是在 F4 系列上。配置 PWM 频率之前,先确认你用的是哪条总线。
第二,一个引脚可能同时复用多个定时器功能。比如 PA0 可以是 TIM2_CH1,也可以是 TIM5_CH1,还可能是别的外设。如果 CubeMX 里这种冲突不提示,代码初始化后引脚输不出波形,多半就是复用功能选错了。
第三,高级定时器有一个"预装载"坑。TIM1/TIM8 的 CCR 寄存器需要设置TIM_CR1的 ARPE 位,让 CCR 在更新事件时才真正生效。如果你没有设这个标志,运行中改占空比可能产生奇怪的毛刺,甚至波形瞬间全高或全低。以前我调一个伺服驱动时,占空比突变导致电机猛冲一下,排查半天才发现是预装载没使能。
6. 当系统睡着了:谁还在维护那根时间基准
6.1 睡眠模式对普通定时器的"断电"
嵌入式系统经常要进入低功耗模式。STM32 的睡眠模式(Sleep)下,CPU 停了,但内核时钟和大部分外设时钟还在跑,TIM 还能工作;一旦进入 Stop 模式,整个主时钟域的芯片内外设几乎全部停摆,普通 TIM 的计数就此冻结。如果你依赖 TIM2 做闹钟想唤醒系统,会发现它根本叫不醒你。
这时候就得请出另一套时间基准——低功耗定时器 LPTIM,或者 RTC。LPTIM 的优势在于它有自己的时钟源(通常接 LSE 外部 32.768kHz 晶振或 LSI 内部低速 RC),并且在一部分低功耗模式下仍然可以运行。也就是说,全系统都睡了,LPTIM 还在独自数着低速脉冲,数到位了,它可以产生中断把系统从 Stop 模式拉回来。
6.2 LPTIM 和 RTC 的分工
RTC(实时时钟)更偏"日历",它维护年、月、日、时、分、秒,闹钟可以设置到某一秒触发中断/唤醒。LPTIM 则更像一个低功耗的普通定时器,适合做低功耗下的周期性事件,比如每 10ms 唤醒一次采样传感器,或者每 1 秒唤醒一次看门狗。
用 LPTIM 做低功耗周期唤醒时,有几个点要注意:
- 时钟源选择。LSE 稳定但需要外部晶振电路;LSI 无需外部晶振,但是精度差,会随温度变化,如果你要精确统计休眠时长,LSI 可能不太够。
- 唤醒时间计算。LPTIM 的分频系数和比较值同样是一个"时间基准"问题,配置前先算清楚 LSE 32.768kHz 下,1 秒需要计多少个脉冲,别想当然直接填 32768,还要考虑预分频。
- 从 Stop 模式唤醒后,系统复位了没有?有些唤醒源会导致芯片复位,有些只是继续执行。你在 LPTIM 中断里写数据到 SRAM 前,最好确认这个 SRAM 在低功耗模式下是否掉电。
我之前做过一个纽扣电池供电的数据记录仪,整机平时停在 Stop 模式,LPTIM 每 15 秒唤醒一次读传感器。调试时发现唤醒周期不稳定,后来量了一下,LSI 实际频率不是标称的 40kHz,而是偏到 36kHz。然后我把 LPTIM 时钟切换到 LSE 外部晶振,周期误差才压到秒级以下。这个经验说明:低功耗时间基准同样要选准时钟源,不能认为"定时器会用就行"。
6.3 一次"定时器时间漂移"的完整排查思路
最后分享一个综合性的排查案例,把前面积累的这些东西串起来。
现象:设备运行约 10 分钟后,定时器产生的秒脉冲出现明显漂移,每分钟快约 1 秒。
排查思路:
先确认定时器时钟源。用示波器测 HSE 晶振引脚的波形,频率是否准确。如果用的是 HSI 内部 RC,温度升高时频率会偏,这是固有的,只能换 HSE 或者用外部时钟。
再检查 APB 分频设置。如果改了总线的分频,定时器时钟会跟着变,但 CubeMX 生成的 HAL 代码会自动调整。可以在定时器初始化时把htim.Init.Prescaler打印出来回算一下,确认是不是你预期的时间基准。
再查预分频值和 ARR 是否溢出。计数器是 16 位的,ARR 最大 65535,如果你把 PSC 设 7199、ARR 设 99999,算出来时间是对的,但 ARR 已经超范围,硬件自动截断,时间就完全不对。
最后检查是否在运行中被其他用户改过寄存器。比如某段代码为了省电,把 APB1 分频改了,而定时器时钟也变了,又没有重新初始化定时器,那时间就飘了。
顺着这条链一路查下去,最终一定能定位到"时间基准被谁动了"。这比对着逻辑分析仪猜要快得多。
在做定时器相关开发时,我最大的体会是:不要急着写代码,先在纸上画一遍时钟路径——信号或时间从哪个引脚/哪个时钟源进来,经过哪些分频和预分频,最后到 CNT 或 CCR。这套图一旦画清楚,90% 的定时器问题都能一眼看穿。把上面的公式记牢,把时钟树背熟,之后再遇到什么"定时器到底在数什么"的疑问,你大概就能笑着反问别人了。