编写者:LCKFB-YZH
版本:v1.0
日期:2026-10-23
更新记录:
- v1.0(2026-10-23):完成 CubeMX 工程目录、启动流程、CMSIS/HAL 分层、用户文件、链接与调试追踪方法说明。
阅读说明:本文由笔者借助 AI 辅助编写。强烈建议大家在学习时也多问问AI。
一 本章简介
第 11 章(【11】新建点灯工程【HAL库版本】)里,我们已经用 CubeMX 生成工程,让 LED 闪了起来。当时主要修改的是 main.c 和 bsp_led.c,工程里还有不少文件没有展开看。现在打开 Keil,自己写的代码只有几行,旁边却有一长串文件:点个灯,为什么要用到这么多东西?
这一章就继续使用那个点灯工程,把这些文件串起来。先从熟悉的 main.c 出发,跟着 HAL_GPIO_WritePin() 找到函数实现和寄存器定义;再看看这些文件怎样编译成固件,芯片上电后又怎样运行到 main()。点灯程序已有的 SysTick、全局初始化和重新生成代码的问题,也会放到相应位置一起说明。
阅读时最好把工程放在旁边,看到文件名就打开看一眼。我们不需要逐行读完整个库,但要知道一段代码从哪里来、在什么地方起作用,以及下次修改应该落在哪个文件里。
1.1 学完后能做什么
本章围绕三件事展开:找到一次点灯调用真正执行的代码,把自己的文件正确加入 Keil,以及在重新生成后保住这些代码。先做到这三件事,再回头看启动和链接细节。
| 学习层次 | 这次需要做到什么 |
|---|---|
| 第一次会操作 | 找到 GPIO 初始化;从调用找到声明和实现;加入自己的 .c/.h;区分头文件错误与链接错误;在合适的位置保存手写代码 |
| 第一次理解关系即可 | 知道启动代码先于 main();知道 HAL 通过设备定义访问寄存器;知道 SysTick 推进延时使用的计数 |
| 工程进阶时再展开 | 用 Map 确认最终实现,理解段布局、CCM 和节回收;比较工具链启动差异;分析 GPIO 翻转竞争与时钟变化后的时基配置 |
这里沿用第 11 章的板级支持包(Board Support Package,BSP)bsp_led.c/.h。如果工程还没有 BSP,从 main() 里的 GPIO 调用开始也可以。阅读前能完成一次 Keil 编译、下载,并能区分 C 文件、头文件和函数声明即可。后面用到指针、结构体和位掩码时,会结合当前表达式说明,不要求先读完整个 C 语言教程。
1.2 本章使用的工程和版本
操作沿用第 11 章《新建点灯工程【HAL 库版本】》的 Keil 点灯工程。源码分析采用下面的组合,已有工程版本不同时可以对照阅读,不必专门升级或降级。
| 项目 | 本章采用的条件 |
|---|---|
| 芯片与板卡 | 天空星 STM32F407VET6 核心板;PB8 LED 使用筑基学习板 |
| 操作主线 | 第 11 章的 Keil 点灯工程与 bsp_led.c/.h |
| 配置工具与驱动 | CubeMX 6.14.0、STM32CubeF4 V1.28.2、HAL V1.8.4 |
| 内核与设备支持 | CMSIS-Core(M) V5.6.0、STM32F4 CMSIS-Device V2.6.11 |
硬件抽象层(Hardware Abstraction Layer,HAL)和 Cortex 微控制器软件接口标准(Cortex Microcontroller Software Interface Standard,CMSIS)会在源码追踪中展开。.ioc 文件名取决于 Project Name,文中的 project.ioc 指你自己工程中的配置文件。
选读:从工程文件确认各组件版本
CubeMX、固件包、HAL、CMSIS-Core 和 CMSIS-Device 各有自己的版本号,CubeMX 的版本只是它自己的版本号。
| 组件 | 版本/条件 | 核对位置 |
|---|---|---|
| STM32CubeMX 工程格式 | CubeMX 6.14.0 生成记录 | project.ioc 中的 MxCube.Version |
| STM32CubeF4 固件包 | V1.28.2 | project.ioc 中的 ProjectManager.FirmwarePackage |
| STM32F4 HAL Driver | V1.8.4 | stm32f4xx_hal.c 的版本宏 |
| CMSIS-Core(M) | V5.6.0 | cmsis_version.h |
| STM32F4 CMSIS-Device | V2.6.11 | stm32f4xx.h |
| MCU | STM32F407VET6 | .ioc 中的 Mcu.CPN |
版本可以直接从工程文件确认。例如,在 stm32f4xx_hal.c 和 cmsis_version.h 中能找到:
/* stm32f4xx_hal.c */
#define __STM32F4xx_HAL_VERSION_MAIN (0x01U)
#define __STM32F4xx_HAL_VERSION_SUB1 (0x08U)
#define __STM32F4xx_HAL_VERSION_SUB2 (0x04U)
/* cmsis_version.h */
#define __CM_CMSIS_VERSION_MAIN (5U)
#define __CM_CMSIS_VERSION_SUB (6U)2
3
4
5
6
7
8
CMSIS-Device 的版本在 stm32f4xx.h 中,搜索 __STM32F4xx_CMSIS_VERSION_MAIN、SUB1、SUB2。本章对应的值是 0x02、0x06、0x0B,即 2.6.11。

1.3 怎样阅读这一章
第一次阅读可以按下面的顺序做,不用先把所有选读框展开:
- 读本文第二部分,打开
main.c、gpio.c和main.h,找到点灯工程的初始化。 - 跟着第 3.1~3.6 节追一次 GPIO 写入,分清调用、声明、实现和寄存器定义。
- 完成第 4.1~4.3 节,给 Keil 加入 BSP,并在工程副本中区分编译错误与链接错误。
- 读本文第五部分的 Keil 启动流程、第 6.1~6.2 节的 SysTick 和全局初始化,先理解谁调用谁。
- 按本文第七部分放置手写代码,最后完成第 9.1 节的三个基础实验。
二 先打开点灯工程

