线程间通信_消息队列
260.2.11线程间通信_消息队列
#include <rtthread.h> // 引入 RT-Thread 的线程、消息队列、延时和内核打印接口
#define DBG_TAG "main" // 设置 rtdbg 日志标签为 main
#define DBG_LVL DBG_LOG // 设置 rtdbg 日志级别
#include <rtdbg.h> // 引入 LOG_E 等 RT-Thread 调试日志宏
rt_thread_t th1_ptr,th2_ptr; // 定义两个动态线程句柄:th1_ptr 用于接收,th2_ptr 用于发送
rt_mq_t mq_ptr; // 定义动态消息队列句柄,后续保存 rt_mq_create() 的返回值
struct msg // 定义本实验每条消息采用的业务结构体类型
{ // struct msg 结构体定义开始
char str[100]; // 分配 100 字节字符数组,用于保存以 '\0' 结尾的字符串
uint8_t len; // 用一个 8 位无符号整数记录字符串中可见字符的数量
}; // struct msg 结构体定义结束
char str1[]="12345678"; // 定义具有全局生命周期的第一个待发送字符串,长度为 8
char str2[]="67890"; // 定义具有全局生命周期的第二个待发送字符串,长度为 5
void th1_recv(void *parameter) // 定义线程 th1 的入口函数,负责从消息队列接收结构体副本
{ // th1_recv 函数体开始
rt_err_t ret; // 保存课程源码中 rt_mq_recv() 返回的接收长度或负错误码
struct msg mq_msg; // 在接收线程栈中创建一个结构体,作为消息接收缓冲区
while(1) // 接收线程永久循环,持续等待并处理新消息
{ // 接收循环体开始
ret = rt_mq_recv(mq_ptr, &mq_msg,sizeof(struct msg), RT_WAITING_FOREVER); // 永久等待一条消息,并把数据复制到 mq_msg
if(ret > 0) // 返回正数表示成功收到相应字节数的数据
{ // 接收成功分支开始
rt_kprintf("rt_mq_recv is %s the len is %d!\r\n" ,mq_msg.str,mq_msg.len); // 打印结构体中的字符串及其字符长度
}else{ // 接收失败分支开始
LOG_E("mq rt_mq_recv failed!\r\n"); // 记录消息队列接收失败信息
} // 接收结果判断结束
} // 接收循环体结束
} // th1_recv 函数体结束
void th2_send(void *parameter) // 定义线程 th2 的入口函数,负责组装并发送结构体消息
{ // th2_send 函数体开始
rt_err_t ret; // 保存消息队列发送接口返回的 RT-Thread 错误码
struct msg mq_msg; // 在发送线程栈中创建待发送结构体
while(1) // 发送线程永久循环,交替发送两组内容
{ // 发送循环体开始
strcpy(mq_msg.str,str1); // 把字符串 str1 连同结尾 '\0' 复制到结构体字符数组
mq_msg.len=strlen(mq_msg.str); // 计算 str1 的可见字符数并保存到 len,本次为 8
ret = rt_mq_send(mq_ptr, &mq_msg, sizeof(struct msg)); // 把整个 mq_msg 的字节副本发送到消息队列
if(ret != RT_EOK) // 判断第一条结构体消息是否发送失败
{ // 第一次发送失败分支开始
LOG_E("mb rt_mb_send failed!\r\n"); // 保留课程源码的日志文字;这里实际调用的是消息队列发送接口
} // 第一次发送结果判断结束
rt_thread_mdelay(1000); // 让发送线程延时约 1000 ms
strcpy(mq_msg.str,str2); // 把字符串 str2 连同结尾 '\0' 复制到同一个结构体字符数组
mq_msg.len=strlen(mq_msg.str); // 计算 str2 的可见字符数并保存到 len,本次为 5
ret = rt_mq_send(mq_ptr, &mq_msg, sizeof(struct msg)); // 再把整个 mq_msg 的当前字节内容复制进队列
if(ret != RT_EOK) // 判断第二条结构体消息是否发送失败
{ // 第二次发送失败分支开始
LOG_E("mb rt_mb_send failed!\r\n"); // 保留课程源码由邮箱例程复制而来的错误日志文字
} // 第二次发送结果判断结束
rt_thread_mdelay(1000); // 再延时约 1000 ms,然后进入下一轮发送
} // 发送循环体结束
} // th2_send 函数体结束
int main(void) // RT-Thread 用户主线程入口函数
{ // main 函数体开始
int count = 1; // 初始化课程用于维持主线程循环的计数变量
mq_ptr = rt_mq_create("mq", sizeof(struct msg),4, RT_IPC_FLAG_FIFO); // 创建单条上限为一个 msg、最多缓存 4 条消息的动态队列
if(mq_ptr == RT_NULL) // 判断消息队列控制块或消息池是否分配失败
{ // 消息队列创建失败分支开始
LOG_E("mq_ptr rt_mq_create failed!\r\n"); // 记录动态消息队列创建失败信息
return -RT_ENOMEM; // 返回内存不足错误,停止后续线程创建
} // 消息队列创建结果判断结束
th1_ptr = rt_thread_create("th1", th1_recv, NULL, 1024, 7, 10); // 创建栈为 1024 字节、优先级为 7、时间片为 10 tick 的接收线程
if(th1_ptr == RT_NULL) // 判断接收线程是否创建失败
{ // 接收线程创建失败分支开始
LOG_E("th1_recv rt_thread_create failed!\r\n"); // 记录接收线程创建失败信息
return -RT_ENOMEM; // 返回内存不足错误,停止后续初始化
} // 接收线程创建结果判断结束
rt_thread_startup(th1_ptr); // 启动接收线程;队列为空时它会阻塞等待
th2_ptr = rt_thread_create("th2", th2_send, NULL, 1024, 8, 10); // 创建栈为 1024 字节、优先级为 8、时间片为 10 tick 的发送线程
if(th2_ptr == RT_NULL) // 判断发送线程是否创建失败
{ // 发送线程创建失败分支开始
LOG_E("th2_send rt_thread_create failed!\r\n"); // 记录发送线程创建失败信息
return -RT_ENOMEM; // 返回内存不足错误,停止后续初始化
} // 发送线程创建结果判断结束
rt_thread_startup(th2_ptr); // 启动发送线程,使其开始交替投递两组结构体数据
while (count++) // 课程用自增变量维持主线程循环,通常不会主动退出
{ // 主线程循环体开始
//LOG_D("Hello RT-Thread!"); // 课程把周期日志注释掉,避免干扰消息队列实验输出
rt_thread_mdelay(1000); // 主线程每次延时约 1000 ms,让其他线程和 shell 正常运行
} // 主线程循环体结束
return RT_EOK; // 理论上的正常返回值;本例通常不会执行到这里
} // main 函数体结束
源码说明: 上方是课程配套
10MQ工程最终的applications/main.c业务代码。除去文件头版权说明和原有说明性注释后,结构体、变量、字符串、函数、参数、调用顺序、错误日志以及被注释掉的LOG_D均按课程源码保留,只增加逐行初学者注释;没有补写 RT-Thread 消息队列的库函数实现。两处发送失败日志仍写着mb rt_mb_send failed,这是由上一节邮箱工程复制代码后遗留的提示文字,实际调用不受影响,因此按“源码原样保留”要求没有改写。
一、线程间通信
线程间通信用于让不同线程传递数据或通知。本节在上一课邮箱的基础上学习消息队列:发送线程把一段数据复制进内核队列,接收线程再从队列中取走并复制到自己的缓冲区。发送和接收不必在同一时刻发生,未被接收的数据可以暂存在有限数量的消息槽中。
1.1 消息队列
消息队列可以理解为邮箱的扩展。邮箱每封邮件只能保存一个机器字,而消息队列的一条消息可以包含多个字节,因此能直接传递数组、结构体或协议数据帧。
“可以传递不定长数据”不能理解为每条消息没有长度上限。准确含义是:
- 创建消息队列时,用
msg_size固定该队列单条消息的最大长度。 - 每次发送时再用
size指定本条消息的实际长度,只要满足0 < size <= msg_size即可。 - 不同次发送可以使用不同的实际长度,但都不能超过创建时的上限。
- 队列会保存每条消息的实际长度,RT-Thread 5.0.2 的接收接口会返回实际复制的字节数。
本例没有发送指针,而是直接复制 struct msg:
th2_send 的局部 mq_msg 内核消息队列 mq(最多 4 条) th1_recv 的局部 mq_msg
[str + len] ──rt_mq_send──> [副本1 | 副本2 | 空 | 空] ──rt_mq_recv──> [str + len]
复制结构体字节 再复制到接收缓冲区
因此,th2_send 下一轮修改自己的局部变量不会改变已经入队的消息;这与上一课邮箱只保存指针地址有本质区别。
本节开头还补充了上一课未实验的 rt_mb_urgent():它把紧急邮件放到邮箱队头,使接收者优先取得。消息队列也有对应的 rt_mq_urgent(),原理相同;紧急发送只是改变队列位置,并不会突破容量限制。课程本节只口头介绍,没有把紧急发送写进最终业务代码,因此文首源码不作补充。
1.1.1 rt_mq_create函数
动态创建消息队列的接口为:
rt_mq_t rt_mq_create(const char *name,
rt_size_t msg_size,
rt_size_t max_msgs,
rt_uint8_t flag);
| 参数 | 本例 | 准确含义 |
|---|---|---|
name | "mq" | 内核对象名称,用于对象管理和调试 |
msg_size | sizeof(struct msg) | 每条消息允许占用的最大字节数 |
max_msgs | 4 | 队列最多同时缓存 4 条尚未取走的消息 |
flag | RT_IPC_FLAG_FIFO | 多个线程阻塞在该 IPC 对象上时,按等待先后排列 |
这里再次要区分两种“FIFO”:
- 普通消息本身由队尾进入、从队头取出,所以按先发送先接收排列;
rt_mq_urgent()可以例外地插到队头。 RT_IPC_FLAG_FIFO控制的是多个阻塞等待线程的排列和唤醒顺序,不是用来开启消息的先进先出属性。若改为RT_IPC_FLAG_PRIO,等待线程按优先级排列。
struct msg 包含两个成员:
struct msg
{
char str[100];
uint8_t len;
};
str[100]最多安全容纳 99 个普通字符和末尾的\0。len保存strlen()得到的可见字符数,不包含结尾\0。sizeof(struct msg)是整个结构体占用的字节数,不等于当前字符串长度;队列据此为每个消息槽预留空间。- 内核还会按
RT_ALIGN_SIZE对消息数据区进行对齐,并为每个槽附加内部消息头,所以实际分配的消息池会大于简单的sizeof(struct msg) × 4。
本例使用动态对象,rt_mq_create() 会从内核堆分配消息队列控制块和消息池,任一分配失败都返回 RT_NULL。消息队列也有静态与动态两套配对接口:
| 对象形式 | 建立 | 释放 | 内存来源 |
|---|---|---|---|
| 动态消息队列 | rt_mq_create() | rt_mq_delete() | 内核动态堆 |
| 静态消息队列 | rt_mq_init() | rt_mq_detach() | 调用者提供控制块和消息池 |
课程提到消息队列具有“创建、发送、接收、删除”四类操作,但本例对象需要在系统运行期间一直存在,最终代码没有调用 rt_mq_delete(),这里也不能擅自补入。真正删除前,必须确保其他执行路径不会继续访问已经失效的句柄。
配套 10MQ 工程使用 RT-Thread 5.0.2,系统 tick 频率为 1000 Hz,启用了消息队列、动态堆和 UART1 控制台。本实验没有新增外部硬件或电路;串口只负责输出观察结果。
1.1.2 rt_mq_send函数
普通消息发送接口为:
rt_err_t rt_mq_send(rt_mq_t mq, const void *buffer, rt_size_t size);
| 参数 | 本例 | 含义 |
|---|---|---|
mq | mq_ptr | 目标消息队列句柄 |
buffer | &mq_msg | 待复制数据的起始地址,不是结构体对象本身的值 |
size | sizeof(struct msg) | 本次需要复制进队列的字节数 |
发送时,内核从空闲消息槽链表中取出一个槽,把 buffer 指向的前 size 个字节复制进去,然后将该槽接到消息队列尾部。因为执行的是内容复制,所以本例可以安全地发送线程局部变量 mq_msg;rt_mq_send() 返回后,队列中的副本不再依赖这个局部变量的地址。
返回值需要区分:
RT_EOK:发送成功。-RT_EFULL:队列的 4 个槽都已占用;普通rt_mq_send()不等待空位,立即失败。-RT_ERROR:本次size超过创建时的msg_size等发送错误。
若希望队列满时让线程等待空位,可以使用带超时的 rt_mq_send_wait()。非阻塞发送可在线程或中断上下文使用,但接收等待不能放在中断服务程序中。
发送线程组装第一条消息的过程是:
strcpy(mq_msg.str, str1);
mq_msg.len = strlen(mq_msg.str);
rt_mq_send(mq_ptr, &mq_msg, sizeof(struct msg));
C 数组不能在定义完成后用 = 整体赋予另一个字符串,因此课程使用 strcpy() 把字符逐个复制,并包含末尾 \0。strlen() 只计算 \0 之前的字符数,所以两组数据的 len 分别为 8 和 5。
要注意以下边界:
strcpy()不知道目标数组容量。如果源字符串超过 99 个字符,就会越界写坏栈;本例两个固定字符串长度足够小,所以演示可正常运行。strlen()返回size_t,课程把结果保存到uint8_t。本例最大有效长度只有 99,不会截断;若换成更大缓冲区,应重新考虑字段类型。- 本例每次发送
sizeof(struct msg),而strcpy()只写到\0为止,str后方未使用区域可能保留旧的栈字节。接收端不会打印这些尾部字节,但整结构体发送会复制它们并消耗固定带宽。产品代码可通过初始化结构体或设计更紧凑的消息格式使内容确定;文首仍保留课程原样。 - 两处失败日志中的
mb、rt_mb_send只是显示文本写错,实际执行的仍是rt_mq_send()。
发送线程先发送 "12345678",延时约 1 秒,再发送 "67890",再延时约 1 秒并循环。
1.1.3 rt_mq_recv函数
RT-Thread 5.0.2 中,接收接口的实际声明是:
rt_ssize_t rt_mq_recv(rt_mq_t mq,
void *buffer,
rt_size_t size,
rt_int32_t timeout);
| 参数 | 本例 | 含义 |
|---|---|---|
mq | mq_ptr | 从哪个消息队列取数据 |
buffer | &mq_msg | 接收数据要复制到的缓冲区地址 |
size | sizeof(struct msg) | 接收缓冲区允许写入的最大字节数 |
timeout | RT_WAITING_FOREVER | 队列为空时永久等待 |
收到消息后,内核取出队头消息,将“该消息实际长度”和“接收缓冲区容量”中的较小值复制到 buffer,并返回实际复制的字节数。队列为空时,本例会挂起 th1,有新消息到达才唤醒,因此接收循环不需要额外延时,也不会空转占用 CPU。
在这个版本中应按下面的规则判断返回值:
- 返回正数:成功,数值是实际复制的字节数。
- 返回负数:失败,例如非阻塞接收时队列为空或等待超时会返回
-RT_ETIMEOUT。 - 本接口要求请求大小非零,所以正常成功不会返回 0。
因此课程把邮箱例程中的 ret == RT_EOK 改为 ret > 0 是正确的。源码仍把 ret 声明成 rt_err_t,在本工程平台上能保存这些返回值;从接口语义和声明一致性看,正式代码使用 rt_ssize_t 更清楚,但不能据此擅自改写课程源码。
接收成功后,%s 打印 mq_msg.str,%d 打印经过整数提升的 mq_msg.len。正常输出交替为:
rt_mq_recv is 12345678 the len is 8!
rt_mq_recv is 67890 the len is 5!
rt_mq_recv is 12345678 the len is 8!
rt_mq_recv is 67890 the len is 5!
1.1.4 本节代码的调度过程
main()创建单条上限为sizeof(struct msg)、深度为 4 的动态消息队列。main()创建并启动优先级为 7 的接收线程th1。th1发现队列为空,在rt_mq_recv()处永久阻塞。main()恢复运行,创建并启动优先级为 8 的发送线程th2。th2把第一份结构体内容复制进队列;等待中的th1被唤醒。th1把队头数据复制到自己的局部结构体并打印,随后再次阻塞等待。th2每隔约 1 秒交替发送两份内容,th1随到随取。
RT-Thread 中优先级数值越小,线程优先级越高,因此 th1 的优先级 7 高于 th2 的优先级 8。只要 th1 被新消息唤醒,它就可以抢占发送线程完成接收和打印。
1.1.5 消息队列与邮箱的选择
| 对比项 | 邮箱 | 消息队列 |
|---|---|---|
| 单条容量 | 一个 rt_ubase_t 机器字;本课程平台为 4 字节 | 创建时指定最大字节数 |
| 入队内容 | 直接保存一个整数或地址值 | 把发送缓冲区中的字节复制到内部消息槽 |
| 大数据常见做法 | 发送指针,应用负责目标对象生命周期 | 直接发送结构体或数据块副本 |
| 时间与空间开销 | 小,固定复制一个机器字 | 随实际消息长度增加,消息池也更大 |
| 适用场景 | 状态码、句柄、指针 | 数据帧、结构体、需要保存快照的数据 |
消息队列避免了“发送局部结构体地址后函数返回导致悬空指针”的问题,但结构体内部若仍包含指针,队列复制的仍只是指针值,不会递归复制它指向的数据。此时那块外部数据的生命周期仍需单独管理。
1.1.6 教学代码中的简化点
- 两个线程入口的
parameter均未使用,这是 RT-Thread 线程入口统一函数签名带来的正常现象。 - 源码没有检查
rt_thread_startup()的返回值,也没有为中途创建失败设计逆序资源清理,这是突出消息队列流程的教学简化。 while (count++)的有符号int长期自增最终会发生溢出,不宜作为产品代码的永久循环条件;文首仍按课程源码保留。LOG_D("Hello RT-Thread!")被注释掉,是为了避免主线程每秒日志干扰实验输出。- 多个发送线程和接收线程可安全共用消息队列内部机制,但一条消息只会由一个接收线程取走,并非广播给所有线程。
核对依据: 本节代码、版本、配置和运行结果取自课程视频、转录文本及配套
10MQ工程;消息块复制、长度限制、紧急消息、等待线程排序、错误码和 RT-Thread 5.0.2 的接收返回值已与该版本的ipc.c、rtthread.h、rtdef.h,以及官方线程间通信文档交叉核对。官方网页中的部分旧版函数原型仍把rt_mq_recv()写成rt_err_t,本笔记涉及本课程工程时以配套 5.0.2 头文件和内核源码中的rt_ssize_t声明为准。