模块化设计原则:高内聚低耦合、单一职责、接口隔离在嵌入式中的落地

模块化设计,说白了就是把一个大系统拆成若干个小模块。每个模块各司其职,彼此之间通过明确的接口通信。这个道理听起来简单,但真正落地的时候,我见过太多项目栽在「模块划分」这个坎上。

我记得刚入行那会儿,接手过一个老项目。整个代码就一个 main.c,三万多行。改一个 bug 要翻半天文件,修完这边,那边又崩了。后来我花了整整两周重构,才把系统拆成 8 个模块。那次经历让我深刻体会到:模块化不是锦上添花,而是嵌入式开发的生存底线。

核心观点:模块化设计的三条黄金原则——高内聚低耦合、单一职责、接口隔离,不是理论空谈,而是每一行代码、每一个 .c/.h 文件都要遵守的实战纪律。

2.1 高内聚低耦合:模块的「内功」与「外功」

高内聚,指的是模块内部的功能要紧密相关。低耦合,指的是模块之间的依赖要尽可能少。这两者相辅相成,缺一不可。

我在项目中遇到过这样一个案例:一个温度采集模块,既负责读取传感器数据,又负责把数据通过 SPI 发送给显示模块,还顺带做了个日志记录。看起来功能齐全,但每次修改 SPI 协议,温度采集的逻辑也要跟着改。这就是典型的内聚不够、耦合过高。

2.1.1 内聚的层次

内聚从低到高可以分为几个层次。我一般只关注两种:

内聚类型 特征 实战建议
功能内聚(最高) 模块内所有元素共同完成一个单一功能 优先追求,比如「CRC 计算模块」只做校验
通信内聚(中等) 模块内元素处理同一数据,但功能不同 可接受,比如「数据包解析模块」
逻辑内聚(低) 把相似功能凑在一起,用参数区分 尽量避免,容易变成「万能模块」
偶然内聚(最低) 毫无关联的代码放在一起 坚决杜绝,这是代码坏味道
我的习惯:写一个新模块之前,先问自己三个问题——这个模块叫什么名字?它只做一件事吗?如果删掉它,会影响几个模块?如果答案超过两个,说明耦合可能高了。

2.1.2 耦合的度量

耦合的强弱,我通常用「修改一个模块,需要改几个其他模块」来衡量。常见的耦合类型:

  • 数据耦合(最弱):模块间只传递基本数据类型。这是最理想的。
  • 标记耦合:传递结构体指针。注意,如果结构体定义变了,所有用到的地方都要改。
  • 控制耦合:A 模块传一个标志给 B 模块,控制 B 的行为。能避免就避免。
  • 内容耦合(最强):一个模块直接访问另一个模块的内部数据。这是灾难。

嗯,这里要注意。嵌入式开发中,为了性能,有时候会忍不住用全局变量。我建议:全局变量就是内容耦合的温床。能用接口函数传递的,就别用全局变量。

2.2 单一职责原则:一个模块,一个理由

单一职责原则(SRP)说:一个模块应该只有一个引起它变化的原因。你想想看,如果一个模块既负责传感器采集,又负责数据上报,那么传感器换了要改它,上报协议变了也要改它。这个模块就变成了「修改集中营」。

我曾经在一个车载项目中,把一个「数据管理模块」拆成了三个:sensor_driver、data_processor、comm_manager。拆完之后,每个模块的代码量减少了 60%,bug 率下降了 70%。

2.2.1 如何判断职责是否单一?

我有个简单的方法:用一句话描述这个模块的职责。如果这句话里出现了「和」、「或」、「以及」,那就说明职责不单一。

  • ❌ 「LED 控制和按键扫描模块」——两个职责
  • ✅ 「LED 控制模块」——一个职责
  • ✅ 「按键扫描模块」——一个职责
避坑指南:我曾经在一个 RTOS 项目中,把任务调度和内存管理写在了同一个模块里。结果后来换了一个调度算法,内存管理的代码也被迫重写。这就是典型的职责没有分离。从那以后,我坚持每个模块只做一件事,哪怕这件事只有 10 行代码。

2.3 接口隔离原则:不要让你不需要的东西

接口隔离原则(ISP)在嵌入式开发中尤其重要。它的核心思想是:客户端不应该依赖它不需要的接口。

说白了,就是接口要小而专。不要设计一个「万能接口」,把所有功能都塞进去。每个使用者只看到自己需要的部分。

2.3.1 嵌入式中的接口设计

