如果你手里已经有一块开发板GPIO 点灯、时钟折腾、中断也都玩过了那下一关大概率就是定时器。这章我翻来覆去改了三版最后决定用寄存器白话解释的方式把通用定时器 TIM 讲明白。很多人一看到寄存器手册第四章这种标题就以为是要背寄存器地址表其实不是。这一章真正想解决的是当你需要精确延时、产生周期信号、做 PWM 输出的时候为什么是靠一堆寄存器的组合而不是靠 delay 函数硬凑。这一章以 STM32F1 系列为基础核心是通用定时器TIM2/TIM3/TIM4/TIM5同时把预分频器 PSC、自动重装载寄存器 ARR、捕获比较寄存器 CCR 这几个核心概念讲透。适合三类读者刚从 GPIO/中断学完准备进入定时器的初学者被 HAL 库封装整得云里雾里想知道底层到底发生了什么的过渡期开发者以及需要在旧项目上做寄存器维护的工程师。1. 内容整体设计与思路拆解1.1 为什么第四章该讲定时器顺着前面章节的节奏GPIO 是让单片机动起来的第一步时钟章节解决的是芯片醒了没、跑多快第三章中断解决的是芯片怎么对突发事件做反应。到这一步你手里的芯片能做的基本操作已经很完整了但要让它真正干活定时器是绕不过去的一堵墙。比如你做了一个信号采集的小板子需要每 10ms 采样一次传感器或者需要让 LED 从亮到暗平滑呼吸或者想产生一个 50Hz 的方波去驱动机器。这些需求用延时函数能不能做能但做得很粗糙。主循环一边 delay 一边干别的事时间就不准了到了业务逻辑稍微复杂一点延时嵌套延时整个程序就像堵车现场。定时器干的事情就是把计时这个工作独立出去闹钟响了发个中断其他时间该干嘛干嘛。另一个更实际的原因是定时器是 STM32 里寄存器数量最多、交互最复杂的外设之一。你去看 TIM2 那一堆寄存器CR1、CR2、SMCR、DIER、SR、EGR、CCMR1、CCMR2、CCER、CNT、PSC、ARR、RCR、CCR1 到 CCR4、DCR、DMAR十几个。这还没算高级定时器。如果一上来就背这些谁都背不下来。但如果能把它们的角色拆清楚你会发下整个定时器就是一个数数-比较-触发事件的流水线这章想帮你搭的就是这个框架。1.2 定时器的大脑从 C 语言思维切到硬件思维学寄存器最容易犯的错是继续用 C 语言的那套顺序执行思维去理解硬件。比如你写counter这是一个动作执行完就没了。但定时器的计数器 CNT 不是这样的只要时钟还在跑CNT 自己就在那里不断加一你写不写代码它都在数。它就像一个永远在跑秒表的小工你只需要告诉它数到多少算一圈ARR数多快算一格PSC数到某个点上要不要通知你DIER/SR。换这个思路之后寄存器就没有那么可怕了。你不需要操作 CNT因为它在被动地累加你需要操作的是边界和通知方式。这就是为什么这是第四章而不是第一章——它需要你接受单片机里有一部分逻辑是自动运行、并行于 CPU 的而不是你每一行代码指挥出来的一步操作。2. 核心细节解析与实操要点2.1 预分频器和自动重装载把 72MHz 变成你想要的节奏先拆第一个大问题定时器数数数多快STM32F103 的定时器时钟最高能到 72MHz。注意我这里的用词是 72MHz 而不是 72 万。1MHz 就是一秒振一百万次72MHz 就是一秒七千二百万次每一次振动计数器都有可能加一。如果直接这么数CNT 从 0 数到 65535 也就大概 0.9ms根本做不了秒级延时。这时候预分频器 PSC 登场了。PSC 的作用就是把时钟脉冲按比例降下来。它带来一个很容易搞混的点不是分频后变慢而是PSC 寄存器值 1 个原始脉冲才产生 1 个计数脉冲。所以实际计数频率的计算公式是计数脉冲频率 定时器时钟 / (PSC 1)为什么是加一因为 PSC 写 0 时就是 1 分频也就是原速。从 0 开始计数是 STM32 寄存器的老传统后面 ARR 那边还会遇到一次。然后是 ARR。ARR 告诉计数器你数到几算完一圈没有 ARR计数器从 0 数到 65535 会溢出一次产生更新事件这是默认行为。有了 ARR你让它数到 999 就跑完一圈跑到 999 之后下个脉冲一来它回到 0同时产生一个更新事件置上更新标志位 UIF。这一圈的时间就是一圈时间 (PSC 1) × (ARR 1) / 定时器时钟这里很多人犯迷糊为什么 ARR 999 实际上是数 1000 次因为计数器是从 0 开始的0、1、2、3……一直到 999这一共是 1000 个数。所以你数到 999实际经历的脉冲是 1000 个。这不是缺陷是硬件设计上一直以来的约定习惯就好。2.2 常用定时器寄存器逐个点名我不打算把参考手册里每个寄存器都抄一遍那没有意义。挑你真正用得到的几个说清楚它们各自管什么以及配的时候有没有坑。CR1 控制寄存器这是定时器的总电源开关和工作模式开关。它的最低位 CEN 是计数器使能位写 1 才开始数数。注意很多人初始化了一大堆寄存器结果忘了置 CEN定时器一点反应没有。另外 CR1 里的 DIR 位控制计数方向0 是向上计数1 是向下。你目前用 0 就够中心对齐模式还要动 CMS 位新手先不碰。PSC 预分频寄存器16 位写在前面讲过。有一个经典坑写入 PSC 的值不会立刻生效要等一次更新事件产生之后新的分频值才真正被加载。换句话说你写了 PSC但 CNT 还在用旧分频跑直到 UIF 置位或者你用 UG 位强行产生一次更新事件新值才上车。这在我遇到的很多新手代码里是隐蔽 bug后面排查部分再细说。ARR 自动重装载寄存器16 位限制计数一圈的终点。它同样有影子寄存器机制写入值要到更新事件才生效。所以标准流程是先设置 PSC/ARR然后产生一次软件更新事件把 EGR 寄存器的 UG 位置 1之后再启动计数。这个顺序很关键。CCR1~CCR4 捕获/比较寄存器这个寄存器名字就把功能写清楚了能干两件事捕获输入信号的边沿或者和当前 CNT 做比较。做 PWM 输出的时候它负责存多少占空比。CCR 的分量不能超过 ARR否则 PWM 输出就会一直是一个电平。举个例子ARR999CCR500那么前 500 个计数值输出高、后 500 个输出低占空比 50%。CCMR1/CCMR2 捕获/比较模式寄存器这个寄存器决定 CCR 到底是用来捕获还是比较还分通道比较模式又分 PWM 模式 1、PWM 模式 2、翻转模式、强制输出。里面还有 OC1PE 预装载使能位和 PSC/ARR 一样同样有影子机制。要输出 PWM通常把两个通道配置为 PWM 模式 1即 CNT CCR 时输出有效电平CNT CCR 时输出无效电平。CCER 捕获/比较使能寄存器每一个 bit 控制一路通道的输出使能。只配置了 CCMR没开 CCERPWM 信号根本走不到引脚上。CCER 最低位 CC1E 对应输出/捕获通道 1 的使能。SR 状态寄存器和 DIER 中断使能寄存器SR 是一堆标志位DIER 管要不要把对应的标志转成中断。以更新事件为例SR 的 UIF 位会置 1DIER 的 UIE 位决定这个置位能不能触发中断。注意读 SR 后清除标志通常是写 0 清标志但 UIF 的话STM32F1 上也支持软件清零可以直接对 UIF 位写 0。2.3 配置顺序的底层含义定时器的配置并不是随便写几个寄存器就完事的。刚才你看到了多个影子寄存器它们的存在让配置顺序变成了一种必须遵守的逻辑先定好 PSC 和 ARR再用 EGR 的 UG 位触发一次更新事件加载新值再清标志位然后使能输出/中断最后把 CEN 置 1。你要是反着来先启动计数再改 PSC分频值要么没生效要么产生一个意外的更新事件导致多进一次中断。我记得最早自己写的时候图省事把预分频放在主循环里动态改结果 PWM 频率一直不是预期值查了半天发现 PSC 影子寄存器只有在更新事件才加载。后来养成习惯每次改 PSC/ARR 之后顺手把 EGR 的 UG 位置 1问题就再没出现过。3. 实操过程与核心环节实现3.1 工程准备与时钟关系这一章的例子基于 STM32F103C8T6 小系统板。开始之前你至少需要准备好一块带 LED 的板子或者自己接 LED 到某个定时器通道引脚ST-Link 或者串口下载器能烧程序就行用寄存器操作方式初始化时需要确认定时器挂在哪个总线我提醒一句很多人在这里栽跟头后一头扎进代码找问题其实是时钟树没搞明白。TIM2 挂在 APB1 上F1 的 APB1 最高是 36MHz。这里有一个很典型的倍增规则APB1 预分频器不为 1 时定时器时钟是 APB1 的 2 倍。也就是说如果 APB1 倍频后是 36MHz那么挂在上面的通用定时器时钟反而是 72MHz。这不是 bug是芯片手册明确写的。如果你系统时钟配置里 APB1 分频系数是 1那定时器时钟就只有 36MHz直接导致你算出来的 PWM 频率差一倍。3.2 寄存器版定时器初始化这个例程的目标让 TIM3 的 CH1 输出 1kHz 的 PWM利用 CCR1 改变占空比实现 LED 呼吸灯效果。先给定时器时钟。如果使用库函数前的寄存器操作可以直接操作 RCC 的 APB1 使能位RCC-APB1ENR | RCC_APB1ENR_TIM3EN;如果选用的引脚需要重映射还需要开启 AFIO 时钟。例子里我直接把 TIM3_CH1 放在了默认引脚 PA6不需要 AFIO 操作所以可以省掉这一步。PA6 要配置为复用推挽输出因为定时器输出是复用功能不是普通 GPIO 输出。这里的寄存器配置为GPIOA-CRL ~(GPIO_CRL_MODE6 | GPIO_CRL_CNF6); GPIOA-CRL | GPIO_CRL_MODE6_1; // 输出模式速度 2MHz GPIOA-CRL | GPIO_CRL_CNF6_1; // 复用推挽输出注意 CNF 两位的编码我不能瞎编实际是以参考手册中的复用功能输出推挽模式为准。一般习惯是 CNF[1:0] 10MODE[1:0] 10 或 11具体速度根据实际需求。然后是定时器初始化函数。我用了一个结构体来集中配置寄存器方便后面修改参数void TIM3_PWM_Init(uint16_t arr, uint16_t psc) { TIM3-ARR arr; TIM3-PSC psc; TIM3-CCMR1 ~TIM_CCMR1_OC1M; TIM3-CCMR1 | TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; // PWM 模式 1 TIM3-CCMR1 | TIM_CCMR1_OC1PE; // 输出比较预装载使能 TIM3-CCER | TIM_CCER_CC1E; // 使能输出通道 1 TIM3-EGR | TIM_EGR_UG; // 产生更新事件加载影子寄存器 TIM3-CR1 | TIM_CR1_CEN; // 启动定时器 }PWM 模式 1 的二进制编码我不直接给数值因为不同系列可能略有差异建议对照手册确认。写代码的时候用位操作拼出两位都置 1 的效果更稳妥。调用时按照 1kHz 的目标频率来算。时钟取 72MHz选 PSC71计数脉冲为 1MHz再选 ARR999一圈为 1000 个脉冲那就是 1kHz。这个组合下占空比精度 0.1%足够呼吸灯使用TIM3_PWM_Init(999, 71);3.3 呼吸灯主循环实现在主循环里做呼吸灯核心是每次循环把 CCR1 的值沿一个方向步进到达边界后反向。关键技巧是改变 CCR1 不需要关定时器因为 CCR 有预装载机制在下一个更新事件时会自动加载新值。这就是为什么你能看到呼吸效果而不是闪烁跳变。while (1) { for (uint16_t duty 0; duty 999; duty 5) { TIM3-CCR1 duty; delay_ms(10); } for (uint16_t duty 999; duty 0; duty - 5) { TIM3-CCR1 duty; delay_ms(10); } }delay_ms可以用第三章里学的中断方式做也可以暂时用简单循环。重点在于定时器输出 PWM 是完全独立的CPU 只负责周期性地更新 CCR1输出波形不会因为 CPU 繁忙而停摆。这种硬件自动运行的体验是刚学寄存器时最值得感受到的差异。如果你想用示波器或逻辑分析仪看输出PA6 引脚在空闲状态是低电平占空比随 CCR1 变化。没有示波器的话肉眼看 LED 亮度变化也能直观感受到 PWM 起作用了这是我最推荐的零成本验证方法。3.4 中断方式做 1 秒定时PWM 只是定时器玩法之一。英文里另一个高频需求是定时中断比如每 1 秒翻转一次 LED。配置思路几乎一样只是多了中断配置步骤。void TIM2_Init(void) { RCC-APB1ENR | RCC_APB1ENR_TIM2EN; TIM2-PSC 7199; // 72MHz / 7200 10kHz 计数脉冲 TIM2-ARR 9999; // 10kHz / 10000 1Hz 更新事件 TIM2-DIER | TIM_DIER_UIE; // 开启更新中断 TIM2-EGR | TIM_EGR_UG; // 产生更新事件加载 PSC/ARR NVIC_EnableIRQ(TIM2_IRQn); TIM2-CR1 | TIM_CR1_CEN; }然后中断服务函数里翻转 LED 并清中断标志void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_UIF) { TIM2-SR ~TIM_SR_UIF; GPIOB-ODR ^ GPIO_ODR_ODR0; } }这里说说清标志的细节。F1 的 UIF 可以通过对位写 0 来清除另外也可以读 SR 再读 CNT 由硬件清除但最省事还是写 0 的方式。如果你不清标志中断服务函数就会反复进入表现为 LED 飞快乱闪程序好像卡死一样。这是所有定时器中断最经典的坑。4. 常见问题与排查技巧实录4.1 定时器不工作从易到难逐项查这里把我见过的问题归类成一张表很实用可以收藏以后排查用。现象大概率原因排查思路定时器完全没动静时钟没打开查 RCC 对应总线的使能位电平有输出但频率不对PSC/ARR 算错或总线时钟不对确认 APB1 预分频是否为 1 导致时钟差一倍PWM 输出一直是低CCER 没使能通道检查 CC1E/CC2E 对应位PWM 输出一直是高CCR 大于等于 ARR用示波器确认占空比参数改 PSC/ARR 后不生效没产生更新事件加载影子值EGR 的 UG 位置 1中断只进一次中断标志没清看 SR 对应标志是否持续为 1输出引脚复用不对GPIO 没配成复用推挽检查 CRL/CRH 的 CNF 位这张表里的每条我都踩过或者看人踩过。最隐蔽的是 APB1 预分频和定时器时钟倍频的问题因为它不会让你的程序完全跑不动只是时间和预期差一倍单看代码很难发现问题。养成习惯拿到一个板子先确认系统时钟怎么配置的再反推外设时钟。4.2 一次真实排障过程复盘有一次我用 TIM4 做 PWM 驱动一个蜂鸣器频率打算出 2kHz。代码写完蜂鸣器不响。最初以为是占空比太大导致蜂鸣器不敏感改来改去没用。后来用示波器量引脚发现根本没波形怀疑 CCER 没配好回去查CCER 没问题。再查 GPIO发现 TIM4_CH3 默认在 PB8我初始化的却是 PB9一字之差一个通道完全错位。这就是为什么排查时先确认引脚映射再确认寄存器配置。还有一次是有人把 ARR 配成 1000 却想要 1kHzPSC 配成 71 之后主频其实不是 72MHz——因为他把系统时钟从 HSE 换成了 HSI 而没有重新配置 PLL导致实际主频变成 8MHz。这就是那种看起来都对跑起来全不对的典型。所有基于时间计算的东西一定要确认时钟树而不是想当然认为频率就是 72MHz。4.3 调试工具怎么选示波器、逻辑分析仪、肉眼如果手头有示波器那是调试 PWM 最好的朋友。把探头夹在输出引脚和 GND 上直接看频率、占空比、上升沿质量。没有示波器的话逻辑分析仪也行它已经足够看到基本波形和占空比。如果两个都没有也有测试方法接一个 LED 串联电阻通过亮度变化和呼吸节奏可以判断有没有输出也可以把定时器中断里翻转 LED用手机秒表测闪烁周期来反推中断频率是否正确。我在实际项目中把这种没有仪器的排查法叫辅助信号法就是用一个可见的、稳定的现象去反推问题范围。比如定时器配置没反应就不要死盯寄存器在中断里翻转一个 GPIO用一个单独的示波器通道去测它把问题分割成定时器有没有工作和PWM 输出有没有配置两个独立部分这样排查效率高得多。5. 底层原理与进阶扩展5.1 从 PWM 到输入捕获PWM 是输出方向输入捕获则是把同一个 CCR 寄存器用于读输入信号当引脚出现上升沿或下降沿时硬件会把当时的 CNT 值锁定到 CCR 里同时置标志。这有什么用用两个通道分别捕获同一个信号的上升沿和下降沿用两次 CNT 的差值就能算出脉冲宽度。这就是测量外部信号周期和占空比的基础。以 TIM3 为例将 CH1 配置为输入捕获模式后每次捕获到边沿事件CCR1 自动更新。你只需要读 CCR1再用两次事件的时间差换算信号周期。相比之下用 GPIO 中断手动计时也能做但精度远不如硬件捕获因为中断响应延迟不一致会有很多抖动噪声。5.2 从通用定时器到编码器接口与高级定时器在工业现场或电机控制里定时器还有一个隐藏技能编码器接口模式。STM32 的定时器可以用两个输入通道接收正交编码器信号硬件自动根据相位关系确定旋转方向并计数这在电机测速中非常常用。它不需要额外 芯片寄存器配置也不复杂只要把 SMCR从模式控制寄存器设为编码器模式然后把两个通道配置成输入定时器就会自己计旋转脉冲。再往上走TIM1 和 TIM8 作为高级定时器多出重复计数器、死区发生器、刹车输入、互补输出等能力。这些用在电机驱动的 H 桥或三相逆变器里是为了防止上下桥臂直通而加入死区时间。通用定时器做不了死区所以控制电机时自然会转向高级定时器。理解了通用定时器的 CNT、PSC、CCR 这套核心框架高级定时器对你来说也只是在同样的骨架上加了几组新器官。5.3 为什么推荐顺着寄存器学一轮现在用 STM32CubeMX 生成工程配置定时器就是点几下界面代码自动生成HAL 库把底层细节全都包掉了。这确实省事但也会带来一个副作用概念被封装后出了问题往往只能靠猜。比如 HAL_TIM_PWM_Start 内部到底做了哪些寄存器操作占空比更新为什么有时要调用 HAL_TIM_PWM_Stop 再 Start这些在寄存器层面都是很清晰的逻辑。学一轮寄存器并不是要在所有项目里手写寄存器而是给自己建立一份底层地图。以后不管是调试 HAL 库还是阅读启动代码你能看出它在操作哪个寄存器为什么要写这个值。这种能力很难从配置界面上获得。我个人的体会是定时器是所有 STM32 外设里最值得花时间死磕的一个。你把它吃透了再去学输入捕获、编码器接口、高级定时器、以及后面要学的 DMA 和 ADC 触发都会顺很多。这些功能看起来五花八门但很多都是定时器框架上长出来的枝叶。第四章能把根扎稳后面一路都是顺水推舟。再说一个我调试时的小技巧改完 PSC 或 ARR 之后如果波形不对先用软件把 EGR 的 UG 位置 1 手动触发一次更新事件。这个动作不花多少时间但能排除至少一半影子寄存器没生效的问题。还有调试 PWM 时记得先确认引脚是复用推挽而不是开漏输出开漏输出没有内部上拉时会读不到高电平波形会一直悬空或低电平。这几点做到位定时器这块基本就稳了。