ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32定时器寄存器操作:从点灯到精确计时的关键跨越

STM32定时器寄存器操作:从点灯到精确计时的关键跨越

1. 为什么第四章要专门谈定时器:从点灯到精确计时的关键跨越

很多朋友跟着前面的章节,已经把GPIO玩得很熟了:拉高拉低、读按键、控制LED闪烁。但一旦开始做稍微复杂一点的东西——比如做一个呼吸灯、测一个脉冲宽度、跑一个精确的延时,就会发现HAL_Delay()或者软件死循环for(i=0;i<100000;i++);根本不够用。为什么?

因为软件延时吃掉了CPU的全部时间。CPU在跑延时的时候,什么事都干不了,按键扫描要停、屏幕刷新要停、通信协议也要停。而定时器(Timer)就是把“数时间”这件苦力活交给硬件去干,CPU只在关键时刻被叫醒一下。这就是第四章必须讲定时器的最核心理由:它是从“裸奔式点灯”走向“非阻塞式架构”的第一道门槛。

我写这一章的时候,刻意没有用标准库或者HAL库,全部直接操作寄存器。原因很简单:寄存器操作能让你真正看见时序是怎么走起来的。库函数封装好的__HAL_TIM_SET_COMPARE()当然好用,但如果你不知道它往哪个寄存器里写了什么值,一旦遇到定时器不工作、PWM波形不对,你会连从哪儿开始排查都不知道。本章的定位是:不追求代码量最少,追求让你看懂“一个定时器从配置到出波形”的全链路。

适合看的读者分两类:一类是把标准库用熟了、但从来不敢碰寄存器的同学,这一章能帮你打通底层逻辑;另一类是已经开始用寄存器操作、但每次写到定时器就抄例程、抄完能跑却说不清楚原理的同学,这一章能帮你把散落的知识点串成体系。

我说一句实话:寄存器的本质,就是芯片厂商扔给开发者的一组“开关和旋钮”。手册里动辄几十页的寄存器描述看起来很吓人,但实际日常开发用到的就那么十几个位。你要做的不是背寄存器地址,而是理解三个问题:时钟从哪里来、计数器往哪里走、到底怎么输出和触发。下文全部围绕这三件事展开。

2. 定时器到底是什么:计数器、预分频器和自动重装载三件套

2.1 用一个“水表”的比喻彻底看透计数原理

你想理解定时器,最好的办法是忘掉“定时”这个词,把它想成一块水表。

水表有一个进水口,水以恒定的速度滴进来,表盘上的数字每次滴入就加一。水表有个上限:比如9999,满了就自动归零。水表还有一个抽水泵:当表盘的数字到达你预设的“报警线”时,水泵启动,抽干整箱水,然后表盘归零重新计。这就是定时器的完整工作模型。

对应到STM32的硬件上:进水口是内部时钟总线送来的脉冲,表盘是16位计数器CNT(可以从0数到65535),报警线是自动重装载寄存器ARR,而“到达报警线之后触发动作”的就是各种中断和事件输出。

那预分频器PSC是干嘛的?它相当于在水表前面加了一个“分流阀”:原本每秒滴进来72万滴水(72MHz时钟),你在分流阀上写一个71,那么每72滴只放行1滴,于是实际送到水表的频率就从72MHz变成了1MHz。这就是预分频器的本质:一个降频器。有了它,你才能让定时器在低速场景下不至于秒秒钟就溢出回零。

举个例子,你想要一个100ms的中断周期。系统时钟是72MHz,分频到1MHz之后,计数器走100000次就是100ms。然后你要确定PSC和ARR怎么配合:先定PSC=71(72M/(71+1)=1M),再定ARR=99999(从0数到99999共100000个周期)。计算就是这么简单,核心公式只有一个:

定时频率 = 时钟频率 / ((PSC+1) * (ARR+1))

这个公式我建议你贴在电脑屏幕上。TIM定时器的所有配置,十个里面有八个都是在围绕它做文章。

2.2 向上计数、向下计数和中心对齐:三种模式分别用在哪儿

计数器不是只能从0往上加的。STM32的定时器给了你三种跑法:

  • 向上计数模式:从0数到ARR,然后瞬间归零重新开始,最常用。普通延时、周期中断、PWM输出都用它。
  • 向下计数模式:从ARR往下数到0,再瞬间跳到ARR重新开始。应用场景相对少,但当你需要和某个外部事件“对齐反向相位”时有用。
  • 中心对齐模式:先向上数到ARR,再向下数回0,如此往返。这个模式是生成对称PWM波形的利器,你在做电机控制或全桥逆变时会经常用到它,因为中心对齐产生的PWM脉宽在时间轴上是对称的,谐波更小。

