☰
STM32定时器寄存器级实战:从定时中断到PWM与输入捕获
2026/10/13 12:06:46 网站建设 项目流程

前几章我们把 GPIO、时钟树这些“地基”聊透了,这一章终于轮到单片机里最磨人、也最值钱的一个外设——定时器(TIM)。第五章我打算用纯寄存器的方式,把定时器从内到外拆开讲一遍:定时中断、PWM 输出、输入捕获,这三个功能覆盖了日常开发里八九成的定时器使用场景。为什么强调寄存器而不是库函数?因为寄存器层面的东西一旦弄懂,后面看各种封装库的代码就像看透明盒子,你清楚每一行配置在硬件上到底干了什么事。这一章适合刚把 GPIO 玩熟、想往深走一步的朋友,也适合那些“调库一时爽、换个芯片火葬场”的兄弟——把定时器的骨架搭起来,换平台只是改几个地址的事。

1. 定时器到底是什么:从“节拍器”到“多面手”

1.1 定时、计数、PWM、捕获:四大看家本领

很多人一听“定时器”就觉得它只是个能定时的东西,实际上定时器在单片机里是个不折不扣的多面手。它最基本的形态是一个计数器:内部时钟每跳一下,计数器加一或者减一,这就是“定时”的雏形;如果计数脉冲来自外部引脚,那它就是“计数”;当计数器的值和某个预设值比较,输出引脚电平翻转,这就变成了 PWM;当外部信号跳变时把当前计数器的值“快照”下来,这就叫输入捕获。

你可以把定时器想象成一个拿着秒表的人:节拍器每滴答一声,秒表走一格;到了设定时间,它会举手报告(产生中断或者触发事件);如果你给它一个目标值,它还能在计数的过程中不停比较“现在到没到”,到了就改变引脚状态,这就是 PWM 的基础。掌握了这个心智模型,后面所有的寄存器配置都是围绕着“数数、比较、报告”这三件事展开的。

1.2 先从时钟树说起:TIM 的时钟到底从哪来

寄存器配置的第一步永远是时钟。定时器挂在两条总线上:高级定时器 TIM1、TIM8 挂在 APB2 上,通用定时器 TIM2~TIM5、基本定时器 TIM6、TIM7 挂在 APB1 上。这里有个新手必踩的坑:很多人以为挂在 APB1 上的定时器时钟就等于 APB1 时钟,于是拿 36MHz 去算周期,结果全错了。

在 STM32F1 系列里有一条隐藏规则:只要 APB1 预分频系数不是 1,定时器时钟就会自动翻倍。比如系统时钟 72MHz,APB1 分频到 36MHz,那么挂在 APB1 上的 TIM2~TIM7 实际时钟是 72MHz,而不是 36MHz。挂在 APB2 上的 TIM1、TIM8 因为 APB2 往往就是 72MHz,所以不需要翻倍。这条规则在其他系列上也类似,但具体倍频关系要看参考手册里的时钟树图。算定时器频率之前,先花十秒钟确认一下自己的定时器到底吃的是哪个时钟源,能省掉后面一大半排查时间。

2. 五大核心寄存器,把它当装修图纸看

2.1 TIMx_CR1:全局开关和计数器模式

定时器最重要的开关在控制寄存器 TIMx_CR1 里。第 0 位 CEN 是计数器使能位,这一位为 1,计数器才开始跑,为 0 就停摆。很多人的定时器“不工作”,第一个要查的就是这里。除此之外,CR1 里还有几个关键位:第 7 位 ARPE 控制自动重装载寄存器 ARR 是否使用影子寄存器,第 4 位 DIR 控制计数方向(向上还是向下)。

影子寄存器这个概念值得多说两句。某些寄存器在运行时不能直接“改了立刻生效”,它先被写到一个缓冲位置,等到特定时刻才一次性搬到硬件真正使用的寄存器里,这个缓冲位置就叫影子寄存器。ARPE 置 1 后,你写 ARR 的值会先进入影子区,等到计数器溢出(更新事件)时才真正生效。这么做的好处是避免在计数器运行中改周期导致波形混乱,就像比赛进行中突然换终点的位置,运动员会懵。实际写代码时,我建议涉及 ARR 都开启 ARPE,养成好习惯。

