4、模块化设计实战:如何划分模块,模块接口设计,模块间通信机制

模块化设计,说白了就是「分而治之」。把一个大系统拆成若干小块,每块干好自己的事,块与块之间只通过明确的接口打交道。这个道理谁都懂,但真正落地的时候,我见过太多项目栽在「模块划分不合理」这个坑里。

今天咱们就聊聊实战中的三个核心问题:怎么切模块、接口怎么定、模块之间怎么说话。

4.1 模块划分的原则与方法

模块划分不是拍脑袋。我个人的习惯是,先画一张系统功能树,把所有的功能点列出来,然后按「内聚性」和「耦合度」这两个指标去切。

核心原则:高内聚、低耦合

  • 高内聚:一个模块内部的功能要紧密相关,最好只做一件事。
  • 低耦合:模块之间的依赖要尽量少,接口要尽量简单。

具体怎么操作?我一般用三步法:

  1. 按功能域划分:比如传感器采集、数据处理、通信协议、用户界面、日志管理。每个功能域独立成一个模块。
  2. 按硬件边界划分:不同的外设、不同的中断源、不同的内存区域,天然就是模块的边界。我在一个项目中曾经把I2C驱动和SPI驱动混在一个文件里,后来调试的时候差点崩溃——从那以后,每个外设一个独立模块,绝不混用。
  3. 按变化频率划分:把容易变化的部分(比如业务逻辑)和不容易变化的部分(比如底层驱动)分开。这样改业务的时候不会动到底层,改驱动的时候不会影响到业务。

举个例子,一个典型的嵌入式系统可以这样切:

模块名称 职责 对外接口
Sensor_Manager 管理所有传感器,采集原始数据 Sensor_Read(), Sensor_Init()
Data_Processor 滤波、校准、特征提取 Process_Data()
Comm_Protocol 封装/解析通信协议帧 Pack_Frame(), Parse_Frame()
Task_Scheduler 任务调度与时间管理 Task_Create(), Task_Delay()
Log_Manager 日志记录与调试输出 Log_Write(), Log_Dump()

你看,每个模块的职责都很清晰,接口也很简单。这就是高内聚的体现。

4.2 模块接口设计:头文件就是合同

模块接口设计,说白了就是定义好「头文件」。头文件就是模块对外承诺的合同——你提供什么功能,别人怎么调用你,都在这里写清楚。

我见过很多新手把实现细节也塞进头文件,比如内部结构体、私有函数声明。这其实是个大坑。你想想看,一旦别人依赖了你的内部细节,你以后想改实现就难了。

接口设计三原则:

  • 最小暴露原则:头文件里只放别人需要的东西。内部结构体、静态函数、局部宏,统统藏起来。
  • 稳定接口原则:接口一旦发布,尽量不要改。实在要改,必须做好版本兼容。
  • 自文档化原则:接口的注释要写清楚参数含义、返回值、使用前提、注意事项。

来看一个实际的头文件例子:

/* sensor_manager.h */
#ifndef SENSOR_MANAGER_H
#define SENSOR_MANAGER_H

#include <stdint.h>

/* 传感器类型枚举 */
typedef enum {
    SENSOR_TEMP,
    SENSOR_HUMID,
    SENSOR_PRESSURE
} SensorType_t;

/* 传感器数据结果 */
typedef struct {
    SensorType_t type;
    float value;
    uint32_t timestamp;
} SensorData_t;

/* 初始化传感器模块,返回0成功,-1失败 */
int32_t Sensor_Init(void);

/* 读取指定传感器的最新数据 */
int32_t Sensor_Read(SensorType_t type, SensorData_t *data);

/* 启动/停止传感器采集 */
void Sensor_Start(void);
void Sensor_Stop(void);

#endif /* SENSOR_MANAGER_H */

这个头文件只暴露了三个函数和一个结构体。内部怎么采集、怎么缓存、怎么处理中断,调用者完全不需要知道。这就是好的接口设计。

4.3 模块间通信机制:选对工具很重要

模块之间怎么通信?这取决于你的系统是单线程还是多线程,是裸机还是RTOS。我总结了几种常见的通信机制,各有各的适用场景。

4.3.1 全局变量:简单但危险

全局变量是最直接的通信方式。两个模块共享一个变量,一个写一个读,完事。

但是——这里我要敲黑板——全局变量是耦合度的头号杀手。你想想看,一旦用了全局变量,模块A和模块B就绑死了。改一个模块,另一个模块可能莫名其妙地出问题。

全局变量使用守则:

  • 只在同一个模块内部使用全局变量,不要跨模块共享。
  • 如果必须跨模块共享,用getter/setter函数封装,不要直接extern。
  • 多任务环境下,全局变量必须加锁保护。

我曾经在一个项目中接手过别人的代码,里面有一个全局变量叫g_Status,被十几个模块读写。每次改代码都提心吊胆,生怕改了一个地方把别的地方搞崩了。后来我花了两周时间,把所有全局变量都改成了消息队列——虽然工作量大了点,但系统的稳定性明显提升了。

