STM32 可移植教程 04:定时器 PWM 驱动 RGB LED,按键消抖与事件机制

前三篇我们已经把几个基础能力跑起来了:

  • LED 能亮、能灭;

  • KEY1、KEY2 能读到按键输入;

  • USART printf 能把程序状态打印到电脑上。

这一篇我们加入定时器 PWM,用三路 PWM 分别控制红、绿、蓝三个 LED。同时把按键处理升级为状态机 + 事件消费的写法,让按键「按一次只触发一次」。

我们要做的小工程:

TIM3 输出 3 路 PWM,分别控制红、绿、蓝 LED
KEY1 / KEY2 带消抖的按键状态机
按键事件:按下事件(Pressed_Event)、释放事件(Release_Event)
串口 printf 打印状态
主循环非阻塞轮询

这一篇的重点不是"把所有功能堆在一起",而是让你看到:

  • PWM 怎么控制 LED 亮度;

  • 按键状态机怎么写才稳定;

  • 事件(Event)为什么要「取走即清零」。

图片

本篇目标

最终现象:

上电后:
  串口打印开机信息和当前状态
  红色 LED 以呼吸灯效果运行(可选扩展)

KEY1 / KEY2 按下:
  按键状态机消抖 20ms
  产生 Pressed_Event 或 Release_Event
  串口打印按键事件
  主循环消费事件后清除

全程不使用 HAL_Delay 阻塞

本篇用到的外设:

外设用途
GPIO InputKEY1(PA0)、KEY2(PC13)按键输入,下拉模式
USART1printf 打印状态,DMA 接收
TIM3 PWM三路 PWM(CH2/CH3/CH4)控制 RGB LED

跑通标准:

  • 编译无 error;

  • 下载后串口能看到开机信息;

  • KEY1/KEY2 按下后产生 Pressed_Event,串口打印;

  • 按一次键只触发一次事件(事件消费机制);

  • LED 能通过 PWM 控制亮度。

本篇先不做:

  • 不做 RGB 混色;

  • 不做 TIM2 系统节拍(使用 SysTick + HAL_GetTick);

  • 不做 RTOS;

  • 不追求特别丝滑的视觉曲线,先把 PWM 和按键事件机制的工程结构跑通。

准备工作

你需要先完成前面几篇:

前置内容本篇怎么复用
LED 基础这一篇升级成 RGB LED,底层不再用普通 GPIO 翻转,而是用 PWM 控制亮度
KEY 输入继续使用 KEY1、KEY2 两个按键
USART printf继续用 printf 打印状态
VSCode Makefile 工程本篇继续使用 CubeMX 生成 Makefile,然后在 VSCode 里编译下载

硬件假设:

功能STM32 引脚CubeMX 功能备注
KEY1PA0GPIO_InputPull-down,按下为高
KEY2PC13GPIO_InputPull-down,按下为高
红灯 LED_RPB5TIM3_CH2PWM 输出
绿灯 LED_GPB0TIM3_CH3PWM 输出
蓝灯 LED_BPB1TIM3_CH4PWM 输出
USART1_TXPA9USART1_TX
USART1_RXPA10USART1_RX

这里的 RGB LED 引脚来自你这块 STM32F103ZET6 开发板的原理图。Key 配置为 Pull-down,按下读到高电平(GPIO_PIN_SET)。如果你换板子,先看自己的原理图——按键可能是 Pull-up 按下读到 0,LED 引脚也可能不同。

为什么要引入定时器

前面几篇我们其实已经在做"按时间办事"了:

  • 按键消抖需要等 20ms;

  • LED 闪烁需要隔一段时间翻转;

  • 串口打印不能每圈都打,一般 1 秒打一次。

如果你到处写 HAL_Delay(),程序很快就会变得难受:

HAL_Delay(100);
App_LED_Toggle();
HAL_Delay(100);
printf("status...\r\n");

这样写的问题是:CPU 在 delay 时什么都不干。你按键按下去,程序可能正在 delay,按键事件就会丢掉。