2.2 TIMx_PSC 与 TIMx_ARR:分频和周期的黄金搭档

TIMx_PSC 是预分频寄存器,16 位,0~65535。它负责把定时器输入时钟降频,实际分频系数是 PSC+1,也就是说 PSC 写 0 表示不分频,写 7199 表示分 7200 倍。为什么是 PSC+1?因为 PSC 存放的是“要跳过几个脉冲”,而一个分频周期本身还要算上当前脉冲,所以加 1。TIMx_ARR 是自动重装载寄存器,16 位,决定计数周期的终点:计数器从 0 数到 ARR,然后就产生一次更新事件。

两个公式是定时器的命根子:

  • 计数器时钟频率 = 定时器输入时钟 / (PSC + 1)
  • 更新事件频率 = 计数器时钟频率 / (ARR + 1)

拿 72MHz 举例:PSC = 7199 时计数器时钟是 10kHz,也就是每格 0.1ms;ARR = 9999 时,更新事件频率就是 10000 / 10000 = 1Hz。这样一套组合拳就把毫秒级的节拍做出来了。注意 ARR 和 PSC 都是 16 位,最大值 65535,要想定时超过几秒,光靠这两个寄存器是顶不住的,得配合后面的中断里“翻牌”计数。

2.3 TIMx_CNT、TIMx_SR、TIMx_DIER:状态和中断的联动

TIMx_CNT 是计数器本体,你在调试器里盯着它看,就能直观地看到数字在跳。TIMx_SR 是状态寄存器,记录各种事件有没有发生,比如第 0 位 UIF 是更新事件标志,第 1 位 CC1IF 是通道 1 捕获/比较事件标志。TIMx_DIER 是中断使能寄存器,对应每个事件都有独立的使能位,比如 UIE 使能更新中断、CC1IE 使能捕获比较中断。

这三个寄存器加 CR1 的逻辑关系是:中断使能了(DIER),事件发生了(SR),并且中断总开关 NVIC 也开了,CPU 才会跳进中断服务函数。这里有个经典坑:更新事件标志 UIF 清除方式是“写 0 清除”,但很多人照着某些例程写成TIMx->SR |= 0,以为置 0 就是清,结果标志永远清不掉,中断一直重入。正确做法是TIMx->SR &= ~TIM_SR_UIF;或者直接写TIMx->SR = 0;,把整个状态寄存器清零。写 0 清标志这个逻辑和 GPIO 写 1 清中断反着来,刚接触时特别容易搞混。

3. 第一实战:定时中断让 LED 精确 1 秒翻转

3.1 参数计算:PSC 和 ARR 怎么配

先说需求:让板载 LED 每 1 秒钟翻转一次。假设系统时钟已经按照前面章节配置到 72MHz,我选用挂在 APB1 上的 TIM2,按前面说的倍频规则,TIM2 的输入时钟就是 72MHz。

计算过程分两步。第一步,把 72MHz 分频到 10kHz,也就是 0.1ms 为一个计数节拍:PSC = 72000000 / 10000 - 1 = 7199。第二步,让计数器数 10000 个节拍产生一次更新,也就是 10000 × 0.1ms = 1s:ARR = 10000 - 1 = 9999。这里又一次体现“寄存器里存的是减一后的值”,PSC、ARR 都是如此。如果分频完不是整数,就取舍一下,误差一般都能接受,但最好让分频后的频率能被 1s 整除,否则你会看到 LED 越走越偏。

3.2 初始化代码逐个寄存器解释

代码如下,注释里写了每行的作用:

