在 STM32 嵌入式开发中,PWM(脉冲宽度调制)是最常用的输出方式之一,而 STM32C5A3R 的定时器 PWM 输出往往成为开发者第一个需要同时控制频率和占空比的外设。无论是控制电机转速、调节 LED 亮度、驱动蜂鸣器,还是给电源开关管提供控制信号,PWM 的“频率稳定、占空比可调”都是最基本要求。很多刚接触 STM32 的开发者会尝试在主循环里翻转 GPIO 来模拟 PWM,这种办法虽然能点亮 LED,但频率受软件时序影响,抖动大,无法用于电机控制和开关电源。真正工程化的做法是让定时器硬件独立产生 PWM 波形,CPU 只需要在需要时修改几个寄存器。本文以 STM32C5A3R 为例,介绍从 STM32CubeMX 配置、HAL 库启动 PWM,到运行时修改频率与占空比的完整流程,并给出示波器验证方式与常见问题排查路径。
1. 理解定时器生成 PWM 的工作机制
1.1 PWM 的通俗含义与技术定义
PWM 的本质是用固定频率的方波,通过调整高电平在一个周期内所占的比例,来模拟一个连续可变的“平均输出”。灯泡的亮度、电机的平均电压、蜂鸣器的响度,都可以通过这个比例近似控制。方波的高电平时间与整个周期时间的比值,就是占空比,通常用百分比表示。
从定时器角度来说,PWM 是定时器计数、比较、翻转三个动作自动配合的结果。计数器从 0 向上计数到 ARR(自动重装载值),到达后再回到 0,形成一个周期。在计数过程中,当计数值小于 CCR(比较值)时,输出引脚为一种电平;当计数值达到或超过 CCR 后,输出切换为另一种电平。于是引脚上就输出了一个频率由计数周期决定、占空比由比较值决定的方波。
这个过程完全由硬件完成,不占用 CPU 指令周期。CPU 只需要在业务需要时改写 PSC、ARR、CCR 三个寄存器,就能改变 PWM 的频率与占空比。这是“定时器生成 PWM”与传统“软件翻转 GPIO”最本质的区别。
1.2 STM32C5A3R 的定时器资源与选型
STM32C5A3R 属于 STM32C5 系列,基于 Arm Cortex-M33 内核,定时器外设在结构上延续了 STM32 经典定时器的设计思路。使用前要从芯片数据手册或者 CubeMX 的器件视图中确认具体型号提供了哪几个定时器、每个定时器有几个通道,以及哪些引脚可以复用为 PWM 输出。
在常见 STM32 定时器分类中:
- 基本定时器(例如 TIM6、TIM7):只有时基功能,不能输出 PWM,主要用于定时中断和触发 DAC。
- 通用定时器(例如 TIM2、TIM3、TIM4、TIM5):可以输出多路 PWM,支持输入捕获、输出比较,是日常 PWM 应用的主力。
- 高级定时器(例如 TIM1、TIM8):除了通用定时器的功能,还能输出互补 PWM、插入死区、响应刹车信号,适合电机控制和电源类应用。
实际项目中,如果只是驱动一个 LED 或者蜂鸣器,选用任意一个通用定时器即可;如果后续要扩展成 H 桥电机驱动,需要互补 PWM 和死区,就应该在引脚规划阶段就把高级定时器预留出来。STM32C5A3R 具体有哪些定时器,要以官方数据手册和 CubeMX 中的器件视图为准,不要照搬其他系列的资源列表。
1.3 频率与占空比的计算关系
PWM 频率由定时器时钟频率、预分频系数和自动重装载值共同决定。假设定时器时钟为 f_TIM,经过 PSC 预分频后得到的计数时钟为 f_TIM / (PSC + 1),再除以计数值 (ARR + 1),就得到 PWM 频率:
fPWM = f_TIM / ((PSC + 1) * (ARR + 1))占空比由比较值 CCR 与计数周期 (ARR + 1) 的比值决定:
占空比 = CCR / (ARR + 1) * 100%举例:定时器时钟 64 MHz,PSC = 63,ARR = 999,那么计数时钟是 1 MHz,计数器从 0 数到 999 需要 1000 个计数周期,因此 PWM 频率为 1 MHz / 1000 = 1 kHz。此时如果 CCR = 500,占空比就是 500 / 1000 = 50%。
这里最容易出错的地方是“Counter Period”的实际含义。CubeMX 里填写的 Counter Period 就是 ARR,但真正的周期是 ARR + 1 个计数时钟。所以当寄存器值需要与外部硬件指标对应时,要始终用 (ARR + 1) 和 (PSC + 1) 参与计算,而不是直接用 ARR 和 PSC。
1.4 修改频率和占空比时该动哪些寄存器
修改占空比只需要改 CCR。CCR 越大,高电平时间越长,占空比越高;CCR 等于 ARR + 1 时输出恒为高,CCR 等于 0 时输出恒为低。
修改频率有两个可调变量:PSC 和 ARR。只改 PSC,频率会按整数倍跳变,但占空比百分比不变,因为占空比只由 CCR 与 ARR 的比值决定;只改 ARR,可以做更细的频率调整,但 CCR 不变时占空比百分比会跟着变化。若希望改变频率后占空比百分比仍然保持,需要同步修改 CCR。
| 操作 | 修改寄存器 | 影响 | 注意事项 |
|---|---|---|---|
| 仅调整占空比 | CCR | 高电平时间变化 | CCR 不能超过 ARR,否则输出恒高 |
| 按整数倍改变频率 | PSC | 频率整数倍变化 | 占空比百分比不受影响 |
| 细粒度改变频率 | ARR | 频率连续可调 | 需要同步修改 CCR 才能保持占空比 |
| 同时改变频率和占空比 | PSC、ARR、CCR | 频率和占空比同时变化 | 三者的写入顺序要满足定时器装载规则 |
写入顺序会在后文“修改频率并保持占空比”一节详细展开。先掌握这个寄存器关系,后续排查“为什么改了频率占空比变了”这类问题时就有切入点。
2. 用 STM32CubeMX 完成 PWM 工程的初始化
2.1 环境准备与版本确认
开发 STM32C5A3R 的 PWM 功能,需要先准备以下环境:
| 工具 | 用途 | 注意事项 |
|---|---|---|
| STM32CubeMX | 图形化配置时钟、引脚、外设 | 建议使用 6.x 及以上版本,并安装 STM32C5 器件支持包 |
| STM32CubeIDE 或 Keil MDK | 编写代码、编译下载 | 必须能识别 STM32C5 系列器件 |
| ST 官方固件包 | 提供 HAL 库驱动 | 在 CubeMX 中自动下载或手动安装 |
| ST-Link 调试器 | 下载固件、在线调试 | 确认驱动安装正常 |
| 示波器或逻辑分析仪 | 验证 PWM 波形 | 没有时可用 LED、蜂鸣器做粗略验证 |
这里要特别提醒:STM32C5 属于较新系列,CubeMX 和固件包版本过旧时可能找不到器件。创建工程前先在 CubeMX 的 Help 菜单中确认器件支持包是否安装成功,否则后续“找不到芯片、生成代码失败”等问题会浪费很多时间。
2.2 创建工程并确认器件封装
打开 STM32CubeMX,选择 New Project,在 Part Number 搜索框中输入 STM32C5A3,选中具体器件。如果列表为空,说明器件支持包未安装,需要先点击 Help -> Manage Embedded Software Packages 安装对应的 C5 系列包。
选中器件后,CubeMX 会显示引脚视图。这个视图非常关键,后续所有外设通道和 GPIO 引脚的对应关系,都以这里的分配结果为准。不要凭经验猜测 STM32C5A3R 的 PWM 引脚位置,不同封装和不同型号差异很大。
2.3 时钟树配置的正确检查点
进入 Clock Configuration 标签页,首先确认系统时钟来源。常见做法有外部晶振 HSE 和内部高速时钟 HSI 两种。使用外部晶振时,需要先在 System Core -> RCC 中把 HSE 设置为 Crystal/Ceramic Resonator;使用内部时钟则可以跳过这一步。
时钟树配置完成后,要专门查看定时器时钟。STM32 系列定时器时钟并不总是等于系统时钟,当 APBx 预分频系数大于 1 时,很多型号的定时器时钟是 APBx 时钟的 2 倍。CubeMX 右侧会直接显示 TIMx 的输入时钟频率,这个数值才是计算 PSC、ARR 时真正要用的 f_TIM。判断配置是否正确的检查点是:在 CubeMX 中点击某个定时器时,看到其输入时钟频率与你的预期一致。
2.4 定时器参数配置与生成代码
以通用定时器 TIM3 的通道 1 为例,进入 Timers -> TIM3,按以下步骤配置:
- 在 Clock Source 中选择 Internal Clock。
- 在 Channels 下拉框中将 Channel1 设置为 PWM Generation CH1。
- 切换到 Parameter Settings,填写 PSC、ARR、CCR 等参数。
假设定时器时钟是 64 MHz,需要输出 1 kHz、50% 占空比的 PWM,参数可以这样设置:
| 参数名 | 填写值 | 含义 | 说明 |
|---|---|---|---|
| Prescaler | 63 | 预分频值 PSC | 实际分频系数为 PSC + 1 = 64 |
| Counter Mode | Up | 向上计数模式 | 常用模式,周期从 0 加到 ARR |
| Counter Period | 999 | 自动重装载值 ARR | 实际周期为 ARR + 1 = 1000 |
| Auto-reload preload | Enable | ARR 预装载 | 建议开启,避免更新瞬间出现异常周期 |
| Pulse | 500 | 比较值 CCR | 占空比为 500 / 1000 = 50% |
| CH Polarity | High | 输出极性 | 高电平时表示高电平为有效区间 |
其中 Auto-reload preload 是建议开启的。开启后,修改 ARR 的值不会立即生效,而是在下一次更新事件时统一装载,这样能避免在计数周期中途更换周期导致波形畸变。
配置完成后,在 Project Manager 中选择工具链、填写工程名和路径,点击 Generate Code 生成工程。此时的工程已经包含 TIM3 的初始化代码,但 PWM 还没有开始输出,需要在主程序中启动。
3. 用 HAL 库启动 PWM 并动态修改参数
3.1 生成的初始化代码如何解读
CubeMX 生成代码后,打开 tim.c 文件,可以看到类似下面的初始化函数:
static void MX_TIM3_Init(void) { TIM_OC_InitTypeDef sConfigOC = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 63; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 999; 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(); } }这段代码做了两件事:第一,把定时器配置成向上计数模式,周期设为 999,使能 ARR 预装载;第二,把通道 1 配置成 PWM1 模式,比较值设为 500。可以看到,CubeMX 生成的代码把 PSC、ARR、CCR 都落到了 HAL 结构体对应的字段上,后续修改只需要使用寄存器操作宏即可。
需要说明的是,PSC 和 ARR 在 HAL 结构体中是 16 位无符号整数,取值范围是 0 到 65535。如果你的目标频率很低,导致 (PSC + 1) * (ARR + 1) 超出 32 位可表示范围,就要考虑换用基础时钟更低的定时器,或者接受最低可用频率的边界。
3.2 启动 PWM 输出的正确位置
初始化函数只完成了配置,并没有让引脚真正输出波形。要在 main.c 中调用 HAL_TIM_PWM_Start 函数:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);这句话执行后,通道 1 对应的引脚才会输出 PWM。调用位置通常在 main 函数初始化外设之后、进入主循环之前。
如果使用的是高级定时器 TIM1 或 TIM8,并且希望输出互补 PWM,需要调用 HAL_TIMEx_PWMN_Start 来启动互补通道,同时还要通过 HAL_TIMEx_ConfigBreakDeadTime 等函数配置死区时间。普通单端 PWM 输出只需要基础的 HAL_TIM_PWM_Start,不要混淆。
3.3 运行时修改占空比
PWM 启动后,修改占空比只需要修改 CCR,HAL 提供了专门的宏:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 300);上面这行把通道 1 的 CCR 设置为 300。如果 ARR 仍然为 999,占空比就是 300 / 1000 = 30%。
这里要注意边界条件:CCR 大于 ARR 时,输出会一直保持有效电平;CCR 等于 0 时,输出会一直保持无效电平。这两个边界在控制电机时都非常危险,写入前建议做一次范围检查。
在使能了比较预装载的情况下,修改 CCR 后新值会在下一次更新事件时生效,因此不会出现半周期毛刺。只有在关闭预装载时才可能看到波形瞬间变化。
3.4 运行时修改频率并保持占空比
修改频率比修改占空比复杂一些,因为 PSC、ARR、CCR 三者存在联动。下面给出一个最小函数,输入定时器时钟频率、目标 PWM 频率和目标占空比,自动计算并写入三个寄存器:
void PWM_SetFreqDuty(TIM_HandleTypeDef *htim, uint32_t Channel, uint32_t timer_clock, uint32_t freq_hz, uint8_t duty_percent) { uint32_t psc = 0; uint32_t arr; uint32_t ccr; uint32_t div; if (freq_hz == 0) { return; } div = timer_clock / freq_hz; /* 需要 (PSC+1)*(ARR+1) = div */ if (div <= 1000) { psc = 0; arr = div - 1; } else { psc = div / 1000 - 1; /* 先确定分频,让 ARR 保持在 1000 附近 */ arr = div / (psc + 1) - 1; } if ((psc > 0xFFFF) || (arr > 0xFFFF)) { return; /* 频率太低,超出 16 位定时器范围 */ } __HAL_TIM_SET_PRESCALER(htim, psc); __HAL_TIM_SET_AUTORELOAD(htim, arr); ccr = (uint32_t)((uint64_t)(arr + 1) * duty_percent / 100); __HAL_TIM_SET_COMPARE(htim, Channel, ccr); __HAL_TIM_GENERATE_EVENT(htim, TIM_EVENTSOURCE_UPDATE); }这个函数的策略是:先根据目标频率算出总的分频系数 div,再把 div 拆解成 PSC 和 ARR 两部分。