4. FreeRTOS 任务管理:任务创建、删除、优先级、堆栈与通知

任务管理,说白了就是 FreeRTOS 的骨架。你想想看,一个实时操作系统里跑的是什么?就是任务。任务怎么生、怎么死、谁先跑、谁后跑、栈够不够用、任务之间怎么打招呼——这些搞不清楚,后面写中间件和状态机就是空中楼阁。

我记得刚接触 FreeRTOS 那会儿,觉得任务不就是个死循环函数嘛,有啥难的?结果第一个产品就踩了堆栈溢出的坑,系统跑着跑着就莫名其妙复位了。嗯,从那以后我再也不敢轻视任务管理了。

4.1 任务创建:xTaskCreate 与 xTaskCreateStatic

创建任务有两种方式:动态创建和静态创建。我个人习惯用动态创建,省心。但有些场景——比如安全关键系统、或者你不想依赖 FreeRTOS 的堆管理——就得用静态创建。

4.1.1 动态创建

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

这里有个坑:usStackDepth 的单位是 字,不是字节。在 32 位 MCU 上,一个字是 4 字节。所以如果你写 128,实际分配的堆栈是 512 字节。我曾经见过有人把 1024 当字节写进去,结果栈直接爆了。

我的习惯: 调试阶段把堆栈设大一点(比如 256 字),等功能稳定后再用 uxTaskGetStackHighWaterMark() 看实际使用量,然后裁剪到 1.5 倍左右。别一上来就抠门。

4.1.2 静态创建

TaskHandle_t xTaskCreateStatic(
    TaskFunction_t pvTaskCode,
    const char * const pcName,
    uint32_t ulStackDepth,
    void *pvParameters,
    UBaseType_t uxPriority,
    StackType_t *puxStackBuffer,      // 你提供的堆栈缓冲区
    StaticTask_t *pxTaskBuffer        // 你提供的任务控制块
);

静态创建需要你提前分配好堆栈和 TCB 的内存。我一般在 freertos_tasks_c_additions_init.h 里用全局数组搞定,这样内存占用一目了然。

4.2 任务删除:vTaskDelete 与资源回收

任务删除比创建容易踩坑。调用 vTaskDelete(NULL) 是删除自己,传句柄是删除别人。但注意——删除任务不会释放你动态分配的内存。

我曾经踩过的坑: 一个任务里用 pvPortMalloc 申请了缓冲区,退出时只调了 vTaskDelete,没调 vPortFree。结果系统跑了三天,内存泄漏到死机。所以删除任务前,一定要先清理它占用的动态资源。

另外,删除任务后,最好把句柄置为 NULL,防止野指针。

void vTaskDeleteSafe(TaskHandle_t *pxTask) {
    if (*pxTask != NULL) {
        vTaskDelete(*pxTask);
        *pxTask = NULL;
    }
}

4.3 任务优先级:别让低优先级饿死

FreeRTOS 是抢占式调度,优先级高的任务随时可以打断优先级低的。优先级数值越大,优先级越高(0 是最低)。

优先级值 典型用途 说明
0 空闲任务 系统自动创建,不可删除
1-2 后台处理、日志输出 不紧急,可被随时打断
3-4 普通业务逻辑 按键扫描、数据显示等
5-6 通信处理 UART、SPI、I2C 数据解析
7 实时控制 电机控制、PID 计算

优先级设计有个原则:不要让高优先级任务一直占着 CPU。如果两个任务优先级相同,FreeRTOS 会按时间片轮转调度。但如果高优先级任务从不阻塞,低优先级任务就永远没机会跑——这叫「优先级反转」的极端情况。

避坑指南: 我习惯在任务里加一个 vTaskDelay(1) 或者等待某个信号量,让高优先级任务主动让出 CPU。别指望靠 taskYIELD() 解决问题,那只是临时抱佛脚。

4.4 任务堆栈配置:小了崩,大了浪费