在 C 语言中,接口通常表现为头文件中的函数声明。我见过这样的头文件:

// ❌ 不好的接口设计:一个头文件暴露了所有功能
void uart_init(uint32_t baud);
void uart_send_byte(uint8_t data);
void uart_send_string(const char *str);
void uart_set_baud(uint32_t baud);
void uart_enable_irq(void);
void uart_disable_irq(void);
void uart_set_parity(uint8_t parity);
void uart_set_stop_bits(uint8_t bits);
void uart_set_flow_ctrl(bool enable);
void uart_get_status(uint32_t *status);

这个接口的问题在于:如果你只是发数据,你被迫看到了所有配置函数。万一有人不小心调了 uart_set_baud,整个通信就乱了。

更好的做法是拆成多个小接口:

// ✅ 好的接口设计:按职责拆分
// uart_cfg.h - 配置接口
void uart_init(uint32_t baud, uint8_t parity, uint8_t stop_bits);
void uart_set_flow_ctrl(bool enable);

// uart_tx.h - 发送接口
void uart_send_byte(uint8_t data);
void uart_send_string(const char *str);

// uart_rx.h - 接收接口
uint8_t uart_recv_byte(void);
uint32_t uart_get_status(void);

这样,发送模块只需要包含 uart_tx.h,配置模块只需要包含 uart_cfg.h。各取所需,互不干扰。

2.3.2 接口隔离的实战技巧

  • 头文件最小化:每个 .h 文件只暴露必要的函数和类型。内部实现用 static 隐藏。
  • 使用不透明指针:在 .h 中声明结构体,但定义放在 .c 中。外部只能通过函数操作,不能直接访问成员。
  • 分层接口:驱动层、中间件层、应用层的接口分开定义,不要混在一个头文件里。
小技巧:写接口的时候,把自己想象成这个模块的「用户」。你希望看到什么?你不想看到什么?只暴露用户需要的,其他的藏起来。

2.4 三条原则的协同落地

这三条原则不是孤立的。它们在实际项目中是互相支撑的:

  • 单一职责帮你确定「模块该做什么」
  • 高内聚低耦合帮你确定「模块内部怎么做、外部怎么连」
  • 接口隔离帮你确定「模块对外暴露什么」

下面这张图展示了它们的关系:

模块化设计三原则协同关系 嵌入式模块 单一职责 一个模块只做一件事 高内聚低耦合 内部紧密,外部松散 接口隔离 只暴露需要的接口 决定做什么 决定怎么做 决定暴露什么 三条原则共同作用,才能设计出可维护、可测试、可复用的嵌入式模块 缺少任何一条,模块化都会变成「伪模块化」

2.5 实战案例:一个温控系统的模块化重构

最后,我分享一个真实的案例。一个温控系统,原始代码结构如下:

// 原始结构:所有功能混在一起
void temp_control_init(void);
void temp_control_read_sensor(void);
void temp_control_calc_pid(void);
void temp_control_set_heater(void);
void temp_control_send_log(void);
void temp_control_handle_alert(void);

这个模块有 6 个函数,涉及传感器读取、PID 计算、加热器控制、日志发送、告警处理。职责严重不单一,耦合极高。

重构之后,拆成了 5 个模块:

模块名 职责 接口文件
sensor_driver 读取温度传感器原始数据 sensor_driver.h
pid_controller 根据目标温度和当前温度计算控制量 pid_controller.h
heater_driver 控制加热器 PWM 输出 heater_driver.h
log_manager 记录运行日志到 Flash log_manager.h
alert_handler 检测异常并触发告警 alert_handler.h

每个模块的接口都遵循了接口隔离原则。比如 pid_controller.h 只暴露了两个函数:

// pid_controller.h
void pid_init(float kp, float ki, float kd);
float pid_calc(float setpoint, float current);

重构之后,每个模块都可以独立测试、独立修改。后来客户要求换传感器,我只改了 sensor_driver.c,其他模块一行代码都没动。

曾经踩过的坑:重构的时候,我一开始把 pid_controller 和 heater_driver 放在了一个模块里,觉得「反正都是控制相关的」。结果后来要换加热器类型,PID 参数也得跟着调。这就是典型的「逻辑内聚」陷阱。记住:相关不等于同一个职责。

模块化设计不是一蹴而就的。它需要你在写每一行代码的时候,都问自己:这个函数属于哪个模块?这个模块的职责是什么?这个接口是不是最小化的?养成这个习惯,你的代码会越来越干净,维护成本会越来越低。