3. 消息队列核心API设计:创建/销毁队列、发送消息、接收消息、队列状态查询
消息队列的API设计,是整个事件驱动架构的基石。说白了,你后面所有的模块通信、任务调度、异步处理,都得靠这几个接口撑着。我做了这么多年嵌入式,见过太多项目因为API设计得不好,后面改得痛不欲生。今天咱们就把这五个核心接口——创建、销毁、发送、接收、状态查询——一次讲透。
3.1 队列控制块:一切的基础
在动手写API之前,得先定义好队列的数据结构。我个人习惯用一个结构体来管理队列的所有元信息,这样不管是创建还是查询状态,都方便得很。
/* 队列控制块 */
typedef struct {
uint8_t *buffer; /* 存储消息的环形缓冲区 */
uint32_t msg_size; /* 每条消息的固定大小(字节) */
uint32_t capacity; /* 队列最大消息条数 */
uint32_t head; /* 读指针(出队位置) */
uint32_t tail; /* 写指针(入队位置) */
uint32_t count; /* 当前队列中的消息数量 */
uint32_t peak_count; /* 历史最高使用量(调试用) */
uint32_t overflow_cnt; /* 溢出次数统计 */
uint8_t initialized; /* 初始化标志 */
} mq_t;
嗯,这里要注意:msg_size是固定值。为什么不用变长消息?我在项目中遇到过,变长消息的内存管理太容易出碎片了,尤其是在MCU上。固定大小虽然浪费一点空间,但胜在稳定、可预测。
3.2 创建队列:mqx_create
创建队列,说白了就是给队列控制块分配内存,并且把环形缓冲区准备好。我建议把队列控制块和缓冲区放在一起管理,这样释放的时候不容易漏。
mq_t *mqx_create(uint32_t capacity, uint32_t msg_size) {
mq_t *mq;
/* 参数校验:容量和消息大小必须大于0 */
if (capacity == 0 || msg_size == 0) return NULL;
/* 一次性分配控制块 + 缓冲区 */
mq = (mq_t *)malloc(sizeof(mq_t) + capacity * msg_size);
if (mq == NULL) return NULL;
mq->buffer = (uint8_t *)(mq + 1); /* 缓冲区紧跟在控制块后面 */
mq->msg_size = msg_size;
mq->capacity = capacity;
mq->head = 0;
mq->tail = 0;
mq->count = 0;
mq->peak_count = 0;
mq->overflow_cnt = 0;
mq->initialized = 1;
return mq;
}
initialized 置为1,后面所有操作都先检查这个标志。我曾经在一个项目里忘了初始化队列就直接发送消息,结果写指针乱飞,把整个内存区都踩坏了。从那以后,我每个API入口都加一句 if (!mq->initialized) return ERROR;。
3.3 销毁队列:mqx_destroy
销毁很简单,但容易踩坑。你想想看,如果队列里还有消息没处理完,直接free掉内存,那这些消息就永远丢了。所以销毁前最好做个检查。
int mqx_destroy(mq_t *mq) {
if (mq == NULL || !mq->initialized) return -1;
/* 安全起见:如果队列非空,可以打印警告 */
if (mq->count > 0) {
/* 这里可以记录日志,或者回调用户注册的清理函数 */
printf("[WARN] Destroying queue with %u messages pending\n", mq->count);
}
mq->initialized = 0;
free(mq); /* 注意:控制块和缓冲区是一起分配的,一次free即可 */
return 0;
}
mq->buffer!因为缓冲区是紧跟在控制块后面的,不是独立malloc出来的。如果你分开分配,那就要分开释放,否则内存泄漏。我建议统一分配,省心。
3.4 发送消息:mqx_send
发送消息的核心逻辑就是:把数据拷贝到环形缓冲区的tail位置,然后移动tail指针。如果队列满了怎么办?这就涉及到策略问题了。
int mqx_send(mq_t *mq, const void *msg) {
uint8_t *dest;
if (mq == NULL || !mq->initialized || msg == NULL) return -1;
/* 队列满了? */
if (mq->count >= mq->capacity) {
mq->overflow_cnt++;
return -2; /* 返回队列满的错误码 */
}
/* 计算目标地址 */
dest = mq->buffer + (mq->tail * mq->msg_size);
memcpy(dest, msg, mq->msg_size);
/* 移动tail指针(环形) */
mq->tail = (mq->tail + 1) % mq->capacity;
mq->count++;
/* 更新峰值 */
if (mq->count > mq->peak_count) {
mq->peak_count = mq->count;
}
return 0;
}
你可能会问:为什么不用链表?我在项目中试过链表队列,插入删除确实灵活,但每个节点都要动态分配内存,在中断里根本不敢用。环形缓冲区+固定大小消息,才是嵌入式系统的王道。
3.5 接收消息:mqx_receive
接收就是从head位置取数据。注意:取完数据后,head指针移动,但缓冲区里的旧数据不用清空——下次写入时会直接覆盖。
int mqx_receive(mq_t *mq, void *msg) {
uint8_t *src;
if (mq == NULL || !mq->initialized || msg == NULL) return -1;
/* 队列空了? */
if (mq->count == 0) return -3;
/* 从head位置读取 */
src = mq->buffer + (mq->head * mq->msg_size);
memcpy(msg, src, mq->msg_size);
/* 移动head指针 */
mq->head = (mq->head + 1) % mq->capacity;
mq->count--;
return 0;
}
3.6 队列状态查询:mqx_status
状态查询这个接口,很多人觉得可有可无。但我在实际项目中吃过亏——有一次系统莫名其妙卡死,查了半天才发现是队列满了,发送方一直在重试。从那以后,我每个队列都定期查询状态,提前预警。
typedef struct {
uint32_t capacity; /* 总容量 */
uint32_t count; /* 当前消息数 */
uint32_t free_space; /* 剩余空间 */
uint32_t peak_count; /* 历史峰值 */
uint32_t overflow_cnt; /* 溢出次数 */
uint8_t is_full; /* 是否已满 */
uint8_t is_empty; /* 是否为空 */
} mq_status_t;
int mqx_status(const mq_t *mq, mq_status_t *status) {
if (mq == NULL || !mq->initialized || status == NULL) return -1;
status->capacity = mq->capacity;
status->count = mq->count;
status->free_space = mq->capacity - mq->count;
status->peak_count = mq->peak_count;
status->overflow_cnt = mq->overflow_cnt;
status->is_full = (mq->count >= mq->capacity);
status->is_empty = (mq->count == 0);
return 0;
}
3.7 核心API一览
| API | 功能 | 返回值 | 注意事项 |
|---|---|---|---|
mqx_create |
创建队列 | 队列指针 / NULL | 一次性分配控制块+缓冲区 |
mqx_destroy |
销毁队列 | 0成功 / -1失败 | 销毁前检查是否有未处理消息 |
mqx_send |
发送消息 | 0成功 / -2队列满 | 固定大小消息,memcpy拷贝 |
mqx_receive |
接收消息 | 0成功 / -3队列空 | 非阻塞,空队列立即返回 |
mqx_status |
查询状态 | 0成功 / -1失败 | 返回容量、使用量、峰值等 |
3.8 核心逻辑流程图
下面这张图展示了消息队列的核心工作流程,从创建到发送、接收,再到状态查询和销毁,一目了然。
3.9 避坑指南
最后,分享几个我这些年踩过的坑:
- 中断里调用API要小心: 我曾经在中断服务函数里直接调用
mqx_send,结果主循环也在调用,两个地方同时修改tail指针,数据全乱了。解决方案是加临界区保护,或者用无锁队列。 - 消息大小别算错: 有个同事把
msg_size设成了结构体的大小,但结构体里有指针。结果发送的是指针值,接收方读到的是野指针。记住:队列里只能存值,不能存引用。 - 状态查询别太频繁: 每毫秒查一次状态,CPU全耗在查询上了。我一般建议在关键节点(比如发送失败、定时任务)才查一次。
好了,这五个核心API的设计思路和实现细节就讲到这里。你把这些接口封装好了,后面的事件分发、任务调度就都有了坚实的基础。记住:好的API设计,是让调用者用着舒服,让维护者看着明白。