任务调度方法概述
FreeRTOS 主要提供两种任务调度算法:
- 基于优先级的抢占式 (pre-emptive) 调度算法:可选择使用或不使用时间片。
- 合作式调度 (co-operative) 算法
调度方式与配置参数对比
在 FreeRTOS 中,可以通过配置宏定义来选择不同的调度方式:
- 抢占式 (使用时间片)
- 宏定义参数及取值:
configUSE_PREEMPTION = 1configUSE_TIME_SLICING = 1
- 特点:基于优先级的抢占式任务调度,同优先级任务使用时间片轮流进入运行状态 (默认模式)。
- 宏定义参数及取值:
- 抢占式 (不使用时间片)
- 宏定义参数及取值:
configUSE_PREEMPTION = 1configUSE_TIME_SLICING = 0
- 特点:基于优先级的抢占式任务调度,同优先级任务不使用时间片调度。
- 宏定义参数及取值:
- 合作式
- 宏定义参数及取值:
configUSE_PREEMPTION = 0configUSE_TIME_SLICING = 任意
- 特点:只有当运行状态的任务进入阻塞状态,或显式地调用要求执行任务调度的函数
taskYIELD(),FreeRTOS 才会发生任务调度,选择就绪状态的高优先级任务进入运行状态。
- 宏定义参数及取值:
使用时间片的抢占式调度方法
FreeRTOS 基础时钟的一个定时周期称为一个时间片 (time slice),默认值为 1ms。
当使用时间片时,在基础时钟的每次中断里会要求进行一次上下文切换 (context switching)。函数 xPortSysTickHandler() 就是 SysTick 定时中断的处理函数。
相同优先级的任务轮流使用时间片。
SysTick 定时中断处理函数:
void xPortSysTickHandler( void )
{ /* SysTick 中断的抢占优先级是15,优先级最低 */
portDISABLE_INTERRUPTS(); // 禁用所有中断
{
if( xTaskIncrementTick() != pdFALSE ) // 增加 RTOS 嘀嗒计数器的值
{
/* 将 PendSV 中断的挂起标志位置位,申请进行上下文切换,上下文切换在 PendSV 中断里处理 */
portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
}
}
portENABLE_INTERRUPTS(); // 使能中断
}
在这里我们可以看到,SysTick 中断本身并不直接执行上下文切换,而是通过操作 NVIC 寄存器 (portNVIC_INT_CTRL_REG) 来挂起 PendSV 中断,这是一种非常典型的解耦设计。

在 cube mx 里可以看到,系统滴答定时器中断和挂起中断的优先级都被设置为了 15,说明 freertos 的任务优先级总是低于系统中断优先级。
任务运行时序图解析 (带时间片的抢占式调度)

