ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32CubeF1 V1.8.6固件包解析:HAL库、LL库与工程迁移实战

STM32CubeF1 V1.8.6固件包解析:HAL库、LL库与工程迁移实战

简介:STM32F1系列微控制器官方固件开发套件,内含STM32CubeMX配置工具与HAL库,可快速完成时钟树配置、外设初始化和代码生成,适合嵌入式工程师在工业控制、医疗设备、消费电子等场景下进行底层驱动和应用层开发。压缩包共2000个文件,以441个C源文件和117个头文件为主,构成HAL库、CMSIS-DSP信号处理算法库等核心代码,另有664个HTML和496个JS承担文档与配置界面,TXT、MD、CSS等辅助说明齐全,总容量约48.45MB,支持Keil MDK、IAR、GCC等主流工具链。已有2915人学习下载。该版本为V1.8.6主干稳定版,代码结构清晰、API注释完整,可搭配CubeMX生成的初始化代码快速搭建工程,参考驱动例程实现GPIO、定时器、USART等外设控制及数字信号处理,显著缩短产品研发周期。

1. STM32CubeF1 V1.8.6:一个固件包版本,如何决定F1项目的开发方式

做 F1 系列的人,大概都经历过这种时刻:从原厂官网找到一个叫 STM32CubeF1 的固件包,下载完解压开,里面一片目录,不知道该拿哪一层的代码来用。V1.8.6 是这条主线里的常见版本号,它代表的不是一组例程,而是一整套以 HAL 库为中心的软件框架:驱动层、中间件、CMSIS 内核抽象、启动文件、链接脚本,全部打包在了一起。对新项目来说,它决定了你写外设代码的方式;对老项目来说,它是从标准外设库迁移时绕不开的对照物。这篇笔记打算按我实际用下来的路径来讲——先把它内部结构拆清楚,再用 USART 和 GPIO 跑一个最小工程,然后集中讲几个高频翻车点,最后落在裁剪和版本验证上。适合正在用 F103、F107 系列做产品,被 HAL 库的行为和参数困扰,或者正准备从老库迁移过来的开发者。

2. 解包 ST 固件包:从目录结构看懂 HAL、LL 与 CMSIS 的分工与选型

2.1 目录结构:先知道每一层代码在哪里

V1.8.6 解压后是一个以版本号命名的顶层目录,里面最核心的只有几个分支。我平时接一个项目,第一步不是翻例程,而是把 Drivers 下面三层的关系确认清楚。下面是一个典型目录形态:

STM32Cube_FW_F1_V1.8.6/ ├── Drivers/ │ ├── CMSIS/ │ │ ├── Core/ # ARM 内核寄存器定义、中断入口声明 │ │ └── Device/ST/STM32F1xx/ # F1 系列 CMSIS 设备头文件与 system 文件 │ ├── STM32F1xx_HAL_Driver/ │ │ ├── Inc/ # HAL 库全部公共头文件 │ │ ├── Inc/Legacy/ # 兼容标准外设库名称的过期接口 │ │ └── Src/ # HAL 库源码 │ └── BSP/ # 原厂评估板相关的板级驱动 ├── Middlewares/ # FatFs、USB、FreeRTOS 等中间件源码 ├── Projects/ # 各评估板例程,含工程模板 ├── Utilities/ # 辅助脚本、CPU 勘误表工具 └── package.xml # 固件包版本元数据

这个目录不是随便分的。CMSIS 层负责把内核寄存器、系统时钟初始化和启动文件统一起来;STM32F1xx_HAL_Driver 层才是你日常接触最多的外设驱动;Middlewares 是可选组件,用不到就不要编译进工程。Projects 目录里的例程我一般只用来快速验证评估板外设是否正常,真正做产品时很少直接改例程起步,因为例程里混杂了评估板型号相关的 BSP 依赖。

值得留意的是 Inc/Legacy 文件夹。从标准外设库迁移到 HAL 库时,部分旧宏可以通过这个目录的兼容头文件过渡编译,但它不是万能的,寄存器结构体字段名和函数调用方式差异仍然存在。我不建议把 Legacy 长期留在工程里,它只是搬家过程中的临时拐杖。

2.2 HAL 与 LL 的选型:两种驱动风格对应两种项目诉求

