3. 低耦合设计模式:事件驱动架构、消息队列解耦实战

模块化设计做到一定程度,你会发现一个绕不开的问题:模块之间怎么通信?

直接函数调用?那叫硬耦合。A模块调B模块,B模块改个接口,A就得跟着改。我见过太多项目,明明分了模块,结果耦合得像一团意大利面。今天咱们聊聊两个真正能解耦的武器:事件驱动架构和消息队列。

3.1 为什么需要事件驱动?

先想一个场景:一个智能家居网关,收到温度传感器数据后,需要做三件事——更新本地数据库、推送告警到手机App、控制空调开关。

传统写法是这样的:

void on_temperature_report(float temp) {
    database_update(temp);      // 模块A
    push_alarm_if_needed(temp); // 模块B
    control_ac(temp);           // 模块C
}

问题在哪?on_temperature_report 函数里硬编码了三个模块的调用。哪天要加个新风系统控制?改代码。哪天要移除告警推送?改代码。这就是典型的时序耦合和接口耦合。

事件驱动的思路完全不同:传感器只管发一个“温度上报”事件,谁关心这个事件,谁自己去订阅。发布者不需要知道订阅者是谁,订阅者也不需要知道发布者是谁。

核心思想:模块之间不直接通信,通过事件总线(Event Bus)间接通信。发布者与订阅者完全解耦。

3.2 事件驱动架构的三种实现

我个人习惯把嵌入式里的事件驱动分成三个层级,大家可以根据项目资源选型:

实现方式 资源开销 实时性 适用场景
回调函数表 极低(几KB ROM) 高 裸机、资源受限MCU
发布-订阅中心 中等(10~50KB ROM) 中 RTOS环境、中等复杂度
消息队列 较高(依赖队列深度) 中低 多任务、跨核、分布式

3.3 实战:裸机事件总线(回调表模式)

先看一个我在STM32F0项目里用过的轻量级实现。资源只有8KB RAM,跑不了RTOS,但又要解耦。

// event_bus.h
typedef enum {
    EVT_TEMP_REPORT,
    EVT_HUMIDITY_REPORT,
    EVT_BUTTON_PRESS,
    EVT_ALARM_TRIGGER,
    EVT_MAX
} event_id_t;

typedef void (*event_handler_t)(void *data, uint16_t len);

void event_bus_init(void);
void event_subscribe(event_id_t evt_id, event_handler_t handler);
void event_publish(event_id_t evt_id, void *data, uint16_t len);
// event_bus.c
#define MAX_HANDLERS_PER_EVENT 3

static struct {
    event_handler_t handlers[MAX_HANDLERS_PER_EVENT];
    uint8_t count;
} event_table[EVT_MAX];

void event_subscribe(event_id_t evt_id, event_handler_t handler) {
    if (event_table[evt_id].count < MAX_HANDLERS_PER_EVENT) {
        event_table[evt_id].handlers[event_table[evt_id].count++] = handler;
    }
}

void event_publish(event_id_t evt_id, void *data, uint16_t len) {
    for (uint8_t i = 0; i < event_table[evt_id].count; i++) {
        event_table[evt_id].handlers[i](data, len);
    }
}

使用起来很清爽:

// 模块A:传感器驱动
void sensor_task(void) {
    float temp = read_temperature();
    event_publish(EVT_TEMP_REPORT, &temp, sizeof(temp));
}

// 模块B:数据库模块初始化时订阅
void db_module_init(void) {
    event_subscribe(EVT_TEMP_REPORT, db_on_temp_report);
}

// 模块C:空调控制模块初始化时订阅
void ac_module_init(void) {
    event_subscribe(EVT_TEMP_REPORT, ac_on_temp_report);
}

避坑指南:我曾经在一个项目里把回调表设成单订阅者,结果后来要加第二个订阅者时,改得头皮发麻。建议一开始就预留多订阅者槽位,哪怕当前只用1个。数组长度设成3或5,不差那几十字节。

3.4 消息队列:真正的异步解耦

事件总线虽然解耦了接口,但还有个问题:发布者会阻塞订阅者。如果某个订阅者处理时间很长,整个事件循环就被拖慢了。

