定时器原理图说:从51到STM32,彻底搞懂硬件定时器 提到定时器这个主题我脑子里第一个画面就是我当年在一张草稿纸上画出“时钟源 → 预分频 → 计数器 → 比较/中断”那条流水线时的顿悟时刻。定时器尤其是硬件定时器看起来每一种芯片都有一套自己的名字和寄存器内核里却永远是一套极其朴素的逻辑它就是一个“数数的人偶”时钟每跳一次它就数一个数数到了你告诉它的目标值它就翻个牌、触发一个中断或者把一个引脚电平翻一下。这篇文章是“图说”的路子不堆大段寄存器手册而是把原理、结构、工作模式、常见配置和踩坑翻车现场一层层扒开透过51、STM32/GD32、SysTick这类典型平台让你看完之后遇到任何一款定时器都能自己上手推理。适合刚学单片机的同学也适合调定时器调到头大、想回来补底层逻辑的嵌入式老油条。1. 定时器到底是什么一个“数数的人偶”引发的原理图1.1 从手掐秒表到芯片内部的计数器你可以把定时器理解成一个不需要睡觉、不需要看表的门卫。门卫的工作非常简单门外每过来一个人他就在小本子上记一个数。你提前告诉他记满1000个人的时候就把小红旗插起来告诉你一声。这里的“人”就是时钟脉冲“小本子”就是计数器寄存器CNT“记满1000个”就是比较值/自动重装载值“插红旗”就是产生中断或者触发某个事件。就这样一个朴素模型在不同的芯片上只是换了个马甲。51单片机的定时器就是一个16位的计数器靠机器周期或者外部引脚脉冲喂它数满溢出中断标志置位然后你自己重新填初值它继续数。STM32的通用定时器在计数器之外多了一个预分频器PSC和一个自动重装载寄存器ARR让“数数”这件事变得精细可控既可以向上数、向下数、也可以来回数还能顺便输出PWM、捕获外部信号。到了SysTick滴答定时器干脆是个24位的向下计数器作为操作系统的“心跳”而存在。不管名词怎么变底层永远是“时钟进计数动比较到事件出”。你把这个链条刻在脑子里后面所有工作模式都是在这条链上不同位置做文章。1.2 定时器内部结构图的四个“零件”用文字画一张最简结构图是下面这个样子时钟源 │ 内部RC/外部晶振/外部引脚脉冲/其他定时器触发 ▼ 预分频器 PSC ──► 计数器 CNT ──► 比较/捕获逻辑 ▲ │ │ ▼ 自动重装载ARR 中断/事件/引脚翻转/触发其他外设四个关键零件各司其职时钟源整个定时器的心脏泵。它决定了“人”多久来一个。内部时钟、外部时钟模式、编码器模式用的外部正交信号本质都是把“某个信号”引到计数器输入端。预分频器PSC把时钟频率降下来用的。比如72MHz的时钟你想要每1微秒数一个数就除以72。分频器的意义就是让计数器的工作节奏能“配平”到应用需要的时基上。计数器CNT真正干活的计数器。板上寄存器每个脉冲到来时加1、减1或者加减交替具体行为由计数方向相关配置决定。自动重装载ARR 比较逻辑每次CNT走到和ARR相等时产生“更新事件”/“溢出事件”触发中断、更新标志或者把CNT重置回0重新开始。注意51定时器没有ARR它的“重装”靠人在中断服务函数里手动把初值写回去效率低也容易丢脉冲STM32/GD32这类硬件自动重装的定时器则是把这件事彻底交给了硬件。理解这张图之后再去看厂商手册里那些寄存器框图你会觉得它们全都是这套结构的加粗版。1.3 为什么要预分频先算一笔账再看图很多新手第一次看到PSC就懵了“我不分频不行吗”行但很多时候你会觉得别扭。拿STM32F103举例定时器时钟72MHz。如果你不分频计数器每个脉冲间隔是 1/72MHz ≈ 13.89ns也就是计数器每加一次只过13.89纳秒。想让定时器产生1ms的周期中断需要数72000次这样ARR就得设成71999。这本身不是不能算但对于很多场景我们希望“计数器加1”对应一个更友好的时间单位比如1微秒。所以我们会把PSC设为71这样72MHz被分成72份得到1MHz的计数时钟CNT每加1正好过1微秒。这时再想定时1ms只需要ARR999计数器从0数到999数满1000次刚好1ms。公式永远逃不出这一条定时时间 (PSC 1) * (ARR 1) / 定时器时钟频率为什么总是“1”因为PSC和ARR的寄存器值从0开始计数PSC71代表72分频ARR999代表从0数到999共1000个脉冲。这个1的细节是最常见的时间计算错误来源之一。预分频的额外价值在于既然计数器可数的脉冲范围有限16位最大65535分频还能间接扩大最大定时范围。不分频时16位计数器在72MHz下最多定时约0.91msPSC71之后同样16位计数器能定到65.535ms。这就是“同样的房子换更慢的脚步声能数更久的时间”的道理。2. 定时器的几种“数法”工作模式图解大全2.1 定时模式向上数、向下数、来回数定时器最基础的工作模式就是“定时”但就算同一个定时也有不同的数法。向上计数CNT从0开始每来一个时钟脉冲加1一直加到等于ARR时产生更新事件然后CNT归零重新向上数。这是最常用的数法产生周期固定的中断、作为软件调度节拍基本都是它。向下计数CNT从ARR开始每个脉冲减1减到0时产生下溢更新事件然后重新从ARR往下数。51定时器没有这个选择但STM32/GD32有。某些PWM中心对齐应用或者一些特定场景比如倒计时看门狗刷新逻辑用向下计数反而更贴合直觉。中心对齐模式CNT先从0向上数到ARR再反过来向下数到0一个完整周期是“上上下下”两个过程。这种数法在很多电机控制的PWM里非常关键因为它产生的PWM波形是对称的谐波更小对电磁噪声控制更友好。这三种数法在图上其实就是CNT随时间变化的曲线不同一个锯齿波向上、一个倒锯齿向下、一个三角波中心对齐。定时器中断的周期、占空比的计算方式也因此略有差异。动手配置前先想清楚我想要的是“每次溢出就中断一次”还是“上下对称的PWM”再决定用哪种数法。2.2 计数模式从“数时间”到“数脉冲”定时器名字里带“定时”但它真正数的是脉冲。既然能数内部时钟脉冲那能不能数外部引脚进来的脉冲当然可以。这就是外部时钟模式和计数模式。场景非常直观流水线上一个传感器每过一个工件就输出一个脉冲我想知道一小时过去过了多少工件。定时器配置成外部时钟模式将脉冲引脚作为时钟源每来一个脉冲计数器加1CPU完全不用参与检测忙别的去定时器自己默默把数记着。这里有个天然坑脉冲信号频率不能太高。计数器虽然硬件飞快但信号本身需要满足输入采样要求。更专业一点说外部脉冲需要满足定时器的输入最小脉宽/最高频率限制否则会漏数。实际使用中超过几百kHz的外部计数不建议硬用通用定时器来干宁可加一个分频再进计数器。这个模式的变形应用是编码器模式。旋转编码器输出两路正交信号A、B定时器在A、B的边沿上不停计数方向由AB相位决定。这时候定时器干的活就是“根据外部脉冲的相位自动判断加还是减”本质依然是最朴素的“数脉冲”逻辑。2.3 PWM输出模式占空比到底怎么调出来的PWM模式是定时器最常用也最好用的“变种”。它的图说版本是这样CNT不停从0数到ARR然后归零周而复始中间有一个比较寄存器CCR每个计数周期硬件都会拿CNT和CCR比较一次。PWM模式1的逻辑是CNT小于CCR引脚输出有效电平CNT大于等于CCR引脚输出无效电平。于是你得到一脚一个方波周期由ARR决定占空比由CCR决定。这个模式最出名的公式有两个PWM频率 定时器时钟 / ((PSC 1) * (ARR 1)) 占空比 CCR / (ARR 1) 以模式1、有效电平为高为例拿舵机控制来说SG90舵机的PWM频率通常是50Hz周期20ms脉宽0.5ms对应0度2.5ms对应180度。在STM32上PSC71计数频率1MHz1us计一格ARR1999920ms周期那0.5ms脉宽就是CCR5002.5ms就是CCR2500。整套参数在图纸上就是一条从0数到20000的直线中间画一条“比较线”CCR过线之后电平翻转干净利落。这里还要提醒一句输出PWM的GPIO必须配置成复用推挽输出AF_PP不是普通推挽输出。GPIO_InitTypeDef里的Mode选GPIO_MODE_AF_PP不然定时器内部信号根本连接不到引脚上这是HAL库新手最常犯的错误之一。2.4 输入捕获模式用定时器去量信号的“脉搏”输入捕获的原理相当优雅定时器正常自由计数外部信号到达指定引脚边沿上升沿或下降沿的时候硬件瞬间把当前CNT的值“拍个快照”存到CCR寄存器里。你只要读取两次捕获值的差值再乘上每个计数刻度对应的时间就能算出一个信号的周期。测频率的思路打开上升沿捕获第一次捕获到t1第二次捕获到t2那信号周期T (t2 - t1) × 计数周期频率f 1/T。这里有个细节如果两次捕获之间CNT溢出翻转了好几次要记得把溢出次数乘上(ARR1)补回去不然算出来的周期短的离谱。STM32还有更省事的“PWM输入模式”用两个通道同时捕获一个通道测周期另一个通道测高电平脉宽。一次硬件配置CCR1和CCR2两个寄存器一组占空比直接出来了。这个模式在测电机转速、遥控器接收机信号等场景非常常用。2.5 编码器、单脉冲、外部触发等“扩展姿势”除了上面四类“主业”定时器还有一批“副业”。比如单脉冲模式配置好之后定时器只输出一个带确定宽度的脉冲做一次性的精确延时触发。比如主从同步模式定时器A更新时间触发定时器B启动实现多个通道严格同步输出。比如高级定时器的互补PWM输出自带死区时间专门驱动H桥/逆变器。这些扩展模式不需要一开始全弄明白但你要知道它们的存在。真到电机控制、数字电源、复杂定时联动这种项目里这些“姿势”就是让你不用外挂一堆逻辑电路的底气。3. 三款典型定时器对照51、STM32/GD32、SysTick3.1 51定时器方式1你还记得初值怎么算吗很多人第一个硬件定时器就是在51上做完的。51的T0/T1本质是两个16位计数器可选4种工作方式最常用的是方式116位不可自动重装和方式28位自动重装。方式1的初值计算是老嵌入式人肌肉记忆里的东西初值 65536 - 需要计数的次数 TH0 初值 / 256 TL0 初值 % 256经典51单片机晶振12MHz时机器周期12个时钟周期也就是计数频率1MHz计数器加1耗时1us。想定时50ms需要计数50000次初值就是 65536 - 50000 15536换算成十六进制0x3CB0于是TH00x3CTL00xB0。注意方式1没有自动重装中断里必须手动重新赋TH0/TL0。这一步漏掉你的定时器第一次中断之后就会从0开始数周期立刻变成65.535ms这是新手刚开始写定时器最经典的“跑飞原因”。我在实际培训中见过太多人卡在这个点上。3.2 STM32/GD32定时器PSC与ARR的配合艺术STM32的定时器被老工程师戏称为“外设之王”。它的时基单元由PSC、CNT、ARR三个寄存器组成高级定时器还有一个RCR重复计数器可以隔N次溢出才触发一次更新事件。STM32F103中APB1总线时钟最大36MHz很多人按36MHz去算通用定时器的频率结果时间全不对。真实情况是当APB1预分频系数不为1时定时器时钟是PCLK1的两倍。也就是说APB1为36MHz时TIM2/3/4/5的时钟其实是72MHz。这是ST老传统GD32同样有这个问题。GD32F450系列里甚至专门有一条配置rcu_timer_clock_prescaler_config(RCU_TIMER_PSC_MUL2);它的作用就是把定时器时钟设成APBx的2倍。如果你在GD32F450上做定时器配置却漏了这一行定时器实际频率会减半所有时间参数整体翻倍这种问题光靠肉眼调参很难发现。配置的核心思路永远是先定“计数时钟频率”再定“计数周期”。比如STM32F103TIM_CLK72MHz想要1kHz的PWMPSC 71 // 72MHz ÷ 72 1MHz计数器每格1us ARR 999 // 1000个数 1ms 1kHz CCR 500 // 占空比50%每一级都是整数除法结果清晰、可验证这就是“分频艺术”的全部秘密。3.3 CubeMX配置中的两个“暗坑”timebase与时钟树用STM32CubeMX配置定时器确实方便但有两个暗坑我每次遇到都得提醒人。第一个时钟树必须先通。很多人CubeMX里TIM2参数配得明明白白但忘了在Clock Configuration里给APB1定时器时钟做好分频或者根本不知道Timer input frequency显示的是多少。界面里会直接给出Timer Input Clock频率配置PSC/ARR之前先看一眼确认这个数和你预期一致。第二个Timebase Source设置。CubeMX默认把HAL库的时基HAL_GetTick / HAL_Delay依赖的“心跳”放在SysTick上。一旦你要用FreeRTOS系统的SysTick会被RTOS接管这时候必须去Project Manager → Project → Linker Settings里的Timebase Source改成TIM6或TIM7这种基础定时器否则HAL_Delay和RTOS时间片会互相打架表现就是调度稀烂、卡死无规律。这个“cubemx设置timebase source为定时器”的热词本质就是这条经验。3.4 SysTick滴答定时器系统的心脏和操作系统的节拍器SysTick是ARM Cortex-M内核自带的24位向下计数器不属于任何厂商外设所以不管你是ST还是GD它的行为一致。它没什么花哨模式就是数完一个LOAD值就置COUNTFLAG并触发SysTick异常。时钟72MHz想要1ms中断一次重装载值LOAD 72000000 / 1000 - 1 71999减1同样是“从0开始数到71999共72000下”的原因。SysTick的最大价值在于做“系统节拍”。裸机上HAL_Delay靠它RTOS里任务调度的时间片靠它。它没有PWM、没有捕获、没有预分频可选也不应该有——它就是一块格子固定的节拍器越简单越可靠。有些野生项目喜欢拿SysTick去做PWM输出这不是不行但CPU会被中断反复轰炸完全浪费了硬件定时器的意义。4. 手把手实操定时器的三个高频应用4.1 定时器中断毫秒级周期任务实操从最常用的定时器中断开始。拿STM32CubeMXF103来说开启TIM2PSC设71Counter Period设999打开NVIC里的TIM2 global interrupt生成代码后在主函数启动定时器HAL_TIM_Base_Start_IT(htim2);中断回调里写你的周期任务void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }就这么简单1ms周期的中断把脑门上的LED扇起来了。如果是51单片机代码内核则是void Timer0_Init(void) { TMOD 0xF0; TMOD | 0x01; // 定时器0方式116位 TH0 0x3C; TL0 0xB0; // 12MHz50ms初值 ET0 1; EA 1; TR0 1; } void Timer0_ISR(void) interrupt 1 { TH0 0x3C; TL0 0xB0; // 记得手动重装 // 你的周期任务 }提醒一点定时器中断的周期不要拍脑袋设太短。1ms中断意味着CPU每秒被拉进来1000次每次进出上下文都有开销。裸机LED闪烁这种需求给个10ms甚至100ms都绰绰有余把CPU时间留给更重要的事。4.2 PWM输出从呼吸灯到舵机控制STM32里启动PWM输出分两步。第一步初始化时配置好PSC、ARR、CCR以及GPIO的复用推挽模式第二步运行期启动输出HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1); __HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, 500);__HAL_TIM_SET_COMPARE这个宏就是改CCR的跑起来之后所有占空比调整都是这一行。呼吸灯让CCR从0逐步增大到ARR再从ARR逐步减小到0用延时控制每一步时间LED亮度就呈现“呼—吸”效果。实际上就是“占空比连续变化”这个动作本身。舵机控制更有工程参考价值50Hz周期0.5ms到2.5ms脉宽映射0到180度。上面已经算过PSC71、ARR19999时CCR500对应0度、CCR2500对应180度。代码就是__HAL_TIM_SET_COMPARE(htim2, TIM_CHANNEL_1, angle);比如想让舵机转到90度脉宽1.5msangle1500。这个映射关系是线性的做个简单比例计算就行。无源蜂鸣器同理只要让PWM频率落在蜂鸣器谐振点附近常见是2.7kHz、4kHz就能发声。主动发声和被动发声的区别本质是“给电”和“给频率”的区别定时器PWM就是给频率的绝佳工具。4.3 输入捕获用定时器测频率和脉宽测频率的最高性价比方案是用CubeMX直接配置PWM Input mode。拿TIM2的CH1举例配置PWM Input on CH1两个CCR寄存器会自动分工——一个捕获周期一个捕获占空比。启动捕获中断HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);在回调里读取结果void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { uint32_t period HAL_TIM_ReadCapturedValue(htim2, TIM_CHANNEL_1); uint32_t duty HAL_TIM_ReadCapturedValue(htim2, TIM_CHANNEL_2); frequency 1000000 / period; // 1MHz计数时钟时period单位为us dutyRatio duty * 100 / period; }这里的1MHz来自PSC71period单位就是us频率自然就是1000000/period。看到没又是“PSC配成好算的1MHz”这一招。一定要记得处理溢出。如果被测信号频率很低CNT在两次上升沿之间翻了好几次读出来的差值偏小频率就虚高。稳妥做法是在定时器更新中断里加一个溢出计数计算时把溢出次数×(ARR1)补回去。5. 定时器调试踩坑实录翻车现场复盘5.1 为什么CNT加到ARR却没有触发中断这是Stack Overflow上定时器问题里最高频的一类“我的计数器明明在跑CNT也会清零但中断就是不进去。”根据我实际排障经验按概率排序的原因是这样的原因特征解决方向NVIC没使能更新标志UIF已置1程序没进中断使能对应定时器的global interrupt定时器没StartCNT根本没动但你只初始化没启动调用HAL_TIM_Base_Start_IT或TRx1寄存器版忘了清中断标志中断只进一次之后卡死中断里清UIF/使用HAL回调自动清时钟树/外设时钟没使能写寄存器完全无效CNT为0使能RCC/RGU的APBx外设时钟预装载影子寄存器延迟ARR第一次配置后需要先等一个更新事件才全生效初始化后主动产生一次更新或使能ARPE相关配置排查手法也很简单第一步看CNT寄存器是否在跑第二步看UIF或SR标志位有没有置1第三步查NVIC使能位。三步走完绝大部分“神秘失踪”的中断都会现出原形。还有一个进阶坑用HAL库时HAL_TIM_Base_Start_IT()放在了中断初始化之前执行或者中断回调里占用时间太长导致后续中断一直被挂起表现就是“偶尔不触发”。这种问题靠逻辑分析仪看中断响应时间比肉眼猜靠谱得多。5.2 为什么PWM占空比到不了100%热词里“stm32定时器输出占空比到不了100”这个问题背后是个很有意思的硬件细节。我把CCR直接设成和ARR相等输出却不是恒高而是差了那么一丁点为什么因为比较器的工作方式是CNT和CCR每时每刻都在比较当CNT从ARR-1跳到ARR的那一刻CNT等于CCR模式1会让输出翻转为无效电平。然后下个时钟CNT归零重新变成CNT小于CCR输出恢复有效电平。于是每个周期里都必然存在一个小小的“无效电平窗口”占空比永远差了 1/(ARR1) 的比例。想要真正的100%占空比正确姿势是让CCR比ARR还大比如ARR999时CCR设成1000这样CNT永远满足“小于CCR”输出恒为有效电平。或者干脆用硬件强制输出很多定时器有强制高电平输出功能比如设置OCxM为Force High专门应对这种需求。反过来0%占空比也会踩类似的坑想输出“绝对低”光靠CCR0在一些比较器实现下仍然会有毛刺。稳妥方案是强制低或者直接把GPIO拉低。这些都是我在实际项目里花了一下午才验证出来的经验先记住能少烧不少脑细胞。5.3 多个定时器如何同步启动多路PWM联动场景下比如四路电机输出或者多头激光系统如果每个定时器的启动只是软件里接连执行几行HAL_TIM_PWM_Start相位会存在微小的不确定偏差。虽然看起来是一瞬间但讲究的场合这零点几微秒的偏差就够系统出问题。硬件级别的解法是定时器主从同步。思路是这样把定时器A设为主机打开它的触发输出TRGO更新事件发生时对外发一个触发脉冲定时器B设为从机配置触发模式收到A发来的脉冲后立即启动。因为硬件路径是直达的启动时刻可以做到严格对齐。STM32/CubeMX里的配置非常直观主定时器选择Trigger Output为Update Event从定时器的Slave Mode选Trigger ModeTrigger Source选对应的ITR信号。假设TIM2的TRGO接到了TIM5的ITR0那TIM5就会被TIM2的更新事件拉起。这种配置在电机FOC控制、多轴协同里几乎是标配操作。5.4 软件定时器和硬件定时器别再混为一谈最后必须把一条路掰直软件定时器和硬件定时器完全是两个物种。Linux下的内核定时器、hrtimerJava的ScheduledExecutor前端里五花八门的cron表达式组件还有RTOS里的软件定时器本质上都是“在某个系统节拍中断到来时检查一堆到期任务”。它们的精度受系统负载、中断延迟、调度策略影响拿来跑个轮询、做个超时判断没问题但想输出精确到微秒的方波或者做硬实时的电机控制那就是自讨苦吃。硬件定时器正好相反时钟源、计数器、比较器全在外设里信号走路不经过CPU也不依赖操作系统的调度。所以“我用软定时器测脉宽”这类需求从一开始就选错了工具。搞嵌入式最怕的就是“看起来很忙其实方向错了”。硬件定时器能干的活尽量别让软件去背。写在最后定时器这个外设一旦把“时钟进、计数动、比较到、事件出”这条逻辑线刻进脑子里51、STM32、GD32、SysTick这些平台在你眼里都只是“同一幅图的不同画法”。我现在每次接手一个新芯片第一件事就是把定时器框图翻出来找到时钟源、PSC、CNT、ARR、比较器这五样东西再复杂的模式也跟着通了。最后分享一个我自己受用多年的调试习惯遇到定时器行为不对先别急着改代码拿张纸把时钟树抄一遍把PSC和ARR按公式算一遍再画一下CNT的波形。90%的问题其实在纸面上就能看出来真正需要动逻辑分析仪的情况少之又少。定时器的问题多半不是芯片有bug而是我们没把“数数”这件事想透。