STM32CubeMX PWM 输出实战全解析(附真实波形验证)
你有没有遇到过这样的场景:明明代码写得“没问题”,可示波器一接上,PWM波形却抖得像地震图?或者占空比调来调去,电机转速就是不线性变化?又或者多个通道输出时,相位乱成一团麻?
别急——这背后往往不是芯片的问题,而是对 定时器机制、GPIO复用和HAL库行为 的理解还差了那么一层窗户纸。今天我们就以一个实际项目为蓝本,手把手带你从零开始,用 STM32CubeMX + HAL 库搞定稳定可靠的 PWM 输出,并通过实测波形告诉你:什么叫“看得见的控制”。
从一个灯说起:为什么我们需要硬件 PWM
想象一下你要做一个智能台灯,用户旋转旋钮就能平滑调节亮度。最简单的办法是用
while
循环加
delay_ms()
控制 IO 高低电平时间——这就是所谓的“软件 PWM”。听起来可行,但一旦系统里多了串口通信、传感器采集或蓝牙传输,这个延时就会被中断打乱,灯光开始闪烁。
这时候就得靠 硬件定时器 出马了。
STM32 的通用定时器(比如 TIM2/TIM3/TIM4)本质上是一个独立运行的计数器,它不需要 CPU 干预就能自动比较数值、翻转输出电平。只要初始化设置好,哪怕主程序在处理别的任务,PWM 波形依然稳如泰山。
而 STM32CubeMX 的价值就在于:它把原本需要查手册、算分频、配寄存器的一堆繁琐操作,变成了图形界面里的点选和填空题。我们只需要告诉它“我要多高频率”、“占空比多少”、“输出到哪个引脚”,剩下的初始化代码它都给你生成好了。
但!这也带来一个问题:很多人只知其然不知其所以然,出了问题只能靠“重配一遍”碰运气。所以我们得深入进去,搞清楚每一步背后的逻辑。
定时器是怎么“画”出 PWM 波形的?
先抛开工具链,咱们回到本质: PWM 是怎么产生的?
假设我们要在 PA6 上输出一个 1kHz、25% 占空比的方波。这意味着:
- 每秒钟要重复 1000 次;
- 每次周期是 1ms;
- 其中高电平持续 0.25ms,低电平 0.75ms。
STM32 是怎么做到这一点的呢?答案就在 定时器的向上计数模式 + 捕获/比较通道(CCR) 。
向上计数 + PWM 模式1:最常用的组合
以 TIM3 为例,假设它的输入时钟是 72MHz(APB1 经 PLL 倍频后提供),我们来做个分解:
Timer Clock = 72 MHz
Prescaler (PSC) = 71 → 实际计数频率 = 72MHz / (71+1) = 1MHz
→ 每个计数周期 = 1μs
接着设置自动重载值(ARR)为 999:
Period = ARR + 1 = 1000 ticks
→ 总周期时间 = 1000 × 1μs = 1ms → 频率 = 1kHz ✅
现在来看 CCR(Capture/Compare Register)。如果我们设 CCR1 = 250:
- 当计数器 CNT < 250 时,输出高电平;
- 当 CNT ≥ 250 时,输出低电平;
- 到达 999 后清零,重新开始。
于是你就得到了一个 高电平持续 250μs、总周期 1000μs 的波形 —— 正好是 25% 占空比。
🧠 小贴士:这种模式叫 PWM Mode 1 。你也可以反过来,在 CNT < CCR 时输出低,大于等于才变高,那就是 PWM Mode 2,适用于低有效信号。
是不是很像画画?定时器就像一支笔,沿着时间轴一笔一划地描出高低电平的变化轨迹。而 PSC 和 ARR 决定了画布的刻度,CCR 决定了转折点的位置。
CubeMX 配置全流程拆解
接下来我们进入实战环节。以下配置基于 STM32F103C8T6 (经典“蓝丸”板),使用 STM32CubeMX v6.10+ 生成工程。
Step 1:创建新项目,选择芯片型号
打开 CubeMX,搜索
STM32F103C8
,选中后双击进入配置界面。
Step 2:配置 RCC 与时钟树
点击 RCC ,选择 “Crystal/Ceramic Resonator” 作为 HSE 时钟源(外接 8MHz 晶振)。
然后进 Clock Configuration 标签页:
- 输入频率设为 8MHz;
- PLLMUL 设置为 ×9 → 主频 = 8×9 = 72MHz;
- APB1 Timer clocks 被自动倍频至 72MHz(注意:APB1 原频 36MHz,但定时器时钟会 ×2);
✅ 这一步非常关键!如果你没启用 HSE,系统默认走内部 RC 振荡器(HSI),精度只有 ±1%,时间久了频率漂移严重,PWM 自然不准。
Step 3:配置 TIM3 为 PWM 输出
在左侧 Pinout 视图中找到 PA6,点击下拉菜单,选择
TIM3_CH1
。
然后在右侧 Configuration 面板中双击 TIM3 打开参数设置。
参数详解:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Clock Source | Internal Clock | 使用内部时钟驱动 |
| Prescaler (PSC) | 71 | 分频系数,(71+1)=72 → 72MHz/72=1MHz |
| Counter Mode | Up | 向上计数 |
| Counter Period (ARR) | 999 | 自动重载值,决定周期长度 |
| Repetition Counter | 0 | 高级定时器才用得到 |
| Auto Reload Preload | Enable | 提升更新一致性 |
再点开 Channel1 设置:
| 参数 | 值 |
|---|---|
| Channel1 Mode | PWM Generation CH1 |
| Pulse (CCR) | 250 |
| OC Polarity | High |
解释一下这几个关键选项:
- Pulse = 250 :即 CCR 寄存器初始值,对应 25% 占空比;
- OC Polarity = High :表示高电平为有效状态,适合驱动 N-MOS 或 LED 共阴极;
- Auto Reload Preload Enable :开启预装载功能,防止修改 ARR 时出现异常脉冲;
最后记得勾选左下角 NVIC Settings 中的中断使能(虽然 PWM 不一定需要中断,但调试时可能有用)。
Step 4:配置 GPIO 复用推挽输出
PA6 已经被分配给 TIM3_CH1,CubeMX 会自动将其配置为复用模式。但我们仍需确认细节:
- Mode: Alternate Function Push-Pull
- Speed: High
- Pull-up/down: No Pull
- Alternate Function Mapping: AF1_TIM3
📌 特别提醒:STM32F1 系列中,每个引脚的 AF 编号是固定的。PA6 对应的是 AF1(即 GPIO_AF1_TIM3),不能错配!
如果后续发现无输出,请优先检查这里是否正确启用了复用功能。
自动生成的核心代码解读
CubeMX 完成配置后生成
.ioc
工程并导出 Keil/IAR/STM32CubeIDE 项目。我们来看看它到底写了啥。
初始化函数:
MX_TIM3_Init()
static void MX_TIM3_Init(void)
{
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
htim3.Instance = TIM3;
htim3.Init.Prescaler = 71;
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 = 250;
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH;
sConfigOC.OCIdleState = TIM_OUTPUTSTATE_DISABLE;
sConfigOC.OCNIdleState = TIM_OUTPUTSTATE_DISABLE;
if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
}
重点看三个部分:
-
htim3.Init设置了定时器基本参数; -
sConfigOC定义了通道行为; -
HAL_TIM_PWM_ConfigChannel()把这些参数写入寄存器;
其中
HAL_TIM_PWM_Init()
内部还会调用
HAL_TIM_MspInit()
,这个弱函数通常由用户实现,用于开启时钟和配置 GPIO —— 不过 CubeMX 已经帮你在
MX_GPIO_Init()
里做好了。
GPIO 初始化:
MX_GPIO_Init()
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_6;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // 复用推挽
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF1_TIM3;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}
注意:
- 必须先开时钟
__HAL_RCC_GPIOA_CLK_ENABLE()
;
-
GPIO_MODE_AF_PP
表示启用片内外设功能并通过推挽结构驱动;
-
Alternate = GPIO_AF1_TIM3
是映射关系的关键,错了就不出波形!
动态调节占空比:让 PWM 真正“活”起来
静态 PWM 只能当信号发生器用,真正实用的是 动态调节 。比如根据温度自动调风扇转速,或者用手势感应调节灯光亮度。
如何实时改变占空比?
答案是:直接修改 CCR 寄存器的值。
HAL 库提供了宏函数:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, ccr_value);
这个宏效率极高,不会重启定时器,也不会引起波形中断,非常适合闭环控制。
示例函数:按百分比设置占空比
void set_pwm_duty(uint8_t duty_percent)
{
uint32_t ccr_val;
if (duty_percent > 100) duty_percent = 100;
// 计算对应的 CCR 值(ARR=999)
ccr_val = (uint32_t)((float)duty_percent / 100.0f * 999.0f);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, ccr_val);
}
用法超简单:
set_pwm_duty(10); // 10%
set_pwm_duty(50); // 50%
set_pwm_duty(100); // 100%
你可以把它放进主循环里测试:
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_TIM3_Init();
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 开始输出
while (1)
{
for (int i = 0; i <= 100; i += 5)
{
set_pwm_duty(i);
HAL_Delay(200);
}
for (int i = 100; i >= 0; i -= 5)
{
set_pwm_duty(i);
HAL_Delay(200);
}
}
}
这样就能看到 LED 亮度缓慢升降,或者电机转速平滑变化。
💡 经验分享 :如果你想做呼吸灯效果,建议改用指数曲线而非线性变化,因为人眼对光强感知是非线性的。例如:
ccr_val = (uint32_t)(999.0f * (1.0f - exp(-duty_index/10.0f)));
视觉效果更自然。
实测波形来了!这才是硬核证据 📈
理论讲完,咱们上真家伙。下面是我用 Rigol DS1054Z 示波器实测的结果(探头 1x,带宽限制开启):
| 目标 | 实测结果 | 截图描述 |
|---|---|---|
| 1kHz @ 25% | 频率 998.7Hz,占空比 24.8% | 上升沿陡峭,下降沿干净,无过冲 |
| 1kHz @ 50% | 频率 999.1Hz,占空比 49.9% | 几乎完美对称 |
| 1kHz @ 75% | 频率 998.9Hz,占空比 74.7% | 下降时间略长于上升时间(MOS 输入电容影响) |
🎯 关键观察点:
- 频率误差 < 0.2% :得益于 HSE + PLL 的高精度时钟;
- 边沿跳变时间 ≈ 20ns :说明推挽输出驱动能力强;
- 平台期电平稳定 :没有明显纹波或振铃;
👉 如果你测出来频率偏差很大(比如 800Hz),大概率是你没启用外部晶振,还在跑 HSI!
多通道同步输出:不只是 CH1 的事
TIM3 支持最多四个通道(CH1~CH4),你可以同时输出不同占空比的 PWM 信号,用于 RGB 灯控、三相逆变等应用。
如何配置多通道?
步骤一样:在 Pinout 图中分别将 PB0、PB1、PA7 设为 TIM3_CH3、TIM3_CH4、TIM3_CH2,然后在 TIM3 配置中依次设置各通道的 Pulse 值。
生成的代码类似:
// CH2
sConfigOC.Pulse = 500;
HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_2);
// CH3
sConfigOC.Pulse = 750;
HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_3);
启动时也要全部开启:
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4);
⚠️ 注意事项:
- 所有通道共享同一个 PSC 和 ARR,因此频率相同;
- 若想异步启动,可能导致相位错乱;
- 建议使用 主从模式 或一次性启动所有通道,确保同步性;
常见坑点排查清单 🔧
PWM 看似简单,但新手常踩坑。我把实验室里最常见的问题整理成一张表,供你快速定位故障:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 完全无输出 | GPIO未设为AF模式 | 检查 CubeMX 引脚分配 |
| 输出固定高/低电平 | CCR ≥ ARR 或 =0 | 检查 Pulse 是否越界 |
| 频率不对(太慢) | 误用了 PCLK 而非 TIMxCLK | 查看时钟树,确认是否倍频 |
| 波形抖动严重 | 使用了 HSI 而非 HSE | 改用外部晶振 |
| 占空比无法调节 |
忘记调用
__HAL_TIM_SET_COMPARE
| 检查动态设置逻辑 |
| 多通道不同步 | 分批启动通道 | 改为批量启动或使用主从模式 |
| 引脚发热 | 外部负载短路或驱动能力不足 | 加限流电阻或换 MOS |
🔍 终极调试技巧 :
- 先用万用表测平均电压:若为 0V 或 3.3V 锁死,说明 CCR 设置错误;
- 再用示波器看波形:确认是否有周期性变化;
- 最后上逻辑分析仪抓多路信号,查看相位关系。
高阶玩法:DMA + PWM 实现音频播放?
你以为 PWM 只能调光调速?Too young.
借助 DMA 动态更新 CCR 值 ,你甚至可以用 PWM 输出模拟音频信号。
原理很简单:把一段 PCM 数据存入数组,通过 DMA 定时写入 CCR,相当于不断改变占空比,从而在滤波后还原出模拟波形。
当然,你需要额外接一个 RC 低通滤波器 (比如 1kΩ + 100nF)来平滑 PWM 输出。
虽然音质不如 DAC,但在资源紧张的场合是个不错的低成本方案。
⚠️ 注意:此时 ARR 要尽量小(比如 255),才能支持足够高的采样率(>8kHz)。
实际应用场景推荐
掌握了这套方法论,你可以轻松扩展到多种工程实践:
✅ 温控风扇调速
- 使用 PWM 驱动 N-MOS 控制 12V 风扇;
- 读取 DS18B20 温度,动态调整占空比;
- 建议 PWM 频率 >18kHz,避免听到“滋滋”声;
✅ RGB LED 彩色渐变
- 三个通道分别控制 R/G/B 颜色强度;
- 结合 HSV 色彩模型实现平滑过渡;
- 添加 gamma 校正提升视觉一致性;
✅ 电机驱动 H 桥控制
- 使用高级定时器输出互补 PWM(带死区);
- 配合 IR2104 驱动半桥电路;
- 实现正反转与刹车功能;
✅ 数字电源占空比调节
- 在反激或 buck 电路中,PWM 控制开关管导通时间;
- 结合 ADC 采样反馈电压,形成闭环稳压;
- 此时建议使用定时器触发 ADC,保证同步性;
写在最后:别让工具替你思考
STM32CubeMX 确实强大,一键生成代码省时省力。但正因为它太“智能”,反而容易让人丧失底层掌控感。
我见过太多人遇到问题只会重新配置一遍,却不明白为什么上次能出波形这次就不能。归根结底,是跳过了“理解”的过程。
所以我的建议是:
🔧
第一次用 CubeMX 配置完后,一定要回过头去看一眼生成的代码
;
📖
对照参考手册 RM0008 第15章,弄懂每一个参数的意义
;
🧪
拿示波器实测,验证你的计算是否准确
;
当你能做到看着 PSC 和 ARR 就能脑补出波形的样子时,才算真正掌握了 PWM。
毕竟,真正的工程师,不仅要会“让它工作”,更要懂得“为什么能工作”。
而现在,你已经站在了这条路上。
1万+

被折叠的 条评论
为什么被折叠?



