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

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

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

  • LED 能亮、能灭;

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

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

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

我们要做的小工程:

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

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

  • PWM 怎么控制 LED 亮度;

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

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

本篇目标

最终现象:

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

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

全程不使用 HAL_Delay 阻塞

本篇用到的外设:

外设 用途
GPIO Input KEY1(PA0)、KEY2(PC13)按键输入,下拉模式
USART1 printf 打印状态,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 功能 备注
KEY1 PA0 GPIO_Input Pull-down,按下为高
KEY2 PC13 GPIO_Input Pull-down,按下为高
红灯 LED_R PB5 TIM3_CH2 PWM 输出
绿灯 LED_G PB0 TIM3_CH3 PWM 输出
蓝灯 LED_B PB1 TIM3_CH4 PWM 输出
USART1_TX PA9 USART1_TX
USART1_RX PA10 USART1_RX

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

为什么要引入定时器

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

  • 按键消抖需要等 20ms;

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

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

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

bash 复制代码
HAL_Delay(100);
App_LED_Toggle();
HAL_Delay(100);
printf("status...\r\n");

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

更工程化的做法是:

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

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

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

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

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

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

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

先别急。

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

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

简单理解:

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

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

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

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

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

bash 复制代码
0, 1, 2, 3, 4, 5, 6, 7, 8, 9

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

所以:

bash 复制代码
想分频 N 次,PSC 写 N - 1
想计数 N 次,ARR 写 N - 1

本篇时钟配置:HSI 8MHz

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

这样配置的好处是:

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

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

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

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

系统的时钟树:

bash 复制代码
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 看起来越亮:

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

在 STM32 定时器里:

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

本篇 TIM3 PWM 的计算:

bash 复制代码
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 模式:

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

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

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

按键状态机

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

bash 复制代码
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):

bash 复制代码
DebouncePress_State → Pressed_State    :产生 Pressed_Event
DebounceRelease_State → IDLE_State     :产生 Release_Event

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

CubeMX 配置步骤

1. 创建工程

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

bash 复制代码
04_timer_pwm_rgb

Project Manager 中 Toolchain / IDE 选 Makefile

2. 系统时钟

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

3. 配置 USART1

沿用第三篇配置:

bash 复制代码
USART1 -> Asynchronous
Baud Rate -> 115200
Word Length -> 8 Bits
Parity -> None
Stop Bits -> 1

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

4. 配置 GPIO 按键

引脚 Mode Pull User Label
PA0 GPIO_Input Pull-down KEY1
PC13 GPIO_Input Pull-down KEY2

5. 配置 TIM3 为三路 PWM

在 CubeMX 左侧找到:

bash 复制代码
Timers -> TIM3

勾选:

bash 复制代码
Clock Source -> Internal Clock
Channel2 -> PWM Generation CH2
Channel3 -> PWM Generation CH3
Channel4 -> PWM Generation CH4

TIM3 参数:

bash 复制代码
Prescaler      -> 72 - 1  (= 71)
Counter Mode   -> Up
Counter Period -> 1000 - 1 (= 999)

三个 PWM Channel 的参数相同:

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

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

TIM3 通道 引脚 连接
TIM3_CH2 PB5 LED_R
TIM3_CH3 PB0 LED_G
TIM3_CH4 PB1 LED_B

6. 生成代码

点击 GENERATE CODE

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

bash 复制代码
TIM_HandleTypeDef htim3;

static void MX_TIM3_Init(void);

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

VSCode / Makefile 需要检查哪里

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

本篇的 Makefile 中 C_SOURCES 包含了:

bash 复制代码
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 文件忘记加,编译时会报:

bash 复制代码
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

bash 复制代码
#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

bash 复制代码
#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_ONAPP_LED_OFF 里的 CCR 值就要对调。

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

移植提示:如果你发现调用 APP_LED_ON 后 LED 反而灭了,把 APP_LED_ON 里的 0999 互换即可。或者更规范的做法是加一个宏 [#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

bash 复制代码
#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

bash 复制代码
#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 中再提供一个「读取并消费」的函数:

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

对应的头文件声明加在 app_key.hApp_Key_Tick 下面:

bash 复制代码
uint8_t App_Key_GetEvent(App_Key *key);

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

bash 复制代码
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

bash 复制代码
#ifndef APP_USART_H
#define APP_USART_H
#include "main.h"

#endif

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

Core/Src/app_usart.c

bash 复制代码
#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 中找到:

bash 复制代码
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */

改成:

bash 复制代码
/* 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 区域

找到:

bash 复制代码
/* USER CODE BEGIN PV */
/* USER CODE END PV */

加入:

bash 复制代码
/* 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() 里的:

bash 复制代码
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */

改成:

bash 复制代码
/* 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 循环

找到:

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 */
  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 */
}

这个主循环的逻辑:

bash 复制代码
每一圈:
  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 里按:

bash 复制代码
Ctrl + Shift + B

或者终端执行:

bash 复制代码
make -j8

下载

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

bash 复制代码
make flash

串口验证

打开串口助手:

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

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

bash 复制代码
================================
 STM32 Timer PWM RGB Demo
 KEY1/KEY2: press to test
================================
[KEY1] Pressed_Event
[KEY2] Pressed_Event
[KEY1] Pressed_Event

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

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

bash 复制代码
❌ 错误:直接读 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/PB1 CubeMX Pinout
TIM3 通道 LED 对应的 PWM 通道可能不同 CubeMX 和 main.c 初始化
LED 有效电平 有些 LED 高电平亮,有些低电平亮 app_led.c 中 ON/OFF 的 CCR 值
按键引脚 KEY1/KEY2 可能不是 PA0/PC13 CubeMX GPIO 和 main.h 的宏
按键有效电平 按下可能读到 0(Pull-up),也可能读到 1(Pull-down) APP_KEY_ACTIVE_LEVEL
按键消抖时间 不同按键抖动时间可能不同 APP_KEY_DEBOUNCE_TIME
TIM 时钟 不同工程的 TIMxCLK 可能不是 8MHz CubeMX Clock Configuration,重新计算 PSC/ARR
串口实例 有些板子 USART1 被占用 CubeMX 选 USART2/3,app_usart.c 改句柄

移植顺序建议:

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

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

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

  4. 再接入 KEY 和 USART。

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

bash 复制代码
TIM3 单色 PWM → 三色 PWM → KEY 消抖 + 事件 → 串口打印

常见问题排查

1. LED 完全不亮

按这个顺序查:

优先级 检查点 方法
1 TIM3 PWM 有没有启动 HAL_TIM_PWM_Start 是否在 USER CODE BEGIN 2 调用
2 通道是否对应 红绿蓝通道是否和原理图一致
3 引脚复用是否正确 CubeMX Pinout 看 PB5/PB0/PB1 是否为 TIM3_CHx
4 LED 有效电平是否反了 交换 APP_LED_ONAPP_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 是否共地;

  • printffputc 是否只有一份定义。

5. 按键完全没反应

检查:

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

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

  • KEY1_GPIO_PortKEY1_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 上是亮度,用在蜂鸣器上就是声音。