第三章如约来了。说实话,STM32参考手册里最劝退的就是定时器这一块:CR、CCMR、CCER、SR、EGR一大串缩写,光看名字就让人头皮发麻。但把寄存器一组一组拆开看,底层逻辑其实特别单纯——一个不停往上翻的计数器,配上几个比较器,再结合中断和输出动作,就构成了定时器的全部家当。这篇保持“寄存器白话手册”的调性,全程只操作寄存器、不依赖任何库,用F1系列的TIM3当例子,带你从时基配置一路干到PWM输出,最后用呼吸灯和舵机两个实战场景收尾。适合已经能把GPIO玩转、想进一步把定时器原理参透的朋友。
1. 定时器整体框架:寄存器地图其实就是开关面板
1.1 定时器到底在干什么
先用大白话把定时器的物理结构捋一遍。定时器的核心是一个硬件计数器,内部有一个CNT寄存器,每来一个时钟上升沿就自动加1。你在ARR里写一个目标上限,CNT数到ARR之后清零,同时产生一个更新事件。PSC在中间做分频,可以把计数节奏放慢,就这么点事。
在这个骨架上,派生出了三类常用能力:
- 定时中断:CNT数到ARR就触发更新中断,于是有了高精度的软件节拍,操作系统里那个tick就是这么来的。
- PWM输出:拿CNT和一个比较值CCR做比较,根据比较结果翻转引脚电平,于是数字引脚能输出“模拟效果”。
- 输入捕获:外部信号跳变的时候把CNT的快照存进CCR,于是能测频率、测脉宽。
F1系列里TIM2/3/4/5属于通用定时器,每个有4个独立通道,覆盖日常90%的需求绰绰有余。这章先把定时中断和PWM这两件事整明白;输入捕获的玩法后面需要用的时候再单独拆。
很多人问,HAL库一句HAL_TIM_PWM_Start就完事了,为什么要手写寄存器?我自己的体会是:手写寄存器最大的回报是大脑里能形成正确的时序模型。以后HAL出问题、换芯片平台、或者遇上诡异到只有看寄存器才能定位的bug,你知道底层到底发生了什么。寄存器操作在工程里未必是最终方案,但一定是帮你建立“第一性原理”的最佳途经。
1.2 TIM3的寄存器速查表
动手写代码之前,最好先把寄存器地图摊开。以下以TIM3为例,偏移地址都是相对于TIM3基地址的。寄存器太多记不住很正常,关键是把“这组寄存器管什么”记在脑子里,细节随时查手册。
| 偏移 | 寄存器 | 一句话作用 |
|---|---|---|
| 0x00 | CR1 | 控制寄存器:计数器使能CEN、自动重装载预装载ARPE、计数方向DIR、中央对齐CMS都在这里 |
| 0x04 | CR2 | 辅助控制:输出比较禁用电平、主输出功能等,日常用较少 |
| 0x08 | SMCR | 从模式控制:用于定时器级联、外部触发,玩高级功能才碰 |
| 0x0C | DIER | 中断/DMA使能:UIE更新中断、CCxIE捕获比较中断 |
| 0x10 | SR | 状态寄存器:UIF更新标志、CCxIF比较标志,读后要手动清 |
| 0x14 | EGR | 事件产生寄存器:往UG位写1,软件触发一次更新事件 |
| 0x18 | CCMR1 | 通道1和通道2的模式:输出方向还是输入方向、PWM模式、预装载 |
| 0x1C | CCMR2 | 通道3和通道4的模式,职责同CCMR1 |
| 0x20 | CCER | 通道输出使能和极性:CCxE、CCxP |
| 0x24 | CNT | 计数器当前值,实时在跑 |
| 0x28 | PSC | 预分频值,决定计数跳一格要多久 |
| 0x2C | ARR | 自动重装载值,决定计数周期 |
| 0x34~0x40 | CCR1~CCR4 | 四个通道的比较值 |
别急着背表格。后面每个寄存器都会在代码里露脸,用一次就记住了。真正有用的是记住这种分组逻辑:CR开头的是“模式开关”,SR和DIER管“状态和中断”,CNT/PSC/ARR管“时基”,CCR管“比较和输出”。
1.3 最容易栽的坑:APB1上的定时器为什么是72MHz
配置定时器之前先解决一个高频困惑。查F103的时钟树你会发现,TIM2/3/4/5挂在APB1总线上,而APB1外设时钟PCLK1默认是36MHz,可定时器时钟却可以到72MHz。这俩不矛盾吗?
这是STM32的一个设计细节:当APBx预分频系数不等于1时,定时器时钟会自动变成APBx的两倍。也就是说,系统主频72MHz、APB1分频2得到PCLK1=36MHz时,TIMxCLK = 2 × 36MHz = 72MHz。这条规则在算PSC和ARR时直接决定结果,很多人第一次算PWM频率对不上,十有八九就是在这里栽的:要么直接拿36MHz算,要么拿72MHz算但忘了通用定时器其实接的就是72MHz。
顺带提一句,挂在APB2上的TIM1和TIM8,APB2预分频如果设成1,那它们就直接吃72MHz,不存在翻倍逻辑。搞混了就会在TIM1和TIM3上算出两个不一样的PSC,这也是老手偶尔也会犯的低级错误。建议每次建工程,先看一眼RCC_CFGR的分频配置,再决定你的计算起点。
2. 时基单元:配出你想要的节拍
2.1 秒表类比与核心公式
把时基单元这组寄存器理解成一块电子秒表:
- PSC:决定秒表跳一格要花多长时间。它是预分频器,作用是把输入时钟先除一下。
- CNT:秒表的当前读数,每个计数时钟沿加1。
- ARR:秒表数到多少就归零重新开始,同时打个更新事件。
对应的核心公式只有两个,写代码前建议先列出来:
计数时钟频率 fCK_CNT = 定时器输入时钟 / (PSC + 1) 更新事件频率 fU = fCK_CNT / (ARR + 1) // 向上计数、边沿对齐模式看到分母里的+1不要莫名其妙——PSC=0时就是不分频,计数频率等于定时器时钟,如果公式里不加1就变成“永远多一分频”了。同理ARR=999时CNT从0计到999,一共1000个计数状态,要算作ARR+1。
周期和频率互为倒数,于是:
频率 f = 72MHz / ((PSC+1) * (ARR+1))实际选参数时一般先定PSC得到合适的分辨率,再用ARR定频率。比如定时输入时钟72MHz,想要1kHz的PWM,可以先让PSC=71,于是计数时钟 = 72MHz / 72 = 1MHz,也就是计数器每1微秒跳一格,分辨率1微秒,再让ARR=999,更新频率 = 1MHz / 1000 = 1kHz。这个组合后面实战会直接用。
2.2 PSC和ARR的16位边界
写配置前还得防一手溢出。PSC和ARR都是16位寄存器,最大65535。想输出很低的频率时,两个值怎么分配是有讲究的。
比如要1Hz的频率:72MHz / 1Hz要分别配给(PSC+1)和(ARR+1)。如果PSC取最大值65535,计数时钟约为1098Hz,ARR自然取1097,正好1Hz。如果两个都用满,最低更新频率约为0.0168Hz,也就是周期接近60秒,这是一个定时器单次能扛住的最长周期。
想定时几分钟甚至几十分钟?别硬扩,正确做法是在更新中断里加软件计数:先把定时器配成1Hz的节拍,中断里维护一个变量,每来一次更新变量加1,变量到目标值再执行任务。这种“硬件时基+软件分频”的组合在工程里极常见,也省去了为了拉长时间而牺牲时间精度的麻烦。
2.3 UG位:一个被忽略的时序细节
PSC和ARR写进去之后并不会马上生效,因为PSC在F1系列里始终带影子寄存器,ARR在ARPE位为1时也带影子寄存器。什么叫影子寄存器?就是你写的值先存到预备区,要等一次更新事件才被打到实际生效区,保证运行中的参数切换是原子性的,不会出现半个周期用新值、半个周期用旧值的混乱。
初始化阶段没有自然更新事件可用,标准动作是手动往EGR寄存器的UG位写1,强制产生一次软件更新事件,把PSC和ARR立即加载进去。这是我的初始化固定顺序:
写PSC → 写ARR → CNT清零 → EGR.UA置1 → 再开CEN启动如果顺序反了,比如先开CEN再去拉UG,计数器会先按默认值跑一拍,虽然最后也能对上,但遇到调试时读CNT会发现第一拍数值异常,掩盖真实问题。保持每一组配置的确定性,调试时少损耗大量心力。
3. 输出比较与PWM模式:把数字变成波形
3.1 比较器本质:CNT和CCR的较量
时基配好只解决了“定时”,要让引脚输出波形,还得靠输出比较单元。原理简单到像一句废话:硬件无时无刻在比较CNT和CCR的大小,一旦满足条件就动作。
PWM模式1是这个比较逻辑最常用的表达。在向上计数、边沿对齐模式下:
- CNT < CCR 时,输出有效电平(默认高电平)
- CNT ≥ CCR 时,输出无效电平(默认低电平)
- 计数器到ARR后清零重新开始,输出回到有效电平
于是占空比就是:
占空比 = CCR / (ARR + 1)边界情况也值得说透:CCR=0时CNT永远不小于CCR,输出恒为无效电平,占空比0%;设置CCR大于ARR时CNT永远小于CCR,输出恒为有效电平,占空比100%。很多人在这两个边界上想当然,结果调试时发现LED灭不彻底或者亮不透,其实不是电路问题,就是CCR没配到边界之外。
3.2 PWM模式1还是模式2
CCMRx寄存器里OCxM位段有8种模式,我们只关心两个:110是PWM模式1,111是PWM模式2。模式1是CNT小于CCR时输出有效;模式2正好反过来,CNT大于CCR时输出有效。
从波形上看,模式2就是模式1的逻辑反相。那什么时候用模式2?其实极少。需要反极性的场景,更推荐留在模式1,然后用CCER里的CCxP位把输出极性反转。好处是计算公式不用动,占空比还是CCR/(ARR+1),脑子不容易乱。我自己只在一种场景用过模式2——某个电路上驱动信号恰好是低电平有效,还不想额外加反相器,用模式2直接省了一个非门,但这种需求现在基本都被CCxP取代了。
3.3 预装载与毛刺:ARPE和OCxPE必须一起开
运行中改ARR或者CCR,是PWM最经典的毛刺来源。想象计数器正在0到999之间跑,你突然把ARR从999改成499,计数器到499那一刻产生更新事件,波形周期瞬间被砍掉一半,输出波形肉眼不可见但电机或电源会立刻给你脸色看。
两个预装载位就是为了解决这个问题:
- CR1的ARPE:置1后ARR带影子寄存器,运行时写ARR不会立即改周期,而是在下一个更新事件统一生效。
- CCMRx的OCxPE:置1后CCR带影子寄存器,运行时写CCR同理,下一个更新事件才生效。
两个位都打开之后,你在更新中断里改占空比,波形会在下一个周期无缝切换,找不到任何半个周期的畸变。代价是修改和生效之间有最多一个周期的延迟,对呼吸灯、电机调速、舵机控制这种低频场景完全无感;对高频电源应用就得留心这个相位延迟。工程上的选择很清楚:默认打开预装载,需要立即响应时再关掉OCxPE。
还有一个和预装载配套的动作:初始化时写完PSC、ARR之后先拉UG,让影子寄存器提前同步。如果忽略了这一步,即使开了ARPE,第一次启动计数器用的也可能是芯片复位时的默认ARR值,输出波形第一周期就是错的。
4. 实战:手写寄存器配置呼吸灯与舵机
4.1 硬件连接与目标参数
纸上谈兵结束,上真东西。示例平台就用最普及的F103,TIM3的通道3映射到PB0。边界条件如下:
- LED呼吸灯:PB0接LED正极,LED负极串联220Ω限流电阻到GND,PWM频率1kHz,周期约2秒(1秒渐亮、1秒渐灭)。
- 舵机扩展:同一个PB0接模拟舵机信号线,信号地共地,PWM频率50Hz,CCR在500~2500之间对应舵机角度范围。
两组需求对时基的要求完全不一样,正好演示同一套寄存器怎么灵活配置。呼吸灯用PSC=71、ARR=999;舵机用PSC=71、ARR=19999。先记下这两个组合,马上逐行拆代码。
4.2 时基初始化代码逐行拆解
void TIM3_TimeBase_Init(void) { /* 1. 打开TIM3时钟,挂在APB1上 */ RCC->APB1ENR |= RCC_APB1ENR_TIM3EN; /* 2. 配置时基:72MHz / 72 = 1MHz,计数每1us跳一格 */ TIM3->PSC = 71; TIM3->ARR = 999; /* 更新频率 = 1MHz / 1000 = 1kHz */ TIM3->CNT = 0; /* 3. 软件更新事件:立即让PSC和ARR生效 */ TIM3->EGR |= TIM_EGR_UG; /* 4. 启动计数器 */ TIM3->CR1 |= TIM_CR1_CEN; }第一步开时钟在寄存器编程里是几乎所有外设的起点:STM32为了省电,默认大部分外设时钟都是关的。直接操作寄存器的一大好处是时钟、外设、中断这三个环节全是你自己接通的,逻辑非常清楚;用库函数时这些都被封装进初始化函数里,出问题反而不容易定位。
第二步选PSC=71,计数频率变成1MHz,分辨率1微秒,ARR=999得到1kHz。如果你希望PWM有更高的占空比分辨率,可以把PSC调小让计数频率更高、ARR更大;反过来如果只求低频、不在乎分辨率,PSC可以大一些。这是时基配置里最常见的权衡。
第三步的UG是关键动作,又提醒一次:不拉UG,PSC和ARR可能还躺在影子寄存器里,计数器按复位默认值跑。初始化里这个动作不能省。
4.3 PWM通道配置代码逐行拆解
void TIM3_PWMChannel_Init(void) { /* 1. PB0配置为复用推挽输出,50MHz */ RCC->APB2ENR |= RCC_APB2ENR_IOPBEN; GPIOB->CRL &= ~(0x0FU << 0); GPIOB->CRL |= (0x0BU << 0); /* 复用推挽输出 CNF=10, MODE=11 */ /* 2. 通道3设为PWM模式1,CCR3预装载 */ TIM3->CCMR2 &= ~TIM_CCMR2_CC3S; /* CC3S=00,选择输出方向 */ TIM3->CCMR2 |= TIM_CCMR2_OC3PE; /* 开启CCR3预装载 */ TIM3->CCMR2 &= ~TIM_CCMR2_OC3M; TIM3->CCMR2 |= TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_2; /* OC3M=110 */ /* 3. 通道3输出使能,默认高电平有效 */ TIM3->CCER &= ~TIM_CCER_CC3P; TIM3->CCER |= TIM_CCER_CC3E; /* 4. 初始占空比0 */ TIM3->CCR3 = 0; /* 5. 开ARR预装载,启动计数器 */ TIM3->CR1 |= TIM_CR1_ARPE; TIM3->CR1 |= TIM_CR1_CEN; }第一步的GPIO配置和上一章的套路一致,只是CNF位段要改成复用功能输出,不是通用推挽。漏改CNF是最典型的“PWM没波形”原因之一,后面排查表会列上。
第二步是通道模式配置。CCMR2管通道3和4,把CC3S设为00意思是通道工作在输出方向;OC3M设为110即PWM模式1。OC3PE打开后,运行时改CCR3会等更新事件生效,这就是之前说的防毛刺机制。
第三步是很多新手最容易漏的:模式配好了,但通道输出还没开。CCER里的CC3E就是那个总开关,不使能它,引脚只会保持GPIO电平,PWM波形根本出不来。CC3P默认0就是高电平有效,不用动。
第四步把初始比较值设为0,LED一开始是全灭状态,避免上电瞬间闪一下。
最后一步打开ARPE位,让ARR也进入预装载模式,然后启动计数器。到这里PB0就应该能测到1kHz的方波了。
4.4 更新中断与呼吸逻辑
光有固定占空比的PWM还不够“呼吸”,要让占空比在每个周期缓慢变化。做法是在更新中断里改CCR3,配合一个方向标志:
volatile uint16_t breath_step = 0; volatile uint8_t breath_dir = 1; void TIM3_IRQHandler(void) { if (TIM3->SR & TIM_SR_UIF) { TIM3->SR &= (uint16_t)~TIM_SR_UIF; /* 清标志,必须手动清 */ TIM3->CCR3 = breath_step; if (breath_dir) { breath_step++; if (breath_step >= 1000) breath_dir = 0; } else { breath_step--; if (breath_step == 0) breath_dir = 1; } } }初始化里还需要补两步:使能更新中断并挂到NVIC:
TIM3->DIER |= TIM_DIER_UIE; NVIC_EnableIRQ(TIM3_IRQn);中断处理的套路和上一章讲外部中断时一样:进中断先判断SR的UIF标志,处理完必须清零。定时器的SR标志和外部中断的挂起位类似,硬件置1但不会自动清,不清的话中断会疯狂重入,表现为程序看起来“卡死”在主循环里,其实是被中断风暴喂饱了。
这里有一个时序细节值得展开。因为OC3PE开着,我们在更新中断里写的CCR3并不是立即影响当前周期,而会在下一个更新事件生效。也就是说,中断里写的新占空比会“排队”到下一周期。好处是波形绝无毛刺,代价是有一个周期的相位延迟。对呼吸灯来说这个延迟完全无感;对高动态响应的场合就需要考虑是否要临时关掉OC3PE。
呼吸节奏上,每一步占空比变化1/1000,更新频率1kHz,单程正好1秒,一个完整呼吸周期2秒。代码里的breath_step >= 1000判断要留意:上限到达后先把CCR停在1000(对应占空比1000/1000即100%),再翻转方向,否则会在峰值处多停一拍或者直接冲过头。
4.5 同一套寄存器逻辑驱动舵机
呼吸灯玩通之后,舵机就是改三个数字的事。模拟舵机的控制信号是50Hz的PWM,高电平时间决定角度:0.5ms对应0度,1.5ms对应90度,2.5ms对应180度。
PSC = 71 → 计数频率 = 1MHz,每1us计一格 ARR = 19999 → 更新频率 = 1MHz / 20000 = 50Hz CCR = 500 → 高电平时间 = 500us = 0.5ms → 0度 CCR = 1500 → 高电平时间 = 1500us = 1.5ms → 90度 CCR = 2500 → 高电平时间 = 2500us = 2.5ms → 180度PWM频率、周期、占空比之间的换算是直接可验证的。舵机对信号的时序相当敏感,但PWM波形是定时器硬件生成的,从CCR写进去到引脚输出全程不占用CPU,所以无论主循环里干了多少别的事,舵机都能稳定在目标角度。这也是定时器外设最大的价值:把周期性时序从软件里解放出来。
要驱动角度,只需要在中断或者主循环里换算CCR:
/* 角度0~180度映射到CCR 500~2500 */ TIM3->CCR3 = 500 + (uint32_t)angle * 2000 / 180;注意别一口气把角度换算的除法挤进高频中断,运算量大了会影响其他实时任务;舵机角度变化本来就慢,在主循环里更新完全来得及。
5. 常见问题与排查速查表
5.1 现象优先排查路线
实际操作中遇到的问题,按“现象”分组排查效率最高。我把踩过的坑整理成一张速查表:
| 现象 | 优先检查点 | 修复方向 |
|---|---|---|
| 更新中断完全不进 | RCC时钟没开、DIER的UIE、NVIC没使能 | 逐个检查三个使能开关,任何一个断了都不进 |
| 中断进去后一直重入 | SR的UIF没清 | 处理完手动写0清标志 |
| PWM引脚没波形 | CCER的CCxE没置位、GPIO配成普通推挽 | 开输出使能,GPIO改复用推挽 |
| 波形有但占空比不对 | CCR和ARR算错、PSC没生效 | 重算公式,确认初始化拉了UG |
| 波形极性反了 | CCxP极性位 | 置0或置1反一反 |
| 在线改占空比出毛刺 | OCxPE和ARPE没开 | 两个预装载位一起打开 |
| 频率和计算值不符 | APB1分频后忘了×2 | TIM3时钟按72MHz算,不是36MHz |
| 首周期异常 | 配置完没拉UG | 初始化时EGR->UG置1 |
5.2 高频翻车点实录
第一个翻车点是初始化顺序。我有一次先开了CEN再拉UG,逻辑上看起来也说得通,但读CNT时发现第一拍计数特别长,原因就是PSC的旧值还占着影子寄存器。从那以后我养成了流程洁癖:PSC、ARR、CNT清零、UG、CEN,顺序写死在代码注释里,换芯片平台也照这个清单走。
第二个高频问题在通道配置。用TIM3通道3就必须用CCMR2,结果我见过太多人把PWM模式写进CCMR1,以为通道编号就是寄存器编号。CCMR1管通道1/2,CCMR2管通道3/4,这个对应关系写错之后,示波器上要么没波形,要么干脆出现的是通道1的波形,查起来特别绕。
第三个问题是清标志的时机。更新中断里先清SR再处理业务逻辑,这是死规矩。但清SR的方式也有讲究:直接用TIM3->SR = 0在很多Cortex-M核上会因为“读-改-写”竞态丢标志,稳妥做法是SR &= ~UIF,只清你确认过的那一位。
还有一个经验之谈:调试PWM时别一上来就上高频率。把PSC调到很大、把PWM频率压到1Hz左右,然后用LED直接看占空比变化——肉眼和逻辑分析仪同时验证,比盯着示波器猜参数快得多。呼吸灯这件事,眼皮比探头更直观。
5.3 个人调试习惯沉淀
最后分享几个我在多轮实战里沉淀出的习惯,未必是标准答案,但实测很省时间:
习惯一:每次配置定时器前先画一个“参数推导链”。从定时器时钟开始,写清楚分频几次、计数频率多少、目标频率多少,最后才落到PSC和ARR的数字。公式写出来比脑子算可靠,尤其改工程参数的时候,有推导链在,改一个值就能顺着链条检查其它值。
习惯二:用一个专门的头文件集中管理频率宏。比如:
#define TIM3_PCLK 72000000UL #define TIM3_PWM_FREQ_HZ 1000UL #define TIM3_PSC_VAL (TIM3_PCLK / 1000000UL - 1UL) #define TIM3_ARR_VAL (1000000UL / TIM3_PWM_FREQ_HZ - 1UL)这样频率需求变化时只改一个宏,PSC和ARR自动跟着变,手算是能省则省。
习惯三:遇到疑难问题,先相信寄存器手册,再怀疑代码。F1系列的参考手册虽然是几百页大砖头,但定时器章节的寄存器描述极其精确,每个位是什么功能、什么时候生效写得明明白白。我调试中遇到的绝大多数问题,最后都能在手翻到对应位描述时恍然大悟。读手册的速度,就是排查问题的速度。
这一章把定时器的时基、中断、PWM三件事用寄存器拆完了。我自己最大的感受是:定时器寄存器看起来多,实际上每个外设的数据手册都遵循同样的组织逻辑——模式开关一组、数值寄存器一组、状态标志一组、事件产生一组。带着这个框架去翻任何一款单片机的定时器章节,都能在半小时内找到对应关系。下一章如果继续往下写,输入捕获和编码器接口是个不错的选题,那套“捉住脉冲”的玩法又是另一种乐趣。