线程间通信_邮箱
259.2.10线程间通信_邮箱
#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_mailbox_t mb_ptr; // 定义动态邮箱句柄,后续保存 rt_mb_create() 返回的邮箱对象地址
char str1[]="12345"; // 定义具有全局生命周期的第一个字符串及其结尾的 '\0'
char str2[]="67890"; // 定义具有全局生命周期的第二个字符串及其结尾的 '\0'
void th1_recv(void *parameter) // 定义线程 th1 的入口函数,负责从邮箱接收字符串地址
{ // th1_recv 函数体开始
rt_err_t ret; // 保存邮箱接收接口返回的 RT-Thread 错误码
char *recv_str; // 保存从邮箱取出的字符串首地址
while(1) // 接收线程永久循环,持续等待新邮件
{ // 接收循环体开始
ret = rt_mb_recv(mb_ptr, (rt_ubase_t *)&recv_str, RT_WAITING_FOREVER); // 永久等待邮件,并把一个机器字的邮件值写入 recv_str
if(ret == RT_EOK) // 判断本次邮件接收是否成功
{ // 接收成功分支开始
rt_kprintf("rt_mb_recv is %s!\r\n" ,recv_str); // 把收到的地址按以 '\0' 结尾的字符串打印
}else{ // 接收失败分支开始
LOG_E("mb rt_mb_recv failed!\r\n"); // 记录邮箱接收失败信息
} // 接收结果判断结束
} // 接收循环体结束
} // th1_recv 函数体结束
void th2_send(void *parameter) // 定义线程 th2 的入口函数,负责向邮箱发送两个字符串地址
{ // th2_send 函数体开始
rt_err_t ret; // 保存邮箱发送接口返回的 RT-Thread 错误码
while(1) // 发送线程永久循环,交替发送两个地址
{ // 发送循环体开始
ret = rt_mb_send(mb_ptr,(rt_ubase_t)str1); // 把 str1 的首地址转换成一个机器字并发送到邮箱
if(ret != RT_EOK) // 判断 str1 的地址是否发送失败
{ // 第一次发送失败分支开始
LOG_E("mb rt_mb_send failed!\r\n"); // 记录第一次邮箱发送失败信息
} // 第一次发送结果判断结束
rt_thread_mdelay(1000); // 让发送线程延时约 1000 ms
ret = rt_mb_send(mb_ptr,(rt_ubase_t)str2); // 把 str2 的首地址转换成一个机器字并发送到邮箱
if(ret != RT_EOK) // 判断 str2 的地址是否发送失败
{ // 第二次发送失败分支开始
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; // 初始化课程用于维持主线程循环的计数变量
mb_ptr = rt_mb_create("mb", 4, RT_IPC_FLAG_FIFO); // 动态创建名为 mb、容量为 4 封邮件的邮箱,等待线程按先来先服务排列
if(mb_ptr == RT_NULL) // 判断邮箱控制块或邮件池是否分配失败
{ // 邮箱创建失败分支开始
LOG_E("mb_ptr rt_mb_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 函数体结束
源码说明: 上方是课程配套
09MB工程最终的applications/main.c业务代码。除去文件头版权说明和原有说明性注释后,变量、字符串、函数、参数、调用顺序、错误处理和被注释掉的LOG_D均按课程源码保留,只增加了逐行初学者注释;没有补入 RT-Thread 邮箱或线程的库函数实现。课程口头提到“删除邮箱”,但本节最终代码没有调用删除接口,因此这里也没有擅自补写。
一、全局变量
本节先概括四种线程间通信方式:全局变量、邮箱、消息队列和信号。最直观的办法是让多个线程读写同一个全局变量:发送方修改值,接收方观察值的变化,由此传递状态或数据。
1.1 全局变量的弊端
全局变量本身不是绝对不能使用,问题在于它只提供“大家都能访问的一块内存”,没有自动提供同步、排队、等待和所有权管理:
- 容易发生竞态条件。 一个线程执行“读出—修改—写回”的过程中可能被另一个线程抢占,双方的操作发生交错,最终结果便取决于不可预测的调度时序。
- 一次读写看似不可分割,也不代表业务正确。 即使某个对齐的 32 位读写在 STM32F103 上通常能由一条指令完成,“先写数据、再置标志”“检查后再修改”等多步逻辑仍可能被打断。
- 需要额外同步。 共享可变数据通常要配合互斥量、临界区、原子操作或其他协议。保护范围、加锁顺序和异常路径稍有不当,就可能产生死锁、数据竞争或遗漏唤醒。
- 缺少消息语义。 一个数值被连续改写多次时,中间值可能被接收线程错过;全局变量也不会自动记录“谁先发送、共有几条、邮箱是否已满”。
- 耦合度高。 多个模块都直接依赖同一变量的名称、结构和修改规则,不利于分层、测试和维护。
邮箱把“投递一个值”和“等待一个值”封装成内核对象,并提供缓存和阻塞唤醒机制,适合明确的生产者—消费者通信。不过,邮箱只负责保护自己的内部队列;如果邮件中传的是指针,指针所指数据仍需由应用保证生命周期和并发访问安全。
二、信号
信号(signal)是一种异步通知机制,从软件行为上可类比“软中断”:线程不必像读取邮箱那样主动取得一条业务数据,信号到达后,系统按该线程预先设置的方式处理通知。
课程中“其他实时操作系统没有信号,只有 RT-Thread 有”的说法不严谨。信号并非 RT-Thread 独有;Linux/Unix 的 POSIX 信号正是常见来源,RT-Thread 也提供了自己的信号机制。准确理解应是:不同 RTOS 提供的异步通知接口和名称并不相同,不能假设所有系统都有与 RT-Thread 完全相同的信号 API。
本节只介绍信号的概念,没有编写信号代码。配套 09MB 工程的配置中,RT_USING_SIGNALS 实际也未启用,因此不要根据这一小段介绍向本节源码中补写信号接口。
三、邮箱通信
RT-Thread 邮箱是带有限缓存的消息通道,适合在线程之间传递一个较小的整数、状态值或指针。其特点是每封邮件只占一个 rt_ubase_t,不需要像消息队列那样为每条消息复制一段可变长度数据,因此开销较小。
课程所用 STM32F103ZE 是 32 位 Cortex-M3,工程中的 rt_ubase_t 和指针均为 32 位,所以本工程每封邮件为 4 字节。从接口和内核源码看,更通用的表述是“每封邮件为一个机器字”;在 64 位移植上不应机械地认定仍为 4 字节。
一个邮箱可以由多个线程使用:多个发送者可以向它投递,多个接收者也可以等待它。但一封普通邮件只会被一个接收者取走,邮箱不是广播机制。若需要同时通知多个线程,应根据语义选择事件集、逐个投递或其他机制。
本节的数据流如下:
th2_send 邮箱 mb(容量 4) th1_recv
str1 的地址 ──rt_mb_send──> [地址1 | 地址2 | 空 | 空] ──rt_mb_recv──> recv_str
str2 的地址 ──rt_mb_send──> 只复制一个机器字 %s 访问原字符串
这里入箱的是 str1、str2 的地址值,不是字符串的 5 个字符。两个数组实际各占 6 字节(5 个可见字符加结尾 \0),邮箱只保存 4 字节地址;接收线程再通过这个地址访问原数组。
邮箱与消息队列的核心区别如下:
| 对比项 | 邮箱 | 消息队列 |
|---|---|---|
| 单条内容 | 一个 rt_ubase_t 机器字;本工程为 4 字节 | 创建时指定单条消息的最大字节数 |
| 传递较大数据 | 通常传指向数据的指针,数据本体不被复制 | 发送时把消息内容复制进队列自己的缓冲区 |
| 数据生命周期 | 指针所指对象必须一直有效,且并发修改需另行同步 | 入队成功后,发送方原缓冲区通常可复用 |
| 开销 | 单条邮件固定且很小 | 随消息大小增加,需要复制更多字节 |
| 典型用途 | 状态值、句柄、长期有效对象的地址 | 结构体、数据帧、需要按值保存的一段内容 |
3.1 创建发送接收删除邮箱
邮箱常用操作包括创建/初始化、发送、接收以及删除/脱离。本节使用动态接口创建邮箱和线程。配套工程启用了 RT_USING_MAILBOX 与动态堆,RT-Thread 版本为 5.0.2,系统 tick 频率为 1000 Hz;控制台为 UART1,用于显示 rt_kprintf() 和日志。本实验没有新增外部电路,邮箱本身完全是内核软件对象。
动态邮箱的创建接口为:
rt_mailbox_t rt_mb_create(const char *name, rt_size_t size, rt_uint8_t flag);
| 参数 | 本例 | 准确含义 |
|---|---|---|
name | "mb" | 内核对象名称,用于对象管理与调试 |
size | 4 | 最多缓存 4 封尚未取走的邮件,不是 4 字节总容量 |
flag | RT_IPC_FLAG_FIFO | 多个线程阻塞在该 IPC 对象上时,按等待先后排列 |
内核为动态邮箱分配邮箱控制块,并另外分配 size * sizeof(rt_ubase_t) 的邮件池。本工程中邮件池为 4 × 4 = 16 字节,此外还有邮箱控制块自身的内存开销。任一动态分配失败时,rt_mb_create() 返回 RT_NULL,所以源码在创建后立即判空。
这里最容易误解的是第三个参数:RT_IPC_FLAG_FIFO 或 RT_IPC_FLAG_PRIO 控制的是多个阻塞等待线程的排列/唤醒规则,不是选择普通邮件按什么顺序出箱。普通 rt_mb_send() 投递的邮件存入环形缓冲区,正常情况下本来就是先投递、先接收;rt_mb_urgent() 才会把紧急邮件放到队头。本例没有使用紧急邮件。
邮箱有两套成对的生命周期接口:
| 对象形式 | 建立 | 释放 | 内存来源 |
|---|---|---|---|
| 动态邮箱 | rt_mb_create() | rt_mb_delete() | 内核动态堆 |
| 静态邮箱 | rt_mb_init() | rt_mb_detach() | 调用者提供控制块和邮件池 |
不能把 delete 与 detach 混用。本例创建的邮箱和两个线程都要在系统整个运行期持续工作,所以最终代码没有删除邮箱。若是可退出的功能模块,应先保证没有线程继续访问该邮箱,再用与创建方式匹配的接口释放资源。
本例的接收线程优先级为 7,发送线程优先级为 8;RT-Thread 中数值越小优先级越高。th1 启动后先尝试收信,此时邮箱为空,因此它在 rt_mb_recv() 处阻塞;随后 th2 启动并发送地址,内核唤醒优先级更高的 th1 来处理邮件。
3.2 发送和接收邮件
本节发送使用:
rt_err_t rt_mb_send(rt_mailbox_t mb, rt_ubase_t value);
mb是目标邮箱句柄,本例为mb_ptr。value是实际写入邮箱的一个机器字,不是缓冲区和长度的组合。本例把str1或str2的首地址强制转换为rt_ubase_t。- 投递成功返回
RT_EOK。 - 若邮箱已满,
rt_mb_send()不会阻塞等待,而是立即返回-RT_EFULL。原稿中笼统写成“返回-1”不准确;课程源码采用ret != RT_EOK,能统一捕获所有失败错误码。
如果确实希望邮箱满时让发送线程等待空位,应使用带超时参数的 rt_mb_send_wait();中断服务程序不能进行阻塞等待。非阻塞的 rt_mb_send() 可用于线程或中断上下文,但中断中仍应只做简短通知,把字符串格式化等耗时处理留给线程。
发送线程依次执行:
- 投递
str1的地址。 - 延时 1000 ms。
- 投递
str2的地址。 - 再延时 1000 ms并循环。
因此正常串口输出会每隔约 1 秒在 12345 与 67890 之间交替。这里的 1000 ms 是线程延时,不是邮箱发送超时。
两个字符串被定义为全局数组,生命周期覆盖整个程序运行期,所以接收方稍后解引用其地址仍然有效。这一点对“邮箱传指针”非常关键:
- 不要发送已经离开作用域的局部数组地址;创建它的函数返回后,该栈空间不再可靠。
- 不要在接收方处理完之前释放动态内存,或在没有同步协议时重写同一缓冲区。
- 如果发送动态分配对象的地址,应明确由谁最终调用
rt_free(),避免重复释放或内存泄漏。 - 邮箱复制地址不等于复制对象快照;发送后修改对象,接收方看到的可能是修改后的内容。
3.3 邮件的接收
本节接收使用:
rt_err_t rt_mb_recv(rt_mailbox_t mb,
rt_ubase_t *value,
rt_int32_t timeout);
| 参数 | 本例 | 含义 |
|---|---|---|
mb | mb_ptr | 从哪个邮箱取邮件 |
value | (rt_ubase_t *)&recv_str | 把取出的一个机器字写到哪里 |
timeout | RT_WAITING_FOREVER | 邮箱为空时永久等待 |
recv_str 是 char *,它本身用来保存一个地址。rt_mb_recv() 的第二个形参要求 rt_ubase_t *,也就是“指向邮件值存放位置的指针”,因此源码传入 &recv_str 并进行类型转换。可以分两步理解:
发送:str1 ──退化为首地址──> 转为 rt_ubase_t ──存入邮箱
接收:邮箱中的 rt_ubase_t ──写入 recv_str──> 再作为 char * 使用
当邮箱中已有邮件时,rt_mb_recv() 立即取出并返回 RT_EOK;邮箱为空时,本例的接收线程因 RT_WAITING_FOREVER 被内核挂起,不会占用 CPU 忙等。新邮件到达后,线程被唤醒继续执行。正因为循环中自然存在这个阻塞点,所以课程没有再给接收线程添加延时。
接收成功后,rt_kprintf() 使用 %s 从 recv_str 指向的位置逐字节读取,直到遇到 \0 为止。若地址无效、数据不是以 \0 结尾的字符串,或缓冲区已被并发破坏,打印就可能越界或异常;邮箱无法替应用检查这些条件。
本例正常输出形如:
rt_mb_recv is 12345!
rt_mb_recv is 67890!
rt_mb_recv is 12345!
rt_mb_recv is 67890!
3.4 本节代码的完整调度过程
main()动态创建容量为 4 的邮箱。main()创建并启动高优先级接收线程th1。th1发现邮箱为空,在rt_mb_recv()处永久阻塞。main()恢复运行,创建并启动发送线程th2。th2把str1的地址写入邮箱,内核唤醒等待邮件的th1。th1取出地址并打印12345;再次收信时邮箱为空,于是重新阻塞。th2延时结束后投递str2的地址,th1被再次唤醒并打印67890。- 两个工作线程重复上述过程;
main()自身只保留延时循环。
课程中把 Hello RT-Thread! 的 LOG_D 注释掉,是为了避免主线程每秒输出日志,干扰邮箱实验现象。rt_kprintf() 可以理解为 RT-Thread 内核提供的格式化控制台输出函数,其中字母 k 表示 kernel。
3.5 容量、顺序和“多线程可共用”的边界
- 容量
4表示可缓存 4 个机器字。若接收线程一时未运行,前 4 次普通发送可以进入邮箱;第 5 次rt_mb_send()在仍无空位时返回-RT_EFULL。 - 普通邮件按环形缓冲区的进入顺序取出。
RT_IPC_FLAG_FIFO不改变这一点,它决定的是有多个线程阻塞等待时谁优先被唤醒。 - 多个发送线程无需再给邮箱内部队列额外加互斥量,内核已保证邮箱操作的一致性;多个接收线程也能共同等待同一邮箱。
- 多接收者是“竞争消费”:一封邮件由其中一个线程取得,不会自动复制给所有线程。
- 邮箱保护的是邮件槽和等待队列,不保护指针所指向的业务数据。若多个线程会写该数据,仍需单独设计互斥或所有权转移规则。
3.6 教学代码中的简化点
parameter在两个线程入口中没有使用,这是线程入口统一签名要求带来的正常现象。- 源码检查了创建和收发结果,但没有检查
rt_thread_startup()的返回值,这是为了突出邮箱主流程所作的简化。 - 邮箱创建成功后,如果后续某个线程创建失败,示例直接从
main()返回,没有回收此前创建的对象;产品代码应设计完整的逆序清理路径。 while (count++)依赖有符号int一直自增,长期运行最终会发生有符号溢出,不适合作为正式项目中的永久循环写法;文首仍严格保留课程最终源码,没有擅自改为while (1)。- 课程演示用全局可写字符数组,实际内容从不修改。若数据本应只读,正式代码可考虑常量限定,但这属于代码设计改进,不应反向改写本节课程源码。
核对依据: 本节代码、版本、配置和运行关系取自课程视频、转录文本及配套
09MB工程;邮箱对象结构、动态内存大小、等待队列规则、错误码和收发行为已与 RT-Thread 5.0.2 的ipc.c、rtthread.h、rtdef.h,以及官方线程间通信文档交叉核对。