4、互斥量(Mutex):优先级反转问题、互斥量与二值信号量的区别、递归互斥量
各位,今天我们来聊聊互斥量。这东西在RTOS里,可以说是最常用也最容易踩坑的同步机制之一。我刚开始用RTOS那会儿,总觉得互斥量和二值信号量差不多,直到在项目里被优先级反转折腾得够呛,才真正理解了它们之间的区别。
4.1 优先级反转问题
先说说优先级反转。这是个经典问题,很多新手甚至老手都会栽在这里。
什么叫优先级反转?简单说就是:高优先级任务被低优先级任务阻塞,而中优先级任务却抢占了CPU。这完全违背了优先级调度的初衷。
我举个例子你就明白了。假设有三个任务:
- 任务A:高优先级,需要访问共享打印机
- 任务B:中优先级,纯计算任务,不需要打印机
- 任务C:低优先级,正在使用打印机
正常流程应该是:任务A就绪后,立即抢占任务C。但问题来了——任务C正拿着打印机的锁呢!
实际发生的情况是这样的:
- 任务C拿到锁,开始打印
- 任务A就绪,想拿锁,但锁被C占着,A被阻塞
- 任务B就绪,它不需要锁,直接抢占CPU开始运行
- 任务C因为优先级比B低,一直得不到CPU,锁也释放不了
- 任务A就这么干等着,明明优先级最高,却跑不了
核心问题:高优先级任务A的实时性,被一个跟它毫无关系的任务B给毁了。这就是优先级反转。
我曾经在一个工业控制项目里遇到过这个问题。一个传感器数据采集任务(高优先级)总是莫名其妙地超时,查了两天才发现是中间层有个日志打印任务(中优先级)在捣乱。从那以后,我对互斥量的使用就格外小心了。
4.2 互斥量与二值信号量的区别
很多人问我:互斥量和二值信号量到底有什么区别?不都是0和1吗?
嗯,表面上看确实像。但实际用起来,差别大了去了。
| 特性 | 互斥量(Mutex) | 二值信号量(Binary Semaphore) |
|---|---|---|
| 所有权 | 有所有权概念,谁拿谁释放 | 无所有权,任何任务都能释放 |
| 优先级继承 | 支持(解决优先级反转) | 不支持 |
| 递归加锁 | 支持(递归互斥量) | 不支持 |
| 典型用途 | 保护共享资源(临界区) | 任务同步、事件通知 |
| 释放限制 | 只能由持有者释放 | 任何任务都能释放 |
你看,最核心的区别就是所有权。互斥量知道"这把锁是谁拿的",二值信号量不知道。这就导致了两个重要差异:
- 优先级继承:互斥量能临时提升低优先级任务的优先级,让它快点跑完释放锁。二值信号量做不到。
- 安全性:互斥量只能由持有者释放,不会出现"任务A拿锁,任务B释放"这种混乱情况。二值信号量就没这个限制。
我的建议:保护共享资源,一律用互斥量。任务间发个信号通知一下,用二值信号量。别混着用,容易出问题。
4.3 递归互斥量
递归互斥量,说白了就是允许同一个任务多次加锁的互斥量。你想想看,什么时候会用到?
最常见的情况就是函数嵌套调用。比如:
void func_a() {
mutex_lock(&print_mutex);
// 打印一些信息
func_b(); // 这里又调用了func_b
mutex_unlock(&print_mutex);
}
void func_b() {
mutex_lock(&print_mutex); // 同一个锁!
// 打印更多信息
mutex_unlock(&print_mutex);
}
如果用普通互斥量,func_a 加锁后调用 func_b,func_b 再去拿同一个锁——死锁!因为锁已经被自己占着了,再拿就永远等下去。
递归互斥量就是解决这个问题的。它允许同一个任务多次加锁,内部维护一个计数器。加一次锁计数器+1,释放一次-1,只有计数器归零了,其他任务才能拿到锁。
注意:递归互斥量虽然方便,但别滥用。每次加锁都要对应一次释放,少一次都不行。我曾经见过一个同事,递归加锁三次只释放两次,结果那个任务再也没释放过锁,整个系统都卡死了。
4.4 实战:共享打印机驱动
好了,理论说完了,咱们来点实际的。下面是一个共享打印机驱动的简化实现,用互斥量保护打印队列。
/* 打印机驱动 - 使用互斥量保护 */
static mutex_t print_mutex;
static char print_buffer[256];
static int buffer_index = 0;
void printer_init(void) {
mutex_init(&print_mutex, MUTEX_TYPE_NORMAL);
buffer_index = 0;
memset(print_buffer, 0, sizeof(print_buffer));
}
void printer_print(const char *data, int len) {
/* 加锁,保护共享缓冲区 */
mutex_lock(&print_mutex);
/* 检查缓冲区是否够用 */
if (buffer_index + len > sizeof(print_buffer)) {
/* 缓冲区满了,先刷新到硬件 */
flush_to_hardware();
buffer_index = 0;
}
/* 拷贝数据到缓冲区 */
memcpy(&print_buffer[buffer_index], data, len);
buffer_index += len;
/* 如果遇到换行符,立即刷新 */
if (data[len-1] == '\n') {
flush_to_hardware();
buffer_index = 0;
}
/* 解锁 */
mutex_unlock(&print_mutex);
}
static void flush_to_hardware(void) {
/* 实际硬件操作,这里简化 */
for (int i = 0; i < buffer_index; i++) {
/* 发送到打印机硬件 */
uart_send_byte(print_buffer[i]);
}
}
这个驱动里,printer_print 函数可以被多个任务同时调用。互斥量确保同一时间只有一个任务在操作缓冲区,不会出现数据错乱。
你可能会问:为什么不用二值信号量?
原因很简单:如果任务A拿到信号量后崩溃了,任务B永远拿不到锁。但互斥量有所有权机制,RTOS能检测到任务A异常终止,自动释放互斥量。这就是安全性的差别。
实战要点:
- 加锁和解锁必须在同一个任务中,别跨任务操作
- 加锁后尽快释放,别在临界区里做耗时操作
- 如果函数可能递归调用,用递归互斥量
- 考虑使用优先级继承,避免优先级反转
4.5 知识结构图
下面这张图总结了互斥量的核心知识点和它们之间的关系:
这张图把互斥量的三个核心知识点串起来了。左边是问题(优先级反转),中间是对比(与二值信号量的区别),右边是扩展(递归互斥量),底部是实战应用。你写代码的时候,脑子里要有这张图,就知道该用哪种机制了。