做嵌入式这些年,经常碰到有人拿着定时器的问题来问我,开场白往往是“我的定时器明明配好了,怎么时间不对”,或者“这个PWM频率怎么算的,怎么跟我想的不一样”。聊到最后你会发现,大部分人对ST芯片里那个整天在“数数”的硬件单元,也就是定时器,其实一直停留在“会用CubeMX点两下”的阶段。定时器到底在数什么?它的时间基准又是从哪里来的?这两个问题如果说不清楚,后面什么PWM、输入捕获、编码器模式全都像在走钢丝。这篇就专门把定时器的时间基准这件事掰开揉碎,从时钟树一路顺到预分频器和自动重装载寄存器,顺便把实际工程里容易踩的坑也一并收拾了。
1. 时间基准从哪里来:时钟树里的“源头之水”
1.1 内核时钟不等于定时器时钟
刚接触STM32的时候,很多人会有一个误区,以为定时器的频率跟主频一样。比如F103跑72MHz,那么定时器精度就是72MHz。这个概念用起来会出问题,因为定时器挂在不同的总线桥上,而总线桥本身还有分频逻辑。
拿最常见的STM32F103来说,内核最高跑72MHz,这个叫SYSCLK。SYSCLK经过AHB预分频器之后,喂给APB1和APB2总线。APB1的极限频率是36MHz,APB2是72MHz。定时器TIM2、TIM3、TIM4挂在APB1上,TIM1挂在APB2上。关键点来了:如果APB1的分频系数不是1,那么定时器时钟会被强制翻倍。APB1分频为2时,定时器时钟 = APB1时钟 × 2 = 36 × 2 = 72MHz。所以F103的定时器,无论挂在APB1还是APB2,默认都能拿到72MHz。
曾经有人问:“我的APB1才36MHz,为什么TIM2最大还能跑到72MHz?”答案就是这个2倍补偿机制。这是芯片设计时为了弥补总线频率降低而做的补偿,也是手册里容易忽略的注释。
1.2 APB分频带来的“反直觉”
这个2倍补偿是新手最容易踩的坑。如果你用STM32CubeMX建工程,外设时钟面板上会直接显示定时器时钟频率,很多人看了一眼没在意,直接去配预分频和重装载值,结果算出的时间全偏了。
实际工程里有这么一种情况:你用STM32F407跑168MHz,APB1设为4分频,那么APB1=42MHz,定时器时钟 = 42 × 2 = 84MHz。如果你以为TIM挂在APB1上就按42MHz计算,那定时延和PWM频率全错。
判断规律一句话:总线分频系数为1,定时器时钟就等于总线时钟;总线分频系数大于1,定时器时钟等于总线时钟的2倍。
自己在代码里也可以验证,RCC挂钟配置完成后,用HAL_RCC_GetPCLK1Freq()读出APB1频率,再检查CUBeMX生成的__HAL_RCC_TIM2_CLK_ENABLE()之后,读一下TIM2的输入时钟,或者直接用调试器看初始化后的寄存器值。
1.3 在CubeMX里看懂时钟树
STM32CubeMX的价值之一就是把复杂的时钟树可视化,但这个可视化反而让很多人失去了“手算”的能力。
配时钟的第一步是先设好系统时钟源和PLL倍频,让SYSCLK达到目标频率。第二步看左侧的APB1/APB2分频系数。如果APB1那一栏显示“/4”,APB1总线频率就显示为具体数值,例如F407的42MHz。接下来要找的是定时器时钟那一栏,CubeMX通常不直接显示定时器时钟,除非你选中某个定时器,在它的配置页底部能看到Timer Input Frequency。
曾经配过一个F411项目,目标是用TIM3产生20kHz的PWM。TIM3挂在APB1上,APB1分频设为1,此时定时器时钟就是APB1 = 100MHz。如果分频设为2,APB1 = 50MHz,定时器时钟反而变成100MHz。看起来数字没变,但APB1上的其他外设频率已经变了,这部分要心里有数。
时间基准的源头清楚了,接下来就是定时器内部怎么拿这些时钟“数数”的问题。
2. 定时器到底在数什么:预分频器和自动重装载寄存器
2.1 从节拍到周期:PSC和ARR的分工
定时器内部其实就是一个不断累加的计数器,它数的是脉冲。一个时钟周期到来,计数器的值加一。这个计数器的上限由自动重装载寄存器ARR决定。但问题来了,72MHz的时钟意味着1秒有7200万个脉冲,全部数一遍显然太“细”了,我们需要的可能是1毫秒、1微秒,或者一个合适的PWM周期。
所以定时器前面还有一道关卡:预分频器PSC。预分频器的作用是“数到一定次数才给计数器派发一个脉冲”,本质上是一种降频处理。计数器最终的工作频率是:
定时器计数频率 = 定时器时钟 / (PSC + 1)
计数一个周期的时间 = (PSC + 1) / 定时器时钟
再搭配ARR,可以得到完整周期:
定时器溢出周期 = (PSC + 1) × (ARR + 1) / 定时器时钟
这个公式是定时器所有应用的地基。
用日历打个比方:系统时钟是一秒一秒走的秒针,PSC相当于在秒针下再设一个“每60秒算一分钟”的逻辑,ARR则像是闹钟定在60的位置,每到60就响一次铃(触发中断或更新事件)。你调整PSC和ARR,本质上是决定“该数多少个最小时间单位才做一次事”。
2.2 动手算一个LED闪烁的例子
假设F103跑72MHz,APB1分频为2,定时器时钟 = 72MHz。要让LED每秒钟闪一次(周期1秒,高低电平各500ms),怎么定PSC和ARR?
思路是先把计数频率降到一个好算的整数,再让溢出周期等于目标。习惯上先把PSC设为7199,这样计数频率就是72000000/7200 = 10kHz,也就是每0.1ms计数器加一。再设ARR为9999,溢出周期 = (9999+1) × 0.0001s = 1s。
代码里是这样的:
TIM_HandleTypeDef htim2; htim2.Instance = TIM2; htim2.Init.Prescaler = 7199; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 9999; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim2);有个细节值得注意:Prescaler虽然叫“预分频值”,但在硬件里实际生效的是寄存器值,所以要给目标分频系数减1。很多人设PSC=7200,算出来频率就不是整数,调了半天没找到原因。
2.3 为什么要先选PSC再选ARR
实际调参有个经验顺序:先把PSC设成一个让计数频率尽量高的值,再调整ARR来定制目标周期。这里有个权衡。计数频率越高,PWM的分辨率越好(占空比可以调得更细),但ARR的值也会变大,意味着中断频率变低,实时响应的粒度变粗。
举个实际例子:你需要1kHz的PWM,系统时钟84MHz。方案A:PSC=83,计数频率1MHz,ARR=999,占空比调节步长0.1%。方案B:PSC=839,计数频率100kHz,ARR=99,占空比调节步长只有1%。两种方案都能输出1kHz,但方案B的输出电压调节会很粗糙,用在LED调光上肉眼看不出,用在电机调速上会感觉到噪声和扭矩波动。
所以我的习惯是:先看负载对频率的要求,频率定了之后,尽量让计数频率高,同时不要超过32位ARR(F1的16位定时器上限65535,F4/H7等32位定时器上限可以到4294967295)或16位定时器的上限。对于F103这种16位定时器,如果PSC太大,ARR会超出65535,这时候就反过来,牺牲计数分辨率,保证ARR在16位范围内。
2.4 PWM的频率和占空比从哪里来
PWM的本质是“定时器在数数时,输出引脚的电平受比较值CCR控制”。计数器从0增长到ARR,当计数值小于CCR时输出高电平,大于等于CCR时输出低电平。频率由PSC和ARR决定,占空比由CCR和ARR共同决定。
FOC电机控制里用的PWM波形要求高精度、低抖动。电机SVPWM的载波频率通常为10kHz到20kHz,这时候PSC选得小一点,ARR能算到几千,相位分辨率就够了。但要注意,互补PWM和死区插入依赖TIM1、TIM8这种高级定时器,F103上用普通定时器做不到硬件死区,得靠外部电路或者软件模拟。
3. 三种计数模式:向上、向下、中央对齐
3.1 向上计数与向下计数的选择
默认的向上计数是最常用的:计数器从0数到ARR,触发事件后清零重新开始。向下计数则从ARR数到0。两者在对称性输出上没有本质区别,但在与DMA配合时会影响数据的到达时刻。
向下计数在少数场景下有用,比如需要某些外设“在计数值最低时触发DMA”来避免总线占用高峰。但大部分工程里,向上计数已经够用。
3.2 中央对齐模式的真实用途
中央对齐模式是“先向上数到ARR,再向下数到0”,输出波形天然关于中点对称,因此生成PWM时,脉宽在两边缘同时变化,谐波更小。这用在电机控制的正弦波PWM上能有效降低电流谐波和噪声。
不过中央对齐模式的更新事件发生在两个点上(上溢和下溢),因此中断或DMA触发频率是计数频率的两倍。同样的ARR和PSC,中断频率翻倍,这个要注意,不然实时系统里一个50kHz的中断会在你意想不到的地方耗费CPU。
3.3 编码器模式的“特殊数法”
定时器还可以直接读取正交编码器信号,这时候它数的就不再是内部脉冲,而是外部输入的A、B两路相位差90°的方波。通过判断A、B信号的先后顺序,定时器内部硬件能自动分辨正反转,同时每来一个边沿都可以计数。这意味着只接一个定时器,既能得到位置信息,也能得到方向信息。
编码器模式下,CNT寄存器就是位置计数器,方向翻转由硬件自动处理,不需要软件轮询判断。电机转速计算要用到定时频率,简单做法是固定一个时间窗口,比如每10ms读一次CNT,差值除以时间就是速度。这个算法我实际调过,位置精度取决于编码器线数和定时器配置的计数边沿。4倍频模式(A和B的上下边沿都计数)是常用的选择,但CNT溢出时要处理好方向判断,否则速度会跳变。
4. 时间基准的扩展玩法:外部时钟、输入捕获与SysTick
4.1 让定时器去数外部脉冲
定时器的时基不一定要来自内部的RCC,它也可以接收外部引脚上的脉冲。STM32的定时器外部时钟有两种:外部时钟模式1(TS引脚上的信号触发)和模式2(ETR引脚直接作为时钟源)。
模式2最典型的应用是“数脉冲数”。比如流量计输出一个频率和流速成比例的方波,直接把信号接到定时器ETR引脚,配置外部时钟模式,计数器每个上升沿加一。过一段时间读CNT,除以时间就是频率。这种做法的好处是不占CPU资源,也不会丢脉冲。
用CubeMX配置时,在定时器的“Clock Source”里选“External Clock Mode2”,再指定ETR引脚和极性。有一个隐藏的坑:ETR输入引脚通常会经过输入滤波器,滤波参数太大会把高频脉冲滤掉。测量几kHz无所谓,测量几十kHz的流量脉冲时,要适当缩小滤波采样频率。
4.2 输入捕获:同一个定时器,不同的“数完了没”
输入捕获也是基于定时器内部的时基,但新增了一组捕获寄存器。信号输入引脚检测到边沿时,计数器的当前值会被硬件锁存到捕获寄存器里。两次捕获值的差,就是两次边沿之间计数器走了多少步,乘以单步时间就能得到脉宽或周期。
超声波测距就是标准例子:发送超声波的同时启动定时器,回波到达时读取捕获值。用上升沿捕获得到起点,下一次上升沿或下降沿捕获得到终点,时间差换算成距离。我用过的方案是TIM2的通道1做输入捕获,配置上升沿和下降沿都捕获,HAL_TIM_IC_CaptureCallback里分别记录两个时间戳。距离 = (结束时间 - 开始时间) × 声速 / 2。要注意捕获溢出,也就是两次事件之间计数发生了重载翻转,需要在中断里处理CNT回绕,不然时间全部算错。
PWM输入模式是输入捕获的进阶,一个通道测频率,另一个通道测占空比。信号接入后,结合主从模式,硬件上能自动把周期值和脉宽值锁存。这里对时钟源的稳定性要求高,采样用的定时常数不稳定,捕获结果就会抖动。
4.3 滴答定时器SysTick:最容易“数不准”的时间基准
SysTick是内核里的一个24位递减计数器,用来做系统节拍和软件延时。很多人写HAL_Delay()、delay_us()都是基于SysTick的,但实际工程中SysTick的时间基准如果被其他中断抢占或者被FreeRTOS接管,延时就会不准确。
SysTick的时钟源选择在STM32上有个细节:可以用HCLK(AHB时钟)或HCLK/8两种。CubeMX默认选择HCLK,意味着72MHz下每个tick约13.9ns。如果你用HAL_Delay(1),它内部的实现方式是等待SysTick计数值归零的标志,但中断优先级不够高时,延时会被其他高优先级中断拉长。在串口打印、I2C时序要求严格的场合,这种误差会累积成可见的问题。
更稳的微秒级延时我试过几种做法,最实用的是:用定时器做基准,比如TIM2配置成1MHz计数频率(PSC = 系统时钟 - 1),然后在要延时的地方读取CNT,直到CNT增量达到目标。这样不受SysTick中断优先级影响,精度可以达到微秒级别,代价是占用了定时器资源和一点点功耗。
4.4 低功耗定时器的特别之处
有低功耗需求的设备,主时钟关掉后,普通定时器全部停摆,只有LPTIM这类低功耗定时器能靠外部低速时钟(如LSI、LSE)继续运行。它在停机模式下仍可工作,可以用来做RTC唤醒、外部脉冲计数等。但它毕竟是低功耗定位,时钟源频率很低,做不了PWM和输入捕获的精细工作。如果你的代码里有“待机模式下还能定时唤醒”的需求,选LPTIM而不是普通TIM,这是硬件设计层面的选择,不是软件调参能解决的。
5. 实操过程中的高频问题与排查经验
5.1 溢出的频率怎么跟理论对不上
这是最常见的,基本上都是PSC和ARR的“+1”没处理好。寄存器存的数是从0开始的,PSC=7199表示7200分频,ARR=9999表示10000个计数周期。手册里写得很清楚,寄存器值等于期望分频系数减1。用调试器看TIMx->PSC和TIMx->ARR的实际值,如果和理论上要写入的值差1,就说明你理解了问题所在。
另一种不太容易想到的概率是:定时器用了外部时钟或从模式。主从模式下,例如触发另一个定时器启动计数,此时定时器的时钟不再是简单的PCLK,而是触发的源。排查时先缩小范围,检查SMCR寄存器里的触发选择位,再用逻辑分析仪看输入波形。
5.2 定时器中断从来没触发
中断没反应,第一件事不是改代码,而是查NVIC使能位。HAL库初始化之后要调用HAL_TIM_Base_Start_IT(&htim2),这个函数负责局部使能更新中断并启动计数。很多人只调用了HAL_TIM_Base_Init,定时器没有进入中断模式,当然毫无反应。
第二个隐藏点在TIM_IT_UPDATE的使能位。HAL库的初始化流程在状态机里会把更新中断关闭,必须在HAL_TIM_Base_Start_IT后才打开。如果调试时发现中断标志置位但程序不跳转,先确认中断处理函数是否进到了HAL_TIM_IRQHandler,再检查优先级分组是否配好。
如果你的系统用了FreeRTOS,还要确认中断优先级不高于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,否则中断回调里调用HAL库函数可能触发断言。
5.3 PWM就是没有波形
输出PWM最常见的问题是忘了调用HAL_TIM_PWM_Start。这个函数与HAL_TIM_Base_Start_IT的通道参数格式不同,第二个参数是TIM_CHANNEL_x,不是周期或占空比。
第二类问题出在引脚复用。CubeMX生成的代码会自动配置GPIO复用,但如果是手动移植工程,很容易漏掉__HAL_AFIO_REMAP_PIN或者GPIO的复用功能配置。F1系列尤其要注意复用重映射,比如TIM2的通道1默认在PA0,如果用了TIM2_REMAP部分重映射到PA15,GPIO配置就要跟着变。
第三类问题是极性。PWM输出极性和初始电平不匹配,测量的波形看起来“反了”。CubeMX里可以配Pulse和Polarity,手动移植时这两个配置分散在TIM_OC_InitTypeDef和GPIO_InitTypeDef里,检查顺序颠倒就很隐蔽。
5.4 输入捕获的值总是偏差
输入捕获得到的CNT值和实际时间对不上,先检查两件事:本地时基有没有和捕获共用PSC;滤波参数是不是过大。输入引脚的高频噪声会导致捕获触发过早或过晚,滤波器能滤掉尖刺,但如果采样窗口太宽,也会把有效边沿丢失。
更麻烦的是溢出处理。捕获中断里读了CCR,但计数器可能已经经历了一到多次回绕。此时记录的“差值”其实是两段余数的差,不是真正的差值。必须在捕获中断里同时读取SR寄存器中的更新标志,若为1则记录一次溢出,最后用溢出次数乘以ARR+1再补上余数差。
5.5 实测小技巧:用DMA搬运捕获值
不需要边沿时实时响应的场景,我习惯把捕获和DMA结合。配置定时器捕获为DMA请求,DMA在多个通道事件发生时把CCR搬运到内存数组。这种模式的优点是CPU不需要在CaptureCallback里频繁进出,大数据量分析时特别省心。
但要注意DMA循环模式的长度和数组缓冲区的边界。DMA循环传输时,内存写指针会自动回到数组起点,如果主循环来不及消费数据,新数据会覆盖旧数据。设计上建议使用双缓冲或者环形缓冲,手动维护读写索引。
写在最后的个人总结
定时器这套东西,理解清楚“数什么”和“基准从哪来”这两个问题,后面所有模式都可以顺着推出来。我的建议是:拿到一块开发板,不要急着用CubeMX生成工程,先手写一个TIM的寄存器版本,把PSC和ARR写进代码,配合调试器看计数器的实际波形,再看它产生的中断频率和理论是否一致。这个流程走一遍,比背十遍手册都管用。
还有个小技巧:调试定时器相关的代码时,开一个逻辑分析仪或者示波器,直接测引脚波形比盯寄存器效率高得多。示波器不是万能,但至少能帮你排除一大半“寄存器看起来对,实际不对”的玄学问题。祝你少踩几个坑。