3. 硬件抽象层(HAL)设计(下):UART/I2C/SPI的HAL层抽象、跨平台HAL接口设计、HAL层单元测试

上一讲我们把HAL层的理论框架搭好了。这一讲,咱们直接动手——拿UART、I2C、SPI这三个最常用的外设开刀,看看它们的HAL层到底该怎么抽象。

我个人习惯,做HAL设计时先问自己三个问题:这个外设的核心操作是什么?不同芯片的差异点在哪?上层驱动最不想看到什么?想清楚这三点,接口自然就出来了。

3.1 UART的HAL层抽象

UART说白了就是收发字节流。不同MCU的UART寄存器千差万别,但上层应用只关心三件事:初始化、发送、接收。

核心原则:HAL层只暴露“做什么”,不暴露“怎么做”。

我在项目中遇到过一种典型情况:某款芯片的UART发送寄存器必须等TXE标志位清空才能写,另一款芯片则用FIFO深度判断。如果上层驱动直接操作寄存器,换芯片就得重写整个驱动层——这显然不行。

来看看我常用的UART HAL接口设计:

/* uart_hal.h */
typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;    /* 5, 6, 7, 8 */
    uint8_t  stop_bits;    /* 1, 2 */
    uint8_t  parity;       /* 0: none, 1: odd, 2: even */
    uint8_t  flow_ctrl;    /* 0: none, 1: RTS/CTS */
} uart_config_t;

typedef enum {
    UART_OK       = 0,
    UART_BUSY     = -1,
    UART_TIMEOUT  = -2,
    UART_ERROR    = -3
} uart_status_t;