V1.8.6 里同时提供了 HAL 和 LL 两套驱动源码,很多人一开始以为这是重复造轮子,实际它们定位完全不同。HAL 库以句柄为中心,一个外设初始化完,后续只用句柄调用函数,回调机制和超时机制都封装好了;LL 库直接操作寄存器,初始化结构体都有,但函数更像标准外设库那种“设置一个位就干一件事”的风格。从代码结构上看,LL 库更透明,HAL 库更黑匣子——这个黑匣子用得好是省事,用不好就是你排查问题的阻碍。

对比维度HAL 库LL 库
接口抽象以句柄和回调为中心以寄存器级函数为中心
初始化复杂度结构体字段多,配置自动完成时序需要手动按寄存器段配置
可读性封装深,逻辑藏在回调里代码直观,和参考手册对照容易
中断处理统一入口再分发到回调状态标志位由用户直接控制
典型场景快速开发、外设多、RTOS 配合时序敏感、Flash 空间紧张、需要精细控制

实际项目中常见做法是两者混用:用 HAL 库初始化外设时序,在中断或高频路径上用 LL 库直接操作寄存器。比如串口波特率初始化交给 HAL_UART_Init,而 GPIO 翻转 LED 这种每秒几十万次的操作用 LL_GPIO_TogglePin。这样既保留 HAL 的配置优势,又把能省掉的函数调用开销省掉。对于 F103C8 这种 64KB Flash 的芯片,全上 HAL 库加上中间件,空间会非常紧张,LL 混用几乎是必须的。

2.3 CMSIS 的作用:为什么启动文件和 system 文件不能被排除

固件包里容易被忽略但绝对不能删的,是 CMSIS 目录下的 Device 文件。它里面包含三样东西:启动文件 startup_stm32f10x_xx.s、系统初始化文件 system_stm32f1xx.c、以及设备头文件 stm32f1xx.h。这三样决定了芯片上电后从哪里取第一条指令,以及 SystemCoreClock 就等于实际工作频率。如果启动文件选错,中断入口对不上,程序跑起来串口一类外设中断全都静默失效。工程模板里默认的启动文件按 Flash 容量划分,F103C8 用 startup_stm32f103xb.s,F103ZE 用 startup_stm32f103xe.s,换芯片型号时第一件事就是核对这个文件。

system_stm32f1xx.c 里的 SystemInit 函数在 main 之前执行,它会配置 Flash 等待周期、设置系统时钟源。V1.8.6 里默认是使用 HSI 还是 HSE 由该文件内的宏定义控制,具体到 CubeMX 生成的工程里则是通过 SystemClock_Config 重新配置一遍。我曾经遇到过一个现象:自己从零搭工程时 SystemInit 正常,但加入 HAL 库的 RCC 驱动后系统频率变得不对,最后发现是 system 文件里的宏与启动文件里定义的芯片型号宏不匹配导致时钟树配置被跳过。所以刚接触这个固件包时,建议把这三个文件看成整体,不要单独替换或删除。

3. USART 透传最小工程:HAL 库初始化、收发模式与时钟树参数

3.1 用 CubeMX 生成工程后,先看懂自动生成的初始化代码

用 STM32CubeMX 选好芯片,勾选 USART1 和板载 LED 对应 GPIO,时钟树里把 HSE 晶振设为 8MHz,系统主频拉到 72MHz,生成代码,这整个过程没有任何难度,真正的门槛在于生成的代码和你的预期是否一致。生成后 main 函数里通常会出现这样一段串口初始化:

// 串口句柄定义,归属于 UART 外设层 UART_HandleTypeDef huart1; // USART1 初始化函数,由 CubeMX 生成 static void MX_USART1_Init(void) { huart1.Instance = USART1; // 绑定外设基地址 huart1.Init.BaudRate = 115200; // 波特率 huart1.Init.WordLength = UART_WORDLENGTH_8B; // 8 位数据位 huart1.Init.StopBits = UART_STOPBITS_1; // 1 位停止位 huart1.Init.Parity = UART_PARITY_NONE; // 无校验 huart1.Init.Mode = UART_MODE_TX_RX; // 收发都启用 huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 不启用硬件流控 huart1.Init.OverSampling = UART_OVERSAMPLING_16; // 默认 16 倍过采样 if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); // 初始化失败统一进错误处理 } }