这三种模式的切换在工程上就改一个TIMx_CR1寄存器的DIR位和CMS位。向上模式时DIR=0,向下模式时DIR=1,中心对齐是CMS不等于零。这里有一个新手容易忽略的细节:修改DIR和CMS的时候,必须先把计数器停掉(CEN=0),手册上明确要求了这一点,但很少有人注意到。我以前就吃过这个亏:程序运行中我直接改方向位,结果整个定时器状态立刻乱掉,波形直接错乱。

2.3 16位还是32位:计数器位宽的坑与判断方法

STM32内部的定时器有两大类:基本定时器(如TIM6、TIM7)是16位的,而高级定时器(如TIM2、TIM5)在某些型号上是32位的。16位计数器的最大值是65535,32位的最大值是4294967295。这两者的差距,用一个大场景你就明白了:

时钟不分频,直接跑72MHz。16位定时器从0数到65535再重装,只需要大约0.91ms就溢出一次;而32位定时器跑一圈需要59.6秒。如果你要做一个1秒钟的周期中断,用16位定时器就必须配大分频系数,但分频系数一旦加大,计数精度就会降低——因为每个计数单位代表的时间变长了。而32位定时器可以做到“不分频计数、依然远远覆盖1秒周期”,精度因此高得多。

实操中怎么快速区分?最靠谱的办法是查手册里的“定时器特性表”,但日常项目里你也可以直接看:在做超长定时时,如果ARR寄存器最大只能写到65535,说明它是16位的;如果写一个大于65535的数代码还能正常编译运行,那它就是32位的。这里提醒一下:即便你是32位定时器,也不能真的把ARR填到4294967295这种极端值去碰运气,因为最大计数值除了受位宽限制,还要看计数器实际能跑多快,边界状态很容易触发意外重装错误。

3. 一次典型项目实战:用TIM2驱动四路PWM输出并动态调速

3.1 项目需求与硬件连接方式

某模拟项目需要四路PWM输出,分别驱动四个LED实现亮度渐变,LED1按1Hz的呼吸节奏变化,其余三路频率相同但相位错开,看起来像流光效果。选择TIM2的原因很简单:它有四个独立的比较通道(CH1到CH4),恰好可以一次输出四路PWM,不需要额外占用其他定时器资源。

硬件连接上,TIM2的四个通道分别映射到PA0、PA1、PA2、PA3。注意:这几个引脚不是随便选一就能用的,它们必须是对应定时器通道的重映射引脚。很多朋友把定时器配置得头头是道,结果波形死活出不来,一查,引脚功能没复用成AF模式,GPIO停留在最普通的推挽输出——那当然不可能看到PWM。STM32的每个引脚都有第二功能,你必须让GPIO模块和定时器模块“连上线”,这条路才通。

还有一个细节:PWM输出甚至不需要GPIO输出数据寄存器(ODR)参与,你给GPIO配好复用推挽模式后,电平完全由定时器的输出比较电路接管。

3.2 具体配置流程:GPIO、时钟、预分频到通道输出

第一步,开启GPIOA和TIM2的时钟。在基于寄存器操作的老款型号上,这一步对应的是RCC->APB2ENR和RCC->APB1ENR各置位对应的位。然后,配置PA0到PA3为复用推挽输出,复用功能映射到TIM2_CH1到CH2_CH4。

第二步,配置TIM2的基本参数。目标PWM频率我定在100Hz,LED呼吸灯的视频上看起来已经很细腻了,再低会闪烁,再高肉眼也分辨不出区别。时钟经过分频后设为1MHz,ARR设为9999,所以周期就是10ms对应100Hz。

第三步,设置四个通道的模式为PWM1,也就是“当计数器的值小于比较寄存器CCR的值时,输出有效电平;大于等于时,输出无效电平”。把CCR1初始设为2500,CCR2、CCR3、CCR4分别设为不同的初值,实现相位错开。最后把预装载寄存器和自动重装载寄存器都使能,TIMx_CR1的ARPE位置1,这样修改ARR不会立刻生效,而是等本轮计数周期结束再更新,避免波形撕裂。

3.3 动态调速的关键代码解读

主循环里,我通过按键中断改变呼吸灯的目标亮度值,然后在定时器更新中断里按步进方式改变CCR1的值。这里最关键的代码是:

