编写者:LCKFB-YZH
版本:v1.0
日期:2026-09-04
更新记录:
- v1.0(2026-09-04):完成 CubeMX 建工程、HAL 双 LED 控制、BSP 封装、编译下载、调试验证与工程边界说明。
一 本章简介
上一章已经用自己编写的 GPIO 库控制过 LED。本章换一种更接近实际 STM32 项目的做法:使用 STM32CubeMX 配置芯片和外设,让它生成 STM32CubeF4 硬件抽象层(Hardware Abstraction Layer,HAL)工程,再在用户代码区完成点灯程序。
这不是“点几下鼠标就结束”的工具操作章。完成工程后,我们还会沿着 HAL_GPIO_WritePin() 继续向下追踪,看看 HAL 怎样写入 GPIO 的位设置/复位寄存器(Bit Set/Reset Register,BSRR),以及为什么 HAL_GPIO_TogglePin() 不能简单理解为原子翻转。
本章以天空星 STM32F407 核心板上的 PB2 用户 LED 为主,同时加入筑基学习板 PB8 LED16。两颗 LED 的有效电平相反,正好可以把“引脚电平”和“灯的亮灭状态”彻底分开。
1.1 学习目标
| 序号 | 学习目标 | 重要程度 |
|---|---|---|
| 1 | 能在 CubeMX 中选择 STM32F407VET6,并完成 SWD、HSE、168 MHz 时钟和 GPIO 配置 | ⭐⭐⭐⭐⭐ |
| 2 | 能说清 PB2 与 PB8 LED 的有效电平,并设置正确的上电熄灭状态 | ⭐⭐⭐⭐⭐ |
| 3 | 能看懂 .ioc、main.c、gpio.c、main.h 和 HAL 配置文件的职责 | ⭐⭐⭐⭐⭐ |
| 4 | 能分别用 HAL_GPIO_TogglePin()、HAL_GPIO_WritePin() 和 BSP 控制 LED | ⭐⭐⭐⭐⭐ |
| 5 | 能用 HAL_GetTick() 写出不阻塞主循环的周期任务 | ⭐⭐⭐⭐ |
| 6 | 能完成编译、下载、静态电平测量和寄存器观察,并按顺序排查失败原因 | ⭐⭐⭐⭐⭐ |
| 7 | 能解释 HAL GPIO API 与 BSRR、ODR 以及 RCC 时钟使能之间的关系 | ⭐⭐⭐⭐ |
1.2 阅读路线
- 第一次建立 HAL 工程:按第二章到第七章顺序操作。
- 已经会用 CubeMX,只想理解生成代码:重点看第四章、第五章和第八章。
- 正在做真实项目:重点看第五章的 BSP、非阻塞写法,以及第九章的代码生成边界。
- 只控制核心板 PB2 LED:PB8 相关配置可以先跳过,但要记住 PB2 是高电平点亮。
本章不要求先阅读寄存器点灯章节。工程创建、LED 硬件、电平极性、下载和调试都在本章给出完整步骤。需要继续深入 GPIO 寄存器时,可再阅读【8】认识 GPIO和【10】实现自己的 GPIO 库。
1.3 本章结果与验证范围
完成后,PB2 与 PB8 每隔 500 ms 同时改变一次亮灭状态。PB2 输出高电平时点亮,PB8 输出低电平时点亮,因此两路 GPIO 的点亮电平相反,但两颗灯可以同时亮、同时灭。

