4. 队列数据结构设计:如何定义通用消息体?
消息队列这东西,说白了就是任务之间传话的“信使”。你想想看,一个蓝牙协议栈里,GATT 要跟 LL 层说话,LL 层要跟 HCI 说话,要是没有个统一的消息格式,那代码写出来就是一团乱麻。
我个人习惯,在设计消息队列之前,先把消息体定义好。这就好比盖房子先画图纸——图纸画好了,后面砌砖才不慌。
4.1 消息体的核心要素
一个通用消息体,至少得包含三样东西:消息类型、消息长度、消息负载。少了哪个,后面都会出问题。
我在项目中遇到过,有人把消息类型和负载混在一起,结果解析的时候各种 if-else 嵌套,看得人头皮发麻。所以,从一开始就把结构定清楚,比什么都重要。
| 字段 | 作用 | 典型大小 |
|---|---|---|
| 消息类型 | 标识这个消息是干啥的 | 1 字节 / 2 字节 |
| 消息长度 | 负载数据的字节数 | 2 字节 |
| 消息负载 | 实际携带的数据 | 可变(0~N 字节) |
| 可选:源/目标 ID | 谁发的、发给谁 | 1 字节 / 各 1 字节 |
4.2 消息类型枚举设计
消息类型用枚举来定义,这是最直观的做法。但要注意,枚举值不能随便乱排,得留出扩展空间。
我曾经在一个项目里,枚举值从 0 开始排到 50,后来要加一个新类型,发现中间插不进去,只能往后面加,结果代码里一堆“类型 51”、“类型 52”,看着就难受。
所以,我建议这样设计:
/* ble_msg_types.h */
typedef enum {
/* 系统消息:0x00 - 0x0F */
MSG_SYS_INIT = 0x00,
MSG_SYS_RESET = 0x01,
MSG_SYS_ERROR = 0x02,
/* 蓝牙协议栈消息:0x10 - 0x2F */
MSG_BLE_ADV_START = 0x10,
MSG_BLE_ADV_STOP = 0x11,
MSG_BLE_CONNECT = 0x12,
MSG_BLE_DISCONNECT = 0x13,
MSG_BLE_DATA_RX = 0x14,
MSG_BLE_DATA_TX_DONE = 0x15,
/* 应用层消息:0x30 - 0x4F */
MSG_APP_BUTTON_PRESS = 0x30,
MSG_APP_TIMER_EXPIRED = 0x31,
MSG_APP_SENSOR_READY = 0x32,
/* 用户自定义:0x50 - 0xEF */
MSG_USER_BASE = 0x50,
/* 保留:0xF0 - 0xFF */
MSG_RESERVED = 0xF0
} ble_msg_type_t;
你看,我把类型分成了几个段:系统、协议栈、应用、用户、保留。每个段之间留了空隙,以后想加新类型,直接往对应的段里插就行,不会影响已有的枚举值。
4.3 消息负载设计
负载是消息的“肉”。不同类型的消息,负载结构完全不一样。比如“按键按下”消息,负载可能就一个字节的按键编号;而“蓝牙数据接收”消息,负载可能是一包几十字节的数据。
那怎么统一呢?我的做法是:用联合体(union)。
/* ble_message.h */
#include <stdint.h>
/* 各种负载结构 */
typedef struct {
uint8_t button_id;
uint8_t press_duration; /* 单位:10ms */
} msg_app_button_press_t;
typedef struct {
uint8_t conn_handle;
uint8_t data[256];
uint16_t data_len;
} msg_ble_data_rx_t;
typedef struct {
uint32_t timeout_ms;
uint8_t timer_id;
} msg_app_timer_expired_t;
/* 通用消息体 */
typedef struct {
ble_msg_type_t type; /* 消息类型 */
uint16_t length; /* 负载长度 */
uint8_t src_id; /* 源模块 ID */
uint8_t dst_id; /* 目标模块 ID */
union {
msg_app_button_press_t button;
msg_ble_data_rx_t ble_rx;
msg_app_timer_expired_t timer;
uint8_t raw[256]; /* 原始字节,用于未知类型 */
} payload;
} ble_message_t;
这样设计的好处是:
- 类型安全:每种消息类型对应一个结构体,不会乱
- 内存紧凑:联合体只占最大成员的大小,不浪费
- 扩展方便:加新消息类型,只需要加一个新结构体,再往联合体里加一个成员就行
4.4 消息体设计的避坑指南
嗯,这里我要多说几句。消息体设计看着简单,但坑不少。
坑一:字节对齐
嵌入式平台上,结构体有字节对齐的问题。如果你的消息体里既有 uint8_t 又有 uint32_t,那编译器可能会在中间填充空白字节,导致实际大小跟你算的不一样。
我曾经因为这个,在消息队列里分配了固定大小的缓冲区,结果消息体放进去就溢出了,查了半天才发现是对齐搞的鬼。
解决办法:用 #pragma pack(1) 或者手动调整字段顺序,把大的字段放前面。
坑二:消息体拷贝
消息队列里传递消息,通常是把整个消息体拷贝进去。如果消息体太大(比如几百字节),拷贝的开销就很大,尤其是在中断里。
我建议:对于大数据,用“零拷贝”方式——消息体里只放指针,数据放在共享内存里,通过引用计数管理生命周期。
坑三:枚举值冲突
如果多个模块各自定义自己的消息类型,很容易出现枚举值重复。比如模块 A 的 MSG_A_START 是 0x10,模块 B 的 MSG_B_START 也是 0x10,那消息队列就乱套了。
所以,一定要统一管理枚举值,最好放在一个头文件里,所有模块都引用同一个。
4.5 消息体设计的总览图
下面这张图,把消息体的整体结构画出来了。你可以看到,消息类型、长度、源/目标 ID 是“信封”,负载是“信纸”。不同的信纸内容,对应不同的消息类型。
4.6 小结
消息体设计,是消息队列的基石。你想想看,如果消息体定义得乱七八糟,后面所有的任务调度、模块通信都会跟着遭殃。
我个人总结了几条原则:
- 类型枚举要分组,留出扩展空间
- 负载用联合体,不同类型不同结构
- 长度字段不能省,哪怕现在用不上
- 注意字节对齐,别让编译器坑了你
把这些搞定了,后面设计消息队列和任务调度,就会顺畅很多。