模块化设计:高内聚低耦合的实战原则

模块化设计,说白了就是「拆」的艺术。但怎么拆、拆到什么粒度、接口怎么定,才是真正考验功力的地方。我见过太多项目,一开始拍脑袋分了几个文件,写到后面全成了「上帝模块」——一个文件几千行,谁都不敢动。

这一章,我们聊聊嵌入式里真正能落地的模块化原则。不是教科书上的空话,而是我在几个量产项目里踩过坑之后总结出来的实战经验。

接口设计:模块之间的「合同」

接口是模块之间唯一的沟通渠道。接口设计得好,两边各改各的,互不干扰。设计得不好,改一个地方,牵连一片。

我个人习惯把接口分为三类:

接口类型 典型形式 耦合度 适用场景
函数调用接口 API 函数、回调函数 低 模块间同步调用
消息/事件接口 队列、信号量、事件标志 极低 异步通信、任务间解耦
共享数据接口 全局变量、共享内存 高 尽量避免,必要时加保护

我在项目中遇到过最典型的反面教材:一个温控模块直接读取另一个电机模块的内部结构体变量。后来电机模块改了数据结构,温控那边编译直接报错,查了半天才发现是耦合太紧。

接口设计三原则:

  • 最小化——只暴露必须暴露的东西,能隐藏的全隐藏
  • 稳定性——接口一旦发布,尽量不做破坏性修改
  • 自文档化——看函数名和参数就知道怎么用,不需要翻文档

来看一个实际例子。这是某传感器驱动模块的接口头文件:

/* sensor_driver.h —— 只暴露用户需要的接口 */
#ifndef SENSOR_DRIVER_H
#define SENSOR_DRIVER_H

#include <stdint.h>
#include <stdbool.h>

/* 初始化传感器,返回是否成功 */
bool sensor_init(void);

/* 读取温度值,单位 0.1°C,失败返回 INT32_MAX */
int32_t sensor_read_temperature(void);

/* 读取湿度值,单位 0.1%RH,失败返回 INT32_MAX */
int32_t sensor_read_humidity(void);

/* 设置采样间隔,单位 ms */
void sensor_set_interval(uint32_t interval_ms);

#endif /* SENSOR_DRIVER_H */

注意看,这个头文件里没有暴露任何内部寄存器地址、I2C 读写函数、状态机变量。用户模块只需要知道「初始化、读温度、读湿度、设间隔」这四个操作就够了。至于底层用的是 I2C 还是 SPI,是轮询还是中断,那是模块内部的事。

小技巧:接口头文件里不要 include 内部实现用的头文件。如果用户模块因为 include 了你的接口,被迫也 include 了一堆底层驱动头文件,说明接口设计已经失败了。

模块边界划分:切在哪,怎么切

模块边界划分是架构设计中最难的一步。切得太粗,模块内部还是一团乱麻;切得太细,接口数量爆炸,管理成本飙升。

我一般遵循「业务语义优先,硬件边界辅助」的原则。什么意思?

  • 业务语义优先:一个模块应该对应一个完整的业务能力。比如「温控模块」负责温度采集、控制算法、执行器驱动,这三件事天然属于同一个业务域。
  • 硬件边界辅助:如果两个功能跑在不同的硬件外设上(比如 UART 和 SPI),那它们天然适合分模块。但如果跑在同一个外设上(比如同一个定时器既做 PWM 又做输入捕获),就要看业务上是否应该拆开。

举个例子。我曾经接手过一个智能家居网关项目,原来的代码把所有通信协议(Wi-Fi、Zigbee、蓝牙)都写在一个文件里。看起来都是「通信」,但业务上完全不一样:Wi-Fi 负责跟云端交互,Zigbee 负责跟传感器通信,蓝牙负责本地调试。我把它们拆成三个独立模块,每个模块只负责自己的协议栈和数据处理。后来 Wi-Fi 模块要升级协议版本,只改了 3 个文件,其他模块零影响。

避坑指南:我曾经犯过一个错误——为了「复用」把两个业务逻辑完全不同的模块硬塞在一起。结果每次改需求都要在两个业务逻辑之间做「隔离手术」,比重新写还痛苦。记住:复用是结果,不是目标。不要为了复用而强行合并。

