
如果你在嵌入式开发里用过 STM32大概率绕不开 PWM。调 LED 亮度、控制舵机角度、驱动直流电机、生成音频信号、做 DCDC 电源控制几乎处处都要和 PWM 打交道。很多新手第一次接触 PWM是在 STM32F103 上抄一段标准库代码把 TIM 配好看到波形出来就算完事。可一旦进入实际项目问题马上变得具体起来频率要改成 20kHz占空比要动态调整输出要随时关断多路 PWM 还要同步更新。这时候如果只会照抄初始化代码就会非常被动。这篇文章以 STM32C5A3R 为例完整走一遍 PWM 输出开发流程先讲清楚 PWM 频率、占空比和定时器时钟、ARR、CCR 的换算关系再用 STM32CubeMX 配置一个最小工程最后重点解决运行期动态修改频率与占空比的问题。读完你会明白为什么只改一个寄存器往往达不到预期效果以及如何用 HAL 库的接口安全地实现“频率和占空比都随意变”的功能。如果你正在做电机控制、调光调色、电源控制或信号发生类项目这篇文章可以直接参考。1. 为什么写这篇PWM 开发中最容易卡住的地方STM32C5 是意法半导体新一代 Arm Cortex-M33 内核 MCU 系列定位在入门与主流市场面向家电、工业控制、消费电子等方向。相比老一代产品它在内核性能、存储容量、安全特性等方面都有明显升级。不过片子的外设使用思路和老的 STM32 系列是延续的定时器 PWM 部分同样遵循“时钟分频 自动重装载 比较捕获”这套经典模型。这给了开发者一个很重要的信号STM32C5 的 PWM 开发完全可以借鉴老平台的经验但要注意新平台在时钟树、HAL 库版本和 CubeMX 配置界面上有差异。我见过很多刚接触 PWM 的读者最容易在三个地方卡住。第一个是概念混淆。PWM 的频率、占空比、分辨率、死区、极性这些术语单独拿出来都能理解但一结合定时器就乱了。比如有人问“为什么我改了 ARR频率变了但占空比也变了”这就是没搞清楚 ARR 同时决定频率和占空比。第二个是 CubeMX 配置不熟。很多人知道要选 TIM 的 PWM Generation但分不清 Channel 配置里 Pulse 是什么、Counter Period 是什么、PSC 和 ARR 到底谁影响频率。生成的代码虽然能跑但初始化参数全靠默认稍微改个需求就不知道从哪里下手。第三个是“运行期修改”没搞明白。这是最要命的。项目里不会只在启动时输出一个固定 PWM往往要根据传感器数据、用户操作或控制算法实时调整频率和占空比。如果直接改 ARR、CCR不做边界检查不处理预装载和更新事件波形可能乱掉甚至输出异常电平把后面的功率器件烧掉也不是没可能。所以这篇不是简单复述“怎么生成 PWM”而是把“输出 PWM 后如何动态修改频率与占空比”这个工程问题讲透。重点会放在公式换算、HAL 库接口用法、寄存器生效时机、边界检查这几个实操细节上。2. PWM 基础概念频率、占空比和定时器的关系PWM全称 Pulse Width Modulation脉冲宽度调制。本质是让 GPIO 以固定频率输出高低电平通过改变高电平在一个周期内所占的时间比例来等效控制输出功率或信号强度。2.1 频率、占空比、分辨率频率Frequency是 PWM 波每秒钟重复的次数单位 Hz。频率越高每个周期越短输出越平滑但开关损耗也相应增加。LED 调光通常用几百 Hz 到几十 kHz电机控制常用 10kHz 到 30kHz音频应用则要到几十 kHz 甚至上百 kHz。占空比Duty Cycle是一个周期内高电平时间占总周期的比例通常用百分比表示。占空比 50% 就是半个周期高、半个周期低。占空比 0% 意味着始终低电平100% 意味着始终高电平。分辨率Resolution描述占空比可以细分的步进数。如果自动重装载值 ARR 是 999那么占空比可以从 0 到 999 分 1000 级调整分辨率约为 0.1%。ARR 越大分辨率越高但最大 PWM 频率会受限制。2.2 定时器为什么能产生 PWM用一句话解释定时器在内部从 0 数到 ARR每数到 CCR 时翻转一次输出数到 ARR 时归零重新开始下一轮。如果输出极性配置为高电平有效那么计数值小于 CCR 时输出高电平计数值大于等于 CCR 时输出低电平计数值到达 ARR 后清零重新计数。所以 CCR 决定高电平的持续时间ARR 决定整个周期的长度。二者结合就生成了频率和占空比都可控的方波。2.3 频率与占空比的换算公式设定时器输入时钟为 fCK预分频系数为 PSC自动重装载值为 ARR比较值为 CCR。PWM 频率fPWM fCK / ((PSC 1) * (ARR 1))占空比Duty CCR / (ARR 1) * 100%这里 PSC 和 ARR 都是寄存器值实际的分频因子和计数周期要分别加 1这是新手最容易漏掉的地方。例如 fCK 64MHz设置 PSC 63ARR 999那么实际频率是 64MHz / (64 * 1000) 1kHz。如果把 PSC 写成 64ARR 写成 1000频率就是 64MHz / (65 * 1001) ≈ 983Hz结果完全不同。从公式也能看出频率和占空比不是完全独立的。想要只改变频率而不改变占空比必须同时调整 ARR 和 CCR让 CCR / (ARR 1) 的比值保持不变。这个关系在后续动态修改时会反复用到。2.4 容易混淆的 Timer 参数参数常见名称含义影响PSC预分频系数对定时器时钟进行分频影响计数频率从而影响 PWM 频率ARR自动重装载值计数器计数上限决定 PWM 周期同时决定占空比分母CCR比较/捕获值与计数器比较决定高电平时间决定占空比分子PulseCubeMX 中的脉冲宽度同 CCR决定占空比初始值Counter Mode计数模式Up/ Down/ Center Aligned改变 PWM 波形对齐方式在这张表里PSC 和 ARR 都影响频率但作用不同。PSC 决定了计数器的时钟频率ARR 决定了计数器从 0 跑到顶部需要多少个时钟周期。调节频率时优先考虑改 ARR因为它不改变计数器的时钟节拍对占空比分辨率的影响更直观PSC 更适合做粗调ARR 适合做细调。3. 环境准备与 CubeMX 基础配置要跑通 STM32C5A3R 的 PWM 工程需要准备以下环境开发板STM32C5A3R 芯片的评估板或自制板本文以通用 TIM 通道演示调试器ST-LINK 或兼容调试器用于下载和在线调试软件STM32CubeMX新版本已支持 STM32C5 系列、STM32CubeIDE 或 Keil MDK、STM32Cube 固件库测量工具示波器或逻辑分析仪。如果没有可以用 LED 和万用表做粗略验证。需要注意STM32C5 系列是较新的产品线CubeMX 需要较新版本才能识别芯片型号。如果打开软件后在 MCU 选择器里搜不到 STM32C5A3R要先升级 CubeMX 版本并安装对应的固件包。版本号更新很快这里不写死以你实际安装的版本为准。在 CubeMX 中新建工程的步骤如下3.1 选择芯片打开 STM32CubeMX点击 New Project在 MCU Selector 里输入 STM32C5A3R选中对应型号后开始工程。此时可以顺便设置工程名和保存路径。3.2 配置时钟源在 System Core - RCC 中将 HSE高速外部时钟或 HSI内部高速时钟配置为实际板卡使用的时钟源。开发板通常有外部晶振选择 Crystal/Ceramic Resonator如果你用的是内部时钟也可以直接保持 HSI 模式。时钟源会影响后续定时器时钟频率的计算。3.3 配置定时器和 PWM在 Timers 中选择一个定时器例如 TIM3。勾选 Internal Clock然后在 Channels 列表中选择 Channel1 的 PWM Generation CH1。此时 CubeMX 会自动为你分配一个 GPIO 引脚并设置为复用功能模式。这个环节有几个关键配置项PrescalerPSC预分频值写 64-1 表示 64 分频Counter ModeUp 向上计数即可Counter PeriodARR自动重装载值写 1000-1 或 1000取决于你希望的计数周期Auto Reload Preload建议开启 Enable这样 ARR 的更新会等待当前周期结束避免波形撕裂PulseCCR初始比较值先写 500对应 50% 占空比CH Polarity保持 High 即可表示高电平有效。PSC 和 ARR 的具体值要和你的时钟频率、目标 PWM 频率匹配。假设定时器时钟是 64MHz希望输出 1kHz PWM那么 PSC*ARR 的关系按前面的公式计算。可以先用一个例子验证PSC 64-1ARR 1000-1PWM 频率 64MHz / (64 * 1000) 1kHz。注意 CubeMX 界面上填的是分频值和周期值有些版本会直接帮你换算并显示最终频率。3.4 生成工程配置完成后在 Project Manager 里选择工具链STM32CubeIDE 或 MDK-ARM生成工程。启动代码会自动包含定时器初始化函数 MX_TIM3_Init()并在 main 函数中调用。PWM 输出还需要你在用户代码中显式启动后面会写。4. 用 HAL 库输出第一路 PWM工程生成后定时器初始化已经完成但默认状态下 PWM 并没有真正输出。打开 main.c可以看到类似下面的初始化代码/* 文件路径Core/Src/main.c */ static void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 64 - 1; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 1000 - 1; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_PWM_Init(htim3) ! HAL_OK) { Error_Handler(); } sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 500; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; if (HAL_TIM_PWM_ConfigChannel(htim3, sConfigOC, TIM_CHANNEL_1) ! HAL_OK) { Error_Handler(); } }这段代码做了四件核心事情设置定时器时钟分频和周期决定 PWM 频率配置 PWM 模式为 PWM1即计数值小于 CCR 时输出高电平配置初始占空比 Pulse 500使能 ARR 预装载避免更新过程中波形瞬间异常。接下来在 main 函数的 while(1) 之前加入 PWM 启动代码HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);编译下载后用示波器或逻辑分析仪测量对应引脚应该能看到频率 1kHz、占空比 50% 的 PWM 波形。如果没有硬件测量工具也可以在引脚上接一个 LED 串联限流电阻肉眼观察 LED 亮度再通过修改 Pulse 值来验证占空比变化。这里要特别提醒HAL_TIM_PWM_Start 只是启动了 PWM 输出它不会阻塞程序。如果你的 while(1) 里还跑了其他任务PWM 会持续在后台由硬件生成不占用 CPU。这是定时器 PWM 相比软件延时翻转 GPIO 的最大优势。软件 PWM 用 GPIO 翻转加延时一方面占用 CPU另一方面频率越高越容易受中断影响导致波形抖动硬件 PWM 则完全没有这些问题。5. 运行期动态修改 PWM 频率与占空比跑通固定 PWM 之后真正的工程问题才刚开始。实际项目中几乎没有“永远 1kHz / 50%”的需求。你可能需要呼吸灯占空比从 0 慢慢加到 100再慢慢减到 0电机调速根据 PID 输出实时改变占空比信号发生器按用户输入切换输出频率电源控制根据负载动态调整开关频率。这些需求全部指向同一个能力运行期修改 PWM 参数。下面拆解几种常见做法。5.1 修改占空比修改占空比最标准的方法是 HAL 库的宏__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 300);这个宏本质上是把值写入定时器的 CCR 寄存器。如果你只想调亮度、调速度不改频率只改这一个宏就够了。占空比会立即更新不过要清楚即使开启了 ARR 预装载CCR 的更新也要等当前计数周期结束这个延迟最多一个 PWM 周期通常可以忽略。也可以直接使用 HAL 库函数HAL_TIM_PWM_Stop(htim3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1);但这种方式不建议在动态调节时使用。因为 Stop 再 Start 会让输出先回到默认电平再重新开始可能造成瞬间毛刺。对于电机、电源这类负载这种毛刺可能导致电流冲击。5.2 修改频率并保持占空比不变如果你只修改 ARR频率确实变了但占空比也会变。原因很简单占空比 CCR / (ARR 1)。ARR 增大分母增大在 CCR 不变的情况下占空比必然下降ARR 减小占空比上升。假设原来 ARR 999CCR 500占空比 50%。现在要频率翻倍ARR 改为 499。如果 CCR 还是 500已经超过 ARR输出会变成常高也就是 100% 占空比。这显然不是想要的结果。正确做法是同时修改 ARR 和 CCR保持比例不变uint32_t new_freq 2000; // 目标频率 2kHz uint32_t timer_clock 64000000; // 定时器时钟 64MHz uint32_t psc 64 - 1; uint32_t arr timer_clock / ((psc 1) * new_freq) - 1; uint32_t ccr (uint32_t)((uint64_t)(arr 1) * 50 / 100); // 保持 50% __HAL_TIM_SET_AUTORELOAD(htim3, arr); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, ccr);注意这里 ccr 计算用到了 uint64_t 强转避免 (arr 1) * 50 在 32 位下溢出。很多 MCU 开发者在做这类乘法时容易忽略中间结果溢出尤其在频率较大、ARR 接近 65535 时。虽然公式简单但溢出问题非常真实。还有一点需要提醒修改 ARR 时如果开启了 AutoReloadPreload新值要等当前周期结束后才生效。如果你希望立即生效可以在修改后加一条更新事件触发__HAL_TIM_SET_AUTORELOAD(htim3, arr); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, ccr); __HAL_TIM_SET_COUNTER(htim3, 0); __HAL_TIM_GENERATE_UPDATE(htim3, TIM_EVENTSOURCE_UPDATE);这里把计数器清零并生成更新事件是为了让 ARR 和 CCR 的新值同步生效避免出现一个周期用了新 ARR 但旧 CCR 的情况。在电机控制等对波形连续性要求高的场景这一步是有价值的。5.3 修改预分频值 PSC除了改 ARR还可以通过修改 PSC 调整频率。改 PSC 的优点是 CCR 不变时占空比也不变因为占空比只和 CCR/ARR 有关与 PSC 无关。__HAL_TIM_SET_PRESCALER(htim3, 32 - 1);但修改 PSC 有一个副作用PSC 变化后计数器的计数频率变了可能导致 PWM 频率变化的同时占空比分辨率也跟着改变。例如原来 PSC64ARR999频率 1kHz占空比分辨率 0.1%。改为 PSC32 后如果不改 ARR频率变成 2kHz分辨率不变因为 ARR 没变。但如果你为了保持频率不变而调整 ARR分辨率就会变化。所以 PSC 和 ARR 的配合需要结合具体需求。实际项目中我的建议是占空比需要精细调节优先保持 ARR 较大用 CCR 调节频率切换范围很大优先改 PSC再微调 ARR 精度频率和占空比都要连续变化封装成一个函数统一计算 ARR 和 CCR。5.4 封装一个动态设置函数为了让代码可复用建议把“设置 PWM 频率和占空比”封装成独立函数/* 文件路径pwm_utils.c */ #include tim.h static uint32_t pwm_timer_clock 64000000; /* 根据实际时钟修改 */ void PWM_SetFreqDuty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq_hz, uint8_t duty_percent) { uint32_t psc 64 - 1; /* 可根据需要改为参数 */ uint32_t arr; uint32_t ccr; if (freq_hz 0) { return; } arr pwm_timer_clock / ((psc 1) * freq_hz) - 1; if (duty_percent 100) { duty_percent 100; } ccr (uint32_t)((uint64_t)(arr 1) * duty_percent / 100); __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, channel, ccr); }使用时只需要在 main 中调用PWM_SetFreqDuty(htim3, TIM_CHANNEL_1, 2000, 25);这个函数最大的意义是让业务代码不再关心 ARR、CCR 的计算细节。后续要加边界检查、日志输出或限幅逻辑集中在这个函数里改即可。对于团队协作来说这种封装能明显降低其他同事使用 PWM 模块的出错率。6. 验证与测量如何确认输出正确完成了代码开发还必须用实际测量验证输出是否达标。下面给出三种验证方式从最准确的到最便捷的。6.1 示波器测量将示波器探头接到 PWM 输出引脚地线接开发板 GND设置触发模式为上升沿测量频率和占空比。预期输出频率接近你设定的目标值误差在允许范围内占空比接近你设定的百分比波形上升沿和下降沿清晰无明显毛刺。如果频率偏差较大第一步检查定时器时钟是否如你预期。很多情况下CubeMX 默认生成的时钟树会把定时器时钟设置成某个分频后的值而不是你手动计算的 64MHz。进入调试模式查看 htim3.Init.Prescaler 和 htim3.Init.Period 的实际值再倒推频率即可。6.2 逻辑分析仪测量没有示波器时逻辑分析仪是很好的替代方案。把逻辑分析仪通道连接到 PWM 引脚设置采样率高于 PWM 频率的 10 倍以上打开协议解析里的 PWM 解码可以直接读出频率和占空比。对于 1kHz 信号逻辑分析仪非常轻松对于 100kHz 以上的高频 PWM要检查逻辑分析仪的带宽是否足够否则测出来的占空比可能失真。6.3 LED 人眼粗略验证如果手头没有测量工具可以用 LED 做粗验证。LED 串联一个 1k 电阻接到 PWM 引脚频率固定在 1kHz 时修改占空比占空比 0%LED 灭占空比 50%LED 半亮占空比 100%LED 全亮。如果修改频率而不改占空比LED 亮度应保持不变。这个测试虽然不能给出精确数值但能验证“频率变化是否影响占空比”这个关键问题。如果频率变了 LED 亮度明显变化说明 ARR 和 CCR 没有同步修改公式或代码有问题。7. 常见问题与排查思路PWM 开发中遇到的问题大部分集中在配置错误、公式写错、寄存器生效时机不对这几个方面。下面整理一份排查表格。问题现象可能原因排查方式解决方案引脚无输出波形GPIO 未配置为复用功能查看 CubeMX 生成的 GPIO 初始化代码确认引脚 Mode 为 AF并配置对应 TIMx_CHx引脚恒为低电平未调用 HAL_TIM_PWM_Start检查 main 代码启动前调用 HAL_TIM_PWM_Start(htimx, TIM_CHANNEL_x)引脚恒为高电平CCR 值大于 ARR 或占空比设为 100%打印或调试查看 CCR 和 ARR设置 ccr 小于等于 arr频率与预期不符PSC 或 ARR 计算错误检查公式和 CubeMX 实际值用频率公式倒推注意 PSC 和 ARR 都要加 1修改 ARR 后占空比变化未同步修改 CCR检查代码逻辑按比例重新计算 CCR动态修改频率后波形乱ARR 预装载未开启或更新事件处理不对查看配置加断点观察寄存器开启 AutoReloadPreload必要时触发更新事件高频率下占空比失真分辨率不足ARR 过小降低 PSC 或查看 ARR 实际值在满足频率前提下增大 ARR 以提升分辨率PWM 在停止后引脚电平不确定停止时输出状态未配置查看 HAL_TIM_PWM_Stop 的电平行为按需配置 OCPolarity 和 inactive state或使用 Break 功能在排查 PWM 问题时建议先不要急着看代码而是先回答三个问题定时器时钟是多少PSC 和 ARR 实际写入的是多少CCR 实际写入的是多少只要这三个值明确频率和占空比按公式手算一遍绝大多数问题都能定位。很多人排查了半天最后发现是 CubeMX 里的时钟树把定时器时钟分频了或者自己把 PSC 和 ARR 的值写反了。8. 工程化最佳实践PWM 虽然简单但工程化时有很多细节会影响可靠性和开发效率。下面这些建议来自实际项目经验供你参考。8.1 参数集中定义不要在 main 函数里到处散落定时器参数。建议使用宏或结构体统一管理频率和占空比的默认值例如#define PWM_DEFAULT_FREQ_HZ 1000u #define PWM_DEFAULT_DUTY_PCT 50u #define PWM_MIN_FREQ_HZ 100u #define PWM_MAX_FREQ_HZ 10000u #define PWM_MIN_DUTY_PCT 0u #define PWM_MAX_DUTY_PCT 100u这样后续调参、做边界检查、对接 UI 或协议都有明确入口。8.2 边界检查动态修改占空比时一定要防止写入非法值。例如电机控制中如果 PID 输出异常导致占空比突然为 100%电机可能过流如果为 0%又可能导致动力瞬间中断。更稳妥的做法是在设置函数里加限幅并且在发现非法输入时保留上一次有效值而不是直接写入。8.3 关注 ARR 上限STM32 的通用定时器 ARR 通常是 16 位最大值 65535。如果你想输出非常低的频率比如 1Hz而定时器时钟是 64MHz光靠 ARR 可能不够这时必须加大 PSC。反过来如果你追求高频率ARR 会被压到很小占空比分辨率就会快速下降可能出现“占空比跳变”的情况。这是定时器 PWM 的固有限制设计阶段就要评估清楚。8.4 多个 PWM 通道的同步问题如果用同一个定时器的多个通道输出 PWM它们共享同一个 PSC 和 ARR因此频率必然相同只能分别修改占空比。如果多个通道需要不同频率就必须使用不同定时器。这是定时器资源规划时的重要约束。在多通道场景下如果希望多路 PWM 的占空比在同一个周期内同时生效需要利用定时器的更新事件让所有 CCR 寄存器的更新与周期对齐。HAL 库默认在更新事件后加载新值但如果你在周期中间改 CCR新值会在下一个周期生效看起来是“慢慢移动”这在呼吸灯效果里反而是优点在需要精确同步的场合则需要额外处理。8.5 启动顺序与安全涉及电机、加热器、功率电源等项目PWM 启动前要确保负载状态安全。建议按以下顺序操作先将 CCR 设为 0 或安全值启动定时器 PWM 输出再逐步修改占空比到目标值。停止时反过来先把占空比降为 0再停 PWM。这个顺序可以有效避免上电瞬间或停机瞬间的电流冲击。8.6 结合 DMA 或 ADCPWM 不只能做输出。可以利用 PWM 上升沿触发 ADC 采样实现电流环、电压环的同步采样这在电机控制和电源控制中非常常见。也可以使用 DMA 周期性更新 CCR实现正弦波输出、SVPWM 等复杂波形。这些方向都是 PWM 的进阶用法建议先掌握本文的基础再逐步探索。9. 总结与后续学习方向到这里用 STM32C5A3R 输出 PWM、修改频率和占空比的完整流程已经梳理了一遍。本文从 PWM 的频率、占空比与定时器参数的换算关系切入完整走过了 CubeMX 工程配置、HAL 库 PWM 启动、运行期动态修改 ARR 与 CCR、以及示波器和逻辑分析仪的验证方法最后整理了一份常见问题排查表。核心收获可以概括为三点第一PWM 频率由 PSC 与 ARR 共同决定占空比由 CCR 与 ARR 共同决定第二动态修改频率时必须同步调整 CCR 才能保持占空比不变第三预装载寄存器和更新事件决定了新参数何时生效工业场景要特别关注这个时机。建议你下一步找一个开发板先在 CubeMX 上配出一路 1kHz / 50% 的 PWM然后用示波器测量确认接着试着写一个呼吸灯程序让占空比从 0 缓慢加到 100最后写一个按键切换频率的程序验证修改 ARR 时占空比是否还能保持不变。这个练习做完你对 PWM 的理解会上一个台阶。如果这些已经掌握可以继续研究 PWM 触发 ADC 采样、互补 PWM 加死区、以及 STM32 高分辨率定时器 HRTIM 的用法。这些内容在实际的电机控制、数字电源项目中用得很多也是从“会配置”走向“会设计”的必经之路。