第四章 模块解耦核心思想:高内聚低耦合、依赖倒置与接口隔离

各位工程师,咱们今天聊点实在的。模块解耦,说白了就是让代码里的各个零件能独立拆装、互不拖累。我见过太多项目,改一个LED闪烁的延时,结果把整个通信协议栈都牵连了——这种“牵一发动全身”的噩梦,根源就是耦合太紧。

这一章,咱们把三个最核心的解耦原则掰开揉碎,看看它们在嵌入式里到底怎么落地。

4.1 高内聚低耦合:模块的“内功”与“外功”

先问个问题:你写一个UART驱动,里面该放什么?

很多人会把波特率计算、环形缓冲区、中断处理、甚至协议解析全塞进去。嗯,这就是典型的“低内聚”。

高内聚,指的是一个模块只做一件事,并且把这件事做到极致。比如UART驱动,它的职责就是收发字节流。至于数据怎么解析、怎么组包,那是上层协议的事。

低耦合,指的是模块之间的依赖关系要尽量简单、清晰。A模块调用B模块,最好只通过一个函数接口,别直接访问B的内部结构体。

核心指标:
  • 内聚度:模块内部元素之间的关联程度。功能内聚 > 顺序内聚 > 通信内聚 > 过程内聚 > 时间内聚 > 逻辑内聚 > 偶然内聚。
  • 耦合度:模块之间的依赖程度。数据耦合 < 标记耦合 < 控制耦合 < 外部耦合 < 公共耦合 < 内容耦合。

我在项目中遇到过这样一个案例:一个传感器采集模块,既负责I2C读写,又负责数据滤波,还负责报警逻辑。结果换了一个传感器型号,整个模块几乎重写。后来拆成三个独立模块——sensor_driver(只做I2C读写)、sensor_filter(只做算法)、sensor_alarm(只做阈值判断),再改型号时只换驱动层,其他两个纹丝不动。

我的习惯:写模块前先问自己三个问题——这个模块对外暴露几个接口?内部有多少全局变量?如果换一个硬件平台,要改几行代码?答案越少,内聚越高,耦合越低。

4.2 依赖倒置原则:让高层不依赖底层

依赖倒置原则(DIP)听起来有点绕,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。

你想想看,传统的嵌入式代码是什么样的?

// 传统写法:高层直接依赖底层
void app_send_data(uint8_t *data, uint16_t len) {
    // 直接调用UART硬件操作
    uart1_tx_bytes(data, len);
}

这种写法,应用层直接绑死了UART1。哪天要改成SPI或CAN,应用层代码得全改。

用依赖倒置的思路,应该这样:

// 抽象接口:定义发送操作
typedef struct {
    void (*send)(uint8_t *data, uint16_t len);
} transport_if_t;

// 高层只依赖接口
void app_send_data(transport_if_t *iface, uint8_t *data, uint16_t len) {
    iface->send(data, len);
}

// 底层实现接口
void uart_send(uint8_t *data, uint16_t len) { /* UART实现 */ }
void spi_send(uint8_t *data, uint16_t len) { /* SPI实现 */ }

// 运行时绑定
transport_if_t uart_if = { .send = uart_send };
transport_if_t spi_if = { .send = spi_send };

看到了吗?应用层不再知道底层是UART还是SPI。它只认一个函数指针。这就是“倒置”——不是高层去适配底层,而是底层去实现高层定义的接口。

我曾经踩过的坑:在STM32上做Bootloader时,把Flash擦写操作直接写在应用逻辑里。后来换芯片,Flash地址和扇区大小全变了,改得我头皮发麻。如果当初定义一个 flash_if_t 接口,换芯片只需要换一个驱动文件。

依赖倒置在嵌入式中的典型应用场景:

场景 传统做法(紧耦合) DIP做法(松耦合)
定时器服务 直接调用 HAL_TIM_Base_Start_IT 定义 timer_if_t,包含 start/stop/set_callback
存储服务 直接调用 SPI_Flash_Write 定义 storage_if_t,包含 read/write/erase
日志输出 直接 printf 或 UART_SendString 定义 log_if_t,包含 log_info/log_warn/log_error

4.3 接口隔离原则:不要强迫模块依赖它不需要的东西

接口隔离原则(ISP)说的是:客户端不应该被迫依赖它不使用的接口。

举个例子。你写一个“传感器”接口,里面定义了 init、read、write、calibrate、sleep、wakeup 六个方法。然后你有一个温度传感器,它不需要 calibrate 和 write。但因为它实现了这个接口,它就得提供两个空函数。

// 臃肿的接口
typedef struct {
    void (*init)(void);
    int (*read)(int *value);
    void (*write)(int value);
    void (*calibrate)(void);
    void (*sleep)(void);
    void (*wakeup)(void);
} sensor_if_t;

// 温度传感器:被迫实现两个空函数
static void temp_calibrate(void) { /* 空实现 */ }
static void temp_write(int value) { /* 空实现 */ }

这种空实现,就是代码坏味道。它意味着接口设计不合理。

正确的做法是:把大接口拆成多个小接口。

// 拆分后的接口
typedef struct {
    void (*init)(void);
    int (*read)(int *value);
} sensor_read_if_t;

typedef struct {
    void (*write)(int value);
} sensor_write_if_t;

typedef struct {
    void (*calibrate)(void);
} sensor_calibrate_if_t;

typedef struct {
    void (*sleep)(void);
    void (*wakeup)(void);
} sensor_power_if_t;

温度传感器只需要实现 sensor_read_if_t 和 sensor_power_if_t。而EEPROM芯片可以实现 sensor_read_if_t 和 sensor_write_if_t。各取所需,谁也不欠谁。

我的判断标准:如果一个接口里超过30%的方法在其他实现中是空函数,说明接口需要拆分。别犹豫,拆!

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

这三个原则不是孤立的。它们共同指向一个目标:让模块像积木一样可替换、可测试、可复用。

  • 高内聚低耦合:是基础。模块内部要凝聚,模块之间要松散。
  • 依赖倒置:是手段。通过抽象接口,让依赖关系从“具体”转向“抽象”。
  • 接口隔离:是细化。确保每个接口都足够“瘦”,只包含客户端真正需要的方法。

下面这张图,是我在实际项目中总结的模块解耦框架:

模块解耦核心框架 应用层(App) 抽象接口层(Interface) transport_if_t | storage_if_t | sensor_read_if_t | timer_if_t UART驱动 SPI驱动 I2C驱动 硬件层(MCU外设 / 外部芯片) 关键原则: ① 应用层只依赖抽象接口,不依赖具体驱动 ② 驱动层实现抽象接口,不暴露内部细节

这个框架的核心逻辑是:应用层只认接口,不认硬件。驱动层只实现接口,不关心谁在调用它。中间那层抽象接口,就是整个系统的“松耦合枢纽”。

实战检查清单:
  1. 每个模块是否只有一个明确的职责?
  2. 模块之间的调用是否都通过函数指针或接口结构体?
  3. 接口中的每个方法是否都被所有实现者使用?
  4. 换一个硬件平台,需要改几个模块?
  5. 单元测试时,能否轻松 mock 掉底层依赖?

嗯,这一章的内容就到这里。记住:解耦不是目的,可维护性才是。别为了解耦而解耦,搞出一堆抽象层把自己绕晕。适度解耦,恰到好处,才是高手之道。