
一个挺有意思的现象很多人用 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或者内部 RCHSI先作为源头经过锁相环 PLL 倍频得到系统时钟 SYSCLK再经过 AHB 预分频器分给各个总线。定时器挂在哪条总线上呢通常挂在 APB1 或 APB2 总线上。但这里有一个 STM32 特有的福利当 APBx 预分频值不为 1 时定时器时钟不是 APBx 的时钟而是 APBx 时钟的 2 倍。拿最经典的 STM32F103C8T6 举例系统时钟 72MHzAPB1 预分频为 2所以 APB1 外设时钟是 36MHz。但挂在 APB1 上的 TIM2、TIM3、TIM4、TIM5 等它们的时钟不是 36MHz而是 36MHz×272MHz。同理APB2 预分频如果不为 1挂在 APB2 上的 TIM1、TIM8 也是 72MHz。为什么要有这个2 倍补回来的设计简单说这是芯片设计时为了给定时器一个不低于系统时钟的节拍宁可多给一个倍频器。如果你在配置 CubeMX 时手动改过 APB1 分频就需要格外注意定时器时钟会和外设总线时钟差一倍。到了 STM32F4 系列这个规则同样存在但上限变了。比如 STM32F407 在系统时钟 168MHz 下APB1 定时器时钟最高 84MHzAPB2 定时器时钟最高 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 这个减速齿轮每来PSC1个原始脉冲计数器 CNT 才走一步。为什么是 PSC1因为 PSC0 时不分频输入 72MHzCNT 每 1/72MHz 秒加一次PSC71 时72MHz ÷ 72 1MHzCNT 每秒走一百万步。然后 CNT 从 0 一直走到 ARR走满后触发一次更新事件overflowCNT 归零重新开始。所以一次溢出的时间就是溢出时间 (PSC1) × (ARR1) / 定时器时钟频率注意 ARR 也要加 1因为 CNT 是从 0 数到 ARR总共是 ARR1 个数。比如 ARR999那就是数了 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计数频率变成 100kHzARR 99同样 100 × 10μs 1ms。所以你会发现 PSC 和 ARR 的组合并不唯一。但在实际工程里一般先定计数分辨率也就是 PSC 决定的每一格是多少时间再定 ARR 决定总周期。比如做电机控制通常把计数频率定在 1MHz 或更高保证 PWM 和捕获的分辨率足够。上面是向上计数模式。STM32 定时器还有向下计数CNT 从 ARR 倒数到 0和中心对齐模式先向上再向下。中心对齐模式下溢出事件发生在顶部和底部各一次计算周期时要小心同一个 ARR 下更新中断频率会翻倍。这一点在电机 FOC 中尤其容易踩坑——你以为是 20kHz 的 PWM 中断结果中心对齐模式上下各触发一次变成 40kHzCPU 开销直接翻倍。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比如 FreeRTOSSysTick 会被操作系统接管HAL_Delay就可能失效。很多移植了 RTOS 的同学说我的HAL_Delay突然卡死多半就是这个原因。换成操作系统的vTaskDelay就好。3.3 为什么系统里需要多个时间基准系统里其实经常同时存在好几套时间基准SysTick 给系统调度用TIM2 可能给某个传感器做精确延时TIM3 在做 PWMTIM4 做编码器计数。它们各有各的时钟源、各有各的分频彼此独立。这样设计的好处是就算 SysTick 被 RTOS 占用了你的 TIM2 依然能用自己独立的时钟跑不会受影响。坏处则是调试时你需要时刻清楚当前这一段代码消耗的是哪个定时器的时间。我见过有同事把 TIM2 的更新中断当成系统时基来维护一个100ms 调度表后来系统加入低功耗 Stop 模式TIM2 直接停止计数那个调度表就彻底乱了。这就是因为 TIM2 在低功耗模式下没有时钟输入和 SysTick 完全不同。所以设计早期就应该确定系统级时基用 SysTick 或专门一个定时器不少人称为tick_timer外设功能用各自的 TIM不要混用。宁可多配一个定时器也别把两个功能强行压到同一个定时器上后面调起来会疯。4. 输入捕获怎么用定时器去量外部信号的频率4.1 捕获的本质在某个瞬间把 CNT 快照下来前面讲的都是定时器自己闷头数脉冲。但定时器还有另一个角色监听外面来的信号。这就是输入捕获模式。原理其实很简单当外部引脚出现设定的边沿上升沿或下降沿时硬件会自动把计数器 CNT 的当前值复制到捕获/比较寄存器CCR同时触发中断如果开启。这时你在中断里读 CCR拿到的就是这一瞬间 CNT 的刻度。那怎么测一个方波的频率经典做法是测两个相邻上升沿之间的距离。假设定时器计数频率是 1MHz外部信号第一个上升沿时捕获 CCR11000第二个上升沿时 CCR22000那这两个上升沿之间走过 1000 个计数每个计数 1μs于是信号周期 1000μs频率 1kHz。我们把这个过程写成公式信号周期 (CCR2 - CCR1) / 计数频率信号频率 1 / 信号周期如果 CCR2 比 CCR1 小说明计数器中途溢出过了CNT 从 65535 跳回 0这时要在差值上加上溢出次数 × (ARR1)。工程上做稳一点会在更新中断里维护一个overflow_count。4.2 一个完整的频率计实例从配置到计算我们用 TIM2 的通道 1PA0做输入捕获目标测一个 10Hz~100kHz 范围的方波。分几步走第一步配置计数器时钟和溢出周期。为了让测低频时不至于溢出太快可以把计数频率设成 100kHzPRL99ARR65535这样计数器满量程 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。比如计数频率 100kHzIC_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 频率 定时器时钟频率 / (PSC1) / (ARR1)占空比 CCR / (ARR1)以呼吸灯为例PSC71ARR999计数频率 1MHzPWM 频率 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% 的定时器问题都能一眼看穿。把上面的公式记牢把时钟树背熟之后再遇到什么定时器到底在数什么的疑问你大概就能笑着反问别人了。