接口设计基础:API与SPI的区别,接口的稳定性与演进策略

说到接口设计,很多人第一反应就是「定义几个函数嘛」。其实没那么简单。我在嵌入式领域摸爬滚打十几年,见过太多因为接口定义不清导致的项目灾难。今天咱们就聊聊接口设计最核心的两个概念:API 和 SPI,以及怎么让接口在长期维护中保持稳定。

API 与 SPI:一个面向调用者,一个面向实现者

先问个问题:你写的接口,到底是给别人用的,还是让别人来扩展的?

这两个场景完全不同。API(Application Programming Interface)是「我提供功能,你来调用」。SPI(Service Provider Interface)是「我定义框架,你来实现」。说白了,API 是消费者视角,SPI 是提供者视角。

维度 API SPI
调用方向 你调用我 我调用你
控制权 接口提供方 接口实现方
典型场景 驱动库、协议栈 设备抽象层、插件框架
变更影响 影响所有调用者 影响所有实现者
版本策略 向后兼容优先 允许扩展,慎用删除

举个例子。你写了一个 I2C 驱动库,提供 i2c_read()、i2c_write() 函数,这就是 API。上层应用直接调用,不用关心底层怎么操作寄存器。

但如果你定义一个 i2c_bus_ops 结构体,里面放了一堆函数指针,让不同的硬件平台去实现——这就是 SPI。框架层调用这些指针完成数据传输,具体怎么实现,由平台决定。

关键区别一句话:API 是你调框架,SPI 是框架调你。

接口稳定性:为什么改个参数名都能炸?

我遇到过最惨的一次教训:一个同事觉得函数参数名不好看,把 len 改成了 length。结果呢?整个团队花了三天排查一个诡异的编译错误。为什么?因为有人用宏定义做了参数名匹配。

接口稳定性不是玄学,是工程纪律。咱们从三个层面来看:

1. 语法层面的稳定性

  • 函数签名不能改:参数个数、类型、返回值,改了就是新接口
  • 宏定义和枚举值不能变:数值变了,所有条件判断都跟着崩
  • 结构体布局不能动:成员顺序、对齐方式,动了就是 ABI 不兼容

2. 语义层面的稳定性

  • 行为契约不能变:比如 open() 返回 -1 表示失败,你不能改成返回 0
  • 错误码含义不能变:EIO 就是 IO 错误,别拿来表示「设备忙」
  • 时序约束不能变:调用顺序、超时时间,变了就是挖坑

3. 资源管理层面的稳定性

  • 谁分配谁释放:这个规则一旦定下来,就别改
  • 句柄的生命周期:别让调用者猜「这个句柄还能不能用」
  • 线程安全边界:接口是线程安全的还是非线程安全的,必须说清楚

我曾经踩过的坑:一个 SPI 接口的初始化函数,原本要求调用者先关中断再调用。后来我改成内部自动关中断,结果老代码里双重关中断导致中断永远打不开。嗯,那次排查花了两天。

接口演进策略:怎么改才不挨骂?

接口不可能一成不变。需求在变,硬件在变,协议在变。关键是——怎么变才能让老用户不骂娘?

策略一:版本号 + 兼容层

这是最常用的方法。接口版本号用宏定义暴露出来:

/* 接口版本定义 */
#define MYLIB_VERSION_MAJOR 2
#define MYLIB_VERSION_MINOR 1
#define MYLIB_VERSION_PATCH 0

/* 兼容旧版本 */
#if MYLIB_VERSION_MAJOR == 1
  /* 提供 v1 到 v2 的适配层 */
  #include "mylib_v1_compat.h"
#endif

我个人习惯在头文件里用 #if 做版本判断,而不是在运行时判断。编译期就能发现问题,比运行时崩溃强多了。

策略二:扩展点预留

设计接口时,提前想好未来可能怎么变。常见的做法:

  • 结构体末尾留保留字段:uint32_t reserved[4];
  • 函数参数用结构体传:而不是一个个参数列出来
  • 回调函数加 context 指针:方便将来传额外数据
/* 不好的设计:参数列表太长,改一个全崩 */
int device_config(int id, int baud, int parity, int stop_bits);

/* 好的设计:用结构体,扩展时加字段就行 */
typedef struct {
    int id;
    int baud;
    int parity;
    int stop_bits;
    uint32_t flags;        /* 预留扩展位 */
    void *reserved[2];     /* 预留指针 */
} device_config_t;

int device_config(const device_config_t *cfg);

策略三:弃用标记 + 过渡期

别直接删接口。先标记为 deprecated,给用户一个过渡期:

/* 旧接口:标记为弃用 */
__attribute__((deprecated("use device_config() instead")))
int device_set_baud(int id, int baud);

编译时会有警告,但代码还能跑。等下一个大版本再彻底移除。我一般给两个版本的过渡期,够用户迁移了。

接口演进的核心逻辑:一张图说清楚

下面这张图展示了接口从定义到废弃的完整生命周期。你想想看,每个阶段该做什么,心里就有数了。

接口演进生命周期 1. 接口定义 API / SPI 区分 2. 接口发布 版本号 + 文档 3. 接口演进 扩展 + 兼容 4. 弃用标记 deprecated 警告 5. 接口移除 大版本更新 用户反馈问题?回退 💡 演进原则: • 新增接口用新函数名,不要重载旧函数 • 弃用标记至少保留两个小版本 • 移除接口必须在大版本号变更时进行

实战建议:接口设计的三条铁律

说了这么多,最后给三条我在项目中一直遵守的铁律:

  1. 接口定义时就想好怎么变——预留扩展字段、用结构体传参、加版本宏。别等到要改的时候才后悔。
  2. 改接口必须改版本号——哪怕只是加个注释,也建议打补丁版本。我见过太多「我就改一点点」然后全盘崩溃的案例。
  3. 文档比代码更重要——接口的行为契约、时序要求、资源管理规则,必须写清楚。代码只能告诉你怎么用,文档才能告诉你怎么不出错。

一个小技巧:每次发布新版本接口时,写一个 UPGRADING.md 文件,记录从旧版本迁移到新版本的步骤。这个文件比任何代码注释都管用。我自己项目里,这个文件救了无数人的命。

接口设计不是一锤子买卖。它是个持续演进的过程。你设计得越好,后面维护的人就越轻松。嗯,说白了,这就是在给自己积德。