兄弟,到第八章了。这一章我忍了很久,《STM32F103 寄存器白话手册》里如果把定时器落下,后面聊 PWM、输入捕获、编码器全都不好使。所以咱这一章直接啃通用定时器,用寄存器把它从头配一遍,目标特别具体:一个 1ms 的定时中断,再把它扩成一个 PWM 呼吸灯。你会看到 PSC、ARR、CNT 这些寄存器到底是什么逻辑关系,也会明白为什么库里封了一层你反而容易踩坑。这一章适合理清 GPIO 和时钟树之后、想摆脱库函数的人,建议手边开一份参考手册,边看边试。
1. 在动手之前:定时器的三个“为什么”
1.1 为什么不继续用库函数,寄存器操作到底好在哪
很多人在开发板上习惯了点灯点得飞快,一旦要定时就调用TIM_TimeBaseInit,完全不管它填的什么参数。麻烦在于,这样用三个月,你还是说不清“PSC 为什么要减一”“ARR 为什么要减一”“APB1 预分频之后定时器时钟到底是 36M 还是 72M”。
寄存器操作逼你把原理弄清楚。TIM2->PSC = 71这句话写得明摆着:预分频值是 72,不是 71。TIM2->ARR = 999也不会被“封装”成一个装模作样的“自动重载值”,它真的就是计数目标值 999,一个周期过 1000 个时钟。代码里没有中间商,手册写什么,你就写什么。
而且调试的时候,库里函数一层套一层看反汇编非常痛苦,寄存器版本就几行赋值,逻辑一目了然。这才是做嵌入式该有的基本功。以后跳到别的 MCU 平台,不管用哪个厂家的库,能看懂寄存器层的人永远不吃亏。
1.2 为什么偏偏把定时器拎出来写一整章
STM32F103 内部最常用的外设里,定时器应该是信息密度最高、最容易“以为会了但还是错”的一个。它不只是用来延时,还能输出 PWM、做输入捕获、编码器接口,甚至完成多路电机控制。这些功能本质都是同一套时基单元在跑,只是不同的外围寄存器参与进来。你只有把时基单元那点事彻底搞明白,后面才不会被霍尔传感器、超声波测距、无刷电机控制之类的东西卡住。
另一个原因是库函数在这里更容易掩盖错误。比如TIM_Cmd到底改了哪个寄存器?TIM_TimeBaseInit有没有帮你触发 UG 更新事件?这些细节全封进去了。你根本不知道影子寄存器有没有生效,测出来中断乱跳只能干瞪眼。所以我这一章坚持零库函数,全部寄存器直写,让问题在你面前暴露出来。
2. 先认识通用定时器,别急着写代码
2.1 定时器家族,你得先分清谁是谁
STM32F103 里面定时器分三类,很多人上来就是一句“用定时器”,但你先得知道选哪个:
| 定时器 | 类别 | 特点 | 典型用途 |
|---|---|---|---|
| TIM1、TIM8 | 高级控制定时器 | 有互补输出、刹车输入、死区控制 | 电机 PWM、全桥驱动 |
| TIM2、TIM3、TIM4、TIM5 | 通用定时器 | 有输入捕获、输出比较,能做标准 PWM | 延时、呼吸灯、测频、编码器 |
| TIM6、TIM7 | 基本定时器 | 只能做时基,没有输入输出引脚 | 系统时基、触发 DAC |
我们这章拿 TIM2 开刀。它是通用定时器,挂在 APB1 总线上,引脚也灵活,功能正好覆盖定时中断和 PWM 两个核心需求。够用了。
这章聊的是时基单元,也就是预分频器 PSC、计数器 CNT、自动重装载寄存器 ARR 这三个家伙。CNT 就像秒表,PSC 把高速时钟切成慢节奏脉冲,ARR 设定一个终点,秒表走到终点就归零重跑。理解这三个角色,后面全是顺水推舟。
2.2 PSC、ARR、CNT 三个寄存器,是怎么回事
先说预分频器 PSC。它输入是定时器时钟,输出是计数脉冲。公式只有一个:
计数脉冲频率 = 定时器时钟频率 / (PSC + 1)
写 PSC 的时候,寄存器里存的是“分频系数减一”。比如你想把 72MHz 分成 1MHz,那就是 72MHz / 72,所以写 PSC = 71。为什么是 71?因为计数器从 0 数到 71 正好过了 72 个时钟脉冲,才产生一个输出。这个“减一”逻辑贯穿所有分频和重装计算。
CNT 就是计数器本身。它每个脉冲加一或者减一,具体看计数方向。我们默认向上计数:从 0 开始,加到 ARR 的瞬间,一个计数周期结束。这里又来了,“ARR 为什么也要减一”。因为周期数是ARR + 1。你想让计数器数 1000 次之后归零,ARR 就得写 999。你要是写 1000,实际数了 1001 个脉冲,时间就多了千分之一,低频你可能觉察不到,做高精度频率计就露馅了。
把两者合起来算一条完整公式:
定时器中断频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)
要 1ms,也就是 1000Hz,我们站在 72MHz 时钟上:72MHz / 72 / 1000 = 1000Hz,所以 PSC = 71、ARR = 999。这套参数在开发板里出现频率极高,你最好一眼能背出来。
2.3 APB1 那个坑:为什么定时器时钟不是 36M
新手最容易在这里爆雷。STM32F103 最常见的系统时钟配置是 72MHz,但 APB1 总线最高只能跑 36MHz,所以系统启动代码会把 APB1 预分频设成 2 倍分频。这时候你看时钟树,会看到 APB1 上挂着 36MHz。于是有人想当然:TIM2 挂在 APB1,所以定时器时钟是 36MHz。错。
这里有个隐藏规则:如果 APBx 预分频系数是 1,定时器时钟才等于 APBx 频率;如果 APBx 预分频系数大于 1,定时器时钟直接翻倍,等于 APBx 频率的 2 倍。也就是说,APB1 = 36MHz 且分频系数为 2,那么 TIM2 的时钟其实还是 72MHz。
为什么这样设计?因为定时器内部往往要输出高精度 PWM,给它低时钟就浪费了硬件能力。很多串口波特率不正常、定时时间偏一倍的案例,根子都在这。你直接改RCC->APB1ENR打开 TIM2 时钟,然后按 72MHz 算参数,就对了。
3. 几个核心寄存器,直接“白话翻译”一遍
3.1 CR1 控制寄存器:启动开关、自动重装开关、更新开关
CR1 是控制寄存器,16 位,但我们现阶段抓四个重要的位就够:
| 位 | 名字 | 白话含义 |
|---|---|---|
| Bit0 | CEN | 计数器使能。写 1 开始跑,写 0 停止。 |
| Bit1 | UDIS | 更新禁止。写 1 后更新事件被屏蔽,影子寄存器不刷新,一般我们不设。 |
| Bit2 | CMS | 中心对齐模式选择,做普通 PWM 和定时中断时保持 00。 |
| Bit4 | DIR | 计数方向。0 向上计数,1 向下计数,向上计数最常见。 |
| Bit7 | ARPE | 自动重装载预装载使能。设 1 时,ARR 值要等更新事件才真正生效;设 0 时,写 ARR 立刻生效。 |
ARPE 特别值得说。你给 ARR 写了一个新值,寄存器里确实写进去了,但硬件不一定会立刻用。如果 ARPE = 1,那这个值只是一个“预装载”,要等更新事件发生才跳到“影子寄存器”,影子寄存器才是实际参与计数的那个。如果你初始化时忘了触发更新事件,可能启动半天了,用的还是复位值,定时时间完全不对。新手建议第一步先弄清楚这个概念,因为它比普通库写着“Autoreload preload”要有意义得多。
CR1 本身直接操作我们也就写一行:
TIM2->CR1 |= (1 << 7); // ARPE=1,使用预装载特性 TIM2->CR1 |= (1 << 0); // CEN=1,计数器开始跑3.2 DIER 和 SR:中断怎么开,标志怎么清
DIER 是中断使能寄存器。我们开定时器更新中断,只动一个位:
TIM2->DIER |= (1 << 0); // UIE=1,更新中断使能SR 是状态寄存器。这里有个 STM32 的特色:很多标志位是“写 0 清除”。UIF 这个更新标志就是硬件置 1、软件写 0 清除。很多人第一次清标志会想“我把这个位置 1 不就行了吗”,于是写TIM2->SR |= (1 << 0),结果发现中断死循环。因为对写 0 清除的位来说,写 1 根本没有任何动作,你等于没清。
正确写:
TIM2->SR &= (uint16_t)~(1 << 0); // 把 UIF 写 0,清除更新标志还有个细节,清除动作放在中断服务函数末尾,或者一进来先清都行。我个人习惯先判断再清除,然后把业务逻辑放后面,省得后续代码时间长了漏掉标志,导致莫名其妙多进一次。
3.3 EGR 寄存器:为什么需要软件触发一个更新
EGR 只要认识 Bit0,也就是 UG 位。软件写一下这个位,硬件立刻产生一次更新事件。更新事件会做三件事:把预装载值搬到影子寄存器、让 CNT 归零、置位 UIF。
很多人初始化的时候,PSC 和 ARR 都写好了,但总感觉定时不对。原因就是没让更新事件生效。正确顺序是先把参数写进去,再通过TIM2->EGR |= (1 << 0)触发一次更新,让参数真正被硬件接收。这一步在库函数TIM_TimeBaseInit里面也有,只是你看不见。
我强烈建议你每次改完 PSC 或 ARR,都主动写一次 UG,别指望“系统会自动处理”。系统确实会处理,但什么时候处理不确定,靠运气写代码早晚出事。
3.4 为 PWM 准备的寄存器:CCMR、CCER、CCR
PWM 输出其实是在时基单元之上,再加一套“输出比较”电路。CCMR 寄存器用来选择输出比较模式,CCER 用来使能某个通道的输出,CCR 用来设定比较值。三者配合起来,计数器 CNT 和 CCR 一比较,引脚电平就会翻转,从而输出不同占空比的方波。
CCMR1 里面 OC1M 位是输出模式选择,PWM 模式 1 是 110,PWM 模式 2 是 111。CCER 里面 CC1E 是输出使能,CC1P 是极性选择。这些字面看起来抽象,后面直接上代码就能看懂,我先点个名,免得你看参考手册时发蒙。
4. 实操一:用寄存器配出一个 1ms 定时中断
4.1 先做计算,再写代码
我们的核心是72MHz / (PSC+1) / (ARR+1) = 1000Hz。前面已经选好了 PSC = 71、ARR = 999。
实际写代码前,请确认你的系统时钟。大多数开发板使用官方启动文件,SysTick 和时钟配置在SystemInit里,默认是 72MHz。如果你不知道自己的主频,先在你手上板子的时钟配置代码里找到 PLL 倍频系数。我见过有人拿着 8MHz 晶振,把 PLL 配置成 9 倍,结果主频是 72MHz;也见过有人配错成全速 108MHz,本来想 1ms,实际变 0.75ms。所以“算”之前先“确认”。
4.2 初始化代码,全部寄存器操作
void TIM2_Init(void) { // 1. 打开 TIM2 时钟,位于 APB1 RCC->APB1ENR |= (1 << 0); // 2. 设置预分频和自动重装载值 TIM2->PSC = 71; // 72MHz / (71+1) = 1MHz TIM2->ARR = 999; // 1MHz / (999+1) = 1kHz = 1ms // 3. 计数器清零,并软件触发更新,让 PSC/ARR 真正生效 TIM2->CNT = 0; TIM2->EGR |= (1 << 0); // UG = 1 // 4. 清除更新标志,避免一开中断立刻进一次 TIM2->SR &= (uint16_t)~(1 << 0); // 5. 使能更新中断 TIM2->DIER |= (1 << 0); // UIE = 1 // 6. 配置 NVIC,让中断能到达 CPU NVIC_SetPriority(TIM2_IRQn, 1); NVIC_EnableIRQ(TIM2_IRQn); // 7. 使能计数器,启动定时 TIM2->CR1 |= (1 << 0); // CEN = 1 }这段顺序有讲究。先写参数,再用 UG 触发更新,然后清标志、开中断、使能计数。如果你把 CEN 提前,计数器已经开始跑了,但 UIE 还没开,那第一秒内的若干次更新你可能都不知情;如果你先开中断再清标志,中间插进来的更新事件可能让 UIF 重新置位,导致刚使能就进入中断。所以顺序建议固定成这个流程,省得排查无头绪。
4.3 中断服务函数怎么写
中断服务函数的名字必须和启动文件里一致,TIM2 的就是TIM2_IRQHandler,不能自己随便起名。名字错了,编译器不会报错,链接器也不会提醒,但中断永远不会执行。
volatile uint32_t g_tick_count = 0; void TIM2_IRQHandler(void) { if (TIM2->SR & (1 << 0)) // 确认是更新中断 { TIM2->SR &= (uint16_t)~(1 << 0); // 清除 UIF g_tick_count++; } }我在g_tick_count前面加了volatile。这很关键。中断里修改的全局变量,主循环里要读取,编译器一开优化,很可能把读取操作直接优化成寄存器里的旧值,怎么用都觉得“没变”。加 volatile 后,每次读取都从内存拿,这是嵌入式开发者容易忽视的一个深坑。
主函数里怎么用就随你了,比如:
while (1) { if (g_tick_count >= 1000) { g_tick_count = 0; // 你想要的每 1 秒动作 } }注意,不要在中断里做耗时操作。中断里放个标志位,主循环里再处理业务,这个习惯能救你很多次。
4.4 这段代码的几个关键注意点
第一,PSC 和 ARR 都是 16 位寄存器,最大只能写 65535。你算出来的分频值超过这个范围,就必须调整策略,比如把定时器时钟再降一档,或者级联定时器。 72MHz 下取极限值,PSC = 65535、ARR = 65535,最大定时周期大约 60 秒,想要几分钟级别的定时,就得在中断里用软件计数器继续累加。
第二,如果修改 ARR 想在运行中动态调整频率,别直接写完就跑。先暂停定时器或者等当前周期结束,然后写新值,再触发一次 UG。否则新值什么时候生效不可控,输出波形会出现毛刺。
第三,有人习惯在定时中断里同时做多个任务,比如每 1ms 翻转一次 LED、每 500ms 扫描一次按键。我的建议是在同一个中断里只做“计数”,把业务拆到主循环判断计数器次数,别让中断函数膨胀。这能让你的系统响应更稳。
5. 实操二:把定时器变成 PWM 输出,做呼吸灯
5.1 需求与引脚速查
说完了定时中断,接下来搞 PWM。呼吸灯是个非常经典、且能验证寄存器的例子:LED 亮度从暗到亮,再从亮到暗循环。本质就是让 PWM 的占空比周期性变化。
TIM2 的 PWM 输出通道很多,我用 CH1,也就是引脚 PA0。如果你要用其他通道,映射关系不一样,建议翻一下数据手册,避免查错引脚把 AFIO 配置也带偏。
5.2 GPIO 和定时器寄存器配置
想要 PA0 输出 PWM,GPIO 必须配成“复用推挽输出”。这里非常坑:它不是“推挽输出”这么简单,而是“复用”功能。只配置 MODE 位不够,CNF 位也要对。
RCC->APB2ENR |= (1 << 2); // 打开 GPIOA 时钟,位于 APB2 // 清掉 PA0 原来的配置,再设置:MODE=11(50MHz),CNF=10(复用推挽) GPIOA->CRL &= ~(0xFUL << 0); GPIOA->CRL |= (0xBUL << 0);接着配置 TIM2。注意,PWM 如果不加时基,结果可能要么输出恒高要么恒低。时基部分和上一段一样,PSC = 71、ARR = 999,这样 PWM 频率就是 1kHz。
输出比较部分按这个来:
// 继续用 TIM2,打开时钟 RCC->APB1ENR |= (1 << 0); TIM2->PSC = 71; TIM2->ARR = 999; TIM2->EGR |= (1 << 0); // 让时基参数生效 // 配置输出比较模式:PWM 模式 1(OC1M = 110),并使能输出比较预装载 TIM2->CCMR1 &= ~(0x3UL << 8); // 清 CC1S,输入/输出方向设为输出 TIM2->CCMR1 |= (0x6UL << 4); // OC1M = 110,PWM 模式 1 TIM2->CCMR1 |= (1 << 3); // OC1PE = 1,输出比较预装载使能 // 使能通道输出,且不反相(CC1P = 0),比较输出直接到引脚 TIM2->CCER |= (1 << 0); // CC1E = 1 // 初始占空比:50% TIM2->CCR1 = 500; // 使用自动重装载预装载,并启动计数器 TIM2->CR1 |= (1 << 7); // ARPE = 1 TIM2->CR1 |= (1 << 0); // CEN = 1这段代码里,CCMR1的 CC1S 位在低两位,是选择“输入捕获”还是“输出比较”的。如果不先把这两位清零,残留配置可能让外设以为你是要做输入捕获,结果 CCR1 怎么设都没反应。写代码时不惯着自己,上来先清掉,后面好查。
5.3 通过 CCR 调整占空比
PWM 模式 1 配合 CC1P = 0(高电平有效)时,逻辑是这样的:计数器 CNT 小于 CCR1,输出高电平;CNT 大于等于 CCR1,输出低电平。所以高电平时间为 CCR1 个计数脉冲,占空比就是 CCR1 / (ARR + 1)。比如 CCR1 = 500,ARR + 1 = 1000,占空比就是 50%。
呼吸灯就是周期性改 CCR1。我推荐把定时中断和 PWM 配合起来,在中断里每几个毫秒左右移动 CCR1,实现平滑变化。比如:
volatile uint8_t dir_up = 1; volatile uint16_t brightness = 0; void TIM2_IRQHandler(void) { if (TIM2->SR & (1 << 0)) { TIM2->SR &= (uint16_t)~(1 << 0); if (dir_up) { brightness += 5; if (brightness >= 999) dir_up = 0; } else { brightness -= 5; if (brightness <= 0) dir_up = 1; } TIM2->CCR1 = brightness; } }这样呼吸周期大约是 999/5 × 1ms ≈ 200ms 一个单程,加上一点节奏调整,视觉上挺自然的。你当然可以在主循环里改 CCR1,但直接在中断里改会平滑很多,这正是“中断里只做短小工作”的典范:改一个寄存器,完全没问题。
6. 我踩过的坑:定时器寄存器操作排查实录
6.1 定时中断根本不进、或者进得乱七八糟
先把现象排开。第一种,中断完全进不去。检查三件事:RCC->APB1ENR对应位是否打开;TIM2->DIER的 UIE 是否置 1;NVIC_EnableIRQ(TIM2_IRQn)是否调用。顺序上我的建议是先查 NVIC 再查 DIER,因为很多人只配了外设中断使能,忘开 NVIC 总闸,中断被 CPU 挡在外面。
第二种,进了中断后死循环。九成原因是 UIF 没清干净。仔细看你是不是用了TIM2->SR |= (1 << 0),这操作是写 1,不是写 0,清不掉。我第一次就栽在 STM32 的“写 0 清除”上,后来才长记性,碰到状态寄存器先看手册标志清除方法。
第三种,中断服务函数名字被写错。检查启动文件,TIM2 的正确函数名是TIM2_IRQHandler,少个字母、多一个字母,编译器不会错。还有,不要在中断函数里用TIM2_IRQHandler和void TIM2_IRQHandler(void)之外再多包一层别的函数名,有些编译器优化后符号对不上,也会失效。
6.2 定时时间不对,误差超过预期
如果你算出来 1ms,实测是 2ms 或者 0.5ms,严重怀疑 APB1 预分频。你要是知道 APB1 = 36MHz,就把定时器时钟当成 36MHz,认为 PSC = 35、ARR = 999 是 1ms,结果实际慢了接近一倍。正确理解是 APB1 分频系数为 2 时,定时器时钟就是 72MHz,不是 36MHz。
还有一种,看起来“差不多”,但一累计就偏。检查你的优化等级。高优化下,如果你读取的计数变量没加 volatile,可能读到的是寄存器缓存旧值,导致软件计数偏少。把变量声明加上 volatile,问题基本解决。
还有一个低级的,但发生频率很高:PSC 或 ARR 喜欢少减一。你希望 1000 分频,写 PSC = 1000,结果实际上是 1001 分频;希望数 1000 下,写 ARR = 1000,结果数 1001 下。低频定时你感觉不到,一旦做频率计,1% 的误差就来了。
6.3 PWM 输出没波形、占空比不对
PWM 没波形,我见过最多的原因是 GPIO 配置不对。有人配成普通推挽输出,PA0 只是被当普通 IO 用了,定时器比较结果根本送不到引脚上。必须配成 MODE = 11、CNF = 10 的复用推挽输出。改完 GPIO 后记得重新初始化外设,别只改一边。
还有,CCER 的 CC1E 没置 1。这个位相当于“通道输出开关”,不开就是死灯。如果 PA0 悬空没接负载,用示波器能看到波形;如果接了 LED,可能因为极性配置反了,LED 反着接导致看起来永远是灭的。你可以在 CCER 里把 CC1P 改 1 试试,极性反过来,看 LED 亮暗是否对调。
占空比不对,多半是 CCR 和 ARR 的关系没搞对。CCR 是指“高电平持续的计数次数”,不是“百分比”。你要 30% 占空比,在 ARR = 999 的前提下,CCR1 大约就是 300,而不是 30。刚接触 PWM 的人最容易在这里犯迷糊。
6.4 改了 ARR 或者 CCR,运行中波形没有及时变
这基本就是“预装载 vs 影子寄存器”的问题。CR1 的 ARPE = 1 时,ARR 要等更新事件才生效。改完 ARR 建议手动TIM2->EGR |= (1 << 0)触发一次更新,让新值进入影子寄存器。CCR 也一样,设置了 OC1PE = 1 之后,CCR 的新值也要等更新事件。这不是 bug,是硬件特性和库函数封装后不透明的地方。寄存器操作的价值就在这里,你能精准控制它更新的时机。
最后再分享一个小习惯
我每次初始化完定时器,第一件事一定是拿逻辑分析仪看引脚波形,而不是直接接业务逻辑。确认周期对不对、占空比是不是预期值,再往下走。寄存器操作最怕“看着像对,其实参数没生效”,一句波形验证比盯半天代码高效得多。
七八个外设玩下来,你会慢慢发现,定时器就像是整个芯片的“节拍器”,一切都围着它转。这一章把时基单元和输出比较讲透了,后面遇到编码器接口、输入捕获那些功能,你只需要在这套肋骨架上再挂肌肉而已。希望这篇白话手册能让你少踩我当年那几个坑。