0%

STM32——用三色LED打造绚烂流水灯

硬件原理

这块板子是 keysking大佬开发的集成开发板,板上载了三个LED,分别连接在以下引脚:

LED 引脚 控制信号 限流电阻
🔴 红色 PB0 GPIO 高电平亮 板载已集成
🟢 绿色 PA7 GPIO 高电平亮 板载已集成
🔵 蓝色 PA6 GPIO 高电平亮 板载已集成

电路原理图是这样的schematic

电路原理很简单:GPIO 引脚输出高电平时,LED 通过限流电阻接地,灯就亮了。

如果你用的是其他开发板,只需要设置或者查询一下LED对应的引脚号,原理是一样的。


CubeMX 引脚配置

新建项目

打开 CubeMX,新建项目,芯片选择 STM32F103C8T6,步骤和上一篇完全一样,不再重复。

配置三个GPIO引脚

这次要用到三个引脚,分别控制三个颜色的LED:

LED 引脚 功能
🔴 红色 PB0 GPIO_Output
🟢 绿色 PA7 GPIO_Output
🔵 蓝色 PA6 GPIO_Output

在芯片图上依次配置 PB0 → GPIO_Output、PA7 → GPIO_Output、PA6 → GPIO_Output。

pinout_1

pinout_2

配置完成后,右键各个引脚,给每个引脚起一个User Label(用户标签),这样生成的代码里就不用记 PB0、PA6 这种编号了,直接写 LED_RED、LED_BLUE 就行,可读性高很多。

以 PA6 为例:在芯片界面中找到 PA6,在 ENTER User Label 一栏输入 LED_BLUE。同样,PA7 标为 LED_GREEN,PB0 标为 LED_RED。

label_setting

pinout_overview

其他参数保持默认即可:

  • Output mode:Push-Pull(推挽输出)
  • Pull:No pull(无上下拉)
  • Speed:Low(低速即可,LED不需要高速翻转)
  • Initial level:Low(初始低电平,LED默认熄灭)

配置调试接口

这一步非常重要——如果不配,烧录一次后 SWD 就被禁用了,再也烧不进新程序。

在 System Core → SYS 中,将 Debug 设为 Serial Wire。

这一步在上一篇博客里也强调过,如果你的项目是从之前的 CubeMX 项目改过来的,记得检查这一步有没有漏掉。

生成代码

切换到 Project Manager 标签页:

设置项 值
Project Name LED_Blink
Toolchain / IDE CMake
Toolchain Location 保持默认

点击 Generate Code,生成后用 VS Code 打开项目。


VS Code 中编写核心代码

认识 main.h 中的引脚宏定义

CubeMX 生成的 main.h 里,User Label 会自动转换成宏定义:

1
2
3
4
5
6
7
8
9
10
11
// 红色 LED — PB0
#define LED_RED_Pin GPIO_PIN_0
#define LED_RED_GPIO_Port GPIOB

// 蓝色 LED — PA6
#define LED_BLUE_Pin GPIO_PIN_6
#define LED_BLUE_GPIO_Port GPIOA

// 绿色 LED — PA7
#define LED_GREEN_Pin GPIO_PIN_7
#define LED_GREEN_GPIO_Port GPIOA

有了这些宏,后面的代码就不用记引脚号了,直接写 LED_RED_Pin 就行。

状态机思路

我们要实现的效果是:三个LED按照不同的组合依次亮灭,通过红绿蓝三色的搭配混合出绚烂的灯光变化。

这里设计成一个6状态的循环,每个状态持续300ms:

状态 红色(PB0) 绿色(PA7) 蓝色(PA6) 实际颜色
0 🔴亮 🟢灭 🔵灭 🔴 红色
1 🔴亮 🟢亮 🔵灭 🟡 黄色(红+绿)
2 🔴亮 🟢亮 🔵亮 ⚪ 白色(红+绿+蓝)
3 🔴灭 🟢亮 🔵亮 🟢 青色(绿+蓝)
4 🔴灭 🟢灭 🔵亮 🔵 蓝色
5 🔴亮 🟢灭 🔵亮 🟣 紫色(红+蓝)

