4. 串行通信抽象:UART/I2C/SPI 通用收发接口设计

串行通信,说白了就是 MCU 和外界对话的嘴巴。

UART、I2C、SPI 这三兄弟,几乎每个嵌入式项目都离不开。但麻烦的是——不同 MCU 的寄存器操作天差地别。你换个芯片,底层驱动就得重写。我早年吃过这个亏,一个项目从 STM32 切到 GD32,光串口就折腾了两天。

所以这一章,我们来聊聊怎么给这三类总线设计一套低耦合的抽象层。目标是:上层应用代码写一次,换 MCU 时只改底层适配文件。

4.1 抽象层的核心思想

抽象层不是什么高深理论。说白了就是定义接口,隔离变化。

我习惯把通信抽象分成三层:

  • 应用层:业务逻辑,只调用抽象接口。
  • 抽象接口层:定义收发函数指针、配置结构体。
  • 驱动适配层:针对具体 MCU 实现接口。

你想想看,这样分层之后,应用代码根本不知道底层用的是 STM32 还是 NXP。它只认那几个函数。

核心原则:抽象接口层不依赖任何 MCU 头文件。所有硬件相关的东西,都藏在适配层里。

4.2 通用收发接口定义

我们先定义一套通用的数据结构。嗯,这里要注意——不要试图用一个结构体兼容所有总线。UART 有波特率,I2C 有地址,SPI 有极性相位,硬塞在一起只会让代码变得恶心。

我的做法是:每个总线单独定义配置结构体,但收发函数的签名保持一致。

/* 抽象接口层头文件:hal_comm.h */

/* UART 配置 */
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;

/* I2C 配置 */
typedef struct {
    uint32_t speed_hz;
    uint8_t  slave_addr;
    uint8_t  addr_width;   /* 7 or 10 */
} hal_i2c_config_t;

/* SPI 配置 */
typedef struct {
    uint32_t speed_hz;
    uint8_t  mode;         /* 0/1/2/3 */
    uint8_t  data_width;   /* 8/16 */
    uint8_t  lsb_first;    /* 0=MSB, 1=LSB */
} hal_spi_config_t;

