☰
STM32通用定时器TIM寄存器详解:从PWM输出到定时中断的底层原理
2026/10/14 20:02:51 网站建设 项目流程

如果你手里已经有一块开发板,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 输出就会一直是一个电平。举个例子,ARR=999,CCR=500,那么前 500 个计数值输出高、后 500 个输出低,占空比 50%。

CCMR1/CCMR2 捕获/比较模式寄存器:这个寄存器决定 CCR 到底是用来捕获还是比较,还分通道,比较模式又分 PWM 模式 1、PWM 模式 2、翻转模式、强制输出。里面还有 OC1PE 预装载使能位,和 PSC/ARR 一样,同样有影子机制。要输出 PWM,通常把两个通道配置为 PWM 模式 1,即 CNT < CCR 时输出有效电平,CNT >= CCR 时输出无效电平。

CCER 捕获/比较使能寄存器:每一个 bit 控制一路通道的输出使能。只配置了 CCMR,没开 CCER,PWM 信号根本走不到引脚上。CCER 最低位 CC1E 对应输出/捕获通道 1 的使能。

SR 状态寄存器和 DIER 中断使能寄存器:SR 是一堆标志位,DIER 管要不要把对应的标志转成中断。以更新事件为例,SR 的 UIF 位会置 1,DIER 的 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] = 10,MODE[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,选 PSC=71,计数脉冲为 1MHz;再选 ARR=999,一圈为 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 却想要 1kHz,PSC 配成 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 时记得先确认引脚是复用推挽而不是开漏输出,开漏输出没有内部上拉时会读不到高电平,波形会一直悬空或低电平。这几点做到位,定时器这块基本就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询