每300ms切换到下一个状态,完成一轮(6个状态)后回到状态0,一直循环。

main.c 核心代码

打开 main.c,在 USER CODE BEGIN 2 中定义一个状态变量:

1
2
3
/* USER CODE BEGIN 2 */
int state = 0;
/* USER CODE END 2 */

然后在 while(1) 中编写状态切换逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* USER CODE BEGIN WHILE */
while (1)
{
// 红色LED:状态0、1、2、5 时亮,3、4 时灭
if (state <= 2 || state == 5)
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);

// 绿色LED:状态1、2、3 时亮,0、4、5 时灭
if (state >= 1 && state <= 3)
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);

// 蓝色LED:状态2、3、4、5 时亮,0、1 时灭
if (state >= 2)
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);

HAL_Delay(300); // 每个状态维持300ms
state++;
if (state > 5)
state = 0; // 循环回状态0
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */

代码讲解

用到的HAL函数:

函数 作用
HAL_GPIO_WritePin(GPIO端口, 引脚号, 电平) 设置指定引脚输出高或低电平
HAL_Delay(毫秒) 延时,单位毫秒

重点看 HAL_GPIO_WritePin,它的三个参数分别是:

参数 含义 取值
GPIO端口 引脚属于哪个GPIO组(GPIOA、GPIOB…) GPIOA、GPIOB 等
引脚号 具体第几个引脚 GPIO_PIN_0、GPIO_PIN_6、GPIO_PIN_7 等
电平 输出高电平还是低电平 GPIO_PIN_SET(高电平/亮)或 GPIO_PIN_RESET(低电平/灭)

所以 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET) 的意思就是:让 GPIOB 组的第 0 号引脚输出高电平——对应到电路上,就是红色 LED 亮了。

但问题来了——每次写代码都要记”红色LED在PB0、蓝色在PA6……”,引脚一多根本记不住。这时就体现出 User Label 的作用了。

回头看在 CubeMX 里配置的那几个标签,生成代码后会变成宏定义:

1
2
#define LED_RED_Pin GPIO_PIN_0
#define LED_RED_GPIO_Port GPIOB

这样一来,上面的代码就可以写成:

1
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);

不用记引脚号,代码自己就能说明白自己在干什么。对比一下两种写法:

1
2
3
4
5
// 不用 User Label — 每次要回想"PB0是什么灯来着?"
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);

// 用 User Label — 一眼能看懂:红色LED亮
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_SET);

在项目只有一两个LED时差距还不明显,但当你同时操作多个引脚时,User Label 能让代码可读性提升一个档次,再也不用对着引脚表一个个对了。

逻辑分析:

每个LED的亮灭由 state 的值决定。state 每300ms加1,到5后归0,形成循环。

这里每个LED的亮灭条件是用区间判断写的,比如绿色LED在 state >= 1 && state <= 3 时亮。你也可以改成查表法——用一个数组存6种状态,每次读数组取值——逻辑会更清晰,但原理是一样的。


解读 MX_GPIO_Init —— 三个引脚的配置

MX_GPIO_Init() 中配置了三个 GPIO 输出引脚,我们看看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};

/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();

/* 初始电平:全部低电平→默认熄灭 */
HAL_GPIO_WritePin(LED_RED_GPIO_Port, LED_RED_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_GREEN_GPIO_Port, LED_GREEN_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_BLUE_GPIO_Port, LED_BLUE_Pin, GPIO_PIN_RESET);

/* 配置 LED_RED (PB0) */
GPIO_InitStruct.Pin = LED_RED_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL; // 无上下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 低速
HAL_GPIO_Init(LED_RED_GPIO_Port, &GPIO_InitStruct);

