硬件抽象层(HAL)设计原则

好,咱们今天聊聊硬件抽象层。说白了,HAL 就是给上层软件一个「统一接口」,让它们不用关心底下接的是 STM32 还是 GD32,是 SPI Flash 还是 I2C EEPROM。

我刚开始做嵌入式那会儿,代码都是直接操作寄存器。换个芯片?好家伙,整个驱动层重写,累得够呛。后来才明白,没有 HAL 的项目,维护成本是指数级增长的。

HAL 的定义与职责

HAL 是什么?它是一层软件,位于硬件驱动和上层应用之间。它的核心职责就三条:

  • 封装硬件细节:把寄存器操作、时序控制、中断处理这些脏活累活包起来
  • 提供统一接口:不管底层是什么芯片,上层调用的函数名、参数、行为都一样
  • 隔离变更影响:换 MCU 或外设时,只改 HAL 层,上层代码不动

记住这个原则:HAL 层对上层暴露的接口,应该像「标准 API」一样稳定。上层开发者不需要知道底层是寄存器操作还是库函数调用。

举个例子。我在一个项目里用过三款不同的温度传感器——DS18B20、SHT30、NTC 热敏电阻。如果没有 HAL,上层逻辑得分别处理三种读取方式。有了 HAL,我只暴露一个 temperature_read(),上层只管调用,底层怎么实现是 HAL 的事。

接口设计规范

接口设计这块,我踩过不少坑。总结下来,三个维度最重要:命名、参数、返回值。

命名规范

我个人习惯用「模块_动作_对象」的格式。比如:

// 好的命名
hal_gpio_write(PIN_LED, HIGH);
hal_uart_send(UART1, data, len);
hal_i2c_read(I2C_BUS0, addr, buf, size);

// 不好的命名
write_gpio();      // 写哪个引脚?写什么值?
uart_send_data();  // 哪个 UART?数据在哪?
i2c_read();        // 读什么?读多少?

命名要自解释。我见过一个项目,函数名叫 dev_opt(),看了半天源码才知道是操作设备。这种命名,三个月后自己都看不懂。

参数设计

参数设计有个黄金法则:能少就少,但别太少。少了不够用,多了容易错。

参数类型 建议 反面案例
句柄/对象 必须传,标识操作哪个实例 全局变量隐式指定
数据指针 用 void* 加长度参数 固定长度数组
配置参数 用结构体打包传入 七八个独立参数
超时时间 用毫秒,0 表示不等待 用 tick 数

我的经验:参数超过 4 个,就考虑用结构体。比如 SPI 配置,把速率、模式、位序打包成一个结构体,清晰又好扩展。

返回值设计

返回值这块,我吃过亏。以前有个项目,函数返回 0 表示成功,-1 表示失败。结果调试时发现,失败原因千奇百怪——超时、参数错误、硬件无响应……一个 -1 根本不够用。

后来我统一用枚举:

typedef enum {
    HAL_OK       = 0,
    HAL_ERROR    = -1,
    HAL_BUSY     = -2,
    HAL_TIMEOUT  = -3,
    HAL_INVALID  = -4
} hal_status_t;

这样上层可以根据返回值做不同处理:超时了重试,参数错了打印日志,硬件错误就报警。

注意:不要用返回值的正负来表示成功失败。有些驱动用正数表示成功、负数表示错误,结果上层判断时写了个 if (ret),把正数当成了失败。统一用枚举,清晰无歧义。

如何屏蔽硬件差异

这是 HAL 设计的核心难点。不同芯片的寄存器、中断、时钟都不一样,怎么让上层感觉不到差异?

我的做法是「分层抽象 + 回调注册」。

分层抽象

把硬件操作分成三层:

  1. 芯片级:直接操作寄存器,比如设置 GPIO 模式、读写 UART 数据
  2. 板级:定义引脚映射、外设配置,比如 LED 接在 PA5 上
  3. 接口级:提供统一 API,比如 led_on()、uart_send()

换芯片时,只改芯片级和板级,接口级不动。

回调注册

有些硬件行为是异步的,比如中断。我习惯用回调函数来屏蔽差异:

// HAL 层定义回调类型
typedef void (*uart_rx_cb_t)(uint8_t data);

// 注册回调
void hal_uart_register_rx_cb(uart_t *uart, uart_rx_cb_t cb);

// 底层中断处理
void UART1_IRQHandler(void) {
    uint8_t data = UART1->DR;
    if (rx_cb) {
        rx_cb(data);  // 调用上层注册的回调
    }
}

这样,上层不用关心中断向量表怎么配,也不用管是哪个芯片。注册好回调,数据来了自然能处理。

核心思想:HAL 层把「变化」封装在内部,把「不变」暴露给上层。硬件差异是变化,接口行为是不变。

HAL 层代码组织方式

代码怎么组织,直接影响可维护性。我推荐按「模块 + 平台」的方式组织目录:

hal/
├── inc/                    # 公共头文件
│   ├── hal_gpio.h
│   ├── hal_uart.h
│   └── hal_i2c.h
├── src/                    # 平台无关实现
│   ├── hal_gpio.c
│   ├── hal_uart.c
│   └── hal_i2c.c
├── platform/               # 平台相关实现
│   ├── stm32f4/
│   │   ├── hal_gpio_stm32.c
│   │   └── hal_uart_stm32.c
│   └── gd32/
│       ├── hal_gpio_gd32.c
│       └── hal_uart_gd32.c
└── hal_config.h            # 平台选择宏

这样组织的好处:

  • 公共头文件定义接口,所有平台共用
  • 平台目录放具体实现,换平台时只加新目录
  • 配置头文件通过宏选择当前平台

我在一个项目里用过这种结构,从 STM32F4 迁移到 GD32F4,只花了半天时间——写了新的平台文件,改了配置宏,上层代码一行没动。

HAL 层代码组织与分层关系 上层应用 / 业务逻辑 HAL 接口层(统一 API) 平台抽象层(平台选择 + 回调注册) STM32 平台实现 GD32 平台实现 其他平台实现 换平台时,只需新增平台实现目录 接口层和上层代码完全不需要修改

嗯,这张图把 HAL 的分层关系说清楚了。上层应用只管调 API,接口层定义规范,平台层做具体实现。换平台?加个新目录就行。

一个小技巧:在 hal_config.h 里用条件编译选择平台。比如 #if defined(STM32F4) 就包含 stm32 的头文件。这样整个工程只需要一个配置文件,清晰好管理。

好了,HAL 的设计原则就聊到这儿。记住:接口稳定、实现隔离、平台可换,这三点做到了,你的嵌入式软件就能经得起折腾。