模块划分三大原则:嵌入式中的实战应用
模块划分这件事,说白了就是「怎么切代码才不后悔」。我见过太多项目,一开始图省事,把所有功能塞进一个文件里。结果呢?改一个 bug 牵出三个新 bug。今天咱们就聊聊三个最核心的原则——单一职责、接口隔离、依赖倒置。这些不是纸上谈兵,是我在多个嵌入式项目里踩过坑之后,才真正理解的。
单一职责原则:一个模块只做一件事
这个原则听起来简单,做起来难。什么叫「一件事」?我个人的判断标准是:这个模块有没有「改变的理由」。如果有两个以上不相关的理由会迫使你修改它,那它就违反了单一职责。
核心判断标准:一个模块应该只有一个引起它变化的原因。
举个例子。我曾经接手过一个温控器项目,原来的代码把温度采集、PID 计算、LCD 显示、按键扫描全写在一个 main.c 里。看起来挺紧凑,对吧?但问题来了——
- 换了一个温度传感器,要改采集逻辑,结果不小心动了显示部分的代码
- 客户要求 LCD 改成 OLED,又得动整个文件
- 按键从机械按键改成触摸,又是一轮大改
这就是典型的「高耦合、低内聚」。后来我重构了一下,拆成四个模块:
| 模块 | 职责 | 变化原因 |
|---|---|---|
temp_sensor.c |
温度采集与校准 | 更换传感器型号 |
pid_controller.c |
PID 计算与控制输出 | 调整控制算法 |
display.c |
界面显示与刷新 | 更换显示硬件 |
key_input.c |
按键检测与事件分发 | 更换输入方式 |
你看,每个模块只有一个变化原因。换传感器只改 temp_sensor.c,换屏幕只改 display.c。互不干扰。
我的习惯:写一个新模块之前,先问自己三个问题——这个模块的职责是什么?谁会因为什么原因修改它?如果答案超过一个,那就再拆。
接口隔离原则:不要强迫模块依赖它不需要的东西
这个原则在嵌入式里特别重要。为什么?因为嵌入式系统的资源有限,接口设计得不好,会带来不必要的依赖和开销。
接口隔离原则的核心思想是:客户端不应该被迫依赖它不使用的接口。说白了,就是别搞「万能接口」。
我记得有个项目,需要驱动多种传感器——温度、湿度、气压、光照。原来的设计者搞了一个 sensor.h,里面定义了所有传感器的操作:
// 不好的设计:胖接口
typedef struct {
void (*init)(void);
void (*read_temp)(float *temp);
void (*read_humidity)(float *hum);
void (*read_pressure)(float *pres);
void (*read_light)(float *lux);
void (*calibrate)(void);
void (*power_down)(void);
} sensor_ops_t;
问题在哪?如果我只用一个光照传感器,它根本不需要 read_humidity 和 read_pressure。但接口里定义了这些函数指针,我就得实现一堆空函数。浪费 ROM,也容易出错。
更好的做法是按功能拆分接口:
// 好的设计:按职责拆分
typedef struct {
void (*init)(void);
void (*read)(float *value);
} temp_sensor_ops_t;
typedef struct {
void (*init)(void);
void (*read)(float *value);
} light_sensor_ops_t;
每个传感器只实现自己需要的接口。没有冗余,没有空函数。
我曾经踩过的坑:在一个 BLE 项目中,我把蓝牙协议栈的所有回调函数都塞进一个结构体里。结果每个模块都要包含这个头文件,编译依赖变得极其复杂。后来拆成 gap_callback.h、gatt_callback.h、l2cap_callback.h,问题才解决。接口隔离,不只是代码层面的,也是编译依赖层面的。
依赖倒置原则:依赖抽象,不要依赖具体实现
这个原则是三个里面最「反直觉」的。传统的思路是「高层模块调用低层模块」,对吧?但依赖倒置说:高层模块不应该依赖低层模块,两者都应该依赖抽象。
你想想看,在嵌入式里,最常变的是什么?硬件。今天用 STM32,明天可能换 GD32;今天用 I2C 的 EEPROM,明天可能用 SPI 的 Flash。如果高层代码直接依赖具体的硬件操作,换一次硬件就得重写一次。
解决方案是什么?定义抽象接口。我举个例子:
// 抽象接口:存储设备
typedef struct {
int (*init)(void);
int (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
int (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
} storage_device_t;
// 具体实现:I2C EEPROM
static int eeprom_init(void) { /* I2C 初始化 */ }
static int eeprom_read(uint32_t addr, uint8_t *buf, uint32_t len) { /* I2C 读 */ }
static int eeprom_write(uint32_t addr, const uint8_t *buf, uint32_t len) { /* I2C 写 */ }
const storage_device_t eeprom_device = {
.init = eeprom_init,
.read = eeprom_read,
.write = eeprom_write,
};
// 具体实现:SPI Flash
static int flash_init(void) { /* SPI 初始化 */ }
static int flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { /* SPI 读 */ }
static int flash_write(uint32_t addr, const uint8_t *buf, uint32_t len) { /* SPI 写 */ }
const storage_device_t flash_device = {
.init = flash_init,
.read = flash_read,
.write = flash_write,
};
高层模块(比如配置管理、日志系统)只依赖 storage_device_t 这个抽象。至于底层是 EEPROM 还是 Flash,它根本不关心。换硬件?只需要换一个结构体指针。
依赖倒置的好处:
- 硬件更换时,高层代码零修改
- 单元测试时,可以注入 mock 设备
- 代码复用性大幅提升
三个原则的关系与实战框架
这三个原则不是孤立的。它们互相配合,形成一个完整的模块化设计体系。我画了一张图,帮你理清它们的关系:
从这张图你能看到,三个原则层层递进:
- 单一职责先帮你把模块拆清楚——每个模块只做一件事
- 接口隔离再帮你把接口设计好——每个模块只暴露必要的接口
- 依赖倒置最后帮你把依赖关系理顺——高层不依赖低层,都依赖抽象
这样设计出来的系统,自然就是低耦合、高内聚的。而且可测试性也大大提升——你可以轻松地替换底层实现来做单元测试。
我的实战建议:在项目初期,花 30% 的时间做模块划分和接口设计。别急着写代码。我见过太多项目,前期图快,后期改 bug 的时间是前期的三倍。模块划分这件事,慢就是快。
常见误区与避坑指南
说了这么多原则,再聊聊我见过的常见误区:
| 误区 | 表现 | 后果 |
|---|---|---|
| 过度拆分 | 一个函数一个文件 | 文件数量爆炸,编译慢,管理难 |
| 接口膨胀 | 一个头文件包含所有功能 | 编译依赖复杂,修改牵一发动全身 |
| 抽象过度 | 为每个硬件都加一层抽象 | 性能损耗,代码冗余,调试困难 |
| 忽视资源 | 不考虑 MCU 的 ROM/RAM 限制 | 编译不过,运行崩溃 |
我曾经犯过的错:在一个资源极度受限的 MCU(只有 64KB Flash)上,我为了追求「完美」的模块化,给每个外设都加了一层抽象。结果代码体积膨胀了 30%,最后不得不砍掉一部分抽象层。嵌入式开发,永远要在「设计优雅」和「资源约束」之间找平衡。
嗯,这就是模块划分的三个核心原则。记住:单一职责让你知道「拆什么」,接口隔离让你知道「露什么」,依赖倒置让你知道「连什么」。三者配合,才能做出真正可维护、可复用、可移植的嵌入式软件。