2.1 从 main.c 里熟悉的几行开始
在 Keil 中打开 main.c,找到 main()。先略过文件开头的注释和声明,看看进入主循环之前的调用顺序。下面摘出点灯工程的初始化部分:
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
BSP_LED_AllOff();2
3
4
HAL_Init() 做 HAL 的基础初始化;SystemClock_Config() 配置系统时钟;MX_GPIO_Init() 按 CubeMX 中的设置配置引脚;BSP_LED_AllOff() 则是我们自己写的板级函数,让两颗 LED 处于熄灭状态。这个的顺序是有实际意义的:BSP 控制 LED 时,前面的端口时钟和引脚配置必须已经完成。
这几行代码把两类工作放在了一起:CubeMX 生成芯片初始化,我们添加应用所需的功能。main.c 里还会有 Error_Handler()、函数声明和 USER CODE 标记。随着程序变大,可以把业务拆到自己的文件中,main() 继续负责组织初始化和主循环;本文第七部分会说明怎样拆。
现在先对 MX_GPIO_Init() 执行 “转到定义”。如果在 STM32CubeMX 生成工程时勾选了“每个外设生成一对 .c/.h 文件”,会进入 gpio.c,声明放在 gpio.h;未勾选时,函数可能仍在 main.c。这是生成方式的差别,初始化所做的事情相同。MX_DMA_Init() 等外设初始化也可以按这种方式分文件。

光标先对准这行函数,然后点击鼠标右键,单击 Go To Definition Of "MX_GPIO_Init',就能找到具体的函数定义了。
2.2 对照磁盘目录和 Keil 工程树
把文件管理器中的工程目录也打开,与 Keil 左侧的工程树放在一起看。启用外设文件分离、并加入 BSP 后,大致是下面的样子:
project/
├── 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/
│ │ ├── Device/ST/STM32F4xx/
│ │ └── Include/
│ └── STM32F4xx_HAL_Driver/
│ ├── Inc/
│ └── Src/
├── BSP/
│ ├── Inc/
│ └── Src/
├── MDK-ARM/
│ ├── fdb_hal_led.uvprojx # Keil 工程配置,名称会随工程变化
│ └── startup_stm32f407xx.s # Keil 使用的启动文件
└── project.ioc2
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

Core 里是应用入口和生成的初始化代码,Drivers 里是 Arm、ST 提供的支持文件,BSP 是我们自己加的。.ioc 保存 CubeMX 配置,MDK-ARM 中的 .uvprojx 则告诉 Keil 怎样构建工程。先分清配置输入、应用与初始化、厂商接口、启动与存储布局、构建配置这几类,文件名就容易找了。
Keil 左侧的 Group 是工程分组,名称不一定对应磁盘目录【你可以把他理解为虚拟文件夹,只是为了方便我们组织工程】。比如把 bsp_led.c 复制到 BSP/Src 后,Keil 未必会自动编译它,还需要把它加入当前 Target。第 4.3 节会实际检查源文件列表和 Include Paths。
2.3 这些文件通常由谁维护
下面的表可以在以后查文件时使用。第一次阅读先找到 Core、CMSIS、HAL 和 BSP 即可,启动文件与链接布局后面还会展开。
| 位置 | 主要提供者 | 职责 | 通常如何修改 |
|---|---|---|---|
Core/Inc、Core/Src | CubeMX + 用户 | 应用入口、外设初始化、MSP、中断入口 | USER CODE 区或按项目规则维护 |
Drivers/CMSIS/Include | Arm | Cortex-M 内核寄存器、编译器抽象、内核内联函数 | 通常不改 |
Drivers/CMSIS/Device/ST/... | ST | STM32F407 设备头、系统文件、启动模板 | 通常不改,按固件包升级 |
Drivers/STM32F4xx_HAL_Driver | ST | HAL/LL 外设驱动 | 通常不直接改;通过配置和自己的模块扩展 |
startup_stm32f407xx.s | ST/工具链模板 | 向量表、复位入口、默认异常处理 | 只在明确理解启动与工具链时修改 |
.ld/Scatter 文件 | 工具链模板 + 项目 | 定义 Flash/RAM 区域和段布局 | Bootloader、外部 RAM、特殊段时按设计修改 |
.ioc | CubeMX | 记录芯片、时钟、引脚与代码生成配置 | 优先用 CubeMX 修改,提交前审查 diff |
BSP、App | 项目 | 隔离板级硬件、业务和服务 | 由项目人员维护 |
2.4 .ioc 怎样变成这些 C 文件
回到 CubeMX 看一次生成过程,就能理解 Core 与 Drivers 为什么分开放。UM1718 Rev 50 给出了下面的关系图:

图源:UM1718 Rev 50,Figure 1。
CubeMX 读取芯片资料、选定的软件包和用户配置,复制或引用驱动库,再生成初始化代码与 IDE 工程文件。比如在界面上把 PB8 设为输出,工具会把对应配置写进 GPIO 初始化函数;但 LED 按什么节奏闪、什么时候报警,需要由应用代码决定。生成后的工程再交给 Keil 等工具编译,CubeMX 本身不承担编译工作。
用文本编辑器打开 .ioc,会看到这样的内容:
#MicroXplorer Configuration settings - do not modify
CAD.formats=
CAD.pinconfig=
CAD.provider=
File.Version=6
GPIO.groupedBy=Group By Peripherals
KeepUserPlacement=false
Mcu.CPN=STM32F407VET6
Mcu.Family=STM32F4
Mcu.IP0=NVIC
Mcu.IP1=RCC
Mcu.IP2=SYS
Mcu.IPNb=3
Mcu.Name=STM32F407V(E-G)Tx
Mcu.Package=LQFP1002
3
4
5
6
7
8
9
10
11
12
13
14
15
2.5 main.h 里的引脚名字从哪里来
第 11 章给 LED 引脚设置了 User Label,CubeMX 会在 main.h 中生成相应的端口宏和引脚宏。以底板 LED 为例,应用里看到的是 LED_FDB_GPIO_Port、LED_FDB_Pin,它们最终对应 GPIOB 和第 8 位引脚掩码。gpio.c 使用这些名字完成初始化,bsp_led.c 也可以使用它们控制 LED。

