STM32C542R 这类 MCU 的 PWM 输出能力,是电机调速、LED 调光、开关电源控制和信号发生里最常被用到的功能之一。PWM 表面上只有两个参数,频率和占空比,但实际配置时很容易遇到没有波形、频率算错、改占空比不生效等问题。下面从 PWM 的产生链路开始讲,在 STM32CubeMX 里完成一次定时器 PWM 输出,再给出运行时修改频率和占空比的具体代码写法,最后整理常见的排查路径和工程落地建议。
1. 先理解 STM32C542R 的 PWM 输出链路
1.1 PWM 是什么,频率和占空比为什么重要
PWM 的全称是脉冲宽度调制,本质是让 GPIO 在固定周期内,按比例输出高电平和低电平。周期决定频率,高电平时间除以整个周期就是占空比。频率表示波形每秒重复多少次,占空比表示一个周期里有效高电平持续的时间占比。
在电机调速场景里,改变占空比相当于改变平均电压,进而改变转速。在 LED 调光场景里,人眼感知的是平均亮度,所以占空比越高灯越亮。在开关电源场景里,占空比直接决定开关管导通时间和输出电压的关系。频率往往决定噪声、纹波、损耗和系统带宽,占空比决定能量传递大小。因此,PWM 驱动能不能用,核心就是频率准不准、占空比稳定不稳定。
STM32C542R 内部定时器负责产生 PWM,不需要 CPU 每个周期去翻转引脚。定时器从 0 计数到自动重装载值,到达比较值后改变输出电平,到达周期值后重新计数。这个方案的好处是波形对 CPU 占用极低,适合长时间稳定输出。
1.2 PSC、ARR、CCR 三个寄存器如何共同决定波形
STM32 定时器产生 PWM 时,真正起决定作用的是三个参数:
- PSC,预分频器,用来把定时器输入时钟分频。
- ARR,自动重装载值,决定一个计数周期从多少重新开始。
- CCRx,通道比较值,决定通道输出电平什么时候翻转。
以向上计数模式为例,计数器 CNT 从 0 加到 ARR,然后回到 0。在一个周期里,CNT 小于 CCR 时输出高,CNT 到达 CCR 后输出低,CNT 到达 ARR 后重载并进入下一个周期。因此:
PWM 频率 = 定时器时钟 / ((PSC + 1) * (ARR + 1)) PWM 占空比 = CCR / (ARR + 1) * 100%这两个公式是整个 PWM 配置的核心。很多初学者会把 PSC 和 ARR 的关系搞混,比如直接用定时器时钟 / (PSC * ARR)计算频率,结果自然对不上。更稳妥的理解是:PSC + 1是定时器计数频率的分频系数,ARR + 1是一个完整 PWM 周期需要的计数个数。
1.3 修改频率与占空比到底要改谁
从公式可以推出三条结论:
- 只改占空比,只需要改 CCR。
- 只改频率,至少需要改 PSC 或 ARR 中的一个。
- 同时改频率和占空比,需要重新计算 PSC、ARR、CCR 三者。
这里最容易被忽略的是,ARR 改变后,如果 CCR 不跟着变,占空比会变化。比如原来 ARR 是 999,CCR 是 500,占空比是 50%。把 ARR 改成 1999 后,CCR 仍然是 500,占空比就变成约 25%。所以在动态调速时,不能只改周期参数,必须把频率和占空比放到一起重新计算。
2. 开发环境和 CubeMX 配置
2.1 硬件和软件清单
先准备一个最小环境。硬件方面,需要一块带 STM32C542R 芯片的开发板、USB 转串口或调试器、一根杜邦线或者示波器探头。调试和验证 PWM 时,示波器或逻辑分析仪不是可选项,而是关键工具,因为只看灯亮不亮无法判断频率是否准确。
软件方面,需要 STM32CubeMX 生成初始化工程,加上 STM32CubeIDE 或其他支持 HAL 库的编译环境。如果 CubeMX 的器件列表里找不到 STM32C542R,需要先通过 Help 菜单里的 Manage Embedded Software Packages 安装对应器件支持包。
这个示例以 TIM2 的某个通道为例。如果工程中实际使用的是 TIM3、TIM4 或其他通用定时器,配置思路完全一样,只需要把代码里的句柄名从htim2改成对应定时器即可。
2.2 新建工程并配置系统时钟
在 CubeMX 中选择 STM32C542R 后,先进入 System Core 里的 RCC,根据开发板原理图选择 HSE 或 HSI 作为时钟源。时钟树配置页里需要确认定时器最终时钟是多少,因为 PSC、ARR、CCR 的计算都依赖这个值。
这里要注意一个常见坑:STM32 的定时器时钟不一定等于 APB 总线时钟。当 APB 预分频系数不为 1 时,定时器时钟通常等于 APB 时钟的 2 倍。CubeMX 的 Clock Configuration 页面能直接显示定时器时钟,生成工程后也可以通过 HAL 库的 RCC 接口或在调试状态下查看寄存器确认。
为了便于说明,下面示例统一假设定时器时钟为 64 MHz。如果你使用的开发板实际定时器时钟不是这个值,只需要替换计算函数里的时钟常量。
2.3 配置定时器 PWM 通道
在 CubeMX 的 Pinout 视图里选择一个通用定时器,比如 TIM2,然后把某个通道设置为 PWM Generation CH1。如果芯片有多个通道复用,CubeMX 会自动把引脚分配到对应的 GPIO。
进入 TIM2 的 Parameter Settings,按下面的参数配置:
| 参数 | 示例值 | 说明 |
|---|---|---|
| Prescaler | 63 | 对应 PSC,写入寄存器时使用 63 |
| Counter Mode | Up | 向上计数模式 |
| Counter Period | 999 | 对应 ARR,决定 PWM 周期 |
| Internal Clock Division | No Division | 没有额外分频 |
| Auto-reload preload | Enable | ARR 预装载,避免更新时出现毛刺 |
| Channel 1 PWM Mode | PWM Generation CH1 | 使用 PWM 模式 1 |
| Pulse | 500 | 对应 CCR1,初始占空比 50% |
| CH Polarity | High | 高电平有效 |
这个配置在当前假设下会输出 1 kHz、50% 占空比的 PWM:
64 MHz / ((63 + 1) * (999 + 1)) = 1000 Hz 500 / (999 + 1) = 50%2.4 生成代码前的检查点
在点击生成代码之前,先核对几个关键点:
- 定时器时钟来源是否正确,右边显示的 Timer Clock 是否和计算时使用的一致。
- 初始 Pulse 是否在 ARR 范围内,如果 Pulse 大于 ARR,输出可能一直为高。
- GPIO 是否被正确分配到定时器通道,引脚模式自动切换为 Alternate Function。
- 是否需要定时器更新中断。普通 PWM 输出不需要,动态修改参数时如果依赖更新事件回调,再考虑使能 NVIC。
生成代码后,不要急着直接烧录,先打开main.c,找到MX_TIM2_Init里的TIM_OC_InitTypeDef。CubeMX 会根据界面配置把 OCMode、Pulse、OCPolarity 等参数写入代码,核对无误后再继续。
3. 最小 PWM 工程:让波形先跑起来
3.1 生成代码后的核心文件
工程生成后,PWM 相关代码主要分布在两个地方。一个是MX_TIM2_Init,负责定时器基础参数和 PWM 通道参数;另一个是HAL_TIM_MspPostInit或MX_GPIO_Init,负责 GPIO 复用功能配置。
实际使用 PWM 时,不需要手动去初始化 GPIO 复用,CubeMX 已经生成了对应代码。但需要确认在main函数里,MX_GPIO_Init和MX_TIM2_Init都被调用了。如果初始化顺序被调整乱,定时器或引脚可能没有进入正确状态。
3.2 启动 PWM 输出
CubeMX 默认只初始化 PWM,不会自动启动输出。在main函数的 while 循环之前加入这一行:
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);这句话做了两件事,一是让定时器开始计数,二是使能 TIM2 通道 1 的 PWM 输出。启动后,对应 GPIO 会持续输出 1 kHz、50% 占空比的方波。
这里要注意,启动 PWM 使用的是HAL_TIM_PWM_Start,不要写成HAL_TIM_Base_Start。HAL_TIM_Base_Start只启动定时器计数,不会把 PWM 通道连接到输出引脚;如果只调用它,引脚通常没有波形。
3.3 用示波器或逻辑分析仪确认波形
把示波器探头接到 PWM 引脚,地线接 GND,设置时基为 500 us/div 左右,能看到周期约 1 ms 的方波。高电平时间约 500 us,低电平时间约 500 us。
如果只有逻辑分析仪,采样率建议设置成至少 10 MHz,否则容易把边沿采集失真。验证时先看三个指标:
| 指标 | 预期结果 | 说明 |
|---|---|---|
| 频率 | 约 1 kHz | 周期约 1 ms |
| 占空比 | 约 50% | 高电平约 500 us |
| 引脚 | 有稳定翻转 | 不是恒高或恒低 |
在这个阶段确认的是初始化链路正确。之后再进入动态修改频率和占空比的环节。
4. 运行时修改频率和占空比
4.1 只改占空比:更新 CCR
如果频率不变,只改占空比,最简单的方法是修改通道比较值。HAL 库提供了宏:
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 300);在 1 kHz、ARR 为 999 的配置下,把 CCR 改成 300 后,占空比变为约 30%:
300 / 1000 = 30%这个宏本质上是写TIM2->CCR1。在 PWM 已经启动的情况下,修改 CCR 就能改变占空比,不需要停止定时器,也不需要重新配置通道。配合预装载功能,新的比较值会在合适的时刻生效,避免一个周期里出现半个完整周期的异常电平。
4.2 同时修改频率和占空比:反算 PSC、ARR、CCR
同时修改频率和占空比时,需要重新计算三个参数。基本原则是先确定 PWM 周期对应的ARR + 1,再根据定时器时钟算出 PSC,最后根据占空比算出 CCR。
假设定时器时钟是 64 MHz,目标频率是 2 kHz,目标占空比是 30%。如果希望一个 PWM 周期里有 1000 个计数单位,可以这样算:
PSC + 1 = 64 MHz / (2 kHz * 1000) = 32 ARR = 1000 - 1 = 999 PSC = 32 - 1 = 31 CCR = (ARR + 1) * 30% = 300得到PSC = 31、ARR = 999、CCR = 300。实际频率会有少量偏移,因为分频和周期值都是整数,但只要定时器时钟准确,误差通常在可接受范围内。
4.3 示例函数:用整数计算频率和占空比
下面这个函数可以在运行过程中修改频率和占空比。它使用整数运算,避免浮点数带来的不确定性,并先搜索一组可行的 PSC、ARR,再计算 CCR。时钟常量需要根据实际工程替换。
#define TIMER_CLOCK_HZ 64000000u int pwm_set_freq_duty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t freq_hz, uint8_t duty_pct) { uint32_t psc = 0; uint32_t arr = 0; uint32_t ccr = 0; uint64_t period = 0; if (freq_hz == 0 || duty_pct > 100) { return -1; } for (psc = 0; psc < 65535u; psc++) { period = (uint64_t)TIMER_CLOCK_HZ / ((uint64_t)(psc + 1) * freq_hz); if (period >= 2u && period <= 65536u) { arr = (uint32_t)period - 1u; ccr = (uint32_t)((period * duty_pct) / 100u); break; } } if (period < 2u || period > 65536u) { return -2; } __HAL_TIM_SET_PRESCALER(htim, psc); __HAL_TIM_SET_AUTORELOAD(htim, arr); __HAL_TIM_SET_COMPARE(htim, channel, ccr); __HAL_TIM_SET_COUNTER(htim, 0); HAL_TIM_GenerateEvent(htim, TIM_EVENTSOURCE_UPDATE); return 0; }调用方式:
pwm_set_freq_duty(&htim2, TIM_CHANNEL_1, 1000, 50); pwm_set_freq_duty(&htim2, TIM_CHANNEL_1, 2000, 30);关于参数顺序,先写 PSC 和 ARR,再写 CCR,最后让更新事件把预装载的影子寄存器刷新到当前寄存器。这个顺序可以减少参数写入过程中出现不一致状态的概率。
4.4 验证修改后的波形
修改后建议再从示波器上读取一次频率和占空比。不要只看测量值,还要观察波形是否有毛刺、是否有周期突变、占空比是否稳定。如果占空比变成了 100% 或 0%,先检查 CCR 是否超过新的 ARR,或者 duty_pct 是否被错误传成 0 和 100。
在动态调频时,频率变化瞬间可能出现相位跳变。如果应用对相位要求不高,可以直接把计数器清零。如果应用要求平滑过渡,比如电机启动和停止,需要在软件里设计斜坡,让频率或占空比逐步变化,而不是一步到位。
5. 动态调速扩展:串口控制 PWM
5.1 把 pwm_set 接到串口指令
动态修改 PWM 最常见的方式是通过串口下发频率和占空比。CubeMX 里配置 UART 后,可以在main的 while 循环中接收一行指令,然后用sscanf解析频率和占空比。
示例解析逻辑:
uint8_t cmd[32] = {0}; HAL_UART_Receive(&huart1, cmd, sizeof(cmd), HAL_MAX_DELAY); uint32_t freq = 0; uint8_t duty = 0; if (sscanf((char *)cmd, "f=%u,d=%hhu", &freq, &duty) == 2) { pwm_set_freq_duty(&htim2, TIM_CHANNEL_1, freq, duty); }实际工程中还要处理换行符、缓冲区溢出、非法输入和串口粘包问题。上面这段代码只演示核心链路,重点是把频率和占空比这两个用户参数,转换成定时器的 PSC、ARR、CCR 三个内部参数。
5.2 斜坡变化与故障处理
如果用来驱动电机,频率或占空比突变通常会让电流冲击变大,影响机械结构和电路寿命。简单做法是在上位机指令和底层 PWM 之间增加一层斜坡更新逻辑:
uint32_t target_duty = 60; uint32_t current_duty = 20; while (current_duty < target_duty) { current_duty++; pwm_set_freq_duty(&htim2, TIM_CHANNEL_1, 1000, (uint8_t)current_duty); HAL_Delay(10); }这个循环把占空比从 20% 逐步抬升到 60%。在真实项目里,斜坡步长和延时时间需要根据电机惯量和电源能力调整,不能靠主观猜测。
故障处理同样重要。如果使用 PWM 驱动功率器件,建议在硬件上加入过流保护信号,并接到定时器的刹车输入或 MCU 的外部中断。软件侧收到异常后应尽快停止 PWM,而不是继续执行斜坡逻辑。
6. 常见问题与排查方法
6.1 常见现象和原因速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 引脚完全没有波形 | 没有调用 HAL_TIM_PWM_Start | 在启动代码处打断点 | 启动 PWM 输出 |
| 引脚只有恒定高电平 | CCR 大于 ARR,或 PWM 模式配置错误 | 查看 CCR 和 ARR 寄存器 | 重新计算占空比参数 |
| 引脚只有恒定低电平 | 极性配置反了,或 PWM 模式为模式 2 | 查看 OCPolarity 和 OCMode | 改成 High 极性、PWM 模式 1 |
| 频率明显偏大 | 定时器时钟理解错误,少算了 2 倍关系 | 查看 CubeMX 时钟树 | 用定时器时钟重新计算 PSC |
| 频率明显偏小 | PSC 或 ARR 多写了 1 | 代入公式复算 | 检查 PSC、ARR 是否使用寄存器值 |
| 修改占空比后波形不变 | 寄存器没写入正确通道,或写错句柄 | 查看 CCRx 地址 | 确认通道和句柄对应 |
| 修改 PSC 后频率不变 | 没有产生更新事件,预装载没刷新 | 检查是否调用 GenerateEvent | 生成更新事件 |
| 动态调频后出现毛刺 | ARR 和 CCR 更新顺序不对 | 用示波器看切换瞬间 | 先算好参数再一次性更新 |
6.2 按这条链路排查
遇到 PWM 输出问题时,不要直接改寄存器,先按链路逐层确认。
第一步,确认引脚配置。打开 CubeMX 的 Pinout 视图,看 PWM 通道引脚是否正确分配,GPIO Mode 是否为 Alternate Function,GPIO 速度是否足够。
第二步,确认定时器时钟。在调试模式下查看 RCC 相关寄存器,或者回到 CubeMX 时钟树页面确认 TIMx Clock 的实际值。定时器时钟算错,后面的全部计算都会偏。
第三步,确认初始化参数。进入MX_TIM2_Init,检查 PSC、ARR、Pulse 是否和 CubeMX 界面一致。这一步能发现代码覆盖或手动修改造成的参数丢失。
第四步,确认 PWM 是否启动。调用HAL_TIM_PWM_Start后再看波形。如果启动前后寄存器 UDE、CCx