第三章 硬件资源抽象: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);
小技巧: 我习惯把pin定义放在平台相关的头文件里,比如stm32_gpio_pin.h里写#define GPIO_LED_RED (1u << 5)。这样换平台时只需要换这个文件,接口层代码不用动。

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);
避坑指南: 我曾经在I2C接口里没加超时参数,结果某个从设备偶尔不响应,系统就卡死在等待ACK的死循环里。后来我强制要求所有I2C接口必须带超时,哪怕默认设成100ms也行。

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);
重点: spi_transfer函数同时发送和接收,这是SPI全双工的特性。如果只发不收,可以把rx_buf传NULL;只收不发,把tx_buf传NULL。驱动层内部处理空指针情况。

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);
经验之谈: 回调函数里不要做耗时操作,比如打印日志。我曾经在回调里调了printf,结果串口中断被卡住,丢了一堆数据。回调里最好只做数据搬运,比如把数据放到队列里,让任务去处理。

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表示成功,有的用指针传参,有的用全局变量。最后集成的时候,光改接口就花了两周。嗯,所以从一开始就把接口定好,后面会省很多事。

下一章咱们聊聊定时器与中断管理,这东西比外设抽象更考验架构能力。到时候见。