2. 硬件抽象层(HAL)设计:GPIO、UART、I2C、SPI等外设的接口抽象方法
做嵌入式开发久了,你会发现一个规律:换芯片是迟早的事。我见过太多项目,因为底层驱动和业务逻辑死死耦合在一起,换一次MCU几乎等于重写整个工程。说白了,这就是没有做好硬件抽象层(HAL)的后果。
这一章,我们就来聊聊怎么给GPIO、UART、I2C、SPI这些常见外设做接口抽象。不是讲理论,是讲实战中怎么落地。
2.1 为什么要做硬件抽象?
先看一个反面教材。很多新手写代码是这样的:
// 直接操作寄存器
void led_on(void) {
GPIOA->BSRR = (1 << 5); // PA5 置高
}
void led_off(void) {
GPIOA->BRR = (1 << 5); // PA5 置低
}
这段代码有什么问题?它把硬件细节写死了。如果下次换到STM32F4系列,GPIO寄存器名字变了;如果换到NXP的芯片,整个操作方式都不一样。你想想看,业务代码里到处都是这种直接寄存器操作,移植起来得多痛苦。
我个人习惯的做法是:先定义接口,再实现底层。接口层只描述"做什么",不关心"怎么做"。
2.2 GPIO接口抽象
GPIO是最基础的外设,但抽象起来也有讲究。我见过有人把GPIO抽象成几十个函数,结果比直接操作寄存器还复杂。没必要。
一个够用的GPIO抽象层,只需要三个要素:
- 初始化:配置引脚方向、上下拉、输出类型
- 写操作:输出高/低电平
- 读操作:读取引脚电平
来看一个我常用的接口定义:
// gpio_hal.h - 硬件抽象层接口
#ifndef GPIO_HAL_H
#define GPIO_HAL_H
#include <stdint.h>
#include <stdbool.h>
// GPIO引脚配置结构体
typedef struct {
uint8_t port; // 端口号
uint8_t pin; // 引脚号
uint8_t mode; // 输入/输出
uint8_t pull; // 上拉/下拉/浮空
uint8_t speed; // 输出速度
} gpio_config_t;
// GPIO操作句柄
typedef void* gpio_handle_t;
// 接口函数声明
gpio_handle_t gpio_init(gpio_config_t *config);
void gpio_set(gpio_handle_t handle, bool level);
bool gpio_get(gpio_handle_t handle);
void gpio_toggle(gpio_handle_t handle);
void gpio_deinit(gpio_handle_t handle);
#endif
这里有个关键点:gpio_handle_t 用了 void*。为什么?因为不同芯片的GPIO对象类型不一样,用void指针可以屏蔽这些差异。底层实现里,这个指针会指向具体的硬件对象。
底层实现示例(以STM32为例):
// gpio_hal_stm32.c
#include "gpio_hal.h"
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
} stm32_gpio_t;
gpio_handle_t gpio_init(gpio_config_t *config) {
stm32_gpio_t *obj = malloc(sizeof(stm32_gpio_t));
// 根据config->port选择对应的GPIO_TypeDef
obj->port = get_stm32_port(config->port);
obj->pin = 1 << config->pin;
// 配置HAL库的GPIO初始化
// ... 省略具体配置代码
return (gpio_handle_t)obj;
}
void gpio_set(gpio_handle_t handle, bool level) {
stm32_gpio_t *obj = (stm32_gpio_t *)handle;
HAL_GPIO_WritePin(obj->port, obj->pin,
level ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
2.3 UART接口抽象
UART的抽象比GPIO复杂一些,因为它涉及数据收发、中断、DMA等多种模式。我建议把UART抽象成三个层次:
| 层次 | 职责 | 典型接口 |
|---|---|---|
| 配置层 | 波特率、数据位、校验位等参数设置 | uart_init(), uart_config() |
| 数据层 | 单字节/多字节收发 | uart_send(), uart_recv() |
| 控制层 | 中断使能、DMA配置、流控 | uart_irq_enable(), uart_dma_config() |
来看一个精简但实用的UART接口:
// uart_hal.h
#ifndef UART_HAL_H
#define UART_HAL_H
#include <stdint.h>
typedef struct {
uint32_t baudrate;
uint8_t data_bits; // 7 or 8
uint8_t parity; // 0:none, 1:odd, 2:even
uint8_t stop_bits; // 1 or 2
} uart_config_t;
typedef void* uart_handle_t;
// 回调函数类型
typedef void (*uart_rx_cb_t)(uint8_t data);
uart_handle_t uart_init(uart_config_t *config);
void uart_send_byte(uart_handle_t handle, uint8_t data);
void uart_send_buf(uart_handle_t handle, uint8_t *buf, uint32_t len);
uint8_t uart_recv_byte(uart_handle_t handle);
int32_t uart_recv_buf(uart_handle_t handle, uint8_t *buf, uint32_t max_len);
void uart_set_rx_callback(uart_handle_t handle, uart_rx_cb_t cb);
void uart_deinit(uart_handle_t handle);
#endif
这里我特别想强调回调函数的设计。我在项目中遇到过一个问题:不同芯片的UART中断处理方式差异很大。有的用中断向量表,有的用事件回调。所以我在接口层只定义回调函数类型,具体怎么注册、怎么触发,交给底层实现去处理。
2.4 I2C接口抽象
I2C的抽象核心在于事务模型。I2C通信本质上是主设备发起的一系列操作:起始信号、发送地址、读写数据、停止信号。好的抽象应该让上层开发者感觉不到这些细节。
我推荐用"事务"的方式来抽象I2C:
// i2c_hal.h
#ifndef I2C_HAL_H
#define I2C_HAL_H
#include <stdint.h>
#include <stdbool.h>
typedef struct {
uint32_t speed; // 标准模式100k, 快速模式400k
uint8_t address_bits; // 7 or 10
} i2c_config_t;
typedef void* i2c_handle_t;
// I2C事务操作
bool i2c_master_write(i2c_handle_t handle, uint8_t dev_addr,
uint8_t *data, uint32_t len);
bool i2c_master_read(i2c_handle_t handle, uint8_t dev_addr,
uint8_t *data, uint32_t len);
bool i2c_mem_write(i2c_handle_t handle, uint8_t dev_addr,
uint16_t mem_addr, uint8_t addr_len,
uint8_t *data, uint32_t len);
bool i2c_mem_read(i2c_handle_t handle, uint8_t dev_addr,
uint16_t mem_addr, uint8_t addr_len,
uint8_t *data, uint32_t len);
i2c_handle_t i2c_init(i2c_config_t *config);
void i2c_deinit(i2c_handle_t handle);
#endif
注意看,这里我提供了i2c_mem_write和i2c_mem_read。为什么?因为大多数I2C传感器(比如温度传感器、加速度计)都是通过寄存器地址来访问的。如果只提供基础的读写接口,上层每次都要自己拼装地址字节,很麻烦。
2.5 SPI接口抽象
SPI的抽象和I2C类似,但有一个关键区别:SPI是全双工的。也就是说,每次发送数据的同时也在接收数据。这个特性必须在接口中体现出来。
// spi_hal.h
#ifndef SPI_HAL_H
#define SPI_HAL_H
#include <stdint.h>
#include <stdbool.h>
typedef struct {
uint32_t speed;
uint8_t mode; // 0,1,2,3 (CPOL/CPHA组合)
uint8_t data_width; // 8 or 16
bool msb_first; // MSB/LSB优先
} spi_config_t;
typedef void* spi_handle_t;
// SPI全双工传输
bool spi_transfer(spi_handle_t handle, uint8_t *tx_buf,
uint8_t *rx_buf, uint32_t len);
// 简化接口:只发送,丢弃接收数据
bool spi_send(spi_handle_t handle, uint8_t *data, uint32_t len);
// 简化接口:只接收,发送0xFF
bool spi_recv(spi_handle_t handle, uint8_t *data, uint32_t len);
// 片选控制(如果硬件没有自动CS)
void spi_cs_set(spi_handle_t handle, bool level);
spi_handle_t spi_init(spi_config_t *config);
void spi_deinit(spi_handle_t handle);
#endif
这里有个细节:spi_transfer的tx_buf和rx_buf都是指针,如果只想发送不接收,rx_buf传NULL;只想接收不发送,tx_buf传NULL。这种设计在底层实现时很灵活。
2.6 接口抽象的整体架构
把上面几个外设的抽象层放在一起,整个HAL的架构是这样的:
2.7 接口抽象的最佳实践
最后,总结几条我在实际项目中总结出来的经验:
- 接口要稳定:接口定义好后不要轻易改。我见过一个项目,HAL接口改了三次,结果上层代码跟着改了三遍,比不抽象还累。
- 错误处理要统一:所有接口函数的返回值要一致。我个人习惯用bool表示成功/失败,用int32_t返回错误码(正数表示成功,负数表示错误码)。
- 不要过度抽象:如果你的项目只用一款芯片,而且永远不会换,那抽象到够用就行。抽象是有成本的——多了一层函数调用,多了一些内存开销。
- 考虑测试性:好的抽象层应该方便做单元测试。比如,你可以写一个"模拟UART"的实现,把数据输出到一个环形缓冲区,这样测试上层逻辑时就不需要真实硬件。