void TIM2_Init_1Hz(void) { RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 1. 打开 TIM2 的时钟门 TIM2->PSC = 7199; // 2. 72MHz / 7200 = 10kHz TIM2->ARR = 9999; // 3. 计数 10000 次 = 1s TIM2->CR1 |= TIM_CR1_ARPE; // 4. ARR 影子寄存器使能 TIM2->EGR |= TIM_EGR_UG; // 5. 产生一次更新事件,把影子值搬到"真身" TIM2->DIER |= TIM_DIER_UIE; // 6. 使能更新中断 TIM2->CR1 |= TIM_CR1_CEN; // 7. 启动计数器,开始跑 NVIC_EnableIRQ(TIM2_IRQn); // 8. 打开 NVIC 这一路中断 }

第 5 步很容易被忽略。前面说了 ARPE 置 1 后 ARR 进了影子寄存器,如果不手动产生一次更新事件,影子值可能迟迟不生效,计数器用的还是复位默认值,导致周期完全不对。EGR里的UG位就是干这个的,写 1 会立刻产生一次更新事件,把 ARR、PSC 的影子值都装载到工作寄存器里。虽然之后计数器每次溢出也会自动装载,但初始化阶段手动触发一次是最稳妥的。

GPIO 那边也要配好。如果 LED 在 PC13,就把 GPIOC 时钟打开,PC13 配成推挽输出:

RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; GPIOC->CRH &= ~(0xF << (4 * (13 - 8))); // 清空 PC13 配置位 GPIOC->CRH |= (0x2 << (4 * (13 - 8))); // 通用推挽输出,2MHz 速度

3.3 中断服务函数里要注意的那点事

void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) // 先判断是不是更新事件 { TIM2->SR &= ~TIM_SR_UIF; // 清标志,别忘了这一句 GPIOC->ODR ^= (1 << 13); // 翻转 LED } }

中断函数里第一件事永远是“判断标志”,原因是同一个定时器的多个中断源可能共用同一个入口,你不判断就处理,万一进来的是别的标志位,逻辑就乱了。第二件事是清标志,而且是写 0 清。第三件事才是干活。还有一个细节:中断函数里尽量少做耗时操作,比如我这里只是翻转一个引脚,非常快;如果你要在中断里做浮点运算、打印日志,定时精度会被拖垮。

调试时有个技巧可以立刻验证定时器在不在工作:把 ARR 临时改成 999,那么中断频率变成 10Hz,LED 闪烁快 10 倍,肉眼立见分晓。

4. 第二实战:PWM 呼吸灯,从“点亮”到“调光”

4.1 PWM 的内部原理:输出比较器怎么工作

PWM 的本质是脉冲宽度调制,说白了就是让引脚在一段固定周期内,高电平和低电平的时间比例可调。这个比例叫占空比。定时器实现 PWM 靠的是“输出比较”机制:计数器 CNT 从 0 向上数到 ARR,通道的比较寄存器 CCR 里放一个目标值,硬件里有个比较器不停判断 CNT 和 CCR 的大小关系。在 PWM 模式 1 下,CNT 小于 CCR 时输出有效电平,CNT 大于等于 CCR 时输出无效电平,CNT 计数到 ARR 后清零重来。

可以把这个过程理解成一场赛跑:CNT 是运动员,ARR 是赛道长度,CCR 是终点线前的某个标记点。运动员跑过标记点之前,引脚输出高电平;跑过标记点之后到终点,输出低电平。下一圈继续重复。所以周期由 ARR 决定,占空比由 CCR 和 ARR 的比值决定,只要调节 CCR,占空比就跟着变,而周期纹丝不动——这对控制 LED 亮度、电机转速来说太关键了。

4.2 PWM 模式寄存器配置细节

这里再往深挖一层,我以 TIM3 通道 1 输出 PWM 为例,对应引脚是 PA6。配置分为两大部分:GPIO 和定时器,缺一个波形都出不来。

GPIO 必须配置成“复用推挽输出”,不是普通推挽输出。普通推挽输出是 CPU 控制引脚,复用推挽输出是让定时器的输出比较信号直接接管引脚。这一点我见过太多人踩坑,GPIO 配置成普通推挽,PWM 配置得再对也是没有波形。

RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; // GPIOA 时钟 GPIOA->CRL &= ~(0xF << (4 * 6)); GPIOA->CRL |= (0xB << (4 * 6)); // PA6 复用推挽输出,50MHz

定时器这边,通道 1 的输出配置主要落在三个寄存器上:CCMR1 负责输出模式,CCER 负责输出开关和极性,CCR1 负责比较值。

RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; // TIM3 时钟 TIM3->PSC = 71; // 72MHz / 72 = 1MHz,即 1us 一格 TIM3->ARR = 999; // PWM 周期 = 1000us = 1kHz TIM3->CCMR1 |= (6 << 4); // OC1M = 110,PWM 模式 1 TIM3->CCMR1 |= TIM_CCMR1_OC1PE; // 输出比较预装载使能 TIM3->CCER |= TIM_CCER_CC1E; // 使能通道 1 输出 TIM3->CCR1 = 500; // 初始占空比 500/1000 = 50% TIM3->EGR |= TIM_EGR_UG; // 立即更新影子值 TIM3->CR1 |= TIM_CR1_ARPE | TIM_CR1_CEN; // ARR 缓冲 + 启动

CCMR1 里的 OC1M 是输出模式位,3 位二进制。110 是 PWM 模式 1,111 是 PWM 模式 2,两者的区别简单说就是有效电平出现的区间相反:模式 1 是 CNT < CCR 时输出有效电平,模式 2 是 CNT > CCR 时输出有效电平。日常用得最多的是模式 1。CCER 的 CC1P 位控制极性,默认 0 是有效电平为高,置 1 后有效电平为低。如果你发现占空比和预期“反了”,先查极性位是不是被改了。

4.3 呼吸灯完整代码与效果调试

呼吸灯的效果是人眼感觉 LED 先变亮再变暗,周期性地循环。实现思路很简单:用一个循环让 CCR1 从 0 慢慢加到 999,再慢慢减回 0,每次步进后延时 2ms,相当于调节占空比在 0% 到 100% 之间来回扫。