1.4 第一次阅读只走这条主线
第一次建立 STM32 工程时,不必同时理解 HAL 内部实现、BSP 分层和非阻塞任务。先只控制核心板 PB2 LED,按下面六步完成最小闭环:
- 核对 DAPLink 接线和 PB2 高电平点亮的极性。
- 在 CubeMX 中完成芯片、SWD、HSE、168 MHz 时钟和 PB2 配置。
- 生成工程,先找到
.ioc、main.c、gpio.c和main.h。 - 在
while (1)中加入 5.1 节的两行闪灯代码。 - 编译、下载并复位运行,确认 PB2 LED 持续闪烁。
- 如果现象不对,先改成 7.4 节的固定亮、固定灭测试,再测量静态电平。
完成这条主线后,再加入 PB8、BSP 封装和 HAL_GetTick() 非阻塞任务。第四节的启动流程、第六节的寄存器行为和第八节的工程边界可以放到第二遍阅读,不要影响第一次把灯点亮。
1.5 三个阶段检查点
每完成一个阶段就停下来核对,避免把配置、编译和硬件问题混在一起排查。
| 阶段 | 此时应当看到什么 | 不满足时先检查 |
|---|---|---|
| CubeMX 配置完成 | Pinout 无冲突,Clock Configuration 无红色错误,PB2 为 GPIO_Output,.ioc 已保存 | 芯片型号、HSE 模式、PLL 参数和 GPIO User Label |
| 编译下载完成 | 工程 0 编译错误,调试器能识别芯片,下载日志包含 Program、Verify 和 Reset 的成功结果 | 源文件是否加入工程、头文件路径、SWD 接线和目标供电 |
| 硬件现象确认 | PB2 每 500 ms 翻转一次,完整亮灭周期约 1 s;加入 PB8 后两颗灯同亮同灭 | 先做固定电平测试,再检查有效电平、GPIO 初始化和程序是否进入主循环 |
二 硬件与软件准备
2.1 所需资源
| 资源 | 本章用途 | 说明 |
|---|---|---|
| 天空星 STM32F407 核心板 | 运行程序、控制 PB2 LED | 主控为 STM32F407VET6 |
| 筑基学习板 | 提供 PB8 LED16 | PB8 为低电平点亮,可选但推荐连接 |
| LCKFB DAPLink 或兼容 CMSIS-DAP 调试器 | SWD 下载与在线调试 | 至少连接 3V3/GND/SWDIO/SWCLK |
| STM32CubeMX | 配置芯片并生成工程 | 界面会随版本变化,参数含义不变 |
| Keil MDK、STM32CubeIDE 或 VS Code + EIDE | 编译、下载和调试 | 本章以 Keil 操作为主 |
| 万用表 | 检查 PB2/PB8 静态电平 | 测量时黑表笔接 GND |
2.2 连接 DAPLink
先断电,再按丝印连接 3V3、GND、SWDIO 和 SWCLK,核对无误后上电。线色不是信号定义,换了排线或调试器后必须重新按丝印确认。

如果调试器给目标板供电,只保留一个可靠电源来源;如果目标板另行供电,调试器仍要与开发板共地。
2.3 两颗 LED 的电气极性
天空星核心板用户 LED 接在 PB2 与 GND 之间。PB2 输出高电平后形成电流路径,因此它是高电平点亮。


PB2 还复用 BOOT1 启动配置功能,开发板使用下拉建立确定的复位采样状态。复位采样完成并进入用户程序后,PB2 可以按普通 GPIO 配置;
筑基学习板 LED16 的阳极通过限流电阻接 3.3 V,阴极接 PB8。PB8 输出低电平时,GPIO 吸收电流,LED 点亮。


| 对象 | GPIO | 点亮电平 | 熄灭电平 | CubeMX 初始输出电平 |
|---|---|---|---|---|
| 核心板用户 LED | PB2 | 高 | 低 | Low |
| 筑基学习板 LED16 | PB8 | 低 | 高 | High |
IMPORTANT
GPIO_PIN_SET 表示引脚输出高电平,GPIO_PIN_RESET 表示输出低电平。它们不等于“灯亮”和“灯灭”,LED 是否点亮取决于外部电路极性。
2.4 为什么要先准备熄灭电平
GPIO 切换为输出模式时,会立即输出 ODR 中已经锁存的电平。如果先切模式、后写熄灭电平,LED 可能在初始化期间短暂闪一下。CubeMX 生成的 MX_GPIO_Init() 通常会先调用 HAL_GPIO_WritePin() 预置输出电平,再调用 HAL_GPIO_Init() 配置输出模式。
本章把 PB2 预置为低、PB8 预置为高。这个顺序不能消除所有复位阶段的硬件瞬态,但能避免软件初始化自身引入不必要的亮灯脉冲。
三 使用 CubeMX 新建工程
3.1 选择 STM32F407VET6
打开 CubeMX,选择 File → New Project → MCU/MPU Selector,在 Part Number 中搜索 STM32F407VET6。确认封装为 LQFP100 后开始工程。

芯片的 Flash 容量和封装会影响链接脚本、可用引脚与工程器件定义。 我们筑基学习板搭配的天空星-焊接排针版所用的主控是 STM32F407VET6,而天空星高配版本所用的主控芯片是STM32F407VGT6,他们除了FLASH容量不一样,其他完全一样,学习的时候可以都选STM32F407VET6 这款芯片。
3.2 配置 SWD 调试接口
进入 System Core → SYS,把 Debug 设置为 Serial Wire。这会保留 PA13/SWDIO 和 PA14/SWCLK。
在第三步(选择[Serial Wire])完成后,右边的的两个引脚PA13和PA14就会变成绿色的已配置状态,以后每次生成新工程的时候,请大家务必注意不要轻易将这两个IO配置为普通的GPIO模式,否则有可能出现调试器再也连不上的情况。
WARNING
如果生成工程后把调试接口改成 Disable,程序一旦运行可能释放 SWD 引脚,随后出现难以再次连接的现象。需要复用 PA13/PA14 时,应先设计好“复位下连接”或其他的 Bootloader 恢复方案。
3.3 配置外部高速时钟
进入 System Core → RCC,把 High Speed Clock (HSE) 设置为 Crystal/Ceramic Resonator。天空星板载 HSE 为 8 MHz,本章让主 PLL 使用它作为输入。

