HAL设计原则:分层设计、接口与实现分离、可移植性、可测试性
好,咱们直接进入正题。HAL 这东西,说白了就是一层「翻译官」。它把芯片厂商那些千奇百怪的寄存器操作,翻译成你上层应用看得懂、用得顺的 API。但翻译官也有好坏之分。怎么才算一个好的 HAL?我总结了四个核心原则。
这四个原则不是拍脑袋想出来的。是我在好几个项目里踩坑踩出来的。你想想看,一个项目做两年,中间换一次芯片方案,如果 HAL 设计得烂,那基本等于重写。嗯,咱们一个一个说。
1. 分层设计:别让上层知道底层在干嘛
分层设计,核心就一句话:每一层只关心自己的事。
我见过最糟糕的代码,是应用层直接操作 GPIO 寄存器。比如这样:
// 糟糕的设计:应用层直接操作寄存器
#define GPIO_BASE 0x40021000
*(volatile uint32_t *)(GPIO_BASE + 0x00) |= (1 << 5); // 直接置位GPIO
这种代码,换个芯片就废了。正确的做法是分层:
| 层次 | 职责 | 依赖 |
|---|---|---|
| 应用层 | 业务逻辑、状态机、协议栈 | 只调用 HAL API |
| HAL 层 | 抽象硬件功能,提供统一接口 | 调用 LL 层 |
| LL 层 | 直接操作寄存器,处理时序 | 依赖芯片手册 |
我个人习惯,在 HAL 层和 LL 层之间再加一个 设备描述结构体。比如:
// HAL 层:用户看到的是这个
typedef struct {
void (*init)(void);
void (*write)(uint8_t data);
uint8_t (*read)(void);
} UART_Device_t;
// LL 层:具体芯片实现
static void uart_stm32_init(void) {
// 操作 STM32 的 USART 寄存器
USART1->CR1 |= USART_CR1_UE;
}
static void uart_imx_init(void) {
// 操作 i.MX 的 UART 寄存器
UART1->UCR1 |= UCR1_UARTEN;
}
// 设备实例
UART_Device_t uart_device = {
.init = uart_stm32_init, // 换芯片时只改这里
.write = uart_stm32_write,
.read = uart_stm32_read
};
这样,应用层永远只调用 uart_device.init()。底层换了?跟我没关系。
2. 接口与实现分离:换芯片就像换灯泡
这个原则,说白了就是「面向接口编程」。你写 HAL 的时候,先定义接口,再写实现。接口放在头文件里,实现藏在 .c 文件里。
举个例子。我定义一个 SPI 接口:
// spi_hal.h —— 接口定义
#ifndef SPI_HAL_H
#define SPI_HAL_H
#include <stdint.h>
typedef struct {
void (*init)(uint32_t freq);
void (*transfer)(const uint8_t *tx, uint8_t *rx, uint32_t len);
void (*cs_control)(uint8_t level);
} SPI_Interface_t;
// 对外暴露的注册函数
void SPI_Register(SPI_Interface_t *iface);
#endif
// spi_stm32.c —— 具体实现
#include "spi_hal.h"
static void stm32_spi_init(uint32_t freq) {
// STM32 的 SPI 初始化代码
SPI1->CR1 = (freq << 3);
}
static void stm32_spi_transfer(const uint8_t *tx, uint8_t *rx, uint32_t len) {
// 轮询或 DMA 传输
}
static SPI_Interface_t stm32_spi_iface = {
.init = stm32_spi_init,
.transfer = stm32_spi_transfer,
.cs_control = stm32_cs_control
};
// 在系统初始化时调用
void HAL_SPI_Init(void) {
SPI_Register(&stm32_spi_iface);
}
换芯片时,我只需要新写一个 spi_imx.c,把 stm32_spi_iface 换成 imx_spi_iface。应用层代码一行都不用改。
uint32_t 是标准类型,可以用。但 STM32_SPI_BASE 这种,绝对不能出现在接口头文件里。
3. 可移植性:一次编写,到处编译
可移植性不是玄学。它是有具体指标的。我一般用这张表来评估:
| 评估项 | 好 | 差 |
|---|---|---|
| 编译器依赖 | 只用标准 C(C99/C11) | 用了 GNU C 扩展、内联汇编 |
| 数据类型 | 使用 stdint.h 定长类型 |
直接用 int、unsigned char |
| 字节序 | 提供 __BIG_ENDIAN / __LITTLE_ENDIAN 宏开关 |
硬编码小端或大端 |
| 中断处理 | 用回调函数封装 | 直接写中断向量表 |
| 内存模型 | 通过宏控制 __RAM_FUNC |
直接写 __attribute__((section(".ramfunc"))) |
我曾经在一个项目里,因为用了 __attribute__((packed)),导致从 GCC 换到 IAR 时结构体对齐全乱了。从那以后,我强制团队:所有结构体对齐,用 #pragma pack(push, 1) 这种标准预处理指令。
还有一个容易忽略的点:空指针检查。不同芯片的 NULL 指针访问行为不一样。有的直接 HardFault,有的返回 0。所以我在 HAL 层统一做检查:
void HAL_UART_Write(UART_Handle_t *huart, uint8_t data) {
if (huart == NULL || huart->write == NULL) {
// 统一错误处理,而不是直接崩溃
HAL_ErrorHandler(HAL_ERROR_NULL_PTR);
return;
}
huart->write(data);
}
4. 可测试性:让 Bug 死在实验室里
嵌入式软件测试难,难在硬件依赖。但 HAL 设计得好,测试就不难。核心思路是:把硬件依赖「注入」到 HAL 里。
看这个例子:
// 可测试的 HAL 设计
typedef struct {
void (*pin_write)(uint8_t pin, uint8_t level); // 可被 Mock
uint8_t (*pin_read)(uint8_t pin);
} GPIO_Device_t;
// 测试代码(在 PC 上运行)
static uint8_t mock_pin_state[16];
static void mock_pin_write(uint8_t pin, uint8_t level) {
mock_pin_state[pin] = level; // 写入模拟数组
}
static uint8_t mock_pin_read(uint8_t pin) {
return mock_pin_state[pin]; // 从模拟数组读取
}
void test_gpio_toggle(void) {
GPIO_Device_t mock_gpio = {
.pin_write = mock_pin_write,
.pin_read = mock_pin_read
};
GPIO_Toggle(&mock_gpio, 5);
assert(mock_pin_state[5] == 1); // 验证结果
}
你看,测试时把真实的硬件函数换成 Mock 函数。这样在 Linux 或 Windows 上就能跑单元测试。我习惯用 Ceedling + Unity + CMock 这套工具链,自动生成 Mock 代码。
HAL_Delay(ms) 做延时等待。结果测试时发现,Mock 环境里没有硬件定时器,整个测试套件卡死了。后来我改成:所有延时函数都通过函数指针注入,测试时替换成 mock_delay,直接返回,不阻塞。
一张图总结:HAL 设计原则
下面这张图,是我做 HAL 架构设计时贴在白板上的。每次评审新模块,都拿它来对照:
这张图从左到右看,就是一次完整的「换芯片」过程。应用层不动,HAL 接口层不动,只换底下的实现和 LL 层。这就是四个原则共同作用的结果。