
第三章如约来了。说实话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基地址的。寄存器太多记不住很正常关键是把“这组寄存器管什么”记在脑子里细节随时查手册。偏移寄存器一句话作用0x00CR1控制寄存器计数器使能CEN、自动重装载预装载ARPE、计数方向DIR、中央对齐CMS都在这里0x04CR2辅助控制输出比较禁用电平、主输出功能等日常用较少0x08SMCR从模式控制用于定时器级联、外部触发玩高级功能才碰0x0CDIER中断/DMA使能UIE更新中断、CCxIE捕获比较中断0x10SR状态寄存器UIF更新标志、CCxIF比较标志读后要手动清0x14EGR事件产生寄存器往UG位写1软件触发一次更新事件0x18CCMR1通道1和通道2的模式输出方向还是输入方向、PWM模式、预装载0x1CCCMR2通道3和通道4的模式职责同CCMR10x20CCER通道输出使能和极性CCxE、CCxP0x24CNT计数器当前值实时在跑0x28PSC预分频值决定计数跳一格要多久0x2CARR自动重装载值决定计数周期0x34~0x40CCR1~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得到PCLK136MHz时TIMxCLK 2 × 36MHz 72MHz。这条规则在算PSC和ARR时直接决定结果很多人第一次算PWM频率对不上十有八九就是在这里栽的要么直接拿36MHz算要么拿72MHz算但忘了通用定时器其实接的就是72MHz。顺带提一句挂在APB2上的TIM1和TIM8APB2预分频如果设成1那它们就直接吃72MHz不存在翻倍逻辑。搞混了就会在TIM1和TIM3上算出两个不一样的PSC这也是老手偶尔也会犯的低级错误。建议每次建工程先看一眼RCC_CFGR的分频配置再决定你的计算起点。2. 时基单元配出你想要的节拍2.1 秒表类比与核心公式把时基单元这组寄存器理解成一块电子秒表PSC决定秒表跳一格要花多长时间。它是预分频器作用是把输入时钟先除一下。CNT秒表的当前读数每个计数时钟沿加1。ARR秒表数到多少就归零重新开始同时打个更新事件。对应的核心公式只有两个写代码前建议先列出来计数时钟频率 fCK_CNT 定时器输入时钟 / (PSC 1) 更新事件频率 fU fCK_CNT / (ARR 1) // 向上计数、边沿对齐模式看到分母里的1不要莫名其妙——PSC0时就是不分频计数频率等于定时器时钟如果公式里不加1就变成“永远多一分频”了。同理ARR999时CNT从0计到999一共1000个计数状态要算作ARR1。周期和频率互为倒数于是频率 f 72MHz / ((PSC1) * (ARR1))实际选参数时一般先定PSC得到合适的分辨率再用ARR定频率。比如定时输入时钟72MHz想要1kHz的PWM可以先让PSC71于是计数时钟 72MHz / 72 1MHz也就是计数器每1微秒跳一格分辨率1微秒再让ARR999更新频率 1MHz / 1000 1kHz。这个组合后面实战会直接用。2.2 PSC和ARR的16位边界写配置前还得防一手溢出。PSC和ARR都是16位寄存器最大65535。想输出很低的频率时两个值怎么分配是有讲究的。比如要1Hz的频率72MHz / 1Hz要分别配给(PSC1)和(ARR1)。如果PSC取最大值65535计数时钟约为1098HzARR自然取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)边界情况也值得说透CCR0时CNT永远不小于CCR输出恒为无效电平占空比0%设置CCR大于ARR时CNT永远小于CCR输出恒为有效电平占空比100%。很多人在这两个边界上想当然结果调试时发现LED灭不彻底或者亮不透其实不是电路问题就是CCR没配到边界之外。3.2 PWM模式1还是模式2CCMRx寄存器里OCxM位段有8种模式我们只关心两个110是PWM模式1111是PWM模式2。模式1是CNT小于CCR时输出有效模式2正好反过来CNT大于CCR时输出有效。从波形上看模式2就是模式1的逻辑反相。那什么时候用模式2其实极少。需要反极性的场景更推荐留在模式1然后用CCER里的CCxP位把输出极性反转。好处是计算公式不用动占空比还是CCR/(ARR1)脑子不容易乱。我自己只在一种场景用过模式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 硬件连接与目标参数纸上谈兵结束上真东西。示例平台就用最普及的F103TIM3的通道3映射到PB0。边界条件如下LED呼吸灯PB0接LED正极LED负极串联220Ω限流电阻到GNDPWM频率1kHz周期约2秒1秒渐亮、1秒渐灭。舵机扩展同一个PB0接模拟舵机信号线信号地共地PWM频率50HzCCR在500~2500之间对应舵机角度范围。两组需求对时基的要求完全不一样正好演示同一套寄存器怎么灵活配置。呼吸灯用PSC71、ARR999舵机用PSC71、ARR19999。先记下这两个组合马上逐行拆代码。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为了省电默认大部分外设时钟都是关的。直接操作寄存器的一大好处是时钟、外设、中断这三个环节全是你自己接通的逻辑非常清楚用库函数时这些都被封装进初始化函数里出问题反而不容易定位。第二步选PSC71计数频率变成1MHz分辨率1微秒ARR999得到1kHz。如果你希望PWM有更高的占空比分辨率可以把PSC调小让计数频率更高、ARR更大反过来如果只求低频、不在乎分辨率PSC可以大一些。这是时基配置里最常见的权衡。第三步的UG是关键动作又提醒一次不拉UGPSC和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); /* 复用推挽输出 CNF10, MODE11 */ /* 2. 通道3设为PWM模式1CCR3预装载 */ TIM3-CCMR2 ~TIM_CCMR2_CC3S; /* CC3S00选择输出方向 */ TIM3-CCMR2 | TIM_CCMR2_OC3PE; /* 开启CCR3预装载 */ TIM3-CCMR2 ~TIM_CCMR2_OC3M; TIM3-CCMR2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_2; /* OC3M110 */ /* 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就是高电平有效不用动。第四步把初始比较值设为0LED一开始是全灭状态避免上电瞬间闪一下。最后一步打开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; } } }初始化里还需要补两步使能更新中断并挂到NVICTIM3-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分频后忘了×2TIM3时钟按72MHz算不是36MHz首周期异常配置完没拉UG初始化时EGR-UG置15.2 高频翻车点实录第一个翻车点是初始化顺序。我有一次先开了CEN再拉UG逻辑上看起来也说得通但读CNT时发现第一拍计数特别长原因就是PSC的旧值还占着影子寄存器。从那以后我养成了流程洁癖PSC、ARR、CNT清零、UG、CEN顺序写死在代码注释里换芯片平台也照这个清单走。第二个高频问题在通道配置。用TIM3通道3就必须用CCMR2结果我见过太多人把PWM模式写进CCMR1以为通道编号就是寄存器编号。CCMR1管通道1/2CCMR2管通道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三件事用寄存器拆完了。我自己最大的感受是定时器寄存器看起来多实际上每个外设的数据手册都遵循同样的组织逻辑——模式开关一组、数值寄存器一组、状态标志一组、事件产生一组。带着这个框架去翻任何一款单片机的定时器章节都能在半小时内找到对应关系。下一章如果继续往下写输入捕获和编码器接口是个不错的选题那套“捉住脉冲”的玩法又是另一种乐趣。