接口隔离原则实战:如何定义稳定的MCU外设抽象接口

接口隔离原则,说白了就是「不要让你依赖你不需要的东西」。在MCU驱动开发里,这条原则尤其重要。我见过太多项目,一个GPIO结构体里塞了PWM配置、中断回调、甚至DMA通道号——结果换个芯片,整个驱动层都得重写。

今天我们就来聊聊,怎么给GPIO、UART、SPI、I2C这四大外设定一个「稳如狗」的抽象接口。

为什么接口会不稳定?

先看一个反面教材。这是我早期项目里真实出现过的代码:

typedef struct {
    uint32_t pin;
    uint32_t mode;       // 输入/输出/复用
    uint32_t pull;       // 上拉/下拉
    uint32_t speed;      // 速度等级
    uint32_t alternate;  // 复用功能号
    uint8_t  irq_enable; // 中断使能
    void    (*irq_callback)(void);
    uint32_t pwm_channel; // ???
} GPIO_Config;

你发现问题了吗?pwm_channel 和 irq_callback 根本不是所有GPIO场景都需要的。如果你只是点个LED,却要配一堆用不上的字段——这不叫灵活,这叫耦合。

我曾经踩过的坑:在一个智能家居项目里,我把UART的DMA配置直接写进了UART初始化结构体。结果后来换了个不支持DMA的MCU型号,整个UART驱动接口都得改,牵连了上层十几个模块。那次教训让我明白——接口只暴露「最小必要」的能力。

核心原则:最小接口 + 分层扩展

怎么定义稳定的接口?我总结了两条铁律:

  1. 最小接口:接口只包含所有实现都必须支持的操作
  2. 分层扩展:特殊能力通过扩展结构体或独立接口提供

来看一个对比表格:

外设 不稳定接口(反面) 稳定接口(正面)
GPIO 包含PWM、中断回调、DMA通道 只暴露读/写/方向/上下拉
UART 直接暴露DMA描述符、FIFO深度 只暴露发送/接收/波特率/中断注册
SPI 包含NSS引脚号、DMA通道选择 只暴露传输/配置模式/速率
I2C 包含时钟延展配置、总线仲裁细节 只暴露读写/速率/从机地址

GPIO抽象接口实战

我个人习惯把GPIO接口拆成两层:

第一层:核心操作——所有GPIO引脚都支持

typedef struct {
    void (*write)(void *obj, uint8_t level);
    uint8_t (*read)(void *obj);
    void (*set_direction)(void *obj, uint8_t dir);
    void (*set_pull)(void *obj, uint8_t pull);
} GPIO_Ops;

第二层:扩展能力——通过独立接口提供

// 中断扩展接口(可选)
typedef struct {
    void (*enable_irq)(void *obj, uint8_t trigger);
    void (*disable_irq)(void *obj);
    void (*register_callback)(void *obj, void (*cb)(void));
} GPIO_IRQ_Ops;

为什么这么分?因为不是所有GPIO引脚都需要中断。你想想看,一个I2C的SCL引脚,你给它配个中断回调干嘛?

小技巧:用 void *obj 作为第一个参数,这样上层代码不需要知道底层具体是什么结构体。我在多个项目里验证过,这种设计在换芯片时只需要改底层实现,上层代码一行都不用动。

UART抽象接口实战

UART的接口设计,核心是「收发分离」。我见过有人把发送和接收绑在同一个结构体里——结果想单独关接收都不行。

typedef struct {
    int32_t (*send)(void *obj, const uint8_t *data, uint32_t len, uint32_t timeout);
    int32_t (*receive)(void *obj, uint8_t *buf, uint32_t len, uint32_t timeout);
    void    (*set_baudrate)(void *obj, uint32_t baud);
    void    (*set_format)(void *obj, uint8_t data_bits, uint8_t parity, uint8_t stop_bits);
} UART_Ops;

这里有个细节:send 和 receive 返回 int32_t,负值表示错误码。为什么不用 bool?因为实际项目中,超时、校验错、溢出都是不同的错误场景,一个布尔值根本不够用。

