3. 驱动层基础
做嵌入式这些年,我见过太多驱动代码写得像一团乱麻。硬件一换,整个软件层就得重写。说白了,问题出在哪?就是抽象层没做好。
今天我们来聊聊 HAL——硬件抽象层。这不是什么新概念,但真正能用好的人不多。我个人习惯把 HAL 看作一道「防火墙」:硬件在墙那边变,应用在墙这边稳。墙砌得好不好,直接决定你的代码能活多久。
抽象的本质:只暴露该暴露的
抽象不是把所有东西都藏起来。那是封装,不是抽象。抽象是「选择性暴露」——只暴露接口使用者需要关心的东西。
举个例子。你写一个 UART 驱动,底层寄存器可能有几十个:数据寄存器、状态寄存器、控制寄存器、波特率寄存器、FIFO 控制寄存器……但上层应用需要知道这些吗?不需要。它只想知道三件事:
- 怎么发数据
- 怎么收数据
- 有没有出错
所以一个合格的 UART HAL 接口,应该长这样:
// 好的抽象:只暴露必要操作
typedef struct {
int (*init)(uint32_t baudrate);
int (*send)(const uint8_t *data, uint32_t len);
int (*recv)(uint8_t *buf, uint32_t len, uint32_t timeout);
int (*get_status)(void);
} uart_hal_t;
你看,没有寄存器地址,没有中断号,没有 FIFO 深度。这些细节全部被挡在 HAL 后面。调用者只需要一个结构体指针,就能完成所有操作。
封装的边界:别把「家底」全抖出来
封装不是越严越好。我见过有人把 HAL 封装到连 GPIO 电平都读不出来——这就过头了。
封装要回答一个问题:哪些东西是「策略」,哪些是「机制」?
- 机制:硬件能做什么(比如:可以输出高电平、可以检测上升沿)
- 策略:上层想怎么用(比如:按键按下时点亮 LED)
HAL 只暴露机制,不暴露策略。策略是应用层的事。
我曾经在一个项目中,把 PWM 的占空比范围写死在 HAL 里——结果后来换了颗芯片,PWM 分辨率从 8 位变成 16 位,整个 HAL 都得改。教训就是:不要把硬件的「特性」当成「约束」暴露出去。
HAL 的五大设计原则
这些年我总结了一套 HAL 设计 checklist,分享给你:
| 原则 | 说明 | 反例 |
|---|---|---|
| 1. 无硬件依赖 | HAL 头文件里不能出现任何寄存器定义 | #define UART_BASE 0x40001000 |
| 2. 无平台假设 | 不假定中断优先级、时钟频率等运行时参数 | 在 HAL 里写死 SystemCoreClock |
| 3. 无状态泄漏 | 内部状态机对外不可见 | 暴露 uart_state_t 枚举给上层 |
| 4. 无阻塞假设 | 接口不强制同步或异步,由实现决定 | HAL 接口名写 send_blocking |
| 5. 无资源硬编码 | 外设实例通过参数传入,不写死 | uart_init() 不带句柄参数 |
uart_send(USART1, data, len)——直接把 USART1 这个硬件实例写死在参数里。换到 STM32F4 上还能用,换到 GD32 上就崩了。正确的做法是用句柄:uart_send(uart_handle_t *handle, data, len)。
接口的「粒度」怎么把握?
这是新手最容易纠结的问题。接口太粗,灵活性差;接口太细,调用繁琐。
我的经验是:按「操作语义」划分,而不是按「寄存器功能」划分。
比如 SPI 接口:
// 粒度太细(按寄存器功能)
int spi_set_cpha(uint8_t val);
int spi_set_cpol(uint8_t val);
int spi_set_baudrate(uint32_t val);
int spi_write_byte(uint8_t byte);
// 粒度适中(按操作语义)
int spi_init(spi_cfg_t *cfg); // 一次性配置所有参数
int spi_transfer(uint8_t *tx, uint8_t *rx, uint32_t len); // 全双工传输
你想想看,上层应用关心 CPHA 和 CPOL 分开设置吗?不关心。它只关心「配置好 SPI,然后收发数据」。所以 spi_init 用一个结构体把配置打包传进去,既清晰又灵活。
HAL 的典型分层结构
下面这张图是我常用的 HAL 分层方式。它把硬件、HAL、中间件、应用层清晰地隔离开:
注意看,中间件层和硬件驱动层之间没有直接箭头。所有跨层访问都必须经过 HAL。这就是我说的「防火墙」——硬件换了,只改 HAL 的实现,上面的中间件和应用一行代码都不用动。
接口设计的一个实用技巧:句柄模式
很多初学者写 HAL 喜欢用全局变量。比如:
// 不好的做法:全局变量
extern UART_HandleTypeDef huart1;
void uart_send(uint8_t *data, uint32_t len) {
HAL_UART_Transmit(&huart1, data, len, 1000);
}
这有什么问题?如果系统里有 3 个 UART,你得写 3 套函数。而且测试的时候没法 mock。
更好的做法是用句柄:
// 好的做法:句柄模式
typedef struct uart_hal uart_hal_t;
struct uart_hal {
void *hw_instance; // 硬件实例指针(由底层驱动填充)
int (*init)(uart_hal_t *self, uart_cfg_t *cfg);
int (*send)(uart_hal_t *self, const uint8_t *data, uint32_t len);
int (*recv)(uart_hal_t *self, uint8_t *buf, uint32_t len, uint32_t timeout);
};
// 使用示例
uart_hal_t uart1 = {
.hw_instance = &USART1,
.init = uart_stm32_init,
.send = uart_stm32_send,
.recv = uart_stm32_recv
};
uart_hal_t uart2 = {
.hw_instance = &USART2,
.init = uart_stm32_init,
.send = uart_stm32_send,
.recv = uart_stm32_recv
};
这样,上层代码只需要操作 uart_hal_t 指针,完全不知道底层是 STM32 还是 GD32。测试的时候,甚至可以传入一个 mock 句柄。
什么时候该「打破」抽象?
说了这么多抽象的好处,我也得泼点冷水。抽象不是万能的,有些场景下过度抽象反而坏事。
- 性能敏感路径:比如 SPI Flash 的页写入,每多一层函数调用就是几十个时钟周期。这时候可以考虑提供「快速路径」——在 HAL 里暴露一个
write_page_fast,直接操作寄存器。 - 调试阶段:我习惯在 HAL 里留一个
hal_get_last_error()接口,返回底层错误码。虽然破坏了封装,但调试效率高很多。 - 硬件特性无法抽象:比如某些芯片的 DMA 有特殊触发方式,抽象成通用接口会丢失能力。这时候可以提供一个
ioctl风格的扩展接口。
小结
抽象与封装,说到底是一种「契约精神」。你承诺给上层一个稳定的接口,底层怎么折腾是你的事。反过来,上层承诺只用你给的接口,不偷偷摸摸去翻寄存器。
好的 HAL 设计,能让你的代码活过三代硬件平台。不好的 HAL 设计,每次换芯片都是重构。
嗯,今天就聊到这里。记住一句话:接口是承诺,实现是细节。承诺要稳,细节要藏。