更工程化的做法是:

用 HAL_GetTick() 获取系统毫秒数。
各任务根据时间差来判断自己是不是该执行了。

HAL 库在 HAL_Init() 时会自动配置 SysTick 为 1ms 中断。所有 HAL 延时函数(包括 HAL_Delay)都依赖这个 SysTick。HAL_GetTick() 返回的就是系统启动以来的毫秒数。

本篇在按键消抖中使用 HAL_GetTick() 来判断时间:

uint32_t now = HAL_GetTick();
if (now - key->entry_time > APP_KEY_DEBOUNCE_TIME) {
    // 消抖时间到
}

这就是非阻塞延时的核心思路:记录时间点,每次轮询比较时间差,到了就执行。

图片

STM32 定时器先理解这 4 个东西

新手学定时器很容易被各种名词吓到:基本定时器、通用定时器、高级定时器、输入捕获、输出比较、PWM、编码器模式。

先别急。

这一篇你只需要抓住 4 个词:

名称含义本篇怎么用
CNTCounter,计数器当前数到多少硬件自动增加
PSCPrescaler,预分频器先把定时器时钟变慢
ARRAuto Reload Register,自动重装载值决定数到多少溢出,也决定 PWM 周期
CCRCapture/Compare Register,捕获比较寄存器PWM 模式下决定高电平时间

简单理解:

PSC:让定时器慢一点数
ARR:规定数到哪里算一个周期
CCR:规定 PWM 在周期里的高电平占多少

图片

为什么配置值经常要写 N - 1

这是学定时器最容易卡住的地方。

定时器不是从 1 开始数,而是从 0 开始数。

如果你想让它数 10 次,计数值应该是:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

最后一个值是 9,不是 10。

所以:

想分频 N 次,PSC 写 N - 1
想计数 N 次,ARR 写 N - 1

图片

本篇时钟配置:HSI 8MHz

本篇使用 STM32F103ZET6 的内部高速时钟 HSI(8MHz),没有使用外部晶振和 PLL。

这样配置的好处是:

  • 不依赖外部晶振,任何板子都能跑;

  • 对 LED PWM 来说,8MHz 足够产生 100Hz 以上的 PWM 信号;

  • 时钟配置简单,不容易出错。

CubeMX 默认就是 HSI,本篇的 .ioc 文件保持默认 RCC 配置即可。

系统的时钟树:

HSI = 8MHz
  ├── SYSCLK = 8MHz
  ├── AHB = 8MHz
  ├── APB1 = 8MHz  → TIM3 时钟 = 8MHz(APB1 分频为 1 时,TIMxCLK = APB1)
  └── APB2 = 8MHz

注意:STM32F1 里 TIM3 挂在 APB1 上。如果 APB1 分频不是 1(比如 APB1 = 4MHz),定时器时钟会变成 APB1 时钟的 2 倍。但本篇 APB1 分频 = 1,所以 TIM3 时钟 = 8MHz。

【截图位置 1:CubeMX Clock Configuration,标出 HSI=8MHz、SYSCLK=8MHz、APB1 Timer Clock=8MHz】

PWM 到底是什么

PWM 叫脉宽调制。它不是输出一个真正的"模拟电压",而是在固定周期内快速切换高低电平。

如果周期足够快,人眼看 LED 就不是在闪,而是觉得亮度变了。

占空比越大,LED 看起来越亮:

占空比 20%:一个周期里高电平时间短,比较暗
占空比 50%:一半亮一半灭,中等亮度
占空比 80%:高电平时间长,更亮

图片

在 STM32 定时器里:

ARR 决定 PWM 周期
CCR 决定高电平时间
占空比 = CCR / (ARR + 1)

本篇 TIM3 PWM 的计算:

TIM3 时钟 = 8MHz(来自 HSI)
PSC = 72 - 1 = 71          → 定时器计数频率 = 8MHz / 72 ≈ 111,111 Hz
ARR = 1000 - 1 = 999       → PWM 频率 = 111,111 / 1000 ≈ 111 Hz

