STM32 零基础可移植教程 11:PWM 输出,让 LED 呼吸起来

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 |

换板子的推荐顺序:

  1. 先看原理图,确认你要控制的 LED 接到哪个引脚;

  2. 在 CubeMX 里确认这个引脚能不能选 TIMx_CHy

  3. 如果板载 LED 不支持 PWM,就外接 LED 到支持 PWM 的引脚;

  4. 配置 TIM Channel 为 PWM Generation CHx

  5. 按 TIMx_CLK 计算 Prescaler 和 Counter Period;

  6. 生成代码后确认有 htimxMX_TIMx_Init()

  7. 修改 APP_PWM_HANDLEAPP_PWM_CHANNEL

  8. 编译下载,先测试固定占空比;

  9. 再运行呼吸效果。

常见问题排查

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 工程。

解决方法:

  1. 右键 Application/User/Core

  2. 选择 Add Existing Files to Group

  3. 添加 Core/Src/app_pwm.c

  4. 重新编译。

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 的用处。

相关推荐
sramdram2 小时前
Cascadeteq国产替代psram芯片,国产psram芯片CSS1604S系列
单片机·嵌入式硬件·psram·cascadeteq·国产替代psram·国产psram芯片
南檐巷上学2 小时前
基于Zynq-7020的带有正弦波发生器的8051软核设计
单片机·嵌入式硬件·fpga开发·fpga
崇山峻岭之间3 小时前
单片机低功耗实验
单片机·嵌入式硬件
周周记笔记3 小时前
【元器件专题】PNP三极管如何搭建开关电路
单片机·嵌入式硬件
不脱发的程序猿3 小时前
如何创建一个标准Skill,让嵌入式经验真正复用起来
人工智能·单片机·嵌入式硬件·嵌入式·skill
czhaii3 小时前
STC8H8K32U工控板运行程序标志位显示
单片机·嵌入式硬件
BT-BOX3 小时前
基于STM32物联网WiFi云平台温湿度烟雾报警器设计
stm32·嵌入式硬件·物联网
小慧10243 小时前
STM 32 TIM定时器(1)
单片机·嵌入式硬件
崇山峻岭之间14 小时前
单片机LCD实验
单片机·嵌入式硬件