当多个任务(或任务与中断)同时访问同一个共享资源(全局变量、外设、文件、内存缓冲区等)时,如果不加以保护,就会出现竞态条件(Race Condition),导致数据不一致。

任务可以在任意时刻被抢占
→ 一个操作做到一半被打断
→ 另一个任务或中断修改了同一个数据
→ 回来继续时数据已经不对了

FreeRTOS 提供了多种资源保护机制,从轻到重依次为:临界区、挂起调度器、互斥锁、守门员任务。

临界区(Critical Section)

用一对宏把代码”保护”起来,进入临界区后中断被禁用,任务切换无法发生(因为任务切换是靠 Tick 中断或 PendSV 中断触发的),当前任务可以独占 CPU 安全地执行完临界代码。

taskENTER_CRITICAL();   // 进入临界区

// 被保护的代码(极短!)
PORTA |= 0x01;
shared_counter++;

taskEXIT_CRITICAL();    // 退出临界区

四条铁律

规则一:必须越短越好 临界区内中断是禁用的,所有中断都被延迟响应。太长会严重影响系统实时性,甚至丢失中断。临界区应该只包含对共享资源的访问本身,不要放计算、延时等操作。

规则二:进出必须成对 少一个 taskEXIT_CRITICAL(),中断将永远不会重新开启,系统彻底卡死。

规则三:支持嵌套 内核用一个计数器管理嵌套深度:

taskENTER_CRITICAL();   // 计数 = 1
    taskENTER_CRITICAL(); // 计数 = 2
    taskEXIT_CRITICAL();  // 计数 = 1(还没真正退出,中断仍然关闭)
taskEXIT_CRITICAL();    // 计数 = 0(真正退出,中断重新开启)

即使你调用的函数内部已经用了临界区,外面再包一层也是安全的。好的内核代码和驱动代码会自己保护需要保护的部分,但你不能保证某个函数永远只在已受保护的上下文中被调用,因此”该加就加”。

规则四:ISR 中要用专用版本

// ISR 版本必须保存进入前的中断状态
UBaseType_t uxSavedInterruptStatus;
uxSavedInterruptStatus = taskENTER_CRITICAL_FROM_ISR();

// 被保护的代码

taskEXIT_CRITICAL_FROM_ISR(uxSavedInterruptStatus);

普通版本的逻辑是”进门关灯,出门开灯”,但如果进门之前灯本来就是关着的(中断本来就被禁用了),出门时开灯就破坏了原来的状态。ISR 版本会记住进入前的状态,退出时原样恢复。

任务版本 vs ISR 版本对比

任务版本ISR 版本
进入宏taskENTER_CRITICAL()taskENTER_CRITICAL_FROM_ISR()
退出宏taskEXIT_CRITICAL()taskEXIT_CRITICAL_FROM_ISR(saved)
返回值进入前的中断状态,退出时必须传回去
类比进门关灯,出门开灯进门记录灯的状态再关灯,出门恢复原来状态

挂起调度器

另一种保护方式:不关中断,只是暂停任务切换

vTaskSuspendAll();   // 挂起调度器
// 较长的临界代码(中断仍然能响应)
xTaskResumeAll();    // 恢复调度器

与临界区的对比

临界区挂起调度器
保护方式关中断暂停调度器
中断还能响应吗❌ 不能✅ 能
ISR 中能否使用用 FromISR 版本❌ 不能
适合场景极短的操作(修改几个变量)稍长的操作(但不能太长)
影响范围大(所有中断延迟)小(只影响任务切换)

调度器挂起期间中断的处理

调度器挂起期间,如果中断触发了需要任务切换的操作(如释放信号量唤醒高优先级任务),FreeRTOS 会把这个切换请求记录下来但不执行。等 xTaskResumeAll() 恢复调度器时,一次性执行所有挂起的切换。

xTaskResumeAll() 返回值:

  • pdTRUE:恢复时执行了被挂起的任务切换
  • pdFALSE:恢复时没有发生切换

挂起调度器也支持嵌套(计数器管理),和临界区一样。

