4、临界区与同步机制:关中断与锁、互斥量与信号量、优先级反转与继承、死锁检测与预防

临界区,说白了就是一段不能被打断的代码。谁要是闯进来,数据就乱套了。我早年做电机控制时,就吃过这个亏——一个全局变量在中断里被改了,主循环读到的值半新不旧,电机直接抖成了筛子。

嵌入式系统里,保护临界区的手段就那么几种。但每种都有脾气,用不对就是坑。咱们一个一个捋。

4.1 关中断与开中断

最原始,也最暴力的方法。直接把CPU的中断关了,谁也别想抢我的执行权。

// ARM Cortex-M 关中断示例
__asm volatile("cpsid i");   // 关中断
// 临界区代码
__asm volatile("cpsie i");   // 开中断

但这里有个大坑:关中断时间不能长。我见过一个项目,工程师在SPI通信时全程关中断,结果按键响应延迟了200ms,用户投诉说“按了没反应”。

警告:关中断时间建议控制在10微秒以内。超过100微秒,实时性基本就废了。而且,关中断期间无法响应紧急事件——比如看门狗喂狗、硬件故障报警。

还有个细节:关中断要支持嵌套。你写个函数,里面关了中断,结果这个函数又被另一个关中断的函数调了,开中断时就会提前把中断打开。正确的做法是保存中断状态,恢复时还原。

// 正确的嵌套关中断
uint32_t save = __get_PRIMASK();
__disable_irq();
// 临界区
__set_PRIMASK(save);  // 恢复原状态,不是直接开中断

4.2 锁机制:自旋锁与互斥锁

多核或者RTOS环境下,光关中断不够了。另一个核也在跑,你关不了别人的中断。这时候就需要锁。

锁类型 适用场景 等待方式 缺点
自旋锁 多核、中断上下文 忙等待(循环检测) 浪费CPU,不能长时间持有
互斥锁 任务上下文 阻塞挂起 有上下文切换开销

自旋锁,我习惯只在中断服务程序里用。因为ISR不能阻塞,只能用忙等。但记住:自旋锁的持有时间必须极短,否则其他核就卡死了。

// 自旋锁示例(简化版)
volatile int lock = 0;

void spin_lock(volatile int *lock) {
    while (__atomic_test_and_set(lock, __ATOMIC_ACQUIRE)) {
        // 等待,可插入CPU暂停指令
        __asm volatile("nop");
    }
}

void spin_unlock(volatile int *lock) {
    __atomic_clear(lock, __ATOMIC_RELEASE);
}

互斥锁则温柔一些。拿不到锁的任务会挂起,让出CPU给别的任务用。但代价是两次上下文切换——挂起一次,恢复一次。我算过,一次切换大概几微秒到几十微秒,看芯片和RTOS实现。

经验之谈:如果临界区执行时间小于上下文切换时间,用自旋锁或关中断更划算。反之,用互斥锁。这个分界线,我一般取50微秒。

4.3 互斥量与信号量

很多人搞混这两个概念。我简单说:互斥量是二进制信号量的特例,但多了优先级继承。

  • 互斥量(Mutex):谁持有谁释放,不能在中断里释放。支持优先级继承,解决反转问题。
  • 二进制信号量(Binary Semaphore):可以在中断里释放(give),用于同步。没有优先级继承。
  • 计数信号量(Counting Semaphore):管理多个资源,比如5个缓冲区,用信号量计数可用数量。
// FreeRTOS 互斥量使用
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void task1(void *pvParameters) {
    if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
        // 访问共享资源
        xSemaphoreGive(xMutex);
    }
}

我曾经在一个项目里,用二进制信号量保护共享内存。结果高优先级任务被低优先级任务堵死了。查了两天才发现——信号量没有优先级继承,反转了。

核心原则:保护共享资源用互斥量;任务与中断同步用二进制信号量;管理资源池用计数信号量。别混用。

4.4 优先级反转与继承

