
手上正好在做一块 STM32C5A3R 的开发板项目里需要输出一路 PWM 信号驱动舵机。按照惯例打开 CubeMX选定时器、选通道、填参数、生成代码看着波形输出一切都很顺利。然后需求变了频率要从 50Hz 调到 200Hz占空比还要在运行过程中从 5% 平滑改到 10%。结果就是这一改很多之前“会”的人也开始卡壳了。我见过不少同学在 STM32 上做 PWM 都是这个状态用 CubeMX 配一个固定频率、固定占空比的波形很容易但真正到了“运行中动态调频”“调完频率还要保证占空比不变”这种需求就开始靠试。今天这篇文章就以 STM32C5A3R 为目标芯片把 PWM 从时钟到引脚、从配置到动态修改、从跑通到排查完整拆一遍。核心不在一两个 API 怎么调用而在于你如何看待定时器内部那三个寄存器PSC、ARR、CCR。1. 为什么很多人在输出 PWM 时会卡在“改频率”这一步先说一个反直觉的判断STM32 的 PWM 输出本身不复杂复杂的是参数之间的耦合。很多人不是不会配置而是只记住了“PSC 控制频率、CCR 控制占空比”这种简化说法一旦要同时修改频率和占空比就会出问题。1.1 PWM 的三个参数从来不是彼此独立的PWM 波形由三个关键参数决定频率、占空比、极性。在 STM32 定时器里频率由定时器时钟、PSC预分频值、ARR自动重装载值共同决定占空比由 CCR比较捕获值和 ARR 共同决定。公式非常关键PWM 频率 定时器时钟 / ((PSC 1) × (ARR 1))占空比 CCR / (ARR 1) × 100%注意看ARR 同时出现在两个公式里。这就意味着当你为了修改频率而调整 ARR 时如果不重新计算 CCR占空比会跟着跑掉。很多人在这一步就开始怀疑代码写错了其实不是代码问题是数学关系没理顺。1.2 从定时器计数模型理解 PWM 波形生成要真正理解 PWM不能靠背 API要看定时器内部是怎么工作的。以最常见的向上计数模式为例定时器有一个计数器 CNT它从 0 开始递增每来一个时钟脉冲就加 1一直加到 ARR 设定的值然后归零重新计数。这个从 0 到 ARR 的循环就是 PWM 的一个周期。在这个循环过程中硬件会不断把 CNT 和 CCR 做比较如果使用 PWM 模式 1当 CNT CCR 时输出有效电平当 CNT CCR 时输出无效电平。如果使用 PWM 模式 2逻辑反过来CNT CCR 时输出无效电平CNT CCR 时输出有效电平。所以 PWM 的单周期波形可以这样直观理解CNT 从 0 往上爬到达 CCR 时电平翻转继续爬到 ARR 后清空重来。CCR 占 ARR 的比例决定了高电平占整个周期的比例也就是占空比。理解了这一个模型你就明白了为什么改 ARR 会影响占空比。因为 ARR 是分母分母变了分子的相对占比自然就变了。2. STM32C5A3R 的定时器到底跑在哪个时钟上很多人在计算 PWM 频率时直接在 CubeMX 里填了一个“预期频率”然后让工具自动算参数。这个流程本身没问题但如果你不知道定时器时钟是多少后续一旦波形不对你连排查的起点都没有。2.1 先确认时钟树再谈频率计算STM32C5A3R 属于 STM32C5 系列基于 Cortex-M33 内核。定时器的时钟来源于 RCC 时钟树一般挂在 APB1 或 APB2 总线上。这里有一个经典规则在绝大多数 STM32 系列里如果 APB 预分频系数等于 1定时器时钟就等于 APB 时钟如果 APB 预分频系数大于 1定时器时钟是 APB 时钟的 2 倍。这个规则在 STM32C5 系列上同样适用但具体数值还是要通过 CubeMX 的 Clock Configuration 页面确认。我的建议是在 CubeMX 里把时钟树配置完以后先点一下你要用的定时器看它挂在哪个总线上然后算出实际的定时器时钟。这里有一个非常容易踩坑的点APB1 和 APB2 的预分频配置可能不一样所以 TIM2、TIM3 这类挂在 APB1 上的定时器和 TIM1、TIM8 这类挂在 APB2 上的定时器即使你的 SYSCLK 相同它们拿到的时钟也可能不同。不要想当然地认为所有定时器时钟都一样。2.2 通用定时器和高级定时器在 PWM 输出上的差异在 STM32C5A3R 上你会看到多组定时器。大体上可以分为三类高级定时器如 TIM1、TIM8支持互补输出、死区插入、刹车输入适合电机控制、半桥驱动这类场景。通用定时器如 TIM2、TIM3、TIM4占绝大多数支持最基本的 PWM 输出也能做输入捕获和输出比较。基本定时器一般只做定时功能不适合输出 PWM。如果你的需求只是输出一路普通 PWM 控制舵机、调 LED 亮度、驱动蜂鸣器用通用定时器就够了。高级定时器反而多了一些你必须处理的使能位比如主输出使能MOE忘了开的话波形不会出现在引脚上。2.3 引脚复用从定时器通道到 GPIO定时器通道不是一个可以直接输出的物理引脚它需要映射到 GPIO 的复用功能Alternate Function。STM32C5 的每个引脚可能对应多组复用功能你在 CubeMX 里给定时器通道选择引脚后工具会自动把 GPIO 配置成对应的 AF 模式。但这里我建议你手动看一眼工具生成的MX_GPIO_Init()确认 GPIO 初始化里确实设置了正确的复用寄存器。因为在一些相对复杂的板卡上如果默认引脚被其他外设占用你手动换了引脚而 CubeMX 又自动重映射了 AF最终代码里可能出现 GPIO 模式和 AF 不匹配的问题。这类问题的典型表现是程序跑了寄存器值也对了引脚就是没有波形。3. CubeMX 配置与最小可运行流程前面把原理讲清楚了现在进入实操。我们以一个具体目标为例用 TIM3 通道 1 输出 1kHz、50% 占空比的 PWM然后在运行时把它改成 2kHz、30% 占空比。3.1 新建工程时的关键配置项在 CubeMX 中选择好 STM32C5A3R 型号后配置时钟树把 SYSCLK 和 APB 分频设置好。然后在左侧外设列表中找到 TIM3Mode 里勾选 ActivatedClock Source 选择 Internal ClockParameter Settings 里把 Channel1 模式设为 PWM Generation CH1这时的关键参数有几个Prescaler预分频值对应 PSC 寄存器实际分频系数是 PSC 1。Counter Period自动重装载值对应 ARR定时器从 0 计数到这个值后回绕。Pulse初始比较值对应 CCR决定了初始占空比。AutoReloadPreload建议开启让 ARR 的修改在更新事件时统一生效避免运行中改参数产生毛刺。3.2 怎么选 PSC 和 ARR假设通过时钟树确认TIM3 的定时器时钟是 250MHz。目标 PWM 频率是 1kHz。根据公式PWM 频率 250MHz / ((PSC 1) × (ARR 1)) 1kHz所以 (PSC 1) × (ARR 1) 250000。这里就有很多组解了。选择哪一组取决于你对分辨率的需求。常见经验是让 ARR 尽量大一些这样 CCR 可设置的档位就多占空比调节精度高。比如选 PSC 249那么 PSC 1 250ARR 1 250000 / 250 1000ARR 999。这时如果想要 50% 初始占空比PulseCCR就填 500。CubeMX 里的对应数值就是Prescaler 249Counter Period 999Pulse 500这一个组合同时确定了频率、周期分辨率、初始占空比。你可以先按这个示例跑通再根据你的实际时钟频率重新计算。3.3 生成代码后需要手动补充什么CubeMX 会生成MX_TIM3_Init()完成定时器参数配置和 PWM 通道配置。但它不会主动启动 PWM 输出。你需要在main()里在初始化完成后调用HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);这句话的作用是使能定时器的更新中断和对应输出通道。对于通用定时器这一步就够了。如果你用的是高级定时器 TIM1 或 TIM8还需要额外使能主输出HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1); __HAL_TIM_MOE_ENABLE(htim1);如果你用的是 HAL 库且复用功能配置正确到这里应该就能在引脚上看到 1kHz、50% 的 PWM 波形了。3.4 一个可复用的四步设计法把上面的过程抽象一下就得到一个简单的 PWM 参数设计顺序确认定时器时钟 T从时钟树页面读取。确定目标频率 f选择 PSC让 ARR 落在 1000 到 65535 之间16 位定时器。计算 ARR T / ((PSC 1) × f) - 1。确定目标占空比 d计算 CCR d × (ARR 1)。这个流程可以复制到任何基于 STM32 定时器的 PWM 项目上。先定频率再定分频再算自动重装载值最后算比较值。4. 动态修改频率与占空比的正确姿势固定波形的输出只是第一步实际项目中更常见的需求是运行中动态修改参数。接下来是全文最核心的部分。4.1 修改占空比CCR 是唯一的旋钮如果频率不变只改占空比最简单。直接修改 CCR 寄存器即可__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 300);上面这行将 CCR 设置为 300。如果 ARR 还是 999那么占空比就从 50% 变成了 30%。也可以直接改写寄存器htim3.Instance-CCR1 300;两种写法效果一样前者更符合 HAL 库的风格。关键点是只改 CCR 不会影响 PWM 频率因为频率由 PSC 和 ARR 决定而 CCR 不参与频率计算。4.2 修改频率ARR 和 PSC 你改哪个修改频率有两个选择改 ARR或者改 PSC。两者都能改变频率但效果和代价各不相同。改 ARR 的方式__HAL_TIM_SET_AUTORELOAD(htim3, 1999);如果 PSC 保持 249定时器时钟 250MHz那么新的 PWM 频率 250MHz / (250 × 2000) 500Hz。频率变低了但注意占空比也变了。原来 CCR 是 500现在占空比 500 / 2000 25%而不是原来的 50%。改 PSC 的方式__HAL_TIM_SET_PRESCALER(htim3, 124);如果 ARR 保持 999那么新的 PWM 频率 250MHz / (125 × 1000) 2kHz。同样因为 CCR 没有变占空比 500 / 1000 50%这个方向上占空比反而能保持不变。但改 PSC 有一个隐患频率分辨率。如果 PSC 是比较大的数值比如 2499那么每次只能按 1/(2500×ARR) 的步进来调频想精确到 1Hz 就非常困难。相比之下保持 PSC 不变通过改 ARR 来调频频率分辨率会高很多。我的建议是固定 PSC用 ARR 调频率同时按比例更新 CCR 来维持占空比。这种方式最容易理解和掌控。4.3 为什么改完 ARR 后占空比会“漂移”这里再剖析一下本质。占空比的数学定义是 CCR 占 ARR 的比例。当你修改 ARR 时相当于改变了分母而分子 CCR 没有变比例就变了。举个例子你的目标是始终输出 50% 占空比。频率从 1kHz 变成 2kHzARR 从 999 变成 499。这时为了保持 50% 占空比CCR 也应该从 500 变成 250。如果你只改了 ARR没改 CCR占空比就变成了 500 / 500 100%输出几乎常高。正确的做法是同时修改 ARR 和 CCR。为了减少代码错误可以定义一个通用的更新函数把频率和占空比作为参数传进来内部先算 ARR再算 CCRvoid set_pwm_freq_duty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t timer_clock, uint32_t freq_hz, uint8_t duty_percent) { uint32_t psc htim-Init.Prescaler; uint32_t arr timer_clock / ((psc 1) * freq_hz) - 1; uint32_t ccr arr * duty_percent / 100; __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, channel, ccr); }这个函数把参数计算集中在一起避免人工同步 ARR 和 CCR 时出错。你只需要在调用前确认htim-Init.Prescaler的值和你预期一致。但要注意在实际工程中CCR 和 ARR 的同步更新不是原子的。你在代码里先设置 ARR 再设置 CCR中间可能有极短的时间窗口实际硬件输出的占空比不是预期值。如果你的应用对波形连续性要求很高比如电机控制建议利用定时器的更新事件或 DMA 来实现 CCR 的同步更新。如果只是控制舵机、调光这类场景微秒级的时间窗口通常不会产生可感知的影响。4.4 预装载机制为什么修改会“延迟生效”或不生效还有一个经常被忽略的细节预装载寄存器。CubeMX 里AutoReloadPreload这个选项控制 ARR 是立即生效还是等到更新事件才生效。如果开启预装载你通过__HAL_TIM_SET_AUTORELOAD()写入的 ARR 值会先被写入影子寄存器等到计数器计数回绕产生更新事件时才真正加载到运行寄存器。这样做的目的是避免计数中途改变周期导致输出毛刺。__HAL_TIM_SET_PRESCALER()也有类似行为。PSC 寄存器有影子寄存器机制写入的值可能不会立刻影响分频比而是等到更新事件发生后才会生效。所以如果你在运行中改 PSC 发现频率没变先不要怀疑寄存器写错先检查是否需要在后面强制产生一次更新事件__HAL_TIM_SET_PRESCALER(htim3, 124); __HAL_TIM_GENERATE_UPDATE(htim3);但是这里要非常克制。__HAL_TIM_GENERATE_UPDATE()会强制产生更新事件同时也会把计数器清零甚至可能触发更新中断这取决于你的 NVIC 配置。如果你正在用这个定时器做连续的 PWM 输出频繁调用这个宏可能带来不期望的波形毛刺。所以在生产代码里我更推荐的做法是如果必须在运行中改 PSC就先停掉 PWM 输出修改参数再重新启动HAL_TIM_PWM_Stop(htim3, TIM_CHANNEL_1); __HAL_TIM_SET_PRESCALER(htim3, 124); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);这个流程会牺牲短时间内波形连续性但换来的是行为可预期、可排查。5. 排错思路从现象定位到根因不管代码写得多么顺畅总会出现波形不对的情况。我建议遵循一个固定的排查顺序而不是靠猜。5.1 没有波形输出先从最简单的可能性开始确认HAL_TIM_PWM_Start()被调用了而且传入的定时器和通道是正确的。如果是高级定时器确认__HAL_TIM_MOE_ENABLE()是否执行。确认 GPIO 复用模式是否正确。CubeMX 虽然会自动配置但如果你手动改过引脚需要重新确认 AF 映射。确认定时器时钟没有被关闭。在 HAL 库中HAL_TIM_PWM_Init()会调用HAL_TIM_Base_Init()其中会使用__HAL_RCC_TIMx_CLK_ENABLE()开启时钟。如果你跳过了初始化函数或者改了外设初始化顺序定时器时钟可能根本没开。5.2 波形频率不对频率不对优先怀疑时钟树。先看 CubeMX 的 Clock Configuration 页面确认你使用的定时器挂在 APB1 还是 APB2再看对应总线的预分频系数。如果你确认定时器时钟是 250MHz但实际波形频率偏差很大再检查代码里的 PSC 和 ARR 值。注意 CubeMX 界面上的Prescaler对应的是PSC最终在寄存器里写入的数值是你在界面上填的数但实际分频系数是PSC 1。这是很常见的偏差来源。还有一个更容易忽略的点htim3.Init.Period在 HAL 库里叫做 Period但它是 ARR 的值不是 ARR 1。你在代码里算频率时如果用Period直接当 ARR 1 用结果会差一拍也就是多减了一个 1。5.3 用示波器或逻辑分析仪验证时的几个建议调试 PWM 时示波器首选逻辑分析仪也可以。但要注意几个边界示波器的采样率要远高于 PWM 频率否则波形边缘不清晰占空比读数不准。测量前先确认探头的地线接好且地线尽量短否则高频噪声可能让波形抖动。触发模式可以设置为边沿触发方便观察频率变化要观察占空比可以把触发电平放在波形中值附近。如果你在测量时发现频率和设置值有轻微偏差先检查时钟源精度。内部 HSI 时钟本身精度有限对于高精度应用应该使用外部晶振。6. 从单路 PWM 到多路输出一个小框架最后把视角从单路 PWM 拉高一点看看这套知识怎么迁移到更复杂的场景。6.1 多路 PWM 之间的频率依赖关系如果你需要同时输出多路 PWM首先要判断频率是否相同。同一个定时器的多个通道共享同一个 ARR 和 PSC也就是说它们的频率必须一致只能通过不同 CCR 来获得不同的占空比。这是硬件结构决定的不是软件能绕开的。如果需要同时输出不同频率的 PWM通常需要占用多个定时器。这就需要规划好定时器资源。STM32C5A3R 的定时器数量是有限的多路不同频率的 PWM 需求会很快把定时器耗尽。在设计需求阶段就要先想清楚到底需要几路独立频率的 PWM。如果你的多路 PWM 频率相同、相位还有特定要求可以考虑多个通道配合使用。高级定时器还支持互补输出和死区插入这在电机控制和半桥驱动中非常关键。但这也意味着代码复杂度上升你需要配置死区时间、刹车输入、互补极性等参数。6.2 从“跑通”到“工程化”还需要补什么本文前面所有示例都是让 PWM“跑起来”的层面。如果要把这套逻辑放进真实产品还会遇到更多挑战参数合法性检查调用动态修改函数时如果传入的频率是 0或者 CCR 计算出来大于 ARR应该提前拦截并处理。失败恢复当通信链路或外部控制信号异常时PWM 输出是保持原值、清空输出还是进入安全状态需要定义清楚。使用 DMA 更新 CCR在需要连续变化占空比的场景比如生成正弦波调光或电机电流控制适合使用 DMA 把一组 CCR 数据周期性地搬运到寄存器减少 CPU 负担。测量与验证任何 PWM 参数修改后都应该用示波器确认实际波形而不是只检查代码逻辑。6.3 这个方案适合谁、不适合谁如果你的应用场景是学习 STM32 定时器、驱动舵机、调节 LED 亮度、驱动蜂鸣器、简单的有刷电机调速那么本文的基于 CubeMX HAL 库 通用定时器的方案已经足够。如果应用进入高可靠性电机控制比如 FOC 中的 PWM 三相输出那么建议直接使用高级定时器的互补输出、死区生成和刹车保护能力并在代码层面加入更严格的错误检查和保护逻辑。如果应用对 PWM 的时序抖动要求很高比如某些传感器驱动或音频相关应用那么你需要认真阅读 STM32C5A3R 的具体定时器时钟精度、预分频粒度以及 MCU 的中断抢占行为单纯依靠本文的通用方法不一定能满足时序要求。回到最开始的问题为什么很多人卡在动态调频率这一步因为他们在用“记住公式”的方式学 PWM而不是用“理解计数模型”的方式学。一旦掌握了定时器的计数过程PSC、ARR、CCR 之间的关系就全部通畅了。那时候再看 CubeMX 里的配置界面看到的不是一堆陌生参数而是一个你可以精确控制的波形生成器。下一步我建议你做的不是把示例代码复制到你的工程而是看完时钟树算出 TIM3 的实际时钟自己把 1kHz、50% 的配置算一遍用示波器验证然后再写一个动态修改频率和占空比的函数。整个过程不会超过一个小时但这一个小时能给你建立的可迁移能力会在后续所有用到定时器的项目中反复兑现。