深入浅出 STM32(十二):双向跑马灯逻辑与 HAL 库查阅技巧


前言
大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~
一、LED 闪烁功能的硬件原理与电路分析
1.1 LED4 的硬件连接与电平逻辑
接下来我们从源码层面回归到实际的需求层面,我们要实现的下一个功能是“闪灯”,即不仅仅是点亮 LED,还要让它产生周期性的闪烁效果。
首先,我们需要明确控制对象。在本次实验中,我们操作的 LED 是 LED4。根据原理图可知,LED4 是一个红色的发光二极管,它连接在 MCU 的 PF8 引脚上(网络标签为 SYS_LED1)。
这里有一个非常关键的电路特征:LED4 采用了外接高电平的设计。也就是说,LED 的阳极通过一个 680Ω 的限流电阻(R47)直接连接到了 3.3V 电源(VDD_3.3V)上,而 LED 的阴极则连接到了 MCU 的 PF8 引脚。这种接法决定了我们的控制逻辑是“低电平点亮,高电平熄灭”。
1.2 开漏输出模式与 MOS 管工作原理
为了配合这种“低电平点亮”的硬件电路,我们在配置 GPIO 时选择了**开漏输出(Output Open Drain)**模式。为什么选开漏而不是推挽?这里涉及到芯片内部 MOS 管的物理结构,我们来详细拆解一下。
在 STM32 的 GPIO 内部结构中,输出级通常包含两个 MOS 管:一个是 P-MOS 管(上拉),一个是 N-MOS 管(下拉)。
- 推挽输出:P-MOS 和 N-MOS 都会参与工作。输出高电平时 P-MOS 导通,输出低电平时 N-MOS 导通。
- 开漏输出:我们在 CubeMX 中配置为开漏模式时,实际上是在内部禁用了 P-MOS 管,只保留了 N-MOS 管在工作。
基于只使用 N-MOS 管的特性,我们可以这样理解 PF8 引脚的电平变化过程:
- 写 0(低电平):当我们向输出数据寄存器写 0 时,内部的 N-MOS 管导通。此时,PF8 引脚相当于直接连通到了内部的 GND。由于外部 LED 阳极接的是 3.3V,阴极(PF8)被拉低到 GND,形成了完整的电流回路(3.3V → R47 → LED4 → PF8 → GND),因此 LED4 点亮。
- 写 1(高电平):当我们向输出数据寄存器写 1 时,由于 P-MOS 被禁用,N-MOS 管截止(闭合)。此时,PF8 引脚处于高阻态(High-Z),相当于内部断开了,MCU 内部与外部电路没有电气连接。既然回路断开,没有电流流过,LED4 自然熄灭。

虽然理论上使用推挽输出也能通过输出高低电平来控制 LED 亮灭,但在本案例的硬件设计中,开漏输出是更匹配“外部上拉/高电平供电”逻辑的配置方式。
二、STM32CubeMX 配置与工程生成
2.1 GPIO 参数详细配置
明确了硬件原理后,我们打开 STM32CubeMX 进行具体的引脚配置。针对 PF8 引脚,我们需要关注以下几个关键参数的设置,这些设置直接对应了刚才讲解的硬件逻辑:
- Pin Name: PF8
- GPIO output level: 设置为 High。
- 注意:这里初始电平设为 High 是为了让系统在复位启动的瞬间,LED 处于熄灭状态(高阻态或高电平),避免上电瞬间出现不可控的闪烁。
- GPIO mode: 选择 Output Open Drain(开漏输出)。这是核心配置,确保只启用 N-MOS 管。
- GPIO Pull-up/Pull-down: 选择 No pull-up and no pull-down。因为外部电路已经有明确的电源连接,且开漏模式下内部上拉通常不参与主要驱动逻辑(视具体设计而定,这里后面有文章会细讲)。
- Maximum output speed: 设置为 Low。对于控制 LED 这种低频信号,低速模式足以满足需求,同时有助于降低电磁干扰(EMI)。