单一职责原则在嵌入式中的落地

单一职责原则(SRP)说:一个模块只应该有一个引起它变化的原因。在嵌入式里,这句话可以翻译成:一个模块只做一件事,并且把这件事做好。

但落地的时候,很多人会问:「一个函数算一个模块吗?一个文件算一个模块吗?」

我的答案是:模块的粒度取决于你的项目规模和团队分工。

项目规模 模块粒度 典型模块数 单人维护代码量
小项目(< 10K 行) 一个 .c/.h 对 5-10 < 2000 行
中项目(10K-100K 行) 一个文件夹 10-30 < 5000 行
大项目(> 100K 行) 一个子目录树 30+ < 10000 行

来看一个违反 SRP 的典型例子:

/* 坏例子:一个模块干了三件事 */
void system_manager_task(void *param)
{
    /* 1. 读取传感器数据 */
    sensor_read(&temp, &humidity);

    /* 2. 控制继电器 */
    if (temp > 30) {
        relay_on();
    }

    /* 3. 通过 Wi-Fi 上报数据 */
    wifi_send(temp, humidity);

    /* 4. 更新 OLED 显示 */
    oled_display(temp, humidity);
}

这个函数看起来「功能完整」,但仔细想想:传感器、继电器、Wi-Fi、OLED,这四个东西任何一个发生变化,都要改这个函数。这就是四个变化原因耦合在一起。

重构之后:

/* 好例子:每个模块只做自己的事 */
void sensor_task(void *param)
{
    sensor_read(&temp, &humidity);
    data_queue_send(temp, humidity);
}

void control_task(void *param)
{
    data_queue_receive(&temp, &humidity);
    if (temp > 30) {
        relay_on();
    }
}

void report_task(void *param)
{
    data_queue_receive(&temp, &humidity);
    wifi_send(temp, humidity);
}

void display_task(void *param)
{
    data_queue_receive(&temp, &humidity);
    oled_display(temp, humidity);
}

每个任务只做一件事,通过消息队列解耦。传感器模块改了,不影响其他三个;Wi-Fi 协议升级了,只改 report_task。这就是 SRP 带来的好处。

判断是否违反 SRP 的简单方法:当你需要用一个「和」字来描述一个模块的职责时,大概率已经违反了。比如「这个模块负责读取传感器和控制继电器和上报数据」——三个「和」,三个变化原因。

模块化设计核心逻辑图

下面这张图展示了模块化设计的核心逻辑:从业务需求出发,经过接口定义、边界划分、职责分配,最终形成高内聚低耦合的模块结构。

模块化设计核心逻辑 业务需求分析 接口最小化原则 业务语义边界 单一职责分配 定义接口契约 划分模块边界 分配模块职责 高内聚低耦合模块结构 可独立修改 可独立测试 可独立复用 核心思想:每个模块只做一件事,通过稳定的接口与其他模块协作,内部实现对外完全隐藏

实战中的几个判断标准

说了这么多原则,怎么判断自己的模块化设计做得好不好?我总结了几个简单的检查清单:

  1. 修改一个需求,改几个文件?理想情况是 1-2 个。如果超过 3 个,说明耦合太紧。
  2. 能不能单独测试这个模块?如果为了测试一个模块,需要把整个系统都跑起来,说明边界没划好。
  3. 换一个硬件平台,要改多少代码?硬件相关代码应该集中在少数几个模块里,业务逻辑应该与硬件无关。
  4. 新同事看代码,能不能快速找到某个功能在哪?模块命名和目录结构应该直观到「一看就知道在哪」。

一个实用技巧:每次新建一个 .c 文件之前,先问自己三个问题:这个文件要做什么?它依赖哪些其他模块?其他模块依赖它什么?如果答不上来,说明边界还没想清楚,先别急着写代码。

模块化设计不是一蹴而就的。我自己的经验是:第一版往往分得不够好,但随着对业务理解的加深,会逐步重构出更合理的边界。关键是要有「随时可以重构」的信心——而这份信心,正来自于良好的接口设计和清晰的模块边界。