4. 事件类型定义:枚举事件、数据结构与优先级
事件驱动架构的核心,说白了就是「事件」本身。你想想看,整个系统都在围着事件转——事件怎么分类?数据长什么样?谁先处理谁?这些问题搞不清楚,后面写再多代码也是白搭。
我个人习惯,在设计任何事件驱动系统之前,先花半小时把事件类型定义清楚。这半小时省不得,我在项目里见过太多因为事件定义混乱导致的「幽灵 bug」——事件发错了、数据对不上、优先级搞反了……嗯,咱们今天就把这些坑一个个填上。
4.1 枚举事件类型:给每个事件一个「身份证」
事件类型用枚举来定义,这是最直接的做法。C 语言的枚举本质就是整数,但咱们不能把它当整数用——每个枚举值必须语义明确,一眼就能看出它代表什么。
核心原则:事件枚举值要具备「自描述性」。看到名字就知道这个事件要干什么,不需要翻文档。
来看一个实际例子。我在做智能家居网关项目时,定义了这样一组事件:
/* 事件类型枚举 */
typedef enum {
/* 系统事件 - 0x00~0x0F */
EVT_SYS_NONE = 0x00, // 空事件,用于初始化
EVT_SYS_INIT = 0x01, // 系统初始化完成
EVT_SYS_TICK = 0x02, // 系统心跳节拍
EVT_SYS_ERROR = 0x03, // 系统级错误
/* 按键事件 - 0x10~0x1F */
EVT_KEY_PRESS = 0x10, // 按键按下
EVT_KEY_RELEASE = 0x11, // 按键释放
EVT_KEY_LONG_PRESS = 0x12, // 长按事件
EVT_KEY_DOUBLE_CLICK = 0x13, // 双击事件
/* 传感器事件 - 0x20~0x2F */
EVT_SENSOR_TEMP_READY = 0x20, // 温度采集完成
EVT_SENSOR_HUMI_READY = 0x21, // 湿度采集完成
EVT_SENSOR_ALARM = 0x22, // 传感器告警
/* 通信事件 - 0x30~0x3F */
EVT_COMM_RX_DONE = 0x30, // 接收完成
EVT_COMM_TX_DONE = 0x31, // 发送完成
EVT_COMM_TIMEOUT = 0x32, // 通信超时
EVT_COMM_ERROR = 0x33, // 通信错误
/* 用户自定义事件 - 0x40~0xFF */
EVT_USER_BASE = 0x40
} event_id_t;
注意看,我用十六进制给事件分了组。0x00~0x0F 留给系统,0x10~0x1F 给按键,以此类推。这样做的好处是:
- 调试方便——看数值就知道是哪类事件
- 扩展容易——每组留了空间,加新事件不会冲突
- 优先级映射简单——后面会讲到,分组天然对应优先级
我的习惯:每个事件枚举值都写注释,哪怕名字已经很清楚。为什么?因为半年后你回来看代码,注释能帮你省 10 分钟回忆时间。
4.2 事件数据结构设计:别让数据「裸奔」
事件类型只是标签,真正干活的是事件携带的数据。我见过有人直接把全局变量塞进事件里,那叫一个酸爽——调试的时候数据被谁改了都查不出来。
事件数据结构,我推荐用「变体联合体」的方式。说白了,就是用一个结构体包住所有可能的数据类型,再用联合体节省空间。
/* 事件数据结构 */
typedef struct {
event_id_t id; // 事件类型
uint8_t priority; // 事件优先级
uint32_t timestamp; // 事件产生时间戳
/* 事件数据联合体 */
union {
/* 按键数据 */
struct {
uint8_t key_id; // 按键编号
uint8_t press_cnt; // 按压次数
} key;
/* 传感器数据 */
struct {
uint8_t sensor_id; // 传感器编号
int32_t value; // 采集值
uint8_t unit; // 单位
} sensor;
/* 通信数据 */
struct {
uint8_t *buffer; // 数据缓冲区指针
uint16_t length; // 数据长度
uint8_t crc_ok; // CRC校验结果
} comm;
/* 通用数据 - 给用户自定义事件用 */
struct {
void *data; // 通用数据指针
uint16_t size; // 数据大小
} generic;
} data;
} event_t;
这个结构体有几个关键点:
- id:事件类型,就是咱们刚才定义的枚举
- priority:优先级,后面单独讲
- timestamp:时间戳,记录事件什么时候产生的。这个字段在调试时特别有用——我曾经靠它抓到一个「事件延迟处理」的 bug
- 联合体 data:不同事件带不同的数据,用联合体节省 RAM。嵌入式设备 RAM 金贵,能省则省
注意:联合体里的指针字段(比如 comm.buffer 和 generic.data)要特别小心。我曾经犯过一个错——事件处理完了,但指针指向的缓冲区被释放了,导致野指针。建议:事件处理完之前,不要释放指针指向的内存。
4.3 事件优先级:谁先谁后,得有个规矩
事件优先级,说白了就是「谁插队谁排队」的问题。裸机系统没有操作系统的优先级抢占,但我们可以用软件实现简单的优先级机制。
我个人习惯把优先级分成 4 级,够用又不复杂:
| 优先级等级 | 数值 | 适用场景 | 举例 |
|---|---|---|---|
| 紧急 | 0 | 必须立即处理,不能延迟 | 系统错误、硬件告警 |
| 高 | 1 | 需要尽快处理,但可以等几个 tick | 按键事件、通信接收 |
| 普通 | 2 | 常规任务,按顺序处理即可 | 传感器数据、状态更新 |
| 低 | 3 | 空闲时处理,不着急 | 日志输出、统计信息 |
优先级数值越小,优先级越高。为什么这么设计?因为后面做优先级比较时,直接用 if (evt1.priority < evt2.priority) 就行了,符合直觉。
避坑指南:我曾经在一个项目中把所有事件都设成同一优先级。结果呢?按键事件被传感器事件淹没了,用户按了键要等好几秒才有反应。所以,优先级一定要根据实时性要求来分配,别偷懒。
4.4 事件优先级与枚举的映射关系
事件类型和优先级之间,最好建立明确的映射关系。我常用的做法是做一个查找表:
/* 事件优先级映射表 */
static const uint8_t event_priority_map[] = {
/* 系统事件 - 高优先级 */
[EVT_SYS_NONE] = 2, // 普通
[EVT_SYS_INIT] = 1, // 高
[EVT_SYS_TICK] = 2, // 普通
[EVT_SYS_ERROR] = 0, // 紧急!
/* 按键事件 - 高优先级 */
[EVT_KEY_PRESS] = 1, // 高
[EVT_KEY_RELEASE] = 1, // 高
[EVT_KEY_LONG_PRESS] = 1, // 高
[EVT_KEY_DOUBLE_CLICK] = 1, // 高
/* 传感器事件 - 普通优先级 */
[EVT_SENSOR_TEMP_READY] = 2, // 普通
[EVT_SENSOR_HUMI_READY] = 2, // 普通
[EVT_SENSOR_ALARM] = 0, // 紧急!
/* 通信事件 - 高优先级 */
[EVT_COMM_RX_DONE] = 1, // 高
[EVT_COMM_TX_DONE] = 2, // 普通
[EVT_COMM_TIMEOUT] = 1, // 高
[EVT_COMM_ERROR] = 0, // 紧急!
};
用数组做映射,查找是 O(1) 的,效率高。注意数组下标就是事件枚举值,所以枚举值要从 0 开始连续递增——这也是为什么我前面用十六进制分组时要留空位。
小技巧:如果枚举值不连续,可以用 switch-case 做映射。但性能会差一点,事件多的场景不推荐。
4.5 本章知识结构图
下面这张图把事件类型、数据结构和优先级的关系串起来了。你看一眼就能明白整个设计思路:
这张图展示了事件定义的三个层次:枚举分组是骨架,数据结构是血肉,优先级映射是规则。三者缺一不可。
4.6 实际项目中的避坑经验
最后分享几个我踩过的坑,希望能帮你少走弯路:
- 枚举值不要用 0 表示有效事件——0 留给 EVT_NONE,用来初始化。否则你分不清是「没事件」还是「第一个事件」
- 联合体里的指针要小心生命周期——事件处理完之前,别释放指针指向的内存。我建议用「事件数据拷贝」而不是「指针传递」,虽然多占点 RAM,但安全
- 优先级不要超过 4 级——级数太多,调度逻辑会变得复杂,而且实际收益不大。4 级足够覆盖大部分场景
- 时间戳用系统 tick 而不是绝对时间——32 位 tick 够用,而且比较方便。绝对时间还要考虑闰年、时区,没必要
重要提醒:事件数据结构定义好之后,不要轻易改动。我在一个项目中改过联合体的成员顺序,结果所有事件处理函数都要重新测试——因为数据偏移量变了。所以,设计阶段多花点时间,把结构定死。
好了,事件类型定义这部分就讲到这里。记住:枚举要分组、结构要清晰、优先级要合理。这三板斧抡好了,事件驱动架构的地基就稳了。