第三章 硬件抽象层(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 种传感器,我只花了半天就搞定了。

驱动模型的核心思想:

  1. 设备节点描述「有什么硬件」
  2. 驱动描述「怎么操作这个硬件」
  3. 匹配机制把设备和驱动绑定起来

知识体系总览

下面这张图是我自己总结的 HAL 设计知识体系,你可以对照着理解:

HAL 设计知识体系 应用层(Application) 硬件抽象层(HAL) 统一接口 · 隔离变化 · 降低耦合 外设抽象层 GPIO UART I2C SPI 设备树与驱动模型 设备节点描述 · 驱动匹配 · 资源管理 硬件层(MCU/SoC) 分层设计 抽象方法

这张图把 HAL 设计的三个核心层次串起来了。从上往下看:应用层通过 HAL 接口调用外设功能,HAL 内部又依赖设备树和驱动模型来管理硬件资源。

我的建议:刚开始做 HAL 设计时,不要追求一步到位。先从一个外设(比如 GPIO)开始,把接口定好,跑通流程。然后再逐步扩展到其他外设。这样迭代起来压力小,也容易发现设计上的问题。

好了,这一章的内容就到这里。记住一句话:HAL 设计不是为了炫技,而是为了让你的代码经得起时间考验


专注资料整理