这段代码逻辑很直白:先填满句柄结构体的配置项,再调用 HAL_UART_Init 把参数写入寄存器。值得说明的是 HAL_UART_Init 内部并不只是配置波特率和数据位,它还会先把 USART 外设时钟使能,检查 GPIO 复用状态,最后调用 HAL_UART_MspInit 来完成引脚映射。MspInit 这个钩子函数是 HAL 库特有的设计,它让“外设本身”和“引脚时钟”分离。如果你自己从零搭工程,忘了实现 HAL_UART_MspInit,初始化永远不会成功,因为引脚根本没有被设置为复用推挽模式。

3.2 阻塞发送与中断接收:两种收发模式的参数边界

点灯的进阶是让串口反馈状态。最简单的发送是阻塞式,代码如:

// 阻塞发送 7 字节,超时时间 1000ms uint8_t boot_msg[] = "ready\r\n"; HAL_UART_Transmit(&huart1, boot_msg, sizeof(boot_msg) - 1, 1000);

第四个参数是超时毫秒数,它依赖 HAL_GetTick 提供的时间基准。HAL_UART_Transmit 在发送期间会不断查询发送完成标志,如果超过时间还没发完就返回 HAL_TIMEOUT。这里有个很容易误判的点:超时时间并不表示“数据要发 1000ms”,只是说“如果 1 秒内没发完就放弃”。正常情况下 115200 波特率发送 7 字节只需要 0.6ms 左右。阻塞发送在等待标志位时 CPU 是空转的,所以中断接收几乎总是更合理的选择:

// 中断方式接收单个字节,数据到达后由 HAL 库调用回调函数 uint8_t rx_byte; HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 数据接收完成后 HAL 库调用这个弱函数 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); // 翻转板载 LED HAL_UART_Transmit(&huart1, &rx_byte, 1, 10); // 重新开启下一次中断接收 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } }

关键在于 HAL_UART_Receive_IT 传入的是“本次期望接收的字节数”,而不是“开启接收”这个动作。接收完成后回调执行,中断状态被自动清理,如果要持续接收就必须在回调里再次调用该函数。很多人第一次写的时候漏掉最后一行,结果只收到第一个字节就再也不进回调了。此外超时参数在中断模式下依然有意义,但它只对代码启动阶段的状态检查生效,数据到达后一般不会超时。

3.3 时钟树参数:APB1 与 APB2 的分线决定外设速率

F1 系列的时钟树是所有外设速率的源头,HAL 库初始化时钟的代码集中在 SystemClock_Config 里。配置后的典型参数是 SYSCLK 72MHz、AHB 72MHz、APB1 36MHz、APB2 72MHz。这个差异直接决定外设能跑多快:挂在 APB1 上的 USART2/3 和定时器,频率上限受限;USART1 和高级定时器在 APB2 上定时器自动倍频到 72MHz。CubeMX 生成代码时用 RCC_OscInitStruct 和 RCC_ClkInitStruct 两个结构体来控制,改动时钟树后只需重新生成,一般不需要手改。

我跟一个自己做开发板的开发者讨论过,他为了省晶振把系统时钟配到内部 HSI 8MHz,结果串口波特率在严格模式下总是不对。原因是 HSI 作为 PLL 输入,配置不当容易超过 PLL 输入范围,而且 HSI 的精度本身就比晶振差。改回 HSE 后问题消失。所以遇到波特率误码率偏高时,先查时钟源精度,而不是怀疑 USART 参数配置——这是经验,也算这章节里最实用的一个提醒。

4. STM32CubeF1 的五个高频坑:现象、原因与解法

4.1 阻塞调用永远超时:HAL_GetTick 停走

现象:HAL_UART_Transmit 一直等到超时返回 HAL_TIMEOUT,如果改为轮询标志位寄存器则数据能发出去。或者 HAL_Delay 本来该延时 500ms,实际卡住数秒。原因:HAL 库的时间基准由 SysTick 中断周期性调用 HAL_IncTick 来维护,如果用户代码把 SysTick 的中断优先级调得比某个外设中断还低,那么外设 ISR 长时间不返回时,SysTick 无法抢占,HAL_GetTick 的值就不更新,所有带超时的 HAL 函数都会误判超时。解决:系统 Tick 中断优先级保持最高或至少可抢占普通外设中断;中断服务函数里避免调用带有超时参数的阻塞函数,需要回传数据时尽量只置标志位。