main.h 还常放着 Error_Handler() 等声明。项目变大后,模块自己的接口放在各自头文件;板级映射需要集中管理时,可以另设 board.h 或 BSP 私有头。这样其他模块不用为了调用一个业务函数,把 main.h 中的内容全部引进来。
这里可以做一个小观察:在工程副本中只改变一个 GPIO 的 User Label 或者 输出速度,生成后查看 .ioc、main.h、gpio.c 的差异。前者记录配置,后两者分别体现名字和初始化参数的变化。
三 跟着一次 GPIO 写入读库文件
3.1 从调用处找到声明和实现
在第 11 章的 bsp_led.c 中找到 BSP_LED_Set(),里面会调用 HAL_GPIO_WritePin()。
对 HAL_GPIO_WritePin() 使用“转到声明/定义”。如果先跳到 stm32f4xx_hal_gpio.h,看到的是函数原型:
void HAL_GPIO_WritePin(GPIO_TypeDef *GPIOx,
uint16_t GPIO_Pin,
GPIO_PinState PinState);2
3
| 项 | 含义与使用条件 |
|---|---|
GPIOx | GPIO 端口实例,例如 GPIOB;对应端口时钟必须已使能 |
GPIO_Pin | GPIO_PIN_x 组成的引脚位掩码,可以同时指定多个引脚 |
PinState | GPIO_PIN_SET 置位,GPIO_PIN_RESET 复位;LED 亮灭还取决于实际电路板的极性 |
| 返回值 | void,没有 HAL 状态码; |
头文件告诉编译器“这个函数怎样调用”,真正实际执行的代码在 stm32f4xx_hal_gpio.c 中。继续跳转到实现;如果 IDE 没有跳到想看的位置,全局搜索函数名也能找到。搜索时区分函数声明、定义和调用,不必依赖某一个编辑器快捷键。
3.2 HAL 最后写了哪个寄存器
下面是 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
GPIOx 是调用时传进来的端口地址,GPIO_Pin 是引脚位掩码。把输出锁存位置 1 时,掩码写进 BSRR 的低 16 位;清 0 时,先左移 16 位,再写进高 16 位。BSRR 的全称是位设置/复位寄存器(Bit Set/Reset Register),置位和复位规则见 RM0090 Rev 21 的 GPIO 寄存器章节。
还是不太理解的,可以返回去看第八章的介绍。[8]认识GPIO。
例如,GPIO_PIN_8 是 1U << 8,值为 0x0100,不是整数 8。向 BSRR 写 0x00000100,会把输出数据寄存器(Output Data Register,ODR)的第 8 位置 1;写 0x01000000,会把这一位清 0。其他引脚位没有被选中,不会因为这次写操作而改变。相比先读 ODR、修改后再整字写回,BSRR 避免了把其他位的旧值一起写回的问题。
一次 BSRR 写入可以原子地完成相应置位/复位操作,但两个模块如果同时控制 PB8,最终电平仍取决于谁后写。板级驱动需要约定由谁控制这个引脚。GPIO_PIN_SET 表示把对应输出锁存位置 1。在本章推挽输出配置下,通常对应输出高电平;开漏输出时则表示释放输出。底板 PB8 LED 是低电平点亮,所以把 ODR 第 8 位置 1 对应的是熄灭,亮灭意图由 BSP 根据电路极性转换。
3.3 GPIOB 和 BSRR 是怎样对应到地址的
接着看 GPIOx->BSRR。要把它与实际寄存器联系起来,需要分别找到端口地址和成员偏移。指针和结构体还不熟悉时,先对照下面几项:
| 表达式 | 在这个例子中的意思 |
|---|---|
GPIOB_BASE | GPIOB 寄存器区的起始地址 |
GPIO_TypeDef * | 按 GPIO 寄存器布局解释这片地址的指针类型 |
GPIOx->BSRR | 访问这个端口中 BSRR 对应的位置 |
GPIO_PIN_8 | 选中第 8 位的掩码 0x0100,不是把编号 8 直接写进寄存器 |
类型转换只是让编译器按指定布局计算访问地址,不会在这片地址新建一个普通结构体。
先回到调用处,跟着传入的端口实参查。例如 LED_FDB_GPIO_Port 在 main.h 中展开为 GPIOB;再查 GPIOB,会进入 stm32f407xx.h,看到它由 GPIOB_BASE 转换为 GPIO_TypeDef *。这条线回答“访问的是哪个端口”。

再从形参类型 GPIO_TypeDef 找到结构体定义,检查 BSRR 成员的位置。对 STM32F407,GPIOB 基地址是 0x40020400,BSRR 偏移为 0x18,因此本次访问对应地址 0x40020418。地址、偏移和访问规则都可在 RM0090 Rev 21 的存储器映射与 GPIO 章节核对。这样,C 语言里的“结构体成员访问”就和芯片上的寄存器地址接上了。

stm32f407xx.h 还定义了 GPIO、RCC 等外设的寄存器结构、基地址和位掩码,例如 RCC_AHB1ENR_GPIOBEN,以及 IRQn_Type 中断编号。这些内容属于 ST 提供的 CMSIS-Device 支持。
选读:寄存器前的 __I、__O、__IO 表示什么
寄存器成员前常见的 __I、__O、__IO,表达软件的访问方向:
| 宏 | 常见展开 | 表达意图 |
|---|---|---|
__I | volatile const | 软件只读 |
__O | volatile | 软件只写 |
__IO | volatile | 软件可读写 |
三个宏都包含 volatile。外设寄存器的内容可能由硬件自行改变,而且读写寄存器本身也可能触发硬件动作。因此,编译器需要保留程序所要求的寄存器读写,不能像处理普通变量那样,随意复用上次读取的值,或删除看似多余的写入。
常说的“每次都重新读写寄存器”,主要是在描述这种对编译器优化的约束。这里要避免混淆:volatile 本身不会关闭处理器的硬件 Cache,也不自动提供原子操作、线程同步或内存屏障,你可以理解为这就是个提醒,是为了让编译器在编译固件的时候注意一下。
3.4 为什么 CMSIS 目录又分成内核和设备两部分
刚才找到的 stm32f407xx.h 描述 STM32F407 外设;再打开 Drivers/CMSIS/Include/core_cm4.h,看到的则是 Cortex-M4 内核资源。它们来自不同层次,所以分开放置。
Arm 提供 CMSIS-Core 的规范与实现。先认两个与本章有关的资源:嵌套向量中断控制器(Nested Vectored Interrupt Controller,NVIC)管理外设中断,SysTick 是内核里的系统滴答定时器,点灯工程用它推进 HAL 时基。
选读:以后还会遇到的内核资源
| 名称或接口 | 用途 |
|---|---|
| SCB(System Control Block,系统控制块) | 内核系统控制,例如向量表位置 |
| MPU(Memory Protection Unit,存储器保护单元) | 内存区域的访问保护 |
| DWT(Data Watchpoint and Trace,数据观察点与跟踪) | 数据观察与周期计数等调试功能 |
| CoreDebug | 内核调试寄存器接口 |
__NOP()、__WFI() | 空操作、等待中断等内核指令接口 |
__disable_irq()、__get_PRIMASK() | 中断屏蔽控制与状态读取 |
这些定义也能沿 CMSIS 头文件找到,编译器相关的内联、对齐和屏障由相应抽象统一处理。当前先知道它们属于内核接口,不用逐个记住。
ST 按 CMSIS 规范补上 STM32F4 的设备头、系统文件和启动模板。不同厂商都可以使用 Cortex-M4 内核,但各自的 GPIO、时钟等外设不一定相同,所以不能共用一份设备寄存器头文件。
工程为什么会选中 stm32f407xx.h?打开上一级 stm32f4xx.h,能找到类似下面的型号选择分支:
#if defined(STM32F407xx)
#include "stm32f407xx.h"
#endif2
3
Keil 的 C/C++ 预定义宏中通常有 STM32F407xx。宏选错时,可能缺少某个外设或中断名字,也可能部分代码仍能编译,却使用了另一型号的寄存器定义。设备宏、启动文件、链接布局和板上的芯片要对应起来,不能只看工程名称写着“F407”。

