"两个任务之间发数据,你会用什么?" 面试的时候这个问题很常见。大多数人第一反应是消息队列,第二反应是全局变量加信号量保护。但消息队列这东西,在不同RTOS里实现原理差别其实挺大。今天拿RT-Thread的消息队列出来翻一翻,看看它底层的设计思路。
消息队列不是什么神秘的东西
本质上就是一个环形buffer + 一个链表。发消息就是把数据拷贝到buffer里,收消息就是从buffer里拷贝出来。但细节上,不同RTOS做了不同的取舍。
先看一段代码,这是我们平常用RT-Thread消息队列最常见的写法:
static rt_mq_t mq;
struct msg_t {
uint8_t data[64];
uint16_t len;
};
void sender_entry(void *param)
{
struct msg_t msg = {0};
while (1) {
msg.len = snprintf(msg.data, 64, "心跳包 %d", rt_tick_get());
rt_mq_send(mq, &msg, sizeof(msg));
rt_thread_mdelay(1000);
}
}
void receiver_entry(void *param)
{
struct msg_t msg;
while (1) {
if (rt_mq_recv(mq, &msg, sizeof(msg), RT_WAITING_FOREVER) == RT_EOK) {
rt_kprintf("收到: %s\n", msg.data);
}
}
}
int mq_demo_init(void)
{
mq = rt_mq_create("demo_mq", sizeof(struct msg_t), 10, RT_IPC_FLAG_FIFO);
if (mq) {
rt_thread_create("sender", sender_entry, RT_NULL, 1024, 5, 10);
rt_thread_create("receiver", receiver_entry, RT_NULL, 1024, 5, 10);
}
return 0;
}
这段代码大概的意思:sender每秒钟发一条"心跳包"消息,receiver一直等着收。队列深度是10条,每条最大64字节的数据域加2字节长度域。
关键在这里------消息是怎么存的
RT-Thread的消息队列在内存管理上有一个很有意思的设计。每个消息不是单独malloc出来的,而是在创建队列时一次性地把整块内存分配好。
我们看一下 rt_mq_create 内部大致干了什么:
队列控制块
└─ 消息池(一片连续内存)
├─ 消息头 + 消息体 (msg_size)
├─ 消息头 + 消息体 (msg_size)
├─ ...
└─ 消息头 + 消息体 (msg_size)
↑ ↑
head指针 tail指针
每个消息前面藏了一个 struct rt_mq_message 作为消息头,里面只放了一个 next 指针。发送时从空闲链表中摘一个节点,把数据拷进去,然后挂到接收队列上。接收时反过来。
那FreeRTOS是怎么做的?
FreeRTOS的队列实现其实更直接------它也是预分配连续内存,但它不区分"空闲链表"和"消息链表",而是用了一个更朴素的环形buffer方式。写入时如果buffer满了就阻塞,读取时如果有数据就拷贝出来。FreeRTOS的xQueueSend实际上是把数据拷贝到环形buffer的写指针位置,然后移动写指针。
两者一对比,差别就出来了:
| 特性 | RT-Thread | FreeRTOS |
|---|---|---|
| 存储结构 | 链表 + 预分配节点池 | 环形buffer |
| 消息大小 | 固定(创建时指定) | 固定(创建时指定) |
| 内存连续性 | 连续 | 连续 |
| 插入/取出方式 | 摘节点/挂节点 | 环形buffer移位 |
| 紧急消息 | 支持(rt_mq_urgent) | 不支持原生,需用队列组 |
紧急消息这个设计挺巧妙
RT-Thread提供了 rt_mq_urgent 这个API,可以把消息插到队列最前面。设计思路上它做的事情很简单------不把节点挂到链表尾部,而是挂到头部。但底层实现里,它动的是空闲链表和消息链表之间的节点转移逻辑。
rt_err_t rt_mq_urgent(rt_mq_t mq, const void *buffer, rt_size_t size)
{
// ... 省略了参数检查
/* 从空闲链表取一个节点 */
msg = (struct rt_mq_message *)mq->msg_queue_free;
mq->msg_queue_free = msg->next;
/* 把数据拷进去 */
memcpy(msg + 1, buffer, size);
/* 挂到消息链表头部------这就是"urgent"的实现 */
msg->next = mq->msg_queue_head;
mq->msg_queue_head = msg;
/* 如果之前队列是空的,尾巴也要指向它 */
if (mq->msg_queue_tail == RT_NULL)
mq->msg_queue_tail = msg;
// ... 唤醒等待的任务
}
看到没有,所谓"紧急"就是在链表操作上做文章------正常发是挂tail后面,紧急发是挂head前面。这个思路很简洁,不需要额外的优先级数组或者分类队列。一个链表,两种插入策略。
一个常见的误区
很多人以为RT-Thread的消息队列支持变长消息。因为 rt_mq_send 的第三个参数可以传不同的大小,看起来像是能发不同长度的数据。但仔细看源码会发现------它只是memcpy了传入的长度,而每个消息节点分配的大小是创建时固定的 msg_size。如果你传的长度超过了 msg_size,数据会被截断,而且没有任何错误提示。
/* rt_mq_send 内部 */
memcpy(msg + 1, buffer, size); // size可以小于msg_size,但不能大于
所以如果你需要变长消息,要么把 msg_size 设成最大可能值然后自己管理有效长度(比如前面代码里的 len 字段),要么换一种方案------用内存池自己封装。
聊聊阻塞机制
RT-Thread的消息队列在收发两端都支持阻塞。发的时候如果队列满了,任务会挂到发送挂起链表上;收的时候如果队列空了,任务挂到接收挂起链表上。有意思的是,RT-Thread把这两根链表合在了同一个结构体里,用的是同一个 struct rt_ipc_object 中的链表头。
当一条消息进来时,它会优先看有没有任务在接收链表上等着。有的话直接唤醒而不往队列里放------这是一种零拷贝优化,消息直接从发送者的buffer拷到接收者的buffer,绕过了队列中转。不过实际代码里我印象中RT-Thread并没有完全做这个优化,它还是先往队列里放,再唤醒接收者。FreeRTOS的某些移植版本做了类似优化,但也不是所有平台都支持。
再看看超时
rt_mq_recv 的最后一个参数可以传超时时间,RT_WAITING_FOREVER 表示一直等,RT_NO_WAITING 表示不等,也可以传具体的tick数。内部实现是靠任务挂起到定时器上实现的------将当前任务控制块的thread_timer设置好,然后挂起。定时器到期了再恢复执行。这种设计的好处是,阻塞中的任务不占用CPU,定时器中断回来自然恢复。坏处是,如果系统tick频率很高(比如1000Hz),频繁的超时检查会增加中断开销。
一个有意思的问题:如果我设置了超时10个tick,然后第5个tick时消息来了,任务被唤醒,那定时器怎么处理?RT-Thread的做法是在唤醒路径里调用 _timer_stop 把定时器取消掉。所以不会有"超时到了但任务已经被唤醒了"的双重唤醒问题。
/* 在 rt_mq_recv 内部,收到消息后的处理 */
/* 停止定时器,防止超时 */
rt_timer_control(&(thread->thread_timer), RT_TIMER_CTRL_SET_TIME, &tick);
这是今天想分享的内容。RT-Thread的消息队列设计整体来说思路清晰,链表加预分配的方式让插入和删除操作都是O(1)复杂度,紧急消息的实现也很优雅。你觉得它和FreeRTOS的环形buffer方案,哪种在实际项目中更好用?