FreeRTOS 任务调度:策略、底层原理与 API 实践


本文以常见的 单核 Cortex-M3/M4/M7 + 抢占式 FreeRTOS 为主,介绍任务状态、调度策略、TCB 与链表、Tick 解阻塞、PendSV 上下文切换,以及任务 API 的使用注意事项。

内容导航


核心结论

FreeRTOS 的任务调度可以概括为:

  1. 每个任务拥有独立栈和一个任务控制块 TCB;
  2. 任务当前所在的链表表示其 Ready、Blocked 或 Suspended 状态;
  3. 调度器始终选择最高优先级的 Ready 任务;
  4. SysTick 维护内核时间并唤醒超时任务;
  5. PendSV 保存旧任务上下文并恢复新任务;
  6. 高优先级任务必须主动阻塞,否则会使低优先级任务饥饿。

最重要的设计原则
让任务等待事件时进入 Blocked 状态,不要让高优先级任务通过循环轮询长期保持 Ready。


任务状态与生命周期

状态 含义 是否占用 CPU
Running 当前正在执行
Ready 可以执行,等待 CPU
Blocked 等待时间或事件
Suspended 被显式挂起,不参与调度
Deleted 已删除,等待或已经完成资源回收
stateDiagram-v2
    [*] --> Ready: 创建任务
    Ready --> Running: 调度器选中
    Running --> Ready: 被抢占/时间片/主动让出
    Running --> Blocked: 延时或等待事件
    Blocked --> Ready: 超时或事件到达
    Running --> Suspended: vTaskSuspend
    Suspended --> Ready: vTaskResume
    Running --> Deleted: vTaskDelete
    Deleted --> [*]: Idle Task 回收资源

FreeRTOS 通常不维护单独的 Running List。当前任务由:

TCB_t * volatile pxCurrentTCB;

指向,而 Running 任务的状态节点仍位于相应优先级的 Ready List 中。


默认调度策略

FreeRTOS 默认采用:

固定优先级 + 抢占式调度 + 同优先级时间片轮转

固定优先级

任务优先级范围为:

0 ~ configMAX_PRIORITIES - 1

任务优先级数值越大,优先级越高;Idle Task 的优先级为 0。

任务优先级 5 > 任务优先级 3 > 任务优先级 0

抢占式调度

#define configUSE_PREEMPTION  1

当更高优先级任务进入 Ready 状态时,它会抢占当前较低优先级任务:

flowchart LR
    A["低优先级任务运行"] --> B["事件唤醒高优先级任务"]
    B --> C["高优先级任务进入 Ready"]
    C --> D["挂起 PendSV"]
    D --> E["上下文切换"]
    E --> F["高优先级任务运行"]

如果 configUSE_PREEMPTION 为 0,则进入协作式调度。当前任务通常需要主动让出、阻塞或挂起,其他任务才有机会运行。

同优先级时间片

#define configUSE_TIME_SLICING  1

多个最高优先级任务同时 Ready 时,SysTick 会驱动它们轮转:

Task A → Tick → Task B → Tick → Task C → Tick → Task A

关闭时间片只会取消 Tick 驱动的同级轮转;任务阻塞、挂起、删除或调用 taskYIELD() 时仍可能切换。

taskYIELD() 不会让低优先级任务运行

只要调用者仍是最高优先级的 Ready 任务,调度器最多选择另一个同优先级任务。

/* 不推荐:低优先级任务仍可能饥饿。 */
for (;;) {
    DoSomething();
    taskYIELD();
}

应让任务真正阻塞:

ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

TCB 与任务栈

每个任务都由一个 Task Control Block 描述。核心字段可简化为:

typedef struct tskTaskControlBlock
{
    volatile StackType_t *pxTopOfStack;

    ListItem_t xStateListItem;
    ListItem_t xEventListItem;

    UBaseType_t uxPriority;
    StackType_t *pxStack;
    char pcTaskName[configMAX_TASK_NAME_LEN];
} TCB_t;

常见扩展字段包括:

  • 基础优先级和继承优先级;
  • 已持有 Mutex 数量;
  • 任务通知值与状态;
  • MPU 配置和线程局部存储;
  • 运行时间统计和调试信息。

两个链表节点

任务等待队列并带有超时时间时,会同时存在于两个链表中:

xStateListItem → Delayed List,记录超时时刻
xEventListItem → Queue 的事件等待链表

