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) 这样的抽象接口,底层怎么映射是驱动的事。
几个具体的避坑建议:
- 头文件里只放接口声明,不放实现细节——结构体定义用前向声明,具体实现在.c文件里
- 错误码要统一——驱动层、中间件层、应用层各有一套错误码,层与层之间要映射,不要透传
- 接口参数用抽象类型——比如用
handle_t代替具体的结构体指针 - 写接口文档时,明确说明前置条件和后置条件——这样后续实现者才知道哪些行为不能变
我的个人习惯:每次写完接口,我会问自己三个问题:
- 调用者需要知道底层实现吗?——如果答案是“是”,就违反了最小知识原则
- 换一个实现版本,调用者代码需要改吗?——如果答案是“是”,就违反了里氏替换原则
- 两个问题的答案都是“否”?——恭喜,这个接口是稳定的
嗯,接口设计哲学这部分就聊到这儿。记住,好的接口不是功能最全的,而是最稳定的。最小知识原则帮你管住接口的“嘴”,里氏替换原则帮你管住接口的“行为”——两者缺一不可。