模块化设计基础:高内聚、低耦合原则
好,我们正式开始第二讲。上一章聊了接口定义的重要性,这一章咱们得把地基打牢——模块化设计。
说白了,模块化就是把一个大系统拆成若干个小块。但怎么拆?拆到什么程度?拆完怎么保证它们不乱成一锅粥?这就是高内聚、低耦合要解决的问题。
我刚开始做嵌入式那几年,也写过那种几千行的单文件程序。嗯,现在回想起来,那简直是给自己挖坑。改一个bug,牵出三个新bug。后来带团队了,我定了一条死规矩:任何源文件超过500行,必须拆。
什么是高内聚?
内聚,衡量的是一个模块内部元素之间的关联程度。高内聚意味着模块里的函数、变量、数据结构都围绕同一个职责服务。
核心原则:一个模块只做一件事,并且把它做好。
我举个例子。你写一个温度传感器驱动,里面既包含I2C读写函数,又包含PID控制算法,还混着日志打印——这就是低内聚。正确的做法是:
- 传感器驱动模块:只负责初始化、读取原始数据、配置寄存器
- 控制算法模块:只负责PID计算
- 日志模块:只负责格式化输出
内聚从低到高分七种,我列个表,你对照一下自己的代码:
| 内聚类型 | 描述 | 评价 |
|---|---|---|
| 偶然内聚 | 几个函数凑一起,只是因为它们在同一文件 | ❌ 最差 |
| 逻辑内聚 | 根据外部条件决定执行哪个功能 | ❌ 差 |
| 时间内聚 | 因为都在同一时间段执行而放一起 | ⚠️ 一般 |
| 过程内聚 | 按处理流程顺序组合 | ⚠️ 一般 |
| 通信内聚 | 操作同一数据结构的函数放一起 | ✅ 较好 |
| 顺序内聚 | 一个函数的输出是另一个的输入 | ✅ 好 |
| 功能内聚 | 所有元素共同完成一个单一功能 | ✅ 最佳 |
我个人习惯,目标就是功能内聚。达不到?至少做到通信内聚或顺序内聚。逻辑内聚和偶然内聚,代码评审时直接打回。
什么是低耦合?
耦合,衡量的是模块之间的依赖程度。低耦合意味着一个模块改了,其他模块不需要跟着改。
耦合也分等级:
| 耦合类型 | 表现 | 评价 |
|---|---|---|
| 内容耦合 | 一个模块直接修改另一个模块的内部数据 | ❌ 最差 |
| 公共耦合 | 多个模块共享全局变量 | ❌ 差 |
| 控制耦合 | 传递控制标志,让被调用者决定行为 | ⚠️ 一般 |
| 印记耦合 | 传递整个数据结构,但只用其中一部分 | ⚠️ 一般 |
| 数据耦合 | 只传递必要的基本类型参数 | ✅ 好 |
| 消息耦合 | 通过消息队列或事件驱动通信 | ✅ 最佳 |
避坑指南:我曾经在一个项目里看到,两个模块通过全局结构体传递状态,结果A模块改了结构体字段名,B模块忘了更新,编译不报错,运行直接跑飞。从那以后,我要求团队禁止公共耦合,全局变量必须封装成模块内的静态变量,通过接口函数访问。
模块划分的粒度与边界
拆模块不是拆得越细越好。你想想看,如果每个函数都单独一个文件,那跟没拆有什么区别?反而多了很多接口文件。
我总结了一个粒度判断三问:
- 这个模块能否独立测试? 如果能单独写单元测试,粒度合适。
- 这个模块能否被替换? 如果换一个实现不影响其他模块,边界清晰。
- 这个模块的接口是否稳定? 如果接口经常变,说明边界没划对。
边界划分的常见模式:
- 按硬件外设划分:UART驱动、SPI驱动、GPIO驱动
- 按业务功能划分:数据采集、协议解析、命令处理
- 按抽象层次划分:硬件抽象层(HAL)、中间件层、应用层
下面这张图展示了典型的嵌入式模块划分结构:
实战:将单文件程序拆分为模块
光说不练假把式。咱们拿一个实际的例子来拆。
假设你有一个单文件程序 main.c,里面混着LED控制、按键扫描、串口打印、定时器配置。代码大概长这样:
// 单文件程序(拆之前)
#include <stdio.h>
#include <stdint.h>
uint8_t led_state = 0;
uint32_t timer_count = 0;
void gpio_init(void) {
// 初始化GPIO
}
void uart_send(char *data) {
// 发送串口数据
}
void led_toggle(void) {
led_state = !led_state;
// 硬件操作
}
void key_scan(void) {
// 扫描按键
if (/* 按键按下 */) {
led_toggle();
uart_send("Key pressed\n");
}
}
void timer_isr(void) {
timer_count++;
if (timer_count >= 1000) {
led_toggle();
timer_count = 0;
}
}
int main(void) {
gpio_init();
while(1) {
key_scan();
}
return 0;
}
这个文件有什么问题?
- 全局变量满天飞,
led_state和timer_count谁都能改 - LED控制、按键扫描、串口打印全耦合在一起
- 没法单独测试任何一个功能
好,现在我们来拆。我建议分成四个模块:
| 模块名 | 头文件 | 源文件 | 职责 |
|---|---|---|---|
| LED驱动 | led.h | led.c | 初始化、开、关、翻转 |
| 按键驱动 | key.h | key.c | 扫描、去抖、事件回调 |
| 串口驱动 | uart.h | uart.c | 初始化、发送、接收 |
| 定时器驱动 | timer.h | timer.c | 配置、中断处理 |
拆完之后,led.h 大概长这样:
// led.h
#ifndef LED_H
#define LED_H
#include <stdint.h>
void led_init(void);
void led_on(void);
void led_off(void);
void led_toggle(void);
#endif
led.c 的实现:
// led.c
#include "led.h"
static uint8_t led_state = 0; // 静态全局变量,外部不可见
void led_init(void) {
// 硬件初始化
led_state = 0;
}
void led_on(void) {
// 硬件操作
led_state = 1;
}
void led_off(void) {
// 硬件操作
led_state = 0;
}
void led_toggle(void) {
led_state = !led_state;
// 硬件操作
}
关键点:注意我把 led_state 从全局变量改成了 static 静态变量。这样外部模块无法直接修改它,只能通过 led_on()、led_off() 这些接口操作。这就是信息隐藏——低耦合的核心手段。
同样,key.c 里把按键事件通过回调函数通知上层,而不是直接调用 led_toggle() 和 uart_send():
// key.h
#ifndef KEY_H
#define KEY_H
typedef void (*key_callback_t)(void);
void key_init(void);
void key_register_callback(key_callback_t cb);
void key_scan(void);
#endif
最后 main.c 变得非常清爽:
// main.c(拆之后)
#include "led.h"
#include "key.h"
#include "uart.h"
#include "timer.h"
void on_key_pressed(void) {
led_toggle();
uart_send("Key pressed\n");
}
int main(void) {
led_init();
key_init();
uart_init();
timer_init();
key_register_callback(on_key_pressed);
while(1) {
key_scan();
}
return 0;
}
拆完的效果:
- 每个模块可以独立测试——写个测试文件,调
led_on()看引脚电平就行 - 替换硬件不影响其他模块——换个LED驱动芯片,只改
led.c - 接口清晰——头文件就是契约,实现细节全在
.c里藏着
嗯,这就是模块化设计的实战。你想想看,如果一开始就按这个结构写,后面维护会省多少事?
我曾经接手过一个项目,单文件3000多行,里面光全局变量就50多个。我花了整整两周才拆完,中间还引入了一个回归bug。所以我现在带项目,第一周就定好模块边界和接口,后面只往里填代码,绝不越界。