STM32 零基础可移植教程 11:PWM 输出,让 LED 呼吸起来
上一篇我们用 TIM 周期中断做了一件事:
bash
不用 HAL_Delay(1000),也能每 1 秒翻转一次 LED
那一篇的重点是"定时器到点提醒 CPU"。
这一篇继续用定时器,但换一种玩法:
bash
让定时器自己输出一串高低电平波形
这就是 PWM。
PWM 是 STM32 里非常常用的功能。它可以用来:
-
调 LED 亮度;
-
控制有刷电机速度;
-
控制舵机角度;
-
驱动无源蜂鸣器发声;
-
做一些简单的功率控制。
这一篇只做一个明确目标:
bash
用 TIM PWM 输出,让 LED 亮度从暗到亮、再从亮到暗,形成呼吸效果
无源蜂鸣器、电机、舵机先不讲。
先把"PWM 调 LED 亮度"跑通。
本篇目标
最终现象:
bash
LED 慢慢变亮
LED 慢慢变暗
循环重复
本篇用到的外设:
bash
TIM PWM Output
GPIO Alternate Function
本篇跑通标准:
-
Keil 编译通过;
-
程序能下载到开发板;
-
LED 能看到明显的呼吸效果;
-
能说清楚 PWM 不是软件延时闪烁;
-
能说清楚占空比越大,LED 平均亮度通常越高;
-
能说清楚换 TIM、换通道、换引脚时要改哪里。
准备工作
你需要准备:
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意 STM32 开发板
|
|
下载器
|
ST-LINK/V2 或板载 ST-LINK
|
|
LED
|
推荐先外接 LED 到支持 PWM 的引脚
|
|
限流电阻
|
例如 220Ω、330Ω、1kΩ 都可以先实验
|
|
原理图
|
确认哪个引脚支持 TIMx_CHy
|
|
CubeMX 工程
|
建议从第 10 篇定时器工程复制
|
建议从上一篇 10_timer_basic 复制一份,改名为:
bash
11_pwm_led_breathing
这一篇会继续用 TIM,但不再配置成周期中断,而是配置成 PWM 输出。
这里先提醒一个非常重要的坑:
bash
不是每个 LED 引脚都能输出 PWM
比如很多开发板的板载 LED 接在普通 GPIO 上,可能没有 TIM PWM 复用功能。
如果你发现板载 LED 那个引脚在 CubeMX 里选不到 TIMx_CHy,不要硬改。
建议先外接一个 LED 到支持 PWM 的引脚,比如常见 STM32F103 上可以用:
bash
PA6 -> TIM3_CH1
具体还是以你的芯片和 CubeMX Pinout 为准。
硬件连接
推荐先用外接 LED 跑通。
常见接法:
bash
STM32 PWM 引脚 ---- 限流电阻 ---- LED ---- GND
比如:
bash
PB5/TIM3_CH2 ---- 330Ω ---- LED ---- GND
这种接法通常是:
bash
PWM 高电平时间越长,LED 越亮
也就是占空比越大,亮度越高。
还有一些开发板的 LED 是接到 3.3V 的:
bash
3.3V ---- LED ---- 电阻 ---- STM32 引脚
这种通常是低电平亮。
如果你用这种 LED 做 PWM,可能会发现:
bash
占空比越大,LED 反而越暗
这不是 PWM 错了,而是 LED 有效电平方向反了。
本篇代码里预留了:
bash
#define APP_PWM_INVERT_DUTY 0u
如果亮度方向反了,可以改成:
bash
#define APP_PWM_INVERT_DUTY 1u