2.2 代码生成与环境同步
配置完成后,点击 GENERATE CODE 生成工程代码。此时需要注意开发环境的同步问题。如果你之前在 VSCode 或其他编辑器中手动修改过文件,重新生成代码可能会触发文件保护或覆盖冲突。
在实际操作中,我们需要将生成的代码同步到 VSCode 环境中。如果在 VSCode 中看到文件有修改标记或提示,需要正确处理合并或覆盖,确保 main.c 中的初始化代码是最新的。特别是 MX_GPIO_Init() 函数,它必须包含我们刚才配置的 PF8 开漏输出初始化逻辑。
三、LED 闪烁的代码实现方案
实现闪灯的本质,就是让 LED 在“亮”和“灭”两个状态之间循环切换,并在每个状态保持一段时间,让人眼能够观察到变化。我们提供了两种主要的代码实现思路。
3.1 基础方案:显式置位与复位
最直观的方法是分别调用点亮和熄灭的函数,中间穿插延时。
在 while(1) 主循环中,逻辑如下:
- 点亮 LED:调用
HAL_GPIO_WritePin,将 PF8 置为GPIO_PIN_RESET(即低电平 0)。根据前面的原理,N-MOS 导通,灯亮。 - 延时保持:调用
HAL_Delay(200),让灯亮持续 200 毫秒。 - 熄灭 LED:调用
HAL_GPIO_WritePin,将 PF8 置为GPIO_PIN_SET(即高电平 1)。此时 N-MOS 截止,进入高阻态,灯灭。 - 延时保持:再次调用
HAL_Delay(200),让灭的状态持续 200 毫秒。
示例代码如下:
/* USER CODE BEGIN WHILE */
while (1)
{
// 1. 点亮 LED4 (写 0,N-MOS 导通)
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET);
HAL_Delay(200); // 延时 200ms
// 2. 熄灭 LED4 (写 1,N-MOS 截止,高阻态)
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET);
HAL_Delay(200); // 延时 200ms
}
/* USER CODE END WHILE */
在这个阶段,我们将延时时间从最初的 1000ms 调整为了 200ms。原本的 1 秒闪烁频率较慢,改为 200ms 后,闪烁节奏明显变快,视觉效果更佳。
3.2 进阶方案:使用 TogglePin 翻转函数
除了显式地写 0 和写 1,HAL 库还提供了一个更简洁的函数——HAL_GPIO_TogglePin。
这个函数的作用是反转当前引脚的电平状态:
- 如果当前引脚是低电平(灯亮),调用后变为高电平(灯灭)。
- 如果当前引脚是高电平(灯灭),调用后变为低电平(灯亮)。
使用这个函数,我们不需要关心当前灯到底是亮还是灭,只需要不断地“翻转”它即可。配合延时函数,同样能实现完美的闪烁效果。
优化后的代码如下:
/* USER CODE BEGIN WHILE */
while (1)
{
// 翻转 PF8 引脚电平
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
// 延时 1000ms (1秒翻转一次)
// 你可以根据需要改为 200ms
HAL_Delay(1000);
}
/* USER CODE END WHILE */
这种写法代码量更少,逻辑也更通用。无论你初始状态是什么,它都能保证产生方波一样的闪烁信号。
3.3 跑马灯逻辑实现
接下来实现一个经典的“跑马灯”效果。这里搞会之后灯随便怎么亮都可以自己玩了
3.3.1 跑马灯需求分析与硬件映射
我们要实现的跑马灯效果,并非简单的单向流动,而是一种“钟摆式”的往复运动。具体来说,就是 LED 灯按照 1→2→3→4 的顺序依次点亮并熄灭,到达末端后,再按照 4→3→2→1 的顺序反向回扫,如此循环往复。
要实现这个效果,首先必须搞清楚硬件上的引脚对应关系。
在我的开发板上,物理存在的四个 LED 灯分别标记为 LED4、LED5、LED7、LED8。查看原理图时,这四个灯对应的网络标签被设计者命名为 SYS_LED1 到 SYS_LED4。这还没完,当我们顺着网络标签找到 STM32F103ZET6 芯片时,会发现它们实际连接的 MCU 内部引脚分别是 PF8、PF9、PF10、PF11。
⚠️ 避坑指南:
千万不要看到板子上写着 “LED4” 就以为它对应芯片的 Pin 4,也不要看到原理图上的SYS_LED1就以为是 PA1 或 PB1。必须严格遵循原理图的电气连接:
- LED4 (红) → \rightarrow →
SYS_LED1→ \rightarrow → PF8- LED5 (绿) → \rightarrow →
SYS_LED2→ \rightarrow → PF9- LED7 (黄) → \rightarrow →
SYS_LED3→ \rightarrow → PF10- LED8 (蓝) → \rightarrow →
SYS_LED4→ \rightarrow → PF11
明确了这一点,我们在软件配置时才能有的放矢。虽然控制四个灯和控制一个灯在本质上没有区别,都是操作 ODR(输出数据寄存器)的特定位,但为了代码的优雅和可维护性,我们需要一种更聪明的配置方式。
3.3.2 CubeMX 配置与底层初始化逻辑
在 STM32CubeMX 中,我们将 PF8 至 PF11 统一配置为 GPIO_Output 模式。这里有几个关键参数需要特别注意,它们直接决定了 LED 的驱动逻辑:
- GPIO Output Level (初始电平):设置为 High。
结合我们之前讲过的开漏输出电路特性,当 MCU 输出高电平时,MOS 管截止,LED 两端无压差,灯是灭的。这样设置可以保证系统上电复位的瞬间,LED 不会发生误闪烁。 - GPIO Mode (输出模式):设置为 Output Open Drain (开漏输出)。
当然,推挽输出(Push Pull)也是完全可以的。但在本案例中,为了配合外部上拉或特定的驱动电路逻辑,我们选择开漏模式。 - GPIO Pull-up/Pull-down:设置为 No pull-up and no pull-down。
- Maximum Output Speed:设置为 Low。
控制 LED 闪烁属于低频操作,不需要高速响应,低速模式有助于降低电磁干扰(EMI)和功耗。
生成代码后,打开 main.c,你会看到自动生成的 MX_GPIO_Init() 函数。请注意这段代码中的细节处理:
static void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* USER CODE BEGIN MX_GPIO_Init_1 */
/* USER CODE END MX_GPIO_Init_1 */
/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOF_CLK_ENABLE(); // 开启 F 端口时钟
__HAL_RCC_GPIOA_CLK_ENABLE(); // 开启 A 端口时钟(虽未用到,但CubeMX可能默认开启)
/*Configure GPIO pin Output Level */
// 【关键点】初始化时直接通过 BSRR 寄存器将 PF8-PF11 置高(熄灭)
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8|GPIO_PIN_9|GPIO_PIN_10|GPIO_PIN_11, GPIO_PIN_SET);
/*Configure GPIO pins : PF8 PF9 PF10 PF11 */
GPIO_InitStruct.Pin = GPIO_PIN_8|GPIO_PIN_9|GPIO_PIN_10|GPIO_PIN_11;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOF, &GPIO_InitStruct);
/* USER CODE BEGIN MX_GPIO_Init_2 */
/* USER CODE END MX_GPIO_Init_2 */
}

