第三章 硬件抽象层(HAL)设计
说到硬件抽象层,很多刚入行的朋友会问:这不就是封装一下寄存器操作吗?
嗯,这么说也对,但只说对了一半。我做了这么多年自动化设备软件,可以负责任地告诉你:HAL 设计的好坏,直接决定了你的代码能活多久。
3.1 HAL 的作用与分层
先说说我个人的理解。HAL 的全称是 Hardware Abstraction Layer,说白了就是在硬件和上层应用之间加一层「翻译官」。
为什么要加这一层?
你想想看,今天你用 STM32 的 GPIO 点了个灯,明天客户说换国产芯片,你难道要把所有点灯代码重写一遍?
这就是 HAL 要解决的问题。
HAL 的核心作用:
- 隔离硬件变化:芯片换了,上层代码不用动
- 统一接口规范:不管底层是啥,上层调用的 API 长一样
- 降低耦合度:应用层不关心寄存器地址,只关心「我要读一个字节」
我习惯把 HAL 分成三层:
| 层级 | 名称 | 职责 | 举例 |
|---|---|---|---|
| L3 | 设备驱动层 | 封装具体外设的操作 | I2C_Read(), SPI_Write() |
| L2 | 总线抽象层 | 统一总线访问方式 | Bus_Transfer(), Bus_Config() |
| L1 | 寄存器映射层 | 直接操作硬件寄存器 | *(volatile uint32_t*)0x4000 |
我在项目中遇到过一种情况:团队里有人直接把寄存器操作写在业务逻辑里。结果芯片一换,整个项目差点重来。从那以后,我强制要求所有硬件操作必须经过 HAL。
3.2 GPIO/UART/I2C/SPI 的抽象方法
这部分是实战中的硬骨头。每种外设的特性不同,抽象的方式也要灵活调整。
3.2.1 GPIO 抽象
GPIO 看起来简单,但坑最多。我见过有人把 GPIO 的引脚号直接硬编码在代码里,换板子时改得想哭。
我的做法是这样的:
// gpio_hal.h
typedef struct {
uint8_t port; // GPIO 端口
uint8_t pin; // 引脚号
uint8_t mode; // 输入/输出/复用
uint8_t pull; // 上拉/下拉/浮空
} gpio_cfg_t;
// 统一接口
void gpio_init(gpio_cfg_t *cfg);
void gpio_set(gpio_cfg_t *cfg, uint8_t level);
uint8_t gpio_get(gpio_cfg_t *cfg);
你看,上层调用时只需要传一个配置结构体,底层怎么实现它不管。换芯片时,只需要重写 gpio_hal.c 里的函数体。
小技巧:把引脚配置做成数组,放在一个单独的头文件里。比如 board_pinmap.h,这样换板子只需要改这个文件。
3.2.2 UART 抽象
UART 的抽象要关注两个点:数据收发和参数配置。
我曾经踩过一个坑:某次项目里 UART 的波特率写死在初始化函数里,结果客户要求改成 115200,我找了半天才找到那个宏定义。
所以我现在这样设计:
// uart_hal.h
typedef struct {
uint32_t baudrate;
uint8_t data_bits; // 5/6/7/8
uint8_t parity; // NONE/ODD/EVEN
uint8_t stop_bits; // 1/2
} uart_cfg_t;
int uart_open(uart_cfg_t *cfg);
int uart_write(uint8_t *data, uint16_t len);
int uart_read(uint8_t *data, uint16_t len);
void uart_close(void);
注意这里我用了 open/close 的语义,而不是 init/deinit。为什么?因为这样更贴近「资源管理」的概念,方便后面做多实例管理。
3.2.3 I2C 抽象
I2C 的抽象要特别注意「从机地址」和「读写方向」。很多芯片的 I2C 控制器实现不一样,但上层只关心「往这个地址写数据」。
// i2c_hal.h
typedef struct {
uint8_t slave_addr; // 7位或10位地址
uint32_t speed; // 标准/快速/高速
} i2c_cfg_t;
int i2c_master_write(i2c_cfg_t *cfg, uint8_t *data, uint16_t len);
int i2c_master_read(i2c_cfg_t *cfg, uint8_t *data, uint16_t len);
int i2c_mem_write(i2c_cfg_t *cfg, uint16_t mem_addr, uint8_t *data, uint16_t len);
注意:I2C 的时序非常敏感。我在调试一个传感器时,发现读回来的数据总是错位。查了半天,原来是 HAL 层没有处理好「重复起始条件」。所以抽象时一定要保留底层的时序控制能力。
3.2.4 SPI 抽象
SPI 的抽象相对简单,但要注意「模式」和「片选」的管理。
// spi_hal.h
typedef struct {
uint8_t mode; // 0/1/2/3
uint32_t freq; // 时钟频率
uint8_t bit_order; // MSB/LSB first
} spi_cfg_t;
int spi_transfer(spi_cfg_t *cfg, uint8_t *tx, uint8_t *rx, uint16_t len);
int spi_cs_control(uint8_t cs_pin, uint8_t level);
我个人习惯把片选控制单独拿出来,因为很多设备需要手动控制片选时序,比如某些 LCD 驱动。
3.3 设备树与驱动模型
这部分是 HAL 设计的进阶内容。如果你用过 Linux,一定对设备树不陌生。但在嵌入式 RTOS 里,我们也可以借鉴这个思想。
设备树说白了就是:用数据结构来描述硬件资源,而不是硬编码在代码里。
举个例子:
// 设备树描述(简化版)
typedef struct {
const char *name;
uint32_t reg_base; // 寄存器基地址
uint32_t irq_num; // 中断号
void *private_data; // 私有数据
} device_node_t;
// 驱动模型
typedef struct {
const char *compatible; // 匹配字符串
int (*probe)(device_node_t *node);
int (*remove)(device_node_t *node);
} driver_t;
这样做的好处是什么?
- 换硬件时,只需要改设备树描述,驱动代码不用动
- 支持热插拔(如果硬件支持的话)
- 代码复用性极高
我在一个多传感器项目中用过这个模型。当时有 6 种不同的温度传感器,每种传感器的寄存器地址、转换时间都不一样。我写了一个通用的 sensor 驱动框架,每种传感器只需要注册自己的 probe 函数和读写接口。后来客户要加第 7 种传感器,我只花了半天就搞定了。
驱动模型的核心思想:
- 设备节点描述「有什么硬件」
- 驱动描述「怎么操作这个硬件」
- 匹配机制把设备和驱动绑定起来
知识体系总览
下面这张图是我自己总结的 HAL 设计知识体系,你可以对照着理解:
这张图把 HAL 设计的三个核心层次串起来了。从上往下看:应用层通过 HAL 接口调用外设功能,HAL 内部又依赖设备树和驱动模型来管理硬件资源。
我的建议:刚开始做 HAL 设计时,不要追求一步到位。先从一个外设(比如 GPIO)开始,把接口定好,跑通流程。然后再逐步扩展到其他外设。这样迭代起来压力小,也容易发现设计上的问题。
好了,这一章的内容就到这里。记住一句话:HAL 设计不是为了炫技,而是为了让你的代码经得起时间考验。