FreeRTOS 主要提供两种任务调度算法:
在 FreeRTOS 中,可以通过配置宏定义来选择不同的调度方式:
configUSE_PREEMPTION = 1configUSE_TIME_SLICING = 1configUSE_PREEMPTION = 1configUSE_TIME_SLICING = 0configUSE_PREEMPTION = 0configUSE_TIME_SLICING = 任意taskYIELD(),FreeRTOS 才会发生任务调度,选择就绪状态的高优先级任务进入运行状态。FreeRTOS 基础时钟的一个定时周期称为一个时间片 (time slice),默认值为 1ms。
当使用时间片时,在基础时钟的每次中断里会要求进行一次上下文切换 (context switching)。函数 xPortSysTickHandler() 就是 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 的过程看作是一部微观的系统运作纪录片 :
vTaskDelay)。假设我们正在一块 STM32F429IGT6 单片机上开发一个经典游戏模拟器1:
对应到上面的时序图:在 t4 到 t5 期间,单片机正在费力地从 Flash 中读取字库并渲染到屏幕上(Task 1)。但是到了 t5 时刻,计算下一帧游戏逻辑的定时器到期了。此时 FreeRTOS 会毫不犹豫地暂停字库渲染,让 CPU 先去计算游戏物理碰撞(Task 2)。等 t6 时刻物理逻辑计算完并挂起后,才会继续回来把剩下的半个字画完。这种机制保证了即使图形渲染再复杂,也不会拖慢游戏的核心按键响应和逻辑判定。
与使用时间片的模式(在每个 SysTick 中断里都请求上下文切换)不同,不使用时间片的抢占式调度算法触发任务调度的条件更为苛刻。它只在以下两种特定情况下才会进行任务调度:
因为去除了 SysTick 级别的周期性强制切换,进行上下文切换的频率比使用时间片时低,从而可降低 CPU 的负担。但是,对于同优先级的任务可能会出现占用 CPU 时间相差很大的情况。
使用合作式任务调度方法时,FreeRTOS 不主动进行上下文切换。而是运行状态的任务进入阻塞状态,或显式地调用 taskYIELD() 函数让出 CPU 使用权时才进行上下文切换。
由于任务不会发生抢占,所以也不使用时间片。
函数 taskYIELD() 的作用就是主动申请进行一次上下文切换。

由于系统不会强制剥夺当前任务的执行权,高优先级任务即使已经满足了运行条件,也必须耐心等待当前任务主动交出 CPU。
以下是各时间节点的详细动作拆解:
taskYIELD(),主动申请进行一次上下文切换。调度器在此时介入,评估就绪列表,并将 CPU 使用权分配给了当前优先级最高的 Task3。当调用 osDelay(或者 FreeRTOS 原生的 vTaskDelay)时,该任务会立刻交出 CPU 的使用权,进入 阻塞状态 (Blocked state)。
在底层,这个动作会引发一系列连贯的系统行为:
xTaskIncrementTick() 中检查延时列表。当发现该任务设定的时间片到期时,RTOS 会自动把它从阻塞列表移回 就绪状态 (Ready state)。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:请求进行一次上下文切换。