模块化设计原则:高内聚低耦合、接口隔离、依赖倒置、模块划分方法论

模块化设计,说白了就是把一个大系统拆成若干小块。每个小块各司其职,块与块之间只通过约定好的接口打交道。我做了十几年嵌入式,见过太多「一坨代码」式的项目——一个文件几千行,全局变量满天飞。改一个 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 多处。从那以后,我再也不敢让业务层直接依赖具体硬件了。

模块划分方法论

原则讲完了,怎么落地?我总结了一套模块划分的方法,分三步走。

  1. 按功能域划分:先看系统有哪些功能。驱动层、协议层、业务层、应用层,这是最粗的划分。
  2. 按变化频率划分:容易变化的部分单独成模块。硬件驱动、通信协议这些经常变的,一定要和业务逻辑解耦。
  3. 按复用潜力划分:可能被多个项目复用的代码,独立成模块。比如 CRC 校验、环形缓冲区、状态机框架。

避坑指南:我曾经把一个模块拆得太细,一个功能拆成 5 个文件,结果接口调用链太长,性能反而下降了。模块划分不是越细越好,适度才是关键。

下面这张图是我常用的模块划分结构,你可以参考一下。

嵌入式软件模块划分结构图 应用层 (Application) 业务逻辑层 (Business Logic) 抽象接口层 (Interface) 驱动层 (Driver) 硬件层 (Hardware) 模块划分要点 1. 每层只依赖下一层 2. 驱动层可替换 3. 接口层隔离变化 4. 业务层不碰硬件 5. 应用层只调业务 --- 常见错误 --- ❌ 业务层直接调驱动 ❌ 驱动层包含业务逻辑 ❌ 接口层过于庞大 ❌ 模块间循环依赖 --- 我的建议 --- 每层不超过 5 个模块 接口函数不超过 10 个 模块间单向依赖

你看这个结构,从上到下是单向依赖。应用层只调业务层,业务层只调接口层,接口层再调驱动层。每一层都可以独立替换。这就是模块化的核心价值——可维护、可测试、可复用。

一个小技巧:写代码前先画模块依赖图。如果发现箭头有环,说明设计有问题。我每次做架构评审,第一件事就是看依赖图有没有环。

最后说一句,模块化不是一蹴而就的。你可以在重构中逐步推进。先把最乱的部分拆出来,定义好接口,然后慢慢优化。别想着一步到位,那反而容易翻车。