PWM 到底是什么
PWM 全称是:
bash
Pulse Width Modulation
中文通常叫:
bash
脉宽调制
名字听起来有点硬,其实可以先这样理解:
bash
PWM 就是在一个固定周期里,控制高电平占了多少时间
比如一个周期是 1 ms。
如果高电平 0.2 ms,低电平 0.8 ms:
bash
占空比 20%
如果高电平 0.5 ms,低电平 0.5 ms:
bash
占空比 50%
如果高电平 0.8 ms,低电平 0.2 ms:
bash
占空比 80%
LED 不是每次都能跟上这么快的亮灭变化。
当 PWM 频率足够高时,人眼看到的是平均亮度:
bash
占空比小 -> 平均亮度低
占空比大 -> 平均亮度高
所以 PWM 调光不是让 LED 慢慢改变电压。
它本质上还是快速开关:
bash
高、低、高、低、高、低......
只是通过改变高电平所占比例,让人眼感觉亮度变了。
PWM 频率怎么配
PWM 也是定时器输出,所以频率仍然和上一篇的公式有关:
bash
PWM Frequency = TIMx_CLK / ((Prescaler + 1) * (Counter Period + 1))
如果你的 TIM 时钟是 72 MHz,可以这样配出 1 kHz PWM:
bash
Prescaler = 72 - 1
Counter Period = 1000 - 1
因为:
bash
72,000,000 / (72 * 1000) = 1000 Hz
也就是:
bash
PWM 周期 = 1 ms
如果你的 TIM 时钟是 8 MHz,可以用:
bash
Prescaler = 8 - 1
Counter Period = 1000 - 1
因为:
bash
8,000,000 / (8 * 1000) = 1000 Hz
为什么这里也有 -1?
还是上一篇讲的原因:
bash
定时器从 0 开始计数,数 N 次就要写 N - 1
所以 Counter Period = 1000 - 1 表示从 0 数到 999,一共 1000 个计数。
这就是我们前面补充的那个口径。
后面 PWM 的 ARR、CCR,也都绕不开"从 0 开始数"这个基本事实。
占空比和 CCR 是什么关系
PWM 里还有一个很关键的值:
bash
Pulse
或者叫:
bash
CCR
Compare
捕获比较值
新手先不用纠结名字。
你可以把它理解成:
bash
在一个 PWM 周期里,高电平持续到哪里
比如我们配置:
bash
Counter Period = 1000 - 1
这表示一个 PWM 周期里有 1000 个计数。
如果 CCR 是 0:
bash
高电平几乎没有
占空比约 0%
如果 CCR 是 250:
bash
高电平占 250 / 1000
占空比约 25%
如果 CCR 是 500:
bash
占空比约 50%
如果 CCR 是 1000:
bash
占空比约 100%
所以本篇代码里不直接让你算 CCR,而是提供一个更容易理解的接口:
bash
App_PWM_SetDutyPermille(500);
这里的 500 表示:
bash
千分之 500 = 50%
1000 表示 100%,0 表示 0%。
代码内部会根据 ARR 自动换算成 CCR。

CubeMX 配置步骤
1. 选择支持 PWM 的引脚
这一步很关键。
不要先想"我想用哪个 LED",而要先确认:
bash
这个引脚能不能配置成 TIMx_CHy
在 CubeMX Pinout 页面点击引脚。
如果你看到类似:
bash
TIM3_CH1
TIM2_CH2
TIM1_CH3
说明这个引脚可以作为定时器 PWM 通道。
本篇示例使用:
bash
PB5 -> TIM3_CH2

2. 配置 TIM3 为 PWM 输出
在 CubeMX 左侧找到:
bash
Timers -> TIM3
把 Channel2 设置为:
bash
PWM Generation CH2
CubeMX 会自动把 PB5 这类引脚配置成复用功能输出。

3. 配置 PWM 频率
如果 TIM3 时钟是 72 MHz,推荐先这样配:
|
配置项
|
推荐值
|
| --- | --- |
|
Prescaler
| 72 - 1 |
|
Counter Mode
|
Up
|
|
Counter Period
| 1000 - 1 |
|
Clock Division
|
No Division
|
|
auto-reload preload
|
Disable
|
这样 PWM 频率约为:
bash
1 kHz
如果 TIM3 时钟是 8 MHz,可以用:
bash
Prescaler = 8 - 1
Counter Period = 1000 - 1
本篇不追求特别高频率,1 kHz 对 LED 呼吸实验已经够用了。

