3. FreeRTOS内核分析(上):任务创建与删除、任务状态机、优先级与调度

好,咱们今天正式进入FreeRTOS的内核分析。说实话,这部分是很多嵌入式工程师的“拦路虎”。我当年刚接触RTOS时,也被任务状态机绕得晕头转向。但别怕,咱们一步步拆开来看,你会发现其实没那么玄乎。

这一章,我打算重点讲三个核心概念:任务创建与删除、任务状态机、以及优先级与调度。这三块是FreeRTOS的基石,搞懂了它们,后面的信号量、队列、互斥锁什么的,理解起来就顺理成章了。

3.1 任务创建与删除:从无到有,再到无

任务,说白了就是一个永不返回的C函数。它有自己的栈空间、优先级和状态。创建任务,就是告诉内核:“嘿,帮我准备一个执行上下文,以后按这个优先级调度我。”

3.1.1 任务创建:xTaskCreate() 深度解析

最常用的API是 xTaskCreate()。它的原型长这样:

BaseType_t xTaskCreate(
    TaskFunction_t pvTaskCode,      // 任务函数指针
    const char * const pcName,      // 任务名称(调试用)
    configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字(Word)
    void *pvParameters,             // 传给任务函数的参数
    UBaseType_t uxPriority,         // 任务优先级(0 ~ configMAX_PRIORITIES-1)
    TaskHandle_t *pxCreatedTask     // 返回的任务句柄
);

嗯,这里有个坑,我必须要提醒你。栈深度单位是“字”,不是字节。在32位MCU上,一个字是4字节。所以如果你写 usStackDepth = 128,实际分配的栈空间是 128 × 4 = 512 字节。我曾经见过有人把这个当字节写,结果任务一跑就栈溢出,查了两天才找到原因。

我的习惯: 给每个任务分配栈时,先估算一下局部变量和函数调用深度,然后至少翻一倍。比如你觉得需要200字节,那就给400字节(即100个字)。宁可浪费一点RAM,也别让栈溢出。

返回值 pdPASS 表示创建成功,errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY 表示堆空间不够。嗯,这个错误很常见,尤其是你开了很多任务或者栈给得太大时。

3.1.2 任务删除:vTaskDelete() 与善后工作

删除任务用 vTaskDelete()。传入任务句柄,内核就会把这个任务从所有状态列表中移除,并回收它的TCB(任务控制块)和栈空间。

void vTaskDelete(TaskHandle_t xTaskToDelete);

如果传入 NULL,表示删除当前正在运行的任务。这个用法很常见,比如任务执行完自己的使命后,自我了断:

void vTaskCode(void *pvParameters) {
    // 做一些初始化工作
    // ...
    // 任务完成,删除自己
    vTaskDelete(NULL);
}
注意: 删除任务后,内核不会自动释放该任务持有的资源(比如动态分配的内存、打开的外设句柄等)。你必须自己在任务退出前做好清理工作。我曾经在一个项目中,任务里 malloc 了一块缓冲区,删除任务时忘了 free,结果系统跑了三天后内存耗尽崩溃了。

3.2 任务状态机:就绪、运行、阻塞、挂起

FreeRTOS的任务有四种状态:就绪(Ready)、运行(Running)、阻塞(Blocked)、挂起(Suspended)。你想想看,一个CPU核心同一时刻只能运行一个任务,那其他任务去哪了?它们就在这些状态之间来回切换。

下面这张图,是我自己画的任务状态迁移图,你一看就明白了:

FreeRTOS 任务状态迁移图 运行 (Running) 就绪 (Ready) 阻塞 (Blocked) 挂起 (Suspended) 调度器选择 时间片耗尽/被抢占 等待事件/延时 事件到达/超时 vTaskSuspend() vTaskResume() vTaskSuspend() vTaskResume() 说明: • 只有就绪态的任务才能被调度器选中进入运行态。 • 阻塞态通常由 vTaskDelay()、队列/信号量等待引起。

我来解释一下这张图里的关键迁移路径:

  • 就绪 → 运行: 调度器从就绪列表中选一个优先级最高的任务,让它占用CPU。
  • 运行 → 就绪: 要么是时间片到了(同优先级轮转),要么是更高优先级的任务就绪了(抢占)。
  • 运行 → 阻塞: 任务主动调用 vTaskDelay(),或者等待某个队列/信号量。说白了,就是“我现在没事干,先睡会儿”。
  • 阻塞 → 就绪: 等待的事件到了,或者延时时间到了。内核把任务从阻塞列表移回就绪列表。
  • 运行/就绪 → 挂起: 调用 vTaskSuspend()。挂起态的任务,调度器完全忽略它。
  • 挂起 → 就绪: 调用 vTaskResume() 或 xTaskResumeFromISR()。
核心区别: 阻塞态和挂起态最大的不同是——阻塞态的任务还在等待某个条件,条件满足后内核会自动把它唤醒;而挂起态的任务只能由其他任务或中断显式地调用恢复函数才能回到就绪态。

3.3 任务优先级与调度:谁先跑,谁后跑?

