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_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(按键状态机 + 事件)
按键模块是本篇最值得仔细看的部分。它实现了:
-
4 状态消抖状态机:IDLE → DebouncePress → Pressed → DebounceRelease
-
边沿事件:在状态切换的瞬间产生 Pressed_Event / Release_Event
-
事件存储 :事件存在
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.h 中 App_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);
}
这里面几个要点:
-
**
__io_putchar**:这是 arm-none-eabi newlib 的半主机(semihosting)接口。重写它,printf就会走你的串口而不是调试器。 -
**
fputc**:标准 C 库的字符输出函数,printf最终调用它。我们让它也走__io_putchar。 -
**
\n → \r\n**:串口终端需要\r\n才能正常换行。__io_putchar在遇到\n时自动补一个\r。 -
**
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 */
注意顺序:
-
CubeMX 生成的
MX_GPIO_Init()、MX_TIM3_Init()等必须先执行(它们在USER CODE BEGIN 2之前); -
然后手动启动 PWM(
HAL_TIM_PWM_Start); -
再初始化 LED 和按键;
-
最后用
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 改句柄 |
移植顺序建议:
-
先确认 RGB LED 三个引脚能不能配置成 PWM;
-
再确认 TIMx 的通道映射;
-
再确认 LED 高/低电平有效;
-
再接入 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_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 上是亮度,用在蜂鸣器上就是声音。