追源码时还会遇到另一种情况:某个接口只有头文件里的实现,找不到对应 .c。许多 CMSIS 函数用 __STATIC_INLINE 等宏定义成内联函数,这是正常的;展开宏、读头文件内的函数体即可。

3.5 HAL 头文件为什么能层层包含进来
回到 Drivers/STM32F4xx_HAL_Driver,目录中同一外设的 .h 和 .c 通常成对出现:
Drivers/STM32F4xx_HAL_Driver/
├── Inc/
│ ├── stm32f4xx_hal.h
│ ├── stm32f4xx_hal_def.h
│ ├── stm32f4xx_hal_gpio.h
│ ├── stm32f4xx_hal_rcc.h
│ └── stm32f4xx_ll_*.h
└── Src/
├── stm32f4xx_hal.c
├── stm32f4xx_hal_gpio.c
├── stm32f4xx_hal_rcc.c
└── stm32f4xx_ll_*.c2
3
4
5
6
7
8
9
10
11
12
stm32f4xx_hal.h 是 HAL 总入口,stm32f4xx_hal_def.h 提供公共定义,各外设文件提供相应 API。
先从手上的文件往下找。第 11 章的 bsp_led.c 包含 main.h;CubeMX 生成的 main.h 直接包含 stm32f4xx_hal.h。HAL 总头再包含应用自己的 stm32f4xx_hal_conf.h,配置头按照模块宏引入 GPIO 等外设头文件:
bsp_led.c(也可从 main.c 开始)
→ main.h
→ stm32f4xx_hal.h
→ stm32f4xx_hal_conf.h
→ stm32f4xx_hal_gpio.h2
3
4
5
在自己的工程中逐层查看 #include,确认实际走的是哪条路径。配置头中会看到这些开关:
#define HAL_MODULE_ENABLED
#define HAL_GPIO_MODULE_ENABLED
#define HAL_RCC_MODULE_ENABLED
#define HAL_CORTEX_MODULE_ENABLED2
3
4

在这个配置头里搜索 HAL_GPIO_MODULE_ENABLED,可以看到它控制 #include "stm32f4xx_hal_gpio.h"。这样,GPIO 的类型、引脚宏和函数声明才沿包含关系进入调用文件。STM32F407xx 负责选择设备头,HAL_GPIO_MODULE_ENABLED 控制 GPIO 模块的相关包含和条件编译。
另一个常见工程宏是 USE_HAL_DRIVER:设置后,stm32f4xx.h 会继续包含 HAL 总头。这是一条设备头引入 HAL 的路径。
配置头里还保存 HSE/HSI 常量、HAL 时基优先级、断言等配置。特别是 HSE_VALUE,它只是软件计算频率时使用的常量,要与板上真实晶体或外部时钟频率一致,修改它不会改变晶体本身的频率。天空星核心板上面所贴的高速晶振都是8Mhz的。

3.6 把刚才经过的文件连起来
到这里,我们已经实际打开了调用文件、HAL 头文件、HAL 实现和 CMSIS 设备头。回头看这张图,就能知道每一步是在查什么:
flowchart TD
A["调用点\nBSP_LED_Set"] --> B["声明\nstm32f4xx_hal_gpio.h"]
B --> C["实现\nstm32f4xx_hal_gpio.c"]
C --> D["设备定义\nGPIO_TypeDef、GPIOB"]
D --> E["硬件寄存器\nGPIOB_BSRR"]
E --> F["参考手册\n偏移、位域、写入语义"]
图中的箭头表示源码查找顺序。运行时并不会“先执行头文件,再执行结构体定义”,实际执行的是 BSP 调用 HAL,HAL 再写寄存器。
如果你手里就有 筑基学习板和调试器,可以在第 11 章固定亮灭的调用处设置断点,进入 HAL 实现,单步执行 BSRR 写入,观察 GPIOB->ODR 对应位和引脚电平。BSRR 用于发出置位/复位操作,输出锁存状态应看 ODR。