4. 配置 PWM 通道参数
在 TIM3 的 PWM Channel 配置里,常见参数这样设置:
|
配置项
|
推荐值
|
说明
|
| --- | --- | --- |
|
Mode
|
PWM mode 1
|
常用 PWM 模式
|
|
Pulse
|
0
|
初始占空比为 0
|
|
Output compare preload
|
Enable 或 Disable 都可先跑通
|
后续复杂场景再细讲
|
|
Fast Mode
|
Disable
|
先不用快速模式
|
|
CH Polarity
|
High
|
高电平有效输出
|
如果你的 LED 是低电平亮,先不用急着改 Polarity。
你可以先跑起来,再通过本篇代码里的 APP_PWM_INVERT_DUTY 调整亮度方向。

5. 生成 Keil 工程
点击:
bash
GENERATE CODE
打开 Keil 后先编译一次,确认 CubeMX 生成工程没问题。

Keil 工程生成和编译
打开 Keil 后,先编译:
bash
Build / F7
确认:
bash
0 Error(s)
然后看一下 main.c 或相关源文件里有没有:
bash
TIM_HandleTypeDef htim3;
以及初始化函数:
bash
MX_TIM3_Init();
如果你用的是 TIM2,那可能是:
bash
TIM_HandleTypeDef htim2;
MX_TIM2_Init();
后面的代码默认使用:
bash
htim3
TIM_CHANNEL_1
如果你用的是别的 TIM 或别的通道,需要改宏。
完整代码
这一篇新增两个文件:
bash
Core/Inc/app_pwm.h
Core/Src/app_pwm.c
main.c 只负责调用。
1. Core/Inc/app_pwm.h
bash
#ifndef APP_PWM_H
#define APP_PWM_H
#include "main.h"
#include <stdint.h>
void App_PWM_Init(void);
HAL_StatusTypeDef App_PWM_Start(void);
void App_PWM_SetDutyPermille(uint16_t duty_permille);
uint16_t App_PWM_GetDutyPermille(void);
#endif
这里我们用"千分比"设置占空比。
|
参数
|
含义
|
| --- | --- |
| 0 |
0%
|
| 500 |
50%
|
| 1000 |
100%
|
为什么不用浮点数?
因为单片机里很多时候整数更简单、可控,也更适合新手先跑通。
2. Core/Src/app_pwm.c
bash
#include "app_pwm.h"
/*
* Default PWM output is TIM3 Channel 1.
* If your project uses another timer/channel, change these two macros.
*/
#ifndef APP_PWM_HANDLE
#define APP_PWM_HANDLE htim3
#endif
#ifndef APP_PWM_CHANNEL
#define APP_PWM_CHANNEL TIM_CHANNEL_2
#endif
/*
* Set to 1 if your LED is active-low and the brightness appears reversed.
*/
#ifndef APP_PWM_INVERT_DUTY
#define APP_PWM_INVERT_DUTY 0u
#endif
extern TIM_HandleTypeDef APP_PWM_HANDLE;
static uint16_t s_duty_permille = 0;
void App_PWM_Init(void)
{
s_duty_permille = 0;
App_PWM_SetDutyPermille(0);
}
HAL_StatusTypeDef App_PWM_Start(void)
{
return HAL_TIM_PWM_Start(&APP_PWM_HANDLE, APP_PWM_CHANNEL);
}
void App_PWM_SetDutyPermille(uint16_t duty_permille)
{
uint32_t period_counts;
uint32_t compare_counts;
uint32_t output_duty;
if (duty_permille > 1000u)
{
duty_permille = 1000u;
}
s_duty_permille = duty_permille;
#if APP_PWM_INVERT_DUTY
output_duty = 1000u - duty_permille;
#else
output_duty = duty_permille;
#endif
period_counts = __HAL_TIM_GET_AUTORELOAD(&APP_PWM_HANDLE) + 1u;
compare_counts = (period_counts * output_duty) / 1000u;
__HAL_TIM_SET_COMPARE(&APP_PWM_HANDLE, APP_PWM_CHANNEL, compare_counts);
}
uint16_t App_PWM_GetDutyPermille(void)
{
return s_duty_permille;
}
这里有两个移植宏:
bash
#define APP_PWM_HANDLE htim3
#define APP_PWM_CHANNEL TIM_CHANNEL_2
如果你用 TIM2_CH2,就改成:
bash
#define APP_PWM_HANDLE htim2
#define APP_PWM_CHANNEL TIM_CHANNEL_2
如果亮度方向反了,就改:
bash
#define APP_PWM_INVERT_DUTY 1u
代码里这句很关键:
bash
period_counts = __HAL_TIM_GET_AUTORELOAD(&APP_PWM_HANDLE) + 1u;
为什么要 +1?
还是因为 ARR 从 0 开始计数。
如果 ARR 是 999,实际一个周期是 1000 个计数。
所以计算占空比时,要用:
bash
ARR + 1
这和第 10 篇讲的 Counter Period = 1000 - 1 是同一个逻辑。
3. 把 app_pwm.c 加入 Keil 工程
在 Keil 工程树里右键:
bash
Application/User/Core
选择:
bash
Add Existing Files to Group 'Application/User/Core'
加入:
bash
Core/Src/app_pwm.c
如果忘了添加,可能会报:
bash
undefined symbol App_PWM_Start
undefined symbol App_PWM_SetDutyPermille
【截图位置 9:Keil 工程树中 app_pwm.c 已加入】
main.c 调用方式
1. Includes 区域
找到:
bash
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */
改成:
bash
/* USER CODE BEGIN Includes */
#include "app_pwm.h"
/* USER CODE END Includes */
2. 初始化区域
确认 CubeMX 已经生成并调用:
bash
MX_TIM3_Init();
然后在:
bash
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */
里面添加:
bash
/* USER CODE BEGIN 2 */
App_PWM_Init();
App_PWM_Start();
/* USER CODE END 2 */
注意顺序:
bash
先 MX_TIM3_Init()
再 App_PWM_Start()
因为 TIM 和 PWM 通道要先初始化,才能启动输出。
3. while 循环区域
找到:
bash
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
/* USER CODE END 3 */
}
改成:
bash
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
static int32_t duty = 0;
static int32_t step = 10;
App_PWM_SetDutyPermille((uint16_t)duty);
duty += step;
if (duty >= 1000)
{
duty = 1000;
step = -10;
}
else if (duty <= 0)
{
duty = 0;
step = 10;
}
HAL_Delay(10);
/* USER CODE END 3 */
}
这段代码的意思是:
bash
占空比从 0 慢慢加到 1000
再从 1000 慢慢减到 0
循环重复
HAL_Delay(10) 只是控制呼吸变化速度。
它不是在软件模拟 PWM。
PWM 波形是 TIM 硬件自己持续输出的。
主循环只是隔一小段时间改一次占空比。
编译、下载和验证
代码加完后,先编译:
bash
Build / F7
确认:
bash
0 Error(s)
然后下载程序。
正常现象:
bash
LED 慢慢变亮
LED 慢慢变暗
循环重复
如果 LED 完全不亮,先别急着改代码。
先确认:
bash
你接的那个引脚,真的配置成了 TIMx_CHy
这是 PWM 篇最常见的坑。
如果你有示波器,可以量 PWM 引脚。
你应该能看到高低电平快速切换,而且占空比会不断变化。