// 在定时器更新中断服务函数里 volatile uint16_t target_ccr = 0; // 目标比较值 volatile uint16_t current_ccr = 0; // 当前比较值 if (current_ccr < target_ccr) { current_ccr += 50; // 每次中断增加步进量 } else if (current_ccr > target_ccr) { current_ccr -= 50; } TIM2->CCR1 = current_ccr; // 直接写比较寄存器

你可能会问:为什么不直接TIM2->CCR1 = target_ccr一步到位?因为那样亮度变化在视觉上就是“切换”,而不是“渐变”。用一个步进值在中断里慢慢逼近目标值,代码极简却效果好。中断频率100Hz,每次步进50,从0到2500需要50次中断,即0.5秒完成一个渐亮周期,节奏刚刚好。

3.4 实测中常见的四种波形异常及原因

做完整套配置上电后,我读了一下输出引脚波形,几乎每次都能看出一类问题。列出最常见的情况:

现象可能原因排查方式
完全没有波形,引脚一直低电平GPIO未配置复用功能,或定时器时钟没开查RCC使能位和GPIO的AF配置
波形有,但频率比预期小了整整一个数量级PSC写错,比如期望71写成了719用公式反算,抓PSC和ARR两个字
波形频率对,但占空比只有50%且不可调CCR寄存器没有使能预装载位,值更新不同步置位TIMx_CR1的ARPE位
输出正常但首周期异常,波形开头多了一截计数器初始值不为零,与ARR不对齐配置时先把CNT清零

其中第二类特别隐蔽,尤其在大分频场景下,一位写错就差了十倍,肉眼完全看不出来。每次调试定时器,我都会先在逻辑分析仪上看一眼实际频率,再拿计算器倒推出PSC和ARR的真实值,问题往往马上暴露。

4. 内部时钟与外部时钟源:搞懂“谁给计数器送脉冲”

4.1 内部时钟链路和最大频率限制

大多数时候,定时器的时钟直接来自内部的TIMxCLK,它通常又是从AHB总线经过APB预分频器得到的。有一个经常让人绕晕的点:当APB预分频器不为1时,定时器的时钟频率是APB频率的2倍。这句话看着简单,实际编码时特别容易踩坑。

在72MHz主频的系统中,如果APB1预分频系数是2,那么APB1外设总线频率是36MHz,但挂在APB1上的定时器时钟不是36MHz而是72MHz,因为定时器时钟有一个专门的倍频器。你如果直接根据APB1频率算定时器的分频系数,就会算出一套差一倍的值,出来的频率全部翻倍,而自己还久久找不到原因。正确做法是:设计系统时钟时就要确认定时器时钟源,然后用RCC->CFGR寄存器里的PPRE1位参数反推。

4.2 外部脉冲时钟模式:不用CPU数数也能数脉冲

定时器不仅可以数内部时钟的脉冲,还可以数外部引脚送来的脉冲。这就是外部时钟模式1:外部信号从定时器某个输入引脚进来,经过边沿检测后,直接作为计数器的时钟。典型场景是旋转编码器测转速:编码器输出的A相脉冲直接接到TIM的通道引脚上,每来一个脉冲就计一次数,完全不占用CPU。

配置外部时钟模式只需两步:把输入引脚映射好,设置SMCR寄存器的ECE位或者SMS位。我要提醒的是,外部信号的频率必须低于定时器时钟频率的1/4,不然采样跟不上信号变化,计数会漏。实际案例中,我见过有人把500kHz的编码器信号直接接到72MHz定时器上,那就没问题;但也有人接了个高频方波振荡器,频率接近甚至超过定时器时钟,结果计数结果像随机数一样乱跳。

如果你要测PWM信号的频率,用两个定时器配合:第一个定时器作为“门控”,产生一段精确的1秒窗口;第二个定时器在这个窗口内对外部信号计数。两次计数值除以窗口时间就是频率。这套思路用寄存器写下来可能二十来行,但很多人拿示波器在那里数格子,浪费的时间远不止这个数。

4.3 编码器接口模式到底是改了什么东西

STM32的高级定时器还有个很妙的功能:编码器接口模式。在这个模式下,定时器不再需要你软件去判断正反转,两个输入引脚上谁先来脉冲、谁在后,硬件内部自动完成方向判定,计数器自动加一或减一。你只需要读出CNT寄存器的值,就得到了转角和方向。

这个模式下,计数器会把A相和B相的两个边沿都利用起来,默认是四倍频计数:一个完整的正交脉冲周期,计数器会走4个步进。对高精度伺服和云台控制来说,这就意味着你在不增加线数的情况下,把分辨率提高了4倍,这个特性做运动控制的老手基本都会用到。

5. 中断、事件和DMA:不要把所有工作都堆给CPU

5.1 更新中断和比较中断的动作分工

