模块化设计三原则:高内聚低耦合、接口隔离、依赖倒置

做嵌入式软件架构,说白了就是在跟复杂度打仗。我见过太多项目,一开始图快,所有功能揉在一个文件里,结果后期改一个 bug 能牵出三个新 bug。今天聊的三个原则,是我这些年拆过无数烂代码之后,觉得最值得刻在工位上的东西。

高内聚与低耦合:模块的「内功」与「边界」

先讲高内聚。一个模块只做一件事,并且把这件事做完整。比如一个 LED 驱动模块,它应该只负责控制 LED 的亮灭、闪烁、PWM 调光。你不该在里面塞入按键扫描的逻辑,哪怕你觉得「顺便读一下按键状态很方便」。

我在项目中遇到过最典型的反例:一个 UART 驱动模块里,居然混着温度传感器的校准算法。原因只是「串口收到的数据正好要送给温度算法」。结果换了一个传感器型号,整个驱动都得重写。这就是内聚度太低——模块内部的东西不相关,改一处就得动全身。

高内聚的判断标准:如果你要给这个模块起名字,名字里的名词能不能准确描述它所有的功能?能,就是高内聚;不能,就该拆。

再讲低耦合。模块之间的依赖要尽量少,而且要单向。A 模块调用 B 模块,B 模块就不应该反过来依赖 A。耦合度高了,就像两个人背靠背绑在一起走路,谁也别想快。

我建议用「接口数量」和「接口方向」来量化耦合度。一个模块对外暴露的接口函数,最好控制在 5 个以内。超过这个数,你就该想想是不是把太多内部细节暴露给别人了。

耦合类型 特征 修改影响范围 我见过的案例
内容耦合 直接访问对方内部变量 全局 两个模块共用全局结构体,改一个字段全崩
公共耦合 共享全局数据区 较大 多个任务读写同一个环形缓冲区,不加锁就死
控制耦合 传递控制标志位 中等 传一个 mode 参数让对方切换内部状态
数据耦合 只传递必要数据 小 传一个温度值,不传结构体指针
消息耦合 通过队列/事件通信 最小 模块间只发消息,不直接调用

你看这个表,从上到下耦合度越来越低。我个人习惯,新模块起步至少做到数据耦合,关键模块必须做到消息耦合。别觉得消息队列开销大,比起后期改 bug 的时间,那点 CPU 周期根本不值一提。

接口隔离:别让调用者看到不该看的东西

接口隔离原则,说白了就是「不要强迫调用者依赖它不需要的方法」。我见过一个传感器驱动,对外暴露了 12 个函数,其中 8 个是内部校准用的。调用者只想读个温度,却要包含一整套校准头文件。

为什么会这样?因为设计者把「传感器能做什么」和「调用者需要什么」混为一谈了。接口隔离要求你站在调用者的角度去设计接口——调用者需要什么,你就给什么,不多给。

我的做法:每个模块只暴露一个头文件,头文件里只放调用者需要的函数声明和类型定义。内部函数全部用 static 隐藏,内部结构体用不完整类型(前向声明)隐藏。调用者看不到,就不会依赖。

举个例子,一个 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() 的接口变了,应用层就得改。这就是高层依赖了低层。

依赖倒置的做法是:应用层定义它需要的接口(抽象),驱动层去实现这个接口。应用层不关心底层是谁,只关心接口能不能满足需求。

我曾经踩过的坑:在一个物联网网关项目里,我把 MQTT 协议栈直接嵌在业务逻辑里。后来要换成 CoAP 协议,业务逻辑几乎重写。如果当时在业务逻辑和协议栈之间加一层抽象接口,换协议就只是换一个实现文件的事。

下面这张图展示了依赖倒置前后的结构对比:

依赖倒置原则:结构对比 传统结构(高层依赖低层) 应用层(高层) 具体驱动(低层) 应用层直接调用驱动函数 换驱动必须改应用层代码 依赖倒置结构(都依赖抽象) 应用层(高层) 抽象接口层 具体驱动实现 应用层依赖抽象接口 驱动实现抽象接口 换驱动只需换实现,应用层不变

你想想看,右侧的结构里,应用层和驱动层之间多了一层抽象接口。应用层只认识这个抽象接口,驱动层去实现它。换驱动时,只要新驱动实现了同样的抽象接口,应用层完全不受影响。

在 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 实例,它就能正常工作。这就是依赖倒置的威力。

三个原则的关系:高内聚让你把模块内部理清楚,低耦合让你把模块之间的边界划清楚,接口隔离和依赖倒置让你把模块之间的契约定清楚。三者缺一不可,共同构成模块化设计的骨架。

嗯,这三个原则说起来简单,做起来需要刻意练习。我刚开始做架构时,也经常写出耦合度高的代码。后来养成一个习惯:每写完一个模块,先问自己三个问题——这个模块只做一件事吗?调用者需要看到这么多东西吗?如果换底层实现,高层需要改吗?三个问题都答「是」,才算合格。