3.2 HAL、LL 与 BSP
在早期微控制器资源极其匮乏的阶段,固件开发普遍依赖汇编指令或直接的 C 语言位操作与寄存器打交道。在这种紧密贴合芯片硬件的开发模式下,每一行读写语句都能直观映射为底层的总线访问指令,指令执行链路极短,几乎不产生额外的函数调用开销或内存占用,能够最大限度地压榨出有限的芯片性能与存储空间。
然而,随着微控制器架构日益丰富、外设复杂度成倍跃升,这种将硬件细节铺满代码库的做法遇到了严重的扩展瓶颈。直接操作具体寄存器的代码与特定型号的物理接线、地址空间深度绑定,任何芯片选型的变动都会导致整个工程面临推倒重写的风险;同时,直接读写掩码位缺乏编译器级别的语义约束与类型保护,团队协作时的阅读与调试成本居高不下。为了在代码执行效率与工程可维护性之间取得平衡,嵌入式软件逐渐走上了分层抽象的道路:通过将硬件访问细节、通用外设驱动以及业务控制逻辑逐层解耦,让应用层专注于状态与业务流转,将底层的适配成本收拢在可控的边界之内。
1. CMSIS 标准体系与分工
为解决 Cortex-M 架构微控制器在跨厂商移植时的碎片化难题,Arm 推出了 Cortex 微控制器软件接口标准(Cortex Microcontroller Software Interface Standard, CMSIS)。CMSIS 的出发点并非取代各芯片厂商的外设驱动,而是在处理器内核、基础运行时环境与通用软件构件之间建立一套独立于硅片供应商的标准规范,使得运行于不同芯片上的嵌入式软件在核心基础设施层具备良好的一致性与可移植性。

