模块化设计原则:高内聚低耦合、单一职责、接口隔离在嵌入式中的落地
模块化设计,说白了就是把一个大系统拆成若干个小模块。每个模块各司其职,彼此之间通过明确的接口通信。这个道理听起来简单,但真正落地的时候,我见过太多项目栽在「模块划分」这个坎上。
我记得刚入行那会儿,接手过一个老项目。整个代码就一个 main.c,三万多行。改一个 bug 要翻半天文件,修完这边,那边又崩了。后来我花了整整两周重构,才把系统拆成 8 个模块。那次经历让我深刻体会到:模块化不是锦上添花,而是嵌入式开发的生存底线。
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 控制模块」——一个职责
- ✅ 「按键扫描模块」——一个职责
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 参数也得跟着调。这就是典型的「逻辑内聚」陷阱。记住:相关不等于同一个职责。
模块化设计不是一蹴而就的。它需要你在写每一行代码的时候,都问自己:这个函数属于哪个模块?这个模块的职责是什么?这个接口是不是最小化的?养成这个习惯,你的代码会越来越干净,维护成本会越来越低。