4.2 assert_param 导致链接报错或卡死在断言里

现象:编译通过,但运行到某个外设初始化时程序进入一个名为 assert_failed 的函数出不来,或者链接阶段直接报未定义符号。原因:HAL 库的 assert_param 宏只有在定义了 USE_FULL_ASSERT 时才调用 assert_failed 函数。CubeMX 默认不定义这个宏,所以大部分工程平安;一旦有人在编译器预处理列表里手抖加上了 USE_FULL_ASSERT,又没有实现 assert_failed 函数,就会翻车。解决:不用断言就把宏去掉;要用就补上实现并打印出错位置:

#ifdef USE_FULL_ASSERT void assert_failed(uint8_t *file, uint32_t line) { // file 和 line 分别指向出错源文件和行号 // 实际项目里建议把这两个参数记录下来再停机等待 while (1) { } } #endif

4.3 EXTI 同一条中断线引脚互相串扰

现象:PA0 和 PB0 都配置成外部中断下降沿触发,按下连接在 PA0 的按键,PB0 的回调也被执行一次,逻辑判断身份出错。原因:F1 的 EXTI0 线同时连接到所有 GPIOA/B/C/D/E/F 的 Pin0,HAL 库的 HAL_GPIO_EXTI_IRQHandler 只会按线号清除中断标志并分发到 HAL_GPIO_EXTI_Callback,无法区分具体端口。解决:最小改动方案是在回调里读取两个候选引脚的当前电平,进一步确定来源;重构方案是避免同一条线的两个引脚同时作为外部中断输入。这个坑在 PCB 设计阶段就该规避,软件只能做二次判断:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_0) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // 确认是 PA0 的按键被按下 key_flag_a = 1; } else if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_0) == GPIO_PIN_RESET) { // 确认是 PB0 的按键被按下 key_flag_b = 1; } } }

4.4 加入中间件后 HardFault:堆栈和堆不够用

现象:裸机串口点灯一切正常,把 FATFS 文件系统加入工程后,程序运行一会儿就跳进 HardFault_Handler,调整了代码顺序偶尔又能跑。原因:HAL 库本身调用层级深,中断回调嵌套占用栈空间大,FatFs 的底层缓冲区也在栈上分配时,默认 0x400 字节的堆栈立即告急。解决:把启动文件里的 Stack_Size 从 0x400 改到 0x800 甚至 0x1000,Heap_Size 视 malloc 使用情况调整;同时检查工程链接脚本里是否有独立的堆栈字段与启动文件重复配置。改完后用调试器在 HardFault 处读 MSP 寄存器并对照 SRAM 地址范围,可以确认是否为溢出。

4.5 启动文件与芯片型号不匹配,中断集体失效

现象:代码能下载、主循环能跑,点灯也正常,但任何外设中断都不进入,在中断函数里打断点永远不命中。原因:启动文件要与具体芯片型号对应,F103C8 用了 F103ZE 的启动文件时,中断向量表长度不同,后面的中断入口错位;也可能工程宏 STM32F10X_MD 和 STM32F10X_HD 没跟着改,导致设备头文件里外设基地址定义与实际芯片不一致。解决:把工程里的 startup_stm32f10x_md.s 换成对应芯片的启动文件,同时把编译器全局宏和系统文件里的芯片型号三家统一,重启工程后再验证。这个问题的隐蔽性在于主循环正常,很容易让人先怀疑外设配置。

5. 从标准外设库迁移到 HAL 库:接口映射表与中断处理差异

5.1 外设接口映射:从寄存器函数到句柄方法

老项目普遍基于标准外设库(SPL)开发,V1.8.6 的 HAL 库在思路上完全不同。迁移第一步是把老代码里的外设函数逐一映射到 HAL 接口,下面是我整理的高频对应关系:

标准外设库写法HAL 库对应写法
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE)__HAL_RCC_USART1_CLK_ENABLE()
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PPGPIO_InitStruct.Mode = GPIO_MODE_AF_PP
USART_Cmd(USART1, ENABLE)__HAL_UART_ENABLE(&huart1)
USART_SendData(USART1, ch)HAL_UART_Transmit(&huart1, &ch, 1, timeout)
USART_GetFlagStatus(USART1, USART_FLAG_RXNE)__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE)
USART_ReceiveData(USART1)__HAL_UART_GET_DATA(&huart1)