while (1) { for (uint16_t duty = 0; duty <= 999; duty++) { TIM3->CCR1 = duty; Delay(2); // 大约 2ms 延时 } for (uint16_t duty = 999; duty > 0; duty--) { TIM3->CCR1 = duty; Delay(2); } }

注意一个细节:CCR1 也是一级影子寄存器。配置了 OC1PE 后,你写 CCR1 的值不会立刻改变输出,而是在下一次更新事件(也就是下一个 PWM 周期)才装载。在 1kHz 的 PWM 下,这个“延迟”只有 1ms,肉眼完全看不出来,但它保证了一段周期内占空比是稳定一致的,不会出现半个周期的畸形波形。

调试 PWM 最有效的工具是示波器或逻辑分析仪。把探头搭在 PA6 上,你应该看到一串固定频率的方波。占空比在 50% 时方波上下各半,亮暗循环时方波的“胖瘦”在变化。如果没有示波器,至少准备一个 LED 串联电阻接在输出脚上,看亮度是否能随 CCR 变化,这是最朴素的验证手段。

4.4 为什么占空比调节要用 CCR 而不是 ARR

很多人会问:占空比不就是高电平时间除以周期吗?那我直接改 ARR 不也一样?这里面的门道在于,ARR 一变,周期就变了,PWM 的频率也变了。而许多应用场景要求频率固定,比如电机驱动的载波频率、LED 调光不能有频闪,频率一飘,整个系统行为就乱了。CCR 独立承担占空比调节,ARR 固定承担周期设定,两者职责分离,正是硬件设计精妙的地方。改 CCR 只改变脉宽,不改周期;改 ARR 则周期和占空比同时变,一般只在需要调整 PWM 频率时才碰它。

5. 第三实战:输入捕获测脉宽,给你的板子装一只“耳朵”

5.1 捕获流程拆解

输入捕获和 PWM 是定时器的一体两面,PWM 是输出比较信号到引脚,输入捕获是引脚信号反过来控制定时器“拍照”。当外部信号出现设定的边沿(上升沿或下降沿)时,硬件会把当前 CNT 的值瞬间复制到 CCR 寄存器,同时置位捕获标志 CC1IF,并可以触发中断。这个过程不需要 CPU 参与,捕获精度只取决于计数器的分辨率,这在测量外部脉冲宽度、频率、电机转速等场景里是标准手段。

以测量一个高电平脉冲的宽度为例,最直接的思路是:先捕获上升沿时刻 t1,再捕获下降沿时刻 t2,t2 - t1 就是高电平持续的时间。难点在于计数器是 16 位的,数到 65535 后会回绕到 0,如果脉宽较长跨过了回绕点,直接相减会得到错误结果,所以必须配合更新事件统计回绕次数。

5.2 配置步骤与代码

我以 TIM2 通道 1(PA0)为例,把定时器时钟配成 1MHz,也就是 1us 一格。PSC = 71,输入捕获本身用不到 ARR 做比较,但为了在计数器溢出时产生更新事件,给 ARR 配成 65535(16 位最大值)即可。

RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~(0xF << (4 * 0)); GPIOA->CRL |= (0x4 << (4 * 0)); // PA0 浮空输入 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; TIM2->PSC = 71; // 1MHz,1us 一格 TIM2->ARR = 65535; // 计数满后产生更新事件 TIM2->CCMR1 = 0x01; // CC1S = 01,通道1接输入捕获 TI1 TIM2->CCER |= TIM_CCER_CC1E; // 使能捕获,默认上升沿触发 TIM2->DIER |= TIM_DIER_CC1IE | TIM_DIER_UIE; // 捕获中断 + 更新中断 TIM2->EGR |= TIM_EGR_UG; TIM2->CR1 |= TIM_CR1_CEN; NVIC_EnableIRQ(TIM2_IRQn);

CCMR1 里的 CC1S 位决定通道方向,00 是输出,01 是输入捕获到 TI1。同一寄存器在不同方向下,各字段含义也完全不同,这是寄存器手册里最容易看晕的地方。输入模式下的 IC1PSC 是输入捕获预分频,IC1F 是数字滤波,后面排查干扰时会用到。

5.3 测量高电平时间的完整思路与误差分析

中断服务函数里维护一个简单的状态机:第一次捕获(上升沿)记录 t_rise,然后把捕获极性改成下降沿;第二次捕获(下降沿)记录 t_fall,两者相减得到脉宽,再把极性改回上升沿。更新中断用来累计回绕次数。

volatile uint32_t t_rise = 0; volatile uint32_t pulse_width = 0; volatile uint16_t overflow_cnt = 0; volatile uint8_t capture_state = 0; // 0: 等待上升沿,1: 等待下降沿 void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR &= ~TIM_SR_UIF; overflow_cnt++; // 计数器回绕一次,累加 } if (TIM2->SR & TIM_SR_CC1IF) { uint16_t cap = TIM2->CCR1; // 读 CCR1 会自动清除 CC1IF if (capture_state == 0) { t_rise = cap; overflow_cnt = 0; // 从上升沿开始重新计算回绕 TIM2->CCER |= TIM_CCER_CC1P; // 改极性为下降沿 capture_state = 1; } else { uint32_t width = cap - t_rise + (uint32_t)overflow_cnt * 65536; pulse_width = width; // 单位:1us 一格 TIM2->CCER &= ~TIM_CCER_CC1P; // 改回上升沿 capture_state = 0; } } }

误差来源要心里有数。一是量化误差,计数器 1MHz 分频下分辨率是 1us,任何脉宽测量结果都会有 ±1us 的固有误差,这在大多数场景下可以忽略。二是数字滤波延迟,如果开了 IC1F 滤波,边沿会被延迟若干个采样周期,导致脉宽测出来偏大或偏小。三是信号本身的抖动,这是外部因素,只能通过滤波和多次测量取平均来缓解。如果被测量信号就是板上某引脚自己输出的 PWM,那误差几乎可以忽略,捕获结果会非常稳定。

6. 那些年我踩过的定时器坑:常见问题与排查实录

6.1 定时器“罢工”现象的排查起点

我遇到过很多次同一个场景:代码检查了无数遍,逻辑没问题,定时器就是不跑。这时候我从不急着看配置,先直奔 RCC,看定时器的时钟门开没开。忘了开时钟是最常见、也最隐蔽的问题,因为无报错、无死机,就像一栋楼电箱总闸没拉,灯怎么按开关都不亮。其次是 CEN 位没置 1,很多人配置了一堆寄存器,唯独漏了启动计数器这一下。排查方法很简单:在调试器里看 TIMx_CNT 的值,如果始终是 0,几乎可以断定时钟没配好或者 CEN 没置位;如果 CNT 在跳,但中断不触发,再往 DIER 和 NVIC 方向查。

6.2 中断进不去:三个开关缺一不可

定时器中断要触发,三个开关必须全部打开:DIER 里的对应事件中断使能位、NVIC 里这个外设的中断通道、以及总中断(如果在裸机里关了的话)。缺任何一个,中断都不会响应。还有个容易忽略的细节:不同定时器的中断向量名不同,TIM2 是TIM2_IRQHandler,TIM3 是TIM3_IRQHandler,写错服务函数名编译器不报错,但链接时不会自动帮你纠正,中断永远进不去。检查办法是在中断服务函数第一行放一个调试引脚翻转或者断点,看能不能走到。

6.3 PWM 没波形、捕获值跳变的排查清单

我把工作中最常见的几个问题整理成一张速查表,方便你对照快速定位:

现象大概率原因排查方向
PWM 引脚始终低电平GPIO 没配复用推挽或 CCER 的 CCxE 没置 1查 GPIO CRL/CRH、查 CCER
PWM 波形反相CCER 的 CCxP 极性与预期相反检查有效电平极性配置
改了 CCR 占空比没反应OC1PE 未使能导致影子机制异常,或 CCR 超出 ARR检查 CCMR 预装载位
高级定时器没输出TIM1/TIM8 需要额外使能主输出 MOE查 BDTR 寄存器的 MOE 位
捕获值忽大忽小输入信号毛刺、引脚悬空、滤波未配置加 IC1F 滤波,确认信号源
定时时间偏差接近一半APB1 倍频规则没算对,使用了错误的总线时钟核对时钟树和实际频率

高级定时器那条是很多人忽略的知识点:TIM1、TIM8 作为高级定时器,输出 PWM 之前不仅要把 CCER 的通道输出打开,还需要把 BDTR 寄存器里的 MOE 主输出使能位置 1,否则引脚上永远不会有波形。这是它和通用定时器最大的区别。

6.4 几条能救命的调试习惯

最后分享几个我没少吃苦头换来的调试习惯。第一,初始化定时器后,先用调试器的 Watch 窗口盯着 CNT 看几秒,确认在跳、节奏大致对,再往下配置中断和输出,逐层验证比一次性全配完再排查高效得多。第二,改参数时一次只改一个变量,比如先固定 PSC 只调 ARR,或者固定 ARR 只调 PSC,避免两个变量同时变导致你根本不知道是谁影响了现象。第三,怀疑中断频率不对时,不要凭感觉数 LED 闪烁,直接在中断里翻转一个 GPIO,用示波器或逻辑分析仪去数这个引脚的频率,实时数据永远比感觉可靠。第四,遇到诡异现象先怀疑自己,对照参考手册的寄存器位定义逐位核对,绝大多数所谓“芯片坏了”最后都是配置位看错了。定时器章节的内容到这里就形成了一个比较完整的小闭环:计数、比较、输出、捕获,全部基于寄存器层面通了。我个人的体会是,把这一章亲手敲过一遍之后,再回头看那些封装库的定时器初始化代码,基本就是“翻译题”了。下一章如果继续往下写,我打算聊聊串口通信,这也是嵌入式开发里绕不开的硬骨头。

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

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

立即咨询