定时器有两类最常用的中断:更新中断和比较中断。

更新中断发生在计数器溢出或重装的时候,也就是一个定时周期走完的时刻。适合做周期性的轮询心跳:比如每10ms修一次呼吸灯目标值、每1ms扫描一次按键抖动脉冲。比较中断则发生在CNT和CCR相等的那一瞬间,它不需要等到整个周期结束,所以特别适合做“精确时刻触发”。比如你需要在PWM周期内20%的位置触发ADC采样一次,先设好CCR的值,等到那一瞬间进中断开启采样,CPU不用忙等,也不会错过相位点。

使用时要留意抢占优先级分组的问题。多个中断同时配置时,如果优先级分组没设好,后配置的中断可能把前一个顶掉,这在定时器中断与串口中断共存的系统里非常常见。STM32的优先级分组一旦设定,全系统生效,推荐在初始化最前面就统一设置好。

5.2 一个经典的定时器触发ADC采样链路

在电机控制、电源监控这类实时性要求很高的项目里,通常要求“PWM波的每个周期固定相位点上采样一次电流”。如果靠主循环轮询ADC,那相位永远对不准。正确的寄存器链路是:

  • 将定时器更新事件连接为ADC的触发源,让每个PWM周期自动触发一次采样
  • ADC采样完成后,通过DMA直接把结果搬到内存,不打断CPU
  • 主循环只负责读取DMA搬来的最新数值

这个链路用到的是定时器的“事件”而非“中断”。事件是硬件到硬件的信号通路,不经过CPU,没有中断响应延迟,精度高且CPU占用低。我刚才在前面项目里没有用DMA,因为那只是LED呼吸灯,对CPU压力可以忽略;但一旦进入传感器、电机这类场景,DMA几乎是一个必须走的方向。

这里有一个容易犯错的问题:你用更新事件触发ADC,势必不会进更新中断,如果此时你想兼顾“每周期做一次软件状态机推进”,该怎么办?答案是用比较中断:设置一个CCR值等于ARR-10,让它比更新事件提前极短时间触发一次中断,软件在比较中断里推进状态机,而ADC继续听更新事件。两者互不干扰,共用一个定时器就都实现了。

5.3 定时器中断频率和主循环的实时性平衡

很多人爱把定时器中断频率设得极高——比如设成100kHz,然后发现整个系统卡顿严重。原因很简单:中断只能打断当前执行的任务,但不能让CPU同时干两件事。如果中断服务函数本身执行时间需要50us,而中断每隔10us就来一次,那CPU基本永远在中断里,主循环彻底饿死。

实际开发中,我的经验是:中断服务函数里只做“置标志、清中断、读寄存器、简单累加”,所有复杂运算全部丢到主循环里去处理。定时器中断负责“捕捉时间点”,主循环负责“算数据”,两者配合才能把100MHz级别的CPU用到刀尖上。再补一句:关闭中断服务函数里一切延时函数,你永远不应该在ISR里用HAL_Delay,那是把系统往死路上推。

6. 用寄存器操作踩出来的五个深度坑和对应解法

6.1 修改ARR后波形不生效:预装载使能没置位

很多人在跑到一半时动态修改ARR来改PWM频率,结果写完代码发现频率纹丝不动。原因和前面提到的一样:TIMx_CR1里的ARPE位没有置1,自动重装载寄存器不具备预装载能力,改动就地生效、且发生在错误时刻,波形一团糟。置ARPE=1意味着ARR的修改统一在本轮计数结束、计数器即将重装的一刻才批量刷新。没有这个位,所谓“平滑调频”就无从谈起。

6.2 CNT清零指令的时序问题

你调用TIMx->CNT = 0,以为计数器立刻归零,但实际上在部分STM32系列里,对CNT的写操作是被缓冲的,会延迟到CNT的下一次更新事件才能生效。这在某些同步要求极高的场景——比如需要两个定时器精确对齐时——会造成一拍误差。建议使用主从模式下的外部触发复位,或者用一个定时器去触发另一个定时器的从模式复位,保证硬件级同步。

6.3 中断标志位不清除导致反复进中断

更新中断对应的标志位是TIMx_SR里的UIF。进入中断服务函数后,如果你不清除这个标志位,CPU会一直认为中断条件成立,然后不停重进中断,表现起来就是整个系统卡死甚至反复跳动。清标志位的方式有两种:直接写零清除,或者读状态寄存器后写零。配置多个中断源同时使能时,你要逐个检查对应的标志位去除情况,这一点在高级定时器上特别明显,因为它一个定时器能产出的中断源太多了。