数据先到达或等待超时后,内核都会把任务从两个链表移除,再放入 Ready List。

任务创建过程

分配 TCB 和任务栈

初始化任务名称、优先级和链表节点

构造初始异常栈帧

加入对应优先级的 Ready List

必要时抢占当前任务

静态创建适合内存必须确定的系统:

StaticTask_t xTaskBuffer;
StackType_t xTaskStack[256];

TaskHandle_t xHandle = xTaskCreateStatic(
    vWorkerTask,
    "Worker",
    256,
    NULL,
    3,
    xTaskStack,
    &xTaskBuffer
);

在常见 Cortex-M 端口中,栈深度通常以 StackType_t 为单位。若 StackType_t 为 32 位,深度 256 通常约等于 1024 字节,而不是 256 字节。

任务函数不能直接返回,需要持续运行或自删除:

static void vWorkerTask(void *pvParameters)
{
    for (;;) {
        /* 任务主体。 */
    }

    /* 如需结束,应调用 vTaskDelete(NULL)。 */
}

调度链表体系

FreeRTOS 主要维护以下任务链表:

List_t pxReadyTasksLists[configMAX_PRIORITIES];

List_t xDelayedTaskList1;
List_t xDelayedTaskList2;

List_t xPendingReadyList;
List_t xSuspendedTaskList;
List_t xTasksWaitingTermination;
链表 作用
Ready Lists 按优先级保存所有可运行任务
Delayed List 保存本轮 Tick 周期内将超时的任务
Overflow Delayed List 保存 Tick 回绕后将超时的任务
Pending Ready List 调度器挂起期间被 ISR 唤醒的任务
Suspended List 被显式挂起或无限期等待的任务
Termination List 等待 Idle Task 回收的已删除任务

Ready List 与快速选任务

每个优先级对应一个 Ready List。同优先级任务通过循环双向链表实现轮转。

支持优化选择的 Cortex-M 端口可配置:

#define configUSE_PORT_OPTIMISED_TASK_SELECTION  1

此时内核使用优先级位图配合 CLZ 等指令快速查找最高 Ready 优先级。此类实现通常要求:

configMAX_PRIORITIES <= 32

Delayed List 与 Tick 溢出

Blocked 任务按照绝对唤醒 Tick 排序。FreeRTOS 使用两个 Delayed List 处理计数器回绕:

pxDelayedTaskList          → 本轮溢出前唤醒
pxOverflowDelayedTaskList  → Tick 回绕后唤醒

当 Tick 从最大值回到 0 时,内核直接交换两个链表。

典型操作复杂度

操作 典型复杂度
Ready List 同优先级轮转 O(1)
优化端口查找最高优先级 接近 O(1)
唤醒事件链表头部任务 O(1)
向排序事件链表插入任务 O(n)
向排序 Delayed List 插入任务 O(n)

Tick 与延时管理

每次 SysTick 到来,端口调用:

xTaskIncrementTick();

内核大致执行:

flowchart TD
    A["SysTick"] --> B["xTickCount 递增"]
    B --> C{"Tick 是否回绕到 0"}
    C -- 是 --> D["交换两个 Delayed List"]
    C -- 否 --> E["检查最近唤醒时间"]
    D --> E
    E --> F["唤醒所有已超时任务"]
    F --> G["加入相应 Ready List"]
    G --> H{"是否需要抢占或时间片轮转"}
    H -- 是 --> I["挂起 PendSV"]

Delayed List 已按唤醒时间排序,因此检查到第一个尚未到期的任务后即可停止扫描。

Tick 频率权衡

#define configTICK_RATE_HZ  1000

通常表示 1 Tick 约为 1 ms。

Tick 频率 优点 代价
较高 延时和时间片粒度更细 ISR 开销、功耗更高
较低 周期性开销更低 延时粒度更粗

Tick 不是微秒级高精度定时器。精确时序应使用硬件 Timer、输入捕获、输出比较或 DMA。


PendSV 上下文切换

Cortex-M 通常使用:

  • SysTick:维护时间并判断是否需要调度;
  • PendSV:执行上下文切换;
  • SVC:启动第一个任务等端口操作。

异常进入时,硬件自动保存:

R0~R3、R12、LR、PC、xPSR

PendSV 软件继续保存:

R4~R11,以及必要的 FPU 上下文

切换流程如下:

sequenceDiagram
    participant O as 旧任务
    participant P as PendSV
    participant S as 调度器
    participant N as 新任务

    O->>P: 触发上下文切换
    P->>P: 保存 R4~R11/FPU
    P->>P: PSP 写入旧任务 TCB
    P->>S: vTaskSwitchContext
    S->>S: 选择最高优先级 Ready 任务
    S-->>P: 更新 pxCurrentTCB
    P->>P: 从新 TCB 恢复 PSP 和寄存器
    P->>N: 异常返回

PendSV 通常设为最低中断优先级,因此不会在普通外设 ISR 中间强行切换任务。


事件阻塞与唤醒

以等待队列 100 Tick 为例:

xQueueReceive(
    xQueue,
    &xData,
    pdMS_TO_TICKS(100)
);

队列为空时:

  1. xEventListItem 进入队列的等待链表;
  2. xStateListItem 进入 Delayed List;
  3. 当前任务从 Ready List 移除;
  4. 调度器选择其他任务运行。

队列收到数据时:

  1. 从事件链表头取出最高优先级等待任务;
  2. 从事件链表和 Delayed List 移除该任务;
  3. 将任务加入对应 Ready List;
  4. 必要时抢占当前低优先级任务。

如果此时调度器被 vTaskSuspendAll() 挂起,任务会先进入 xPendingReadyList,等 xTaskResumeAll() 后再转入 Ready List。


Mutex 与优先级继承

典型优先级反转:

High:等待 Low 持有的 Mutex
Medium:持续运行
Low:持有 Mutex,但被 Medium 抢占

FreeRTOS Mutex 会让 Low 临时继承 High 的优先级:

flowchart TD
    A["High 等待 Low 持有的 Mutex"] --> B["Low 临时继承 High 的优先级"]
    B --> C["Low 抢占 Medium"]
    C --> D["Low 完成临界区并释放 Mutex"]
    D --> E["Low 恢复基础优先级"]
    E --> F["High 获得 Mutex"]

TCB 会保存:

uxPriority;      /* 当前实际优先级 */
uxBasePriority;  /* 基础优先级 */
uxMutexesHeld;   /* 已持有 Mutex 数量 */

注意:

  • Binary Semaphore 不提供优先级继承;
  • Mutex 不能在 ISR 中使用;
  • 优先级继承不能解决死锁;
  • 多个 Mutex 应规定统一获取顺序;
  • FreeRTOS 使用简化继承,通常在释放全部 Mutex 后恢复基础优先级。

常用任务 API

需求 推荐 API 注意事项
动态创建任务 xTaskCreate() 检查返回值和 heap
静态创建任务 xTaskCreateStatic() TCB 与栈缓冲区必须长期有效
相对延时 vTaskDelay() 周期会包含任务自身执行时间
固定周期 xTaskDelayUntil() 可检测周期超期
主动让出 taskYIELD() 通常只让同优先级任务运行
暂停任务 vTaskSuspend() 不适合作为普通事件同步手段
恢复任务 vTaskResume() Resume 事件不会被保存
挂起调度器 vTaskSuspendAll() ISR 仍可执行,期间不要调用阻塞 API
恢复调度器 xTaskResumeAll() 必须与 SuspendAll 严格配对
删除任务 vTaskDelete() 首选任务释放资源后自删除
调整优先级 vTaskPrioritySet() 可能立即触发抢占
获取 Tick xTaskGetTickCount() ISR 应使用 FromISR 版本
查询栈余量 uxTaskGetStackHighWaterMark2() 结果单位和精度依端口及测试覆盖而定

vTaskDelay()xTaskDelayUntil()

相对延时:

vTaskDelay(pdMS_TO_TICKS(100));

实际周期包含任务执行时间,适合非严格周期任务。

固定周期:

static void vPeriodicTask(void *pvParameters)
{
    TickType_t xLastWake = xTaskGetTickCount();
    const TickType_t xPeriod = pdMS_TO_TICKS(10);

    for (;;) {
        BaseType_t xWasDelayed =
            xTaskDelayUntil(&xLastWake, xPeriod);

        if (xWasDelayed == pdFALSE) {
            deadlineMissCount++;
        }

        RunControlAlgorithm();
    }
}

旧版 FreeRTOS 可能使用 vTaskDelayUntil(),应以工程实际内核版本为准。

Suspend/Resume 不适合事件同步

下面的时序会丢失唤醒:

Task B 先调用 vTaskResume(TaskA)
Task A 后调用 vTaskSuspend(NULL)
结果:Task A 永久挂起