FreeRTOS是一个抢占式优先级调度的RTOS。什么意思?说白了就是:高优先级的任务一旦就绪,立刻抢走CPU,低优先级的任务只能靠边站。

3.3.1 优先级数值与含义

优先级是一个整数,范围从 0 到 configMAX_PRIORITIES - 1。数值越大,优先级越高。这是FreeRTOS的约定,和uC/OS正好相反(uC/OS是数值越小优先级越高)。

优先级值 含义 典型用途
0 最低优先级(空闲任务) 空闲任务、后台统计
1 ~ 3 低优先级 非实时性任务(如LED闪烁、按键扫描)
4 ~ 6 中优先级 常规控制任务(如CAN报文处理)
7 ~ (configMAX_PRIORITIES-1) 高优先级 实时性要求高的任务(如电机控制、传感器采集)

我建议你把 configMAX_PRIORITIES 设得不要太大,5~32就够用了。优先级太多,调度器的查找开销会变大,而且你也不容易管理。

3.3.2 调度策略:抢占 + 时间片轮转

FreeRTOS的调度策略可以概括为两句话:

  1. 抢占式调度: 任何时候,只要有一个更高优先级的任务就绪,当前运行的任务立刻被换下。这个切换发生在任何可能的内核点(比如tick中断、API调用)。
  2. 时间片轮转: 如果多个任务优先级相同,它们会轮流占用CPU,每个任务运行一个时间片(通常1个tick)。

举个例子,假设有三个任务:

  • Task_A:优先级 5,一直运行
  • Task_B:优先级 3,延时100ms后打印
  • Task_C:优先级 5,一直运行

调度过程是这样的:

  1. 一开始,Task_A和Task_C都是优先级5,它们轮流跑时间片。
  2. 100ms后,Task_B延时结束,进入就绪态。但它的优先级是3,低于5,所以它抢不过Task_A和Task_C,只能等着。
  3. 只有当Task_A和Task_C都阻塞了(比如都调用了vTaskDelay),Task_B才有机会运行。

嗯,这里有个常见的误解:很多人以为高优先级任务就绪后,低优先级任务会立刻被暂停。其实不是的——抢占只发生在内核的调度点,比如tick中断、任务切换API调用时。如果你关掉了中断,或者在一个循环里没调用任何内核API,那高优先级任务就只能干等着。

我曾经踩过的坑: 在一个项目中,我写了一个低优先级的任务,里面有个大循环做数据校验,循环里没加任何阻塞或延时。结果高优先级的通信任务一直得不到CPU,导致CAN报文丢失。后来我在循环里加了个 taskYIELD(),问题就解决了。记住:别让同优先级或低优先级的任务长时间霸占CPU不放。

3.3.3 空闲任务与钩子函数

当所有任务都阻塞或挂起时,调度器会运行空闲任务(Idle Task)。空闲任务的优先级是0,最低。它主要做两件事:

  • 回收被删除任务的TCB和栈空间
  • 调用用户定义的钩子函数 vApplicationIdleHook()

你可以在空闲钩子里做低功耗处理,比如进入睡眠模式。但注意:空闲钩子里不能调用任何可能阻塞的API,否则系统会崩溃。

3.4 实战:创建一个两任务系统

光说不练假把式。咱们写个简单的例子,创建两个任务,一个高优先级负责采集数据,一个低优先级负责打印输出。

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

// 任务句柄
TaskHandle_t xTaskCollectHandle = NULL;
TaskHandle_t xTaskPrintHandle = NULL;

// 高优先级:数据采集任务
void vTaskCollect(void *pvParameters) {
    uint32_t ulSensorValue = 0;
    for (;;) {
        // 模拟读取传感器
        ulSensorValue++;
        // 延时10ms,让出CPU给低优先级任务
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// 低优先级:打印任务
void vTaskPrint(void *pvParameters) {
    for (;;) {
        // 打印信息
        // 延时100ms
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void main(void) {
    // 创建高优先级任务(优先级2)
    xTaskCreate(vTaskCollect, "Collect", 128, NULL, 2, &xTaskCollectHandle);
    // 创建低优先级任务(优先级1)
    xTaskCreate(vTaskPrint, "Print", 128, NULL, 1, &xTaskPrintHandle);
    // 启动调度器
    vTaskStartScheduler();
    // 正常情况下不会执行到这里
    for (;;);
}

这个例子里,vTaskCollect 每10ms运行一次,vTaskPrint 每100ms运行一次。因为采集任务的优先级更高,所以只要它就绪,打印任务就得等着。但采集任务每次运行完就主动延时,给了打印任务执行的机会。

我的建议: 在设计任务优先级时,先把所有任务按实时性要求排个序。最紧急的给最高优先级,最不紧急的给最低优先级。然后,高优先级任务的执行时间要尽量短,别在里面做复杂计算或大循环。否则低优先级任务会“饿死”。

好了,这一章的内容就到这里。任务创建、状态机、优先级调度,这三块是FreeRTOS的骨架。你把这些搞清楚了,后面学队列、信号量、互斥锁时,就会觉得“哦,原来内核是这么帮我管理的”。