3.7 HAL、LL 和自己的代码怎样分工
把视线从 GPIO 放回整个工程,可以得到下面的依赖关系:
flowchart TD
APP["应用层 App\n状态机、业务逻辑"] --> BSP["BSP/设备驱动\n板级极性、器件协议"]
BSP --> HAL["STM32F4 HAL/LL\n外设通用驱动"]
INIT["Core 初始化层
main、GPIO
MSP、中断入口"] --> HAL
HAL --> DEVICE["CMSIS-Device
STM32F407
寄存器与 IRQ 定义"]
DEVICE --> CORE["CMSIS-Core\nCortex-M4 内核接口"]
DEVICE --> PERIPH["STM32F407 外设寄存器"]
CORE --> CPU["Cortex-M4 内核寄存器"]
IOC[".ioc + CubeMX"] -. "复制或引用驱动库" .-> HAL
IOC -. "生成初始化与工程配置" .-> INIT
实线表示主要依赖,虚线表示 CubeMX 的生成工作。CubeMX 生成 Core 中的初始化骨架,复制或引用驱动;BSP 和 App 由项目维护。图里的 Core 目录是工程目录名,不能与 CMSIS-Core 这个内核接口组件混为一谈。
HAL 提供外设接口,CMSIS 提供内核和芯片定义,BSP 把板上的引脚、电平和器件对应起来。先用这三个分工理解点灯代码即可。
选读:什么时候考虑 LL 或直接访问寄存器
HAL 把外设初始化、轮询、中断、直接存储器访问(Direct Memory Access,DMA)和状态管理组织成统一风格的 API。同系列迁移时,这能省下不少重复工作,但时钟、引脚、DMA 映射、中断和外设能力仍要按目标芯片配置;涉及带缓存的其他芯片时,还要处理缓存一致性。
低层驱动(Low-Layer,LL)更接近寄存器,通常较少管理状态,很多接口也放在头文件中。需要严格控制访问顺序、调用开销或特殊功能时,可以考虑 LL 或直接访问寄存器。初始化之外的状态、超时、并发和错误恢复则需要由自己的驱动代码处理。
我建议先让一个外设的管理方式清楚。若一个驱动正在使用外设,另一个模块又用 LL 重配关键控制位,原驱动依赖的配置与状态就可能被破坏。也可以由 HAL 完成初始化,再由 LL 处理经过设计的高频数据路径,但要写清哪些寄存器允许修改、何时交接,以及失败后谁负责恢复。
四 这些文件怎样变成固件
4.1 从源文件到 AXF、HEX 和 BIN
前面打开了很多 .h 和 .c。现在点一次 Rebuild,看构建输出,就会发现它们参与的方式不同:C 源文件经过预处理、编译和汇编形成目标文件,启动汇编也形成目标文件,最后由链接器把目标文件与运行库组合起来。
flowchart TD
SRC["C 源文件与它包含的头文件"] --> CC["预处理、编译、汇编"]
CC --> OBJ["目标文件 .o"]
ASM["启动汇编文件 .s"] --> AS["汇编"]
AS --> OBJ
OBJ --> LINK["链接器"]
LIB["运行库与 Scatter / .ld 布局规则"] --> LINK
LINK --> ELF["ELF / AXF 固件与 Map 文件"]
ELF --> BIN["按下载方式使用 AXF、HEX 或 BIN"]
.h 通常通过 #include 成为某个 C 文件的输入,不单独编译;.ioc 是 CubeMX 的配置输入,不进入这个编译过程。Keil 工程配置决定源文件、宏和头文件搜索路径,链接布局决定代码、数据和栈等放在什么地址。
所以 找不到头文件 发生在编译调用代码时, 找不到函数实现 则通常发生在链接时。下面用点灯工程做一次对照,差别就很直观了。
4.2 头文件能找到,为什么还会链接失败
HAL_GPIO_WritePin() 的声明在头文件中,实现却在 stm32f4xx_hal_gpio.c 中。调用文件能看到声明,只说明编译器知道该怎样生成调用;链接器还要找到函数实现。
对本章的包含方式,需要同时启用 HAL_GPIO_MODULE_ENABLED,并让 GPIO 源文件参与编译链接。前者控制头文件包含和驱动中的条件编译,后者决定实现是否被构建。即使 .c 已加入工程,模块宏关闭后,其中的函数也可能被预处理排除。
在工程副本中先全量构建一次,确认原工程正常,再做下面的操作。每次只改一项,记录首条相关报错,恢复后再做下一项;这个实验只观察构建过程,不需要下载到板上。
| 操作 | 保持不变 | 预期观察 |
|---|---|---|
从当前 Target 排除 stm32f4xx_hal_gpio.c,然后 Rebuild | GPIO 模块宏与实际 HAL 调用 | 调用点可以编译,但链接器报告缺少 GPIO 函数实现 |
恢复源文件,只注释 HAL_GPIO_MODULE_ENABLED,然后 Rebuild | 点灯工程的 GPIO 调用 | 按本章包含关系,GPIO 类型或声明缺失,通常先在编译阶段暴露问题 |
| 恢复宏和源文件,再次 Rebuild | 其余配置全部不变 | 回到原本可构建的状态 |
Keil 的链接错误常写成 Undefined symbol,GNU 常写成 undefined reference。具体报错文字可能不同,但都可以顺着缺失的函数名找到实现文件。
从当前 Target 排除 stm32f4xx_hal_gpio.c,然后 Rebuild : 
只注释 HAL_GPIO_MODULE_ENABLED,然后 Rebuild : 
恢复宏和源文件,再次 Rebuild : 
4.3 把 bsp_led.c 加入当前工程
自己添加模块时,也要把刚才的两件事落实到工程配置。以 BSP 为例,在 Keil 中把 bsp_led.c 加入当前 Target 的 Group,再在 C/C++ 的 Include Paths 中添加指向 BSP/Inc 的路径。按本文目录结构,.uvprojx 位于 MDK-ARM 中,这里应填写 ..\BSP\Inc;相对路径以 Keil 工程文件所在目录为基准。头文件路径让编译器找到 bsp_led.h,源文件列表让编译器构建实现。

添加后检查文件是否被当前构建配置排除,再 Rebuild,确认输出中确实编译了 bsp_led.c。
4.4 用 Map 文件确认最终链接结果
第一次先记住:Map 是本次链接结果的清单,能确认函数最终来自哪里。需要排查代码体积或弱定义覆盖时,再展开下面的步骤。
选读:在 Keil 中找符号、来源和地址
Rebuild 完成后,打开输出目录里的 .map 文件。找不到时,在 Keil 的目标选项中检查链接器列表输出及输出目录;GNU 工程则查看链接命令是否配置了 -Map 输出。

先搜索 Reset_Handler、main 和 BSP_LED_Toggle,看看地址和所属目标文件。第 11 章闪烁循环实际调用 BSP_LED_Toggle(),适合拿来观察。BSP_LED_Set() 若没有被用到,可能已经被链接器移除;启用内联或链接时优化后,某些函数也不再有独立符号。这时结合调用处的反汇编看,不能只凭搜不到函数名判断源文件没有编译。

Map 还能用于确认向量表地址、弱函数最终由谁提供、哪些节被丢弃,以及 Flash/RAM 的使用情况。目录树说明“工程里有哪些文件”,Map 则说明“这次链接实际留下了什么”。内存突然增加时,可以先比较两次 Map,找出增加的函数或缓冲区。
4.5 链接布局怎样安排 Flash 和 RAM
代码与变量需要由链接器安排到实际存储区域。第一次理解【代码主要在 Flash、可写变量运行在 RAM】即可,下面把 Keil 的界面与 GNU 的段名对应起来。
选读:Keil 存储器设置、Program Size 与 GNU 段布局
先打开 Options for Target → Target,检查 Read/Only、Read/Write Memory Areas 中的起始地址和容量。对于本文未设置 Bootloader 偏移、先使用普通 SRAM 的 STM32F407VET6 示例,核对值如下:

IROM1是片内 Flash,IRAM1是普通 SRAM。
再看 Linker 页的 Use Memory Layout from Target Dialog:勾选时,µVision 按 Target 等设置生成 Scatter 布局;未勾选时,查看选用的 .sct 文件。具体行为见 Keil Target 设置和 Keil Linker 设置。
Rebuild 后,把实际 Scatter 文件、Map 的区域和 Program Size 输出放在一起看。常见的字段含义是:

| Program Size 字段 | 主要内容 | 在本文普通布局中的用途 |
|---|---|---|
| Code | 机器指令及代码区内嵌数据(如常量池) | 通常放入 Flash |
| RO-data | 只读数据 | 通常放入 Flash |
| RW-data | 有初值的可写静态数据 | 运行在 RAM,初值随固件保存到 Flash |
| ZI-data | 启动时清零的数据 | 在 RAM 中预留并初始化 |
这几项有助于理解占用来自哪里,具体加载大小、对齐、填充、栈堆和运行区域仍看本次 Map。
下面是 GNU 链接脚本的区域声明节选,只有普通 SRAM 和 Flash:
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
}2
3
4
5
后续规则通常把向量表 .isr_vector、代码 .text、只读数据 .rodata 放入 Flash。普通可写全局变量和 static 局部变量中,带非零初值的通常进入 .data:运行时位于 RAM,初值随固件存进 Flash;未显式初始化或零初始化的通常进入 .bss,启动时在 RAM 中清零。Keil 的区域命名有所不同,初始化的目的相同。
这里说的是具有静态存储期的对象。函数里的普通局部变量通常使用栈或寄存器。
这也解释了一个常见现象:增加一个带非零初值的静态数组,可能同时增加 Flash 和 RAM 占用;增加零初始化的缓冲区,主要增加 RAM 占用。
STM32F407VET6 的 Flash 为 512 KiB,普通 SRAM1、SRAM2 合计 128 KiB,从 0x20000000 开始;另有 64 KiB 内核紧耦合存储器(Core Coupled Memory,CCM),地址从 0x10000000 开始。容量和总线连接见 DS8626 Rev 12、RM0090 Rev 21。
CCM 与普通 SRAM 地址不连续,不能把上例的 RAM LENGTH 改成 192K 就算配置完成,否则会造成严重错误。使用 CCM 时要单独声明区域、放置相应段,并配套启动初始化。直接存储器访问(DMA)也不能访问 CCM,因此 DMA 收发缓冲区应放在它可访问的区域。先看懂现有工程的布局,再根据需要增加区域,会更容易排查问题。
五 芯片上电后怎样走到 main()
5.1 向量表中的前两个值
固件已经链接好、下载进 Flash,接下来换一个方向看:芯片复位后,谁来调用 main()?Cortex-M4 不认识 C 语言的函数名,硬件启动时先按约定读取向量表。
复位时,内核从启动映射地址 0x00000000 读取初始主栈指针(Main Stack Pointer,MSP),从 0x00000004 读取复位入口。按用户 Flash 启动时,STM32 把相应的 Flash 启动内容映射到这片地址,实际程序仍存放在 Flash 中。启动模式与存储区映射见 RM0090 Rev 21。

前两个值之后,是内核异常和外设中断的入口地址。后面追踪 SysTick_Handler() 时,还会回到这张表。
5.2 在 Keil 启动文件里找到复位入口
打开当前 Keil 工程使用的 startup_stm32f407xx.s,搜索 Reset_Handler。不必把整份汇编逐行读完,先找到调用 SystemInit、再转到 __main 的位置。执行顺序是:
硬件装入初始 MSP 与复位入口
→ Reset_Handler
→ SystemInit()
→ Arm 运行库 __main:准备 C 运行环境
→ 用户 main()2
3
4
5
这里的 __main 属于 Arm C 运行库,和我们写的 main() 是两个入口。运行库负责准备 C 程序所需的环境,包括安排已初始化数据和清零数据,随后才调用用户 main()。因此,按本章的普通链接布局,进入 main() 时全局变量和 static 变量的初始值已经准备好了。
第 4.5 节的选读内容说明了 .data 的初值存于 Flash、运行时却位于 RAM,这中间的搬运就由启动过程配合链接布局完成。把二者连起来看,启动文件不再只是一份“工程必须带着的汇编”。
想亲眼看到 main() 之前的执行过程,需要先检查 Options for Target → Debug 中当前调试方式的 Run to main()。启用时,µVision 进入调试后会自动执行到 main();取消后,通常停在应用的第一条指令。调试初始化脚本中的自动运行命令仍可能继续执行,必要时一并检查。

在原工程副本中关闭自动运行到 main(),重新进入调试,必要时使用调试器的复位并暂停操作。先确认 PC 位于复位入口,再用 Disassembly 单步跟到 SystemInit()、运行库 __main,最后进入用户 main()。这一路以 PC 和反汇编为准,Call Stack 能显示时再作补充;观察结束后恢复原来的调试设置。

5.3 GNU 启动模板有什么不同
先按上一节读懂 Keil 路径即可。使用 GNU 工具链时,再对照它如何完成相同的准备工作。
选读:GNU 启动汇编中的数据初始化
如果以后换到 GNU 工具链,启动文件会把一部分数据初始化直接写在汇编中。本章版本的 GNU 模板按下面的顺序执行:
flowchart TD
A["硬件从向量表取得 SP 与 Reset_Handler"] --> B["设置栈指针"]
B --> C["调用 SystemInit"]
C --> D["把 .data 初值从 Flash 复制到 RAM"]
D --> E["清零 .bss"]
E --> F["调用 __libc_init_array"]
F --> G["调用 main"]
两套模板都在用户 main() 之前调用 SystemInit() 并准备 C 运行环境,只是由谁完成、代码写在哪里有所区别。阅读启动文件时,先确认它属于哪套工具链,再按真实指令看顺序,不用把 Keil 的 __main 硬套到 GNU 模板里。
这两份模板里的 SystemInit() 都早于普通 C 数据初始化。若把依赖全局变量初值、HAL 时基或应用模块的代码放进去,就可能用到尚未准备好的状态。这类初始化应安排在进入 main() 之后。
5.4 两个名字相近的初始化函数
在 Core/Src/system_stm32f4xx.c 中找到 SystemInit()。按本章的 STM32CubeF4 版本,它主要做的是:
- 工程启用硬件浮点单元(Floating-Point Unit,FPU)时,为 CP10/CP11 设置访问权限。
- 按宏选择是否初始化外部存储器。
- 按宏选择是否设置
SCB->VTOR,即向量表的位置。
而 main.c 中的 SystemClock_Config() 才根据 CubeMX 配置设置 HSE、PLL 和总线分频。第 11 章的 168 MHz 配置由它完成,详细计算见【12】RCC 时钟配置。
两者名字都带有 System,却在不同阶段解决不同问题:前者先准备内核和必要的底层环境,后者在 main() 中配置应用使用的时钟。以后遇到运行频率不对,应先查看实际时钟配置函数,而不是仅凭文件名去改 system_stm32f4xx.c。
5.5 为什么启动文件里有许多默认处理函数
继续往下看启动文件,会看到初始栈顶、复位入口、不可屏蔽中断(Non-Maskable Interrupt,NMI)、HardFault、SysTick 和一长串外设 IRQ 的向量。模板为许多处理函数提供了弱定义,应用未提供自己的实现时,向量会落到默认处理函数。

应用中按约定名称定义一个普通处理函数,就可以替换对应弱定义。如果名字拼错,或者写了函数的 C 文件没有加入链接,默认实现就仍被保留。程序运行后表现为进入启动文件里的循环,单看自己的 C 文件却会觉得“函数明明写了”,很大概率都是函数名写错了几个字母。
这时 Map 和反汇编比猜测更有效:确认向量中实际放了哪个地址,再找这个地址对应的函数。若复位后连 main() 都到不了,还要检查调试器当前程序计数器(Program Counter,PC)、初始栈地址、复位入口、链接布局以及是否进入 HardFault。
六 点灯工程里的 SysTick 和全局初始化
6.1 先看点灯工程已有的 SysTick
点灯程序本身就有一条容易追踪的中断路径。打开 stm32f4xx_it.c,找到 SysTick_Handler()。在默认用 SysTick 提供 HAL 时基的工程中,它调用 HAL_IncTick(),让 uwTick 计数递增,HAL_GetTick() 和 HAL_Delay() 使用这个计数。
这样就能把主循环中的延时与中断文件联系起来:主循环在用计数,中断在推进计数。
stm32f4xx_it.h 放相关函数声明,启动文件的向量表提供入口地址,stm32f4xx_it.c 提供 C 语言处理函数。三个文件分工不同,但共同完成一次中断响应。
6.2 HAL_MspInit() 为什么写在另一个文件
再回到 main() 的第一行 HAL_Init(),打开 stm32f4xx_hal.c 查看实现。它会配置设置中断优先级分组、初始化 HAL 时基,并调用 HAL_MspInit()。
全局搜索 HAL_MspInit(),通常能找到两个实现:HAL 驱动里有带 __weak 的默认实现,CubeMX 生成的 stm32f4xx_hal_msp.c 里有面向本工程的普通实现。链接时采用后者;也可以在 Map 中确认实际来自哪个目标文件。这就是第 5.5 节介绍的弱定义覆盖:默认实现由工程自己的实现替换。

函数名中的 MSP 指微控制器支持包(MCU Support Package)。这里的 HAL_MspInit() 是全局初始化钩子,CubeMX 生成代码中常能看到 SYSCFG、PWR 等时钟的使能。
点灯工程的 GPIO 端口时钟和引脚配置都在 MX_GPIO_Init() 中,因此 stm32f4xx_hal_msp.c 里没有 LED 初始化是正常的。
6.3 时钟变了,HAL 的延时计数为什么还能继续用
HAL_Init() 在系统时钟配置之前初始化时基,随后 SystemClock_Config() 又改变了时钟。默认 SysTick 时基因此需要按新频率重新设置,不能一直沿用复位后的计数配置。
在本章 HAL V1.8.4 中,SystemClock_Config() 调用的 HAL_RCC_ClockConfig() 会更新 SystemCoreClock,然后再次调用 HAL_InitTick(uwTickPrio)。默认实现据此重配 SysTick,让 HAL 的毫秒计数适应新的 HCLK。可以分别在 stm32f4xx_hal_rcc.c 和 stm32f4xx_hal.c 中搜索这两个位置,把初始化顺序连起来。
七 把自己的代码放进工程
7.1 从已有 BSP 开始拆分
第 11 章用 bsp_led.c 处理 PB2、PB8 两颗灯的引脚和相反极性。应用只表达亮灭意图,板级代码决定该写什么电平。以后更换 LED 引脚或调整有效电平,修改集中在 BSP 中,应用中的调用可以保持原样。
继续增加功能时,可以采用下面的最小组织:
project/
├── App/
│ ├── Inc/app.h
│ └── Src/app.c
├── BSP/
│ ├── Inc/bsp_led.h
│ └── Src/bsp_led.c
├── Core/
└── Drivers/2
3
4
5
6
7
8
9
Core 保存生成入口和 MCU 初始化,BSP 管当前板卡的引脚、电平、器件映射,App 放应用状态机。应用变复杂后,再根据实际需要拆设备驱动、协议、服务和 RTOS 适配层。一个小点灯程序还不需要为了凑齐目录,先建很多空模块。
拆分后,main() 可以保持下面这样的调用顺序。这里仅展示结构:App_Init()、App_Run() 是项目自行定义的接口,需要有相应头文件声明和源文件实现。
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
BSP_LED_AllOff();
App_Init();
while (1)
{
App_Run();
}
}2
3
4
5
6
7
8
9
10
11
12
13
生成的 gpio.c、dma.c 等继续负责芯片初始化,设备协议与业务逻辑放在自己的模块里。这样下一次调整 CubeMX 配置时,需要合并的手写代码会少很多。
7.2 头文件只公开调用者需要的接口
沿用第 11 章的 bsp_led.h,led 区分核心板和底板 LED,on 表达亮灭意图:
/**
* @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
外部模块包含这个头文件就能调用 LED 接口,具体寄存器访问、私有状态和内部辅助函数留在 bsp_led.c。头文件只暴露必要内容,可以减少模块之间的依赖,也减少无关修改引起的重新编译。
7.3 跨文件变量怎样声明
有时确实需要几个文件访问同一个对象。头文件用 extern 声明,某一个 C 文件提供定义,例如下面这一对文件:
/* app_state.h */
#ifndef APP_STATE_H
#define APP_STATE_H
#include <stdint.h>
extern volatile uint32_t g_event_flags;
#endif2
3
4
5
6
7
8
9
/* app_state.c */
#include "app_state.h"
volatile uint32_t g_event_flags = 0U;2
3
4
头文件保护宏只防止同一个编译单元重复包含,不能阻止多个 C 文件各自定义出一个同名对象。因此变量定义不能随手放进公共头文件。只在当前 C 文件中使用的对象则加 static,让使用范围留在文件内。
上面的例子只说明声明与定义的关系。若主循环和中断都要修改 g_event_flags,还需设计同步;比如 g_event_flags |= mask 包含读、修改、写几个动作,extern volatile 并不会把它们合成一个原子操作,必须要自己做保护,否则会出现异常情况。
7.4 哪些代码能在重新生成后保留下来
下一次在 CubeMX 中修改配置,工具还会重写 main.c、gpio.c 和其他生成文件。少量调用可以放进已有的 USER CODE BEGIN 与 USER CODE END 之间,完整驱动和业务则放在独立 BSP、App 文件中。
CubeMX 的 Keep User Code when re-generating 选项需要启用,标记还必须正确成对并位于生成器识别的位置。