移植到其他板子的修改点
这篇的移植点主要有 9 个。
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
PWM 引脚
|
不是所有 GPIO 都支持 PWM
|
CubeMX Pinout 和原理图
|
|
TIM 实例
|
不同芯片可用 TIM 不同
|
CubeMX Timers
|
|
PWM 通道
|
CH1/CH2/CH3/CH4 可能不同
|
CubeMX TIM Channel
|
|
Prescaler
|
决定 PWM 频率
|
CubeMX TIM 参数
|
|
Counter Period
|
决定 PWM 周期和调光分辨率
|
CubeMX TIM 参数
|
|
Pulse 初值
|
决定初始占空比
|
CubeMX PWM Channel
|
|
定时器句柄
| htim3
、htim2 不同
| APP_PWM_HANDLE |
|
通道宏
| TIM_CHANNEL_1/2/3/4
不同
| APP_PWM_CHANNEL |
|
LED 有效方向
|
有些低电平亮
| APP_PWM_INVERT_DUTY |
换板子的推荐顺序:
-
先看原理图,确认你要控制的 LED 接到哪个引脚;
-
在 CubeMX 里确认这个引脚能不能选
TIMx_CHy; -
如果板载 LED 不支持 PWM,就外接 LED 到支持 PWM 的引脚;
-
配置 TIM Channel 为
PWM Generation CHx; -
按 TIMx_CLK 计算 Prescaler 和 Counter Period;
-
生成代码后确认有
htimx和MX_TIMx_Init(); -
修改
APP_PWM_HANDLE和APP_PWM_CHANNEL; -
编译下载,先测试固定占空比;
-
再运行呼吸效果。
常见问题排查
1. LED 完全不亮
优先检查:
|
优先检查
|
具体方法
|
| --- | --- |
|
引脚是否支持 PWM
|
CubeMX 是否能选 TIMx_CHy
|
|
LED 是否接反
|
外接 LED 注意方向
|
|
限流电阻是否串联
|
防止电流过大
|
|
是否调用 App_PWM_Start()
|
必须启动 PWM 输出
|
| app_pwm.c
是否加入 Keil
|
Keil 工程树确认
|
|
TIM 句柄是否正确
| APP_PWM_HANDLE
是否和 htimx 一致
|
|
通道是否正确
| APP_PWM_CHANNEL
是否和 CubeMX 通道一致
|
2. LED 一直亮,不呼吸
可能原因:
-
while 循环里的呼吸代码没放进
USER CODE BEGIN 3; -
App_PWM_SetDutyPermille()没有被反复调用; -
HAL_Delay(10)改得太大,看起来变化很慢; -
PWM 引脚其实没有接到 LED;
-
CubeMX 配置的不是 PWM Generation。
可以先手动测试:
bash
设置 0
设置 500
设置 1000
看亮度是否明显变化。
3. 亮度方向反了
现象:
bash
占空比变大,LED 反而变暗
说明 LED 可能是低电平有效。
把:
bash
#define APP_PWM_INVERT_DUTY 0u
改成:
bash
#define APP_PWM_INVERT_DUTY 1u
再编译下载。
4. 编译报 htim3 未定义
说明你的工程没有 htim3。
可能你配置的是 TIM2。
把:
bash
#define APP_PWM_HANDLE htim3
改成:
bash
#define APP_PWM_HANDLE htim2
如果是 TIM4,就改成 htim4。
5. 编译报 undefined symbol App_PWM_Start
通常是 app_pwm.c 没加入 Keil 工程。
解决方法:
-
右键
Application/User/Core; -
选择
Add Existing Files to Group; -
添加
Core/Src/app_pwm.c; -
重新编译。
6. 呼吸效果不顺滑
可能原因有几个:
-
step太大,占空比变化太粗; -
HAL_Delay()太大,变化间隔太长; -
PWM 频率太低,人眼能看到闪烁;
-
LED 本身亮度变化不线性。
可以先试:
bash
step = 5
HAL_Delay(5)
如果 PWM 频率太低,可以把 PWM 频率提高到 1 kHz 或更高。
7. 板载 LED 不能呼吸
很多板载 LED 接的引脚只是普通 GPIO,不一定支持 TIM PWM。
如果 CubeMX 里那个引脚选不到 TIMx_CHy,就不要强行用它做 PWM。
先外接一个 LED 到支持 PWM 的引脚。
等你理解 PWM 后,再根据具体板子找可用通道。
本篇小结
这一篇我们完成了 PWM 输出的第一个实验:
bash
用 TIM PWM 改变 LED 亮度
你现在至少应该知道:
-
PWM 是快速高低电平切换,不是软件延时闪烁;
-
占空比越大,LED 平均亮度通常越高;
-
PWM 频率仍然由 TIM 时钟、Prescaler、Counter Period 决定;
-
ARR 从 0 开始计数,所以实际周期计数是
ARR + 1; -
CCR/Pulse 决定高电平持续多久;
-
HAL_TIM_PWM_Start()用来启动 PWM 输出; -
__HAL_TIM_SET_COMPARE()可以动态修改占空比; -
PWM 必须使用支持
TIMx_CHy复用功能的引脚; -
换板子时重点检查 TIM、通道、引脚、频率和 LED 有效方向。
下一篇我们继续用 PWM:
STM32 无源蜂鸣器唱一声:PWM 频率和占空比怎么调。
LED 呼吸主要改的是占空比。
无源蜂鸣器更关心的是频率。
这两个合起来,你就会真正体会到定时器 PWM 的用处。