2. 分层架构设计原则:高内聚低耦合在嵌入式中的具体体现,模块划分的边界

好,咱们进入第二章。这一章我打算聊聊分层架构里最核心的两个词——高内聚、低耦合。

你可能在书本上见过无数次了。但说实话,我早年做项目时,对这两个词的理解也就停留在「哦,就是要分开写嘛」。直到有一次,我在一个智能家居网关项目里,把协议解析、数据缓存、业务逻辑全塞在一个文件里……嗯,后来产品要增加一个新的传感器类型,改一个地方崩三处。那之后我才真正明白,什么叫「内聚」,什么叫「耦合」。

2.1 高内聚:一个模块只干一件事,并且干好

高内聚,说白了就是模块内部的功能要紧密相关。一个模块不应该既管数据收发,又管业务决策,还管硬件初始化。

我个人习惯把模块的职责画在一个小框里,问自己一个问题:如果这个模块要换一个平台,我改动的范围有多大?如果答案是需要改超过30%的代码,那内聚性大概率出了问题。

内聚性分级(实用版)
  • 功能内聚(最好):模块内所有元素共同完成一个单一功能。例如:一个 CRC 校验模块,只做校验。
  • 顺序内聚(可接受):模块内元素按顺序执行,前一步输出是后一步输入。例如:数据采集→格式转换→发送。
  • 通信内聚(一般):模块内元素操作同一数据。例如:读写同一个结构体的多个函数。
  • 偶然内聚(最差):几个不相关的功能被硬塞在一起。例如:一个 util.c 里放了 CRC、延时、LED 闪烁、日志打印。

我在项目中遇到过最典型的反面教材:一个 driver.c 文件,里面既有 I2C 读写函数,又有温度换算公式,还有看门狗喂狗逻辑。每次改温度算法,都要重新编译整个驱动层。这就是典型的低内聚。

2.2 低耦合:模块之间只通过接口说话

低耦合,就是模块之间的依赖要尽可能少、尽可能弱。你想想看,如果模块 A 直接调用了模块 B 的内部全局变量,那 A 和 B 就焊死了。以后想换掉 B,A 也得跟着动手术。

嵌入式里常见的耦合方式,我列了个表:

耦合类型 表现 耦合度 我的建议
内容耦合 一个模块直接修改另一个模块的内部数据 极高 绝对禁止
公共耦合 多个模块共享同一个全局变量 高 尽量用接口替代
控制耦合 传递控制标志(如 enum 状态) 中 可接受,但需文档化
数据耦合 只传递基本参数(int, char*) 低 推荐使用
消息耦合 通过消息队列或事件传递 最低 最理想,但开销稍大
避坑指南:我曾经在一个 RTOS 项目里,为了图方便,让任务 A 直接读写任务 B 的局部变量(通过指针暴露)。后来任务 B 的栈空间调整了一次,A 读到的全是乱码。查了整整两天。从那以后,我强制团队所有模块之间只通过函数接口或消息队列通信。

2.3 模块划分的边界:怎么切才合理?

这是实际开发中最头疼的问题。模块切得太细,接口满天飞;切得太粗,又回到大泥球。我总结了一套边界划分四步法:

  1. 按变化频率切:经常一起改的,放一起;很少一起改的,分开。
  2. 按硬件依赖切:直接操作寄存器的,放底层;只调用 API 的,放上层。
  3. 按复用潜力切:可能被其他项目复用的,独立成模块。
  4. 按测试难度切:难以模拟的硬件相关部分,与纯逻辑部分分离。

举个例子。一个典型的嵌入式系统,我习惯这样分层:

应用层(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 里切换底层驱动指针。上层控制逻辑一行代码都没改。

小技巧:模块划分时,可以先用「依赖关系图」画出来。如果发现箭头交叉太多,或者有循环依赖,说明边界没切对。我一般要求团队:依赖图必须是一个有向无环图(DAG)。

2.5 知识结构图:高内聚低耦合的落地框架

下面这张图,是我自己总结的「嵌入式模块划分决策树」,帮你快速判断一个功能应该放在哪一层:

嵌入式模块划分决策树 功能需求 直接操作硬件? 驱动层 抽象层 纯业务逻辑? 服务层 应用层 决策要点: • 操作寄存器/时序 → 驱动层 • 封装硬件差异/提供统一接口 → 抽象层 • 组合多个抽象实现功能 → 服务层 • 任务调度/用户交互 → 应用层

2.6 关于边界,我的三条铁律

最后,分享三条我在团队里定死的规则:

  1. 禁止跨层调用:应用层不能直接调驱动层。必须经过抽象层。谁违反谁请全组喝奶茶。
  2. 接口参数必须是基本类型或简单结构体:不要传复杂的内部对象指针。否则耦合度瞬间飙升。
  3. 每个模块的头文件,只暴露必要的接口:内部函数用 static 隐藏。全局变量能不用就不用。
一句话总结:高内聚让你改一处不影响别处,低耦合让你换一个模块不牵连全局。这两条做到了,你的嵌入式代码就能像乐高积木一样,灵活拼装,随意移植。