消息队列能解决这个问题。发布者把消息丢进队列就返回,订阅者在自己的上下文里慢慢取。说白了,就是空间换时间,异步换解耦。

看一个FreeRTOS下的消息队列实战:

// 定义消息结构
typedef struct {
    event_id_t id;
    uint8_t data[64];
    uint16_t len;
} msg_t;

// 创建队列
QueueHandle_t msg_queue = xQueueCreate(10, sizeof(msg_t));

// 发布者任务
void publisher_task(void *param) {
    msg_t msg;
    msg.id = EVT_TEMP_REPORT;
    float temp = 26.5;
    memcpy(msg.data, &temp, sizeof(temp));
    msg.len = sizeof(temp);
    
    // 非阻塞发送,队列满直接丢弃
    xQueueSend(msg_queue, &msg, 0);
}

// 订阅者任务
void subscriber_task(void *param) {
    msg_t msg;
    while (1) {
        if (xQueueReceive(msg_queue, &msg, portMAX_DELAY) == pdTRUE) {
            switch (msg.id) {
                case EVT_TEMP_REPORT:
                    handle_temp(msg.data, msg.len);
                    break;
                // 其他事件...
            }
        }
    }
}

注意:消息队列不是万能的。队列深度要算好,我见过一个项目队列深度设成5,结果高负载时疯狂丢消息。建议根据最坏情况下的消息积压量,再乘以1.5的安全系数。另外,消息拷贝有开销,大数据量建议传指针+内存池管理。

3.5 事件驱动 vs 消息队列:怎么选?

很多同学会问:这两个到底用哪个?

我的经验是:

  • 同任务内、实时性要求高 → 事件总线(回调表)。比如按键响应、传感器数据更新。
  • 跨任务、跨优先级、允许延迟 → 消息队列。比如日志写入、网络发送、UI刷新。
  • 混合使用 → 事件总线做任务内分发,消息队列做任务间通信。这是最灵活的做法。

3.6 架构图:事件驱动+消息队列混合架构

下面这张图是我在一个物联网网关项目里实际用过的架构,大家可以参考:

事件驱动 + 消息队列混合架构 硬件抽象层 传感器驱动 按键驱动 通信外设 定时器 发布事件 事件总线 EVT_TEMP_REPORT EVT_BUTTON_PRESS EVT_ALARM_TRIGGER 回调表分发 入队 消息队列 Queue: 10 槽 非阻塞发送 阻塞接收 任务间解耦 订阅者A 数据库更新 优先级: 中 订阅者B 告警推送 优先级: 高 订阅者C 空调控制 优先级: 低 出队 架构要点: ① 硬件驱动只发布事件,不关心谁处理 → 接口解耦 ② 事件总线做同任务内快速分发,消息队列做跨任务异步通信 → 时序解耦

3.7 避坑与最佳实践

最后分享几个我踩过的坑,大家引以为戒:

  • 不要在中断里直接发布事件。中断上下文不能阻塞,回调表里万一有个慢操作,系统就崩了。我习惯在中断里只设标志位,由任务去发布事件。
  • 事件ID要全局唯一。别两个模块都定义 EVT_DATA_READY,值还一样。建议用枚举统一管理,或者用字符串哈希。
  • 队列满时的策略要想好。丢弃最新?丢弃最旧?阻塞等待?没有银弹,取决于业务。日志可以丢,控制指令不能丢。
  • 调试时加事件跟踪。我每个项目都会在事件总线上加一个调试钩子,可以打印或记录所有事件流。出问题的时候,回放事件流比看代码快十倍。

个人习惯:我会在事件总线上加一个 event_dump() 函数,把当前所有订阅关系打印出来。调试时跑一下,就知道谁订阅了谁,有没有漏订阅、重复订阅。这个习惯救过我至少三次。

事件驱动和消息队列,说白了就是让模块之间不要直接谈恋爱,通过中间人传话。中间人可能是一张回调表,也可能是一个队列缓冲区。选哪个,看你的资源、实时性要求和任务结构。但不管选哪个,解耦的思路是一致的:发布者不关心谁在听,订阅者不关心谁在说。