映射表看起来简单,实际动手会发现 HAL 库把初始化细节藏进了句柄,外设时钟使能要在 MX_USART1_Init 之后由 HAL_UART_MspInit 触发,而不是在调用 HAL_UART_Init 之前手动打开。混写过两套库的开发者容易习惯性在初始化前加一行时钟使能,这在 HAL 库里虽然不会报错,但等于重复操作,没有副作用但会让排查代码的人产生困惑。

5.2 中断入口的差异:从裸寄存器到 HAL 统一分发

标准外设库的中断里,你需要自己读状态寄存器、自己判断事件类型,再自己清标志:

// 标准外设库的中断处理风格 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t ch = USART_ReceiveData(USART1); // 用户自行处理接收数据 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } }

HAL 库把这段逻辑包进了 HAL_UART_IRQHandler,用户要做的只是把外设中断入口注册给它:

void USART1_IRQHandler(void) { // 中断统一入口:由 HAL 库判断事件类型并调用对应回调 HAL_UART_IRQHandler(&huart1); }

迁移时最容易遗漏的是中断里没有调用 HAL_UART_IRQHandler,HAL 库的回调永远不执行。另一种常见误用是在同一个中断里既调 HAL_UART_IRQHandler 又自己再写一遍标志位检查,导致同一帧数据被处理两次。移动端开发里有一句“不要重复造轮子”,放在这里就是“让 HAL 库处理完中断闭环,用户只碰回调”。中断溢出标志处理也要注意:HAL 库在收到 overrun 错误后会调用 HAL_UART_ErrorCallback,老的标志位清零写法在 HAL 和回调并存时可能会让错误状态丢失。

5.3 迁移后的三个习惯

第一,检查每个 HAL 函数的返回值。标准外设库返回 void,参数不对只会在运行时表现诡异;HAL 库统一返回 HAL_StatusTypeDef,HAL_ERROR 时尽早进入 Error_Handler。第二,不要跨中断回调传递数据时用全局变量裸写,用 volatile 修饰并配合临界保护。第三,工程的 CubeMX 配置文件(.ioc)要备份,HAL 库的版本升级或芯片变更都要从 .ioc 重新生成而不是手工拼接代码。这三个习惯能挡住大部分迁移后的隐性 bug。

6. 从完整包到最小工程:裁剪、LL 混用与版本回归验证

产品从开发转到量产时,完整固件包就不再适合直接烧录,裁剪是必经之路。V1.8.6 解压后几十 MB,实际工程只需要 Drivers/CMSIS 下的启动文件与设备头文件、STM32F1xx_HAL_Driver 里你用到的外设源文件、以及 Middlewares 里确实启用的组件。从工程模板里删掉不用的驱动源文件后,编译产物会显著变小,链接时间也缩短。我一般会保留 HAL 库的 stm32f1xx_hal.c 主驱动和 stm32f1xx_hal_cortex.c,删除用不到的外设模块文件。

裁剪之后可以把 LL 库函数加进中断路径。GPIO 翻转这种操作,HAL 库封装层开销在 F1 这种 72MHz 主频下也能跑,但如果你做的是 DShot 信号或单总线时序,直接用寄存器更稳:

// LL 库直接翻转引脚,比 HAL 封装省掉函数调用和状态检查 LL_GPIO_TogglePin(GPIOC, LL_GPIO_PIN_13); // 等价于 HAL 写法: // HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);

两种写法在功能上没有区别,但 LL 版生成的指令更短,定时要求严格的场合优先用。版本回归验证的最后一步,是把 CubeMX 里使用的固件包锁定为 V1.8.6,升级前先在有问题的工程里编译跑一遍原厂 Projects 下的官方例程,确认外设基线正常再动工程代码。吃过亏的人都知道,很多时候改坏的不是自己的代码,而是固件包被不知不觉换成另一个版本。我自己就在某一次接手别人硬件时,因为无视版本差异排查了一个下午的串口唤醒异常,后来才发现问题出在 HAL 库更新后中断回调时序发生了变化。从那以后,我开工前必做的一件事就是把下半年的项目固件包版本写进工程 README,不让黑匣子变成玄学。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进