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

好,咱们直接进入正题。硬件抽象层,圈里人常叫 HAL。这东西到底解决什么问题?说白了,就是让上层的应用代码,别直接跟寄存器、中断号、GPIO 管脚这些硬件细节谈恋爱。你想想看,今天用 STM32,明天换 GD32,后天可能又换成瑞萨。如果代码里到处都是寄存器操作,那移植起来,啧啧,基本等于重写。

我早期做项目就吃过这个亏。一个数据采集器,原厂芯片缺货,临时换方案。结果底层驱动全耦合在一起,改了一个星期,还引入一堆新 bug。从那以后,我学乖了。HAL 不是可选项,是嵌入式工程的刚需。

这一章,咱们就掰开揉碎,聊聊 HAL 设计的三个核心原则。这三个原则,是我个人认为最实用的。

2.1 分层思想:别让代码乱成一锅粥

分层,是软件工程的老祖宗思想。但在嵌入式里,它特别容易被忽视。很多人觉得,单片机资源有限,分层会引入额外开销。其实这是个误解。

合理的分层,反而能让代码更清晰,更容易优化。我一般把嵌入式软件分成这么几层:

层次 职责 依赖关系
应用层 业务逻辑、状态机、算法 只调用 HAL 接口
硬件抽象层 (HAL) 封装硬件差异,提供统一 API 调用芯片驱动层
芯片驱动层 直接操作寄存器、中断、DMA 依赖具体芯片手册

你看,每一层都有明确的职责。应用层工程师,根本不需要知道底层用的是 SPI 还是 I2C。他只需要调用 HAL_Sensor_Read() 就行。这就是分层的魅力。

核心要点: 上层依赖下层,但下层绝不能反向依赖上层。这是铁律。

我曾经在一个项目中,看到有人把 UI 界面的刷新逻辑,直接写在了定时器中断里。结果换了个主频更高的芯片,界面反而卡顿了。为什么?因为分层没做好,中断里干了不该它干的事。所以,分层不只是为了移植,更是为了代码的可维护性和可测试性。

2.2 依赖倒置:让高层不再看底层脸色

依赖倒置原则,英文叫 Dependency Inversion Principle,简称 DIP。这名字听着挺唬人,其实核心思想很简单:高层模块不应该依赖低层模块,两者都应该依赖抽象。

什么意思?我给你举个例子。假设你写了一个数据记录模块,它需要把数据存到 EEPROM 里。传统写法,你可能会在数据记录模块里直接调用 EEPROM 的写函数。这就叫高层依赖了低层。如果哪天你想把存储介质换成 Flash 或者 SD 卡,你就得改数据记录模块的代码。

依赖倒置的做法是:先定义一个抽象的存储接口,比如:

// 抽象接口:存储设备
typedef struct {
    int32_t (*Init)(void);
    int32_t (*Write)(uint32_t addr, uint8_t *data, uint32_t len);
    int32_t (*Read)(uint32_t addr, uint8_t *data, uint32_t len);
} Storage_Device_t;

然后,数据记录模块只依赖这个 Storage_Device_t 接口。至于底层是 EEPROM 还是 Flash,由具体的实现去填充这个接口的函数指针。

我的习惯: 在 HAL 层,我特别喜欢用结构体封装函数指针。这样既能实现多态,又不会引入 C++ 那样的复杂机制。对于资源受限的 MCU,这招特别实用。

这样做的好处很明显。高层模块(数据记录)变得稳定,不受底层硬件变更的影响。底层模块(EEPROM 驱动)也可以独立测试和替换。这就是依赖倒置的核心价值。

2.3 接口与实现分离:隐藏那些不该暴露的细节

这个原则,跟依赖倒置是孪生兄弟。接口与实现分离,强调的是头文件里只放用户需要知道的东西。

我见过很多新手写的驱动,头文件里把寄存器地址、位定义、内部状态枚举全暴露出来了。这其实是一种“过度暴露”。正确的做法是:

  • 公开接口: 放在 hal_xxx.h 中,只包含函数声明、必要的类型定义和错误码。
  • 私有实现: 放在 hal_xxx_priv.h 或直接在 .c 文件中用 static 隐藏。

举个例子,一个 UART 的 HAL 接口,头文件应该长这样:

// hal_uart.h
#ifndef __HAL_UART_H__
#define __HAL_UART_H__

#include <stdint.h>

typedef enum {
    HAL_UART_OK       = 0,
    HAL_UART_ERROR    = -1,
    HAL_UART_TIMEOUT  = -2
} HAL_UART_Status_t;

typedef void (*HAL_UART_RxCallback_t)(uint8_t data);

HAL_UART_Status_t HAL_UART_Init(uint32_t baudrate);
HAL_UART_Status_t HAL_UART_Send(uint8_t *data, uint32_t len);
HAL_UART_Status_t HAL_UART_RegisterRxCallback(HAL_UART_RxCallback_t cb);

#endif

你看,用户只需要知道这三个函数和一个回调类型。至于底层用的是哪个定时器做波特率发生器,中断优先级怎么配,这些细节,用户完全不需要关心。这就是接口与实现分离。

注意: 不要为了“方便用户”而把内部结构体暴露出去。一旦暴露,你就失去了修改实现的权利。用户可能会直接访问你的内部成员,导致耦合。我曾经就因为这个原因,重构了一个驱动,结果下游有十几个模块都要跟着改,教训深刻。

2.4 三个原则的关系与落地

这三个原则,不是孤立的。它们相辅相成,共同构建了一个健壮的 HAL。

  • 分层思想 是骨架,定义了代码的宏观结构。
  • 依赖倒置 是血肉,让层与层之间的依赖关系变得灵活。
  • 接口与实现分离 是皮肤,隐藏了内部的复杂性,只展示干净的外表。

下面这张图,是我做 HAL 设计时常用的结构,你可以参考一下:

HAL 设计原则关系图 应用层 (Application) 硬件抽象层 (HAL API) 芯片驱动层 (Chip Driver) 硬件 (Hardware) 设计原则 1. 分层思想 上层依赖下层 2. 依赖倒置 高层依赖抽象 3. 接口与实现分离 头文件只暴露接口 实现细节隐藏 在 .c 文件中 或私有头文件中

从这张图你可以看到,每一层都只跟相邻的层打交道。应用层只认 HAL 接口,HAL 接口调用芯片驱动,芯片驱动操作硬件。这就是一个典型的、符合分层思想的架构。

在实际项目中,我建议你从最小的模块开始实践。比如先给一个 GPIO 输出做 HAL,然后再扩展到 UART、SPI。不要试图一步到位,那样容易陷入过度设计的泥潭。

总结一下: 好的 HAL 设计,能让你的代码在不同芯片之间“丝滑”移植。分层是基础,依赖倒置是灵魂,接口与实现分离是保障。这三板斧用好,你的嵌入式软件架构,就成功了一半。