3. 硬件抽象层(HAL)设计(下):UART/I2C/SPI的HAL层抽象、跨平台HAL接口设计、HAL层单元测试
上一讲我们把HAL层的理论框架搭好了。这一讲,咱们直接动手——拿UART、I2C、SPI这三个最常用的外设开刀,看看它们的HAL层到底该怎么抽象。
我个人习惯,做HAL设计时先问自己三个问题:这个外设的核心操作是什么?不同芯片的差异点在哪?上层驱动最不想看到什么?想清楚这三点,接口自然就出来了。
3.1 UART的HAL层抽象
UART说白了就是收发字节流。不同MCU的UART寄存器千差万别,但上层应用只关心三件事:初始化、发送、接收。
我在项目中遇到过一种典型情况:某款芯片的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 |
中断统一入口 | 中断向量表、嵌套优先级、标志位清除 |
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);
这里有个细节——为什么主模式和从模式接口要分开?因为很多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); /* 软件片选 */
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实例。上层驱动、业务逻辑、应用代码——全部不用动。
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%,后来换芯片平台时,跑一遍测试就知道哪些接口没实现好,省了大把调试时间。
3.6 小结
这一讲我们干了三件事:把UART/I2C/SPI的HAL接口设计出来了,用函数指针表实现了跨平台,还搞了一套可复用的单元测试方案。说白了,HAL层就是给上层驱动一个“干净的接口”,把芯片差异全部挡在下面。