/* 通用收发函数指针类型 */
typedef int32_t (*hal_comm_send_t)(void *handle, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
typedef int32_t (*hal_comm_recv_t)(void *handle, uint8_t *buf, uint32_t len, uint32_t timeout_ms);

/* 每个总线实例的结构体 */
typedef struct {
    void              *hw_handle;    /* 指向底层硬件句柄 */
    hal_comm_send_t    send;
    hal_comm_recv_t    recv;
} hal_comm_instance_t;

你看,send 和 recv 的函数指针类型是统一的。不管 UART 还是 SPI,上层调用的方式一模一样。

我的习惯:返回值统一用 int32_t。负数表示错误码,0 表示成功,正数表示实际发送/接收的字节数。这样上层可以区分「发送了部分数据」和「完全失败」。

4.3 驱动适配层实现示例

有了接口定义,接下来就是针对具体 MCU 写适配代码。我以 STM32 的 UART 为例,给你看看适配层长什么样。

/* 适配层文件:hal_uart_stm32.c */
#include "hal_comm.h"
#include "stm32f4xx_hal.h"

/* 底层句柄结构体 */
typedef struct {
    UART_HandleTypeDef huart;
    /* 可以加一些统计、状态标志 */
} uart_stm32_t;

static int32_t uart_stm32_send(void *handle, const uint8_t *data, uint32_t len, uint32_t timeout_ms)
{
    uart_stm32_t *self = (uart_stm32_t *)handle;
    HAL_StatusTypeDef ret;

    ret = HAL_UART_Transmit(&self->huart, (uint8_t *)data, len, timeout_ms);
    if (ret == HAL_OK) {
        return (int32_t)len;
    }
    return -1;  /* 发送失败 */
}

static int32_t uart_stm32_recv(void *handle, uint8_t *buf, uint32_t len, uint32_t timeout_ms)
{
    uart_stm32_t *self = (uart_stm32_t *)handle;
    HAL_StatusTypeDef ret;

    ret = HAL_UART_Receive(&self->huart, buf, len, timeout_ms);
    if (ret == HAL_OK) {
        return (int32_t)len;
    }
    return -1;
}

/* 初始化函数,由上层调用 */
int32_t hal_uart_init(hal_comm_instance_t *inst, hal_uart_config_t *cfg)
{
    uart_stm32_t *self;

    self = (uart_stm32_t *)malloc(sizeof(uart_stm32_t));
    if (!self) return -1;

    /* 配置 STM32 HAL 的 UART 参数 */
    self->huart.Instance = USART1;
    self->huart.Init.BaudRate = cfg->baudrate;
    self->huart.Init.WordLength = (cfg->data_bits == 9) ? UART_WORDLENGTH_9B : UART_WORDLENGTH_8B;
    self->huart.Init.StopBits = (cfg->stop_bits == 2) ? UART_STOPBITS_2 : UART_STOPBITS_1;
    self->huart.Init.Parity = cfg->parity;  /* 直接映射 */
    self->huart.Init.Mode = UART_MODE_TX_RX;
    self->huart.Init.HwFlowCtl = UART_HWCONTROL_NONE;

    if (HAL_UART_Init(&self->huart) != HAL_OK) {
        free(self);
        return -2;
    }

    /* 填充抽象实例 */
    inst->hw_handle = self;
    inst->send = uart_stm32_send;
    inst->recv = uart_stm32_recv;

    return 0;
}

换到 GD32 或者 AT32 怎么办?很简单——再写一个 hal_uart_gd32.c,实现同样的 hal_uart_init 函数。上层代码一行都不用改。

我曾经踩过的坑:初始化函数里直接 malloc 分配句柄内存。在资源紧张的 MCU 上,malloc 可能失败或者产生碎片。后来我改用静态数组预分配,或者让上层传入内存池。建议你在设计初期就定好内存管理策略。

4.4 I2C 和 SPI 的适配要点

I2C 和 SPI 的适配思路完全一样,但有几个细节需要注意。

总线 适配难点 我的建议
UART 波特率精度、流控 抽象层只暴露基本参数,流控用独立接口
I2C 从机地址、ACK/NACK 处理 收发接口里隐含地址参数,或者单独传
SPI 片选管理、DMA 配合 片选单独抽象,不要和收发绑死

拿 I2C 来说,我习惯在 send 和 recv 之外再加一个 write_read 接口。因为很多 I2C 器件需要先写寄存器地址再读数据。这个组合操作如果拆成两次调用,中间可能被其他任务打断。

/* I2C 特有的组合操作 */
typedef int32_t (*hal_i2c_write_read_t)(void *handle, 
                                         uint8_t dev_addr,
                                         const uint8_t *wdata, uint32_t wlen,
                                         uint8_t *rdata, uint32_t rlen,
                                         uint32_t timeout_ms);

SPI 的片选信号,我建议单独抽象成一个 GPIO 操作接口。不要把它藏在 SPI 收发函数里。因为有些场景需要手动控制片选(比如多个从机共用总线)。

4.5 抽象层的整体结构

说了这么多,我们画一张图来总结一下。

串行通信抽象层架构 应用层(业务逻辑) 抽象接口层(hal_comm.h) hal_comm_instance_t | send / recv 函数指针 hal_uart_config_t | hal_i2c_config_t | hal_spi_config_t 驱动适配层(hal_uart_stm32.c / hal_i2c_gd32.c / hal_spi_nxp.c ...) 实现 hal_uart_init / hal_i2c_init / hal_spi_init 封装 HAL 库 / 寄存器操作 / LL 驱动 每个 MCU 系列一个 .c 文件,互不干扰 硬件层(UART / I2C / SPI 外设) 换 MCU 时,只需新增一个驱动适配文件,应用层和抽象接口层完全不动

这张图很直观。从上到下,依赖关系是单向的。应用层只依赖抽象接口层,抽象接口层只依赖驱动适配层提供的函数指针。硬件变化被隔离在最底层。

4.6 调试与验证技巧

抽象层写好了,怎么验证它真的「跨平台」?我分享几个实战经验。

  • 先写测试桩:在 PC 上写一个模拟的适配层,用 printf 模拟收发。这样不用硬件就能验证应用层逻辑。
  • 回环测试:把 TX 和 RX 短接,发一串数据看能不能收回来。这是验证 UART 适配层最直接的方法。
  • 对比抓波形:用逻辑分析仪抓 I2C/SPI 波形,和预期时序对比。我遇到过适配层把 SPI 模式 0 配成了模式 3,波形一抓就现原形。

一个小技巧:在抽象接口层加一个 hal_comm_ioctl 函数,用来处理一些特殊操作(比如修改波特率、复位外设)。函数签名可以设计成 int32_t (*ioctl)(void *handle, uint32_t cmd, void *param)。这样既保持了接口的通用性,又保留了灵活性。