/* HAL接口声明 */
uart_status_t uart_init(uint8_t port, const uart_config_t *cfg);
uart_status_t uart_send(uint8_t port, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
uart_status_t uart_recv(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
void          uart_irq_handler(uint8_t port);  /* 中断入口 */

你想想看,这套接口换到STM32、GD32、NXP甚至国产RISC-V芯片上,上层代码一行都不用改。唯一要改的,只是HAL层内部的实现。

接口函数 抽象意图 隐藏的芯片差异
uart_init 配置通信参数 时钟使能、GPIO复用、波特率发生器
uart_send 发送数据块 FIFO/非FIFO、DMA/轮询、中断标志
uart_recv 接收数据块 超时机制、溢出处理、硬件流控
uart_irq_handler 中断统一入口 中断向量表、嵌套优先级、标志位清除
小技巧:timeout_ms参数用0表示“无限等待”,用非0值表示“超时返回”。这样上层可以灵活选择阻塞或非阻塞模式。

3.2 I2C的HAL层抽象

I2C比UART复杂一些,因为它有主从模式、多主机仲裁、时钟拉伸这些概念。但HAL层的目标不变——让上层驱动像操作文件一样读写I2C设备。

我记得有一次调试一个I2C触摸屏驱动,芯片手册说发送地址后要等ACK,但某款国产MCU的I2C控制器自动处理了ACK检测,反而让我的驱动在等待ACK时卡死了。嗯,这就是典型的HAL层没抽象好导致的悲剧。

/* i2c_hal.h */
typedef struct {
    uint32_t clock_hz;      /* 标准模式100k, 快速模式400k */
    uint8_t  addr_bits;     /* 7 or 10 */
    uint8_t  own_addr;      /* 从机模式用 */
} i2c_config_t;

typedef enum {
    I2C_MASTER = 0,
    I2C_SLAVE  = 1
} i2c_mode_t;

/* 主模式接口 */
uart_status_t i2c_master_init(uint8_t bus, const i2c_config_t *cfg);
uart_status_t i2c_master_write(uint8_t bus, uint8_t dev_addr, 
                                const uint8_t *data, uint32_t len);
uart_status_t i2c_master_read(uint8_t bus, uint8_t dev_addr, 
                               uint8_t *buf, uint32_t len);
uart_status_t i2c_master_write_read(uint8_t bus, uint8_t dev_addr,
                                     const uint8_t *wdata, uint32_t wlen,
                                     uint8_t *rdata, uint32_t rlen);

/* 从模式接口 */
uart_status_t i2c_slave_init(uint8_t bus, const i2c_config_t *cfg);
uart_status_t i2c_slave_write(uint8_t bus, const uint8_t *data, uint32_t len);
uart_status_t i2c_slave_read(uint8_t bus, uint8_t *buf, uint32_t *len);
注意:I2C的write_read组合操作(先发寄存器地址再读数据)在HAL层必须作为一个原子操作提供。如果拆成write和read两次调用,中间可能被其他主机抢占总线。

这里有个细节——为什么主模式和从模式接口要分开?因为很多MCU的I2C外设主从模式共用同一套寄存器,但配置逻辑完全不同。分开后,上层驱动一眼就能看出当前设备是主还是从,不会搞混。

3.3 SPI的HAL层抽象

SPI的抽象相对简单,因为它本质就是一组移位寄存器。但SPI的模式(CPOL/CPHA)、数据位宽、片选管理这些差异,必须在HAL层消化掉。

我曾经在一个项目里同时用了W25Q64(Flash)和NRF24L01(无线模块),前者要求模式0,后者要求模式3。如果HAL层不封装模式切换,上层驱动就得自己记着每次通信前改寄存器——这种代码我写过一次就再也不想写了。

/* spi_hal.h */
typedef struct {
    uint32_t clock_hz;
    uint8_t  mode;          /* 0, 1, 2, 3 对应CPOL/CPHA组合 */
    uint8_t  data_bits;     /* 8 or 16 */
    uint8_t  cs_pin;        /* 片选引脚号,-1表示软件控制 */
} spi_config_t;

uart_status_t spi_init(uint8_t bus, const spi_config_t *cfg);
uart_status_t spi_transfer(uint8_t bus, const uint8_t *tx_data, 
                            uint8_t *rx_data, uint32_t len);
uart_status_t spi_transfer_async(uint8_t bus, const uint8_t *tx_data,
                                  uint8_t *rx_data, uint32_t len,
                                  void (*callback)(uart_status_t));
void          spi_cs_control(uint8_t bus, uint8_t level);  /* 软件片选 */
关键设计:spi_transfer同时处理发送和接收。SPI是双工的,发送一个字节的同时必然收到一个字节。如果上层只想发送,把rx_data传NULL就行;只想接收,把tx_data传NULL。

3.4 跨平台HAL接口设计

讲完三个外设的接口,咱们聊聊怎么让这套接口真正跨平台。说白了,就是定义一套“HAL接口契约”,所有芯片的实现都必须遵守这个契约。

我建议用结构体函数指针表来实现多态——这样上层驱动通过指针调用HAL函数,换芯片时只需要换一个结构体实例。

/* hal_platform.h */
typedef struct {
    uart_status_t (*uart_init)(uint8_t port, const uart_config_t *cfg);
    uart_status_t (*uart_send)(uint8_t port, const uint8_t *data, uint32_t len, uint32_t timeout);
    uart_status_t (*uart_recv)(uint8_t port, uint8_t *buf, uint32_t len, uint32_t timeout);
    
    uart_status_t (*i2c_master_write)(uint8_t bus, uint8_t addr, const uint8_t *data, uint32_t len);
    uart_status_t (*i2c_master_read)(uint8_t bus, uint8_t addr, uint8_t *buf, uint32_t len);
    
    uart_status_t (*spi_transfer)(uint8_t bus, const uint8_t *tx, uint8_t *rx, uint32_t len);
} hal_driver_t;

/* 全局HAL驱动实例 */
extern const hal_driver_t g_hal_driver;

上层代码这样用:

/* 上层驱动 - 完全与芯片无关 */
void sensor_read_temperature(float *temp) {
    uint8_t cmd = 0x03;
    uint8_t buf[2];
    g_hal_driver.spi_transfer(0, &cmd, buf, 2);
    *temp = (buf[0] << 8 | buf[1]) * 0.01f;
}

换芯片时,只需要提供一个新的g_hal_driver实例。上层驱动、业务逻辑、应用代码——全部不用动。

跨平台HAL接口架构 应用层(业务逻辑) 设备驱动层(传感器/外设) HAL接口层(hal_driver_t 函数指针表) STM32实现 GD32实现 国产RISC-V实现 换芯片只需替换底层实现,上层代码零修改

3.5 HAL层单元测试

很多人觉得嵌入式代码没法做单元测试——寄存器不能mock,硬件依赖太重。其实不然。HAL层恰恰是最适合做单元测试的层次,因为它的接口是纯软件的,硬件依赖被隔离在实现内部。

我常用的方法是:写一个Mock HAL实现,把所有外设操作替换成内存操作。这样测试用例可以在PC上编译运行,不用烧录到开发板。

/* test_hal_mock.c - 用于单元测试的Mock实现 */
static uint8_t mock_uart_buffer[256];
static uint32_t mock_uart_len = 0;

uart_status_t mock_uart_send(uint8_t port, const uint8_t *data, 
                              uint32_t len, uint32_t timeout) {
    /* 把数据拷贝到mock缓冲区,不操作真实硬件 */
    memcpy(mock_uart_buffer, data, len);
    mock_uart_len = len;
    return UART_OK;
}

/* 测试用例 */
void test_uart_send_should_copy_data_to_buffer(void) {
    uint8_t test_data[] = {0x01, 0x02, 0x03};
    
    /* 调用mock实现 */
    mock_uart_send(0, test_data, 3, 100);
    
    /* 验证数据被正确拷贝 */
    TEST_ASSERT_EQUAL(3, mock_uart_len);
    TEST_ASSERT_EQUAL_MEMORY(test_data, mock_uart_buffer, 3);
}
测试策略:
  • 接口测试:验证HAL函数参数校验、返回值、超时逻辑
  • 状态测试:验证初始化前后状态转换、多次初始化、重复关闭
  • 边界测试:空指针、0长度、最大长度、超时0
  • 并发测试:中断中调用HAL函数、多任务同时操作同一外设

我建议把HAL层测试用例和实现代码放在同一个仓库里,每次提交代码都自动跑一遍。我在上一个项目里就是这么做的——HAL层测试覆盖率从0%干到了92%,后来换芯片平台时,跑一遍测试就知道哪些接口没实现好,省了大把调试时间。

避坑指南:我曾经在测试I2C的write_read组合操作时,mock实现里忘了模拟“从设备返回NACK”的情况。结果真实芯片上遇到NACK时,驱动直接死循环了。所以测试用例一定要覆盖异常路径——超时、NACK、总线冲突、数据溢出,一个都不能少。

3.6 小结

这一讲我们干了三件事:把UART/I2C/SPI的HAL接口设计出来了,用函数指针表实现了跨平台,还搞了一套可复用的单元测试方案。说白了,HAL层就是给上层驱动一个“干净的接口”,把芯片差异全部挡在下面。