模块化设计三原则:高内聚低耦合、接口隔离、依赖倒置
做嵌入式软件架构,说白了就是在跟复杂度打仗。我见过太多项目,一开始图快,所有功能揉在一个文件里,结果后期改一个 bug 能牵出三个新 bug。今天聊的三个原则,是我这些年拆过无数烂代码之后,觉得最值得刻在工位上的东西。
高内聚与低耦合:模块的「内功」与「边界」
先讲高内聚。一个模块只做一件事,并且把这件事做完整。比如一个 LED 驱动模块,它应该只负责控制 LED 的亮灭、闪烁、PWM 调光。你不该在里面塞入按键扫描的逻辑,哪怕你觉得「顺便读一下按键状态很方便」。
我在项目中遇到过最典型的反例:一个 UART 驱动模块里,居然混着温度传感器的校准算法。原因只是「串口收到的数据正好要送给温度算法」。结果换了一个传感器型号,整个驱动都得重写。这就是内聚度太低——模块内部的东西不相关,改一处就得动全身。
再讲低耦合。模块之间的依赖要尽量少,而且要单向。A 模块调用 B 模块,B 模块就不应该反过来依赖 A。耦合度高了,就像两个人背靠背绑在一起走路,谁也别想快。
我建议用「接口数量」和「接口方向」来量化耦合度。一个模块对外暴露的接口函数,最好控制在 5 个以内。超过这个数,你就该想想是不是把太多内部细节暴露给别人了。
| 耦合类型 | 特征 | 修改影响范围 | 我见过的案例 |
|---|---|---|---|
| 内容耦合 | 直接访问对方内部变量 | 全局 | 两个模块共用全局结构体,改一个字段全崩 |
| 公共耦合 | 共享全局数据区 | 较大 | 多个任务读写同一个环形缓冲区,不加锁就死 |
| 控制耦合 | 传递控制标志位 | 中等 | 传一个 mode 参数让对方切换内部状态 |
| 数据耦合 | 只传递必要数据 | 小 | 传一个温度值,不传结构体指针 |
| 消息耦合 | 通过队列/事件通信 | 最小 | 模块间只发消息,不直接调用 |
你看这个表,从上到下耦合度越来越低。我个人习惯,新模块起步至少做到数据耦合,关键模块必须做到消息耦合。别觉得消息队列开销大,比起后期改 bug 的时间,那点 CPU 周期根本不值一提。
接口隔离:别让调用者看到不该看的东西
接口隔离原则,说白了就是「不要强迫调用者依赖它不需要的方法」。我见过一个传感器驱动,对外暴露了 12 个函数,其中 8 个是内部校准用的。调用者只想读个温度,却要包含一整套校准头文件。
为什么会这样?因为设计者把「传感器能做什么」和「调用者需要什么」混为一谈了。接口隔离要求你站在调用者的角度去设计接口——调用者需要什么,你就给什么,不多给。
举个例子,一个 I2C 存储器驱动,调用者只需要读、写、擦除三个操作。但底层有页写入、地址自增、忙等待等细节。接口隔离的做法是:
// eeprom.h —— 调用者看到的
typedef struct EepromDevice EepromDevice;
EepromDevice* eeprom_create(uint8_t i2c_addr);
int eeprom_read(EepromDevice* dev, uint32_t addr, uint8_t* buf, uint32_t len);
int eeprom_write(EepromDevice* dev, uint32_t addr, const uint8_t* buf, uint32_t len);
int eeprom_erase(EepromDevice* dev, uint32_t addr, uint32_t len);
void eeprom_destroy(EepromDevice* dev);
// eeprom_internal.h —— 只在模块内部使用
struct EepromDevice {
uint8_t i2c_addr;
uint8_t page_buf[64];
int (*wait_ready)(EepromDevice*);
};
int _eeprom_page_write(EepromDevice* dev, uint32_t addr, const uint8_t* data);
int _eeprom_wait_busy(EepromDevice* dev);
调用者只看到 5 个函数和一个不透明指针。内部细节完全隔离。换芯片型号时,只要保持这 5 个接口不变,调用者代码一行都不用改。这就是接口隔离的价值。
依赖倒置:让高层模块说了算
依赖倒置原则听起来有点绕,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。
传统做法是:应用层直接调用驱动层。比如一个温度采集任务,直接调用 sensor_read()。如果换了传感器,sensor_read() 的接口变了,应用层就得改。这就是高层依赖了低层。
依赖倒置的做法是:应用层定义它需要的接口(抽象),驱动层去实现这个接口。应用层不关心底层是谁,只关心接口能不能满足需求。
下面这张图展示了依赖倒置前后的结构对比:
你想想看,右侧的结构里,应用层和驱动层之间多了一层抽象接口。应用层只认识这个抽象接口,驱动层去实现它。换驱动时,只要新驱动实现了同样的抽象接口,应用层完全不受影响。
在 C 语言里实现依赖倒置,最常用的手法就是函数指针结构体。我习惯定义一个接口结构体,里面放一组函数指针:
// sensor_if.h —— 抽象接口定义
typedef struct {
int (*init)(void);
int (*read)(uint8_t* data, uint32_t len);
int (*get_status)(uint8_t* status);
} SensorInterface;
// 应用层只依赖这个接口
int temperature_task(SensorInterface* sensor) {
uint8_t buf[4];
if (sensor->init() != 0) return -1;
if (sensor->read(buf, 4) != 0) return -1;
// 处理数据...
return 0;
}
// 具体驱动实现
static int tmp117_init(void) { /* TMP117 初始化代码 */ }
static int tmp117_read(uint8_t* data, uint32_t len) { /* TMP117 读取代码 */ }
static int tmp117_get_status(uint8_t* status) { /* TMP117 状态查询 */ }
const SensorInterface tmp117_driver = {
.init = tmp117_init,
.read = tmp117_read,
.get_status = tmp117_get_status
};
这样,应用层的 temperature_task 根本不关心底层是 TMP117 还是 DS18B20。只要传入不同的 SensorInterface 实例,它就能正常工作。这就是依赖倒置的威力。
嗯,这三个原则说起来简单,做起来需要刻意练习。我刚开始做架构时,也经常写出耦合度高的代码。后来养成一个习惯:每写完一个模块,先问自己三个问题——这个模块只做一件事吗?调用者需要看到这么多东西吗?如果换底层实现,高层需要改吗?三个问题都答「是」,才算合格。