在上一篇文章中,我们学会了用信号量实现任务同步与资源计数。但当多个任务需要修改同一个共享变量时,二值信号量可能会引入优先级反转的隐患。本篇将引入 FreeRTOS 专门为此设计的互斥量(Mutex),剖析其优先级继承机制,并通过实验直观对比互斥量与信号量的行为差异。
一、为什么二值信号量不能做互斥?
二值信号量可以被任意任务给出(Give),即使这个任务并没有事先获取(Take)它。这在“中断通知任务”的场景中是合理的——中断本身并不申请资源,它只是通知事件发生。
但在多任务共享同一资源的场景中,正确的规则应该是:只有成功获取资源的任务,才有资格释放它。二值信号量无法强制执行这一规则,因此会带来一个严重问题——优先级反转。
1.1 优先级反转的经典场景
假设系统中有三个任务:高优先级任务 H、中优先级任务 M、低优先级任务 L。
- L 先获取了信号量,开始访问共享资源;
- 随后 H 也尝试获取同一个信号量,于是被阻塞;
- 此时 M 就绪,由于 M 优先级高于 L,内核调度 M 运行,L 被抢占但还没有释放信号量;
- H 被 M 无限期地阻塞,尽管 H 的优先级最高——这就是优先级反转。
1.2 互斥量的解决方案
互斥量本质上是一种带有优先级继承机制的二进制信号量。当高优先级任务 H 因获取互斥量而被阻塞,且互斥量正被低优先级任务 L 持有时,内核会临时将 L 的优先级提升至与 H 相同,以防止 M 抢占 L。这样 L 可以尽快执行完毕并释放互斥量,H 得以继续运行。任务释放互斥量后,优先级自动恢复。
二、互斥量的关键 API
| 功能 | API 名称 | 说明 |
|---|---|---|
| 创建互斥量 | xSemaphoreCreateMutex | 返回互斥量句柄,初始为“可用”状态 |
| 获取互斥量(任务) | xSemaphoreTake | 与获取信号量共用同一个函数 |
| 释放互斥量(任务) | xSemaphoreGive | 只有成功获取的任务才能释放,否则返回失败 |
| 删除互斥量 | vSemaphoreDelete | 释放互斥量占用的内存 |
重要:互斥量不允许在中断服务函数中使用(没有 FromISR 版本),因为中断中不应持有锁。这也是互斥量与信号量的关键区别之一。
使用互斥量前,必须在FreeRTOSConfig.h中将configUSE_MUTEXES设置为 1(第一篇的配置中已默认开启)。
三、实验:模拟优先级反转与互斥量的效果
3.1 实验设计
创建三个任务:高(优先级 3)、中(优先级 2)、低(优先级 1),它们共享一个全局变量shared_counter。
- 低优先级任务 L:获取互斥量后,对共享资源进行耗时操作(模拟长时间临界区);
- 中优先级任务 M:不断翻转一个 LED,指示它是否在运行;
- 高优先级任务 H:同样尝试获取互斥量,操作共享变量。
我们先用二值信号量实现“互斥”,观察中优先级任务是否能在低优先级任务持有锁时继续运行(导致反转);然后换成互斥量,观察优先级继承的效果。
3.2 硬件准备
沿用之前的硬件:PA0、PA1 和 PC13 分别连接三个 LED。BSP 文件bsp_led.h/bsp_led.c中包含LED_InitAll()、LED1_Toggle()(PA0)、LED2_Toggle()(PA1)、LED3_Toggle()(PC13),此处不再重复给出。
同时确保main()中调用了NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);。
四、使用二值信号量的缺陷代码
#include"stm32f10x.h"#include"FreeRTOS.h"#include"task.h"#include"semphr.h"#include"bsp_led.h"SemaphoreHandle_t xBinarySem;/* 共享资源 */volatileuint32_tshared_counter=0;/* 高优先级任务 H(优先级 3) */voidvHighTask(void*pvParameters){while(1){if(xSemaphoreTake(xBinarySem,portMAX_DELAY)==pdTRUE){shared_counter++;// 快速操作共享变量xSemaphoreGive(xBinarySem);}vTaskDelay(pdMS_TO_TICKS(100));}}/* 中优先级任务 M(优先级 2) */voidvMediumTask(void*pvParameters){while(1){LED2_Toggle();// 翻转 PA1,指示该任务在运行vTaskDelay(pdMS_TO_TICKS(200));}}/* 低优先级任务 L(优先级 1) */voidvLowTask(void*pvParameters){while(1){if(xSemaphoreTake(xBinarySem,portMAX_DELAY)==pdTRUE){LED1_Toggle();// 表示低优先级任务进入临界区// 模拟长时间操作,如写 EEPROMfor(volatileuint32_ti=0;i<2000000;i++);xSemaphoreGive(xBinarySem);}vTaskDelay(pdMS_TO_TICKS(50));}}intmain(void){NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);LED_InitAll();/* 创建二值信号量,并先 Give 一次,使其处于可用状态 */xBinarySem=xSemaphoreCreateBinary();xSemaphoreGive(xBinarySem);// 初始可用xTaskCreate(vHighTask,"High",128,NULL,3,NULL);xTaskCreate(vMediumTask,"Medium",128,NULL,2,NULL);xTaskCreate(vLowTask,"Low",128,NULL,1,NULL);vTaskStartScheduler();while(1);}运行现象:你会看到Medium任务控制的 LED(PA1)在Low任务进入临界区时依然持续闪烁,说明中优先级任务成功抢占了低优先级任务,导致高优先级任务长时间无法获取信号量——典型的优先级反转。
五、替换为互斥量
将二值信号量替换为互斥量,只需修改创建和初始化部分,其余代码结构完全相同:
#include"stm32f10x.h"#include"FreeRTOS.h"#include"task.h"#include"semphr.h"#include"bsp_led.h"SemaphoreHandle_t xMutex;volatileuint32_tshared_counter=0;/* 高优先级任务 H(优先级 3) */voidvHighTask(void*pvParameters){while(1){if(xSemaphoreTake(xMutex,portMAX_DELAY)==pdTRUE){shared_counter++;xSemaphoreGive(xMutex);}vTaskDelay(pdMS_TO_TICKS(100));}}/* 中优先级任务 M(优先级 2) */voidvMediumTask(void*pvParameters){while(1){LED2_Toggle();vTaskDelay(pdMS_TO_TICKS(200));}}/* 低优先级任务 L(优先级 1) */voidvLowTask(void*pvParameters){while(1){if(xSemaphoreTake(xMutex,portMAX_DELAY)==pdTRUE){LED1_Toggle();for(volatileuint32_ti=0;i<2000000;i++);xSemaphoreGive(xMutex);}vTaskDelay(pdMS_TO_TICKS(50));}}intmain(void){NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);LED_InitAll();/* 创建互斥量:初始即为可用状态,无需额外 Give */xMutex=xSemaphoreCreateMutex();if(xMutex==NULL)while(1);xTaskCreate(vHighTask,"High",128,NULL,3,NULL);xTaskCreate(vMediumTask,"Medium",128,NULL,2,NULL);xTaskCreate(vLowTask,"Low",128,NULL,1,NULL);vTaskStartScheduler();while(1);}运行现象对比:使用互斥量后,当Low任务持有互斥量且High任务开始等待时,内核会临时将Low任务的优先级提升到 3(与High相同)。此时Medium任务(优先级 2)无法抢占Low,只能等待。直到Low完成操作释放互斥量,High立即获得互斥量继续运行。你会观察到Medium任务的 LED 在Low持有互斥量期间停止闪烁(或出现明显的停顿),这直观地验证了优先级继承机制的作用。
六、互斥量的正确使用规则
在任务中使用,绝不在中断中使用
互斥量没有 FromISR 版本,因为它可能引发优先级继承,这只能在任务上下文中完成。谁获取,谁释放
互斥量会检查释放者是否持有它。如果任务 A 获取了互斥量,却由任务 B 来释放,操作将失败。这就要求临界区代码的获取与释放必须成对出现,推荐在同一函数内完成。避免在持有期间长时间阻塞
持有互斥量的任务应尽快完成操作并释放,不要在临界区内调用vTaskDelay等可能长时间阻塞的函数,否则高优先级任务会被严重拖累。递归互斥量
如果一个任务可能需要多次获取同一个互斥量(例如嵌套函数调用),普通互斥量会导致死锁。此时应使用xSemaphoreCreateRecursiveMutex,并配合xSemaphoreTakeRecursive/xSemaphoreGiveRecursive使用。
七、总结
通过本篇的对比实验,我们清楚地认识到:
- 二值信号量适合中断与任务同步,但不能可靠地用于多任务间的互斥访问;
- 互斥量通过优先级继承机制,有效缓解了优先级反转问题,是保护共享资源的标准手段;
- 互斥量的使用规则比信号量更严格,必须在任务中成对使用,且绝不能在中断中调用。
下一篇文章我们将学习 FreeRTOS 的软件定时器,了解如何在不占用额外硬件定时器的情况下,实现定时回调与周期性处理。
下一篇:FreeRTOS 软件定时器 —— 不占硬件 Timer 的定时回调实现。