时序过程拆解
我们可以把 t1 到 t8 的过程看作是一部微观的系统运作纪录片 :
- t1 – t2:只有 Idle Task(空闲任务)处于就绪态,因此它独占 CPU 运行。Task1 和 Task2 都处于阻塞态(比如正在执行
vTaskDelay)。 - t2:Task1 的阻塞条件解除(例如延时结束),进入就绪态。因为 Task1 的优先级高于 Idle Task,它立刻抢占了 CPU 开始运行。此时 Idle Task 被迫让出 CPU,变为就绪态(水平虚线)。
- t3:Task1 主动进入阻塞态(例如调用了延时函数,或等待信号量)。此时就绪列表中只有 Idle Task,于是 Idle Task 恢复运行。
- t4:Task1 再次解除阻塞,重新抢占 CPU 运行。
- t5 :在 Task1 正在运行的中途,高优先级任务 Task2 的阻塞条件解除。此时触发了我们在上一节提到的 SysTick 或硬件中断,系统立刻执行上下文切换。Task1 被无情打断,被迫退回到就绪态等待(蓝色虚线),Task2 抢占 CPU 开始运行(红色矩形)。
- t6:Task2 执行完毕它当前周期的工作,主动调用阻塞函数进入等待。系统重新在就绪列表中寻找最高优先级任务,也就是刚才被打断的 Task1。Task1 从 t5 时刻保存的断点处继续往下运行。
- t7 – t8:Task1 再次进入阻塞态,系统再次将 CPU 交给 Idle Task。
结合实际场景的思考
假设我们正在一块 STM32F429IGT6 单片机上开发一个经典游戏模拟器1:
- Task 2 (High) 可以是游戏物理引擎与核心逻辑。它必须保证每秒 60 次的绝对稳定运算,不能有丝毫卡顿。
- Task 1 (Normal) 可以是屏幕刷新与字库渲染任务。
- Idle Task 则是系统空闲时的状态。
对应到上面的时序图:在 t4 到 t5 期间,单片机正在费力地从 Flash 中读取字库并渲染到屏幕上(Task 1)。但是到了 t5 时刻,计算下一帧游戏逻辑的定时器到期了。此时 FreeRTOS 会毫不犹豫地暂停字库渲染,让 CPU 先去计算游戏物理碰撞(Task 2)。等 t6 时刻物理逻辑计算完并挂起后,才会继续回来把剩下的半个字画完。这种机制保证了即使图形渲染再复杂,也不会拖慢游戏的核心按键响应和逻辑判定。
不使用时间片的抢占式调度方法
与使用时间片的模式(在每个 SysTick 中断里都请求上下文切换)不同,不使用时间片的抢占式调度算法触发任务调度的条件更为苛刻。它只在以下两种特定情况下才会进行任务调度:
- 抢占发生:有更高级别的任务进入就绪状态时。
- 主动让出:运行状态的任务进入阻塞状态或挂起状态时。
优缺点分析:
因为去除了 SysTick 级别的周期性强制切换,进行上下文切换的频率比使用时间片时低,从而可降低 CPU 的负担。但是,对于同优先级的任务可能会出现占用 CPU 时间相差很大的情况。
合作式任务调度方法
使用合作式任务调度方法时,FreeRTOS 不主动进行上下文切换。而是运行状态的任务进入阻塞状态,或显式地调用 taskYIELD() 函数让出 CPU 使用权时才进行上下文切换。
由于任务不会发生抢占,所以也不使用时间片。
函数 taskYIELD() 的作用就是主动申请进行一次上下文切换。
任务运行时序图解析(合作式任务调度)

