
到了第五章前面那些 GPIO 口、时钟树、位操作的基础算是有眉目了。点灯没问题读按键也没问题但你有没有一种感觉点灯点得再漂亮也只是让 CPU 在 while(1) 里一圈圈往前跑CPU 一忙灯的节奏就乱。这时候就需要定时器登场。STM32 的定时器很多人第一反应是“好复杂”——PSC、ARR、CNT、CCR、DIER、SR光名字就让人头皮发麻。可我跟你交个底这套东西拆开之后核心原理和一个电子表差不多真正难的不是理解某个寄存器而是把它们之间的时序关系串起来。本章就用白话把定时器从计数、中断再到 PWM 和输入捕获一层层剥开每个寄存器怎么算、怎么写、怎么清标志全部给到位。适合能把基础工程编译下载、却还没开始碰外设的人也适合想跳过库函数直接用寄存器的人。1. 时基单元的白话版PSC、ARR、CNT分别是什么角色“时基单元”四个字听起来像教科书里的一级标题其实就是一个计数器、一个分频器、一个闹钟上限。我用大白话说一遍CNT 是正在跑的秒表从 0 开始往上走PSC 是秒表倍率调节器决定多长时间跳一格ARR 是闹钟上限CNT 一撞到 ARR整个系统就“叮”一声——这就是更新事件。更新事件会触发三个动作CNT 归零、UIF 标志置 1、可以有选择地给 CPU 发中断。这里必须纠正一个容易出错的直觉ARR 不是比较阈值而是“重装载值”。CNT 不是“等于 ARR 时触发”而是“从 ARR 再进一步溢出时触发”只是这个“进一步”在绝大多数情况下瞬间发生所以你观察到的结果就是 CNT 到了 ARR 就归零。明白这一点之后后面预装载、影子寄存器、更新事件这些概念就都能串起来了。1.1 计数器的主频怎么算很多新手拿到定时器第一件事就是填 PSC 和 ARR但填完之后完全不知道实际时间是多少。这里给出一条完整的计算公式建议直接背下来计数器频率 定时器时钟 / (PSC 1)更新事件频率 定时器时钟 / ((PSC 1) * (ARR 1))为什么都要加 1因为 PSC 0 时就是 1 分频ARR 0 时计数器只有 0 和回绕两个状态。计数器从 0 走到 ARR中间一共有 ARR 1 个计数值这个细节在做高频 PWM 时特别重要。假设定时器时钟为 72MHz想产生 1ms 的定时中断计数器每 1us 跳一格PSC 7172MHz / 72 1MHz计数器走满 0~999ARR 9991MHz / 1000 1kHz正好 1ms注意 PSC 和 ARR 通常是 16 位寄存器范围 0~65535部分型号的 CNT 和 ARR 是 32 位。所以定时器最长溢出时间不是随意设的最大分频比例是 65536配合 16 位计数器能覆盖很大的时间范围但分频越大计数精度就越低这是一个需要权衡的点。1.2 时基单元核心寄存器一览先把这一章的“主角”列个表后续所有操作都围绕它们展开寄存器白话作用CR1定时器总开关CEN 位启动计数DIR 位改方向CNT当前计数值可以读也可以写PSC分频比写入值加 1 才是真正分频系数ARR自动重装载值计到它并溢出就是更新事件SR状态标志UIF 置 1 表示更新事件发生过DIER中断开关UIE 使能更新中断EGR软件触发事件强制产生一次更新另外要提醒一点PSC 和 ARR 在物理上都有“影子寄存器”。你往 PSC 写值时真正生效的不一定是你表面上写的那个数而是要看更新事件有没有发生。这个机制我在第五节单独展开先记住有这么回事。2. 跑通第一个定时器中断从RCC时钟到UIF标志点灯只是测试 LED能让定时器 1ms 一次准点打断你的主循环才是从裸机轮询思维过渡到中断驱动思维的标志。有人在这里卡了很久不是代码看不懂而是不知道中断从哪来、标志怎么清、为什么清完又进来。我来把整条链路按实际配置顺序走一遍。2.1 先使能时钟不然寄存器全是空气STM32 为了省电默认关闭大多数外设时钟。你没给定时器供时钟后面的 PSC、ARR 写再多也不会报错但就是没反应像对着断电的设备按开关。以挂在 APB1 总线上的 TIM2 为例RCC-APB1ENR | (1U 0); // 开启 TIM2 时钟这一步放在所有 TIM2 寄存器操作之前。很多人移植代码的时候漏掉它结果定时器完全不工作最后排查一圈发现是时钟没开。2.2 初始化寄存器完整代码目标是产生 1ms 更新中断定时器时钟按 72MHz 算// 配置时基PSC71 1MHzARR999 1ms TIM2-PSC 72 - 1; TIM2-ARR 1000 - 1; TIM2-CNT 0; TIM2-SR 0; // 先清状态防止历史标志误触发 TIM2-DIER | (1U 0); // UIE 置 1使能更新中断 TIM2-CR1 | (1U 0); // CEN 置 1启动计数器 NVIC_EnableIRQ(TIM2_IRQn); // 打开 NVIC 中断通道这里我习惯把 NVIC 使能放在最后因为 DIER、CEN、NVIC 三个条件都满足才会进中断哪个晚开哪个就先别触发。你如果放在启动之前也没问题但初始化阶段尽量形成固定顺序排查起来省事。2.3 UIF 标志必须手动写 0进入中断后第一步是判断是不是更新事件第二步是清标志void TIM2_IRQHandler(void) { if ((TIM2-SR (1U 0)) ! 0) // UIF 位 { TIM2-SR (uint16_t)~(1U 0); // 写 0 清除 // 在这里执行你的 1ms 任务 } }UIF 是硬件置 1、软件写 0 清除的标志不要指望它自动消失。不清的话中断会一直触发因为中断源没有被解除。还有一点值得注意有人图省事直接写 TIM2-SR 0把整个 SR 都清掉。临时测试可以但不够干净如果同一个中断服务函数里还处理了捕获标志可能把不该一起清的也清了。我建议始终用按位清养成习惯。2.4 中断里别做耗时的事1ms 中断意味着每秒进 1000 次如果你在中断里做延时、串口打印、浮点运算主循环会被压得喘不过气定时精度也崩了。正确做法是中断里只改标志或计数变量具体处理放主循环volatile uint32_t tick_ms 0; void TIM2_IRQHandler(void) { if ((TIM2-SR (1U 0)) ! 0) { TIM2-SR (uint16_t)~(1U 0); tick_ms; } }tick_ms 记得加 volatile否则编译器优化后可能在主循环里永远读到旧值。2.5 怎么验证真的准最直接的验证方式在中断里翻转一个 GPIO拿示波器或逻辑分析仪看波形周期是不是精确的 1ms。没有示波器的话可以主循环里每秒读一次 tick_ms如果数值在 1000 附近说明基本没问题。不过这只证明平均频率正确不代表每次中断间隔均匀想看抖动还是得上仪器。3. 从计数逻辑到PWM输出占空比不是CCR/ARR定时器玩熟之后下一个绕不开的功能就是 PWM。PWM 说穿了就是把一个电平快速开和关用高电平占一个周期的比例去控制平均值。LED 亮度、电机转速、舵机角度全是靠这个比例在驱动。寄存器层面它就是把 CCR1 从“捕获存值”的角色切到“参与比较”的角色。3.1 PWM 在寄存器里是怎么工作的把 CCMR1 的 CC1S 设置为 00通道就切到输出模式CCR1 变成比较寄存器。此时每个定时器时钟周期都会把 CNT 和 CCR1 比一次CNT CCR1输出有效电平CNT CCR1输出无效电平一个周期内高电平时间由 CCR1 决定整个周期长度由 ARR 决定。所以占空比占空比 CCR1 / (ARR 1)不是 CCR1 / ARR这个加 1 的细节做高精度控制时非常关键。ARR 999、CCR 250正确结果应该是 25%。如果按 CCR / ARR 去算会得到 25.025%平时无所谓但在电机闭环或者电源控制里这种偏差会让你排查到怀疑人生。有效电平到底是高还是低由 CCER 的 CC1P 决定CC1P 0 时有效电平为高CC1P 1 时有效电平为低。很多人以为翻转电平必须换模式其实改 CC1P 就够了。3.2 一个呼吸灯需要的全部配置下面用 TIM2_CH1 默认引脚 PA0 做呼吸灯PWM 频率定在 1kHz占空比从 0 到 100% 来回扫// 1. GPIOA 时钟 PA0 复用推挽输出 RCC-APB2ENR | (1U 2); GPIOA-CRL ~(0xFUL 0); GPIOA-CRL | (0xBUL 0); // MODE11 50MHzCNF10 复用推挽 // 2. TIM2 时钟 时基 RCC-APB1ENR | (1U 0); TIM2-PSC 72 - 1; // 1MHz TIM2-ARR 1000 - 1; // 1kHz PWM // 3. 通道配置为 PWM 模式1 TIM2-CCMR1 | (0x6U 4); // OC1M110PWM 模式1 TIM2-CCMR1 | (1U 3); // OC1PE1CCR1 预装载使能 TIM2-CCER | (1U 0); // CC1E1使能输出 TIM2-CCR1 0; // 初始占空比 0 // 4. 使能 ARR 预装载和计数器 TIM2-CR1 | (1U 7); // ARPE1 TIM2-CR1 | (1U 0); // CEN1开始跑主循环里改变 CCR1uint16_t duty 0; int8_t dir 1; while (1) { TIM2-CCR1 duty; duty dir * 20; if (duty 999) dir -1; else if (duty 0) dir 1; delay_ms(5); }为什么要开 ARPE 和 OC1PE 这两个预装载位因为 ARR 和 CCR 一旦允许预装载写入的新值不会立即进入影子寄存器而是等下一次更新事件一起生效。这样能避免在一个周期内出现“ARR 已经变了、CCR 还没变”的中间状态。呼吸灯场景里不预装载也能跑但到了电机控制这种必须平滑改变占空比的场景预装载就是必须项。3.3 频率和分辨率不能两边都要PWM 频率公式再贴一遍PWM 频率 定时器时钟 / ((PSC 1) * (ARR 1))。想算高频率就得把 PSC 和 ARR 压小想让占空比分辨率高ARR 就得大。比如 ARR 999分辨率是 0.1%ARR 99分辨率只有 1%。高频和高分辨率本质上冲突因为 16 位计数器总共就 65536 个状态鱼和熊掌不可兼得。做 20kHz 电机 PWM 时我一般这样配72MHz / 3600 20kHzPSC 0ARR 3599 而不是继续用 72MHz 分到 1MHz 再分频。因为 PSC 越大振荡器一级分频误差越容易被放大虽然理论没错但能直接分就尽量直接用主频减少中间环节。3.4 同一组寄存器还能玩出输入模式CCMR1 里的 CC1S 位决定了通道方向输出模式用比较逻辑输入模式用捕获逻辑。同一个 CCR1在输出模式下存放比较值在输入模式下存放捕获值。所以配置之前先想清楚这路通道到底要干什么否则写了半天发现引脚一直没信号很可能就是 CC1S 方向搞错了。4. 输入捕获的反向用法测量一段高电平时间定时器不仅能往外输出确定的波形还能往内看外部波形有多宽。输入捕获的原理就是给 CNT 装一个快门外部信号发生边沿跳变时当前 CNT 的值瞬间被锁存进 CCR1同时置捕获标志。整个过程由硬件完成CPU 不用轮询这是它比 GPIO 加延时测量准得多的原因。4.1 为什么坚持用硬件捕获早期我做红外解码用 GPIO 中断加 while 读数来测脉宽结果每路信号误差时大时小稍微一点中断延迟就把时序打乱了。后来换成定时器捕获事情就简单了边沿一到CNT 自动进 CCR1应用层只需要读寄存器中断延迟的影响被大幅降低CPU 也可以去做别的事。测频率、测占空比、解码红外遥控器输入捕获都是最稳定的方案。4.2 捕获模式配置同样拿 TIM2_CH1 举例PSC 设成 71让计数器 1us 跳一格这样 CCR1 读到的数值直接就是微秒数TIM2-PSC 72 - 1; // 1us 一格 TIM2-CCMR1 | (1U 0); // CC1S01CH1 作为输入捕获 TIM2-CCER ~(1U 1); // CC1P0上升沿捕获 TIM2-CCER | (1U 0); // CC1E1使能捕获通道 TIM2-DIER | (1U 1); // CC1IE1使能捕获中断 TIM2-CNT 0; TIM2-CR1 | (1U 0); // CEN1启动计数器4.3 用状态机测一段高电平时间一个最简单的测高电平思路上升沿进来时清零 CNT并切换成下降沿捕获下降沿进来时读 CCR1然后切回上升沿。代码如下static uint32_t phase 0; static uint32_t high_us 0; void TIM2_IRQHandler(void) { if ((TIM2-SR (1U 1)) ! 0) // CC1IF { uint32_t cap TIM2-CCR1; // 读 CCR1按手册可清 CC1IF if (phase 0) { TIM2-CNT 0; TIM2-CCER | (1U 1); // 改成下降沿捕获 phase 1; } else { high_us cap; // 高电平时间单位 us TIM2-CCER ~(1U 1); // 改回上升沿捕获 phase 0; } } }读 CCR1 这个动作本身就可能在芯片上起到清除 CC1IF 的作用即使不写清除代码中断也能正常退出。不过需要说明不同参考手册对标志清除方式的描述不完全一样有的是软件写 0 有效有的是读 CCR 自动清稳妥做法是进入中断后先读值再按手册要求清对应标志不要盲目套模板。4.4 短脉冲和超长脉冲的边界上面用中断改边沿的方法受中断响应时间影响只适合脉宽在几十微秒以上的信号。如果测很短的红外载波脉冲中断切换边沿的延迟本身就大于信号宽度数据就不准了这时候应该用两个通道分别捕获上升沿和下降沿或者直接用参考手册里的“PWM 输入模式”。另外如果被测高电平超过了定时器最大溢出周期CNT 回绕后测出来会是一个很小甚至为零的值需要开更新中断累计溢出次数或者把 PSC 调大、降低计数器频率来扩展量程。5. 我在实际调试中反复踩过的定时器细节前面几步在理想状态下能跑起来但一换板子、一改主频、一进调试器各种问题就冒出来了。下面这五个坑是我自己调试时反复遇到过的记录下来给你提个醒。5.1 定时器时钟不等于总线时钟很多人移植代码时把 PSC 71 当作万能配置结果发现时间不对。原因很常见APB1 总线在分频不为 1 时定时器时钟会变成 APB1 的两倍。也就是说虽然 APB1 外设是 36MHz但挂在它上面的定时器可能是 72MHz。不同主频、不同分频配置定时器时钟完全不一样。建议每次拿到新板子先确认整个时钟树主频多少、APB1 分频多少、定时器时钟多少再反推 PSC 和 ARR。这一步花不了几分钟能省下大量排查时间。5.2 改了 PSC 为什么没反应PSC 写入的是预装载值并不会立刻改变计数器频率而是要等一次更新事件把新值搬进影子寄存器。如果你只改 PSC、其他什么都不做计数器可能还在按老节奏跑。想让它立即生效写完 PSC 后手动触发一次软件更新TIM2-EGR | (1U 0); // UG1触发更新事件但注意这会顺手把 UIF 置 1如果你开了更新中断这次软件更新也会进一次中断要提前想好怎么处理。ARR 同理ARPE 1 时 ARR 的新值也要等更新事件所以动态改频率时通常会先把 ARR、CCR 都写进预装载寄存器再通过 UG 一次性生效。5.3 SR 标志“一律写 0 清”是假经验定时器不同标志的清除方式五花八门UIF 是软件写 0CC1IF 除了软件写 0还可能通过读取 CCR 自动清除有些更新事件读 SR 再读 CNT 也会清。所以配置功能之前先翻手册找到那个位的描述写着“cleared by writing 0”就用按位清写着“automatically cleared by reading”就老老实实读一次对应寄存器。一套清标志代码通吃所有外设的贪方便思路在定时器上最容易翻车。5.4 寄存器初始化里 | 和 的差别会被放大很多新手把下面的写法搞混TIM2-CR1 | (1U 0); // 只打开 CEN保留其他位 TIM2-CR1 (1U 0); // 等于把 CR1 整体覆盖其他位全清零表面上看两种写法都启动了计数器但第二种把 ARPE、DIR、CMS、CKD 等位全部清零。后续只要你想组合使用 PWM、预装载、向上计数各种奇怪现象就来了PWM 不输出、中断不触发、方向不对。所以初始化时最好先把该寄存器的所有相关位按完整配置写清楚再通过 | 去开最后的开关避免无关位被误伤。5.5 在线调试时 CNT 不走或乱走都是正常假象停在断点时内核暂停外设却不一定暂停。有人看到 CNT 停在一个值以为定时器没跑有人发现 CNT 还在疯涨以为频率设置错了。两种都可能合理关键看调试器对“内核停止时外设时钟”是怎么配置的。想跟踪软件逻辑就把外设时钟暂停打开想观察真实波形别太依赖断点用逻辑分析仪直接看引脚最省心。我个人的习惯是每配置一个新定时器先手算一遍溢出时间和占空比再开寄存器监视窗口对着确认一遍。这个习惯帮我躲过了数不清的低级错误。定时器的寄存器链路到这里就算打通了后面如果要深入研究中断优先级、DMA 配合外设你会发现在时基和通道方向已经清楚的前提下那些机制理解起来轻松得多。