CCR 范围:0 ~ 999
CCR = 0    → 占空比 0%(LED 最暗)
CCR = 999  → 占空比 ≈ 100%(LED 最亮)

为什么选 111Hz?因为 PWM 频率只要 > 50Hz 人眼就不会看到闪烁。111Hz 完全足够,而且和 1kHz 相比没有可感知的差别。但如果你后面做蜂鸣器或电机控制,频率就要根据需求重新算。

按键的硬件连接和行为设计

硬件连接

本篇 KEY1(PA0)和 KEY2(PC13)配置为 Pull-down 模式:

默认状态:IO 被下拉电阻拉低,读到 0
按键按下:IO 被拉到 VCC,读到 1

所以 APP_KEY_ACTIVE_LEVEL 定义为 GPIO_PIN_SET(读到 1 表示按键被按下)。

如果你的板子按键是按下接地,应该配置 Pull-up,并把 APP_KEY_ACTIVE_LEVEL 改为 GPIO_PIN_RESET。

按键状态机

按键处理用一个 4 状态的状态机:

IDLE  →  DebouncePress  →  Pressed  →  DebounceRelease  →  IDLE
状态含义
IDLE_State空闲,等待按下
DebouncePress_State检测到按下,等待 20ms 消抖
Pressed_State确认按下(稳定)
DebounceRelease_State检测到松开,等待 20ms 消抖

状态切换条件:

  • IDLE → DebouncePress:读到有效电平

  • DebouncePress → Pressed:消抖时间到(20ms)

  • Pressed → DebounceRelease:读到无效电平

  • DebounceRelease → IDLE:消抖时间到(20ms)

事件机制

状态机告诉你「当前按键处于什么状态」,但不告诉你「刚刚发生了什么」。

比如按键按住期间,状态一直是 Pressed_State。如果你在主循环里判断 if (state == Pressed_State),这件事每个循环周期都会触发一次——按住越久,触发越多次。

所以我们要在状态切换的边沿产生事件(Event):

DebouncePress_State → Pressed_State    :产生 Pressed_Event
DebounceRelease_State → IDLE_State     :产生 Release_Event

事件产生后存储在 event 字段里,被读取后立刻清零。这就是事件消费(Event Consumption)机制。

图片

CubeMX 配置步骤

1. 创建工程

打开 CubeMX,选择芯片 STM32F103ZETx,创建新工程命名为:

04_timer_pwm_rgb

Project Manager 中 Toolchain / IDE 选 Makefile。

2. 系统时钟

保持默认 HSI 配置即可。RCC 中 High Speed Clock (HSI) 已默认启用。

3. 配置 USART1

沿用第三篇配置:

USART1 -> Asynchronous
Baud Rate -> 115200
Word Length -> 8 Bits
Parity -> None
Stop Bits -> 1

如果需要 DMA 接收,在 DMA 选项卡中添加 USART1_RX 的 DMA 通道。

4. 配置 GPIO 按键

引脚ModePullUser Label
PA0GPIO_InputPull-downKEY1
PC13GPIO_InputPull-downKEY2

5. 配置 TIM3 为三路 PWM

在 CubeMX 左侧找到:

Timers -> TIM3

勾选:

Clock Source -> Internal Clock
Channel2 -> PWM Generation CH2
Channel3 -> PWM Generation CH3
Channel4 -> PWM Generation CH4

TIM3 参数:

Prescaler      -> 72 - 1  (= 71)
Counter Mode   -> Up
Counter Period -> 1000 - 1 (= 999)

三个 PWM Channel 的参数相同:

Mode     -> PWM mode 1
Pulse    -> 0(默认占空比 0%,上电不亮)
Polarity -> High

对应引脚(CubeMX 自动分配):

TIM3 通道引脚连接
TIM3_CH2PB5LED_R
TIM3_CH3PB0LED_G
TIM3_CH4PB1LED_B

图片

图片