堆栈大小是任务管理里最玄学的地方。你设小了,任务跑着跑着就栈溢出;设大了,RAM 不够用。尤其是那些有局部大数组或者递归调用的任务,栈消耗特别快。

FreeRTOS 提供了两个调试手段:

  • 栈溢出钩子函数:在 FreeRTOSConfig.h 里把 configCHECK_FOR_STACK_OVERFLOW 设为 1 或 2,然后实现 vApplicationStackOverflowHook()。一旦溢出,系统会调用这个钩子——但注意,溢出已经发生了,这个钩子只是让你知道死在哪。
  • 高水位标记:调用 uxTaskGetStackHighWaterMark() 查看任务历史上剩余的最小堆栈空间。单位是字。
// 在任务循环里定期打印栈余量
UBaseType_t uxHighWaterMark = uxTaskGetStackHighWaterMark(NULL);
printf("Task stack remaining: %u words\n", uxHighWaterMark);

我个人习惯:开发阶段把 configCHECK_FOR_STACK_OVERFLOW 设为 2,这样每次上下文切换都会检查栈指针,虽然慢一点,但能尽早发现问题。发布前再关掉。

4.5 任务通知:轻量级任务间通信

任务通知是 FreeRTOS 里被低估的一个功能。它比信号量快,比消息队列省内存。说白了,每个任务内部都有一个 32 位的通知值,你可以用它来发信号、传数据、甚至当二值信号量用。

4.5.1 发送通知

BaseType_t xTaskNotifyGive(TaskHandle_t xTaskToNotify);  // 递增通知值
BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue, eNotifyAction eAction); // 更灵活

eNotifyAction 有几种模式:

  • eNoAction:只唤醒任务,不修改通知值
  • eSetBits:按位或,适合传标志位
  • eIncrement:通知值加 1,类似计数信号量
  • eSetValueWithOverwrite:直接覆盖,不管旧值
  • eSetValueWithoutOverwrite:如果通知值还没被取走,就不覆盖

4.5.2 接收通知

uint32_t ulNotificationValue = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // 取走通知值
// 或者
uint32_t ulNotificationValue;
BaseType_t xResult = xTaskNotifyWait(0x00, ULONG_MAX, &ulNotificationValue, portMAX_DELAY);

我在项目中常用任务通知代替二值信号量,因为速度更快——没有队列或信号量的内核对象开销。但注意,任务通知只能一对一,不能广播。如果你需要一对多通知,还是得用事件组或消息队列。

性能对比(实测数据): 在 Cortex-M4 @168MHz 上,任务通知的发送-接收延迟大约 0.5μs,而二值信号量大约 1.2μs。如果你的系统对实时性要求极高,优先用任务通知。

4.6 知识结构总览

下面这张图把任务管理的核心要素串起来了。你可以把它当作本章的「思维导图」。

FreeRTOS 任务管理知识结构图 FreeRTOS 任务管理 任务创建 任务删除 任务优先级 任务堆栈配置 任务通知 xTaskCreate xTaskCreateStatic vTaskDelete 资源回收 抢占式调度 时间片轮转 栈溢出检测 高水位标记 xTaskNotifyGive ulTaskNotifyTake 核心原则:任务创建时规划好栈和优先级,删除时清理资源,通信优先用任务通知 调试阶段打开栈溢出检测,发布前关闭以提升性能

4.7 实战建议:任务管理 checklist

最后,给你一份我每次写任务代码都会过一遍的 checklist:

  1. 任务栈大小:先设大,后用高水位标记裁剪
  2. 优先级分配:实时控制 > 通信 > 业务 > 后台,别让高优先级任务死循环
  3. 任务删除:先释放动态资源,再调 vTaskDelete,最后句柄置 NULL
  4. 任务间通信:能任务通知就别用信号量,能信号量就别用队列
  5. 调试开关:开发期打开 configCHECK_FOR_STACK_OVERFLOW 和 configUSE_TRACE_FACILITY

嗯,任务管理这部分就这些。你把这些吃透了,后面写中间件和状态机的时候,底子就稳了。