1. 课程导入
做嵌入式开发久了,你会发现一个规律:代码量越大,维护成本越高。我早年带团队时接过一个项目,前任工程师把所有功能都塞在一个 .c 文件里,足足 8000 行。改一个定时器参数,要翻十几处宏定义;加一个新传感器,得从头读到尾才能找到挂载点。那段时间,团队几乎每周都在修 bug,修完一个又冒出来两个。
后来我痛定思痛,把所有功能拆成独立模块。结果呢?开发周期缩短了 40%,bug 率下降了 60%。模块化不是花架子,它是实打实的生产力。
1.1 为什么需要模块化?
说白了,模块化就是把一个大问题拆成若干个小问题,每个小问题独立解决。你想想看,一个 10 万行的工程,如果拆成 100 个模块,每个模块平均 1000 行。调试时你只需要关注当前模块,不用被其他模块的细节干扰。
我个人习惯把模块化的好处归纳为四点:
| 维度 | 说明 | 我踩过的坑 |
|---|---|---|
| 可维护性 | 修改一个模块不影响其他模块 | 曾经改了一个全局变量,导致三个驱动同时崩溃 |
| 可复用性 | 模块可以在不同项目间直接移植 | 同一个 I2C 驱动写了五遍,每次都是复制粘贴改参数 |
| 可测试性 | 可以单独测试每个模块的功能 | 整系统联调时,定位一个问题要花三天 |
| 并行开发 | 多人同时开发不同模块,互不干扰 | 两个人改同一个文件,合并时冲突 200 处 |
核心观点:模块化的本质是控制复杂度。人的大脑一次只能处理 7±2 个信息块,模块化就是把信息块的数量控制在这个范围内。
1.2 高内聚低耦合原则
这是模块化设计的黄金法则。我见过太多人把模块化理解成「把代码分文件」,结果分出来的模块之间互相调用、全局变量满天飞。那叫「物理拆分」,不叫「模块化」。
高内聚:一个模块内部的元素应该紧密相关,共同完成一个明确的功能。比如一个 UART 驱动模块,它应该只做 UART 相关的事:初始化、发送、接收、中断处理。不要把 LED 闪烁的逻辑也塞进去。
低耦合:模块之间的依赖关系要尽可能弱。A 模块的修改,不应该导致 B 模块也要跟着改。我常用的判断标准是:能不能在不看其他模块代码的情况下,独立理解当前模块的逻辑?
我曾经接手过一个项目,模块 A 直接读取模块 B 的内部结构体成员。后来 B 的结构体改了字段名,A 编译直接报错。这就是典型的高耦合——两个模块绑死在一起,改一个就得改另一个。
我的经验:判断耦合度有个简单方法——看模块的接口函数数量。如果一个模块对外暴露了超过 10 个函数,大概率耦合过高。好的模块接口通常控制在 3~5 个。
1.3 接口隔离的核心理念
接口隔离,说白了就是「不要让你不需要的东西」。一个模块只暴露它必须暴露的接口,隐藏所有内部实现细节。
我习惯用「契约」来理解接口:模块 A 和模块 B 之间通过接口达成一个契约。A 承诺「只要你调用这个函数,传入这些参数,我就返回这些结果」。至于 A 内部是用中断轮询还是 DMA,B 完全不需要知道。
接口隔离的好处很明显:
- 降低依赖风险:内部实现变了,只要接口不变,调用方就不用改
- 提高可替换性:可以随时换掉底层实现,比如从软件模拟 I2C 换成硬件 I2C
- 简化测试:可以 mock 掉接口,单独测试调用方的逻辑
注意:接口隔离不是「越少越好」。接口太少会导致功能不足,调用方不得不绕过接口直接操作内部数据。我见过有人为了「极致隔离」,一个模块只暴露一个 init() 函数,结果所有配置都通过全局变量传递——这比不隔离更糟糕。
1.4 模块化设计框架图
下面这张图展示了我常用的模块化分层结构。你可以看到,每一层只通过接口与相邻层通信,同层模块之间不直接调用。
1.5 接口隔离的代码示例
光说理论不够,我拿一个实际例子来说明。下面是一个 I2C 驱动模块的接口设计,我刻意把「接口」和「实现」分开:
/* i2c_driver.h —— 接口头文件 */
#ifndef I2C_DRIVER_H
#define I2C_DRIVER_H
#include <stdint.h>
/* 模块初始化 */
int i2c_init(uint32_t bus_speed_hz);
/* 写操作 */
int i2c_write(uint8_t dev_addr, uint8_t reg_addr,
const uint8_t *data, uint16_t len);
/* 读操作 */
int i2c_read(uint8_t dev_addr, uint8_t reg_addr,
uint8_t *buffer, uint16_t len);
/* 模块去初始化 */
void i2c_deinit(void);
#endif /* I2C_DRIVER_H */
/* i2c_driver.c —— 实现文件 */
#include "i2c_driver.h"
/* 内部结构体,对外完全隐藏 */
typedef struct {
uint32_t base_addr;
uint32_t speed;
uint8_t initialized;
} i2c_private_t;
static i2c_private_t i2c_inst = {0};
int i2c_init(uint32_t bus_speed_hz) {
if (i2c_inst.initialized) return -1;
/* 配置寄存器、设置时钟、使能外设 */
i2c_inst.base_addr = I2C1_BASE;
i2c_inst.speed = bus_speed_hz;
i2c_inst.initialized = 1;
return 0;
}
int i2c_write(uint8_t dev_addr, uint8_t reg_addr,
const uint8_t *data, uint16_t len) {
if (!i2c_inst.initialized) return -1;
/* 发送起始条件、设备地址、寄存器地址、数据、停止条件 */
return 0;
}
int i2c_read(uint8_t dev_addr, uint8_t reg_addr,
uint8_t *buffer, uint16_t len) {
if (!i2c_inst.initialized) return -1;
/* 发送起始条件、设备地址、寄存器地址、重复起始、读取数据 */
return 0;
}
void i2c_deinit(void) {
if (!i2c_inst.initialized) return;
/* 关闭外设时钟、复位寄存器 */
i2c_inst.initialized = 0;
}
关键点:注意看,头文件里只暴露了 4 个函数。内部结构体 i2c_private_t 完全隐藏在 .c 文件中,调用方根本不知道它的存在。这就是接口隔离——你只需要知道「怎么用」,不需要知道「怎么实现」。
1.6 模块化设计的常见误区
我带过不少新人,发现大家容易犯这几个错误:
- 过度拆分:一个文件只有 50 行代码,函数只有 3 行。拆得太碎反而增加阅读负担。我一般控制每个模块 200~500 行。
- 接口膨胀:一个模块暴露 20 多个函数,调用方根本记不住。好的接口设计应该让调用方「看一眼就知道怎么用」。
- 忽略错误处理:接口函数没有返回值检查,或者所有错误都返回 -1。我习惯为每个模块定义专用的错误码枚举。
- 全局变量泛滥:模块之间通过全局变量传数据,绕过了接口。这是最隐蔽的耦合方式,比函数调用更难发现。
避坑指南:我曾经在一个项目中,模块 A 和模块 B 通过一个全局结构体共享数据。后来需求变更,需要加一个字段,结果 A 和 B 都要改,还牵连了模块 C 和 D。从那以后,我定了一条铁律:模块之间只通过函数接口通信,禁止直接访问其他模块的全局变量。
1.7 模块化设计的评估指标
怎么判断你的模块化设计好不好?我常用这几个指标:
| 指标 | 计算公式 | 理想值 | 我的经验 |
|---|---|---|---|
| 内聚度 | 模块内部函数调用次数 / 模块总函数数 | > 0.7 | 低于 0.5 说明模块内部关联太弱 |
| 耦合度 | 模块间接口调用次数 / 模块总函数数 | < 0.3 | 高于 0.5 说明耦合过紧 |
| 接口数量 | 头文件中声明的函数数 | 3~8 个 | 超过 10 个就要考虑拆分 |
| 可移植性 | 移植到新平台需要修改的代码行数 | < 50 行 | 超过 100 行说明硬件抽象没做好 |
嗯,这些指标不是绝对的,但可以作为参考。我每次做完模块设计,都会拿这张表过一遍,基本能发现 80% 的问题。
最后提醒一句:模块化设计不是一蹴而就的。我见过有人花两周时间设计模块接口,结果写代码时发现根本用不上。我的习惯是:先写一个最小可用的版本,跑通后再重构。模块化是迭代出来的,不是设计出来的。