互斥锁(Mutex)

互斥锁(Mutual Exclusion)用于保护较长的共享资源访问,比如访问 SPI 总线、写文件、操作 LCD 屏等。临界区太短不能做复杂操作,挂起调度器期间中断虽然能用但任务不切换影响较大,互斥锁是更通用的选择。

基本使用

// 创建互斥锁
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

// 任务A拿锁访问资源
void vTaskA(void *pv) {
    for(;;) {
        xSemaphoreTake(xMutex, portMAX_DELAY);  // 拿到锁才能访问
        // 访问共享资源(SPI 传输、写 LCD 等)
        xSemaphoreGive(xMutex);                  // 用完还锁
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

Mutex 的工作流程:

初始:Mutex 可用(在桌上没人拿)
    │
    ▼
Task A 调用 xSemaphoreTake() → 成功拿到锁 → 访问资源
    │
    ▼
Task B 也调用 xSemaphoreTake() → 锁被占着 → B 阻塞等待
    │
    ▼
Task A 用完 → xSemaphoreGive() → 还锁
    │
    ▼
Task B 被唤醒 → 拿到锁 → 访问资源
    │
    ▼
Task B 用完 → 还锁 → 锁重新可用

绝对不要在中断中使用互斥锁

Mutex 内部带有优先级继承机制,需要任务上下文,ISR 中不能使用。中断中保护资源请用临界区或挂起调度器的 ISR 版本。

优先级反转与优先级继承

**优先级反转(Priority Inversion)**是实时系统的经典问题:

高优先级任务 H → 需要 Mutex,但被低优先级任务 L 持有 → H 阻塞等待
    │
    ▼
中优先级任务 M → 抢占了 L → M 开始运行
    │
    ▼
低优先级任务 L → 无法运行,无法释放 Mutex
    │
    ▼
高优先级任务 H → 被中优先级任务 M 间接阻塞!
    (H 的优先级实际上被"反转"到了比 M 还低)

最坏情况是无界优先级反转:如果 M 和其他中优先级任务一直有就绪的,L 永远拿不到 CPU,H 永远被阻塞。

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

当高优先级任务等待低优先级任务持有的 Mutex 时,持有锁的低优先级任务临时提升优先级到等待者的优先级,这样中优先级任务就无法抢占它了:

H 等 L 的锁 → L 临时"继承"H 的高优先级
→ M 无法抢占 L → L 尽快执行完释放锁
→ L 还锁后,L 的优先级恢复原值
→ H 拿到锁,立即执行

优先级继承不能完全消除优先级反转

它只是把反转时间缩短到”低优先级任务持有锁的时间”,不能消除反转本身。设计上还是应尽量减少持锁时间。

死锁(Deadlock)

两个任务互相等待对方持有的锁,永远阻塞:

步骤Task ATask B
1拿到 Mutex X
2被抢占拿到 Mutex Y
3尝试拿 X → 阻塞等待
4尝试拿 Y → 阻塞等待
结果两个任务永远互相等待,系统卡死

避免死锁的方法

  1. 按固定顺序拿锁:所有任务都先拿 X 再拿 Y,就不会出现交叉等待
  2. 设置超时时间xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)),超时后释放已持有的锁,稍后重试
  3. 尽量不嵌套拿锁:如果设计中到处需要同时拿两把锁,考虑重构
  4. 保持持锁时间最短:持锁期间不要做延时等无关操作

同优先级任务还锁后的调度陷阱

很多人以为同优先级任务还锁后,等待的任务会立刻拿到锁运行,其实不是:

Task2 还锁
  → Task1 只是从 Blocked 变成 Ready,还没运行
  → Task2 继续 Running,直到下一次 Tick 中断才可能切换

如果 Task2 还锁后又马上拿锁(比如循环里”拿锁→操作→还锁→生成数据→拿锁→操作…”),可能出现 Task1 永远拿不到锁的”饥饿”情况:

Task1:生成文字(快)→ 想拿锁 → 锁被占 → 阻塞...
Task2:拿锁 → 写显示屏(慢)→ 还锁 → 生成文字(快)→ 又拿锁...

