3. 模块化设计原则
好,我们接着聊模块化设计。上一节讲了单一职责和接口隔离,这次要聊的三个原则,说实话,在实际项目中更容易被忽略,但踩坑的代价也更大。
我个人习惯把这三个原则放在一起讲,因为它们都指向同一个核心问题:模块之间的边界到底该怎么划?划得太死,改一个功能要动一片;划得太松,模块之间纠缠不清,谁也不敢动。
开闭原则(Open-Closed Principle)
定义很简单:对扩展开放,对修改关闭。说白了,就是加新功能的时候,尽量别去改已有的代码。
我在项目中遇到过最典型的反例:一个数据采集模块,原本只支持串口,后来要加网络接口。有人直接在采集函数里加了个 if (type == NET) 分支。嗯,当时是快了,但后面加蓝牙、加CAN总线的时候,那个函数膨胀到800多行,谁都不敢动。
核心思路:用抽象层隔离变化。底层实现可以换,但上层的调用接口不变。
看一个简单的C语言示例:
// 抽象接口
typedef struct {
int (*init)(void);
int (*send)(uint8_t *data, uint32_t len);
int (*recv)(uint8_t *buf, uint32_t *len);
} comm_driver_t;
// 串口实现
static int uart_init(void) { /* ... */ }
static int uart_send(uint8_t *data, uint32_t len) { /* ... */ }
static int uart_recv(uint8_t *buf, uint32_t *len) { /* ... */ }
const comm_driver_t uart_driver = {
.init = uart_init,
.send = uart_send,
.recv = uart_recv
};
// 上层业务只依赖 comm_driver_t,不关心具体实现
void app_send_data(comm_driver_t *drv, uint8_t *data, uint32_t len) {
drv->send(data, len);
}
加新通信方式的时候,只需要新写一个结构体实例,已有的 app_send_data 一行都不用改。这就是开闭原则的落地方式。
小技巧:嵌入式里用函数指针表来实现多态,比C++的虚表更轻量,也更容易控制内存布局。
里氏替换原则(Liskov Substitution Principle)
这个原则的名字听着很学术,其实意思很直白:子类应该能完全替代父类,而且程序的行为不会出问题。
我曾经在一个电机控制项目里吃过亏。基类定义了一个 set_speed(int rpm) 方法,约定输入范围是0~10000。有个子类实现的是步进电机,它偷偷把范围限制到了0~2000,而且超出后直接返回错误码。上层调用者按照基类的约定传了5000,结果电机不动了,查了两天才找到原因。
教训:子类可以扩展功能,但不能削弱父类的约定。返回值范围、异常行为、前置条件,这些都要保持一致。
在嵌入式C里,虽然没有继承语法,但通过结构体嵌套和函数指针表,同样会碰到这个问题:
// 基类"接口"
typedef struct {
int (*open)(void);
int (*close)(void);
int (*ioctl)(uint32_t cmd, void *arg);
} device_t;
// "子类"实现1:正常返回
int sensor_ioctl(uint32_t cmd, void *arg) {
if (cmd > MAX_CMD) return -EINVAL;
// 处理命令...
return 0;
}
// "子类"实现2:违反约定
int fake_sensor_ioctl(uint32_t cmd, void *arg) {
// 直接返回0,什么都不做 —— 这违反了"ioctl应该执行操作"的约定
return 0;
}
你想想看,如果上层代码依赖 ioctl 去配置传感器,第二个实现虽然编译通过,但系统根本跑不对。这就是里氏替换原则要防止的问题。
迪米特法则(Law of Demeter)
这个法则有个更形象的名字:最少知识原则。意思是,一个模块只应该和它直接相关的模块通信,不要绕来绕去。
我见过最夸张的代码:一个温度采集模块,为了获取"当前是否需要加热"这个状态,居然调了 system_config->heater->control->mode->auto_enable 这种五层链式访问。后来结构体一改,十几个文件都要跟着改。
迪米特法则的落地规则:
- 模块A只能调用模块B的公开接口
- 不能通过模块B去调用模块C的接口
- 模块内部的结构,对外部不可见
正确的做法是加一层封装:
// 错误:绕来绕去
if (sys_cfg->heater_ctrl.mode.auto_enable) {
heater_on();
}
// 正确:直接问
if (is_heater_auto_mode()) {
heater_on();
}
// 封装内部细节
bool is_heater_auto_mode(void) {
return sys_cfg->heater_ctrl.mode.auto_enable;
}
这样做的好处很明显:哪天结构体改了,只需要改 is_heater_auto_mode 这一个函数,调用者完全不受影响。
模块划分实战案例
光讲理论没意思,我们拿一个真实的嵌入式项目来拆解:智能温控器。
这个系统需要:温度采集、加热控制、LCD显示、按键输入、Wi-Fi通信、OTA升级。如果全塞在一个 main.c 里,那就是灾难。
按照前面讲的原则,我建议这样划分:
| 模块 | 职责 | 依赖 | 对应原则 |
|---|---|---|---|
| sensor | 温度采集、滤波、单位转换 | 硬件I2C/SPI驱动 | 单一职责 |
| heater | PID控制、加热开关、过温保护 | sensor(获取温度) | 迪米特法则 |
| display | LCD驱动、界面渲染、菜单管理 | sensor(显示温度) | 接口隔离 |
| keypad | 按键扫描、去抖、事件分发 | 无 | 开闭原则 |
| wifi | 网络连接、MQTT通信、数据上报 | sensor(读取数据) | 里氏替换 |
| ota | 固件下载、校验、分区切换 | wifi(获取数据流) | 依赖倒置 |
每个模块对外暴露的接口,都通过头文件中的函数声明来约定。模块之间只通过接口通信,不直接访问对方的结构体内部。
我的习惯:每个模块的 .h 文件里只放公开接口,内部结构体和静态函数都藏在 .c 文件里。这样别人看头文件就知道怎么用,不用关心实现细节。
下面这张图展示了模块之间的依赖关系,注意看,都是单向的:
这个划分方式,我用了好几年,在多个项目里验证过。核心思路就是:每个模块只做一件事,只和直接邻居说话,接口稳定后尽量不改。
注意:模块划分不是越细越好。我曾经把一个温控器拆成20多个模块,结果接口文件比实现文件还多,维护成本反而上升了。一般5~8个模块是比较合理的范围。
最后总结一下这三个原则的落地要点:
- 开闭原则:加功能时先想能不能不改旧代码。用函数指针表、回调、抽象接口来实现。
- 里氏替换:子类或替代实现必须遵守父类的约定。返回值、异常、行为语义都要一致。
- 迪米特法则:只和直接朋友说话。别绕三层去拿数据,加一层封装不丢人。
这些原则不是教条,是前人踩坑踩出来的经验。你想想看,一个项目迭代三年,代码量从1万行涨到10万行,如果没有这些原则约束,那维护成本会指数级上升。嗯,我见过太多这样的项目了。