模块化设计原则:高内聚低耦合、接口隔离、依赖倒置、模块划分方法论
模块化设计,说白了就是把一个大系统拆成若干小块。每个小块各司其职,块与块之间只通过约定好的接口打交道。我做了十几年嵌入式,见过太多「一坨代码」式的项目——一个文件几千行,全局变量满天飞。改一个 bug 能引出三个新 bug。嗯,这种痛苦,经历过的人都懂。
今天我们就来聊聊模块化设计的四个核心原则。这些原则不是理论空谈,是我在多个项目里踩坑踩出来的经验。
高内聚与低耦合
这两个词几乎是模块化的代名词。先说说我的理解。
高内聚,就是一个模块内部的东西应该高度相关。比如一个 UART 驱动模块,它只负责收发数据、配置波特率、处理中断。你不该把 LED 闪烁的逻辑也塞进去。
低耦合,就是模块之间的依赖要尽可能少。A 模块调用 B 模块,最好只通过一个函数接口,而不是直接访问 B 的内部变量。
内聚与耦合的对比
| 维度 | 高内聚 | 低耦合 |
|---|---|---|
| 关注点 | 模块内部 | 模块之间 |
| 目标 | 功能单一、职责清晰 | 依赖最少、改动影响小 |
| 反面 | 一个模块做太多事 | 模块间互相牵制 |
| 我见过的典型问题 | 一个 .c 文件包含驱动、协议、业务逻辑 | 全局变量被 10 个模块读写 |
我在项目中遇到过这样一个案例:一个温控系统,温度采集、PID 计算、加热控制全部写在一个文件里。后来要换温度传感器,改了一个函数,结果加热逻辑也跟着出问题。这就是典型的内聚不够、耦合太高。
接口隔离原则
这个原则的意思是:客户端不应该依赖它不需要的接口。你想想看,如果一个驱动接口提供了 10 个函数,但你的业务层只用其中 2 个,那剩下的 8 个就是隐患。
我习惯把接口设计得尽量小。比如一个 SPI Flash 驱动,我只暴露 spi_flash_read()、spi_flash_write()、spi_flash_erase() 三个函数。底层怎么操作寄存器、怎么处理时序,统统不对外暴露。
/* 接口隔离的典型做法 */
/* flash_driver.h - 只暴露业务需要的接口 */
int32_t spi_flash_read(uint32_t addr, uint8_t *buf, uint32_t len);
int32_t spi_flash_write(uint32_t addr, const uint8_t *buf, uint32_t len);
int32_t spi_flash_erase(uint32_t addr, uint32_t len);
/* flash_driver.c - 内部实现细节对外不可见 */
static void spi_select_chip(void) { /* ... */ }
static void spi_deselect_chip(void) { /* ... */ }
static int32_t spi_wait_busy(void) { /* ... */ }
我的经验:接口函数数量控制在 5 个以内最好。超过 10 个,说明这个模块可能拆得不够细。
依赖倒置原则
这个原则听起来有点绕,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象。
举个例子。你的业务层需要存储数据,传统做法是直接调用某个 Flash 驱动的函数。但如果你将来想换成 EEPROM 或者 SD 卡呢?代码就得大改。
正确的做法是定义一个抽象接口,比如 storage_read() 和 storage_write()。业务层只调用这两个抽象函数。底层具体用什么存储介质,由驱动层去实现这个接口。
/* 依赖倒置的典型实现 */
/* storage_interface.h - 抽象接口 */
typedef struct {
int32_t (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
int32_t (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
} storage_driver_t;
/* 业务层只依赖这个抽象接口,不关心底层实现 */
void save_config(const storage_driver_t *drv, config_t *cfg) {
drv->write(CONFIG_ADDR, (uint8_t *)cfg, sizeof(config_t));
}
我曾经在一个项目里吃过亏。当时直接调用了某个 Flash 芯片的驱动函数,后来芯片停产,换了另一家。结果业务层代码改了 30 多处。从那以后,我再也不敢让业务层直接依赖具体硬件了。
模块划分方法论
原则讲完了,怎么落地?我总结了一套模块划分的方法,分三步走。
- 按功能域划分:先看系统有哪些功能。驱动层、协议层、业务层、应用层,这是最粗的划分。
- 按变化频率划分:容易变化的部分单独成模块。硬件驱动、通信协议这些经常变的,一定要和业务逻辑解耦。
- 按复用潜力划分:可能被多个项目复用的代码,独立成模块。比如 CRC 校验、环形缓冲区、状态机框架。
避坑指南:我曾经把一个模块拆得太细,一个功能拆成 5 个文件,结果接口调用链太长,性能反而下降了。模块划分不是越细越好,适度才是关键。
下面这张图是我常用的模块划分结构,你可以参考一下。
你看这个结构,从上到下是单向依赖。应用层只调业务层,业务层只调接口层,接口层再调驱动层。每一层都可以独立替换。这就是模块化的核心价值——可维护、可测试、可复用。
一个小技巧:写代码前先画模块依赖图。如果发现箭头有环,说明设计有问题。我每次做架构评审,第一件事就是看依赖图有没有环。
最后说一句,模块化不是一蹴而就的。你可以在重构中逐步推进。先把最乱的部分拆出来,定义好接口,然后慢慢优化。别想着一步到位,那反而容易翻车。