解决方法:还锁后判断是否发生了时间片切换,如果是则主动让出 CPU:

xTimeAtWhichMutexWasTaken = xTaskGetTickCount();

xSemaphoreTake(xMutex, portMAX_DELAY);
vCopyTextToFrameBuffer(cTextBuffer);  // 慢操作
xSemaphoreGive(xMutex);

// 如果持锁期间发生过 Tick,说明有其他任务可能在等锁
if(xTaskGetTickCount() != xTimeAtWhichMutexWasTaken)
{
    taskYIELD();  // 主动让出 CPU
}

不要无脑 taskYIELD()

如果还锁后直接 taskYIELD(),两个同优先级任务会频繁切换,浪费 CPU 时间在上下文切换上。只有确实持锁时间跨过了 Tick(说明被抢占过,真的有其他任务在等)才让出。

递归互斥锁(Recursive Mutex)

普通 Mutex 不能被同一个任务重复 Take(会死锁)。递归互斥锁允许同一个任务多次 Take,但必须 Give 相同次数才真正释放:

SemaphoreHandle_t xRecursiveMutex = xSemaphoreCreateRecursiveMutex();

// 任务可以多次 Take
xSemaphoreTakeRecursive(xRecursiveMutex, portMAX_DELAY);  // 计数 1
    xSemaphoreTakeRecursive(xRecursiveMutex, portMAX_DELAY);  // 计数 2
        // 访问资源
    xSemaphoreGiveRecursive(xRecursiveMutex);  // 计数 1
xSemaphoreGiveRecursive(xRecursiveMutex);  // 计数 0,真正释放

用于一个函数拿锁后调用另一个也拿同一把锁的函数的场景。

守门员任务(Gatekeeper Task)

守门员任务是一种更简洁的资源管理模式:所有对共享资源的访问都通过唯一的一个任务来进行,其他任务不直接访问资源,而是通过队列发消息给守门员。

Task1 ──→ 发消息到队列 ──┐
Task2 ──→ 发消息到队列 ──┼──→ 队列 ──→ 守门员任务 ──→ 唯一访问资源
中断  ──→ 发消息到队列 ──┘

经典例子:printf 守门员

多个任务同时调用 printf 会导致输出乱码。用守门员解决:

QueueHandle_t xPrintQueue;

int main(void) {
    xPrintQueue = xQueueCreate(5, sizeof(char*));
    xTaskCreate(prvPrintTask, "PrintGatekeeper", 200, NULL, 1, NULL);
    xTaskCreate(vTask1, "Task1", 200, NULL, 2, NULL);
    xTaskCreate(vTask2, "Task2", 200, NULL, 2, NULL);
    vTaskStartScheduler();
}

// 守门员:唯一有权调用 printf 的任务
void prvPrintTask(void *pv) {
    char *pcMsg;
    for(;;) {
        xQueueReceive(xPrintQueue, &pcMsg, portMAX_DELAY);
        printf("%s", pcMsg);  // 只有守门员调用 printf
    }
}

// 其他任务:不直接 printf,发消息给守门员
void vTask1(void *pv) {
    char *msg = "Task1 在打印\n";
    for(;;) {
        xQueueSend(xPrintQueue, &msg, portMAX_DELAY);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

守门员模式的优缺点

优点缺点
设计简单,不需要 Mutex/信号量多了一个任务和一个队列,有轻微开销
天然串行化,不会有竞态所有访问都要经过队列,有延迟
可以集中管理资源的访问策略资源访问的优先级由守门员任务的优先级决定
中断也可以通过 FromISR 发送消息

保护机制选择指南

场景推荐机制
修改几个全局变量、设置寄存器位临界区(极短,关中断)
较长的操作,但不能关中断挂起调度器
访问外设(SPI/I2C/LCD)、文件操作互斥锁(Mutex)
多任务/中断都要输出日志、打印守门员任务
中断与任务之间同步事件二值信号量(见 04-中断处理

下一步:07-事件组

相关:04-中断处理03-队列返回目录