大家注意看 HAL_GPIO_WritePin 这一行。它在 GPIO 初始化结构体配置之前,就先把这四个引脚的电平拉高了。这是为了防止在 GPIO 模式配置的短暂窗口期内,引脚处于默认的低电平状态导致 LED 瞬间亮一下。这种“先置位,后配置”的手法,是嵌入式开发中保证系统稳定性的常用技巧。
3.3.3 核心代码实现:数组映射与双向循环
为了让跑马灯的逻辑清晰且易于扩展,我们不建议直接硬编码 PF8、PF9 这样的宏定义。相反,我们应该利用 C 语言的数组特性来抽象硬件。
1. 定义 LED 引脚数组
在 main.c 的私有变量区域(/* USER CODE BEGIN PV */),我们定义如下变量:
/* USER CODE BEGIN PV */
// 定义一个数组,按顺序存储四个 LED 的引脚号
uint16_t gleds[] = {GPIO_PIN_8, GPIO_PIN_9, GPIO_PIN_10, GPIO_PIN_11};
// 利用 sizeof 动态计算 LED 的数量,避免手动数数出错
const uint16_t gleds_num = sizeof(gleds) / sizeof(gleds[0]);
/* USER CODE END PV */

这样做的好处是显而易见的:如果未来硬件改版,LED 换到了 PE 端口,或者顺序变了,你只需要修改这个数组的内容,后面的循环逻辑完全不用动。gleds_num 的计算方法也是非常标准的 C 语言技巧,它能自动适应数组大小的变化。
2. 主循环中的跑马灯逻辑
在 main 函数的 while(1) 循环中,我们分两步来实现“钟摆”效果:
第一步:正向扫描(从左向右)
// 从左向右跑马灯
for(int i = 0; i < gleds_num; i++)
{
HAL_GPIO_WritePin(GPIOF, gleds[i], GPIO_PIN_RESET); // 点亮当前 LED (低电平有效)
HAL_Delay(200); // 延时 200ms
HAL_GPIO_WritePin(GPIOF, gleds[i], GPIO_PIN_SET); // 熄灭当前 LED (高电平)
}
这里利用了数组下标 i 从 0 递增到 3。每次循环,先写低电平(RESET)点亮灯,人眼观察到亮后,延时 200ms,再写高电平(SET)熄灭灯,为下一次循环做准备。
第二步:反向扫描(从右向左)
// 从右向左跑马灯
for(int i = gleds_num - 1; i >= 0; i--)
{
HAL_GPIO_WritePin(GPIOF, gleds[i], GPIO_PIN_RESET); // 点亮当前 LED
HAL_Delay(200); // 延时 200ms
HAL_GPIO_WritePin(GPIOF, gleds[i], GPIO_PIN_SET); // 熄灭当前 LED
}
反向扫描的逻辑与正向完全一致,唯一的区别在于循环变量 i 的初始值是 gleds_num - 1(即 3),并且条件是 i >= 0,步长是 i--。这样就实现了从最后一个灯回溯到第一个灯的效果。
此外,在进入 while(1) 死循环之前,我们还加了一段初始化代码:先遍历一次数组将所有灯熄灭(双重保险),然后执行 HAL_Delay(2000)。这 2 秒的延时是为了让系统上电后有一个明显的“准备期”,避免一上电就开始狂闪,体验更好。
3.3.4 进阶技巧:HAL 库函数的查阅与使用
在编写上述代码时,我们用到了 HAL_GPIO_WritePin。其实 HAL 库还提供了一个非常便捷的函数叫 HAL_GPIO_TogglePin(翻转引脚电平)。
如果你不想显式地写“点亮”和“熄灭”两步操作,而是想让灯的状态直接取反(亮变灭,灭变亮),就可以使用这个函数。它的源码实现非常精妙,利用了 BSRR 寄存器的特性进行原子操作:
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin)
{
uint32_t odr;
/* 检查参数合法性 */
assert_param(IS_GPIO_PIN(GPIO_Pin));
/* 获取当前 ODR 寄存器的值 */
odr = GPIOx->ODR;
/*
* 核心逻辑:
* 如果当前位是低(0),则向 BSRR 的低 16 位写 1 (置位/Set);
* 如果当前位是高(1),则向 BSRR 的高 16 位写 1 (复位/Reset)。
* 这是一个原子操作,不会被中断打断。
*/
GPIOx->BSRR = ((odr & GPIO_Pin) << GPIO_NUMBER) | (~odr & GPIO_Pin);
}
虽然在这个跑马灯案例中,我们需要明确的“亮-灭”时序,用 WritePin 更直观,但在很多 Blink(闪烁)场景下,TogglePin 能少写一半代码。
如何高效查阅 HAL 库手册?
很多人在找函数原型或参数说明时会感到迷茫。这里教大家三种高效的方法:
-
AI 辅助:直接让 AI 生成 HAL 库的常用接口,让它解释参数和用法,这是最快的方式。
-
IDE 跳转:在代码中右键点击函数名(如
HAL_GPIO_WritePin),选择“Go to Definition”,直接看头文件stm32f1xx_hal_gpio.h中的声明和注释。
-
官方 CHM 手册(推荐):
-
找到你的固件包目录,通常在
STM32Cube\Repository\STM32Cube_FW_F1_V1.8.0\Drivers\STM32F1xx_HAL_Driver下。 -
打开
STM32F103xG_User_Manual.chm(根据你的芯片型号选择对应的 G/B/E 系列手册)。

-
在索引栏搜索
HAL_GPIO_W,你就能立刻找到WritePin、TogglePin等所有相关函数的详细文档,包括参数定义、返回值说明以及注意事项。
-
建议可以把这个 CHM 手册创建一个快捷方式放到桌面上,可以当字典来用。
结语

更多推荐

所有评论(0)