模块化设计原则:高内聚低耦合、接口隔离、依赖倒置、单一职责在嵌入式中的应用

嵌入式开发做到一定规模,你就会发现——代码好不好改,跟当初怎么切模块关系太大了。我见过太多项目,前期图快,所有功能揉在一个文件里,到后面改一个 bug 能引出三个新 bug。说白了,模块化设计不是花架子,它是你后期能不能睡安稳觉的关键。

这一章,我们聊聊四个最核心的设计原则。它们不是理论空谈,是我在多个嵌入式项目里反复验证过的实战经验。

4.1 高内聚与低耦合:模块划分的黄金标准

先问个问题:你写了一个驱动,里面既管硬件寄存器,又做协议解析,还顺带处理日志——这算一个模块吗?算,但这是个坏模块。

高内聚,就是一个模块只做一类事。比如 I2C 驱动只管收发字节,别在里面掺和传感器数据校准。低耦合,就是模块之间依赖尽量少,改一个模块不影响另一个。

我的经验法则: 如果你改了一个 .c 文件,需要同时改三个其他模块的代码,说明耦合太高了。

我在一个车载项目中遇到过这种情况:LED 指示灯模块直接调用了 CAN 通信模块的内部结构体。后来 CAN 协议升级,结构体变了,LED 模块也得跟着改。明明只是换个灯的颜色,结果牵扯到通信层——这就是耦合的代价。

怎么判断内聚和耦合好不好?看这张图:

高内聚低耦合 vs 低内聚高耦合 ✅ 高内聚 + 低耦合 传感器驱动 协议解析 日志模块 应用逻辑 模块间仅通过接口通信 修改一个模块不影响其他 ❌ 低内聚 + 高耦合 大杂烩模块(驱动+协议+日志) 直接访问内部数据 全局变量满天飞 改一处牵动全身 调试时不知道问题出在哪

4.2 接口隔离原则:别让调用方看到不该看的东西

接口隔离原则,说白了就是:你提供什么功能,调用方就只能看到什么功能。别把一堆内部函数全暴露出去。

举个例子。你写了一个 EEPROM 驱动,内部有 eeprom_write_page()、eeprom_erase_sector()、eeprom_wait_busy() 这些函数。但上层应用只需要 eeprom_read() 和 eeprom_write()。那你就只暴露这两个,其他的用 static 藏起来。

小技巧: 头文件里只放调用方需要的东西。内部结构体、内部函数声明,统统别放进去。我见过一个头文件有 200 行,实际外部只用 3 个函数——剩下的全是噪音。

我曾经接手过一个项目,一个头文件里暴露了 40 多个函数。我花了三天才搞清楚哪些是外部接口,哪些是内部函数。后来我重构时,把所有内部函数都加上了 static,头文件从 40 个函数缩减到 5 个。调用方说:终于知道该调哪个了。

4.3 依赖倒置原则:让高层模块不依赖底层细节

依赖倒置听起来玄乎,其实核心就一句话:高层模块不应该依赖底层模块,两者都应该依赖抽象接口。

在嵌入式里,最常见的场景就是:应用层直接调用了某个具体硬件的驱动函数。比如:

// 坏例子:应用层直接依赖具体硬件
void temperature_report(void) {
    uint8_t data = ds18b20_read_temperature();  // 直接调 DS18B20 驱动
    // ... 处理数据
}

如果哪天你要把 DS18B20 换成 SHT30,你得改应用层代码。这就是依赖了具体实现。

好的做法是定义一个抽象的温度传感器接口:

// 好例子:依赖抽象接口
typedef struct {
    int16_t (*read_temperature)(void);
    int8_t  (*init)(void);
} temp_sensor_t;

// 应用层只依赖这个接口
void temperature_report(temp_sensor_t *sensor) {
    int16_t temp = sensor->read_temperature();
    // ... 处理数据
}

换传感器时,只需要换一个接口实现,应用层代码不用动。

注意: 嵌入式里用函数指针做抽象,会有一点性能开销(多一次间接跳转)。在中断服务函数或高频调用路径上要谨慎使用。我的做法是:低频路径用抽象接口,高频路径用宏或内联函数。

4.4 单一职责原则:一个函数只做一件事

这个原则最容易被忽视。你想想看,你写的函数是不是经常又做校验、又做转换、又做存储、又做日志?

单一职责要求:一个函数只负责一个功能点。如果一个函数有多个「修改理由」,就该拆。

举个例子:

// 坏例子:一个函数干了三件事
void process_sensor_data(uint8_t *raw, uint16_t len) {
    // 1. 校验数据
    if (crc_check(raw, len) != CRC_OK) return;
    // 2. 转换数据格式
    float value = convert_to_physical(raw);
    // 3. 存储并上报
    save_to_flash(value);
    send_to_can_bus(value);
}

这个函数有三个修改理由:校验算法变了要改它,转换公式变了要改它,存储方式变了也要改它。拆开之后:

// 好例子:每个函数只做一件事
static bool validate_sensor_data(uint8_t *raw, uint16_t len);
static float convert_sensor_data(uint8_t *raw);
static void store_and_report(float value);

void process_sensor_data(uint8_t *raw, uint16_t len) {
    if (!validate_sensor_data(raw, len)) return;
    float value = convert_sensor_data(raw);
    store_and_report(value);
}

拆开之后,每个函数都可以独立测试、独立修改。我在一个工业控制项目里,把原来一个 300 行的函数拆成 8 个小函数,每个不超过 40 行。后来定位 bug 的时间缩短了至少一半。

4.5 四个原则的协同关系

这四个原则不是孤立的。它们互相配合,才能让代码真正「好改」。

原则 核心问题 嵌入式典型场景 我踩过的坑
高内聚低耦合 模块怎么切 驱动层、协议层、应用层分离 LED 模块直接调 CAN 结构体
接口隔离 暴露什么 头文件只放外部接口 头文件 40 个函数,实际只用 5 个
依赖倒置 依赖谁 应用层依赖抽象接口,不依赖具体硬件 换传感器要改应用层代码
单一职责 函数做什么 校验、转换、存储分开 300 行函数改一个功能要通读全函数
我的建议: 写代码前先画模块依赖图。如果发现箭头交叉、循环依赖,说明设计有问题。别急着写代码,先理清楚模块边界。

嗯,这四个原则说起来简单,做起来需要刻意练习。我刚开始也经常写出「万能函数」,后来每次 review 代码时都问自己一句:这个函数有多个修改理由吗?这个模块依赖了不该依赖的东西吗?慢慢就养成习惯了。

记住一句话:好的模块化设计,是改一处代码时,不需要打开其他文件。做到这一点,你的嵌入式代码就成功了一半。