4、互斥量(Mutex):优先级反转问题、互斥量与二值信号量的区别、递归互斥量

各位,今天我们来聊聊互斥量。这东西在RTOS里,可以说是最常用也最容易踩坑的同步机制之一。我刚开始用RTOS那会儿,总觉得互斥量和二值信号量差不多,直到在项目里被优先级反转折腾得够呛,才真正理解了它们之间的区别。

4.1 优先级反转问题

先说说优先级反转。这是个经典问题,很多新手甚至老手都会栽在这里。

什么叫优先级反转?简单说就是:高优先级任务被低优先级任务阻塞,而中优先级任务却抢占了CPU。这完全违背了优先级调度的初衷。

我举个例子你就明白了。假设有三个任务:

  • 任务A:高优先级,需要访问共享打印机
  • 任务B:中优先级,纯计算任务,不需要打印机
  • 任务C:低优先级,正在使用打印机

正常流程应该是:任务A就绪后,立即抢占任务C。但问题来了——任务C正拿着打印机的锁呢!

实际发生的情况是这样的:

  1. 任务C拿到锁,开始打印
  2. 任务A就绪,想拿锁,但锁被C占着,A被阻塞
  3. 任务B就绪,它不需要锁,直接抢占CPU开始运行
  4. 任务C因为优先级比B低,一直得不到CPU,锁也释放不了
  5. 任务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 知识结构图

下面这张图总结了互斥量的核心知识点和它们之间的关系:

互斥量知识结构图 互斥量(Mutex) 优先级反转 高优先级被低优先级阻塞 中优先级任务趁机抢占CPU 解决方案:优先级继承 vs 二值信号量 互斥量有所有权概念 信号量无所有权 互斥量支持优先级继承 递归互斥量 允许同一任务多次加锁 内部计数器管理 解决函数嵌套死锁 实战:共享打印机驱动

这张图把互斥量的三个核心知识点串起来了。左边是问题(优先级反转),中间是对比(与二值信号量的区别),右边是扩展(递归互斥量),底部是实战应用。你写代码的时候,脑子里要有这张图,就知道该用哪种机制了。