4. 线程间同步:信号量、互斥量、事件集的使用场景与 API 详解
多线程编程中,最头疼的问题是什么?我个人觉得,不是线程跑不起来,而是线程之间互相打架。两个线程同时操作一个串口,数据就乱了;一个线程在等数据,另一个线程还没准备好,系统就卡死了。这些问题的根源,说白了就是资源竞争和执行顺序没控制好。
RT-Thread 提供了三种同步机制:信号量、互斥量、事件集。它们各有各的脾气,用对了事半功倍,用错了……嗯,我调试过不少因为同步机制用错导致的诡异 bug,后面会跟大家细说。
4.1 信号量(Semaphore)—— 轻量级的资源计数器
信号量本质上就是一个计数器。你可以把它想象成一个停车场门口的显示屏:显示剩余车位数量。每进去一辆车,数字减1;每出来一辆车,数字加1。如果数字变成0,后来的车就得排队等着。
4.1.1 使用场景
- 资源管理:比如一个内存池只有5个缓冲区,多个线程需要申请使用
- 同步通知:一个线程做完某件事,通知另一个线程继续执行
- 中断与任务同步:中断服务程序释放信号量,任务获取信号量后处理数据
我的经验:在项目中用信号量做中断与任务的同步,是最常见的做法。但要注意,中断中只能释放信号量,绝对不能获取信号量——中断里不能阻塞等待。
4.1.2 核心 API
| API 函数 | 功能说明 | 注意事项 |
|---|---|---|
rt_sem_create / rt_sem_init |
创建/初始化信号量 | 动态创建 vs 静态初始化,看你的内存管理策略 |
rt_sem_take |
获取信号量(计数减1) | 可设置超时时间,0表示不等待 |
rt_sem_release |
释放信号量(计数加1) | 可在中断中调用 |
rt_sem_delete / rt_sem_detach |
删除/脱离信号量 | 删除前确保没有线程在等待 |
4.1.3 代码示例:中断与任务同步
/* 定义信号量控制块 */
static rt_sem_t uart_rx_sem = RT_NULL;
/* 创建信号量,初始值为0 */
uart_rx_sem = rt_sem_create("uart_rx", 0, RT_IPC_FLAG_FIFO);
/* 中断服务程序 */
void UART_IRQHandler(void)
{
/* 检查接收中断标志 */
if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
{
/* 读取数据到缓冲区 */
rx_buffer[rx_index++] = USART_ReceiveData(USART1);
/* 释放信号量,通知任务处理 */
rt_sem_release(uart_rx_sem);
}
}
/* 接收任务 */
void uart_rx_thread_entry(void *parameter)
{
while (1)
{
/* 等待信号量,超时时间设为 RT_WAITING_FOREVER */
if (rt_sem_take(uart_rx_sem, RT_WAITING_FOREVER) == RT_EOK)
{
/* 处理接收到的数据 */
process_uart_data();
}
}
}
我曾经踩过的坑:信号量的初始值设错了。如果初始值设为1,而你的本意是让任务等待中断通知,那任务第一次执行
rt_sem_take 就会直接返回成功,根本不等中断。后来我养成了习惯:做同步用的信号量,初始值一律设为0。
4.2 互斥量(Mutex)—— 解决优先级反转的利器
互斥量和信号量看起来很像,但有一个本质区别:互斥量支持优先级继承。什么意思呢?假设低优先级任务持有互斥量,高优先级任务在等待这个互斥量,系统会临时把低优先级任务的优先级提升到和高优先级任务一样,防止中间优先级的任务插队导致高优先级任务无限等待。
4.2.1 使用场景
- 保护共享资源:多个线程访问同一个全局变量、外设寄存器
- 防止重入:同一个线程可以多次获取同一个互斥量(递归特性)
- 需要优先级继承的场景:实时性要求高的系统中必须用互斥量
4.2.2 核心 API
| API 函数 | 功能说明 | 注意事项 |
|---|---|---|
rt_mutex_create / rt_mutex_init |
创建/初始化互斥量 | 互斥量默认支持优先级继承 |
rt_mutex_take |
获取互斥量 | 同一个线程可多次获取,不会死锁 |
rt_mutex_release |
释放互斥量 | 获取几次就要释放几次 |
rt_mutex_delete / rt_mutex_detach |
删除/脱离互斥量 | 释放次数必须等于获取次数 |
4.2.3 代码示例:保护共享资源
/* 定义互斥量控制块 */
static rt_mutex_t lcd_mutex = RT_NULL;
/* 创建互斥量 */
lcd_mutex = rt_mutex_create("lcd_lock", RT_IPC_FLAG_FIFO);
/* 线程1:更新温度显示 */
void temp_display_thread_entry(void *parameter)
{
while (1)
{
rt_mutex_take(lcd_mutex, RT_WAITING_FOREVER);
/* 操作LCD显示温度 */
lcd_clear();
lcd_show_string(0, 0, "Temp: 25.5C");
rt_mutex_release(lcd_mutex);
rt_thread_mdelay(1000);
}
}
/* 线程2:更新湿度显示 */
void humidity_display_thread_entry(void *parameter)
{
while (1)
{
rt_mutex_take(lcd_mutex, RT_WAITING_FOREVER);
/* 操作LCD显示湿度 */
lcd_clear();
lcd_show_string(0, 0, "Humidity: 60%");
rt_mutex_release(lcd_mutex);
rt_thread_mdelay(1000);
}
}
关键区别:信号量可以用于中断中释放,互斥量绝对不能在中断中使用。因为互斥量的优先级继承机制依赖任务调度,中断上下文没有任务的概念。如果你在中断里释放互斥量,系统会直接断言报错。
4.3 事件集(Event)—— 一对多、多对一的灵活同步
事件集是我个人比较喜欢的一种同步机制。它不像信号量那样只传递一个"有/没有"的信息,而是可以传递多个标志位。你可以设置事件、等待事件、清除事件,非常灵活。
4.3.1 使用场景
- 多条件触发:一个任务需要等待多个条件同时满足才能执行
- 广播通知:一个事件发生后,多个任务同时被唤醒
- 状态机驱动:用事件位表示不同的系统状态
4.3.2 核心 API
| API 函数 | 功能说明 | 注意事项 |
|---|---|---|
rt_event_create / rt_event_init |
创建/初始化事件集 | 一个事件集最多32个事件位 |
rt_event_send |
发送事件(置位) | 可在中断中调用 |
rt_event_recv |
接收事件(等待) | 支持与逻辑、或逻辑 |
rt_event_delete / rt_event_detach |
删除/脱离事件集 | 删除前确保没有线程在等待 |
4.3.3 代码示例:多条件同步
/* 定义事件标志 */
#define EVENT_BUTTON1 (1 << 0)
#define EVENT_BUTTON2 (1 << 1)
#define EVENT_TIMEOUT (1 << 2)
/* 定义事件集控制块 */
static rt_event_t key_event;
/* 创建事件集 */
key_event = rt_event_create("key_event", RT_IPC_FLAG_FIFO);
/* 按键中断服务程序 */
void EXTI0_IRQHandler(void)
{
if (EXTI_GetITStatus(EXTI_Line0) != RESET)
{
/* 发送按键1事件 */
rt_event_send(key_event, EVENT_BUTTON1);
EXTI_ClearITPendingBit(EXTI_Line0);
}
}
/* 组合按键检测任务 */
void combo_key_thread_entry(void *parameter)
{
rt_uint32_t recv_set = 0;
while (1)
{
/* 等待按键1和按键2同时按下,或超时 */
rt_event_recv(key_event,
EVENT_BUTTON1 | EVENT_BUTTON2,
RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR,
RT_WAITING_FOREVER,
&recv_set);
/* 检查收到的事件 */
if (recv_set & EVENT_TIMEOUT)
{
/* 超时处理 */
continue;
}
/* 两个按键都按下了,执行组合功能 */
execute_combo_function();
}
}
我的建议:事件集的
RT_EVENT_FLAG_CLEAR 选项很有用。加上这个标志后,事件被接收后会自动清除,省得你手动调用清除函数。但要注意,如果有多个任务在等待同一个事件,只有第一个接收到的任务能拿到事件,后面的任务会继续等待。
4.4 三种同步机制对比
| 特性 | 信号量 | 互斥量 | 事件集 |
|---|---|---|---|
| 核心机制 | 计数器 | 所有权+优先级继承 | 32位标志位 |
| 中断中使用 | 可释放,不可获取 | 禁止使用 | 可发送,不可接收 |
| 传递信息量 | 单一(有/无) | 单一(锁/未锁) | 多标志位 |
| 优先级继承 | 不支持 | 支持 | 不支持 |
| 典型场景 | 资源计数、中断同步 | 保护共享资源 | 多条件触发、广播 |
4.5 知识结构图
下面这张图展示了三种同步机制的核心关系和使用边界,方便你快速对照选择:
4.6 避坑指南与调试技巧
做嵌入式这么多年,我总结了几条关于同步机制的调试经验,分享给大家:
- 死锁排查:如果系统卡死了,先检查是不是互斥量获取后没有释放。我习惯在每个
rt_mutex_take后面加个日志,释放时再打一条,对照看是否成对出现。 - 信号量泄漏:信号量释放次数多于获取次数,会导致计数值不断增大。我曾经遇到过这种情况,一个中断频繁释放信号量,但任务处理不过来,最后信号量计数值溢出,系统直接崩溃。
- 优先级反转:如果你发现高优先级任务响应变慢,检查是不是用了信号量保护共享资源。换成互斥量,问题通常就解决了。
- 事件集标志位冲突:32个事件位,建议用宏定义明确每个位的含义,不要直接写数字。我见过有人用
1 << 5和1 << 6表示两个不同事件,结果代码里到处是魔数,调试起来非常痛苦。
重要提醒:在中断服务程序中,绝对不要调用
rt_sem_take、rt_mutex_take、rt_event_recv 这些可能阻塞的函数。中断上下文没有任务调度,一旦阻塞,整个系统就挂了。这个错误我刚开始做 RT-Thread 时犯过,后来每次写中断代码都会反复检查。