2、硬件抽象层(HAL)设计原则:分层思想与依赖倒置

好,咱们进入第二章。这一章聊的是 HAL 的设计原则,说白了就是「怎么把硬件藏起来,让上层代码看不见它」。

我刚开始做嵌入式那几年,写过不少「裸奔」代码。GPIO 直接操作寄存器,UART 读写全靠查手册。那时候觉得挺爽,代码跑得飞快。后来项目一多,芯片一换,我就傻眼了——几乎每行代码都得重写。嗯,这就是没有 HAL 的代价。

2.1 为什么需要分层?

先看一个反面教材。假设你写了个 LED 闪烁程序:

// 直接操作寄存器
void led_blink(void) {
    *(volatile uint32_t *)0x40020C14 |= (1 << 13);  // 开
    delay(500);
    *(volatile uint32_t *)0x40020C14 &= ~(1 << 13); // 关
    delay(500);
}

这段代码有什么问题?

  • 硬件耦合太紧:地址 0x40020C14 是 STM32 的 GPIOC_BSRR,换个芯片就得改。
  • 不可测试:你没法在 PC 上跑这个函数,除非你有块开发板。
  • 不可复用:换个 LED 引脚,所有调用处都得改。

分层的核心目的就三个:

目标 说明 我见过的反面案例
隔离变化 硬件变了,只改 HAL 层,上层不动 某项目换 MCU,改了 200 多个文件
方便测试 上层逻辑可以在模拟环境跑 硬件没回来,代码没法测,工期全浪费
团队并行 驱动组和业务组可以同时开发 驱动没写好,应用组只能干等

2.2 分层架构长什么样?

我个人习惯把嵌入式软件分成四层。你想想看,从下往上:

  1. 硬件层:芯片、外设、寄存器。这是物理世界。
  2. HAL 层:封装寄存器操作,提供抽象接口。比如 GPIO_WritePin()。
  3. 中间件层:协议栈、文件系统、RTOS。它们只调用 HAL。
  4. 应用层:业务逻辑。它只跟中间件和 HAL 打交道。

这里的关键是:依赖方向必须从上到下。应用层可以调用 HAL,但 HAL 不能反过来调用应用层。一旦出现反向依赖,你的架构就乱了。

核心原则:上层依赖下层,下层不依赖上层。HAL 是「承上启下」的隔离带。

下面这张图展示了典型的分层结构:

嵌入式软件分层架构 应用层(Application) 业务逻辑、任务调度、用户交互 中间件层(Middleware) 协议栈、文件系统、RTOS、算法库 硬件抽象层(HAL) GPIO、UART、SPI、I2C、Timer 抽象接口 硬件层(Hardware) MCU 寄存器、外设、物理引脚 依赖方向 上 层 依 赖 下 层

2.3 依赖倒置:让抽象不再「倒挂」

分层解决了「谁依赖谁」的问题,但还有一个更隐蔽的坑——高层直接依赖低层的具体实现。举个例子:

// 不好的设计:应用层直接依赖具体驱动
void app_task(void) {
    stm32_gpio_write(LED_PIN, 1);  // 直接调 STM32 的驱动
    delay_ms(500);
    stm32_gpio_write(LED_PIN, 0);
}

如果哪天你要把代码移植到 GD32 上,就得把 stm32_gpio_write 全部改成 gd32_gpio_write。这就是典型的「高层依赖低层具体实现」。

依赖倒置原则(DIP)告诉我们:高层模块不应该依赖低层模块,两者都应该依赖抽象。

怎么做?用接口。在 C 语言里,接口就是函数指针结构体:

// HAL 接口定义(抽象)
typedef struct {
    void (*init)(void);
    void (*write)(uint8_t pin, uint8_t level);
    uint8_t (*read)(uint8_t pin);
} gpio_hal_t;

// 应用层只依赖这个抽象接口
void app_task(gpio_hal_t *gpio) {
    gpio->write(LED_PIN, 1);
    delay_ms(500);
    gpio->write(LED_PIN, 0);
}

这样,底层驱动只要实现了 gpio_hal_t 接口,应用层代码一行都不用改。我在一个物联网网关项目里用过这个模式,换了三次 MCU(STM32 → NXP → 国产某芯片),应用层代码零改动。说实话,那种感觉真的很爽。

我的经验:接口定义要「够用但不过度」。别想着把所有功能都抽象出来,那会让接口变得臃肿。只抽象当前项目需要的操作,未来需要再加。

2.4 接口隔离:别让 HAL 变成「万能工具箱」

还有一个常见问题:HAL 层接口设计得太大。一个 gpio_hal_t 里塞了 init、write、read、toggle、set_mode、set_pull、set_speed…… 十几个函数。应用层明明只需要 write 和 read,却被迫依赖了所有函数。

这就是接口隔离原则(ISP)要解决的问题:客户端不应该被迫依赖它不使用的接口。

我建议的做法是:

  • 按功能拆分接口:GPIO 控制、UART 通信、SPI 传输各自独立。
  • 每个接口尽量小:5 个以内函数比较合适。
  • 允许组合:一个设备可能需要多个接口,那就组合使用。
接口 函数 适用场景
gpio_output_t init, write, toggle LED、继电器、蜂鸣器
gpio_input_t init, read, enable_interrupt 按键、传感器中断
uart_tx_t init, send, flush 日志输出、数据上报
uart_rx_t init, receive, register_callback 指令接收、数据采集

注意:接口拆分太细也不行。我曾经把一个 SPI 接口拆成 6 个微型接口,结果每个设备要组合 3-4 个接口,代码反而更复杂了。适度拆分,别走极端。

2.5 避坑指南:我踩过的几个坑

讲完原则,分享几个真实教训。

坑一:HAL 层里塞了业务逻辑

我曾经在 HAL 的 UART 驱动里加了一段「收到特定指令后自动回复」的代码。当时觉得方便,后来发现业务逻辑变了,HAL 也得改。嗯,这其实违反了分层原则。HAL 只做「收发」,不关心「收的是什么、该怎么回」。

坑二:接口定义太「硬件化」

比如 spi_transfer(uint8_t *tx, uint8_t *rx, uint16_t len, uint8_t cs_pin)。这个 cs_pin 参数暴露了硬件细节。更好的做法是在 init 时配置好片选,transfer 时只传数据。

坑三:忽略错误处理

HAL 接口一定要有返回值。我见过不少 HAL 函数是 void 类型,出错了也不报。结果上层代码跑得欢,硬件其实已经挂了。建议所有 HAL 接口返回 int32_t,0 表示成功,负数表示错误码。

总结一下:HAL 设计的三条铁律——
1. 分层:上层依赖下层,下层不依赖上层。
2. 依赖倒置:高层和低层都依赖抽象接口。
3. 接口隔离:接口要小,按功能拆分。