优先级反转,是实时系统里最隐蔽的杀手。看个经典场景:

  1. 任务L(低优先级)持有互斥量。
  2. 任务H(高优先级)来抢互斥量,被阻塞。
  3. 任务M(中优先级)开始运行,它不需要互斥量,但抢占了L。
  4. L无法运行,释放不了互斥量。H就一直等。

结果:H被M间接阻塞了。明明H优先级最高,却被中优先级的任务拖死。这就是反转。

优先级继承就是解决这个问题的。当H被L阻塞时,L临时提升到H的优先级。这样M就抢不了L,L赶紧跑完释放锁,H恢复运行。

注意:优先级继承只能缓解反转,不能完全消除。而且继承链可能很长——A等B,B等C,C等D。我见过一个系统,继承链长达7层,调度器都快疯了。设计时尽量缩短锁持有时间,减少嵌套。

还有一种更彻底的方法:优先级天花板。给每个互斥量设一个最高优先级,谁拿锁,谁就提到那个优先级。简单粗暴,但需要静态分析所有任务优先级。

方案 优点 缺点
优先级继承 动态调整,灵活 实现复杂,有死锁风险
优先级天花板 实现简单,可预测 需要静态配置,可能过度提升
关中断 彻底避免反转 影响实时性,不能用于多核

4.5 死锁检测与预防

死锁,就是两个任务互相等对方释放锁,谁也动不了。经典场景:

  • 任务A持有锁1,等锁2。
  • 任务B持有锁2,等锁1。
  • 死锁。

我遇到过最离谱的一次,是三个任务循环等待,系统完全卡死。看门狗复位后复现,再复位再复现。最后用逻辑分析仪抓了三天才定位到。

死锁的四个必要条件(记住,破坏任意一个就能预防):

  1. 互斥:资源一次只能一个任务用。
  2. 持有并等待:拿着一个锁,还去等另一个。
  3. 不可剥夺:锁只能持有者释放。
  4. 循环等待:形成等待环。

预防策略,我常用的几种:

  • 锁顺序:所有任务按相同顺序拿锁。比如先拿锁1再拿锁2,就不会循环。
  • 一次性申请:要么拿到所有锁,要么一个都不拿。释放时一起释放。
  • 超时机制:拿锁设超时,拿不到就放弃已有锁,重试。
// 超时拿锁,避免死锁
if (xSemaphoreTake(lock1, pdMS_TO_TICKS(100)) == pdTRUE) {
    if (xSemaphoreTake(lock2, pdMS_TO_TICKS(100)) == pdTRUE) {
        // 两个锁都拿到了
        xSemaphoreGive(lock2);
    }
    xSemaphoreGive(lock1);  // 没拿到lock2也要释放lock1
}
调试技巧:我习惯在RTOS里挂一个死锁检测任务,定期检查所有任务状态。如果发现某个任务长时间处于“等待互斥量”状态,就打印调用栈。FreeRTOS的uxTaskGetSystemState()可以拿到任务状态。

还有一种更高级的方法:银行家算法。系统预先计算分配是否安全,不安全就不分配。但嵌入式系统资源有限,很少用这么重的算法。我做了十年,只用过一次——在一个航天项目中,安全等级要求极高。

4.6 知识结构总览

下面这张图,把临界区保护的整个体系串起来了。我建议你存下来,做设计时对照着看。

临界区与同步机制知识结构图 临界区保护 关中断/开中断 锁机制 信号量/互斥量 嵌套保存 时间控制 自旋锁 互斥锁 二进制信号量 计数信号量 优先级反转 死锁 优先级继承 优先级天花板 锁顺序 超时机制 一次性申请 核心原则:破坏死锁四个必要条件之一,即可预防死锁 互斥 | 持有并等待 | 不可剥夺 | 循环等待

这张图从中心向外展开。最上层是保护手段,中间是核心问题,最下面是解决方案。你设计时,从问题出发,往上找手段,往下找解法。

嗯,临界区这块就这些。记住一句话:没有银弹。关中断快但影响实时性,互斥量安全但有反转风险,优先级继承能解决问题但实现复杂。选什么,看你的系统对实时性、安全性、复杂度的权衡。