普通同步应使用 Queue、Semaphore 或 Task Notification。

删除任务

删除其他任务可能使目标任务来不及释放 Mutex、外设和动态内存。推荐:

发送停止通知

任务退出工作循环

主动释放资源

vTaskDelete(NULL)

自删除任务的动态栈和 TCB 通常由 Idle Task 回收。因此不能长期饿死 Idle Task。


推荐设计模式

事件驱动任务

static void vUartProcessTask(void *pvParameters)
{
    for (;;) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

        while (UartDataAvailable()) {
            ProcessOneFrame();
        }
    }
}

没有事件时任务处于 Blocked 状态,不消耗 CPU。

固定周期任务

static void vControlTask(void *pvParameters)
{
    TickType_t xLastWake = xTaskGetTickCount();
    const TickType_t xPeriod = pdMS_TO_TICKS(10);

    for (;;) {
        if (xTaskDelayUntil(&xLastWake, xPeriod) == pdFALSE) {
            deadlineMissCount++;
        }

        RunControlLoop();
    }
}

Queue 工作任务

static void vWorkerTask(void *pvParameters)
{
    WorkItem_t xItem;

    for (;;) {
        if (xQueueReceive(
                xWorkQueue,
                &xItem,
                portMAX_DELAY) == pdPASS) {
            ProcessWorkItem(&xItem);
        }
    }
}

常见错误与排查

高频错误

  1. 高优先级任务永不阻塞,导致低优先级任务和 Idle Task 饥饿;
  2. 误以为 taskYIELD() 能让低优先级任务执行;
  3. 使用 Suspend/Resume 做普通事件同步,造成丢唤醒;
  4. vTaskSuspendAll() 或临界区内调用阻塞 API;
  5. 任务函数直接返回;
  6. 把任务栈深度误认为字节数;
  7. 创建任务后不检查返回值;
  8. pvParameters 指向已经失效的局部变量;
  9. 删除仍持有 Mutex 或控制硬件的任务;
  10. 任务删除后继续使用旧 TaskHandle;
  11. 误以为 Binary Semaphore 具有优先级继承;
  12. 使用 vTaskDelay() 实现严格固定周期;
  13. Tick 频率设置过高,增加内核开销和功耗;
  14. 只根据一次正常运行的栈高水位压缩栈空间;
  15. 在 ISR 中调用普通任务 API,而不是 FromISR 版本。

栈检查建议

#define configCHECK_FOR_STACK_OVERFLOW  2

并实现:

void vApplicationStackOverflowHook(
    TaskHandle_t xTask,
    char *pcTaskName)
{
    /* 记录故障并进入安全状态。 */
}

栈高水位只能反映已经执行过的路径。应结合压力测试、编译器栈报告、异常路径覆盖和安全余量综合评估。

建议的基础配置

#define configUSE_PREEMPTION                     1
#define configUSE_TIME_SLICING                   1
#define configMAX_PRIORITIES                     8
#define configTICK_RATE_HZ                       1000
#define configUSE_PORT_OPTIMISED_TASK_SELECTION  1

#define configSUPPORT_STATIC_ALLOCATION          1
#define configSUPPORT_DYNAMIC_ALLOCATION         1
#define configUSE_MUTEXES                        1
#define configUSE_TASK_NOTIFICATIONS             1
#define configCHECK_FOR_STACK_OVERFLOW           2

这些值只是结构示例,实际配置应根据 CPU、端口、RAM、实时性和功耗要求调整。


一页速记

1. FreeRTOS 任务优先级数值越大,优先级越高。
2. 调度器始终选择最高优先级的 Ready 任务。
3. 高优先级任务如果永不阻塞,会饿死所有低优先级任务。
4. taskYIELD 通常只能把 CPU 让给同优先级任务。
5. vTaskDelay 是相对延时,xTaskDelayUntil 适合固定周期。
6. TCB 保存任务栈顶、优先级、状态节点和事件节点。
7. SysTick 维护时间并唤醒超时任务,PendSV 负责上下文切换。
8. SuspendAll 只暂停调度,不会停止中断。
9. Suspend/Resume 不保存事件,不宜用于普通同步。
10. Mutex 有优先级继承,Binary Semaphore 没有。
11. 自删除任务的动态内存通常由 Idle Task 回收。
12. 栈深度在 Cortex-M 上通常以 StackType_t 字为单位。

参考资料