目录
前言
本文章以一个Demo工程为例来阐述在FreeRTOS工程中,系统不能从低优先级任务切换到高优先级任务的一些基本的原因。
- 本工程设置了两个任务A和B
- 任务A的优先级比任务B的优先级高
- 任务A一直在等待任务通知(阻塞态),任务内容为置位标志位,然后进入死循环
- 任务B的内容是在不断地循环中置位标志位,但从不进入阻塞
- 使用按键触发外部中断,在外部中断的回调函数内向任务A发送任务通知
- 设置四个标志位:分别为SysTick标志,用于标志系统时基(每1ms)
任务A标志位,指示任务A在运行
任务B标志位,指示任务B在运行
KEY标志位,指示按键被按下 - 在文件FreeRTOS.h中设置
#define configUSE_TIME_SLICING 1
(开启时间片轮转) - 在文件FreeRTOSConfig.h中设置
#define configUSE_PREEMPTION 1
(允许优先级抢占)
一、Demo工程代码
- 任务配置文件:
#include "rtos.h"
TaskHandle_t StartTask_Handler;
TaskHandle_t Demo_task_A_Handler;
TaskHandle_t Demo_task_B_Handler;
uint8_t systick_flag = 0; // SysTick中断标志位
uint8_t Demot_A_flag = 0; // 任务A标志位
uint8_t Demot_B_flag = 0; // 任务B标志位
uint8_t Key_cnt_flag = 0; // 按键按下标志
void start_task(void *pvParameters)
{
//进入临界区
taskENTER_CRITICAL();
xTaskCreate((TaskFunction_t )Demo_task_A, //DEMO_A
(const char* )"Demo_task_A",
(uint16_t )Demo_task_A_STK_SIZE,
(void* )NULL,
(UBaseType_t )3,
(TaskHandle_t* )&Demo_task_A_Handler);
xTaskCreate((TaskFunction_t )Demo_task_B, //DEMO_B
(const char* )"Demo_task_B",
(uint16_t )Demo_task_B_STK_SIZE,
(void* )NULL,
(UBaseType_t )1,
(TaskHandle_t* )&Demo_task_B_Handler);
//删除开始任务
vTaskDelete(StartTask_Handler);
//退出临界区
taskEXIT_CRITICAL();
}
void Demo_task_A(void* pvParameters)
{
uint32_t ulNotificationValue;
for(;;)
{
xTaskNotifyWait( 0, // 清除所有bit位(初始条件)
EVENT_KEY_BIT0, // 清除bit位0(退出条件)
&ulNotificationValue, // 接收到的数值
portMAX_DELAY ); // 无限等待
Demot_A_flag = 1;
Demot_B_flag = 0;
while(1);
}
}
void Demo_task_B(void* pvParameters)
{
for(;;)
{
Demot_A_flag = 0;
Demot_B_flag = 1;
}
}
void vApplicationTickHook (void) //SysTick钩子函数
{
systick_flag = !systick_flag;
}
- GPIO中断:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if(GPIO_Pin == GPIO_PIN_4)
{
Key_cnt_flag = 1;
xTaskNotifyFromISR( Demo_task_A_Handler, // 目标任务句柄
EVENT_KEY_BIT0, // 设置位0
eSetBits, // 动作为按位设置(不影响其他位)
&xHigherPriorityTaskWoken);
// portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // 退出中断函数后立即启动抢占调度,故意不启用
}
}
二、portYIELD函数 / portYIELD_FROM_ISR函数
这两个函数作用类似,一个是任务使用版本,另一个是外设中断中使用的安全版本。
- 为了实验效果,最开始先故意不使用这个函数,但是姑且讲一下这个函数的作用。
在 FreeRTOS 中,portYIELD() 是一个关键宏,用于手动触发任务调度。它的核心作用是强制调度器立即检查是否有更高优先级的任务就绪,并执行上下文切换(如果满足抢占条件)。
1. portYIELD() 的作用
- 手动触发上下文切换:调用 portYIELD() 会强制调度器立即检查任务就绪列表,如果存在更高优先级任务或同优先级任务需要时间片轮转,则切换任务。
- 不依赖中断或阻塞:与自动调度(如 Tick 中断或任务阻塞)不同,portYIELD() 允许在任务代码中主动让出 CPU。
2. 使用场景
- 协作式多任务处理:在协作式调度(需配置 configUSE_PREEMPTION=0)中,任务需主动调用 portYIELD() 让出 CPU。
- 优化实时性:即使启用抢占式调度(configUSE_PREEMPTION=1),若任务执行长循环且不阻塞,插入 portYIELD() 可减少高优先级任务的响应延迟。
- 同步共享资源访问:在未使用互斥量的情况下,通过 portYIELD() 避免长时间占用共享资源。
三、实验过程
1. 示例一
- 现象如下图
可以看到当按键按下的时候,中断函数向任务A发送了任务通知,但是任务A从未成功运行。从理论上来说,由于A优先级比B高,任务A进入就绪态后,调度器应该进行任务切换到任务A。但是现象并非如此,难道任务A没有进入就绪态吗?
对该现象进行阐述:
- 任务 A 确实已就绪,但调度器未及时响应:当外部中断发送任务通知给任务 A 时,任务 A 的阻塞状态会被解除,其状态标记为 就绪(Ready)。但 FreeRTOS 的调度器 不会主动轮询所有任务的就绪状态,而是通过以下两种方式触发任务切换:
显式触发: 在中断或任务中调用 portYIELD_FROM_ISR() 或 taskYIELD(),强制调度器立即检查就绪任务。
隐式触发: 在系统 Tick 中断中,通过 xTaskIncrementTick() 检测到超时任务或时间片耗尽。
- 在这个Demo工程场景中
中断函数未调用 portYIELD_FROM_ISR(),因此未显式触发调度。
任务 A 的解除阻塞是通过任务通知(而非 Tick 机制),因此 xTaskIncrementTick() 不会主动感知任务 A 的就绪状态。
综上所述,任务A进入就绪态后,没有调用 portYIELD_FROM_ISR
函数,调度器不会主动查询任务状态,也就不知道任务A已经Ready;另外,通过SysTick中断调用xTaskIncrementTick
函数,并读取其返回值来与调度器进行协作,但是在该程序下没有任务需要进行时间片切换(没有与任务B优先级相同的任务并且任务B永不主动进入阻塞),导致xTaskIncrementTick
函数的返回值为pdFALSE,因此也不会触发调度器切换上下文,而是继续执行B任务。
2. 示例二
这一次在任务B中添加主动阻塞并观察现象
void Demo_task_B(void* pvParameters)
{
for(;;)
{
Demot_A_flag = 0;
Demot_B_flag = 1;
vTaskDelay(3); //执行置位操作之后进入3ms的阻塞
}
}
- 现象如下图
- 任务B调用 vTaskDelay(3)
行为: 任务B主动请求阻塞(Block)3个Tick(每个Tick为1ms,总阻塞时间为3ms)
内部操作:
1.将任务B从 就绪列表(Ready List) 移至 阻塞列表(Blocked List)。
2.在阻塞列表中记录任务B的唤醒时间:xTickCount + 3(xTickCount 为当前系统Tick计数值)。
- 触发任务切换
任务B让出CPU:由于任务B主动调用阻塞函数(vTaskDelay),在下一个Tick中断到来时xTaskIncrementTick
函数会返回pdTRUE,FreeRTOS会立即触发调度器,执行以下操作:
1.检查当前是否有更高优先级的任务处于就绪状态(此处为任务A,优先级3)。
2.若有更高优先级任务,立即切换到该任务(任务A)。
- 切换到任务A的流程
调度器决策:调度器发现任务A已就绪(例如任务A之前通过任务通知解除阻塞),且优先级高于其他就绪任务(如空闲任务)。
上下文切换:
1.保存任务B的上下文(寄存器、堆栈指针等)到其任务控制块(TCB)。
2.恢复任务A的上下文,从任务A上次阻塞或切换出的位置继续执行。
3. 示例三
这一次删除任务B的主动阻塞(vTaskDelay)但是把任务B的优先级改成0,同空闲任务。
xTaskCreate((TaskFunction_t )Demo_task_B, //DEMO_B
(const char* )"Demo_task_B",
(uint16_t )Demo_task_B_STK_SIZE,
(void* )NULL,
(UBaseType_t )0,
(TaskHandle_t* )&Demo_task_B_Handler);
void
Demo_task_B(void* pvParameters)
{
for(;;)
{
Demot_A_flag = 0;
Demot_B_flag = 1;
}
}
实验现象:
从图中可以观察到现象与示例二相似,虽然任务B没有进行主动阻塞,但是也成功切换到任务A了。那为什么示例一中没有进行主动阻塞就不能切换到任务A呢?
- 关键原因:当任务 B 的优先级为 0 时(示例三),它能触发切换到任务 A 的根本原因是时间片轮转(Time Slicing) 机制强制释放 CPU,使得调度器有机会检查到任务 A 已就绪。而当任务 B 的优先级为 1 时(示例一),由于没有调度点强制触发切换,且任务 A 的就绪状态未被 Tick 中断主动感知,导致任务 B 持续独占 CPU。
任务 B 优先级为 0 时的行为:
-
与空闲任务同级:空闲任务的优先级固定为 0,当任务 B 的优先级也为 0 时,它与空闲任务处于同一优先级。
FreeRTOS 默认启用时间片轮转(需配置configUSE_TIME_SLICING=1
),同优先级任务会共享 CPU 时间片(每个时间片长度为 1/configTICK_RATE_HZ,即 1ms)。 -
时间片耗尽触发强制切换:即使任务 B 不主动阻塞,每经过 1ms 的 Tick 中断,调度器会强制切换到同优先级的另一个任务(空闲任务)。空闲任务运行后,发现任务 A(优先级 3)已就绪,会立即让出 CPU,任务 A 开始执行。
任务 B 优先级为 1 时的行为:
-
压制空闲任务:任务 B 的优先级为 1,高于空闲任务(优先级 0)。由于任务 B 始终运行空循环且不阻塞,FreeRTOS 不会强制剥夺其 CPU 使用权(除非更高优先级任务就绪)。
-
Tick 中断的局限性:Tick 中断的
xTaskIncrementTick
函数主要处理基于时间的阻塞任务(如 vTaskDelay 超时),而非事件驱动的任务通知。任务 A 的解除阻塞是通过外部中断发送通知触发的,这一操作仅标记任务 A 为就绪态,但未通过portYIELD_FROM_ISR
通知调度器,因此 Tick 中断可能无法感知到任务 A 需要运行。 -
调度器的“盲区”:即使 Tick 中断触发,若
xTaskIncrementTick
未检测到需要切换的条件(例如没有超时任务解除阻塞),它会返回pdFALSE
,导致中断退出后不触发上下文切换。此时,任务 A 的就绪状态被忽略,任务 B 继续运行。
任务 A 已就绪,但 FreeRTOS 的调度器需要显式或隐式的调度点来响应其就绪状态。任务 B 优先级为 1 时,由于未触发任何调度点,调度器无法感知任务 A 的需求;而优先级为 0 时,时间片轮转机制间接提供了调度点,使得任务 A 得以运行。这体现了 FreeRTOS 事件驱动与时间驱动调度机制的差异。
4.示例四
这一次把程序还原成示例一的情况,然后在外部中断函数的结尾加上portYIELD_FROM_ISR
函数。
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
if(GPIO_Pin == GPIO_PIN_4)
{
Key_cnt_flag = 1;
xTaskNotifyFromISR( Demo_task_A_Handler, // 目标任务句柄
EVENT_KEY_BIT0, // 设置位0
eSetBits, // 动作为按位设置(不影响其他位)
&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken); // 退出中断函数后立即启动抢占调度
}
}
现象:
可以看到,当按键按下的时候,调度器切换到了任务A,甚至无需等待新的Tick到来,也不用等待任务B主动让出CPU 。这是因为该函数可以在退出中断之后立刻通知调度器查询当前任务列表的任务状态,如果有就绪态且优先级更高的任务立即进行切换。
四、导致高优先级任务不能运行的原因
通过上面四个示例可知,任务 A 的就绪状态能否被调度器检测到,取决于 触发调度的机制 和 任务解除阻塞的方式。
1.为什么任务 A 的就绪状态未被 Tick 中断感知?
FreeRTOS 的调度器通过以下两种方式感知任务就绪:
-
显式触发:在中断或任务中调用 portYIELD 或 portYIELD_FROM_ISR,强制要求调度器立即检查就绪任务。
-
隐式触发:通过 Tick 中断的 xTaskIncrementTick() 检测超时任务(如 vTaskDelay 结束),或通过时间片轮转强制切换同优先级任务。
任务通知的解除阻塞属于“显式事件”,但其就绪状态的传递依赖调度器的及时检查。若未调用 portYIELD_FROM_ISR,则:
任务 A 的就绪状态仅被记录在内部就绪列表中,但调度器不会主动扫描该列表,除非有调度点(如任务阻塞、时间片耗尽或显式切换)。
2.调度器何时能检测到任务 A 已就绪?
任务 A 的就绪状态能否被调度器检测到,取决于触发调度的机制:
- 任务 A 通过任务通知解除阻塞
触发方式:在外部中断中调用 xTaskNotifyFromISR() 解除任务 A 的阻塞。
未调用 portYIELD_FROM_ISR:此时任务 A 被标记为就绪,但调度器不会立即响应,而是等待下一个调度点(如显式调用 taskYIELD() 或 Tick 中断)。
Tick 中断的局限性:由于任务 A 的解除阻塞与时间无关,xTaskIncrementTick() 不会检测到其就绪状态,因此即使 Tick 中断触发,调度器也不会切换任务。
- 任务 A 通过 vTaskDelay 解除阻塞
触发方式:任务 A 调用 vTaskDelay(100) 阻塞自身,等待延时结束。
Tick 中断处理:当延时时间到达时,xTaskIncrementTick() 会发现任务 A 需要解除阻塞,将其移至就绪列表,并返回 pdTRUE,触发任务切换。
3. xTaskIncrementTick() 的作用
xTaskIncrementTick
是 FreeRTOS 在 系统节拍中断(Tick Interrupt) 中调用的核心函数,其关键行为如下:
-
更新时间计数器:递增系统时间(xTickCount),用于跟踪任务延时或超时。
-
处理阻塞任务:检查是否有任务因延时结束(如 vTaskDelay 超时)而需要解除阻塞。若有,则将其从阻塞列表移至就绪列表。
-
标记是否需要切换任务:若发现更高优先级的任务解除阻塞(或时间片轮转触发),则返回
pdTRUE
,提示调度器需要切换任务。
4. xTaskIncrementTick() 与调度器的关系
-
触发调度检查的条件:
xTaskIncrementTick
的返回值直接影响调度器是否在 Tick 中断退出后触发任务切换。
若返回 pdTRUE,调度器会调用 portYIELD() 强制切换任务;否则继续执行当前任务。 -
局限性:
xTaskIncrementTick
仅处理与时间相关的任务状态变更(如延时结束),而不会主动检测事件驱动的任务状态变更(如任务通知、信号量、队列等)。因此,若任务 A 的解除阻塞是由任务通知触发的(而非vTaskDelay
超时),xTaskIncrementTick
不会感知到任务 A 已就绪。
五、避免这个问题的方法
以本Demo工程为例:
- 在任务B中加入主动阻塞相关的函数,例如
vTaskDelay
- 将任务B的优先级改为0,或者与其他任务优先级相同,从而实现时间片轮转来强行切换上下文。
- (最推荐) 在中断函数的结尾添加
portYIELD_FROM_ISR
目的是为了在退出中断函数后,立即通知调度器来查询任务状态并进行上下文的切换。在实际使用中,往往存在需要较快响应的场景,使用这种方式能避免因等待下一个Tick中断所带来的延迟。
除了上述概括外,在实际的应用中还有很多更复杂的情况,例如优先级翻转等问题。需要在开发过程中合理配置内核变量等参数以及做好任务之间的同步。