4、事件分发机制:中心化分发器 vs 订阅发布模式
事件分发,说白了就是解决「谁产生了事件,谁该处理它」这个问题。我在嵌入式项目里见过太多因为分发逻辑混乱导致的死锁、丢事件、优先级反转。今天咱们就把两种主流方案掰开揉碎讲清楚。
4.1 中心化分发器:一个调度员管所有
中心化分发器,就像一个总机接线员。所有事件都发给它,它再根据事件类型查表,转发给对应的处理函数。
核心特征:单一入口,统一路由,处理函数不直接感知事件源。
4.1.1 典型实现
// 事件分发器结构
typedef struct {
uint32_t event_id;
void (*handler)(void *data, uint32_t len);
} event_route_t;
// 路由表(静态数组)
static event_route_t route_table[] = {
{EVT_BUTTON_PRESS, button_press_handler},
{EVT_SENSOR_READY, sensor_data_handler},
{EVT_TIMER_EXPIRED, timer_handler},
// ...
};
// 分发函数
void dispatcher_dispatch(uint32_t event_id, void *data, uint32_t len) {
for (int i = 0; i < ROUTE_TABLE_SIZE; i++) {
if (route_table[i].event_id == event_id) {
route_table[i].handler(data, len);
return;
}
}
// 未注册事件——我习惯在这里打一个调试断言
ASSERT(0);
}
这种模式的好处是简单、确定性强。我做过一个工业控制器的项目,所有IO事件都走中心化分发,调试时只要在分发函数里加一个打印,整个系统的事件流就一目了然。
注意:中心化分发器不适合高动态场景。如果事件类型经常增删,或者处理函数需要频繁切换,路由表维护起来会非常痛苦。
4.2 订阅发布模式:谁关心谁订阅
订阅发布模式,说白了就是「广播+订阅」。事件产生者只管发,不关心谁收;处理者只管订阅,不关心谁发。两者完全解耦。
4.2.1 核心机制
- 发布者(Publisher):产生事件,调用发布接口
- 订阅者(Subscriber):注册自己关心的事件类型
- 事件总线(Event Bus):维护订阅关系,负责转发
我的经验:在物联网网关项目中,传感器模块、网络模块、日志模块各自独立,用订阅发布模式后,新增一个传感器类型只需要写订阅代码,完全不用改分发逻辑。
4.2.2 简单实现示例
// 订阅节点
typedef struct sub_node {
uint32_t event_id;
void (*handler)(void *data, uint32_t len);
struct sub_node *next;
} sub_node_t;
// 事件总线
static sub_node_t *sub_list_head = NULL;
// 订阅接口
void event_bus_subscribe(uint32_t event_id, void (*handler)(void*, uint32_t)) {
sub_node_t *node = malloc(sizeof(sub_node_t));
node->event_id = event_id;
node->handler = handler;
node->next = sub_list_head;
sub_list_head = node; // 头插法
}
// 发布接口
void event_bus_publish(uint32_t event_id, void *data, uint32_t len) {
sub_node_t *cur = sub_list_head;
while (cur) {
if (cur->event_id == event_id) {
cur->handler(data, len);
}
cur = cur->next;
}
}
4.3 两种模式对比
| 维度 | 中心化分发器 | 订阅发布模式 |
|---|---|---|
| 耦合度 | 中等(分发器知道所有处理者) | 低(发布者和订阅者互不知晓) |
| 实时性 | 高(查表直接调用) | 中等(链表遍历有开销) |
| 动态性 | 差(路由表需编译时确定) | 好(运行时增删订阅) |
| 调试难度 | 低(单一入口,容易打桩) | 中等(需要追踪订阅关系) |
| 内存开销 | 小(静态数组) | 较大(链表节点动态分配) |
4.4 事件路由表设计
路由表是事件分发的核心数据结构。设计得好,系统清晰;设计得烂,调试地狱。
4.4.1 路由表结构
// 我常用的路由表结构
typedef struct {
uint32_t event_id; // 事件ID
uint8_t priority; // 优先级(0最高)
void *handler; // 处理函数指针
uint32_t timeout_ms; // 超时时间(0表示不超时)
const char *desc; // 描述(调试用)
} route_entry_t;
// 路由表实例
static route_entry_t g_route_table[] = {
{EVT_EMERGENCY_STOP, 0, emergency_handler, 100, "急停事件"},
{EVT_SENSOR_ALARM, 1, alarm_handler, 500, "传感器告警"},
{EVT_UI_REFRESH, 2, ui_refresh_handler, 0, "界面刷新"},
// ...
};
关键设计原则:
- 事件ID用枚举,不要用魔数
- 优先级字段用于中断上下文和任务上下文的分流
- 描述字段在调试时能救命——我曾经靠它定位了一个事件ID冲突的bug
4.4.2 路由查找优化
如果路由表条目超过几十个,线性查找就不太行了。我一般用两种优化:
- 哈希索引:按事件ID哈希到不同的桶,每个桶内链表较短
- 二分查找:路由表按事件ID排序,用二分查找定位
// 二分查找路由表
static route_entry_t *route_find(uint32_t event_id) {
int left = 0, right = ROUTE_TABLE_SIZE - 1;
while (left <= right) {
int mid = (left + right) >> 1;
if (g_route_table[mid].event_id == event_id)
return &g_route_table[mid];
else if (g_route_table[mid].event_id < event_id)
left = mid + 1;
else
right = mid - 1;
}
return NULL;
}
4.5 动态注册与注销
动态注册和注销,是订阅发布模式的核心能力。但嵌入式系统里做动态操作,要格外小心。
4.5.1 注册流程
- 分配订阅节点(从预分配池或堆)
- 填充事件ID和处理函数指针
- 插入订阅链表(注意临界区保护)
- 可选:注册回调通知(比如日志记录)
4.5.2 注销流程
- 从链表中找到目标节点
- 从链表中摘除(注意中断安全)
- 释放节点内存(或归还到池中)
- 清理相关资源(如定时器、缓冲区)
我曾经踩过的坑:在中断服务函数里直接调用注销接口,结果链表操作破坏了临界区,导致系统随机崩溃。后来我改用「延迟注销」——在中断里只标记,等任务上下文再真正执行注销。
4.5.3 安全注销示例
// 延迟注销机制
static volatile bool g_unregister_pending = false;
static uint32_t g_pending_event_id;
void event_bus_unregister_safe(uint32_t event_id) {
// 中断上下文中只标记
if (in_isr()) {
g_unregister_pending = true;
g_pending_event_id = event_id;
return;
}
// 任务上下文中直接执行
event_bus_unregister(event_id);
}
// 在主循环或空闲任务中处理延迟注销
void event_bus_process_pending(void) {
if (g_unregister_pending) {
event_bus_unregister(g_pending_event_id);
g_unregister_pending = false;
}
}
4.6 本章知识结构图
4.7 选型建议
到底用哪种模式?我个人的经验法则:
- 系统事件类型固定(<20种) → 中心化分发器,简单可靠
- 模块间解耦需求高 → 订阅发布模式,扩展性好
- 硬实时要求(中断级响应) → 中心化分发器 + 静态路由表
- 动态加载模块 → 订阅发布模式 + 延迟注销机制
一个小技巧:我经常在项目初期先用中心化分发器快速验证,等模块多了再切换到订阅发布模式。路由表接口设计得通用一些,切换成本其实很低。