3、硬件抽象层(HAL)设计:HAL的作用、典型接口定义、GPIO/UART/I2C/SPI驱动抽象
好,咱们进入第三章。这一章我打算聊聊硬件抽象层,也就是HAL。
说实话,HAL这个词在嵌入式圈子里被用得太滥了。很多人觉得,不就是把寄存器操作包一层函数吗?嗯,没那么简单。我见过太多项目,号称有HAL,结果换了个MCU型号,代码改得跟重写似的。那叫封装,不叫抽象。
3.1 HAL到底解决了什么问题?
先想一个问题:你的应用代码,到底该不该知道它跑在STM32上,还是GD32上,或者是某款国产RISC-V芯片上?
答案显然是不该。应用层只关心“我要点亮一个LED”、“我要通过UART发一串数据”。至于这个LED是挂在PA5还是PB12,UART是USART1还是LPUART1,那是底层的事。
HAL的作用,就是在应用和硬件之间,砌一堵墙。这堵墙让应用层只跟接口打交道,不跟寄存器打交道。
HAL的核心价值:
- 隔离变化:硬件换了,只改HAL实现,应用层纹丝不动。
- 提高可测试性:可以写一个Mock的HAL,在PC上跑单元测试。
- 团队并行开发:应用工程师和驱动工程师,各自盯着接口干活,互不耽误。
我个人习惯,在项目启动的第一周,先把HAL接口定死。哪怕底层驱动还没写一行代码,应用层也能基于接口定义,先把逻辑框架搭起来。这叫“接口先行”。
3.2 典型接口应该怎么定义?
接口定义这件事,说白了就是定协议。你给应用层提供什么函数,参数是什么,返回值是什么,都得想清楚。
我一般会把接口分成三类:初始化类、控制类、数据收发类。咱们拿几个最常用的外设举例。
3.2.1 GPIO抽象
GPIO是最简单的,但也是最容易写死的。很多人喜欢直接写 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, SET)。这好吗?这不好。因为应用层还得知道GPIOA和GPIO_PIN_5。
我建议的做法,是用一个枚举或者宏,把引脚抽象成一个逻辑编号。
/* hal_gpio.h */
typedef enum {
HAL_GPIO_LED_STATUS,
HAL_GPIO_BUTTON_USER,
HAL_GPIO_RELAY_1,
HAL_GPIO_RELAY_2,
/* ... */
HAL_GPIO_NUM_MAX
} hal_gpio_id_t;
typedef enum {
HAL_GPIO_LEVEL_LOW = 0,
HAL_GPIO_LEVEL_HIGH = 1
} hal_gpio_level_t;
/* 初始化 */
void hal_gpio_init(hal_gpio_id_t id);
/* 写电平 */
void hal_gpio_write(hal_gpio_id_t id, hal_gpio_level_t level);
/* 读电平 */
hal_gpio_level_t hal_gpio_read(hal_gpio_id_t id);
/* 翻转 */
void hal_gpio_toggle(hal_gpio_id_t id);
你看,应用层调用的时候,只需要说 hal_gpio_write(HAL_GPIO_LED_STATUS, HAL_GPIO_LEVEL_HIGH)。至于这个LED到底对应哪个端口哪个引脚,那是 hal_gpio.c 里的一张映射表说了算。
我的经验:映射表最好用数组实现,不要用switch-case。数组查表是O(1),switch-case是O(n)。虽然GPIO数量不多,但养成好习惯很重要。
3.2.2 UART抽象
UART的抽象,重点在于数据收发模型。是轮询?是中断?还是DMA?接口设计上,我倾向于把底层机制藏起来,对外只提供同步或异步的读写接口。
/* hal_uart.h */
typedef enum {
HAL_UART_1,
HAL_UART_2,
/* ... */
HAL_UART_NUM_MAX
} hal_uart_id_t;
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 */
} hal_uart_config_t;
/* 初始化 */
int hal_uart_init(hal_uart_id_t id, hal_uart_config_t *cfg);
/* 发送(阻塞) */
int hal_uart_send(hal_uart_id_t id, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
/* 接收(阻塞) */
int hal_uart_recv(hal_uart_id_t id, uint8_t *buf, uint32_t len, uint32_t timeout_ms);
/* 异步发送(非阻塞,需要注册回调) */
int hal_uart_send_async(hal_uart_id_t id, const uint8_t *data, uint32_t len,
void (*cb)(void *ctx, int status), void *ctx);
这里我故意把 timeout_ms 暴露出来。为什么?因为我在项目中遇到过,某个传感器模块在异常情况下会一直不回复数据。如果没有超时机制,整个系统就卡死了。嗯,那次排查了很久,后来所有阻塞接口我都加了超时参数。
3.2.3 I2C抽象
I2C比UART复杂一点,因为它有设备地址、子地址、读写方向这些概念。接口设计上,我习惯把“发起一次I2C传输”作为最小操作单元。
/* hal_i2c.h */
typedef enum {
HAL_I2C_1,
HAL_I2C_2,
/* ... */
HAL_I2C_NUM_MAX
} hal_i2c_id_t;
typedef struct {
uint32_t speed_hz; /* 100000, 400000, 1000000 */
uint8_t addr_bits; /* 7 or 10 */
} hal_i2c_config_t;
/* 初始化 */
int hal_i2c_init(hal_i2c_id_t id, hal_i2c_config_t *cfg);
/* 写寄存器 */
int hal_i2c_write_reg(hal_i2c_id_t id, uint8_t dev_addr,
uint16_t reg_addr, uint8_t reg_addr_len,
const uint8_t *data, uint32_t len);
/* 读寄存器 */
int hal_i2c_read_reg(hal_i2c_id_t id, uint8_t dev_addr,
uint16_t reg_addr, uint8_t reg_addr_len,
uint8_t *buf, uint32_t len);
/* 原始传输(用于特殊协议) */
int hal_i2c_transfer(hal_i2c_id_t id, uint8_t dev_addr,
hal_i2c_msg_t *msgs, uint32_t num_msgs);
注意:I2C的时序非常敏感。我曾经遇到过,某款MCU的I2C外设在高速模式下,SCL高电平时间不够,导致从设备不响应。后来在HAL层加了一个微调参数才搞定。所以,hal_i2c_config_t 里最好预留一个 uint32_t reserved 字段,给底层驱动做平台适配用。
3.2.4 SPI抽象
SPI的抽象,核心在于“全双工”。你发一个字节的同时,一定会收到一个字节。所以接口设计上,我通常把发送和接收合并到一个函数里。
/* hal_spi.h */
typedef enum {
HAL_SPI_1,
HAL_SPI_2,
/* ... */
HAL_SPI_NUM_MAX
} hal_spi_id_t;
typedef enum {
HAL_SPI_MODE_0, /* CPOL=0, CPHA=0 */
HAL_SPI_MODE_1, /* CPOL=0, CPHA=1 */
HAL_SPI_MODE_2, /* CPOL=1, CPHA=0 */
HAL_SPI_MODE_3 /* CPOL=1, CPHA=1 */
} hal_spi_mode_t;
typedef struct {
uint32_t speed_hz;
hal_spi_mode_t mode;
uint8_t data_bits; /* 8 or 16 */
uint8_t cs_control; /* 0: 软件控制CS, 1: 硬件自动CS */
} hal_spi_config_t;
/* 初始化 */
int hal_spi_init(hal_spi_id_t id, hal_spi_config_t *cfg);
/* 全双工传输 */
int hal_spi_transfer(hal_spi_id_t id, const uint8_t *tx_buf,
uint8_t *rx_buf, uint32_t len);
/* 只发送(接收数据丢弃) */
int hal_spi_send(hal_spi_id_t id, const uint8_t *data, uint32_t len);
/* 只接收(发送0xFF占位) */
int hal_spi_recv(hal_spi_id_t id, uint8_t *buf, uint32_t len);
你想想看,如果应用层每次调用SPI都要自己处理CS引脚,那这个抽象就失败了。CS引脚的控制,应该由HAL层在 hal_spi_transfer 内部自动完成。应用层只管传数据。
3.3 接口设计的原则与避坑
说了这么多接口定义,我总结几条原则。这些原则不是我发明的,是我踩坑踩出来的。
| 原则 | 说明 | 反面案例 |
|---|---|---|
| 最小接口原则 | 只暴露应用层真正需要的操作 | 把寄存器级别的 set_baudrate()、enable_fifo() 全暴露出去 |
| 参数原子化 | 用枚举和结构体,不要用魔数 | hal_uart_init(1, 115200, 8, 1, 0) —— 谁看得懂? |
| 返回值统一化 | 所有接口返回 int,0成功,负数为错误码 |
有的返回 bool,有的返回 void,有的返回 HAL_StatusTypeDef |
| 错误码可扩展 | 预留自定义错误码范围 | 只定义 HAL_OK 和 HAL_ERROR,遇到新错误没法区分 |
我曾经踩过的一个坑:在一个项目里,我把所有HAL接口的返回值都定义成了 void。当时觉得,反正初始化不会失败。结果产品量产之后,有5%的板子在低温下I2C初始化失败,系统直接死机。因为没有返回值,上层根本不知道初始化没通过。从那以后,所有接口我都加了返回值,哪怕只是 int。
3.4 一张图看懂HAL的分层位置
说了这么多,咱们用一张SVG图来总结一下HAL在整个软件架构中的位置。
这张图很清楚地展示了:应用层只跟服务层打交道,服务层只跟HAL打交道,HAL下面是MCU驱动层,再下面是硬件。每一层都只依赖它的下一层,而且依赖的是接口,不是实现。
3.5 小结
这一章咱们聊了HAL的设计思路。说白了,HAL就是给上层一个“干净”的接口,把硬件的复杂性藏起来。
我个人觉得,HAL设计的好坏,直接决定了这个项目能活多久。接口定得好,换平台就像换衣服;接口定得烂,换平台就像换骨头。