6.4 刹车和死区:高级定时器独有的混乱源头

假如你用的是TIM1、TIM8这类高级定时器,光配通道输出还不够,你还必须把主输出使能位TIMx_BDTR的MOE位置1,否则PWM波形永远出不来。当年我调一个驱动板,愣是查了半天没找到为什么引脚上没波形,最后发现是这个位的锅。刹车功能是用来做故障保护关断用的,死区插入是为了防止上下桥臂直通设计的,日常功能开发时它俩往往是被忽视但经常误触发的两个陷阱:刹车输入信号一旦误拉低,所有PWM立刻被硬件封锁。

6.5 调试时必须掌握的验证手段:寄存器现场快照

排查定时器问题,最高效的方式不是翻代码,而是趁程序跑在断点处,一次性读出这几个寄存器当前的值:CR1(看模式和预装载)、SMCR(看触发源)、CCER(看输出极性)、CNT(看实时计数)、CCR(看比较值)、SR(看标志位)。把这六七个值列出来对照芯片手册,问题十之八九马上现形。这里有一个我常用的即兴技巧:不要直接读寄存器数值本身,而是把它的二进制展开看。比如读回CR1=0x0085,二进制头尾一串布控位全亮,你一眼就能识别它是不是处于你预期的工作模式。

7. 准备好以后,从这款芯片迁移到别的版本时要注意哪些差异

STM32的寄存器在不同子系列里是“高度相似但不完全一样”的。你在某个型号上调好的定时器代码,直接移植到另一个型号,大概率有三个地方会出问题:

当时钟频率变了,所有PSC和ARR计算全都要重来。旧系统72MHz,新系统168MHz甚至更高,沿用旧分频值输出的就是完全错误的频率。所以一套要跨型号复用的代码,最好的方式是把PSC和ARR的计算公式抽出来,让它在初始化时读取RCC里面的实际时钟值来动态算出两个参数。

重映射引脚表不一样。旧型号里PA0就是TIM2_CH1,新型号里可能是PB4才是。如果你不查新芯片的数据手册而照抄旧工程的PIN配置,代码会编译通过但功能全无。跨型号开发时,打开相应芯片的参考手册去确认复用功能表,这一关马虎半分都不行。

高级特性的寄存器布局有差异。刹车、死区、重复计数器这些高级功能在不同系列中所在的寄存器甚至位号都有变化。做基础定时倒是问题不大,但一旦用到这些特性,老代码直接搬过去很可能莫名其妙。

我自己的习惯是:每逢跨系列迁移,都会重新过一遍系统时钟树,画一个从晶振到外设时钟的完整路径图,然后在定时器配置代码中把每一步的时钟来源都注释清楚。看似费功夫,实际上避免的返工比这多得多。

8. 一些没有办法写得面面俱到但你必须知道的寄存器扩展话题

定时器的功能牵涉面极广,第四章篇幅有限,我再挑几个高频话题,简要讲一下方向,日后有机会再逐个展开。

主从模式是同步多个定时器的核心:主定时器输出触发信号,从定时器接收触发信号,两者可以实现硬件级连锁。你做无刷电机控制时,一桥臂的PWM需要和另一桥臂的死区严格对齐,光靠软件根本无法保证时序,必须用主从链接。

输入捕获的速度边界意味着它不能测任意快信号。输入捕获本质上靠定时器时钟采样,信号频率超过一定极限后,捕获值会存在一个计数单位的误差,应对策略是合理降采样、增加平均次数,或者外接硬件计数器芯片。很多新手拿定时器输入捕获去测几MHz甚至几十MHz的信号,测出来的数据完全不可信,就是这个道理。

PWM输出还可以结合DAC实现双通道不同幅度的波形合成,这个玩法在波形发生器和音频应用里非常讨巧。另外,高级定时器自带的互补输出在驱动全桥电路时,防直通功能内置,软件配置得当就可以省下大量外部硬件保护电路。

对于这几块,我想对你说的是:先把本章的计数原理吃透,这些扩展话题往前走一两个项目自然就会碰到。做嵌入式的过程,本来就是一步一步从“能点灯”,向“能精确控制时间”,再向“能控制能量”递进的。第四章帮你迈出了第二步,后面的路,最好带着自己做过的实物去走,这样每一步都会踩得更扎实。

如果你已经能在示波器上稳定看到自己配置出来的四路PWM波形,动动手试试让它们以不同步长动态调速,然后再回到手册里翻一遍五个定时器寄存器的位说明,你会发现,原本那些枯燥的位定义突然都活了起来。那才是这一章真正想让你达到的状态。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进