八 练习与后续使用
8.1 在自己的工程里做一遍
三个实验都使用第 11 章点灯工程,分别验证源码追踪、构建和代码保留。先复制一份可全量构建的工程,保存初始配置;涉及硬件时沿用第 11 章的接线和推挽输出配置。故意制造构建错误的结果不下载到板上。
| 实验 | 操作前先预测 | 需要留下的证据 | 完成标准 |
|---|---|---|---|
| 追踪一次点灯调用(第 3.1~3.6 节) | BSP_LED_Set(BSP_LED_FDB, true) 会传什么参数,向哪个地址写什么值,LED 应当怎样变化 | 调用实参、HAL 分支、设备头地址与偏移;实机观察 ODR 和 PB8 电平 | 能区分 ODR 的 0/1 与 LED 的亮灭,解释调用怎样落到寄存器 |
| 构建故障对照(第 4.2 节) | 排除 GPIO 实现文件、关闭模块宏,各会在哪个阶段暴露问题 | 每次只改一项的 diff、首条相关报错、源文件状态和宏配置 | 能区分声明/类型不可见与实现未链接,并恢复正常 Rebuild |
| 重新生成保留实验(第 7.4~7.5 节) | USER CODE 内、区外、独立 BSP 中的测试内容会怎样变化 | 生成前后 diff、当前 Target 源文件列表和 Include Paths、构建日志 | 说明哪些内容保留、保留的文件是否仍被构建;清理测试内容后全量构建通过 |
8.2 接手另一个 HAL 工程时先看什么
换一个陌生工程,先看 README、构建说明和 Git 状态,在原有状态下尝试一次干净构建。
随后核对 .ioc 和源码里的芯片、工具、固件包版本,再把设备宏、启动文件、链接布局与实际 Flash/RAM 容量对起来。时钟则把板上 HSE、HSE_VALUE、SystemClock_Config() 和总线频率一起核对。
硬件部分从原理图或板级引脚表开始,尤其关注 LED、按键、电源使能和通信接口的有效电平。先沿一颗 LED 的初始化和写引脚调用查到寄存器,再沿 SysTick 查到时基计数,确认哪些代码由工具生成、哪些由项目维护。
再用最新 Map 和体积输出检查内存区域、栈堆设置及主要占用来源。有安全的硬件条件时完成下载和最小功能测试。
8.3 这一章要记住的关系
回到最开始那个点灯工程:.ioc 保存配置,CubeMX 生成初始化;应用和 BSP 调用 HAL,HAL 使用 CMSIS 提供的定义访问寄存器;源文件经过编译链接形成固件,启动代码准备好运行环境后才进入 main()。SysTick 发生时,内核按向量表找到处理函数,由它推进 HAL 使用的计数。
理解这些关系后,文件名多一点并不难处理。声明找不到就看头文件和宏,实现找不到就看构建与链接,运行路径不对就看启动、调用栈和寄存器。Map 与反汇编能帮助确认最终结果,USER CODE、独立模块和生成后的差异检查则帮助我们把后续修改维护下去。
8.4 参考资料
- PM0214 Rev 10:STM32 Cortex-M4 编程手册。
- RM0090 Rev 21:STM32F405/407 等系列参考手册。
- DS8626 Rev 12:STM32F405xx/STM32F407xx 数据手册。
- UM1718 Rev 50:STM32CubeMX 用户手册。
- UM1725 Rev 8:STM32F4 HAL 与 LL 驱动说明。
- UM2609 Rev 17:STM32CubeIDE 用户指南。
- ARM CMSIS 官方文档。
- ST STM32F4 HAL Driver V1.8.4 源码:GPIO 写入与翻转、模块条件编译、全局初始化与 RCC 时基重配置。
- ST STM32F4 CMSIS-Device V2.6.11 源码:设备头、系统初始化与各工具链启动模板。