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;

这样设计的好处是:

  • 类型安全:每种消息类型对应一个结构体,不会乱
  • 内存紧凑:联合体只占最大成员的大小,不浪费
  • 扩展方便:加新消息类型,只需要加一个新结构体,再往联合体里加一个成员就行
注意:联合体的大小取决于最大的成员。如果你的负载数据可能很大(比如 256 字节的蓝牙数据包),那整个消息体就会很大。这种情况下,可以考虑用指针指向外部缓冲区,消息体里只放指针和长度。

4.4 消息体设计的避坑指南

嗯,这里我要多说几句。消息体设计看着简单,但坑不少。

坑一:字节对齐

嵌入式平台上,结构体有字节对齐的问题。如果你的消息体里既有 uint8_t 又有 uint32_t,那编译器可能会在中间填充空白字节,导致实际大小跟你算的不一样。

我曾经因为这个,在消息队列里分配了固定大小的缓冲区,结果消息体放进去就溢出了,查了半天才发现是对齐搞的鬼。

解决办法:用 #pragma pack(1) 或者手动调整字段顺序,把大的字段放前面。

坑二:消息体拷贝

消息队列里传递消息,通常是把整个消息体拷贝进去。如果消息体太大(比如几百字节),拷贝的开销就很大,尤其是在中断里。

我建议:对于大数据,用“零拷贝”方式——消息体里只放指针,数据放在共享内存里,通过引用计数管理生命周期。

坑三:枚举值冲突

如果多个模块各自定义自己的消息类型,很容易出现枚举值重复。比如模块 A 的 MSG_A_START 是 0x10,模块 B 的 MSG_B_START 也是 0x10,那消息队列就乱套了。

所以,一定要统一管理枚举值,最好放在一个头文件里,所有模块都引用同一个。

4.5 消息体设计的总览图

下面这张图,把消息体的整体结构画出来了。你可以看到,消息类型、长度、源/目标 ID 是“信封”,负载是“信纸”。不同的信纸内容,对应不同的消息类型。

通用消息体结构图 通用消息体结构 ble_message_t(通用消息体) type 消息类型 length 负载长度 src_id 源模块 dst_id 目标模块 payload(联合体) 消息负载 payload 联合体展开 button 按键消息 ble_rx 蓝牙接收 timer 定时器消息 raw[256] 原始字节 联合体大小 = 最大成员大小(256 字节) 消息类型枚举分组 0x00 - 0x0F:系统消息 0x10 - 0x2F:蓝牙协议栈消息 0x30 - 0x4F:应用层消息 0x50 - 0xEF:用户自定义 0xF0 - 0xFF:保留 每个分组之间留空隙,方便扩展 枚举值统一管理,避免冲突

4.6 小结

消息体设计,是消息队列的基石。你想想看,如果消息体定义得乱七八糟,后面所有的任务调度、模块通信都会跟着遭殃。

我个人总结了几条原则:

  • 类型枚举要分组,留出扩展空间
  • 负载用联合体,不同类型不同结构
  • 长度字段不能省,哪怕现在用不上
  • 注意字节对齐,别让编译器坑了你

把这些搞定了,后面设计消息队列和任务调度,就会顺畅很多。