第三章 硬件资源抽象:GPIO、I2C、SPI、UART等外设的抽象接口设计
各位同学,咱们今天聊点实在的。硬件抽象层(HAL)的核心,说白了就是给上层应用提供一套“干净”的接口,让应用开发者不用关心底层寄存器长什么样。
我个人习惯把外设抽象分成三层:物理层、驱动层、接口层。物理层管寄存器操作,驱动层封装协议时序,接口层提供API给应用调。今天咱们重点讲接口层怎么设计。
3.1 GPIO抽象:最简单的往往最讲究
GPIO看起来简单,不就是拉高拉低嘛。但我在项目中遇到过一个问题:某款芯片的GPIO输出模式有推挽、开漏、复用功能,另一款芯片只有简单的输出输入。如果接口设计得太死,换芯片就得改应用代码。
我的做法是:用枚举把通用属性抽象出来。
/* gpio_hal.h */
typedef enum {
GPIO_DIR_INPUT,
GPIO_DIR_OUTPUT,
GPIO_DIR_ALT_FUNC /* 复用功能,具体功能由驱动层决定 */
} gpio_direction_t;
typedef enum {
GPIO_PULL_NONE,
GPIO_PULL_UP,
GPIO_PULL_DOWN
} gpio_pull_t;
typedef struct {
uint32_t pin; /* 引脚号,由平台定义 */
gpio_direction_t dir;
gpio_pull_t pull;
uint8_t init_val; /* 初始电平,仅输出模式有效 */
} gpio_config_t;
/* 接口函数 */
int gpio_init(gpio_config_t *cfg);
int gpio_set(uint32_t pin, uint8_t level);
int gpio_get(uint32_t pin, uint8_t *level);
int gpio_deinit(uint32_t pin);
3.2 I2C抽象:时序敏感,接口要稳
I2C这东西,说简单也简单,说坑也坑。我记得有一次调试一个触摸屏,I2C时钟线被拉低后一直不释放,查了半天发现是从设备地址写错了。嗯,这里要注意:I2C的抽象接口一定要把地址、速率、超时时间都暴露出来。
/* i2c_hal.h */
typedef struct {
uint32_t instance; /* I2C外设实例号,如I2C1、I2C2 */
uint32_t speed; /* 标准模式100k,快速模式400k */
uint16_t slave_addr; /* 7位或10位地址 */
uint8_t addr_len; /* 7或10 */
uint32_t timeout_ms; /* 超时时间,单位毫秒 */
} i2c_config_t;
int i2c_init(i2c_config_t *cfg);
int i2c_master_write(uint32_t instance, uint16_t addr,
uint8_t *data, uint16_t len);
int i2c_master_read(uint32_t instance, uint16_t addr,
uint8_t *data, uint16_t len);
int i2c_master_write_read(uint32_t instance, uint16_t addr,
uint8_t *wdata, uint16_t wlen,
uint8_t *rdata, uint16_t rlen);
int i2c_deinit(uint32_t instance);
3.3 SPI抽象:全双工,接口要灵活
SPI比I2C快,但配置项也多。时钟极性、相位、数据顺序、片选控制……你想想看,如果每个芯片的SPI驱动都自己写一套,那代码复用性基本为零。
我建议这样设计:
/* spi_hal.h */
typedef enum {
SPI_MODE_0, /* CPOL=0, CPHA=0 */
SPI_MODE_1, /* CPOL=0, CPHA=1 */
SPI_MODE_2, /* CPOL=1, CPHA=0 */
SPI_MODE_3 /* CPOL=1, CPHA=1 */
} spi_mode_t;
typedef enum {
SPI_MSB_FIRST,
SPI_LSB_FIRST
} spi_bit_order_t;
typedef struct {
uint32_t instance;
uint32_t baudrate; /* 实际速率由驱动层取整 */
spi_mode_t mode;
spi_bit_order_t bit_order;
uint8_t data_bits; /* 8或16 */
uint32_t timeout_ms;
} spi_config_t;
int spi_init(spi_config_t *cfg);
int spi_transfer(uint32_t instance, uint8_t *tx_buf,
uint8_t *rx_buf, uint16_t len);
int spi_write_then_read(uint32_t instance,
uint8_t *wdata, uint16_t wlen,
uint8_t *rdata, uint16_t rlen);
int spi_deinit(uint32_t instance);
3.4 UART抽象:异步通信,缓冲是关键
UART的抽象,我个人觉得最麻烦的是接收缓冲管理。串口数据是异步来的,你不知道它什么时候来、来多少。如果接口设计成阻塞接收,那CPU就废了。
我的方案是:环形缓冲区 + 回调机制。
/* uart_hal.h */
typedef void (*uart_rx_callback_t)(uint8_t data);
typedef struct {
uint32_t instance;
uint32_t baudrate;
uint8_t data_bits; /* 7或8 */
uint8_t stop_bits; /* 1或2 */
uint8_t parity; /* 0:无, 1:奇, 2:偶 */
uint32_t rx_buf_size; /* 接收环形缓冲区大小 */
uart_rx_callback_t rx_cb; /* 收到数据后的回调,可为NULL */
} uart_config_t;
int uart_init(uart_config_t *cfg);
int uart_send(uint32_t instance, uint8_t *data, uint16_t len);
int uart_receive(uint32_t instance, uint8_t *buf, uint16_t len,
uint32_t timeout_ms);
int uart_flush(uint32_t instance); /* 清空接收缓冲区 */
int uart_deinit(uint32_t instance);
3.5 统一错误码:让调试不再抓狂
最后说一个容易被忽视的点:错误码定义。不同外设的错误码如果不统一,上层应用就得写一堆if-else。我习惯把所有外设的错误码放在一个头文件里:
/* hal_error.h */
#define HAL_OK 0
#define HAL_ERROR -1
#define HAL_BUSY -2
#define HAL_TIMEOUT -3
#define HAL_INVALID_PARAM -4
#define HAL_UNSUPPORTED -5
所有外设接口都返回这些错误码。上层应用只需要判断返回值是否等于HAL_OK,其他情况统一走错误处理流程。简单粗暴,但有效。
3.6 总结一下
硬件抽象接口设计,说白了就是找共性、留扩展、定规范。找共性是指把不同芯片的相同功能抽象出来;留扩展是指用结构体参数来应对芯片差异;定规范是指错误码、命名风格要统一。
我见过不少项目,每个工程师写的驱动风格都不一样,有的返回0表示成功,有的返回1表示成功,有的用指针传参,有的用全局变量。最后集成的时候,光改接口就花了两周。嗯,所以从一开始就把接口定好,后面会省很多事。
下一章咱们聊聊定时器与中断管理,这东西比外设抽象更考验架构能力。到时候见。