模块划分三大原则:嵌入式中的实战应用

模块划分这件事,说白了就是「怎么切代码才不后悔」。我见过太多项目,一开始图省事,把所有功能塞进一个文件里。结果呢?改一个 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 设备
  • 代码复用性大幅提升

三个原则的关系与实战框架

这三个原则不是孤立的。它们互相配合,形成一个完整的模块化设计体系。我画了一张图,帮你理清它们的关系:

模块划分三大原则关系图 模块划分 单一职责原则 一个模块只做一件事 接口隔离原则 不依赖不需要的接口 依赖倒置原则 依赖抽象而非具体 低耦合 高内聚 可测试 可维护、可复用、可移植 三个原则共同作用:单一职责决定「拆多细」,接口隔离决定「暴露什么」,依赖倒置决定「怎么连接」 三者缺一不可,顺序推进才能做出真正的模块化架构

从这张图你能看到,三个原则层层递进:

  1. 单一职责先帮你把模块拆清楚——每个模块只做一件事
  2. 接口隔离再帮你把接口设计好——每个模块只暴露必要的接口
  3. 依赖倒置最后帮你把依赖关系理顺——高层不依赖低层,都依赖抽象

这样设计出来的系统,自然就是低耦合、高内聚的。而且可测试性也大大提升——你可以轻松地替换底层实现来做单元测试。

我的实战建议:在项目初期,花 30% 的时间做模块划分和接口设计。别急着写代码。我见过太多项目,前期图快,后期改 bug 的时间是前期的三倍。模块划分这件事,慢就是快。

常见误区与避坑指南

说了这么多原则,再聊聊我见过的常见误区:

误区 表现 后果
过度拆分 一个函数一个文件 文件数量爆炸,编译慢,管理难
接口膨胀 一个头文件包含所有功能 编译依赖复杂,修改牵一发动全身
抽象过度 为每个硬件都加一层抽象 性能损耗,代码冗余,调试困难
忽视资源 不考虑 MCU 的 ROM/RAM 限制 编译不过,运行崩溃

我曾经犯过的错:在一个资源极度受限的 MCU(只有 64KB Flash)上,我为了追求「完美」的模块化,给每个外设都加了一层抽象。结果代码体积膨胀了 30%,最后不得不砍掉一部分抽象层。嵌入式开发,永远要在「设计优雅」和「资源约束」之间找平衡。

嗯,这就是模块划分的三个核心原则。记住:单一职责让你知道「拆什么」,接口隔离让你知道「露什么」,依赖倒置让你知道「连什么」。三者配合,才能做出真正可维护、可复用、可移植的嵌入式软件。