Chapter 02 · FreeRTOS 实时操作系统

任务管理与调度

理解任务状态机与优先级,掌握抢占式与协作式调度、时间片轮转,学会 vTaskDelay 与 vTaskDelayUntil 的区别,认识空闲任务与钩子函数。

课程进度17%

关键概念

任务状态机(四态)
FreeRTOS 任务在四种状态间迁移:运行(Running)——正占用 CPU;就绪(Ready)——准备好但被更高优先级任务抢占,在等 CPU;阻塞(Blocked)——在等待某事件(延时到期、队列有数据、信号量可用)且有超时上限;挂起(Suspended)——被 vTaskSuspend 显式移出调度,不参与调度直到被 resume。
优先级 (Priority)
每个任务有一个 0 到 configMAX_PRIORITIES-1 的整数优先级,数值越大优先级越高(与某些 RTOS 相反)。调度器永远运行当前就绪任务中优先级最高者。多个任务可共享同一优先级,此时它们之间按时间片轮转。
抢占式调度 (Preemptive)
当一个更高优先级的任务变为就绪(如中断唤醒),调度器会立即打断当前任务、切换到高优先级任务运行。由 configUSE_PREEMPTION=1 启用,是保证实时性的关键,也是绝大多数场景的默认选择。
协作式调度 (Cooperative)
configUSE_PREEMPTION=0 时启用。任务只有在主动调用 taskYIELD() 或阻塞式 API(如 vTaskDelay、队列等待)时才让出 CPU,不会被强制打断。切换点可控、无需担心共享数据竞争,但一个"跑飞"的任务会饿死其他所有任务,实时性差。
时间片轮转 (Time Slicing)
configUSE_TIME_SLICING=1 启用。当多个任务处于同一优先级时,每来一个系统节拍(Tick)就轮流切换,使它们均分 CPU。若关闭时间片,同优先级任务会一直运行到主动阻塞才切换。
空闲任务 (Idle Task)
调度器启动时自动创建的优先级 0(最低)任务。当没有任何用户任务就绪时运行它。空闲任务负责回收被 vTaskDelete 删除任务的内存(TCB 和栈),也是 Tickless 低功耗和运行时统计的挂钩点。它必须存在且不能被阻塞太久。
钩子函数 (Hook)
内核在特定时刻回调的用户函数。vApplicationIdleHook() 在空闲任务每圈调用,可用于喂狗或进入低功耗;vApplicationTickHook() 在每个 Tick 中断里调用,运行在中断上下文,必须极短。需分别用 configUSE_IDLE_HOOKconfigUSE_TICK_HOOK 开启。
vTaskDelay vs vTaskDelayUntil
vTaskDelay(n) 是"相对延时":从调用那一刻起再阻塞 n 个 tick,累积误差会漂移。vTaskDelayUntil(&last, period) 是"绝对延时":以上次唤醒时刻为基准精确间隔 period,适合需要严格固定周期的采样任务,不受任务本身执行时间波动影响。
任务控制块 TCB
Task Control Block,内核为每个任务维护的结构体,保存栈顶指针、优先级、状态列表项、任务名等。TaskHandle_t 本质就是指向 TCB 的指针。所有任务管理 API(挂起、恢复、改优先级、查询)都通过句柄操作对应 TCB。

任务状态机与迁移路径

理解四种状态之间的迁移,是排查"任务不运行"问题的基础。下图展示了完整的状态转换:

FreeRTOS 任务状态机 ═══════════════════════════════════════════════════════════ xTaskCreate() │ ▼ ┌────────────────────────┐ ┌────────►│ 就绪 Ready │◄────────┐ │ │ (准备好,等 CPU) │ │ │ └───────────┬────────────┘ │ │ 调度器选中 │ ▲ 被更高优先级抢占 │ 事件发生 │ ▼ │ │ / 延时到期 │ ┌────────────────────────┐ │ │ │ 运行 Running │ │ │ │ (正在占用 CPU) │ │ │ └──┬──────────────────┬───┘ │ │ vTaskSuspend│ │vTaskDelay/ │ │ │ │等待队列/信号量 │ │ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ │ └──┤ 挂起 Suspended │ │ 阻塞 Blocked ├─┘ │ (脱离调度) │ │ (等事件+超时) │ └──────────────────┘ └──────────────────┘ ▲ vTaskResume() 必然带超时上限
阻塞 vs 挂起的关键区别

阻塞是"我在等某件事,最多等这么久"——一定伴随超时(可以是 portMAX_DELAY 永久等待)。挂起是"把我从调度里拿走",不参与任何调度,直到别人调用 vTaskResume() 把它唤醒。阻塞是任务自己主动进入的,挂起通常是被其他任务操控的。

抢占式 vs 协作式:一图看懂

抢占式(推荐)

  • 高优先级就绪立即抢占低优先级
  • 实时性强,响应确定
  • 需要用互斥手段保护共享数据
  • configUSE_PREEMPTION = 1
  • 99% 的项目用它

协作式

  • 只在主动让出时才切换
  • 切换点可预测,无数据竞争
  • 一个任务卡死则全体饿死
  • configUSE_PREEMPTION = 0
  • 仅用于极简、可控场景

vTaskDelay 与 vTaskDelayUntil 的本质区别

假设任务每次执行体耗时不定(2~8ms),要求严格每 100ms 采样一次。用 vTaskDelay 会导致周期漂移,用 vTaskDelayUntil 才能锁定精确周期:

/* ❌ 相对延时:实际周期 = 执行时间 + 100ms,会漂移 */
void SampleTask_Bad(void *arg) {
  for (;;) {
    do_sample();                     /* 耗时 2~8ms 不定 */
    vTaskDelay(pdMS_TO_TICKS(100));  /* 从此刻再等 100ms */
  }                                    /* → 真实周期 102~108ms,抖动 */
}

/* ✅ 绝对延时:以上次唤醒点为基准,锁定精确 100ms 周期 */
void SampleTask_Good(void *arg) {
  TickType_t xLastWake = xTaskGetTickCount();   /* 记录起点 */
  const TickType_t period = pdMS_TO_TICKS(100);
  for (;;) {
    do_sample();
    /* 阻塞到 xLastWake + period,并自动更新 xLastWake */
    vTaskDelayUntil(&xLastWake, period);
  }                                    /* → 真实周期恒为 100ms */
}
新版 API 名称

FreeRTOS V10.4 起 vTaskDelayUntil() 被推荐替换为返回布尔值的 xTaskDelayUntil(),后者在"延时时间已经过去"时返回 pdFALSE,便于检测周期超载。旧名仍向后兼容。pdMS_TO_TICKS(ms) 宏把毫秒换算成 tick 数,永远用它而不要手写数字,代码才能不依赖 configTICK_RATE_HZ

任务的创建、删除、挂起与恢复

#include "FreeRTOS.h"
#include "task.h"

TaskHandle_t workerHandle = NULL;

void WorkerTask(void *arg) {
  for (;;) {
    do_work();
    vTaskDelay(pdMS_TO_TICKS(50));
  }
}

void ManagerTask(void *arg) {
  /* 动态创建一个任务;返回 pdPASS 表示成功 */
  if (xTaskCreate(WorkerTask, "WORK", 256, NULL,
                  2, &workerHandle) != pdPASS) {
    printf("create failed: heap not enough\r\n");
  }

  vTaskDelay(pdMS_TO_TICKS(1000));
  vTaskSuspend(workerHandle);   /* 暂停 worker */

  vTaskDelay(pdMS_TO_TICKS(1000));
  vTaskResume(workerHandle);    /* 恢复 worker */

  /* 动态调整优先级 */
  vTaskPrioritySet(workerHandle, 3);

  vTaskDelay(pdMS_TO_TICKS(2000));
  vTaskDelete(workerHandle);    /* 删除,内存由空闲任务回收 */
  workerHandle = NULL;

  vTaskDelete(NULL);           /* 删除自己 */
}
vTaskDelete 的内存回收时机

删除其他任务时,其 TCB 和栈由空闲任务异步回收,因此删除后不会立刻释放内存——若空闲任务长期得不到运行,内存不会归还。删除任务自己(传 NULL)时也是如此。所以低优先级的空闲任务不应被长时间饿死。此外,删除任务前若它持有互斥量或未关闭外设,会造成资源泄漏,需自行清理。

静态创建:不用堆的确定性方案

安全关键领域常禁止动态分配。xTaskCreateStatic() 让你自己提供栈和 TCB 内存,编译期即确定,运行期零堆分配:

/* 需 configSUPPORT_STATIC_ALLOCATION = 1 */
#define STACK_WORDS   256
static StackType_t  xStack[STACK_WORDS];   /* 用户自备栈 */
static StaticTask_t xTCB;                 /* 用户自备 TCB */

void setup(void) {
  TaskHandle_t h = xTaskCreateStatic(
      WorkerTask, "WORK", STACK_WORDS,
      NULL, 2, xStack, &xTCB);   /* 返回句柄,永不为空 */
}

三种调度模式下的执行时序

同/不同优先级任务的调度行为 场景 A:不同优先级(抢占式) High(prio2): ████ ████ ← 只要就绪就霸占 CPU Low (prio1): ████ ████ ← 仅在 High 阻塞时运行 场景 B:同优先级 + 时间片开启 TaskX(prio1): ██ ██ ██ ██ ← 每个 tick 轮流 TaskY(prio1): ██ ██ ██ ██ ← 均分 CPU 场景 C:同优先级 + 时间片关闭 TaskX(prio1): ██████████ ← 一直跑到主动阻塞 TaskY(prio1): ████████ ← 才轮到 Y
本章小结

FreeRTOS 任务有四态:运行、就绪、阻塞(等事件+超时)、挂起(脱离调度)。优先级数值越大越高,调度器永远运行最高优先级的就绪任务。抢占式(configUSE_PREEMPTION=1)让高优先级就绪即抢占,保证实时性,是默认选择;协作式只在主动让出时切换。同优先级任务在时间片轮转(configUSE_TIME_SLICING)下均分 CPU。vTaskDelay 是相对延时会漂移,vTaskDelayUntil(新名 xTaskDelayUntil)是绝对延时锁定精确周期,采样任务必用后者,并用 pdMS_TO_TICKS() 换算。任务管理 API:xTaskCreate(动态)/xTaskCreateStatic(静态,零堆分配)创建,vTaskDelete 删除(内存由空闲任务异步回收)、vTaskSuspend/Resume 挂起恢复、vTaskPrioritySet 改优先级。空闲任务是优先级 0 的自动任务,负责回收内存并挂载 vApplicationIdleHook 等钩子,不能被长期饿死。