6. 生成代码

点击 GENERATE CODE。

生成后检查 Core/Src/main.c 里是否出现:

TIM_HandleTypeDef htim3;

static void MX_TIM3_Init(void);

以及 main() 里是否调用了 MX_TIM3_Init()。

VSCode / Makefile 需要检查哪里

新建 .c 文件后,Makefile 里必须把它们加入 C_SOURCES。

本篇的 Makefile 中 C_SOURCES 包含了:

C_SOURCES =  \
Core/Src/main.c \
Core/Src/stm32f1xx_it.c \
Core/Src/stm32f1xx_hal_msp.c \
Core/Src/app_led.c \
Core/Src/app_key.c \
Core/Src/app_usart.c \
...

如果你新增了 .c 文件忘记加,编译时会报:

undefined reference to `App_LED_Init'

这不是函数名写错,而是 .c 文件没有参与编译。去 Makefile 里加上即可。

完整代码:app_led(RGB LED 模块)

LED 模块负责用 PWM 控制单颗 LED 的亮度。

PWM 模式下控制 LED 不需要翻转 GPIO,而是直接往 CCR 寄存器写占空比值:

  • __HAL_TIM_SET_COMPARE(handle, channel, 0) → 占空比 0%,LED 最暗

  • __HAL_TIM_SET_COMPARE(handle, channel, 999) → 占空比 100%,LED 最亮

Core/Inc/app_led.h

#ifndef APP_LED_H
#define APP_LED_H
#include "main.h"

typedef struct
{
    TIM_HandleTypeDef* handle;
    uint32_t channel;
    uint8_t status;          // 1=亮, 0=灭
} App_Led;

void APP_LED_INIT(App_Led* led, TIM_HandleTypeDef* handle, uint32_t channel);
void APP_LED_ON(App_Led* led);
void APP_LED_OFF(App_Led* led);
void APP_LED_TOGGLE(App_Led* led);

#endif

Core/Src/app_led.c

#include "app_led.h"

void APP_LED_INIT(App_Led *led, TIM_HandleTypeDef *handle, uint32_t channel)
{
    led->handle = handle;
    led->channel = channel;
    APP_LED_OFF(led);
}

void APP_LED_ON(App_Led *led)
{
    __HAL_TIM_SET_COMPARE(led->handle, led->channel, 0);
    led->status = 1;
}

void APP_LED_OFF(App_Led *led)
{
    __HAL_TIM_SET_COMPARE(led->handle, led->channel, 999);
    led->status = 0;
}

void APP_LED_TOGGLE(App_Led *led)
{
    if (led->status == 1) {
        APP_LED_OFF(led);
    } else {
        APP_LED_ON(led);
    }
}

注意 APP_LED_ON 写 CCR = 0,APP_LED_OFF 写 CCR = 999。

为什么这样写?这取决于 LED 的硬件连接方式:

  • 如果你板子上的 LED 是低电平点亮(LED 阳极接 VCC,阴极接 GPIO),CCR = 0 时输出一直为低 → LED 亮。CCR = 999 时输出一直为高 → LED 灭。

  • 如果你的 LED 是高电平点亮,那 APP_LED_ON 和 APP_LED_OFF 里的 CCR 值就要对调。

这也是为什么后面我们建议加一个 APP_LED_SET_BRIGHTNESS 函数,并配合 APP_RGB_ACTIVE_LOW 这样的宏来处理电平反转。

移植提示:如果你发现调用 APP_LED_ON 后 LED 反而灭了,把 APP_LED_ON 里的 0 和 999 互换即可。或者更规范的做法是加一个宏 [#define](javascript:;) APP_LED_ACTIVE_LOW 1 来统一处理。

完整代码:app_key(按键状态机 + 事件)

按键模块是本篇最值得仔细看的部分。它实现了:

  1. 4 状态消抖状态机:IDLE → DebouncePress → Pressed → DebounceRelease

  2. 边沿事件:在状态切换的瞬间产生 Pressed_Event / Release_Event

  3. 事件存储:事件存在 event 字段中,等待调用者取走

Core/Inc/app_key.h

#ifndef APP_KEY_H
#define APP_KEY_H
#include "main.h"

#ifndef APP_KEY_ACTIVE_LEVEL
#define APP_KEY_ACTIVE_LEVEL GPIO_PIN_SET
#endif

#ifndef APP_KEY_DEBOUNCE_TIME
#define APP_KEY_DEBOUNCE_TIME 20u
#endif

/*
状态机:
Idle_State  ->  DebouncePress_State  ->  Pressed_State  ->  DebounceRelease_State  ->  Idle_State
*/

typedef enum {
    IDLE_State = 0,
    DebouncePress_State,
    Pressed_State,
    DebounceRelease_State
} KeyState;

typedef enum {
    Idle_Event = 0,
    Pressed_Event,
    Release_Event
} KeyEvent;

typedef struct {
    GPIO_TypeDef* port;
    uint16_t pin;
    uint8_t current_state;
    uint32_t entry_time;
    uint8_t event;           // 待消费的事件
} App_Key;

void APP_KEY_INIT(App_Key *key, GPIO_TypeDef* port, uint16_t pin);
void App_Key_Tick(App_Key *key);

#endif

Core/Src/app_key.c

#include "app_key.h"

/*
状态机:
Idle_State  ->  DebouncePress_State  ->  Pressed_State  ->  DebounceRelease_State  ->  Idle_State
*/

void APP_KEY_INIT(App_Key *key, GPIO_TypeDef *port, uint16_t pin)
{
    key->current_state = IDLE_State;
    key->port = port;
    key->pin = pin;
    key->event = Idle_Event;
}

void App_Key_Tick(App_Key *key)
{
    uint8_t raw = HAL_GPIO_ReadPin(key->port, key->pin);
    uint32_t now = HAL_GetTick();
    switch (key->current_state)
    {
    case IDLE_State:
        if (raw == APP_KEY_ACTIVE_LEVEL)
        {
            key->current_state = DebouncePress_State;
            key->entry_time = now;
        }
        break;

    case DebouncePress_State:
        if (now - key->entry_time > APP_KEY_DEBOUNCE_TIME)
        {
            key->current_state = Pressed_State;
            key->event = Pressed_Event;      // 产生按下事件
        }
        break;

    case Pressed_State:
        if (raw != APP_KEY_ACTIVE_LEVEL)
        {
            key->current_state = DebounceRelease_State;
            key->entry_time = now;
        }
        break;

    case DebounceRelease_State:
        if (now - key->entry_time > APP_KEY_DEBOUNCE_TIME)
        {
            key->current_state = IDLE_State;
            key->event = Release_Event;      // 产生释放事件
        }
        break;

    default:
        break;
    }
}

事件如何被消费

状态机在 DebouncePress_State → Pressed_State 时写入 Pressed_Event。但写入后,如果调用者不清理,这个事件就会一直保留。

所以调用者读到事件后,必须立刻清零——这就是事件消费。你应该在 app_key.c 中再提供一个「读取并消费」的函数:

uint8_t App_Key_GetEvent(App_Key *key)
{
    uint8_t e = key->event;
    key->event = Idle_Event;    // 取走即清零
    return e;
}

对应的头文件声明加在 app_key.h 中 App_Key_Tick 下面:

uint8_t App_Key_GetEvent(App_Key *key);

这样调用者在主循环里就可以这样用:

if (App_Key_GetEvent(&s_key1) == Pressed_Event) {
    // 按一次 KEY1,这里只执行一次
}

如果你忘了调用 App_Key_GetEvent 而是直接读 s_key1.event,就不会自动清零,事件会一直保持。

完整代码:app_usart(串口 printf)

串口模块保持简单——只负责把 printf 重定向到 USART1。

Core/Inc/app_usart.h

#ifndef APP_USART_H
#define APP_USART_H
#include "main.h"

#endif

这个头文件目前只有 include 保护,没有暴露函数。printf 的重定向实现在 .c 文件里,不需要额外声明。

Core/Src/app_usart.c

#include "app_usart.h"
#include <stdio.h>
#include <string.h>

#ifndef APP_UART_HANDLE
#define APP_UART_HANDLE huart1
#endif

extern UART_HandleTypeDef APP_UART_HANDLE;

int __io_putchar(int ch)
{
    if (ch == '\n') {
        uint8_t cr = '\r';
        HAL_UART_Transmit(&APP_UART_HANDLE, &cr, 1u, HAL_MAX_DELAY);
    }

    uint8_t data = (uint8_t)ch;
    HAL_UART_Transmit(&APP_UART_HANDLE, &data, 1u, HAL_MAX_DELAY);

    return ch;
}

int fputc(int ch, FILE *f)
{
    return __io_putchar(ch);
}

这里面几个要点:

  1. **__io_putchar**:这是 arm-none-eabi newlib 的半主机(semihosting)接口。重写它,printf 就会走你的串口而不是调试器。

  2. **fputc**:标准 C 库的字符输出函数,printf 最终调用它。我们让它也走 __io_putchar。

  3. **\n → \r\n**:串口终端需要 \r\n 才能正常换行。__io_putchar 在遇到 \n 时自动补一个 \r。

  4. **HAL_MAX_DELAY**:阻塞发送,直到数据发完。对于打印调试信息来说,阻塞是可以接受的。但在中断里不要调用 printf。

main.c 调用方式

本篇 main.c 负责:

  • 初始化 TIM3 PWM 并启动三个通道;

  • 初始化三个 LED 并绑定到对应通道;

  • 初始化两个按键;

  • 在主循环中轮询按键状态机、消费按键事件。

1. Includes 区域

在 main.c 中找到:

/* USER CODE BEGIN Includes */
/* USER CODE END Includes */

改成:

/* USER CODE BEGIN Includes */
#include "app_led.h"
#include "app_key.h"
#include "app_usart.h"
#include <stdio.h>
/* USER CODE END Includes */

2. Private variables 区域

找到:

/* USER CODE BEGIN PV */
/* USER CODE END PV */

加入:

/* USER CODE BEGIN PV */
static App_Led s_led_r, s_led_g, s_led_b;
static App_Key s_key1, s_key2;
/* USER CODE END PV */

这里用 s_ 前缀表示 static 变量,led_r/led_g/led_b 分别对应红、绿、蓝。

3. 初始化区域

找到 main() 里的:

/* USER CODE BEGIN 2 */
/* USER CODE END 2 */

改成:

/* USER CODE BEGIN 2 */
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_3);
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_4);

APP_LED_INIT(&s_led_r, &htim3, TIM_CHANNEL_2);
APP_LED_INIT(&s_led_g, &htim3, TIM_CHANNEL_3);
APP_LED_INIT(&s_led_b, &htim3, TIM_CHANNEL_4);

APP_KEY_INIT(&s_key1, KEY1_GPIO_Port, KEY1_Pin);
APP_KEY_INIT(&s_key2, KEY2_GPIO_Port, KEY2_Pin);

printf("\r\n");
printf("================================\r\n");
printf(" STM32 Timer PWM RGB Demo\r\n");
printf(" KEY1/KEY2: press to test\r\n");
printf("================================\r\n");
/* USER CODE END 2 */

注意顺序:

  1. CubeMX 生成的 MX_GPIO_Init()、MX_TIM3_Init() 等必须先执行(它们在 USER CODE BEGIN 2 之前);

  2. 然后手动启动 PWM(HAL_TIM_PWM_Start);

  3. 再初始化 LED 和按键;

  4. 最后用 printf 打印开机信息。

4. while 循环

找到:

while (1)
{
  /* USER CODE END WHILE */

  /* USER CODE BEGIN 3 */
  /* USER CODE END 3 */
}

改成:

while (1)
{
  /* USER CODE END WHILE */

  /* USER CODE BEGIN 3 */
  App_Key_Tick(&s_key1);
  App_Key_Tick(&s_key2);

  if (App_Key_GetEvent(&s_key1) == Pressed_Event)
  {
      APP_LED_TOGGLE(&s_led_g);
      printf("[KEY1] Pressed_Event\r\n");
  }

  if (App_Key_GetEvent(&s_key2) == Pressed_Event)
  {
      APP_LED_TOGGLE(&s_led_b);
      printf("[KEY2] Pressed_Event\r\n");
  }
  /* USER CODE END 3 */
}

这个主循环的逻辑:

每一圈:
  1. 驱动 KEY1 状态机(消抖 + 状态切换 + 事件产生)
  2. 驱动 KEY2 状态机
  3. 消费 KEY1 事件 → 翻转绿灯 + 打印
  4. 消费 KEY2 事件 → 翻转蓝灯 + 打印

没有 HAL_Delay,没有阻塞。按键状态机使用 HAL_GetTick() 做消抖定时,主循环可以在任意时刻调用 App_Key_Tick,不会丢事件。

验证效果

按下 KEY1:

  • 绿灯翻转一次

  • 串口输出 [KEY1] Pressed_Event

  • 再按一次,绿灯再翻转一次

按下 KEY2:

  • 蓝灯翻转一次

  • 串口输出 [KEY2] Pressed_Event

按住 KEY1 不放:

  • 只触发一次 Pressed_Event(因为事件被消费了,后续 event 为 Idle_Event)

  • 直到松手后产生 Release_Event

这就是事件消费机制的核心价值:按一次,触发一次。

编译、下载和验证

编译

在 VSCode 里按:

Ctrl + Shift + B

或者终端执行:

make -j8

下载

如果你已经配置好 OpenOCD,可以按 F5 调试,或者执行:

make flash

串口验证

打开串口助手:

COM 口:你的 USB 转串口模块对应的 COM
波特率:115200
数据位:8
停止位:1
校验:None

正常上电后,应该看到类似:

================================
 STM32 Timer PWM RGB Demo
 KEY1/KEY2: press to test
================================
[KEY1] Pressed_Event
[KEY2] Pressed_Event
[KEY1] Pressed_Event

从这篇开始建立"事件消费"的习惯

按键事件如果只写不消费,就会一直触发。这是很多初学者在按键处理上踩的坑。

❌ 错误:直接读 current_state
   if (key->current_state == Pressed_State) { ... }
   → 按住按键期间,每圈都触发

✅ 正确:读取事件 + 清零
   if (App_Key_GetEvent(&key) == Pressed_Event) { ... }
   → 只在按下边沿触发一次

这个「取走即清零」的模式不仅用于按键,后面做串口命令解析、传感器报警、CAN 消息队列等场景也会反复出现。

扩展:呼吸灯

掌握了 PWM 控制 LED 亮度的基础后,你可以自然地扩展出呼吸灯效果:

这部分不再展开,代码结构和你已经写好的按键事件消费模式完全一致:用 HAL_GetTick() 做非阻塞定时,用事件驱动状态变化。

移植到其他板子的修改点

要改的地方为什么要改在哪里改
RGB LED 引脚不同板子的红绿蓝 LED 不一定接 PB5/PB0/PB1CubeMX Pinout
TIM3 通道LED 对应的 PWM 通道可能不同CubeMX 和 main.c 初始化
LED 有效电平有些 LED 高电平亮,有些低电平亮app_led.c 中 ON/OFF 的 CCR 值
按键引脚KEY1/KEY2 可能不是 PA0/PC13CubeMX GPIO 和 main.h 的宏
按键有效电平按下可能读到 0(Pull-up),也可能读到 1(Pull-down)APP_KEY_ACTIVE_LEVEL
按键消抖时间不同按键抖动时间可能不同APP_KEY_DEBOUNCE_TIME
TIM 时钟不同工程的 TIMxCLK 可能不是 8MHzCubeMX Clock Configuration,重新计算 PSC/ARR
串口实例有些板子 USART1 被占用CubeMX 选 USART2/3,app_usart.c 改句柄

移植顺序建议:

  1. 先确认 RGB LED 三个引脚能不能配置成 PWM;

  2. 再确认 TIMx 的通道映射;

  3. 再确认 LED 高/低电平有效;

  4. 再接入 KEY 和 USART。

不要一上来就把所有功能全打开。移植时建议按顺序验证:

TIM3 单色 PWM → 三色 PWM → KEY 消抖 + 事件 → 串口打印

常见问题排查

1. LED 完全不亮

按这个顺序查:

优先级检查点方法
1TIM3 PWM 有没有启动HAL_TIM_PWM_Start 是否在 USER CODE BEGIN 2 调用
2通道是否对应红绿蓝通道是否和原理图一致
3引脚复用是否正确CubeMX Pinout 看 PB5/PB0/PB1 是否为 TIM3_CHx
4LED 有效电平是否反了交换 APP_LED_ON 和 APP_LED_OFF 中的 0 和 999

2. 按键按一次触发多次

这就是本篇重点讲的事件消费问题。检查:

  • 是否使用了 App_Key_GetEvent() 而不是直接读 key->current_state?

  • App_Key_GetEvent() 内部是否返回事件后清零?

  • 事件 event 字段是否在 APP_KEY_INIT 时初始化为 Idle_Event?

3. 按住按键不放会怎样

  • App_Key_Tick 驱动状态机:IDLE → DebouncePress → Pressed(停留)

  • Pressed_Event 只产生一次(从 DebouncePress 进入 Pressed 的瞬间)

  • 被消费后 event 变回 Idle_Event

  • 松手时:Pressed → DebounceRelease → IDLE,产生 Release_Event

如果你希望在按住期间重复触发(比如长按连发),需要在 Pressed 状态下增加额外的定时逻辑。但这是另一个需求,本篇不展开。

4. 串口没有输出

优先确认:

  • USART1 是否仍然是 115200 8N1;

  • app_usart.c 是否加入 Makefile;

  • USB 转串口模块 TX/RX 是否交叉;

  • GND 是否共地;

  • printf 的 fputc 是否只有一份定义。

5. 按键完全没反应

检查:

  • CubeMX 里 PA0/PC13 是否配置成 GPIO Input;

  • Pull-down 是否和原理图一致;

  • KEY1_GPIO_Port、KEY1_Pin 是否在 main.h 里正确生成;

  • App_Key_Tick() 是否在主循环里被调用;

  • APP_KEY_ACTIVE_LEVEL 是否写反。

本篇小结

这一篇我们把前面的 LED、KEY、USART 和定时器 PWM 整合在一起。

你现在应该理解:

  • TIM3 可以输出 PWM 控制 LED 亮度;

  • PSC 负责预分频,ARR 负责周期,CCR 负责占空比;

  • N - 1 是因为定时器从 0 开始数;

  • 按键状态机有 4 个状态:空闲 → 消抖按下 → 按下 → 消抖释放;

  • 事件(Event)是在状态切换边沿产生的,不是状态本身;

  • 事件必须被消费(读取后清零),否则会一直触发;

  • HAL_GetTick() 来自 SysTick,可以替代 HAL_Delay 做非阻塞定时;

  • 主循环没有阻塞代码,所有任务都可以公平轮询。

这一篇搭建的「状态机 + 事件」结构是后续工程的地基。后面无论做串口命令解析、传感器报警、还是菜单导航,都可以沿用这个模式。

下一篇预告

下一篇我们继续沿着 PWM 往下走:

STM32 可移植教程 05:PWM 控制无源蜂鸣器,频率、占空比和声音到底是什么关系。

这一篇我们用 PWM 控制 LED 亮度,下一篇就用 PWM 控制蜂鸣器频率。到时候你会看到:同样是 PWM,用在 LED 上是亮度,用在蜂鸣器上就是声音。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