3.4 配置 168 MHz 系统时钟
进入 Clock Configuration,把 HSE 输入填写为 8 MHz,并设置:
| 参数 | 数值 | 结果 |
|---|---|---|
| [2]PLL Source | HSE | 主 PLL 使用外部 8 MHz 时钟 |
| [3]PLLM | 8 | PLL 输入变为 1 MHz |
| [4]PLLN | 336 | VCO 输出变为 336 MHz |
| [5]PLLP | 2 | SYSCLK 变为 168 MHz |
| [6]AHB Prescaler | 1 | HCLK 为 168 MHz |
| [7]APB1 Prescaler | 4 | PCLK1 为 42 MHz |
| [8]APB2 Prescaler | 2 | PCLK2 为 84 MHz |
计算过程如下:
PLL 输入 = 8 MHz / 8 = 1 MHz
VCO 输出 = 1 MHz × 336 = 336 MHz
SYSCLK = 336 MHz / 2 = 168 MHz2
3

为什么 PLL 输入、VCO、APB 和 Flash 都有约束,会在[【12】RCC 时钟配置]中完整展开。本章先按表完成配置,不能只在右侧输入 168 后忽略 CubeMX 自动调整的分频值。
3.5 配置 PB2 核心板 LED
按上图所示,我们先把这个PB2用鼠标右键设置为GPIO输出模式。 在引脚视图中点击 PB2,选择 GPIO_Output。把 User Label 设置为 LED_CORE,参数使用:
| 参数 | 设置 | 原因 |
|---|---|---|
| GPIO output level | Low | PB2 低电平时 LED 熄灭 |
| GPIO mode | Output Push Pull | 直接输出高、低电平 |
| GPIO Pull-up/Pull-down | No pull-up and no pull-down | 外部 LED 电路不依赖内部上下拉 |
| Maximum output speed | Low | 500 ms 点灯不需要高边沿速度,可减小无谓 EMI |
| User Label | LED_CORE | 生成有硬件含义的宏 |

3.6 配置 PB8 筑基学习板 LED
PB8 同样选择 GPIO_Output,User Label 设为 LED_FDB。PB8 和 PB2 之间唯一不同的关键项是初始电平必须为 High,才能让低电平有效的 LED16 保持熄灭。

3.7 设置工程名称与工具链
进入 Project Manager → Project:
- Project Name 建议填写
fdb_hal_led。 - Project Location 使用纯英文短路径,避免旧版工具链对中文、空格或超长路径处理不一致。
- Toolchain/IDE 按实际环境选择
MDK-ARM。 - Firmware Package 固定到工程实际使用的 STM32CubeF4 版本,团队开发时不要随意更换升级版本。

3.8 设置代码生成选项
进入 Project Manager → Code Generator,推荐勾选:
Copy only the necessary library files:拷贝必要的库文件。Generate peripheral initialization as a pair of '.c/.h' files per peripheral:把 GPIO 等外设初始化拆到独立文件。Keep User Code when re-generating:重新生成时保留规范 USER CODE 区域。

3.9 生成前自查
| 检查项 | 正确结果 |
|---|---|
| MCU | STM32F407VET6,LQFP100 |
| 调试接口 | Serial Wire,PA13/PA14 已启用 |
| HSE | Crystal/Ceramic Resonator,8 MHz |
| 系统时钟 | SYSCLK/HCLK 168 MHz,PCLK1 42 MHz,PCLK2 84 MHz |
| 48 MHz 域 | 48 MHz |
| PB2 | Output Push Pull,Low 初始电平,Low speed |
| PB8 | Output Push Pull,High 初始电平,Low speed |
点击 GENERATE CODE。等待工程生成完毕后,我们就可以打开工程目录了。

四 看懂 CubeMX 生成的工程
4.1 生成过程不是一个黑盒
CubeMX 根据芯片资料、STM32CubeF4 软件包和 .ioc 配置生成初始化代码与 IDE 工程。下面是 UM1718 Rev 50 给出的代码生成流程图。

