模块化设计原则:高内聚低耦合的实战理解
模块化设计,说白了就是「拆」。但怎么拆,拆多大,边界划在哪——这才是真功夫。我见过太多项目,一上来就画模块图,画得漂漂亮亮,结果联调时发现 A 模块要等 B 模块的某个内部变量,C 模块又直接调了 D 模块的全局数组……嗯,这种架构,基本就是等着重构。
今天咱们就聊聊模块划分的两个核心原则:高内聚和低耦合。我会结合嵌入式实战场景,把粒度与边界的问题讲透。
什么是高内聚?
高内聚,就是一个模块内部的东西,应该紧密相关。说白了,模块里的函数、变量、数据结构,都是为了完成同一个职责。
举个例子。我做过一个温控器项目,有人把温度采集、PID 计算、LCD 显示、按键扫描全塞进一个文件里。结果呢?改个显示刷新率,要重新编译整个模块;换个温度传感器型号,得翻遍 2000 行代码找哪里调了 ADC 寄存器。这就是典型的内聚度低。
我个人习惯,一个模块只做一件事。比如:
- 温度采集模块:只负责从传感器读原始值,做滤波,返回摄氏度。
- PID 控制模块:只接收目标温度和当前温度,输出控制量。
- 显示模块:只负责把数据刷到 LCD 上。
这样每个模块内部,函数之间关系紧密,改一个不影响另一个。这就是高内聚。
内聚度分级(从低到高)
| 级别 | 描述 | 嵌入式例子 |
|---|---|---|
| 偶然内聚 | 无关功能凑一起 | 一个文件里放 GPIO 初始化 + 串口打印 + 延时函数 |
| 逻辑内聚 | 同类操作放一起 | 一个模块处理所有外设的初始化 |
| 时间内聚 | 同时执行的操作 | 系统初始化模块 |
| 过程内聚 | 按流程顺序组合 | 数据采集→滤波→存储 三步写在一个模块 |
| 通信内聚 | 操作同一数据 | 一个模块管理所有传感器数据 |
| 顺序内聚 | 输出作为输入 | 编码器读取→速度计算→位置更新 |
| 功能内聚 | 一个模块一个功能 | PID 模块只做 PID 计算 |
目标就是做到功能内聚。我在项目中遇到过,把功能内聚做到极致后,单元测试变得特别轻松——每个模块单独拿出来就能测,不需要 mock 一堆东西。
什么是低耦合?
低耦合,就是模块之间的依赖要尽量少。你想想看,如果 A 模块改了,B、C、D 模块都得跟着改,那这架构就是一团乱麻。
耦合的方式有很多种,我挑几个嵌入式里最常见的说:
- 内容耦合:一个模块直接访问另一个模块的内部数据。比如模块 A 直接读写模块 B 的全局变量。这是最糟糕的,我建议绝对禁止。
- 公共耦合:多个模块共享同一个全局变量。比如一个全局的
system_status,到处都在改。调试起来你会疯的。 - 控制耦合:一个模块通过参数控制另一个模块的内部逻辑。比如传一个
mode枚举进去,让被调函数走不同分支。这种要少用。 - 数据耦合:只通过参数传递数据,不传递控制信息。这是最理想的。
我的经验法则:模块之间只通过接口函数通信,不共享全局变量。接口函数的参数只传数据,不传控制标志。如果必须传控制信息,用回调函数或者状态机,别用 if-else 分支。
模块划分的粒度
粒度问题,说白了就是「一个模块该多大」。太小了,模块满天飞,接口爆炸;太大了,又回到大泥球。
我一般按这个思路来:
- 按硬件边界切:一个外设一个模块。比如 UART 驱动、SPI 驱动、I2C 驱动。这是最自然的切法。
- 按功能逻辑切:一个业务功能一个模块。比如温度控制、数据记录、报警管理。
- 按变化频率切:把容易变化的部分和稳定的部分分开。比如硬件抽象层(HAL)和业务逻辑层分开,换 MCU 时只改 HAL。
举个例子,我之前做的一个智能家居网关项目,模块划分是这样的:
| 模块名 | 职责 | 粒度说明 |
|---|---|---|
| wifi_drv | WiFi 芯片驱动 | 硬件边界,一个芯片一个模块 |
| mqtt_client | MQTT 协议栈 | 功能逻辑,独立于硬件 |
| sensor_mgr | 传感器数据管理 | 功能逻辑,统一管理所有传感器 |
| alarm_engine | 报警规则引擎 | 变化频率高,独立封装 |
| hal_timer | 定时器硬件抽象 | 变化频率高,方便移植 |
你看,每个模块的粒度都控制在「一个人一周能搞定」的范围。太大了,一个人搞不定;太小了,接口太多,管理成本高。
避坑指南:我曾经把一个 SPI Flash 驱动拆成了「读模块」「写模块」「擦除模块」「状态查询模块」。结果呢?读之前要查状态,写之前要擦除,模块之间互相调用,耦合度极高。后来我合并成一个 spi_flash 模块,内部封装好状态机,对外只提供 read、write、erase 三个接口。清爽多了。
所以,粒度不是越细越好。要找到那个「刚好」的点。
模块边界的定义
边界,就是模块之间的「墙」。墙要厚,但门要清晰。
我习惯用接口头文件来定义边界。每个模块都有一个公开的头文件,里面只放:
- 模块初始化函数
- 对外提供的 API 函数声明
- 必要的数据结构定义(尽量用不透明指针)
模块内部的东西,全部藏在 .c 文件里,用 static 限制作用域。这样,外部只能通过接口访问,内部怎么改都不影响别人。
举个例子:
// temperature.h —— 模块对外接口
#ifndef TEMPERATURE_H
#define TEMPERATURE_H
#include <stdint.h>
// 初始化温度传感器
void temp_init(void);
// 读取当前温度,返回摄氏度,精度 0.1°C
int16_t temp_read_celsius(void);
// 获取传感器状态
typedef enum {
TEMP_OK,
TEMP_ERROR,
TEMP_NOT_READY
} temp_status_t;
temp_status_t temp_get_status(void);
#endif
// temperature.c —— 模块内部实现
#include "temperature.h"
#include "hal_i2c.h"
// 内部变量,外部不可见
static int16_t last_value = 0;
static temp_status_t status = TEMP_NOT_READY;
// 内部辅助函数,static 限制作用域
static void temp_filter(void) {
// 滑动平均滤波
}
void temp_init(void) {
hal_i2c_init();
// 配置传感器...
status = TEMP_OK;
}
int16_t temp_read_celsius(void) {
uint16_t raw = hal_i2c_read(0x48, 0x00);
last_value = (int16_t)(raw * 0.0625);
temp_filter();
return last_value;
}
temp_status_t temp_get_status(void) {
return status;
}
你看,外部只知道 temp_init、temp_read_celsius、temp_get_status 三个函数。内部怎么滤波、用什么 I2C 地址,全是秘密。这就是边界清晰。
知识结构图
下面这张图,把模块化设计的核心逻辑串起来了:
实战中的常见问题
最后,我总结几个实战中经常踩的坑:
- 过度抽象:为了低耦合,搞了五六层抽象,结果一个简单的读温度操作要经过 4 个函数调用。性能损失不说,代码可读性也差。记住,嵌入式不是企业级 Java,适可而止。
- 接口膨胀:一个模块对外提供 20 多个 API。这说明模块职责不清晰,该拆了。我一般控制在一个模块不超过 10 个公开函数。
- 循环依赖:A 模块调 B,B 模块调 A。这种在嵌入式里很常见,尤其是中断和主循环之间。解决办法是引入回调或者消息队列。
- 全局变量泛滥:为了省事,把状态标志、配置参数全搞成全局的。嗯,我早期项目也这么干过,后来重构时差点崩溃。现在我的原则是:全局变量只出现在
main.c里,其他地方要用,通过接口传递。
一个小技巧:每次新增一个模块前,先写它的接口头文件。写的时候问自己三个问题:
- 这个模块的职责是什么?(一句话说清楚)
- 外部需要知道它的哪些内部细节?(越少越好)
- 如果换一个实现方式,接口需要改吗?(不需要就是好接口)
这三个问题想清楚了,模块划分基本不会跑偏。
模块化设计不是一蹴而就的。我做了十几年嵌入式,到现在也不敢说每次都能一次划对。但只要你坚持高内聚、低耦合,粒度适中、边界清晰,架构就会越来越稳。嗯,今天就聊到这儿,下次咱们聊聊接口定义的具体写法。