接口设计基础: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);
编译时会有警告,但代码还能跑。等下一个大版本再彻底移除。我一般给两个版本的过渡期,够用户迁移了。
接口演进的核心逻辑:一张图说清楚
下面这张图展示了接口从定义到废弃的完整生命周期。你想想看,每个阶段该做什么,心里就有数了。
实战建议:接口设计的三条铁律
说了这么多,最后给三条我在项目中一直遵守的铁律:
- 接口定义时就想好怎么变——预留扩展字段、用结构体传参、加版本宏。别等到要改的时候才后悔。
- 改接口必须改版本号——哪怕只是加个注释,也建议打补丁版本。我见过太多「我就改一点点」然后全盘崩溃的案例。
- 文档比代码更重要——接口的行为契约、时序要求、资源管理规则,必须写清楚。代码只能告诉你怎么用,文档才能告诉你怎么不出错。
一个小技巧:每次发布新版本接口时,写一个 UPGRADING.md 文件,记录从旧版本迁移到新版本的步骤。这个文件比任何代码注释都管用。我自己项目里,这个文件救了无数人的命。
接口设计不是一锤子买卖。它是个持续演进的过程。你设计得越好,后面维护的人就越轻松。嗯,说白了,这就是在给自己积德。