2. 分层架构设计原则:高内聚低耦合在嵌入式中的具体体现,模块划分的边界
好,咱们进入第二章。这一章我打算聊聊分层架构里最核心的两个词——高内聚、低耦合。
你可能在书本上见过无数次了。但说实话,我早年做项目时,对这两个词的理解也就停留在「哦,就是要分开写嘛」。直到有一次,我在一个智能家居网关项目里,把协议解析、数据缓存、业务逻辑全塞在一个文件里……嗯,后来产品要增加一个新的传感器类型,改一个地方崩三处。那之后我才真正明白,什么叫「内聚」,什么叫「耦合」。
2.1 高内聚:一个模块只干一件事,并且干好
高内聚,说白了就是模块内部的功能要紧密相关。一个模块不应该既管数据收发,又管业务决策,还管硬件初始化。
我个人习惯把模块的职责画在一个小框里,问自己一个问题:如果这个模块要换一个平台,我改动的范围有多大?如果答案是需要改超过30%的代码,那内聚性大概率出了问题。
- 功能内聚(最好):模块内所有元素共同完成一个单一功能。例如:一个 CRC 校验模块,只做校验。
- 顺序内聚(可接受):模块内元素按顺序执行,前一步输出是后一步输入。例如:数据采集→格式转换→发送。
- 通信内聚(一般):模块内元素操作同一数据。例如:读写同一个结构体的多个函数。
- 偶然内聚(最差):几个不相关的功能被硬塞在一起。例如:一个 util.c 里放了 CRC、延时、LED 闪烁、日志打印。
我在项目中遇到过最典型的反面教材:一个 driver.c 文件,里面既有 I2C 读写函数,又有温度换算公式,还有看门狗喂狗逻辑。每次改温度算法,都要重新编译整个驱动层。这就是典型的低内聚。
2.2 低耦合:模块之间只通过接口说话
低耦合,就是模块之间的依赖要尽可能少、尽可能弱。你想想看,如果模块 A 直接调用了模块 B 的内部全局变量,那 A 和 B 就焊死了。以后想换掉 B,A 也得跟着动手术。
嵌入式里常见的耦合方式,我列了个表:
| 耦合类型 | 表现 | 耦合度 | 我的建议 |
|---|---|---|---|
| 内容耦合 | 一个模块直接修改另一个模块的内部数据 | 极高 | 绝对禁止 |
| 公共耦合 | 多个模块共享同一个全局变量 | 高 | 尽量用接口替代 |
| 控制耦合 | 传递控制标志(如 enum 状态) | 中 | 可接受,但需文档化 |
| 数据耦合 | 只传递基本参数(int, char*) | 低 | 推荐使用 |
| 消息耦合 | 通过消息队列或事件传递 | 最低 | 最理想,但开销稍大 |
2.3 模块划分的边界:怎么切才合理?
这是实际开发中最头疼的问题。模块切得太细,接口满天飞;切得太粗,又回到大泥球。我总结了一套边界划分四步法:
- 按变化频率切:经常一起改的,放一起;很少一起改的,分开。
- 按硬件依赖切:直接操作寄存器的,放底层;只调用 API 的,放上层。
- 按复用潜力切:可能被其他项目复用的,独立成模块。
- 按测试难度切:难以模拟的硬件相关部分,与纯逻辑部分分离。
举个例子。一个典型的嵌入式系统,我习惯这样分层:
应用层(Application)
↑ 调用业务接口
服务层(Service)
↑ 调用抽象接口
抽象层(Abstraction)
↑ 调用驱动接口
驱动层(Driver)
↑ 直接操作寄存器
硬件层(Hardware)
每一层只依赖它的下一层。而且层与层之间,通过结构体函数指针表或者回调注册机制来解耦。这样,换一个 MCU 时,只需要重写驱动层和部分抽象层,上层代码基本不动。
2.4 实战案例:一个温控系统的模块划分
我记得有个温控项目,需求是读取 DS18B20 温度,控制继电器,并通过串口上报数据。我当时的模块划分是这样的:
- driver_ds18b20.c:只负责单总线时序,返回原始温度值(int)。
- driver_relay.c:只负责 GPIO 高低电平控制。
- driver_uart.c:只负责串口字节收发。
- abstraction_temperature.c:调用 driver_ds18b20,做单位换算、滤波、阈值判断。
- abstraction_output.c:封装继电器控制和串口上报的抽象接口。
- service_controller.c:实现 PID 或开关控制逻辑,只调用抽象层接口。
- app_main.c:初始化各模块,启动任务。
这样做的好处是什么?后来客户要求把 DS18B20 换成 NTC 热敏电阻,我只需要新增一个 driver_ntc.c,然后在 abstraction_temperature.c 里切换底层驱动指针。上层控制逻辑一行代码都没改。
2.5 知识结构图:高内聚低耦合的落地框架
下面这张图,是我自己总结的「嵌入式模块划分决策树」,帮你快速判断一个功能应该放在哪一层:
2.6 关于边界,我的三条铁律
最后,分享三条我在团队里定死的规则:
- 禁止跨层调用:应用层不能直接调驱动层。必须经过抽象层。谁违反谁请全组喝奶茶。
- 接口参数必须是基本类型或简单结构体:不要传复杂的内部对象指针。否则耦合度瞬间飙升。
- 每个模块的头文件,只暴露必要的接口:内部函数用 static 隐藏。全局变量能不用就不用。