4.3.2 消息队列:最推荐的通信方式

消息队列是我个人最推荐的方式。它天然解耦——发送方只管发,接收方只管收,双方不需要知道对方的存在。

在RTOS环境下,消息队列几乎是标配。比如FreeRTOS的xQueueSend()和xQueueReceive(),用起来非常方便。

/* 创建消息队列 */
QueueHandle_t xDataQueue = xQueueCreate(10, sizeof(SensorData_t));

/* 发送方:传感器模块 */
void Sensor_Task(void *pvParameters) {
    SensorData_t data;
    while(1) {
        data = Read_Sensor();
        xQueueSend(xDataQueue, &data, portMAX_DELAY);
    }
}

/* 接收方:数据处理模块 */
void Process_Task(void *pvParameters) {
    SensorData_t data;
    while(1) {
        if(xQueueReceive(xDataQueue, &data, pdMS_TO_TICKS(100)) == pdPASS) {
            Process_Data(&data);
        }
    }
}

你看,传感器模块和数据处理模块之间只通过一个队列通信。以后想换传感器、换处理算法,只要接口不变,两个模块互不影响。

4.3.3 信号量:用于同步和互斥

信号量不是用来传数据的,它是用来「打招呼」的。比如一个模块告诉另一个模块「我干完了,你可以开始了」,或者「这个资源我正在用,你先等着」。

信号量分两种:

  • 二值信号量:相当于一个标志位,用于任务同步。比如中断服务程序发信号量,任务等信号量。
  • 计数信号量:用于管理多个资源。比如有5个缓冲区,每次用掉一个就减1,释放一个就加1。

信号量使用场景:

  • 中断与任务之间的同步
  • 多个任务对共享资源的互斥访问
  • 生产者-消费者模型中的缓冲区管理

4.3.4 共享内存:高性能但高风险

共享内存适合大数据量的传递,比如摄像头采集的图像数据。如果每次都用消息队列拷贝一份,性能开销太大了。

共享内存的玩法是:两个模块约定好一块内存区域,一个写一个读。但这里有个关键问题——同步。写的时候不能读,读的时候不能写,否则就会出现数据不一致。

我一般用「双缓冲」或者「环形缓冲区」来解决这个问题。双缓冲就是准备两块内存,一块给写模块,一块给读模块,写满了就交换。环形缓冲区则是一个固定大小的循环队列,写指针和读指针各自维护。

/* 环形缓冲区示例 */
#define BUF_SIZE 1024

typedef struct {
    uint8_t buffer[BUF_SIZE];
    volatile uint32_t head;  /* 写指针 */
    volatile uint32_t tail;  /* 读指针 */
} RingBuffer_t;

/* 写入一个字节 */
int32_t RingBuf_Write(RingBuffer_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) % BUF_SIZE;
    if(next == rb->tail) {
        return -1;  /* 缓冲区满 */
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return 0;
}

/* 读取一个字节 */
int32_t RingBuf_Read(RingBuffer_t *rb, uint8_t *data) {
    if(rb->head == rb->tail) {
        return -1;  /* 缓冲区空 */
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % BUF_SIZE;
    return 0;
}

这个环形缓冲区不需要加锁——只要保证只有一个写者和一个读者,就不会有竞争问题。嗯,这里要注意,如果多个任务同时写,那就必须加锁了。

4.4 通信机制选型对比

为了让你看得更清楚,我把四种通信机制放在一起对比:

通信机制 数据量 耦合度 同步需求 适用场景
全局变量 小 高 需要加锁 简单状态共享,不推荐跨模块
消息队列 中 低 自带同步 任务间数据传递,最推荐
信号量 无 低 自带同步 任务同步、资源管理
共享内存 大 中 需要手动同步 大数据量传递,如音视频

4.5 模块化设计知识体系

下面这张图总结了本章的核心知识结构,你可以对照着梳理自己的设计思路:

模块化设计知识体系 模块划分原则 按功能域划分 按硬件边界划分 按变化频率划分 模块接口设计 最小暴露原则 稳定接口原则 自文档化原则 模块间通信机制 全局变量 消息队列 信号量 共享内存

这张图从顶层到底层,把模块化设计的三个层次串起来了。你设计系统的时候,可以按照这个顺序来思考:先划分模块,再设计接口,最后选通信机制。

实战建议:

  • 新项目起步时,花30%的时间做模块划分和接口设计,不要急着写代码。
  • 模块划分完成后,先写头文件,再写实现。头文件就是你的设计文档。
  • 通信机制优先选消息队列,除非有明确的性能瓶颈,再考虑共享内存。

好了,这一章的内容就到这里。模块化设计不是一蹴而就的,需要在实践中不断打磨。你动手做几个项目,踩过几次坑,自然就找到感觉了。