CMSIS 标准体系由多个面向不同维度的模块协同构成:
- CMSIS-Core:定义了针对 Cortex-M 内核通用资源的标准化访问函数与寄存器结构体,覆盖了中断控制系统(NVIC)、系统异常处理、系统滴答定时器(SysTick)、存储器保护单元(MPU)以及专用的内核汇编指令封装。
- CMSIS-DSP:提供了针对数字信号处理应用的高度优化函数库,涵盖复数运算、矩阵运算、数字滤波器设计以及快速傅里叶变换(FFT),充分利用了处理器内部的 SIMD 指令与硬件乘除法加速单元。
- CMSIS-RTOS:构建了一套与具体实时操作系统内核无关的通用抽象接口。开发者可以基于该标准 API 编写多任务调度、信号量同步与消息队列逻辑,在需要评估或更换不同 RTOS 内核实现时,上层业务代码无需联动修改。
- CMSIS-Device:由微控制器厂商在 CMSIS 规范下提供,为具体芯片定义外设物理基地址、寄存器结构体布局以及中断向量表映射,是连接内核与片上外设的基础。
- CMSIS-Driver:针对常见通信外设(如 USART、SPI、I2C、以太网 MAC 等)制定的统一驱动接口规范,通常以函数指针结构体的形式对外暴露能力,便于中间件协议栈跨平台复用。
需要明确的是,CMSIS 能够实现跨芯片厂商绝对统一的部分主要集中在处理器内核本身(如 NVIC 优先级配置、SysTick 启动)。虽然各家芯片都采用同样的 Cortex-M 核心,但外设的内部架构、寄存器排布以及电气特性依然由各个半导体厂商自主设计。因此,脱离内核层进入具体外设操作时,直接读写外设寄存器的代码仍然受制于厂商的硬件布局。
2. 厂商外设库的演进:标准库、HAL 与 LL
为了帮助开发者降低直接翻阅数千页芯片参考手册的门槛,同时提升外设配置的容错率与复用度,意法半导体(ST)围绕 STM32 生态先后演进了三套层次不同的外设库支持体系:
标准外设库(Standard Peripheral Libraries) 是早期广泛使用的固件库。其设计理念是将每个外设的控制寄存器通过结构体封装,并提供一组细粒度的 C 语言函数完成配置,代码结构相对直观、运行开销较轻。然而随着 STM32 产品线从最初的 F1/F4 扩展到覆盖多种先进制程与超低功耗架构的庞大家族,早期库架构难以兼顾全家族的通用配置需求,目前意法半导体已将其转入维护状态,在新推出的微控制器系列中不再提供。
硬件抽象层(Hardware Abstraction Layer, HAL) 是意法半导体当前主推的通用固件库。HAL 库采用了高层次的对象句柄与通用状态机设计,将复杂外设的配置时序、中断分发以及 DMA 联动封装进通用的 API 之中。其优势在于提供跨越 STM32 全家族的高度一致性编程接口,并与 STM32CubeMX 图形化配置工具深度整合。不过,这种普适性抽象带来了明显的代码体积膨胀与调用间接性,内部层层传递的防御性校验与状态判断在时序敏感的高频操作中容易产生额外的开销。
低层库(Low Layer, LL) 则是为了弥补 HAL 库在性能敏感场景下的短板而引入的轻量级方案。LL 库基本不引入额外的内存消耗或运行时状态机制,而是通过大量的 static inline 内联函数直接对寄存器位进行安全包裹。由于其函数展开后几乎等同于手写的位操作汇编指令,既保留了 C 语言编译期的类型检查与语义自解释性,又维持了极高的执行效率。LL 库主要聚焦于寄存器层级的精确控制,对于 USB 协议栈、复杂的网络通信等需要多模块状态机联动的复合外设,LL 库通常并不提供开箱即用的高级辅助能力。
3. 从 CMSIS、HAL 到 BSP 的分层协同
在现代嵌入式系统架构中,CMSIS、厂商外设库(HAL/LL)以及板级支持包(BSP)并非非此即彼的对立关系,而是自底向上构筑成一个层层递进的软硬件协作体系。
CMSIS 处于底座位置,由架构设计方 Arm 确立规范,抹平了不同芯片在 CPU 内核控制与底层编译工具链上的差异;厂商提供的 HAL 与 LL 库架设在 CMSIS 之上,由具体的半导体厂商基于自家硬件硅片特性实现,完成了对片上复杂外设的时钟、引脚重映射与控制逻辑的封装。而更上一层的板级支持包(BSP),则进一步结合了具体硬件电路板的电气连接——包括指示灯所对应的 GPIO 引脚与导通极性、按键的上下拉电路形式、外部传感器所挂载的总线编号等。
这三层的分工协同为嵌入式软件赋予了清晰的结构:CMSIS 保障了内核操作的标准化,HAL/LL 提供了外设能力的构建块,而 BSP 则将具体的板卡接线细节与上层应用逻辑彻底隔开。这种层次分明的体系不仅降低了开发与排错难度,也使得应用算法在面对硬件改版甚至更换芯片平台时,能够保持最大程度的纯净与稳定。
4. 习题
下面四行代码分别属于哪一层(寄存器直写、LL、HAL、BSP),各写一句判断依据:
GPIOC->BSRR = 1u << 13;
LL_GPIO_SetOutputPin(GPIOC, 1u << 13);
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);
board_led_set(true);2
3
4
再说明第 3 行与第 4 行虽然都能点亮同一块板上的灯,换一块 LED 接在 PB0、高电平点亮的板子时,哪一行必须改写、哪一行可能一个字都不用动。
同一个功能写三份,比较生成代码的大小。新建三个文件,每个只含一个 board_led_set(int on):
cmp_raw.c直接写GPIOC->BSRR(置位用1u << 13,清零用1u << 29);cmp_ll.c自己写两个static inline函数LL_GPIO_SetOutputPin与LL_GPIO_ResetOutputPin,内部都写BSRR,board_led_set只调这两个函数;cmp_hal.c照HAL_GPIO_WritePin(GPIOx, GPIO_Pin, PinState)的形式写一个带GPIO_TypeDef *与uint16_t两个参数的普通函数,再用一个结构体保存 LED 的端口与引脚号来调用它。
三个文件都用下面的命令编译,用 arm-none-eabi-size 与 arm-none-eabi-objdump -d 看 text 段与 board_led_set 的反汇编:
arm-none-eabi-gcc -std=c11 -mcpu=cortex-m3 -mthumb -O2 -Wall -Wextra \
-DSTM32F103xB -I cmsis/CMSIS/Core/Include -I cmsis/cmsis_device_f1/Include \
-c cmp_ll.c -o cmp_ll.o2
3
回答:
(1) 三份 text 段各多少字节,board_led_set 各多少字节。
(2) cmp_ll 与 cmp_raw 的反汇编是否逐条相同。如果相同,说明 static inline 与宏在这里的差别是什么;如果不同,贴出差异。
(3) cmp_hal 里那句 if (h == NULL || h->port == NULL) 在 -O2 下还剩几条指令。把这一句删掉再编一次,比较 text 段,说明编译器证明了什么。
(4) 把三个文件都改成 -O0 再编一次。哪一份的 text 段涨得最多,涨在哪几条指令上(提示:看 bl 与压栈)。
(5) 综合 (3)(4) 回答:「HAL 效率低」这个说法在什么条件下成立、在什么条件下不成立;如果项目真的卡在 Flash 容量上,该做的是换库还是换编译选项。
按 CMSIS-Driver 的思路,把 uart 驱动改造成函数表,让应用代码不出现 USART_TypeDef:
(1) 定义
struct uart_driver {
const char *name;
int32_t (*init)(void *inst, uint32_t baud);
int32_t (*send)(void *inst, const void *buf, uint32_t len);
int32_t (*recv)(void *inst, void *buf, uint32_t len);
};2
3
4
5
6
(2) 写两份实现:一份包住现有的 uart_init、uart_write(用 USART1,pclk 取 72000000u);另一份是宿主机上的假实现,把收到的字节追加进一个字符数组。
(3) app_run(const struct uart_driver *drv, void *inst) 只调函数表,依次发 "hello" 与 " world",最后打印 drv->name 与假实现收到的内容。宿主机运行结果应为 FAKE: hello world。
(4) 再写一个 main,用同一个 app_run 分别跑两次:一次传假实现,一次传真实现(只在 arm-none-eabi-gcc 下编译,不运行)。
(5) 说明这种函数表与 3.1 第 6 节「外设实例作参数」的接口相比,各自由谁决定用哪个外设,以及函数表多出来的那一层在什么规模的项目里才划算。
选一块与 Blue Pill 接线不同的板子(LED 接 PB0 高电平点亮、按键接 PB1 按下为低电平、控制台走 USART2),在 3.1 的工程上验证「换板只改板级」这个说法到底有多成立。
(1) 写出新的 board_stm32f103_b.c:只改 k_led_pin、k_btn_pin 的端口与有效电平,控制台改用 USART2(PA2 复用推挽、PA3 浮空输入,PCLK1 为 PCLK2 的一半)。
(2) 用 arm-none-eabi-gcc -c 编译。如果 app/blink_logic.c 与 drivers/gpio.c、drivers/uart.c 一个字都没改就通过,说明分层成立;如果有文件必须改,指出是哪个、为什么,并说明这属于设计缺陷还是本来就应该由板级承担。
(3) 用 arm-none-eabi-size 比较新旧两份板级文件编出来的 text 段,说明差别来自哪里。
(4) 在 PC 上用 board_host.c 模拟这块新板(LED 高电平亮、按键低电平有效),把同样的按键序列喂进去,确认 blink_logic.c 的行为与 Blue Pill 上一致。
(5) 反思 3.1 第 6 节的「应用层不该知道 LED 接在哪个引脚」:board_button_pressed 把「按下」定义成高电平有效,这个决定属于驱动、板级还是应用?如果换成一块按键按下为低电平、且外部有上拉电阻的板子,gpio_pin 里的 active_low 该写什么?