第四篇FreeRTOS同步机制-信号量、互斥量与优先级反转

第四篇 FreeRTOS 同步机制:信号量、互斥量与优先级反转

掌握信号量与互斥量的原理和使用场景,深入理解优先级反转问题。这是 RTOS 面试中出现频率最高的知识点之一。本文从"为什么需要同步"讲起,带你彻底搞清楚二值信号量、计数信号量、互斥量的区别和实战用法。


一、为什么需要同步机制?

1.1 多任务并发问题

任务A: 读取温度 → [计算] → 写入全局变量 temp
任务B: 读取 temp → [显示到LCD]

问题:如果任务A写到一半被任务B抢占,B读到的就是"半成品"数据!

三大经典问题

  1. 竞态条件(Race Condition):多任务同时访问共享资源
  2. 数据不一致:读写被打断导致数据错乱
  3. 执行顺序:任务间需要协调先后顺序
ℹ️ 一句话理解:多任务系统中,共享资源如果不加保护,就会出现"你写到一半我来读"的混乱局面。同步机制就是解决这个问题的工具箱。

二、信号量(Semaphore)

2.1 二值信号量(Binary Semaphore)

就像一把只有一把钥匙的锁,只有 0 和 1 两种状态。

中断(Give)              二值信号量              任务(Take)
   │                     初始值=0                  │
   │── xSemaphoreGiveFromISR() ──→│  值=1           │
   │                              │── xSemaphoreTake() 成功 ──→│
   │                              │  值=0            │  执行处理逻辑

典型用途中断与任务同步(中断 Give,任务 Take)

SemaphoreHandle_t xBinarySem;

// 初始化
xBinarySem = xSemaphoreCreateBinary();  // 初始值为0(空)

// 中断服务函数中
void EXTI0_IRQHandler(void)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    xSemaphoreGiveFromISR(xBinarySem, &xHigherPriorityTaskWoken);

    // 如果唤醒了更高优先级的任务,触发上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中
void vProcessTask(void *pvParameters)
{
    for(;;)
    {
        // 等待信号量(阻塞直到中断Give)
        if(xSemaphoreTake(xBinarySem, portMAX_DELAY) == pdTRUE)
        {
            // 处理中断触发的事件
            ProcessData();
        }
    }
}
💡 关键点:二值信号量用 xSemaphoreCreateBinary() 创建后初始值为 0(空),任务会立即阻塞在 xSemaphoreTake(),直到中断调用 xSemaphoreGiveFromISR() 释放信号量。

2.2 计数信号量(Counting Semaphore)

就像一个停车场计数器,记录可用资源数量。

// 创建:最大值10,初始值0
SemaphoreHandle_t xCountSem = xSemaphoreCreateCounting(10, 0);

// 生产者:释放资源
xSemaphoreGive(xCountSem);  // 计数+1

// 消费者:获取资源
xSemaphoreTake(xCountSem, portMAX_DELAY);  // 计数-1,为0时阻塞

典型用途

  1. 事件计数:记录中断发生次数(防止丢失)
  2. 资源管理:管理有限资源池(如 DMA 通道、连接池)

2.3 二值信号量 vs 计数信号量

特性 二值信号量 计数信号量
值范围 0 或 1 0 ~ 最大值
用途 同步(通知) 资源管理 / 事件计数
中断中多次 Give 只记住一次 每次都计数
⚠️ 面试关键:二值信号量中断中连续多次 Give,任务只能 Take 一次(丢失事件);计数信号量每次 Give 都会计数,不会丢失事件。需要精确统计中断次数时必须用计数信号量。

三、互斥量(Mutex)

3.1 为什么不能用二值信号量保护临界区?

任务A(低优先级): Take → 进入临界区 → [被抢占]
任务C(高优先级): Take → 阻塞等待...
任务B(中优先级): 一直在运行(不需要信号量)

结果:高优先级C被中优先级B间接阻塞了!→ 优先级反转!
🔴 优先级反转:高优先级任务 C 等待低优先级任务 A 释放信号量,而中优先级任务 B 抢占了 A 的 CPU 时间。结果就是高优先级 C 被中优先级 B "间接阻塞",违背了优先级调度的初衷。经典案例:1997年火星探路者号因优先级反转导致系统反复重启。

3.2 互斥量 = 二值信号量 + 优先级继承

SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void vTaskA(void *p)  // 低优先级
{
    for(;;)
    {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 访问共享资源(临界区)
        SharedResource++;
        xSemaphoreGive(xMutex);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void vTaskC(void *p)  // 高优先级
{
    for(;;)
    {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 访问共享资源
        ReadSharedResource();
        xSemaphoreGive(xMutex);
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

3.3 优先级继承机制(核心重点)

低优先级任务A          中优先级任务B          高优先级任务C
      │                     │                     │
 持有 Mutex                 │                     │
 在临界区执行──────→        │              尝试 Take Mutex
      │                     │                  → 阻塞
 ┌────────────────────────────────────────────────┐
 │  A 的优先级被临时提升到与 C 相同!                    │
 │  B 无法抢占 A(A 现在优先级 = C)                    │
 └────────────────────────────────────────────────┘
      │                     │                     │
 释放 Mutex ─────────────────────────────→ 获得 Mutex
 优先级恢复原值             │               开始执行
💡 优先级继承:当高优先级任务等待低优先级任务持有的 Mutex 时,低优先级任务的优先级临时提升到与高优先级任务相同,防止中间优先级的任务造成优先级反转。Mutex 释放后优先级自动恢复。

3.4 信号量 vs 互斥量(高频面试题)

特性 二值信号量 互斥量
优先级继承 ❌ 无 ✅ 有
主要用途 同步(通知) 互斥(保护资源)
谁 Give/Take 不同任务(A Give, B Take) 同一任务(谁 Take 谁 Give)
中断中使用 ✅ 可以 GiveFromISR ❌ 不可以
优先级反转 会发生 通过继承缓解
💬 记忆口诀信号量管同步,互斥量管互斥。信号量像"通知"——一个人发信号、另一个人收信号;互斥量像"门锁"——谁锁的谁开,而且锁门期间你的"权限"(优先级)会临时升高防止被人插队。

3.5 递归互斥量

// 同一任务可以多次 Take,不会死锁
SemaphoreHandle_t xRecMutex = xSemaphoreCreateRecursiveMutex();

void vTask(void *p)
{
    xSemaphoreTakeRecursive(xRecMutex, portMAX_DELAY);  // Take 1次
    FuncA();  // FuncA 内部也 Take 了一次
    xSemaphoreGiveRecursive(xRecMutex);  // Give 1次
}

void FuncA(void)
{
    xSemaphoreTakeRecursive(xRecMutex, portMAX_DELAY);  // Take 第2次(不会死锁)
    // ...
    xSemaphoreGiveRecursive(xRecMutex);  // Give 第2次
}
ℹ️ 使用原则:Take 几次就要 Give 几次,计数归零后才真正释放。适用于同一任务内嵌套调用需要锁保护的函数。

四、临界区保护的其他方式

4.1 关中断

taskENTER_CRITICAL();    // 关中断(允许嵌套)
// 临界区代码(尽量短!)
taskEXIT_CRITICAL();     // 开中断

// ISR版本
UBaseType_t uxSaved = taskENTER_CRITICAL_FROM_ISR();
// ...
taskEXIT_CRITICAL_FROM_ISR(uxSaved);

4.2 挂起调度器

vTaskSuspendAll();       // 暂停调度器(中断仍然开启)
// 临界区代码
xTaskResumeAll();        // 恢复调度器

4.3 三种临界区保护方式对比

方式 关中断 挂起调度器 互斥量
中断响应 ❌ 中断被屏蔽 ✅ 中断正常 ✅ 中断正常
任务切换 ❌ 不会切换 ❌ 不会切换 ✅ 会切换(阻塞等待)
适用时长 极短(几us) 短(不宜太长) 可以较长
适用场景 ISR 与任务共享 纯任务间共享 任务间共享(推荐)
💡 选型速记
  • 临界区代码极短(几行赋值) → taskENTER_CRITICAL()
  • 临界区代码较长或涉及阻塞操作 → Mutex
  • 需要和 ISR 共享数据 → 关中断
  • 任务间共享 → Mutex(不影响中断响应)

五、实战练习

练习 1:中断同步 — 按键触发任务

SemaphoreHandle_t xButtonSem;

void vButtonTask(void *p)
{
    xButtonSem = xSemaphoreCreateBinary();
    for(;;)
    {
        if(xSemaphoreTake(xButtonSem, portMAX_DELAY) == pdTRUE)
        {
            printf("Button pressed! Processing...\r\n");
            HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        }
    }
}

// EXTI中断回调
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    BaseType_t xWoken = pdFALSE;
    if(GPIO_Pin == BUTTON_Pin)
    {
        xSemaphoreGiveFromISR(xButtonSem, &xWoken);
        portYIELD_FROM_ISR(xWoken);
    }
}

练习 2:Mutex 保护共享资源

SemaphoreHandle_t xMutex;
uint32_t ulSharedCounter = 0;

void vIncrementTask(void *p)
{
    for(;;)
    {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        for(int i = 0; i < 1000; i++)
            ulSharedCounter++;
        printf("Task%d: counter = %lu\r\n", (int)p, ulSharedCounter);
        xSemaphoreGive(xMutex);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 创建两个同优先级任务,观察 counter 是否正确递增
xTaskCreate(vIncrementTask, "Inc1", 256, (void*)1, 2, NULL);
xTaskCreate(vIncrementTask, "Inc2", 256, (void*)2, 2, NULL);
⚠️ 验证方法:如果不加 Mutex,两个任务同时对 ulSharedCounter 做 1000 次自增,最终值会比预期少(因为 ++ 操作不是原子的,读-改-写会被打断)。加了 Mutex 后,counter 应精确递增。

六、面试高频问答

1. 什么是优先级反转?怎么解决?

**优先级反转**:高优先级任务等待低优先级任务持有的资源,而中间优先级任务抢占了低优先级任务,导致高优先级任务被间接阻塞

**解决方案**:

- **优先级继承**(FreeRTOS Mutex 自带):临时提升持锁任务的优先级
- **优先级天花板**(FreeRTOS 不支持):将 Mutex 设为所有使用者的最高优先级
- 经典案例:**火星探路者号**(1997年)因优先级反转导致系统重启

2. Mutex 能在中断中使用吗?为什么?

**不能**。因为:

1. Mutex 有优先级继承机制,中断没有"优先级"概念(中断优先级和任务优先级是两套体系
2. Mutex 的 Take 可能导致阻塞,**中断中绝对不能阻塞**
3. 中断中应使用**二值信号量**(xSemaphoreGiveFromISR)来通知任务

3. 什么是死锁?FreeRTOS 怎么避免?

**死锁**:两个任务互相等待对方持有的资源。

- 任务A持有MutexA,等待MutexB
- 任务B持有MutexB,等待MutexA

**避免方法**:

1. 所有任务按相同顺序获取多个 Mutex
2. 使用超时参数(不用 portMAX_DELAY
3. 尽量减少同时持有多个 Mutex 的情况
4. 使用**递归互斥量**防止自死锁

4. taskENTER_CRITICAL() 和 Mutex 怎么选?

- 临界区代码**极短**(几行赋值) → 关中断 taskENTER_CRITICAL()
- 临界区代码**较长**或涉及阻塞操作 → 用 Mutex
- 需要和 **ISR 共享**数据 → 关中断
- 纯**任务间**共享 → Mutex(不影响中断响应)

核心原则:关中断时间越短越好,长时间关中断会严重影响系统实时性。


七、巩固练习题

选择题

1. 以下哪个说法正确?
A. 二值信号量有优先级继承  B. 互斥量可以在中断中使用
C. 互斥量有优先级继承机制  D. 计数信号量只能计数到1

**答案:C** ✅

💡 **解析**:互斥量 = 二值信号量 + 优先级继承,这是它与二值信号量的本质区别。A 错(二值信号量无优先级继承),B 错(Mutex 不能在中断中使用),D 错(计数信号量可计数到设定的最大值)。

2. 中断服务函数中应该使用哪个 API 释放信号量?
A. xSemaphoreGive()  B. xSemaphoreGiveFromISR()
C. xSemaphoreTake()  D. xSemaphoreCreateBinary()

**答案:B** ✅

💡 **解析**:中断中必须使用带 FromISR 后缀的 API。普通版本可能导致任务调度操作在中断上下文中执行,引发系统崩溃。

3. 以下哪种情况会导致优先级反转?
A. 高优先级任务使用 Mutex 保护资源
B. 低优先级任务持有二值信号量,高优先级任务等待,中优先级任务抢占
C. 两个同优先级任务使用 Mutex
D. 任务使用 taskENTER_CRITICAL 保护临界区

**答案:B** ✅

💡 **解析**:优先级反转的经典场景:低优先级持有资源 → 高优先级等待 → 中优先级抢占低优先级 → 高优先级被间接阻塞。使用 Mutex 的优先级继承可以缓解(A选项)。关中断(D选项)不存在此问题。

4. xSemaphoreCreateBinary() 创建的信号量初始值是?
A. 1  B. 0  C. -1  D. 未定义

**答案:B. 0** ✅

💡 **解析**:二值信号量创建后初始值为 0(空),任务调用 xSemaphoreTake() 会立即阻塞,需要等待其他地方 Give。注意:xSemaphoreCreateMutex() 创建的 Mutex 初始值为 1(可用)。

5. 关于递归互斥量,以下说法错误的是?
A. 同一任务可以多次 Take 不会死锁
B. Take 几次就要 Give 几次
C. 不同任务可以对同一个递归互斥量交叉 Take/Give
D. 使用 xSemaphoreCreateRecursiveMutex() 创建

**答案:C** ✅

💡 **解析**:递归互斥量的"递归"是指同一任务可以多次 Take。不同任务不能交叉 Take/Give,Mutex 的基本原则始终是"谁 Take 谁 Give"。

填空题

6. FreeRTOS 中,信号量主要用于____,互斥量主要用于____

信号量主要用于同步(通知/事件触发),互斥量主要用于互斥(保护共享资源)

💡 信号量是"通知机制"(A发信号给B),互斥量是"锁机制"(谁锁谁开)。

7. 互斥量不能在中断中使用的两个原因是:____ 和 ____

1. Mutex 有优先级继承机制,中断没有任务优先级概念(两套体系)
2. Mutex 的 Take 可能导致阻塞,中断中绝对不能阻塞

💡 中断中需要同步时,应使用 xSemaphoreGiveFromISR() 释放二值信号量来通知任务。

8. 优先级继承是指:当____优先级任务等待____优先级任务持有的 Mutex 时,____优先级任务的优先级被临时提升到与____优先级任务相同

优先级任务等待优先级任务持有的 Mutex 时,优先级任务的优先级被临时提升到与优先级任务相同。

💡 Mutex 释放后,低优先级任务的优先级会自动恢复原值。

9. 避免死锁的三种方法:____、____、____

1. 所有任务按相同顺序获取多个 Mutex
2. 使用超时参数(不用 portMAX_DELAY
3. 尽量减少同时持有多个 Mutex 的情况

💡 还可以使用递归互斥量防止"自死锁"(同一任务对同一 Mutex 重复 Take)。

简答题

10. 面试官问:"请详细说明优先级反转的过程,以及 FreeRTOS 如何解决?"

**优先级反转过程**:

1. 低优先级任务 A 获取了信号量(或 Mutex),进入临界区执行
2. 高优先级任务 C 就绪,抢占 A,然后尝试获取同一个信号量 → 阻塞等待
3. 中优先级任务 B 就绪,由于 A 的优先级低于 B,B 抢占了 A
4. 结果:A 无法运行所以无法释放信号量,C 只能一直等。实际执行顺序变成 B > C,中优先级反而比高优先级先执行

**FreeRTOS 的解决方案(优先级继承)**:

- 使用 xSemaphoreCreateMutex() 代替 xSemaphoreCreateBinary()
- 当 C 等待 A 持有的 Mutex 时,A 的优先级被临时提升到 C 的优先级
- B 无法抢占 A(因为 A 此时优先级 = C > B)
- A 尽快执行完临界区并释放 Mutex,优先级恢复
- C 获得 Mutex,开始执行

11. 面试官问:"如果按键中断频繁触发,用二值信号量可能丢事件,怎么办?"

**问题分析**:

二值信号量只有 0 和 1 两个状态,连续多次 Give 只会将值保持在 1,任务只 Take 到一次。

**解决方案**:

1. **计数信号量**:xSemaphoreCreateCounting(MAX, 0),每次 Give 都会计数 +1,任务每次 Take 消费一个计数,不会丢失事件
2. **队列**:用 xQueueSendFromISR() 发送按键信息(带时间戳或键值),不仅不丢事件,还能携带额外数据
3. **任务通知**:xTaskNotifyFromISR() + eIncrement 模式,效率最高

实际项目中推荐方案 2(队列),因为既不丢事件又能传数据。

12. 面试官问:"vTaskSuspendAll() 和 taskENTER_CRITICAL() 都能保护临界区,区别是什么?"

| 对比 | taskENTER_CRITICAL() | vTaskSuspendAll() |
| :--- | :--- | :--- |
| 中断 | 关闭中断,中断不响应 | 中断正常响应 |
| 任务调度 | 不会发生 | 不会发生 |
| 适用场景 | ISR 和任务共享的变量 | 纯任务间共享的变量 |
| 时长要求 | 必须极短(us 级) | 可以稍长但不宜太长 |

**核心区别**:taskENTER_CRITICAL() 关中断,所有中断不响应,影响系统实时性vTaskSuspendAll() 只暂停调度器,中断仍能响应,对实时性影响小。

**选型原则**:如果需要防止 ISR 访问共享数据,必须关中断;如果只是防止其他任务抢占,用挂起调度器即可。


八、总结 Checklist

  • [ ] 理解信号量的 "Give / Take" 模型
  • [ ] 区分二值信号量、计数信号量、互斥量的使用场景
  • [ ] 能解释优先级反转及其解决方案
  • [ ] 掌握 Mutex 的优先级继承机制
  • [ ] 知道三种临界区保护方式的适用场景
  • [ ] 完成中断同步实验
  • [ ] 完成 Mutex 保护共享资源实验