1. 定时器这一章到底在学什么:先别碰代码,把外设结构看明白
前两章讲GPIO和时钟的时候,我一直建议大家把寄存器当作“一格格可以拨动的开关”来看。到了定时器,这个思路依然管用,但难度会上一层楼——因为定时器不再是单个寄存器,而是一组寄存器协同工作。很多人在STM32寄存器这条路上放弃,多半就是卡在定时器:手册里又是CR1又是CCMR1又是SR,看一圈下来头晕,最后老老实实退回库函数。
说实话,库函数能用,但寄存器能让你真正理解硬件在干什么。这一章我用白话把所有核心寄存器过一遍,重点放在两个最常见的场景:定时中断和PWM输出。你把这两个场景吃透,后面什么输入捕获、编码器接口、多通道波形,都是同一套逻辑换个姿势而已。
1.1 定时器不是“一个寄存器”,而是“一组有分工的寄存器”
我们先建立一个整体框架。拿一颗F103系列芯片的通用定时器来说,它内部大概分这么几块:时基单元、输入捕获/输出比较通道、中断与DMA控制逻辑、以及与外部引脚的映射关系。这些模块对应着一批寄存器,常见的有下面这些:
- CR1 / CR2:控制定时器开关、计数方向、对齐模式,属于“总开关”和“工作模式选择”。
- PSC:预分频器,负责把输入时钟降到你想要的节奏。
- ARR:自动重装载寄存器,决定计数器数到哪里算到头。
- CNT:计数器本体,实时保存当前数到哪了。
- CCR1~CCR4:捕获/比较寄存器,输出比较和输入捕获都靠它。
- CCMR1 / CCMR2:配置每个通道工作在捕获模式还是比较模式。
- CCER:输出使能和极性控制。
- DIER / SR / EGR:中断使能、状态标志、软件触发事件。
看到这一堆名字别慌。你仔细看就会发现,它们其实是在回答四个问题:时钟从哪里来、数到多少算一轮、数数过程中和谁比较、结果如何通知外部。这个思路会贯穿整章。我配置定时器时,永远是先问自己这四个问题,再去找对应的寄存器位,而不是拿着手册从第0位往下背。
1.2 白话速览:先把它当成一个会自动数数的秒表
理解定时器最简单的方式,就是把它看成一个自带节拍器的秒表。节拍器每“嗒”一声,秒表就往前进一格。你提前设好“数到100就自动清零并响铃”,那么这块秒表就变成了一个可以用来测量时间的工具。
在这里,“嗒”声来自输入时钟,秒表的格子就是计数器CNT,而“数到100就响铃”的设定值放在ARR里。PSC负责把原始时钟降频,相当于在节拍器前面加了一个“每隔N下才算一下”的分频器。至于“响铃”这个动作,在硬件里叫“更新事件”:计数器从ARR回到0的一瞬间,SR寄存器的UIF标志位置1,同时可以触发中断、也可以触发DMA,让CPU得知“我数完一轮了”。
有了这个画面,你再去看寄存器的功能描述,会发现所有位都只是在调整这台秒表的具体行为:是向上数还是向下数、是循环反复还是数一次就停、数完一轮要不要叫CPU、叫的时候顺带要不要把某个引脚的IO电平翻转一下。下面几节就按这条主线展开。
2. 时基单元三板斧:PSC、ARR、CNT三个寄存器怎么配出你要的时间
2.1 先摸清计数时钟从哪来:时钟树和那个容易忽略的“倍频器”
配置定时器之前,第一件事不是写PSC,而是确认输入时钟到底是多少。很多人的时间计算从第一步就错了,原因在于STM32的定时器时钟并不总是等于对应的APB总线时钟。
在F103里,TIM2~TIM7挂在APB1总线上。有一条规则非常关键:如果APB1的预分频系数是1,那么定时器时钟就等于PCLK1;只要APB1预分频系数大于1,定时器时钟就会自动变成PCLK1的两倍。也就是说,外部晶振8MHz,系统主频跑到72MHz时,AHB通常是72MHz,APB1被分频成36MHz,而TIM2的时钟却是72MHz。这个“悄悄翻倍”的设计很容易让人算错时间,尤其是当你从库函数或者别人例程里复刻一段代码时,频率差一倍,输出波形直接不对。
我在实际调试中会先看一眼RCC->CFGR里的PPRE1位,确认APB1到底被分频成了多少,再去参考手册里查时钟树。不同型号的芯片规则还不太一样,有的系列没有这个倍频逻辑,所以不要记一个结论到处套,一定要对着你手里那颗芯片的时钟树图确认。这一步看似啰嗦,却能省下后面一大半排查时间。
2.2 PSC怎么定:预分频的“+1”陷阱
确定了定时器输入时钟之后,接下来就是用PSC做分频。PSC是一个16位寄存器,它能对输入时钟进行1到65536分频,范围看起来很大,但新手最容易栽在一个地方:公式里的PSC必须加1。
计数时钟的最终频率是:fCK_CNT = fCK_PSC / (PSC + 1)。为什么是加1?因为PSC等于0时,预分频器等于没有分频,计数时钟就是输入时钟。你可以把PSC理解成“每隔几个输入脉冲才算一个计数脉冲”,如果PSC=71,就意味着每72个输入脉冲计数一次。
举例,已知TIM2输入时钟是72MHz,我想得到1us一个计数节拍,就需要计数时钟为1MHz,那么PSC+1=72,PSC=71。这里有个容易犯的错:有人会直接写PSC=72,结果计数节拍变成0.99us,一天运行下来误差累计就很明显。所以我在写代码时一定会先算PSC+1,再回填PSC值。另外PSC是16位寄存器,最大65535,算出来的分频系数如果超过65536,就需要考虑改变输入频率或者采用其他方案,而不是硬塞一个超范围数值进去。
2.3 ARR怎么定:终点线、计数方向与“改了没反应”的秘密
PSC决定的是“走多快”,ARR决定的是“走多远”。ARR全称自动重装载寄存器,也是16位,它存放的就是计数器这次旅程的终点。
如果配置为向上计数模式,CNT从0开始递增,到达ARR之后归零并产生更新事件;如果配置为向下计数,CNT从ARR递减到0;如果是中心对齐模式,计数器会从0数到ARR再掉头数到0,像钟摆一样往复。计数方向由CR1的DIR位控制,对齐方式由CMS位控制。默认的向上计数模式对绝大多数场景都够用,我平时也最常用它。
ARR这边有一个特别容易让新手摸不着头脑的现象:改了ARR的值,计数器却像没听见一样,还是按旧值运行。这不是寄存器写错了,而是影子寄存器在捣鬼。STM32的ARR和PSC都有两层结构:你写入的那一层叫预装载寄存器,真正参与硬件运行的是影子寄存器。只有当更新事件发生时,预装载寄存器里的值才会被搬到影子寄存器里。CR1的ARPE位控制ARR是否启用这种延迟机制,ARPE=0时写入立即生效,ARPE=1时等待更新事件。复位之后ARPE默认是0,所以多数例程初始化时倒没有这个问题,但一旦你在程序运行中途想动态修改ARR,就会发现“改了没反应”,那就是ARPE被置位了。
回到时间计算上:如果定时器时钟是72MHz,PSC=71,ARR=999,那么计数节拍是1us,一轮要数1000个节拍,也就是1ms产生一次更新事件。这是非常经典的一个时基配置,后面做PWM也经常从这组参数起步。
2.4 CNT与更新事件:闹钟响了之后发生了什么
有了PSC和ARR,CNT就开始老老实实地数数了。它从0涨到999,再跳回0,如此反复。每次从ARR跳回0的瞬间,硬件会做三件事:把PSC和ARR的影子寄存器刷新一遍、把CNT清零、把SR寄存器里的UIF位置1。这个瞬间统称为更新事件。
更新事件的用途非常多。它可以触发中断,让CPU在固定的时间点执行一段代码,这就是我们常说的定时中断;它可以触发DMA,实现数据搬运的固定节拍;它也可以作为其他定时器的触发源,配合实现级联。软件还能主动制造一次更新事件,方法是对EGR寄存器的UG位置1。这个动作在初始化时很常用,因为它能强制完成上述所有动作,让配置好的PSC和ARR立刻生效,不用干等计数器自己跑完一轮。
需要留意的是,SR的UIF位是只读的,你无法直接写1把它置位,清除它要靠写0。这和其他一些外设“写1清标志”的习惯相反,很多人第一次调试定时器中断时,在中断服务函数里写了SR |= UIF,结果标志永远清不掉,中断像复读机一样反复进入。这个坑我后面会专门再讲,你只要先记住:定时器的SR寄存器是“写0清标志”的。
3. 从“配置完成”到“确认在跑”:一套可以照抄的寄存器启动流程
3.1 完整配置序列:一步对应一个寄存器位
理论讲完,现在给出一套可以直接抄的初始化序列。以TIM2产生1ms定时中断为例,完整流程是:打开TIM2的时钟,设置PSC和ARR,软件触发一次更新事件,清除UIF标志,使能更新中断,配置NVIC,最后打开CEN让定时器跑起来。
// 1. 打开TIM2所在总线时钟:TIM2在APB1上,对应RCC->APB1ENR的第0位 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 2. 配置预分频和自动重装载 // 输入时钟72MHz,PSC=71,计数时钟为1MHz(即每格1us) // ARR=999,从0数到999共1000格,所以一轮1ms TIM2->PSC = 71; TIM2->ARR = 999; // 3. 软件触发一次更新事件,让PSC和ARR立即进入影子寄存器 TIM2->EGR |= TIM_EGR_UG; // 4. 顺便把更新事件产生的UIF标志清掉,避免一使能中断就立刻进入一次 TIM2->SR &= ~TIM_SR_UIF; // 5. 使能更新中断 TIM2->DIER |= TIM_DIER_UIE; // 6. 在NVIC里打开TIM2的中断通道(F103里TIM2的中断号是28) NVIC->ISER[0] = (1 << 28); // 7. 打开计数器使能位,定时器开始运行 TIM2->CR1 |= TIM_CR1_CEN;这段代码的顺序是刻意安排的。先写PSC和ARR,再触发UG,这样能保证影子寄存器中的值和我们预期一致;清UIF放在使能中断之前,否则计数器一跑起来就可能立刻触发一轮意外中断。NVIC寄存器的具体数值在不同型号上不一样,如果你不确定,打开启动文件查一下中断向量表,比猜更靠谱。
中断服务函数里也不能空着,至少要清标志:
void TIM2_IRQHandler(void) { if ((TIM2->SR & TIM_SR_UIF) != 0) { TIM2->SR &= ~TIM_SR_UIF; // 写0清标志 // 你的周期任务写在这里,比如让一个变量自增 } }3.2 怎么确认它真在跑:调试器里看CNT和SR
配置完成之后,怎么确认定时器真的按预期在跑?我的习惯是先在调试器里观察TIM2->CNT这个寄存器。打开外设寄存器监视窗口,找到TIM2_CNT,然后全速运行几秒再暂停。如果CNT的值变了,说明计数器已经在走;如果暂停之前配置过一次,还能大致推算出走了多少格,估算一下频率对不对。
这个方法有讲究。内核暂停时,计数器不会自动停下来,CNT可能一直在快速累加,你看到的数值会是“暂停瞬间”的随机值。如果想让调试更可控,可以把DBGMCU->CR里的TIM2停止调试位打开,这样一旦触发断点,定时器也会同步冻结,方便单步观察每一步配置带来的变化。否则你单步踩进去的时候,CNT早就不知道跑哪去了。
除了看寄存器,我还会用一个更朴素的验证方法:让定时器中断服务函数每进入一次就翻转一次某个GPIO,然后用示波器或者逻辑分析仪看这个引脚的波形。占空比50%的方波周期应当是2ms,直接用波形来校准时间,比盯着一堆数字更直观。没有示波器的话,也可以让一个LED按1s间隔闪动,用秒表估一下,时间差不太多就算基本跑通了。
4. 定时器的重头戏:输出比较与PWM模式下CCR寄存器的玩法
4.1 输出比较的本质:CNT和CCR的“斗地主”
定时器除了“数数报信”,还有一个更常用的能力:输出PWM波形。要理解PWM,就得先理解输出比较。
输出比较的逻辑可以概括成一句话:硬件不停地把CNT和CCR拿来做比较,一旦满足设定的条件,就自动操作对应的输出引脚。CNT是那个永不停歇的计数器,CCR是你提前设置好的关卡值,两者相等的瞬间就是真正触发动作的时刻。
CCMR1里的OC1M位决定了动作方式。比如模式001意思是“相等时强制输出高电平”,模式010是“相等时强制输出低电平”,模式011则是“相等时翻转引脚电平”,这几种模式适合产生特定时刻的边沿信号,用来驱动步进电机脉冲或者红外编码都很常见。不过我们最常用的还是模式110和111,也就是PWM模式1和PWM模式2。PWM模式并不是说引脚自动产生正弦波,它的本质其实是:硬件在每个计数周期内,根据CNT是否小于CCR来持续输出两种电平。
4.2 PWM模式下的寄存器配置顺序与占空比计算法
以TIM2的通道1在PA0引脚输出PWM为例,我需要同时配置GPIO和定时器两套寄存器。GPIO要设置成复用推挽输出,定时器要把对应通道设成PWM模式、打开输出使能,并设置CCR1的值。
// 1. 打开GPIOA和TIM2的时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 2. PA0配置为复用推挽输出,速度50MHz // CRL的低4位是MODE0和CNF0,MODE=10、CNF=10 GPIOA->CRL &= ~(GPIO_CRL_MODE0 | GPIO_CRL_CNF0); GPIOA->CRL |= GPIO_CRL_MODE0_1 | GPIO_CRL_CNF0_1; // 3. 配置时基:PSC=71,ARR=999,得到1kHz的PWM周期 TIM2->PSC = 71; TIM2->ARR = 999; // 4. 通道1设为PWM模式1,即OC1M=110 TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; // 5. 打开通道1输出使能,极性默认高电平有效 TIM2->CCER |= TIM_CCER_CC1E; // 6. 设置比较值:CCR1=500,占空比约50% TIM2->CCR1 = 500; // 7. 触发更新事件让配置生效,然后启动计数器 TIM2->EGR |= TIM_EGR_UG; TIM2->CR1 |= TIM_CR1_CEN;这段流程里,占空比的计算是:duty ≈ CCR / (ARR + 1)。之所以ARR要加1,是因为计数器实际跑了ARR+1个节拍。CCR=500、ARR=999,占空比就是500/1000=50%。PWM的周期则是:T = (PSC+1) × (ARR+1) / fTIMxCLK,带入前面的72MHz、PSC=71、ARR=999,得到1ms周期,也就是1kHz。
顺便说一下PWM模式1和模式2的区别:向上计数时,PWM模式1是CNT小于CCR时输出有效电平,CNT不小于CCR时输出无效电平;PWM模式2正好反过来。有效电平到底对应高还是低,则由CCER的CC1P极性位控制。默认CC1P=0时有效电平是高电平,CC1P=1时有效电平变成低电平。你用逻辑分析仪看到波形反相时,优先检查这个位,而不是怀疑代码写错了。
4.3 动态调占空比:调节“呼吸灯”时先动CCR而不是改ARR
PWM配置跑通之后,最自然的下一步就是动态调节占空比,做呼吸灯或者调光。这时候有一个原则:只改CCR,不要动ARR。
CCR直接影响占空比,改它的实时性也最好。我写过一段呼吸灯代码,用一个变量在0到999之间来回变化,每次主循环里直接给TIM2->CCR1赋值,LED的亮度就能平滑过渡。值得注意的是CCMR1里还有一个OC1PE位,它决定CCR是否启用预装载。复位值默认没有启用,也就是写入立即生效;如果哪天你手滑把它置位了,修改CCR就得等更新事件才生效,效果就会有一拍延迟,看起来像亮度变化“卡顿”了。
如果确实想改周期,那就必须同时考虑ARR的变化会改变占空比,因为占空比是CCR和ARR共同决定的函数。我通常的做法是:固定ARR,用软件把目标百分比换算成CCR。比如想要35%的占空比,就计算ccr = 35 * (ARR + 1) / 100,结果取整后赋给CCR。要注意ARR+1超过65536时,这个乘法的中间结果会溢出16位,所以计算时用32位中间变量再截断,否则百分比越高越容易出错。
5. 定时器寄存器调试高发区复盘:从“不跑”到“乱跑”的完整排查链
5.1 案例A:CNT纹丝不动——先查RCC,再查CR1
我遇到过不少次这样的求助:配置代码看起来完全正确,CR1的CEN也置位了,可调试器里TIM2->CNT始终是0。这种“根本不跑”的问题,排查顺序其实很固定。
第一步先看RCC,确认定时器时钟有没有被打开。STM32里每个外设都有独立的时钟门控,不是配置了寄存器就会通电。RCC->APB1ENR的TIM2EN位如果是0,整个TIM2模块都处于无时钟状态,CNT自然一动不动。这个问题我自己也干过,初始化函数里先开了GPIO的时钟,回头却忘了TIM2这趟,代码检查好几遍才发现少写一行。
第二步才轮到CR1本身。CEN置1后,还要确认CR1有没有被别的东西覆盖。比如有些初始化代码习惯先用“=”整体赋值,如果不小心把整个CR1写成0,CEN就被冲掉了。我用调试器观察CR1的完整值,比只盯着某一个位要可靠得多。
还有一种情况是计数器在跑,但时间参数差了一倍,看起来像“没跑对”。这个时候不要怀疑CNT,回头去查APB1预分频和定时器时钟倍频。前面提到的72MHz被算成36MHz,PSC就会算错,最终时间整整慢一倍。用示波器量一下实际输出波形周期,再对比代码里的数学期望,立刻就能判断是不是这个问题。
5.2 案例B:改ARR不生效、PSC不生效——影子寄存器的锅
另一个高频问题出现在程序运行中途:初始化时定时器工作正常,但我在主循环里修改了TIM2->ARR,计数器还按原来的周期走。排除RCC和CEN之后,十有八九是影子寄存器机制在起作用。
ARR和PSC都有一层“预装载寄存器”作为缓冲。你直接写入的数值进入缓冲层,硬件真正使用的是影子寄存器。只有当更新事件发生时,缓冲值才被复制到影子寄存器。所以在ARPE=1的情况下,你写ARR不会立即改变运行周期,必须等一次更新事件。初始化时我们手动触发UG,所以看起来配置“立即生效”;但运行中修改时,很多人忘了要等这一下。
排查时先看CR1的ARPE位,如果它是1,要么临时把ARPE清掉变成直接写入模式,要么在写入ARR后主动置位EGR的UG位,立刻制造一次更新事件。不过要小心,UG置位会同时产生更新事件,如果此时更新中断已经使能,UIF会被置位并触发一次中断。你在修改ARR时可能不想让中断处理函数被执行,那就要先清一次UIF,或者临时关掉更新中断。
还有一个隐藏的坑:CR1的UDIS位,它叫“禁止更新”。如果UDIS=1,更新事件就不会产生,影子寄存器永远不会刷新。很多例程不会碰这个位,但如果你是从某个低功耗示例里拷过来的代码,就得多看一眼。总之,凡是遇到“改了值就是不变”的寄存器,先确认它有没有两张皮,再确认更新通道有没有被堵死。
5.3 案例C:中断风暴/进不了主循环——UIF没清
定时器中断还有一个经典故障:程序一运行,主循环几乎没反应,好像卡死在中断里。这种“中断风暴”最常见的原因就是UIF标志没有清除。
现象是这样的:定时器照常产生更新事件,UIF置位,NVIC把中断触发给CPU;CPU进入中断服务函数,但服务函数里忘记清UIF;退出中断后,UIF还是1,NVIC检测到挂起的中断仍然有效,于是立刻再次进入中断。循环往复,主循环根本没有机会执行。
更隐蔽的是清标志的方式写错。前面强调过,TIM的SR寄存器是写0清标志,而不是像EXTI那样写1清。如果你用HAL库习惯了某些外设的操作手法,到了裸寄存器里顺手写一个“置1清零”,标志就会顽固地留在那里。我一般在中断服务函数的第一行就做清除动作:
void TIM2_IRQHandler(void) { TIM2->SR &= ~TIM_SR_UIF; // 后续处理 }另外还有一类情况:中断服务函数在启动文件里的名字写错了,导致函数没有被正确链接,中断一进来就跳到一个空函数里。这个不会导致中断风暴,但会让你怀疑人生:代码逻辑明明没问题,为什么好像什么都没执行?检查办法是看看启动文件里TIM2_IRQHandler的符号最终落在哪个函数上,顺便在服务函数里打断点,确认入口是否真的被触发。
5.4 给新手的排查顺序表
我把调试过程中最常见的几种异常整理成一张表,方便你在现场快速定位。用这张表的时候,永远从第一行开始往下查,不要跳步,尤其是不要一上来就怀疑编译器有问题。
| 现象 | 先查哪里 | 关键寄存器 | 最常见原因 |
|---|---|---|---|
| CNT完全不动 | 外设时钟 | RCC->APB1ENR | 忘记开TIMxEN |
| 时间慢一倍或快一倍 | 时钟树 | RCC->CFGR PPRE1 | 忽略了定时器时钟倍频逻辑 |
| 运行中改ARR/PSC没反应 | 影子寄存器 | TIMx_CR1 ARPE、TIMx_EGR | 预装载未刷新 |
| 中断反复进入 | 状态标志 | TIMx_SR UIF | 中断服务函数里没有清UIF |
| PWM无波形 | 引脚配置 | GPIOx_CRL/CRH | 忘了复用推挽输出 |
| PWM输出一直高/一直低 | 极性位 | TIMx_CCER CCxP | 有效电平极性理解反了 |
这张表不是万能的,但它覆盖了八成以上的寄存器配置问题。每次排查完,我都会顺手在代码注释里写一句“这个坑是因为什么”,以后再遇到类似问题,翻注释比翻聊天记录快得多。
6. 把参考手册用“白话”读出来的心法
6.1 我总是先找五个对象,再找寄存器
很多人读芯片参考手册时,习惯按页码一路读,读到定时器章节就被几十个寄存器劝退了。我自己的方法完全不同:先把一个功能拆成五个对象——输入时钟、计数器、比较值、输出、标志位,然后到手册里去“对号入座”。
比如你看到的问题是“我想让LED每500ms翻转一次”,那在脑海里马上拆成:输入时钟是72MHz,计数器是CNT,要配置PSC让节拍变成某值,再配置ARR让一轮恰好500ms,最后用UIF标志判断一轮结束。整个过程根本不需要碰CCMR1、CCER这些寄存器。反过来,如果问题是“我想输出一个占空比可调的方波”,那你需要关心的就变成CCR、OC1M和CC1E,PSC和ARR反而不是重点。
这样按需读取手册,比把整章背下来高效得多。手册里的寄存器位定义很枯燥,但当你明确知道自己在找哪一个时,它就变成了一张“找答案的索引”。
6.2 你不需要记住每个位,但需要记住“哪类位”
实际操作中我发现,寄存器位虽然多,但它们的命名规律非常统一,几乎每个外设都有类似的分工:使能位、模式选择位、极性和方向位、状态标志位、预装载/影子位。你只要记住这些“功能族”,新外设上手的速度会快很多。
比如说,你在定时器里理解了“预装载寄存器”的概念,之后学ADC时再看到类似机制就不会觉得陌生;你在定时器里记住了“写0清标志”,之后遇到I2C、SPI的状态寄存器时,也会条件反射地先去查手册确认清除方式,而不是想当然。这些底层经验是通用的。
我还有个习惯:每用一个寄存器,就在代码注释里写一句大白话,比如“TIM2->PSC = 71; // 72MHz被71+1分频成1MHz,也就是1us一格”。这句话不是为了给谁看,是为了三个月后再打开这个工程时,能一眼想起当初的设计意图。很多寄存器位的名字其实不难懂,难的是把它们放回真实硬件场景里。用一句话把“这个位影响了什么”翻译出来,整个外设的逻辑就通透了。
这也是我坚持写这套“白话手册”的原因。寄存器的学习不需要死记硬背,关键在于建立一张“配置硬件时头脑中的运行画面”:时钟进来、分频、计数、比较、输出、报信。你脑子里的这张画面越清晰,读到每个位时就越有感觉。下一章如果继续往下写,我会把输入捕获、编码器模式这些基于同一框架的内容展开,到时候你会发现,本章打下的这些基础仍然完全适用。