当多个任务(或任务与中断)同时访问同一个共享资源(全局变量、外设、文件、内存缓冲区等)时,如果不加以保护,就会出现竞态条件(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 A | Task B |
|---|---|---|
| 1 | 拿到 Mutex X | |
| 2 | 被抢占 | 拿到 Mutex Y |
| 3 | 尝试拿 X → 阻塞等待 | |
| 4 | 尝试拿 Y → 阻塞等待 | |
| 结果 | 两个任务永远互相等待,系统卡死 |
避免死锁的方法:
- 按固定顺序拿锁:所有任务都先拿 X 再拿 Y,就不会出现交叉等待
- 设置超时时间:
xSemaphoreTake(xMutex, pdMS_TO_TICKS(100)),超时后释放已持有的锁,稍后重试 - 尽量不嵌套拿锁:如果设计中到处需要同时拿两把锁,考虑重构
- 保持持锁时间最短:持锁期间不要做延时等无关操作
同优先级任务还锁后的调度陷阱
很多人以为同优先级任务还锁后,等待的任务会立刻拿到锁运行,其实不是:
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-事件组