
最近在一个项目里用 STM32C542R 做电机调速和 LED 呼吸灯本来以为输出 PWM 是十分钟就能搞定的事结果几十个引脚、好几种定时器、上百个配置项摆在一起还是让我花了不少时间才理清频率和占空比的关系。更麻烦的是单次跑通简单真正要按需求动态改变频率和占空比时很多人包括我自己第一反应都是直接让某个寄存器更新但结果要么波形没变要么频率跟着跳变甚至还会影响另一路 PWM 输出。这篇文章不会只贴一份 CubeMX 配置截图然后说“照着设就行”。我想把 PWM 输出的底层计算逻辑、CubeMX 里的关键配置、运行时修改频率和占空比的各种写法以及我实际踩过的坑一起整理出来。核心判断只有一句PWM 这个外设不难难的是你始终要清楚时钟、预分频、自动重载和比较值这四个数字之间的数学关系。一旦把这张关系图刻在脑子里无论换到 STM32 的哪个系列你都能在几分钟内定位问题。1. 先搞清楚PWM的基础周期、频率、占空比不是三个孤立的参数1.1 PWM的本质在一个周期内切换高低电平PWM 的全称是脉冲宽度调制本质上就是在一个固定周期内让引脚输出的高电平和低电平按一定比例分配。比如周期是 1 毫秒高电平持续 0.5 毫秒那么这个信号的频率是 1kHz占空比是 50%。这个定义看起来很简单但真正到芯片寄存器配置时很多人会把“周期”和“频率”混在一起。在 STM32 的定时器里频率由两段分频共同决定第一段是时钟源经过预分频器得到定时器计数时钟第二段是计数器从 0 数到自动重载值后产生更新事件。你最终看到的 PWM 频率就是计数时钟频率除以自动重载值加一。这里的预分频值对应 PSC自动重载值对应 ARR。很多初学者容易忽略的是ARR 不仅决定了频率它同时也是占空比的“分母”。占空比由比较寄存器 CCR 决定实际高电平时间是 CCR 和计数器的匹配点。所以当你为了改频率而调整 ARR 时如果不重新计算 CCR占空比会立刻发生变化。这就是为什么“只改频率不改占空比”这个需求在代码里必须同时改两个寄存器。1.2 频率和占空比各自决定了什么从负载角度看频率决定了控制系统的响应速度、噪声特性以及功率器件的开关损耗。比如驱动直流电机PWM 频率太低电机会发出明显的嗡嗡声转速波动也大频率太高MOSFET 的开关损耗会上升驱动芯片可能会过热。而占空比决定了输出能量的平均值也就是电机转速或 LED 亮度。但频率也不是越高越好。不同负载有不同适用区间LED 调光一般几百赫兹到几千赫兹就够了再高肉眼也分辨不出闪烁反而可能引入 EMI 问题无刷电机或开关电源可能需要几十 kHz 甚至更高。所以配置 PWM 之前一定要先明确负载类型和期望的控制精度不能拿着一个默认参数直接套在所有场景里。1.3 为什么很多人修改占空比成功修改频率却经常失败原因在于 HAL 库的接口设计。修改占空比通常用__HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_x, compare)修改后立刻生效不需要中断也不需要重新启动定时器。但修改频率涉及 PSC 和 ARR这两个寄存器在定时器运行时更新是有讲究的。PSC 通常有缓冲寄存器可能在下一个更新事件才生效ARR 同样存在预装载和立即装载的配置。如果没有设置自动重载预装载或没有触发更新事件你可能看到频率一直是旧值。我在实际调试过程中的体会是修改占空比只要关注 CCR 一个变量而修改频率必须同时考虑 PSC、ARR、重装载时机还要保证 CCR 跟着比例变化。大多数人失败是因为脑海里没有建立起“频率和占空比共享 ARR 这个分母”的概念。2. 在 STM32CubeMX 里初始化一个 PWM 输出通道STM32C542R 虽然是一个具体型号但它的 PWM 配置路径和 STM32 其他系列非常相似。我这里以通用配置流程为例重点讲清楚每一步背后的原因而不是机械地告诉你截图里的选项要选哪个。2.1 选择定时器和通道先看需求再选资源打开 STM32CubeMX 后第一件事不是直接点某个引脚而是先想清楚你要用什么定时器以及定时器的哪个通道。STM32 的定时器资源有限高级定时器和通用定时器都可以输出 PWM但通道数量、互补输出、刹车输入等功能不同。如果你的应用只需要一个简单的 PWM 信号优先选择通用定时器把高级定时器留给可能需要互补输出或死区控制的场景。以电机控制为例通常需要两路互补的 PWM并且要求带死区那首选高级定时器 TIM1 或 TIM8。如果只是 LED 呼吸灯或者蜂鸣器TIM3、TIM4 这类通用定时器完全够用。选好定时器后再在 CubeMX 的 Pinout 视图里找到对应通道引脚比如某个 TIM3_CH1 的引脚。这里给你一个判断顺序是否需要互补输出是就用高级定时器否看通用定时器。需要几路独立 PWM如果只是单路任意通道即可。引脚冲突是否严重CubeMX 会提示引脚冲突优先选择没有冲突的通道。是否有 DMA 需求如果要在内存里放多个占空比数据需要查找定时器的 DMA 请求映射。2.2 配置时钟树PWM频率的根在这里PWM 频率的源头是定时器时钟而定时器时钟来源于 APB1 或 APB2 总线。在 CubeMX 的 Clock Configuration 页面里你可以看到 APB1 和 APB2 的时钟频率以及定时器时钟倍频后的数值。很多初学者在这里忽略了一个细节如果 APBx 分频系数不等于 1定时器时钟通常是 APBx 时钟的 2 倍。比如 APB1 定时器时钟可能显示为 80 MHz而 APB1 外设时钟是 40 MHz这是因为定时器有独立的倍频逻辑。所以你在计算 PWM 频率时绝对不能拿 System Clock 直接算必须确认 CubeMX 里给出的定时器时钟频率。最稳妥的做法是在代码里给定时器时钟做实际测量或者使用HAL_RCC_GetPCLK1Freq()加倍频规则来计算不要凭感觉写一个数值。2.3 生成代码后用HAL库启动PWMCubeMX 生成代码后默认只会初始化定时器并不会输出 PWM。你需要在主程序里先启动定时器的 PWM 通道。常见写法是HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);如果你启用了定时器全局中断还要加一句HAL_TIM_PWM_Start_IT(htim3, TIM_CHANNEL_1);但这句只用于开启中断不是必须。启动后引脚上就会有波形默认占空比由你在 CubeMX 里设置的 Pulse 值决定。如果看不到波形第一反应不要是代码问题先用万用表或示波器确认引脚配置是否真的被拉为复用功能也就是 GPIO 的 Alternate Function 是否正确。3. 频率和占空比的计算逻辑预分频、自动重载、比较值我见过不少人直接在代码里写htim3.Init.Prescaler 72;然后问为什么频率不对。因为计算 PWM 参数不是单独设一个数就能解决必须三个寄存器配合。3.1 核心公式PWM频率 定时器时钟 / ((PSC1)*(ARR1))这是整个 PWM 开发最核心的公式。PSC 是预分频值ARR 是自动重载值。定时器时钟先经过 PSC1 分频得到计数器的计数频率计数器再从 0 加到 ARR产生一个更新周期。因此 PWM 频率就等于定时器时钟除以两者乘积。举个例子。假设定时器时钟是 80 MHz我需要输出 20 kHz 的 PWM。那么(PSC1) * (ARR1) 80MHz / 20kHz 4000其中一种组合是 PSC3ARR999因为 (31)*(9991)4000。另一种组合是 PSC39ARR99乘积也是 4000。你会发现存在多组解这就要看你的频率分辨率和占空比精度需求了。3.2 怎么确定PSC和ARR两个自由度的取舍PSC 和 ARR 的取值不是随便挑的它们直接影响频率分辨率和占空比特数。ARR 越大占空比的调节粒度越细因为占空比按 ARR1 来分份。如果你要用 10% 的占空比ARR999 时 CCR100 对应 10%误差不到 0.1%但 ARR99 时 CCR10 对应约 10.1%精度就差一些。反过来PSC 越大频率分辨率可能越差。比如定时器时钟 80 MHzPSC79 时计数时钟 1 MHz那么频率只能按 1MHz/(ARR1) 的步进变化。如果你想输出 10.5 kHz可能就找不到整数 ARR 刚好匹配。我的建议是分两步先根据占空比精度要求确定 ARR比如需要千分之一的占空比精度ARR 至少取 999。再用公式反推 PSC 是否在 16 位或 32 位范围内且频率是否可接受。3.3 占空比 CCR/(ARR1)别写反占空比的高电平时间由 CCR 决定。标准 PWM 模式下计数器从 0 递增到 ARR当计数值小于 CCR 时输出高电平大于等于 CCR 时输出低电平取决于极性配置。所以占空比公式是占空比 CCR / (ARR1) * 100%注意这里的分母是 ARR1而不是 ARR。很多人写代码时直接compare duty * arr / 100如果后面加一减一不一致最后得到的结果可能偏小。举个反例ARR999要 50% 占空比CCR 应该取 500。如果代码里写compare 50 * 1000 / 100 500看起来没问题。但如果你直接拿 ARR999去算50 * 999 / 100 499.5取整后是 499真实占空比是 499/100049.9%误差有时可以接受但在高精度应用中会变成一个奇怪的现象。所以建议统一用(ARR1)作为分母并且在代码里把 ARR 与 CCR 之间的关系写清楚不要留魔法数字。4. 运行时修改频率与占空比的几种做法很多应用不会让 PWM 频率和占空比固定不变。比如变频器需要动态调速或者协议规定不同阶段用不同频率的信号。这时候就要考虑运行时更新寄存器的方法。4.1 修改占空比HAL_TIM_PWM_Start后的__HAL_TIM_SET_COMPARE修改占空比最简单因为不需要改动频率相关的 PSC 和 ARR只需要更新 CCR。HAL 提供了宏__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, compareValue);这个宏实际上就是直接写定时器的 CCR 寄存器。如果开启了预装载它会在下一个更新事件生效如果没有它会立即生效。在电机调速或 LED 渐变场景中这种修改方式非常合适因为它不会引起定时器重启也不会产生频率跳变。但要注意如果你在中断服务函数里高频调用占空比修改比如每次更新事件都改一次 CCR那么要确保比较值的变化不会超出 ARR 的范围。如果直接取随机数或者外部输入的值建议先做限幅。4.2 修改频率更新ARR和PSC要注意的关联问题修改频率比修改占空比复杂。因为频率由 PSC 和 ARR 共同决定而占空比依赖于 CCR 和 ARR 的比值。直接更新 ARR 而不改 CCR占空比会变直接更新 PSC计数器可能重新开始波形会产生一个不完整的周期。我建议用下列顺序更新频率以避免跳变停止输出或者利用定时器的更新事件同步修改。计算新的 ARR 和 PSC。按新的 ARR 重新计算 CCR以保持当前占空比不变。设置 PSC、ARR、CCR。重新启动 PWM。看起来繁琐但这是最可控的方式。如果一定要在运行中连续平滑变化频率那么可以考虑使用定时器更新中断在中断里修改这些寄存器。但要注意中断频率不能太高否则主循环会受影响。代码示例示意需根据实际型号调整void SetPwmFreqAndDuty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq, uint8_t duty_percent) { uint32_t timer_clock HAL_RCC_GetPCLK1Freq() * 2; // 需根据实际总线倍频确认 uint32_t arr (timer_clock / freq) - 1; uint32_t ccr (arr 1) * duty_percent / 100; __HAL_TIM_SET_PRESCALER(htim, 0); // 先重置 PSC __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, channel, ccr); // 如果定时器没有运行需要启动 HAL_TIM_PWM_Start(htim, channel); }这个示例里故意把 PSC 设为 0其实不太合理只是为了强调修改顺序。更通用的是先计算好 PSC 和 ARR 再一起更新。4.3 使用DMA和定时器更新中断的进阶方案如果你需要在 PWM 频率不变的情况下周期性改变占空比比如产生正弦波查表那 DMA 是一个很高效的选择。STM32 的定时器支持 DMA 请求可以把内存里的一段 CCR 数据按事件触发自动搬移到寄存器里整个过程不占 CPU。但使用 DMA 前要确认两件事定时器的 DMA 请求是否连接到对应的 DMA 通道。DMA 配置是循环模式还是正常模式循环模式会不断从头读取数据。我在使用 DMA 时踩过一个坑如果同时开启了多个通道的 DMA 请求而 DMA 通道优先级没有配好可能会导致某个通道的占空比更新被延迟。此时建议把高实时性通道的 DMA 优先级提上来或者改用定时器更新中断来同步。定时器更新中断是另一种方式。适用于频率或占空比需要根据外部输入动态调整的场景。在中断回调里修改 PSC、ARR、CCR 时要注意不要使用 HAL_TIM_PWM_Stop 再重新 Start因为那会产生一个短暂的引脚禁用期对某些负载可能造成冲击。更好的做法是直接写寄存器并且让它在一个安全边界更新。5. 常见错误与排查链路这部分我想直接给你一条排查路径而不是零散地罗列问题。遇到 PWM 没有输出或输出异常按下面顺序查一般能定位 90% 的问题。5.1 现象没有波形输出先看代码里是否调用了HAL_TIM_PWM_Start。很多人初始化完就以为有输出其实没有启动。再看 GPIO 初始化。CubeMX 生成代码后引脚应该是复用功能模式。如果你后来手动改过 GPIO 配置可能把它改成了输出模式导致 PWM 信号被覆盖。接着看定时器时钟是否使能。HAL_TIM_PWM_Init 内部会调用定时器时钟使能但如果前面代码有错误导致中断返回也可能没使能。5.2 现象频率不对确认定时器时钟频率。先用printf打印htim-Init.Prescaler和htim-Init.Period再用公式反算。确认 PSC 和 ARR 是否包含“加一”的细节。很多人把 PSC 和 ARR 当成分频系数但实际硬件计数是从 0 开始的所以要加一。确认 CubeMX 自动计算的值是否和你的需求一致。特别是在使用自动计算按钮后CubeMX 可能给出一个 ARR 很大或很小的组合要检查占空比精度是否能接受。5.3 现象修改频率/占空比没生效如果你修改的是 CCR但占空比没有变化先看是不是通道选择错误。比如 TIM3_CH1 对应的__HAL_TIM_SET_COMPARE第二参数应该是TIM_CHANNEL_1而不是TIM_CHANNEL_2。如果修改 ARR 之后频率没变检查是否开启了自动重载预装载。HAL_TIM_PWM_Start不会自动设置预装载需要在 CubeMX 里把Auto Reload Preload使能或者代码里调用__HAL_TIM_ENABLE_OC_PRELOAD(htim, channel)。如果 PSC 修改后迟迟不生效可能是因为 PSC 寄存器在更新事件到来之前不会写入。你可以手动产生一个更新事件__HAL_TIM_GENERATE_UPDATE(htim, TIM_EVENTSOURCE_UPDATE)。但注意这个操作会把计数器清零可能导致占空比瞬时变化。6. 从跑通PWM到工程化使用PWM的几条建议6.1 先确定负载类型阻性、感性、容性PWM 输出到不同负载上的表现完全不同。阻性负载如电热丝、灯泡电流随电压线性变化PWM 频率只要不太低就行。感性负载如电机、电感电流不会突变如果频率太低电流波动会很大还可能出现断续现象。容性负载对 PWM 频率更敏感可能需要更高的频率来减小纹波但驱动起来要注意瞬间充电电流。所以选频率不是按公式算出整数就行要先看负载类型。一个可用的参考框架是电机调速用 10 kHz 到 20 kHz 比较多因为可以避开人耳听得到的范围LED 调光用 1 kHz 以上就足够开关电源的 PWM 可能要做到 100 kHz 以上但这不是普通定时器直接输出就能稳定的事还要考虑驱动电路。6.2 不要把PWM输出和控制逻辑混在一个中断里很多人为了动态调整占空比把 PID 计算、按键扫描、显示刷新全塞进 PWM 更新中断里。这种做法在小项目里能跑但工程化以后会很危险。中断里代码执行时间过长可能导致定时器更新事件丢失或者高优先级中断频繁打断主循环。更好的设计是控制逻辑放在主循环或者低优先级任务里PWM 更新中断只负责把计算好的目标值写入寄存器或者通过 DMA 直接搬运数据。这样即使控制逻辑被卡住PWM 输出仍然可以保持上一个有效值对电机或加热器来说安全性会高很多。6.3 定期校准时钟源、负载变化和长期稳定性PWM 频率的长期稳定性取决于时钟源。如果使用 HSI 或 PLL 的内部时钟温度变化和电源电压波动都会引起一定偏差。对精度要求较高的场景建议使用外部晶振并且在代码里加入时钟校准逻辑。对于占空比尤其是电压控制类负载负载自身的阻值会随温度变化比如加热丝冷态和热态电阻差异很大这时 PWM 输出相同实际功率可能不一样需要通过闭环调节来修正。你可以把这个过程记成三个步骤根据负载类型定频率根据控制精度定 ARR。用公式反推 PSC并通过示波器验证真实频率。运行时如果需要改频率和占空比先算后写、先停后改、边改边查。这套流程不只是一次性配置也适合后续排查问题。PWM 开发的回报率很高因为它是嵌入式系统里最常用的输出形式之一从电机到电源、从灯光到音频都会碰到。只要你把 PSC、ARR、CCR 这三个寄存器的关系搞透以后换芯片、换项目都只是换个配置入口罢了。