2. 接口设计哲学:接口的稳定性原则——最小知识原则与里氏替换原则在嵌入式中的应用

做嵌入式接口设计这么多年,我越来越觉得,真正考验功力的不是把功能做出来,而是让接口在三年后、五个人接手后依然稳定。今天咱们就聊聊两个最基础也最重要的原则:最小知识原则和里氏替换原则。

2.1 最小知识原则:别让你的接口“话太多”

最小知识原则,英文叫 Least Knowledge Principle,也叫迪米特法则。说白了就是:一个模块只应该和它最直接的朋友通信。你想想看,如果驱动层直接访问了中间件层的内部结构,那中间件一改,驱动就得跟着改——这种耦合谁受得了?

核心思想:每个单元只应了解与自己直接相关的其他单元,不要窥探别人的内部细节。

2.1.1 嵌入式场景下的“话多”陷阱

我在项目中遇到过这样一个案例:一个传感器驱动,为了“方便”上层使用,直接把I2C总线的寄存器地址暴露给了中间件层。结果呢?后来换了另一款传感器,I2C地址不同,中间件层所有调用这个地址的地方全得改。这就是典型的“话太多”——驱动层不该让中间件知道它用的是I2C还是SPI。

正确的做法是什么?驱动层只暴露抽象的操作接口,比如:

/* 错误示范:暴露了实现细节 */
int32_t sensor_read_register(uint8_t i2c_addr, uint8_t reg);

/* 正确示范:只暴露抽象操作 */
int32_t sensor_read_temperature(float *temp);
int32_t sensor_read_humidity(float *humidity);

你看,上层调用者根本不需要知道底层是I2C、SPI还是UART。这就是最小知识原则的精髓——接口只告诉调用者“能做什么”,不告诉“怎么做的”。

2.1.2 接口设计的“三不”原则

我个人习惯用“三不”来检查接口是否违反了最小知识原则:

  • 不暴露内部数据结构:别把结构体定义直接扔到头文件里,用不透明指针(opaque pointer)
  • 不传递内部状态码:驱动内部的错误码不要直接透传,要映射成统一的错误类型
  • 不要求调用者了解实现顺序:接口调用顺序应该自然,不需要调用者知道“必须先初始化A才能调用B”

小技巧:写接口时,把自己当成调用者。如果调用者需要翻看你的源码才能正确使用接口,那这个接口就“话太多”了。

2.2 里氏替换原则:接口的“契约精神”

里氏替换原则,Liskov Substitution Principle,名字听着挺学术,其实道理很简单:子类应该能够替换父类,而不影响程序的正确性。在嵌入式接口设计中,我把它理解为:接口的实现版本应该可以互相替换,调用者不需要知道具体用的是哪个版本。

2.2.1 嵌入式里的“替换”场景

嵌入式系统里,替换场景太常见了:

  • 同一款芯片的不同批次,寄存器地址可能微调
  • 同一个功能,不同厂商的芯片实现不同
  • 硬件版本升级,驱动需要兼容旧版

我曾经接手过一个项目,原来的驱动直接操作寄存器地址,后来换了新芯片,寄存器偏移量变了。结果呢?所有调用点都得改,改了一个星期,还引入了一堆bug。这就是没有遵循里氏替换原则的代价。

2.2.2 如何设计可替换的接口

里氏替换原则要求接口满足几个条件:

条件 说明 嵌入式示例
前置条件不能强化 子类不能要求比父类更多的输入条件 父类接受NULL指针,子类也必须接受
后置条件不能弱化 子类必须保证父类承诺的输出结果 父类保证返回非负值,子类不能返回负数
异常类型不能扩大 子类不能抛出父类未定义的错误 父类返回-1表示超时,子类不能返回-2表示新错误

看一个实际例子:

/* 父类接口:通用定时器驱动 */
typedef struct {
    int32_t (*init)(uint32_t freq_hz);
    int32_t (*start)(void);
    int32_t (*stop)(void);
} timer_drv_t;

/* 子类实现1:硬件定时器 */
int32_t hw_timer_init(uint32_t freq_hz) {
    /* 配置硬件寄存器 */
    if (freq_hz > 1000000) return -1;  /* 硬件限制 */
    return 0;
}

/* 子类实现2:软件定时器(基于SysTick) */
int32_t sw_timer_init(uint32_t freq_hz) {
    /* 软件模拟,无频率限制 */
    return 0;
}

这里有个问题:hw_timer_init 在频率超过1MHz时返回-1,而 sw_timer_init 没有这个限制。如果调用者原来用的是软件定时器,换成硬件定时器后,同样的调用可能失败——这就违反了里氏替换原则。

注意:里氏替换原则不是要求所有实现功能完全一样,而是要求调用者在不了解具体实现的情况下,也能正确使用接口。如果某个实现有特殊限制,应该在接口文档中明确说明,而不是在返回值里“悄悄”增加新的错误码。

2.3 两个原则的协同:接口稳定的“双保险”

最小知识原则和里氏替换原则,一个管“接口该知道什么”,一个管“接口该怎么替换”。两者结合,才能保证接口的长期稳定。

我画了一张图,帮你理解它们的关系:

接口稳定性双原则协同框架 最小知识原则 控制接口的“信息边界” 只暴露抽象操作,隐藏实现细节 不传递内部状态,不暴露数据结构 里氏替换原则 保证接口的“行为契约” 前置条件不强化,后置条件不弱化 异常类型不扩大,行为可预测 协同 共同目标:接口稳定性 降低耦合,提高可维护性,支持长期演进 实践建议 先按最小知识原则设计接口边界,再用里氏替换原则验证接口的可替换性 两者都满足的接口,才是真正稳定的接口

2.4 实战中的“避坑”指南

说了这么多理论,来点实际的。我在项目中总结了几条经验:

我曾经踩过的坑:一个UART驱动,为了“灵活”,允许上层直接设置波特率寄存器值。结果换了芯片后,波特率寄存器的映射方式变了,所有上层代码都得改。后来我改成只暴露 uart_set_baudrate(115200) 这样的抽象接口,底层怎么映射是驱动的事。

几个具体的避坑建议:

  1. 头文件里只放接口声明,不放实现细节——结构体定义用前向声明,具体实现在.c文件里
  2. 错误码要统一——驱动层、中间件层、应用层各有一套错误码,层与层之间要映射,不要透传
  3. 接口参数用抽象类型——比如用 handle_t 代替具体的结构体指针
  4. 写接口文档时,明确说明前置条件和后置条件——这样后续实现者才知道哪些行为不能变

我的个人习惯:每次写完接口,我会问自己三个问题:

  • 调用者需要知道底层实现吗?——如果答案是“是”,就违反了最小知识原则
  • 换一个实现版本,调用者代码需要改吗?——如果答案是“是”,就违反了里氏替换原则
  • 两个问题的答案都是“否”?——恭喜,这个接口是稳定的

嗯,接口设计哲学这部分就聊到这儿。记住,好的接口不是功能最全的,而是最稳定的。最小知识原则帮你管住接口的“嘴”,里氏替换原则帮你管住接口的“行为”——两者缺一不可。