至于DMA、FIFO这些高级特性——我建议通过独立的配置接口来设置:

// DMA扩展配置(可选)
typedef struct {
    uint8_t  tx_dma_enable;
    uint8_t  rx_dma_enable;
    uint32_t dma_channel_tx;
    uint32_t dma_channel_rx;
} UART_DMA_Config;

int32_t uart_config_dma(void *obj, UART_DMA_Config *cfg);

SPI抽象接口实战

SPI的接口设计,最容易出问题的是「传输模式」的定义。不同MCU对SPI模式的编号方式不一样——有的用0-3,有的用CPOL/CPHA分开配。

我的做法是:在抽象层统一用模式编号,底层做映射。

typedef struct {
    int32_t (*transfer)(void *obj, const uint8_t *tx, uint8_t *rx, uint32_t len);
    void    (*set_mode)(void *obj, uint8_t mode);  // 0-3
    void    (*set_speed)(void *obj, uint32_t hz);
    void    (*cs_control)(void *obj, uint8_t level); // 片选控制
} SPI_Ops;

注意 cs_control 这个接口。我见过有人把片选逻辑写死在SPI驱动里——结果换了个用GPIO模拟片选的芯片,整个驱动都得改。把片选控制暴露出来,让上层决定什么时候拉高拉低,这才是正确的解耦姿势。

核心观点:接口的稳定性,取决于你对「变化」的预判。不是预判未来会加什么功能,而是预判「什么一定不会变」。GPIO一定可以读和写,UART一定可以发和收——这些才是接口的基石。

I2C抽象接口实战

I2C的接口设计,有个容易忽略的点:从机地址长度。有的MCU支持7位地址,有的支持10位。如果你在接口里写死 uint8_t addr,那遇到10位地址的器件就尴尬了。

typedef struct {
    int32_t (*master_write)(void *obj, uint16_t addr, const uint8_t *data, uint32_t len);
    int32_t (*master_read)(void *obj, uint16_t addr, uint8_t *buf, uint32_t len);
    int32_t (*master_write_read)(void *obj, uint16_t addr, 
                                 const uint8_t *wdata, uint32_t wlen,
                                 uint8_t *rdata, uint32_t rlen);
    void    (*set_speed)(void *obj, uint32_t hz);
} I2C_Ops;

用 uint16_t 做地址,7位和10位都能兼容。底层实现时再做地址格式转换——这是典型的「接口统一,实现灵活」。

接口稳定性检查清单

每次定义完接口,我都会拿这张表自检一遍:

检查项 说明
是否所有方法都是「必须」的? 去掉任何一个,这个外设还能用吗?
参数类型是否足够通用? 用 uint16_t 而不是 uint8_t,给未来留余地
是否有硬件细节泄露? 比如寄存器地址、DMA通道号、中断向量号
错误处理是否统一? 所有接口用相同的错误码体系
扩展能力是否独立? 高级特性通过独立接口或配置结构体提供

整体架构图

下面这张图展示了接口隔离原则在MCU驱动中的落地方式:

接口隔离原则:MCU外设抽象层架构 应用层 / 中间件 GPIO_Ops 读/写/方向/上下拉 UART_Ops 发送/接收/波特率 SPI_Ops 传输/模式/速率/片选 I2C_Ops 读写/速率/从机地址 GPIO_IRQ_Ops UART_DMA_Config SPI_DMA_Config I2C_DMA_Config MCU硬件层(STM32 / GD32 / NXP / 国产MCU ...) 核心设计思想: 应用层只依赖抽象接口(绿框),不依赖具体MCU实现。 扩展能力(橙框)按需实现,不污染核心接口。

写在最后

接口隔离原则,说白了就是「少即是多」。你定义接口时加的功能越多,未来被改动的概率就越大。我见过最稳定的接口,往往只有三四个方法——够用,但绝不冗余。

下次你写驱动时,不妨先问自己一个问题:「如果换一颗完全不兼容的MCU,这个接口还能用吗?」 如果答案是「能」,那你的接口就稳了。