不少朋友学STM32,一开始兴致冲冲地点灯,点完灯之后信心爆棚,然后翻到寄存器手册的定时器章节,脸色就不太好了。一页纸里密密麻麻排着TIMx_CR1、CR2、SMCR、DIER、SR、EGR、CCMR1、CCMR2、CCER、CNT、PSC、ARR、CCR1~CCR4,加起来十几个寄存器,每个里面又是好几组位域,很多位还有“向上计数/向下计数/中央对齐”这种看着像选择题的说明,瞬间就从“我会点灯”变成“我是不是不太行”。
其实定时器这部分寄存器,并不需要背,也背不住。厂商写手册的时候是按模块功能把寄存器堆在一页,方便查,但作为使用者,我们最好按“这条数据会流经哪些寄存器”去理解它。这一章我会挑定时器使用频率最高的寄存器,用大白话讲清楚它们的职责,同时把我自己实际调试时踩过的坑一并交代。适合两类人:第一类是刚把GPIO寄存器玩明白、正准备碰定时器的新手;第二类是用了几年的库函数、遇到诡异现象时想自己看一眼寄存器去定位问题的老手。
1. 定时器寄存器那么多,先从“外设地图”开始看
1.1 把十几个寄存器分成四类,信息量立刻少一半
拿到GPIO的寄存器时,无非就是配置模式、读输入、写输出,三个动作对应三五组寄存器,很直白。定时器则不同,它要同时管理“时间基准”和“输入输出通道”两条链路,所以寄存器数量几乎是翻倍的。但只要我们把它们按职责分成控制类、时基数据类、通道类、状态类四组,记忆压力就小很多。
| 分类 | 寄存器 | 这一章里的用途 |
|---|---|---|
| 控制类 | CR1、CR2、SMCR、DIER、EGR | 计数方向、计数模式、中断开关、事件触发 |
| 时基数据类 | PSC、ARR、CNT | 决定计数频率、计数上限、当前计数值 |
| 比较/捕获通道类 | CCMR1、CCMR2、CCER、CCR1~CCR4 | 对外输出PWM波形,或从外部抓住信号边沿 |
| 状态类 | SR | 看UIF、CCxIF这些标志位,判断发生了什么 |
这个分法不是手册上的标准叫法,是我自己用熟了之后简化出来的。你去看手册,它可能按功能模块一章一章讲,但实际调试的时候,你脑子里应当先有这张地图:你现在是想改频率,还是想改波形,还是想查为什么标志位没置起?对应去翻时基类、通道类还是状态类寄存器,比从头到尾扫一遍寄存器列表快得多。
状态类寄存器SR是很多人忽略的一个入口。出问题的时候,刷一刷SR,看哪些标志位是1,往往比瞎猜代码逻辑更接近真相。UIF表示更新事件发生过,CCxIF表示捕获/比较事件发生过,这些标志位就是硬件写给软件的小纸条。
1.2 两条主线:时基链路和通道链路
定时器内部可以理解成有两条数据流。第一条是“时基链路”:时钟进来,经过PSC预分频,变成计数脉冲,CNT按这个脉冲累加,加到ARR设定的上限就溢出,产生更新事件。这条链路负责回答“现在是什么时刻”“多长时间到点”。第二条是“通道链路”:CNT这个不断变化的数值,要么拿去和CCR比较产生输出,要么被外部信号的边沿“拍照”存进CCR。这条链路负责回答“什么时候该翻转电平”“外部信号到底持续了多久”。
很多初学者被寄存器搞晕,是因为把这两条链路混在一起看。其实PSC、ARR、CNT是时基这条线的,尽量先弄懂;CCMR、CCER、CCR是通道这条线的,等时基稳定了再碰。把它们分开看,定时器就没有那么可怕了。这也是这个系列前面章节一直在强调的“地图感”:先看整体,再钻细节,别一上来就掉进位域描述里。
2. 时基单元拆解:PSC、ARR、CNT是怎么算出时间的
2.1 预分频器的“加一”法则:想除10,写9
PSC是预分频寄存器,16位,范围0到65535。它的作用是把高频时钟降下来,变成计数器能处理的低频计数脉冲。这里有一个新手最容易踩的坑:实际分频系数是PSC + 1。
想10分频,要向PSC写9;想72分频,要写71;想不分频,写0。为什么硬件这么设计?因为计数器是从0开始计数的,从0数到9一共经过了10个时钟,代表“10分频”。也就是说,寄存器的值表示的是“要跳过多少个边沿”,而不是“要除以几”。
我最初写定时器的时候,就犯过“想100分频,写了个100,结果实际101分频”的错。频率高的时候可能感觉不出来,但一旦多个参数叠加,误差会累积,最后测出来的东西怎么看都觉得不对。这个“减一”习惯,建议从第一次写PSC就养成。
时钟来源这里还要多说一句。定时器的输入时钟不一定是系统主频直接过来的,中间通常还隔着APB1或APB2的预分频。不少平台上,当APB预分频不为1时,定时器时钟反而会变成APB时钟的2倍。所以,在你拿72MHz去算PSC之前,最好先确认这块芯片的定时器时钟到底是多少。参考手册里的时钟树图值得花十分钟盯一遍,比任何口诀都靠谱。
2.2 从72MHz到1kHz,来一次完整计算
假设定时器输入时钟是72MHz,这个数值很常见。我们想要一个1kHz的更新事件,也就是每1ms一次中断,计算过程分两步。
第一步,选PSC。想让计数脉冲变成1MHz,也就是每个计数周期1微秒,那么72MHz除以1MHz等于72,所以PSC = 71。第二步,选ARR。计数器以1MHz频率向上跑,要每1ms溢出一次,需要计满1000个脉冲。因为CNT是从0开始,计到999时已经累计了1000个脉冲,所以ARR = 999。
最后的校验公式是:
f_update = f_timer / ((PSC + 1) * (ARR + 1))代入一下:72MHz / (72 * 1000) = 1kHz,正好。如果PSC写成72,ARR写成1000,实际更新频率就会变成72MHz / (73 * 1001),约等于984.7Hz,比预期的1kHz慢了1.5%。单看一次可能无所谓,但做累计计时或者频率测量时,这点误差会非常明显。
“把PSC和ARR都减一”这句话,值得写在手边。我见过太多人改ARR的时候纠结“我明明设了1000,为什么实测是1001”,原因都是少了这一步。
2.3 三种计数模式:向上、向下、中央对齐分别干什么
CR1寄存器里有一组CMS位,用来选择计数模式。默认情况下是向上计数:CNT从0递增到ARR,然后归零,产生更新事件。这是最常用的模式,我们前面算的1kHz中断就是向上计数。
向下计数与之相反,CNT从ARR一路减到0,然后重新装载ARR,产生更新事件。这种模式平时用得少,但在某些需要和外部信号对齐的场合会用到。中央对齐模式更有意思:CNT先向上加到ARR,再向下减到0,如此往复。它在向上和向下两个方向到达边界时都会产生更新事件,所以更新频率是向上计数的两倍。电机控制、全桥逆变这类需要对称PWM波形的场景,基本都会用到中央对齐模式,因为它天然能生成对称的三角载波。
中央对齐模式下,更新事件在哪个点触发,还受CMS的取值影响,有的在顶点,有的在谷底,有的两者都触发。初学者可以先知道有这个区别,真用到的时候再按手册逐位确认,不用一开始就死磕。
3. 更新中断:让芯片每隔1ms自己“打断”一次CPU
3.1 UIF、UIE、NVIC,三个开关串起来才叫中断
时基链路跑通以后,CNT每溢出一次,SR寄存器里的UIF标志位就会被置1。这个“UIF = 1”本身只是一个状态,它不会自动去打断CPU。想让CPU停下来干活,还需要两个额外的开关:DIER寄存器里的UIE位,负责把UIF信号变成定时器向NVIC发出的中断请求;NVIC那边的通道使能,负责让这个中断请求真正到达CPU核心。
这三个东西是串联关系,任何一个断开,中断都不会发生。所以调试“定时器不进中断”的时候,不要急着怀疑代码逻辑,先按这个顺序查:看SR.UIF到底有没有置1。如果UIF根本不是1,说明时基没跑起来,或者计数器没到溢出点;如果UIF是1但中断没触发,再查DIER的UIE和NVIC的配置。
这个排查顺序,我自己的体验是能省下一半的调试时间。很多人一上来就盯着NVIC配置反复改,改来改去发现引脚根本没输出,其实是计数器压根没启动。
3.2 影子寄存器与UG位:改参数为什么“不生效”
PSC和ARR有一个共同的特性:我们写入的值,很多时候不会立刻影响到硬件,而是先进入“预装载寄存器”,等到一个更新事件发生后,才被拷贝到真正参与计数的“影子寄存器”里。这种设计的目的很朴素:计数器正在跑的时候,你突然改了ARR,如果立即生效,计数过程可能变得混乱,输出的波形也会出现毛刺。通过影子机制,可以保证参数在一个安全的时间点整体切换。
所以,修改PSC和ARR之后,如果一直没有等来更新事件,你会发现参数“没反应”。解决方法是主动产生一次更新事件:往EGR寄存器的UG位写1。硬件会立刻把所有预装载值加载到影子寄存器,并重新初始化计数器。
这里有一个经验性的先后顺序:如果UIE已经开启,手动触发UG的那一下,也会被当成一次更新事件,导致UIF置位,中断服务函数多跑一次。稳妥的做法是在初始化阶段先把UG触发了,清掉UIF,再开UIE,最后启动计数器。
// 以定时器的时基配置和中断使能为例 TIMx->PSC = 71; // 72MHz -> 1MHz TIMx->ARR = 999; // 更新频率 1kHz TIMx->EGR |= TIM_EGR_UG; // 手动加载影子寄存器 TIMx->SR &= ~TIM_SR_UIF; // 清掉刚才UG留下的标志 TIMx->DIER |= TIM_DIER_UIE; // 开更新中断 // NVIC配置略,取决于所用内核的具体寄存器 TIMx->CR1 |= TIM_CR1_CEN; // 启动计数每一行都可以当口诀来看。尤其是TIMx->SR &= ~TIM_SR_UIF这一句,很多人在定时器上栽跟头,就是因为清标志的方式写反了。
3.3 中断服务函数里到底该干什么:只置标志,不做重活
定时器中断服务函数最容易犯的错误,是把所有运算都塞在里面。比如在1kHz中断里直接做大量浮点计算,导致中断还没退出,下一次更新事件又来了,系统就一直卡在中断里出不去,主循环饿死,现象就是“程序看起来跑飞了”。
正确的做法是中断服务函数只做一个动作:置一个全局标志位,或者把捕获到的值存进一个变量,然后清UIF。真正要算的事情,放到主循环里去做。这样即使某一次中断处理被其他高优先级中断打扰,也不至于把耗时操作叠加在定时精度上。
我在实际调试时,还遇到过“UIF清了,但下一次中断立刻又进来”的情况。查到最后发现,是初始化顺序导致UG触发的更新事件在UIF清零之前又置了一次位。所以清UIF这个动作,最好放在所有可能产生更新事件的操作之后再执行,顺序不能乱。
4. 输出比较与PWM:把CCR当“卡尺”去切波形
4.1 OCxM、CCxE、CCxP:三个位决定波形长相
PWM在多数人理解里是一个高大上的功能,但落到寄存器层面,它其实就是一件事:每个计数时钟周期,比较CNT和CCR的大小,根据比较结果决定输出电平。CNT是一个不停变化的锯齿波,CCR是你设好的一个固定刻度,两者一比较,输出就是方波。这个思路和用卡尺切一段均匀移动的纸带很像,卡尺的位置定了,切出来的每一段长度就定了。
CCMR寄存器里的OCxM位决定比较结果的输出逻辑,也就是PWM模式1还是模式2。CCER寄存器里的CCxE位是通道输出使能,CCxP位决定有效电平是高还是低。这三个位配合,就能完全描述输出波形的长相。
有一个容易绕晕的点:PWM模式1和模式2,本身只决定了“CNT小于CCR时输出有效还是无效”,而“有效电平”具体是高还是低,又由CCxP决定。两个维度一组合,看起来就有四种情况。以向上计数为例,CCxP设为高有效时,PWM模式1是CNT < CCR时输出高,否则输出低;PWM模式2正好相反。CCxP设为低有效时,波形就是在前面基础上整体反相。
所以调试PWM波形时,如果发现波形反了,不要急着改代码逻辑,直接把CCxP位翻转一下,往往就正常了。这个操作比重新设计一套比较逻辑方便得多。
4.2 呼吸灯实测:把ARR和CCR的作用彻底分清
拿呼吸灯来练手最直观。目标是把LED从灭到亮、再从亮到灭,循环变化。要实现这个效果,需要固定PWM频率,然后周期性地改变占空比。
PWM频率由时基链路决定。沿用前面的配置,72MHz、PSC=71、ARR=999,计数频率1MHz,溢出周期1ms,对应PWM频率约1kHz。这个频率下LED不会有闪烁感,亮度过渡很平滑。
占空比由CCR1决定。CCR1=500时,高电平占500个计数,低电平占500个计数,占空比50%。CCR1=999时,高电平几乎占满整个周期,LED最亮;CCR1=0时,LED最暗。呼吸效果只需要在更新中断里每隔一段时间把CCR1加一个步进,到边界再反向减回来。
static uint16_t duty = 0; static int8_t step = 1; // 在更新中断里做这个动作,或者在主循环里用系统节拍触发 duty += step; if (duty >= 999) { step = -1; } if (duty == 0) { step = 1; } TIMx->CCR1 = duty;有一个边界细节要注意:CCR值大于ARR时,输出会一直保持有效电平。想实现全灭,应该把CCR设为0,而不是纠结“我是不是该把ARR改成0”。理解CCR和ARR各自的作用范围之后,这些边界问题就变得很自然了。
4.3 高级定时器的MOE和其他特殊情况
通用定时器TIM2、TIM3、TIM4在配置好上面那些位以后,基本就能正常输出PWM了。但如果你用的是带高级定时器功能的TIM1或TIM8,还会遇到一个经典问题:所有配置都正确,引脚就是没有波形。原因通常出在BDTR寄存器里的MOE位,也就是主输出使能。MOE没有置1时,整个高级定时器的输出通道全部禁用,等于焊盘上的信号根本没接到引脚去。
这也是我建议先从通用定时器上手的原因。通用定时器没有刹车输入、死区插入、重复计数器这些概念,链路更短,不容易被各种辅助功能干扰。等把PWM的原理完全吃透,再去看高级定时器,就会发现多出来的寄存器只是对“输出何时安全”做更精细的控制,并没有改变CNT比较CCR这个核心逻辑。
调试PWM问题时,我习惯列一张快速排查表:
| 现象 | 可能原因 |
|---|---|
| 引脚完全没有波形 | RCC时钟没开 / GPIO复用没配 / CCxE没使能 / 高级定时器MOE没开 |
| 波形有,但占空比不对 | CCR算错,或ARR写错导致频率偏移 |
| 波形反相 | CCxP极性选反,翻转这一位即可 |
| 频率不对 | 定时器时钟没确认好,PSC或ARR忘记减一 |
这张表帮我解决过无数次“定时器不干活”的问题。多数情况下,问题都不在模式选择,而在某个使能位被漏掉。
5. 输入捕获反着用:量外部信号的周期与脉宽
5.1 捕获的硬件行为:就是给CNT拍一张快照
输出比较是“用CNT去比CCR,产生输出”,输入捕获则是反过来“用外部信号去触发,把当前CNT的值存进CCR”。外部信号出现一个你关心的边沿时,硬件会产生一个捕获事件,同时把CNT此刻的值“咔嚓”一下拍进CCR,并把SR里的CCxIF标志置1。
这个动作完全是硬件完成的,精度只取决于计数时钟频率,跟软件响应快慢没关系。你在中断里读到的CCR值,是边沿发生那一瞬间的计数值,而不是中断真正执行时的计数值。所以输入捕获适合测量外部方波的周期、脉宽、频率,或者用来做超声波测距这类需要精确时间差的场景。
第一次接触输入捕获的人,容易犯一个错误:在中断服务函数里先做一串处理,最后才去读CCR。如果这串处理时间较长,下一次捕获又来了,CCR可能已经被新的边沿覆盖,读到的数据就废了。正确习惯是进中断后第一件事就把CCR读走,再清标志,再做后续计算。
5.2 测1kHz方波:两次上升沿之间的差值就是周期
假设我们已经把捕获通道配置为上升沿触发,计数频率仍是1MHz,也就是每个计数代表1微秒。对于1kHz的方波,周期是1毫秒,理论上相邻两个上升沿之间的CNT差值应该是1000。
代码思路很简单:
uint16_t count1, count2, period; count1 = TIMx->CCR1; // 第一次捕获的计数值 // 等待第二次捕获中断 count2 = TIMx->CCR1; // 第二次捕获的计数值 period = count2 - count1; // 单位是计数周期,这里是微秒直接用无符号数做减法有个好处:即使CNT出现了回绕,只要两次捕获之间CNT没有溢出超过一次,差值依然是对的。这个特性在很多底层算法里会用到。
更完整的频率测量,通常会连续读多个周期,然后取平均,消除单次测量引入的量化误差。如果被测信号频率较低,周期超过了定时器的最大计数范围,就需要额外记录CNT的溢出次数,形成多周期扩展计数。这也是为什么有的场景会特意选用32位定时器,16位定时器在测低频时会显得力不从心。
5.3 边沿、滤波和预分频:三个细节让捕获结果更可靠
边沿选择由CCER里的CCxP控制,上升沿还是下降沿一目了然。如果要做脉宽测量,可以配置两个通道,一个捕获上升沿,一个捕获下降沿,然后两个CCR相减,就得到高电平持续时间。
CCMR里的ICxF是输入滤波器,它按照一定的采样窗口对输入信号进行滤波,可以剔除高频毛刺。这里有个经验:信号干净的时候,别开滤波,不然可能把正常边沿也过滤掉;信号抖动明显的时候,再逐渐加大滤波深度,观察测量结果是否变稳定。
ICPS是捕获预分频,它可以设置成每2个、4个或8个边沿才捕获一次。这个功能在高频信号场景下很有用,可以降低中断频率,避免CPU被捕获中断淹没。但要注意,捕获预分频之后,两次捕获之间间隔的实际时间变成了边沿间隔的倍数,计算周期时要把这个倍数乘回去。
测量频率之前,一定要先估算一下计数时钟的分辨率。如果被测信号是10MHz,而计数时钟只有1MHz,那么一个周期内只有0.1个计数,基本测不出有效值。先保证计数时钟比被测信号快出一个数量级以上,捕获结果才有意义。
6. 配置顺序与“翻车现场”复盘
6.1 一个推荐的初始化顺序:先搭好计数器,再让它干活
寄存器配置的先后顺序,在很多时候并不强制,但一个好的顺序能帮你少踩很多莫名其妙的坑。我习惯的顺序是这样:
- 打开对应定时器的RCC时钟;
- 配置相关GPIO为复用功能;
- 配置时基:PSC、ARR、计数模式;
- 软件UG触发一次更新,并把SR里的UIF清掉;
- 配置通道:PWM模式或输入捕获模式、极性;
- 使能通道输出或捕获;
- 使能定时器中断(如果使用);
- 配置并打开NVIC通道;
- 置位CEN,启动计数。
这个顺序的核心原则是“先搭好计数器,再让它干活”。如果你先把通道和中断使能了,计数器还没开始跑,或者跑到一半突然改了ARR,很容易产生一次意料之外的更新中断,干扰你对问题的判断。先把时基链路搞稳定,再打开通道和中断,思路会清晰很多。
6.2 我遇到过的高频翻车现场
第一个是清UIF标志位的方式写反。定时器的SR寄存器,很多标志位是写0清除的,而像EXTI之类的外设是写1清除。我早期写定时器代码时,沿用写1清标志的习惯,结果发现UIF怎么都清不掉,中断服务函数被反复进入。查手册才发现,这几位就是写0清零。
第二个是PSC改了,频率却没变。这个问题通常不是因为写错了内容,而是没有产生更新事件,预装载值一直躺在预装载寄存器里。解决办法就是手动往EGR的UG位写1,或者等计数器自然溢出一次。
第三个是高级定时器没有波形。这个问题前面已经提到过,TIM1或TIM8的MOE位忘记打开。普通定时器没有这个位,所以习惯普通定时器之后转用高级定时器,很容易漏掉这里。
第四个是配置了TIM2,却开错了RCC时钟位。这种问题没有任何报错,纯粹是“时钟没起来”,调试器里看定时器寄存器全是默认值。排查方法就是检查RCC对应外设的时钟使能位,确认自己到底把定时器接在哪条总线上。
6.3 没有示波器也能自测:GPIO翻转法和串口打印法
调PWM和输入捕获的时候,示波器是最理想的工具,但手边没有示波器的情况也很常见。这时候可以用一个空闲的GPIO做粗略自测:在更新中断服务函数里翻转这个GPIO,然后用逻辑分析仪或者另一块板子的输入捕获去测它的翻转频率。如果测量结果和理论计算一致,说明时基链路和中断链路都没有问题。
如果连逻辑分析仪也没有,还可以用最原始的方法:写一个计数变量,每进一次中断加一,然后在主循环里翻转另一盏LED,配合秒表观察LED闪烁的快慢。这个方法精度有限,但可以用来判断量级对不对,比如该1kHz却闪成了每秒一次,那肯定哪里错了。
输入捕获的调试,最适合用串口。把两次捕获的差值或者计算出来的频率通过串口打印到终端,一边调节输入信号一边观察数据,能很快定位是配置问题还是信号问题。我调红外遥控解码时全靠这个办法,比对着寄存器一位位猜快多了。
如果你刚接触这些寄存器,别急着把每个位都背下来。先把时基链路跑通,再做输出比较,最后碰输入捕获,这条路是我自己踩出来的,比一上来啃完整章手册轻松得多。实际上到今天为止,我也做不到把定时器每个寄存器都背出来,但遇到问题我会翻手册的时钟树、事件产生框图和数据速览表,它们比正文好懂得多。下一章如果继续写,大概率会聊DMA或者ADC怎么和定时器联动,那些机制和本章这些概念关系很紧密。