/* 配置 LED_GREEN (PA7) —— 同上 */
GPIO_InitStruct.Pin = LED_GREEN_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(LED_GREEN_GPIO_Port, &GPIO_InitStruct);

/* 配置 LED_BLUE (PA6) —— 同上 */
GPIO_InitStruct.Pin = LED_BLUE_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(LED_BLUE_GPIO_Port, &GPIO_InitStruct);
}

和第一篇的区别

相比第一篇博客的单引脚配置,这次有三个关键变化:

变化 说明
两个时钟使能 __HAL_RCC_GPIOA_CLK_ENABLE() + __HAL_RCC_GPIOB_CLK_ENABLE(),因为三个引脚分布在 GPIOA(PA6、PA7)和 GPIOB(PB0)两个端口
User Label 替代硬编码 前一篇直接写 GPIOA、GPIO_PIN_7,这一篇写成 LED_RED_GPIO_Port、LED_RED_Pin——CubeMX 把 User Label 翻译成了宏,代码可读性大大提升
三个引脚重复配置 每次配置前重新给 GPIO_InitStruct 赋值,CubeMX 就是按照这个模板批量配置引脚的

参数回顾

单个引脚的配置参数和第一篇完全一样,这里汇总一下:

参数 值 解释
Mode GPIO_MODE_OUTPUT_PP 推挽输出——可以输出高/低电平,适合驱动LED
Pull GPIO_NOPULL 输出引脚不需要上下拉
Speed GPIO_SPEED_FREQ_LOW LED 不需要高速翻转,低速省电、抗干扰

为什么不一次性配置所有引脚?

你可能会想,既然三个引脚参数都一样,为什么 CubeMX 不把它们放在一个结构体里一次性配置?

答案是 HAL_GPIO_Init 的 Pin 成员支持按位或组合,所以其实可以一次配多个:

1
2
3
4
5
GPIO_InitStruct.Pin = LED_RED_Pin | LED_GREEN_Pin | LED_BLUE_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 但这里只能指定一个端口

不过引脚分布在不同的端口(PA6、PA7 在 GPIOA,PB0 在 GPIOB),所以至少要用两次 HAL_GPIO_Init。CubeMX 选择分开配置,逻辑更清晰,也更容易针对某个引脚单独修改参数。


编译 & 烧录

编译和烧录的步骤和上一篇完全一样,不再重复写一遍了:

  1. VS Code 底部工具栏点击齿轮图标 → 生成
  2. 连接 ST-Link,点击运行和调试 → 选择 STM32Cube:STM32 Launch STLink GDB Server
  3. 点击继续按钮,程序开始运行

如果遇到编译或烧录的问题,翻回去看上一篇的排坑部分。


效果展示

烧录成功后,三色 LED 会以 300ms 的间隔循环变化:

1
红色亮 → 红+绿亮 → 全亮 → 绿+蓝亮 → 蓝色亮 → 红+蓝亮 → 回到红色亮 ...

红色、绿色、蓝色以不同的组合交替出现,看起来就像在调色盘上不断调出新的颜色,比单灯闪烁丰富太多了。

效果展示视频:https://www.bilibili.com/video/BV1dq356YEse/


进阶思路

这个状态机虽然简单,但稍加扩展就能玩出更多花样:

  • 改变速度:把 state++ 放到定时器中断里,用按键调节速度
  • 呼吸效果:把 GPIO 换成 PWM,LED 就能渐亮渐灭
  • 更多状态:把6种组合扩展到 2³ = 8 种(全亮/全灭也加进来),或者用随机数控制
  • 查表法:用一个二维数组存所有状态,while(1) 里取第 state 行的值写入引脚,代码更简洁

总结

这次的项目相比上一篇的单灯闪烁,主要多了两个新东西:

  1. 多引脚配置 —— CubeMX 里配多个 GPIO,用 User Label 区分
  2. 状态机编程 —— 用一个变量控制多个LED的亮灭组合,比单独控制每个灯要简洁得多

参考资源