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 架构图:事件驱动+消息队列混合架构
下面这张图是我在一个物联网网关项目里实际用过的架构,大家可以参考:
3.7 避坑与最佳实践
最后分享几个我踩过的坑,大家引以为戒:
- 不要在中断里直接发布事件。中断上下文不能阻塞,回调表里万一有个慢操作,系统就崩了。我习惯在中断里只设标志位,由任务去发布事件。
- 事件ID要全局唯一。别两个模块都定义
EVT_DATA_READY,值还一样。建议用枚举统一管理,或者用字符串哈希。 - 队列满时的策略要想好。丢弃最新?丢弃最旧?阻塞等待?没有银弹,取决于业务。日志可以丢,控制指令不能丢。
- 调试时加事件跟踪。我每个项目都会在事件总线上加一个调试钩子,可以打印或记录所有事件流。出问题的时候,回放事件流比看代码快十倍。
个人习惯:我会在事件总线上加一个 event_dump() 函数,把当前所有订阅关系打印出来。调试时跑一下,就知道谁订阅了谁,有没有漏订阅、重复订阅。这个习惯救过我至少三次。
事件驱动和消息队列,说白了就是让模块之间不要直接谈恋爱,通过中间人传话。中间人可能是一张回调表,也可能是一个队列缓冲区。选哪个,看你的资源、实时性要求和任务结构。但不管选哪个,解耦的思路是一致的:发布者不关心谁在听,订阅者不关心谁在说。