图源:UM1718 Rev 50,Figure 1。界面截图属于官方示意,实际版本的菜单和布局可能不同。
4.2 常见目录结构
fdb_hal_led/
├── Core/
│ ├── Inc/
│ │ ├── gpio.h
│ │ ├── main.h
│ │ ├── stm32f4xx_hal_conf.h
│ │ └── stm32f4xx_it.h
│ └── Src/
│ ├── gpio.c
│ ├── main.c
│ ├── stm32f4xx_hal_msp.c
│ ├── stm32f4xx_it.c
│ └── system_stm32f4xx.c
├── Drivers/
│ ├── CMSIS/
│ └── STM32F4xx_HAL_Driver/
├── MDK-ARM/ # 选择 Keil 时生成
├── startup_stm32f407xx.s
└── fdb_hal_led.ioc2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
不同工具链会改变启动文件、链接脚本和 IDE 目录,但 Core、Drivers 和 .ioc 的职责基本一致。[【13】基础 HAL 库文件梳理]会继续逐个拆解这些文件。 如果你已经安装好了Keil,那我们双击 MDK-ARM/ 目录下的 fdb_hal_led.uvprojx 就可以打开这个工程了:

4.3 从复位到 main()
flowchart TD
A["复位后从向量表取得初始 SP 和 Reset_Handler"] --> B["启动文件执行 Reset_Handler"]
B --> C["SystemInit:FPU、可选外部存储器、可选向量表设置"]
C --> D["C 运行时初始化 data、bss 和静态构造"]
D --> E["进入 main"]
E --> F["HAL_Init:HAL 基础设施与 1 ms Tick"]
F --> G["SystemClock_Config:配置 HSE、PLL 和总线时钟"]
G --> H["MX_GPIO_Init:使能 GPIOB 并配置 PB2/PB8"]
H --> I["while 主循环"]
SystemInit() 在 main() 之前由启动过程调用。以本章基线中的 system_stm32f4xx.c 为例,它主要处理 FPU、可选外部存储器和可选向量表重定位,不负责把系统时钟配置成 168 MHz。真正配置 HSE、PLL、AHB/APB 的是 CubeMX 生成在 main.c 中的 SystemClock_Config()。
HAL_Init() 通常完成 HAL 全局状态、NVIC 优先级分组、1 ms Tick 以及 HAL_MspInit() 等初始化。具体细节受 HAL 版本和配置宏影响,不能把某个版本的内部实现当作永远不变的 API 保证。
4.4 main.c 的主线
生成代码的核心顺序如下:
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
while (1)
{
}
}2
3
4
5
6
7
8
9
10
顺序不能随便交换:HAL Tick 依赖基础初始化,SystemClock_Config() 需要在大多数外设初始化前确定时钟,GPIO 只有在端口时钟开启并配置完成后才能可靠控制。
4.5 MX_GPIO_Init() 做了什么
同时配置 PB2 和 PB8 后,关键代码是下面这样,函数具体排版可能随 CubeMX 版本变化:
void MX_GPIO_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
/* GPIO Ports Clock Enable */
__HAL_RCC_GPIOH_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(LED_CORE_GPIO_Port, LED_CORE_Pin, GPIO_PIN_RESET);
/*Configure GPIO pin Output Level */
HAL_GPIO_WritePin(LED_FDB_GPIO_Port, LED_FDB_Pin, GPIO_PIN_SET);
/*Configure GPIO pins : LED_CORE_Pin LED_FDB_Pin */
GPIO_InitStruct.Pin = LED_CORE_Pin|LED_FDB_Pin;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
我们在前面cubemx的设置中是勾选了“每外设生成一对 .c/.h”,该函数位于 gpio.c的;如果你没有勾选,他可能可能留在 main.c。函数放在哪里不改变寄存器配置结果。

4.6 main.h 中的 User Label 宏
#define LED_CORE_Pin GPIO_PIN_2
#define LED_CORE_GPIO_Port GPIOB
#define LED_FDB_Pin GPIO_PIN_8
#define LED_FDB_GPIO_Port GPIOB2
3
4

User Label 的意义不只是少打几个字符,而是把“PB2”提升为“核心板 LED”。后续换板换芯片时,上层代码可以继续调用有业务含义的名字,硬件映射集中在一个位置修改,方便我们迁移到其他板子。
4.7 USER CODE 区域的保护边界
CubeMX 只承诺保留成对标记之间的内容:
/* USER CODE BEGIN 2 */
/* 这里的内容会在正确配置下保留。 */
/* USER CODE END 2 */2
3
不要把手写代码放在标记外,也不要修改标记文本。更稳妥的做法是把 BSP、协议和业务模块放到独立目录,仅在生成文件的 USER CODE 区调用它们。我们只能再类似下面这写红框的位置加入【成对的 BEGIN 和 END 之间】自己的代码,否则当你用 STM32CubeMX 重新生成代码时,你写在其他地方的代码会直接被覆盖从而丢失代码。

