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 抽象层的整体结构
说了这么多,我们画一张图来总结一下。
这张图很直观。从上到下,依赖关系是单向的。应用层只依赖抽象接口层,抽象接口层只依赖驱动适配层提供的函数指针。硬件变化被隔离在最底层。
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)。这样既保持了接口的通用性,又保留了灵活性。