STM32定时器与PWM寄存器详解:从计数原理到呼吸灯实战

发布时间:2026/10/21 2:30:02
STM32定时器与PWM寄存器详解:从计数原理到呼吸灯实战
你要是玩过 STM32早晚会走到“寄存器”这一关。HAL 库用多了就会发现想要彻底搞懂一个外设为什么这么配置终究要翻到底层去问寄存器。这套《STM32 寄存器白话手册》系列我一直在按“能让人读懂”的标准去写这一篇是第五章主题我想了很久最后定了定时器与 PWM。不是博人眼球而是这一步跨过去你会从“会用外设”变成“看懂外设”这个转变很值得。第五章要解决的事情很明确定时器怎么计数、怎么产生中断、怎么输出 PWM 波形。它背后牵扯的预分频、自动重载、比较寄存器这些概念几乎是所有工业和消费级单片机项目的地基。看完这一章你至少能独立写出一个呼吸灯效果能在逻辑分析仪上看到一组频率、占空比都可控的方波并且每一步都清楚自己在写什么。适合两类人一类是已经用库函数写过点灯、串口想往底层走一步的读者另一类是遇到“库函数配置不出来、也不知道为什么”的问题想掌握排查能力的开发者。1. 第五章的整体编排逻辑为什么这个节点讲定时器写技术手册最忌讳的就是堆砌外设。前四章我们依次聊了 GPIO、时钟系统、中断系统和串口到第五章安排定时器是有明确递进关系的。串口章节让你第一次体会“外设在后台自己工作CPU 被中断拉起来处理数据”而定时器则是把这个模型放大到极致计数器自己跑溢出时给你一个中断比较器自己盯着数值一旦匹配就悄悄翻转引脚。CPU 全程可以不参与这才是外设该有的样子。1.1 定时器之前需要哪些基础在定时器之前有几样东西是必须已经掌握的否则看这一章会很吃力。第一是时钟树你必须知道 APB1、APB2 和定时器时钟倍频的关系否则配置出来的时基完全是错的。第二是中断定时器溢出中断、PWM 虽然不一定用中断但你要能理解 NVIC 的开关和优先级。第三是 GPIO 复用模式PWM 信号是从引脚输出的引脚必须切到复用功能不能用普通的推挽输出去硬顶。这几样东西在前面章节都有铺垫。如果是从中间开始读的读者建议至少把时钟系统和 GPIO 复用两章过一遍。我把寄存器与时钟相关的部分写得比较细定时器章节里会直接用。1.2 三类定时器怎么区分STM32 的定时器通常分成基本定时器、通用定时器和高级定时器三大类。很多新手一开始就栽在这里用了一个只有时基功能的基本定时器却发现没有 PWM 输出引脚又或者高级定时器的寄存器更多配置起来一脸懵。以 F1 系列为例基本定时器 TIM6、TIM7 只有计数和中断功能没有引脚输出适合做内部定时或者驱动 DAC通用定时器 TIM2、TIM3、TIM4、TIM5 是主力有四个独立通道支持输入捕获、输出比较和 PWM绝大多数项目里的电机调速、呼吸灯都是用这一类高级定时器 TIM1、TIM8 在通用定时器的基础上多了互补输出、刹车输入和死区控制主要用于电机控制这类需要防桥臂直通的场景。定时器类型典型实例主要能力常见用途基本定时器TIM6、TIM7时基、溢出中断、触发 DAC内部定时、系统节拍通用定时器TIM2~TIM5时基、输入捕获、输出比较、PWM呼吸灯、测频率、舵机高级定时器TIM1、TIM8通用定时器功能 互补输出、死区、刹车电机控制、逆变器做项目时不用纠结哪个“更好”按需求选即可。本章所有示例我都用通用定时器因为这覆盖了 80% 的日常玩法也最能讲清楚寄存器结构。1.3 寄存器写法和库函数取舍我见过不少人在“到底该用寄存器还是 HAL 库”这个问题上反复纠结。我的看法很直白这不是二选一而是角度不同。库函数是为了让你快速交付产品寄存器是为了让你在出问题时能一层层往下扒知道库函数在那几百行代码里到底做了什么。写寄存器有一个好处当波形不对、时间不对时你可以直接读回寄存器数值比在库里加断点快得多。调试时我几乎都是“寄存器定位库函数修复”。本章的代码刻意保持“最小可用”风格不追求优雅分层目的就是让每一行都对应手册里的一个寄存器位你能边读代码边翻参考手册逐位对上。2. 动手前的原理准备时钟、计数和 PWM 怎么协作寄存器配置看起来是“往寄存器里写值”但真正需要理解的是背后那条数据流时钟进入定时器预分频器先降频计数器按降频后的速率递增到达自动重载值时溢出或者产生比较事件输出控制器再根据比较结果改变引脚电平。这四个环节缺一不可很多诡异的“定时器不走”问题最后查出来都是时钟使能没开或者预分频算错了。2.1 时钟树定时器时钟到底从哪里来定时器的时钟不是凭空产生的它挂在 APB1 或 APB2 总线上。以 F1 系列常见的 72 MHz 系统时钟为例APB1 预分频器设置为 2 分频时APB1 总线时钟是 36 MHz但定时器时钟有特殊规则——APB1 预分频不为 1 时定时器时钟自动倍频为总线时钟的 2 倍也就是 72 MHz。同理APB2 预分频不为 1 时挂在 APB2 上的 TIM1、TIM8 也会获得 2 倍时钟。这个规则经常被忽略。用库函数时你感觉不到它的存在因为库里把时钟初始化封装好了一旦直接操作寄存器你就必须亲手打开 RCC 对应位。比如用 TIM2你要写RCC-APB1ENR | RCC_APB1ENR_TIM2EN;。千万别小看这一句我排查过好几个“定时器不计数”的案例最后都是这一位没置 1。实操时可以这样验证把预分频器设成 0自动重载值设成大一点的数然后在中断里翻转一个 GPIO用逻辑分析仪测 GPIO 的翻转周期。如果翻转频率和理论值差了整整一倍十有八九就是 APB 预分频和定时器倍频的关系没对上。2.2 预分频与自动重载把时间公式彻底算明白定时器本质上是一个计数器计数脉冲的频率决定了定时的精度。预分频器 PSC 的作用是把定时器时钟降下来自动重载值 ARR 则决定计数到多少产生溢出事件。完整的时间公式是定时周期 T (PSC 1) × (ARR 1) / 定时器时钟频率为什么两个值都要加 1因为寄存器从 0 开始计数PSC0 表示 1 分频ARR0 表示计数范围是 0~0溢出周期只有一个时钟。举个例子定时器时钟 72 MHz希望产生 10 ms 的溢出中断10 ms 0.01 s。先选 PSC预分频到 100 kHz也就是 720 分频PSC719。那么计数脉冲周期就是 10 µs要凑 10 ms 需要计 1000 个数ARR999。实际算下来计数频率 72 MHz / (719 1) 100 kHz 溢出周期 (999 1) / 100 kHz 10 ms配置代码对应为TIM2-PSC 719; TIM2-ARR 999; TIM2-EGR TIM_EGR_UG; // 产生更新事件让预分频立即生效这里有个必须养成的习惯修改 PSC 或 ARR 后建议手动把更新事件位置 1写 UG 位否则新值要等当前计数周期结束才加载调试时会误以为配置没生效。2.3 PWM 输出原理比较寄存器到底在比什么PWM 输出比单纯的定时中断多了一个比较环节。计数器 CNT 从 0 递增到 ARR每递增一次都会和捕获比较寄存器 CCR 做比较。当 CNT 小于 CCR 时输出引脚为一种电平大于等于 CCR 时输出引脚翻转为另一种电平。一个 PWM 周期内高电平的宽度由 CCR 决定占空比就是 CCR 和 ARR1 的比值。PWM 模式 1 和模式 2 的区别很简单模式 1 是 CNT CCR 时输出有效电平模式 2 是 CNT CCR 时输出有效电平。选哪种看习惯我一般默认用模式 1配合向上计数直观理解就是“前段高、后段低”。输出极性也是新手容易搞混的点。CCER 寄存器里的 CCxP 位控制极性0 表示高电平为有效电平1 表示低电平为有效电平。同样的 CCR 值改一位极性示波器上看到的波形就反相了。如果发现 PWM 波形是“反的”不用急着改代码逻辑先检查 CCxP 位。2.4 脉宽调制背后占空比怎么转换成实际效果占空比的物理意义在于负载对平均电压的感知。LED 的亮度、电机的转速、舵机的角度本质上都不是被“瞬时电压”控制的而是被“平均电压”控制的。这就是为什么 50 Hz、20 ms 周期的 PWM 能驱动舵机因为脉冲宽度变化对应角度变化LED 呼吸灯则利用人眼视觉暂留只要频率超过几十赫兹人眼就感知不到闪烁只感知到亮度变化。这一步理解透了你就明白为什么 PWM 的频率和占空比要分开调频率决定控制的平滑度占空比决定控制的量。频率太低会闪频率太高可能超出负载的响应能力。LED 常用的频率是 1 kHz 到几十 kHz舵机标准是 50 Hz直流电机常用 10 kHz 到 20 kHz。3. 手把手写寄存器代码从定时中断到 PWM 呼吸灯理论学习再多不如亲手跑一遍。这一节我用最少的代码量实现两个功能第一通过定时器溢出中断让 LED 以精准的 500 ms 周期闪烁第二通过 PWM 通道输出一个由暗到亮、再由亮到暗的呼吸灯效果。前者让你掌握时基配置和中断流程后者让你掌握比较输出和 CCR 的动态更新。3.1 用你自己的板子先做好三件准备工作写代码之前先确认硬件。我用的是某款市面上常见的 F1 系列开发板晶振 8 MHz系统主频 72 MHz。这块板子上的 LED 引脚我接到 PA5定时器用 TIM2 的通道 1对应引脚是 PA0。具体引脚分配每块板子不同但配置流程完全一样。准备工作的第一步是打开 GPIO 复用时钟。PWM 信号不是普通的 GPIO 输出它由外设驱动所以引脚要配置为复用推挽输出并且要使能对应 GPIO 的时钟。第二步是使能定时器时钟前面已经提过RCC-APB1ENR的写法。第三步是设置通道引脚映射F1 系列的部分外设引脚需要重映射如果芯片默认引脚正好是你要用的可以跳过 AFIO 配置但一旦发现 PWM 不出波形第一个检查点就应该是这里。3.2 寄存器最小代码500 ms 定时中断翻转 LED这段代码演示定时器中断的标志位处理和 NVIC 配置。PA5 的 GPIO 配置已经在前面章节写过这里直接给出定时器部分。void timer_init(void) { // 1. 打开 TIM2 时钟 RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 2. 预分频72 MHz / 7200 10 kHz计数周期 0.1 ms TIM2-PSC 7200 - 1; // 3. 自动重载0.1 ms × 5000 500 ms TIM2-ARR 5000 - 1; // 4. 清更新中断标志产生更新事件 TIM2-SR ~TIM_SR_UIF; TIM2-EGR | TIM_EGR_UG; // 5. 允许更新中断 TIM2-DIER | TIM_DIER_UIE; // 6. 配置 NVIC使能 TIM2 全局中断 NVIC_EnableIRQ(TIM2_IRQn); // 7. 使能计数器 TIM2-CR1 | TIM_CR1_CEN; }中断服务函数里标志位必须手动清除这是新手最容易漏的一步。如果不清标志位中断会不断触发看上去就是程序卡死在中断里。void TIM2_IRQHandler(void) { if (TIM2-SR TIM_SR_UIF) { TIM2-SR ~TIM_SR_UIF; // 清标志 GPIOA-ODR ^ GPIO_ODR_OD5; // PA5 翻转 } }代码中的 PSC 我写的是 7200 - 1而不是直接写 7199之前有读者问过。我这样写的目的是把“实际配置值等于目标分频数减一”这件事摆在明面上避免算错。同理ARR 也写成了 5000 - 1。这两个“减一”就是公式里(PSC 1)的直接体现。3.3 PWM 配置让 PA0 输出可调占空比方波PWM 是在定时器时基基础上增加比较输出的。复用 TIM2 通道 1配置 PA0 为复用推挽然后把 CCMR1 的低四位设置成 PWM 模式 1占空比由 CCR1 给定。void pwm_init(void) { // 1. 打开 GPIOA 时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN; RCC-APB1ENR | RCC_APB1ENR_TIM2EN; // 2. PA0 复用推挽输出速度 50 MHz GPIOA-CRL ~(GPIO_CRL_CNF0 | GPIO_CRL_MODE0); GPIOA-CRL | GPIO_CRL_MODE0_1 | GPIO_CRL_MODE0_0; // 输出模式50 MHz GPIOA-CRL | GPIO_CRL_CNF0_1; // 复用功能 // 3. 预分频到 1 kHz20 ms 周期对应 ARR19 TIM2-PSC 72 - 1; TIM2-ARR 20 - 1; // 4. 通道1 PWM 模式 1输出比较预装载使能 TIM2-CCMR1 | TIM_CCMR1_OC1M_2 | TIM_CCMR1_OC1M_1; // 110PWM 模式 1 TIM2-CCMR1 | TIM_CCMR1_OC1PE; // 5. 使能通道1输出极性为高有效 TIM2-CCER | TIM_CCER_CC1E; // 6. 产生更新事件然后使能计数器 TIM2-EGR | TIM_EGR_UG; TIM2-CR1 | TIM_CR1_CEN; }这段代码里 ARR19也就是占空比分辨率是 1/20。如果想让呼吸效果更细腻可以把 PSC 调小ARR 调大比如 PSC71ARR999这样占空比能细到 0.1%。实际项目中分辨率越高越平滑但寄存器位宽有限还要算清楚装载时间和更新频率之间的平衡。3.4 呼吸灯主循环动态修改 CCR 实现亮度渐变PWM 配置好之后占空比由 CCR1 决定。呼吸灯就是不断修改 CCR1 从 0 到 ARR、再从 ARR 回到 0。这里有个细节直接修改 TIM2-CCR1 是立即生效的除非你特意关闭预装载。我演示的代码简单直接不追求精细的频率控制只求效果直观。while (1) { // 由暗到亮 for (uint16_t i 0; i 19; i) { TIM2-CCR1 i; delay_ms(30); } // 由亮到暗 for (uint16_t i 19; i 0; i--) { TIM2-CCR1 i; delay_ms(30); } }delay_ms可以由前面章节里的 SysTick 实现也可以用定时器中断里的节拍变量。实测中30 ms 的步进、20 级亮度呼吸效果已经足够自然。想要更顺滑就把 ARR 提到 999步进 2 ms效果会好很多但这会涉及更大范围的 CCR 更新需要评估主循环里其他任务是否被打断。3.5 用读寄存器的方式验证配置是否正确代码写完不要急着看现象先学会“读”。调试时我常做三件事第一读TIM2-CR1的 CEN 位确认计数器确实在跑第二读TIM2-CNT看它在不在变化第三读TIM2-CCR1和TIM2-SR确认配置值与预期一致。这一步虽然简单但能快速把“代码写错”和“波形本来没问题”区分开。有一次我在外部中断里动态修改 CCR1 实现遥控调亮度结果发现波形偶尔跳变。后来发现是 CCR1 写入时没有考虑预装载未使能的问题数值更新发生在计数周期的任意位置导致单个脉冲宽度异常。这个现象在呼吸灯里看不出来但在精密调光或者电机控制里就可能造成抖动。解决方法是使能预装载位让 CCR1 的新值在下一个计数周期开始时才生效。4. 配置与调试实录我在寄存器项目中踩过的坑每次写定时器项目几乎都会遇到几个典型的“妖问题”。这些问题单看原理没毛病组合起来就让你怀疑人生。我把这类问题整理成一个速查表都是实际调试时验证过的。4.1 定时器完全不动先查时钟再查计数器最常见的现象是 LED 不闪烁或者 PWM 引脚电平固定不动。排查第一步读 CR1 的 CEN 位是不是 1第二步看 APB1ENR 里对应的 TIMxEN 是不是 1第三步用调试器读 CNT看计数数值是否一直在变。如果 CNT 不变说明没有时钟进来重点检查 RCC 配置或者晶振是否起振。如果 CNT 在变但中断不触发那就是 DIER 的 UIE 位或者 NVIC 使能的问题。很多新手会把“定时器不工作”归咎于代码逻辑但大多数情况下是初始化顺序问题。我习惯按“时钟 → 时基 → 输出配置 → 中断配置 → 启动计数”的顺序写出问题时也按这个顺序排查能省下大量时间。4.2 PWM 引脚没波形复用、极性、使能位三连查PWM 没有波形先确认 GPIO 是不是复用推挽模式再确认 CCER 的 CCxE 有没有置 1最后确认 CCMR 里的 OCxM 是不是 PWM 模式。这三个位置有一个不对引脚就是死的。尤其注意普通 GPIO 配置和复用 GPIO 配置在 CRL/CRH 里的 CNF 位是不同的。普通推挽输出 CNF 是 00复用推挽输出 CNF 是 10。写库的时候这层由库隐藏了写寄存器就必须自己对着手册一位位设。4.3 中断疯狂触发更新标志位必须清定时器更新中断一旦使能如果中断服务函数里没有清标志位CPU 会被中断反复拉入表现为主循环不执行、程序看起来像卡死。这个现象和“死循环”特别像但本质上不是卡死而是中断风暴。解决办法很简单进中断后先判断标志位处理完立即清标志位。更稳妥的做法是在使能中断之前先写一次TIMx-SR ~TIM_SR_UIF;确保残留标志位不会在初始化时立刻触发一次假中断。4.4 常见问题速查表现象可能原因检查点CNT 不变定时器时钟未使能或系统时钟异常RCC-APB1ENR 对应位、CEN 位中断不触发更新中断未使能或 NVIC 未配置DIER.UIE、NVIC_EnableIRQ中断风暴标志位未清SR.UIF 立即清零PWM 无输出GPIO 不是复用模式或通道未使能CRL/CRH 复用位、CCER.CCxEPWM 波形反相输出极性配置相反CCER.CCxP占空比突变预装载未使能CCMR.OCxPE时间不准APB 预分频与定时器倍频关系搞错RCC CFGR 的 PPRE 位新参数不生效修改 PSC/ARR 后未产生更新事件EGR.UG这张表我在调试时越来越厚每次遇到新问题就记一笔。寄存器开发没有太多“银弹”靠的就是这种见招拆招的积累。4.5 强烈推荐的一套调试工具组合如果你还在用“编译→下载→看现象”这个循环强烈建议把逻辑分析仪纳入你的日常工具。不需要很贵百元内的 8 通道逻辑分析仪配一个开源上位机软件就能把 PWM 波形一眼看穿。很多占空比不对、极性反了、频率差一倍的问题用逻辑分析仪三秒钟就能定位远比靠经验猜要快得多。调试时我通常把通道 0 接到 PWM 输出脚通道 1 接一个 GPIO 翻转脚作为参照。这样能同时看到 PWM 波形和主循环的执行节奏二者配合可以立刻判断出 PWM 波形异常到底是外设配置问题还是主循环的 CCR 更新逻辑问题。5. 实操后的个人体会定时器这套玩法还能往哪里延伸写定时器这个章节我自己也重新把参考手册的定时器部分翻了一遍。说实话寄存器手册很多描述读起来很干但一旦配合示波器或者逻辑分析仪把波形拉出来你就会发现那些寄存器描述全部“活”过来了。这是我在实际开发中最大的转折点从背寄存器到看波形的变化。这一章里我只用了通用定时器的时基和 PWM 输出功能但定时器的能力远不止这些。下一步值得研究的有三件事第一是输入捕获可以测外部信号的频率和脉宽第二是编码器接口模式直接对接正交编码器读取电机转速第三是定时器的 DMA 请求可以在更新事件时自动搬运数据不用 CPU 参与就能实现高频率波形更新。这三块内容在后续的章节里都会逐个展开。最后再分享一个小习惯我每次配置定时器都会在手边放一张纸把自己的时间计算公式先写出来再往寄存器里填值。看起来有点多此一举但遇到参数需要调整时纸上那两行公式能帮你快速判断该改 PSC 还是该改 ARR。很多时候调试的瓶颈不在代码而在你对参数的认知是否清晰。