4.8 读生成代码时只问四个问题
面对生成文件时,不需要从第一行读到最后一行。先围绕一个函数回答四个问题:
- 谁调用它:例如
main()在进入while (1)前调用MX_GPIO_Init()。 - 调用前提是什么:配置 GPIOB 前必须先使能 GPIOB 外设时钟。
- 它改变了什么硬件状态:
MX_GPIO_Init()设置输出初值、模式、上下拉和速度;HAL_GPIO_WritePin()改变输出电平。 - 重新生成后谁负责维护它:CubeMX 负责生成区,用户只在 USER CODE 区调用手写模块;独立的
bsp_led.c/.h由项目自己维护。
回答完这四个问题,再沿函数调用继续向下追踪。这样既能看懂生成代码,也不容易误改 CubeMX 管理的区域。
五 用 HAL 控制 LED
5.1 最小版本:直接翻转 PB2
把代码写入 while (1) 中:
while (1)
{
HAL_GPIO_TogglePin(LED_CORE_GPIO_Port, LED_CORE_Pin);
HAL_Delay(500U);
}2
3
4
5
预期现象是 PB2 LED 每 500 ms 改变一次状态,一个完整亮灭周期约 1 s。这里的 500 ms 是两次翻转之间的间隔,不是完整周期。
5.2 明确写出亮灭状态
直接使用 HAL_GPIO_WritePin() 时,要按硬件极性选择 SET/RESET:
/* 两颗 LED 同时点亮。 */
HAL_GPIO_WritePin(LED_CORE_GPIO_Port, LED_CORE_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(LED_FDB_GPIO_Port, LED_FDB_Pin, GPIO_PIN_RESET);
HAL_Delay(500U);
/* 两颗 LED 同时熄灭。 */
HAL_GPIO_WritePin(LED_CORE_GPIO_Port, LED_CORE_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_FDB_GPIO_Port, LED_FDB_Pin, GPIO_PIN_SET);
HAL_Delay(500U);2
3
4
5
6
7
8
9
这段代码适合核对极性,但上层仍要记住每颗灯的有效电平。工程变大后,应该让 BSP 层来隐藏差异。
5.3 用小函数隔离极性
static void LedCore_Set(bool on)
{
HAL_GPIO_WritePin(LED_CORE_GPIO_Port,
LED_CORE_Pin,
on ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
static void LedFdb_Set(bool on)
{
HAL_GPIO_WritePin(LED_FDB_GPIO_Port,
LED_FDB_Pin,
on ? GPIO_PIN_RESET : GPIO_PIN_SET);
}2
3
4
5
6
7
8
9
10
11
12
13
调用处现在表达“亮灭”,而不是“写高写低”:
LedCore_Set(true);
LedFdb_Set(true);2
5.4 推荐版本:独立 BSP
新建 BSP/Inc/bsp_led.h:
/**
* @file bsp_led.h
* @author LCKFB-YZH
* @brief 天空星核心板与筑基学习板 LED 板级接口。
*/
#ifndef BSP_LED_H
#define BSP_LED_H
#include <stdbool.h>
typedef enum
{
BSP_LED_CORE = 0,
BSP_LED_FDB
} BSP_LED_Id;
void BSP_LED_AllOff(void);
void BSP_LED_Set(BSP_LED_Id led, bool on);
void BSP_LED_Toggle(BSP_LED_Id led);
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
新建 BSP/Src/bsp_led.c:
/**
* @file bsp_led.c
* @author LCKFB-YZH
* @brief 隔离两颗 LED 相反的有效电平。
*/
#include "bsp_led.h"
#include "main.h"
void BSP_LED_AllOff(void)
{
HAL_GPIO_WritePin(LED_CORE_GPIO_Port, LED_CORE_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(LED_FDB_GPIO_Port, LED_FDB_Pin, GPIO_PIN_SET);
}
void BSP_LED_Set(BSP_LED_Id led, bool on)
{
switch (led)
{
case BSP_LED_CORE:
HAL_GPIO_WritePin(LED_CORE_GPIO_Port,
LED_CORE_Pin,
on ? GPIO_PIN_SET : GPIO_PIN_RESET);
break;
case BSP_LED_FDB:
HAL_GPIO_WritePin(LED_FDB_GPIO_Port,
LED_FDB_Pin,
on ? GPIO_PIN_RESET : GPIO_PIN_SET);
break;
default:
/* 无效 ID 不操作硬件,避免误改其他 GPIO。 */
break;
}
}
void BSP_LED_Toggle(BSP_LED_Id led)
{
switch (led)
{
case BSP_LED_CORE:
HAL_GPIO_TogglePin(LED_CORE_GPIO_Port, LED_CORE_Pin);
break;
case BSP_LED_FDB:
HAL_GPIO_TogglePin(LED_FDB_GPIO_Port, LED_FDB_Pin);
break;
default:
break;
}
}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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

头文件保护宏使用 BSP_LED_H。把 BSP/Inc 加入头文件搜索路径,并确认 bsp_led.c 参与构建;只有头文件能被找到,不代表实现文件已经链接进工程。假如你现在还不知道如何添加头文件的话,请回去看第九章:[9]新建点灯工程【寄存器版本】,看如何给Keil添加C文件和头文件路径。

5.5 阻塞闪烁主循环
在 main.c 的 USER CODE 区包含头文件:
/* USER CODE BEGIN Includes */
#include "bsp_led.h"
/* USER CODE END Includes */2
3
初始化后先确保两颗灯熄灭:
/* USER CODE BEGIN 2 */
BSP_LED_AllOff();
/* USER CODE END 2 */2
3
主循环:
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
BSP_LED_Toggle(BSP_LED_CORE);
BSP_LED_Toggle(BSP_LED_FDB);
HAL_Delay(500U);
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */2
3
4
5
6
7
8
9
10
11
12

5.6 非阻塞闪烁【可不看本章】
HAL_Delay() 会让当前执行流等待。项目只有一颗闪灯时问题不大,但主循环还要轮询按键、刷新状态机或处理通信时,长延时会拖慢所有任务。
使用 HAL_GetTick() 可以让主循环继续运行:
#define LED_TOGGLE_INTERVAL_MS 500U
static void App_LedTask(void)
{
static uint32_t last_toggle_ms = 0U;
const uint32_t now_ms = HAL_GetTick();
/* 无符号减法在 32 位 Tick 回绕后仍能正确判断短时间间隔。 */
if ((uint32_t)(now_ms - last_toggle_ms) >= LED_TOGGLE_INTERVAL_MS)
{
last_toggle_ms = now_ms;
BSP_LED_Toggle(BSP_LED_CORE);
BSP_LED_Toggle(BSP_LED_FDB);
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
BSP_LED_AllOff();
while (1)
{
App_LedTask();
/* 其他非阻塞任务放在这里。 */
}
}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
5.7 HAL_Delay() 与中断环境
默认 HAL Tick 常由 SysTick 中断递增。若在优先级更高、且阻止 Tick 中断运行的中断服务程序里调用 HAL_Delay(),等待条件可能永远不满足。即使没有死锁,在中断里等待几百毫秒也会显著增加系统中断延迟。
WARNING
中断服务程序应尽量只记录事件、搬运最少数据并快速退出。闪灯、日志、协议处理等工作放到主循环或 RTOS 任务中完成。
低功耗模式、暂停 Tick、调试器冻结或重新配置时钟也会影响延时。遇到“程序卡在 HAL_Delay()”,先观察 uwTick 是否继续增长,而不是立刻怀疑相关外设。
六 HAL GPIO API 与寄存器行为
6.1 本章用到的 API
| API | 作用 | 前置条件 | 工程边界 |
|---|---|---|---|
HAL_GPIO_Init() | 配置模式、类型、速度、上下拉和复用 | 端口时钟已使能 | 多字段通常是读改写,并发修改同一端口要有所有权规则 |
HAL_GPIO_WritePin() | 写一个或多个引脚电平 | 引脚掩码和端口有效 | 当前 HAL 实现写 BSRR,适合按位置位/复位 |
HAL_GPIO_TogglePin() | 翻转目标引脚 | 目标状态可从 ODR 推导 | 读取 ODR 后再写 BSRR,整个过程不是并发原子操作 |
HAL_GPIO_ReadPin() | 读取输入数据寄存器 IDR | 引脚已按用途配置 | 读取的是引脚采样状态,不一定等于 ODR 锁存值 |
API 原型与完整约束可查 UM1725 Rev 8。寄存器位定义以 RM0090 Rev 21 为准。
6.2 HAL_GPIO_WritePin() 为什么适合置位和复位
其关键逻辑可概括为:
if (PinState != GPIO_PIN_RESET)
{
GPIOx->BSRR = GPIO_Pin;
}
else
{
GPIOx->BSRR = (uint32_t)GPIO_Pin << 16U;
}2
3
4
5
6
7
8
BSRR 低 16 位写 1 置位对应 ODR 位,高 16 位写 1 复位对应 ODR 位。一次写操作不会先读取并重写整个 ODR,因此不会因为设置 PB2 而覆盖同端口其他位的当前状态。
6.3 HAL_GPIO_TogglePin() 仍然会读取 ODR
要翻转引脚,软件必须先知道当前 ODR 状态。HAL 先读取 ODR,再计算哪些位要置位、哪些位要复位,最后写入 BSRR。
如果主循环和中断同时翻转同一引脚,可能都读到相同旧值,最终少翻转一次。可选处理方法有:
- 规定该 GPIO 只有一个模块负责写入。
- 在短临界区内完成翻转。
- 不读取当前状态,由软件保存目标状态后调用
HAL_GPIO_WritePin()。 - 将跨任务控制请求汇总到单一 GPIO 服务或状态机。
volatile 只能约束编译器访问,不能让“读 ODR → 计算 → 写 BSRR”变成不可分割操作。
6.4 GPIOB 时钟使能后的回读
CubeMX 生成的 __HAL_RCC_GPIOB_CLK_ENABLE() 是宏,不是普通函数。HAL 写 RCC->AHB1ENR 后会回读一次对应位。这个 dummy read 不只是“确认写成功”,还为外设时钟真正生效提供必要延迟。
七 编译、下载与验证
7.1 编译前检查
bsp_led.c已加入工程源文件列表。BSP/Inc已加入 Include Paths。- 工程预定义宏包含
STM32F407xx和所需 HAL 配置。 - 使用硬件浮点时,编译器、汇编器和链接库的 FPU/ABI 选项一致。
- 点击 Rebuild,优先处理第一条 error,不要从最后一条连锁错误开始猜。

7.2 配置 CMSIS-DAP
在 Keil 中进入 Options for Target → Debug,选择 CMSIS-DAP Debugger,点击 Settings 后确认能识别 SWD 目标。

在 Flash Download 页勾选 Reset and Run,下载后自动复位运行。

识别不到芯片时,先检查供电、共地、SWDIO/SWCLK 和接口方向,再尝试把 SWD Clock 降到 1 MHz。不要一开始就反复重装软件。
7.3 下载并记录结果

7.4 先用静态电平验证极性
闪烁状态每 500 ms 变化一次,万用表可能只显示跳动或平均值。测量前先把程序改为固定状态:
BSP_LED_Set(BSP_LED_CORE, true);
BSP_LED_Set(BSP_LED_FDB, true);
while (1)
{
}2
3
4
5
预期 PB2 接近 3.3 V、PB8 接近 0 V,两颗灯都亮。随后把参数改为 false,预期 PB2 接近 0 V、PB8 接近 3.3 V,两颗灯都灭。
7.5 观察实机运行效果

7.6 用调试器确认执行路径
在 BSP_LED_Set()、HAL_GPIO_WritePin() 和 MX_GPIO_Init() 设置断点,查看 Call Stack、局部变量与 GPIOB 寄存器。建议同时观察:
RCC->AHB1ENR的 GPIOBEN。GPIOB->MODER中 PB2/PB8 模式位。GPIOB->ODR中 bit 2 和 bit 8。uwTick是否持续增加。

7.7 本章工程验收表
| 验收项 | 初学者完成标准 | 工程检查点 |
|---|---|---|
| 配置可复现 | 保存 .ioc,能够重新打开并看到本章参数 | 记录 CubeMX、STM32CubeF4 和工具链版本,工程中不依赖未说明的全局设置 |
| 上电状态安全 | 复位后 PB2、PB8 默认熄灭 | PB2 先写低、PB8 先写高,再切换为输出模式,避免初始化瞬间闪亮 |
| 编译链接 | 0 编译错误且生成固件 | BSP 源文件已加入构建,Map 文件中能找到 BSP_LED_Set(),无未定义引用 |
| 下载运行 | 下载、校验和复位均成功 | 复位后无需手动单步即可进入主循环,异常时能在 Error_Handler() 和 HardFault 入口定位 |
| 闪烁周期 | 两次翻转间隔约 500 ms,完整周期约 1 s | 区分阻塞版与非阻塞版,确认 uwTick 持续递增,长期任务不依赖溢出不安全的绝对时间比较 |
| 电气极性 | 固定亮灭测试与肉眼现象一致 | 万用表相对 GND 测得 PB2 高有效、PB8 低有效,记录板卡和测试点 |
| 调用链 | 能找到 main → BSP/HAL → GPIO | 能说明 WritePin 写 BSRR,而 TogglePin 涉及 ODR 读取,不把后者用于并发原子翻转 |
| 重新生成 | USER CODE 区内容仍在 | 重新生成前后不破坏原有工程结构,确认独立 BSP 未被覆盖且生成文件只出现预期变化 |
八 生成代码与手写代码的边界
8.1 适合交给 CubeMX 的内容
- 芯片、封装和引脚复用选择。
- RCC、PLL、AHB/APB 分频和基础 Tick。
- GPIO、DMA、中断及外设句柄的初始配置。
- 启动文件、链接脚本和 IDE 工程骨架。
8.2 适合自己维护的内容
- BSP、设备驱动、协议解析和状态机。
- 业务逻辑、错误恢复、超时和日志。
- 与产品安全状态、并发所有权有关的决策。
- 单元测试、接口文档和迁移说明。
8.3 重新生成前的工程习惯
- 确认工作区变更已经提交或至少可恢复。
- 修改
.ioc后只生成一次,立即查看 Git diff。 - 重点检查时钟、引脚、NVIC、DMA、启动文件和链接设置是否发生意外变化。
- 重新构建全部目标,不只编译刚改动的文件。
- 在板上执行最小冒烟测试:能下载、能进入
main()、Tick 正常、关键输出处于安全状态。
九 常见问题
9.1 CubeMX 重新生成后代码消失
现象:手写的闪灯代码或初始化代码不见了。
可能原因:代码写在 USER CODE 区外,或改动了生成器管理的文件。
检查与解决:查看 Git diff 找到被覆盖位置;把调用移入 USER CODE 区,把完整模块移到独立 BSP 目录。恢复后重新生成一次,确认仍能保留。
9.2 bsp_led.h: No such file or directory
现象:编译器找不到头文件。
可能原因:BSP/Inc 未加入 Include Paths,或大小写不一致。
检查与解决:确认磁盘上的实际路径和文件名;把目录而不是具体头文件加入搜索路径;清理后重新构建。
9.3 undefined reference to BSP_LED_Set
现象:编译通过,链接失败。
可能原因:bsp_led.c 没有加入构建,或函数声明与定义不一致。
检查与解决:查看编译输出中是否出现 bsp_led.c;检查工程分组、CMake source list 或 EIDE 源文件排除状态;核对函数签名。
9.4 LED 完全不亮
现象:下载成功,但 PB2/PB8 均无现象。
检查顺序:
- 核对供电、共地和核心板安装方向。
- 确认程序已越过
SystemClock_Config()和MX_GPIO_Init()。 - 查看 GPIOBEN、MODER 和 ODR。
- 改成静态亮灯程序,用万用表直接测 PB2/PB8。
- 再检查 BSP 极性与主循环是否被其他代码阻塞。
9.5 PB8 亮灭与 PB2 相反
现象:两路写入相同 GPIO_PIN_SET/RESET,两颗灯一亮一灭。
原因:PB2 高电平有效,PB8 低电平有效。
解决:不要把相同引脚电平当成相同 LED 状态。使用本章 BSP,让上层传入 true/false 表示亮灭。
9.6 程序卡在 Error_Handler()
现象:进入 main() 后很快停在错误处理函数。
可能原因:HSE 未起振、PLL 参数不合法、供电/电压调节等级不满足频率,或时钟切换超时。
检查与解决:回看 CubeMX 时钟树是否无红色错误;观察 RCC->CR 的 HSERDY/PLLRDY 和 RCC->CFGR 的 SWS;确认板载 HSE 频率与 HSE_VALUE 一致。不要直接注释掉错误处理继续运行。
9.7 HAL_Delay() 不返回
现象:程序停在延时循环,LED 只改变一次。
可能原因:SysTick 没有运行、Tick 被暂停,或在阻止 Tick 的中断上下文调用延时。
检查与解决:观察 uwTick;检查 SysTick 中断和优先级;把长延时移出中断;确认时钟重配后 HAL Tick 已正确重新初始化。
9.8 下载成功但程序不自动运行
现象:手动按复位后才运行,或调试退出后停住。
检查与解决:确认 Reset and Run;检查 BOOT0 保持用户 Flash 启动;确认没有断点、看门狗复位或 HardFault;必要时使用“复位下连接”擦除异常固件后重新下载。
十 练习与总结
10.1 动手练习
- 只保留 PB2,分别用 Toggle、WritePin 和 BSP 实现 1 s 周期闪烁。
- 让 PB2 与 PB8 交替点亮,验证两路输出电平应该相同还是相反。
- 在非阻塞主循环中加入一个每 100 ms 运行的计数任务,证明 LED 闪烁不会阻塞它。
- 故意移除
bsp_led.c的构建项,记录编译与链接阶段报错的差异,再恢复工程。 - 在调试器中单步执行
HAL_GPIO_WritePin(),观察写 BSRR 前后 ODR 的目标位变化。
10.2 本章小结
- CubeMX 负责把芯片、时钟、引脚和软件包配置转换为工程骨架,
.ioc必须与源码一起管理。 - PB2 高电平点亮,PB8 低电平点亮;BSP 应隐藏这种板级极性差异,不能在写代码的时候还要考虑是高电平点亮还是低电平点亮。
HAL_GPIO_WritePin()当前实现通过 BSRR 置位/复位,HAL_GPIO_TogglePin()则包含 ODR 读取,不能忽略并发风险。HAL_Delay()适合最小演示,多个任务并行推进时应使用 Tick、定时器或 RTOS 调度。- 编译成功只是第一步,下载日志、静态电平、寄存器和实机现象共同组成验证闭环。