由于系统不会强制剥夺当前任务的执行权,高优先级任务即使已经满足了运行条件,也必须耐心等待当前任务主动交出 CPU。
以下是各时间节点的详细动作拆解:
- t1时刻:低优先级的 Task1 处于运行状态。
- t2时刻:中等优先级的 Task2 满足了运行条件,进入就绪状态(对应蓝色的水平虚线),但由于是合作式调度,它不能抢占 CPU。此时 Task1 不受影响,继续执行。
- t3时刻:即使是最高优先级的 Task3 进入了就绪状态(对应红色的水平虚线),它同样不能抢占 CPU。此时系统中有两个更高优先级的任务在“排队”等待。
- t4时刻:这是一个关键的转折点。Task1 在其代码逻辑中,显式地调用了函数
taskYIELD(),主动申请进行一次上下文切换。调度器在此时介入,评估就绪列表,并将 CPU 使用权分配给了当前优先级最高的 Task3。 - t5时刻:Task3 运行了一小段时间后,可能因为调用了延时函数或等待某个信号量,进入了阻塞状态。调度器再次触发,将 CPU 的使用权交给了当时处于就绪状态的 Task2。
- t6时刻:Task2 执行完毕其当前的工作,也进入了阻塞状态。此时,高优先级的任务都在挂机,Task1 终于又获得了 CPU 使用权,从 t4 时刻让出 CPU 的断点处继续向下运行。
拓展
调用 osDelay 函数
当调用 osDelay(或者 FreeRTOS 原生的 vTaskDelay)时,该任务会立刻交出 CPU 的使用权,进入 阻塞状态 (Blocked state)。
状态流转的全过程
在底层,这个动作会引发一系列连贯的系统行为:
- 移出就绪链表:调用延时函数后,调度器会将这个任务从当前的“就绪列表 (Ready List)”中摘除,并计算出它的唤醒时间,然后将其挂载到“延时/阻塞列表 (Delayed List)”中。
- 触发上下文切换:因为当前任务不能继续运行了,系统会立刻触发一次任务调度(通常是通过触发 PendSV 中断),从当前处于就绪状态的任务中挑选优先级最高的一个,把 CPU 移交给它。
- 彻底休眠:在设定的延时时间到达之前,该任务保持阻塞状态。
- 后台计时与唤醒:系统的每一次基础时钟中断(SysTick)都会在
xTaskIncrementTick()中检查延时列表。当发现该任务设定的时间片到期时,RTOS 会自动把它从阻塞列表移回 就绪状态 (Ready state)。 - 抢占或等待:一旦回到就绪状态,如果它的优先级高于当前正在占用 CPU 的任务(且系统开启了抢占式调度),它就会立刻抢夺 CPU,重新回到 运行状态 继续执行延时函数之后的代码;如果优先级不够,保持在就绪列表里排队等待。
相关函数
任务相关
xTaskCreate():创建任务并以动态方式分配内存。
xTaskCreateStatic():创建任务并以静态方式分配内存。
vTaskDelete():删除任务。
如果要删除的是任务自己,那么必须在退出死循环之后、退出任务函数之前调用。freertos 会自动释放内核动态分配的空间。
请注意,使用静态分配创建的任务、任务内部由用户代码分配的内存不会被 freertos 自动释放,需要用户手动释放。
vTaskSuspend():挂起任务。
vTaskResume():恢复被挂起的任务。
任务调度器相关
vTaskStartScheduler():开启任务调度器。
vTaskSuspendAll():挂起任务调度器,但不禁止中断。调度器被挂起后,不再进行上下文的切换。
xTaskResumeAll():恢复任务调度器,但不恢复那些通过vTaskSuspend挂起的任务。
延时与调度函数相关
vTaskDelay:延时指定的节拍数,具体时间取决于时间片长度。
vTaskDelayUntil:延时指定的节拍数并进入阻塞状态,用于精确延时的周期性任务。
vTaskDelay和vTaskDelayUntil 的区别在于,vtaskdelay 延迟时间从调用的那一刻开始计算,而 vtaskdelayuntil 是从上一次调用时开始计算的。
假设我们希望一个任务严格按照每 10ms 执行一次的频率运行,任务逻辑执行花费 2ms,调用 vTaskDelay(10)。下一次唤醒是在 2ms + 10ms = 12ms 之后。这就已经偏离了 10ms 的初衷。如果调用之前被其它任务中断、抢占,会偏离更长时间。
除此之外,两者的调用方式也不同。vTaskDelayUntil 需要一个外部指针记录上次调用节拍数:
void vGameLoopTask( void * pvParameters )
{
// 1. 声明一个变量,用于保存任务上一次离开阻塞状态(被唤醒)的时间
TickType_t xLastWakeTime;
// 2. 定义任务的绝对执行周期。
// pdMS_TO_TICKS 宏可以将毫秒转换为系统 Tick 数(假设 configTICK_RATE_HZ 为 1000)
// 16ms 约等于 60 FPS 的刷新率
const TickType_t xFrequency = pdMS_TO_TICKS( 16 );
// 3. 重点:在进入无限循环之前,必须用当前的系统时间初始化 xLastWakeTime
xLastWakeTime = xTaskGetTickCount();
for( ;; )
{
/* 这里是你的核心逻辑代码 (耗时可能是不固定的) */
// 4. 调用绝对延时函数。
// 系统会自动计算:距离上次唤醒 (xLastWakeTime) 是否已经过去了 xFrequency 个 Tick。
// 如果还没到,任务就进入阻塞状态等待;如果已经过了,任务就不会阻塞。
// 注意:在函数内部,xLastWakeTime 的值会被自动更新为下一次的预期唤醒时间,无需我们手动累加。
vTaskDelayUntil( &xLastWakeTime, xFrequency );
}
}xTaskGetTickCount:返回当前滴答信号计数值。
xTaskAbortDelay:终止一个任务的延时,并使其立刻处于就绪状态。
taskYIELD:请求进行一次上下文切换。
- 因为我正打算做一个STM32的游戏机,所以才会举这个例子( ↩︎


发表回复