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 当字节写进去,结果栈直接爆了。
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);
我在项目中常用任务通知代替二值信号量,因为速度更快——没有队列或信号量的内核对象开销。但注意,任务通知只能一对一,不能广播。如果你需要一对多通知,还是得用事件组或消息队列。
4.6 知识结构总览
下面这张图把任务管理的核心要素串起来了。你可以把它当作本章的「思维导图」。
4.7 实战建议:任务管理 checklist
最后,给你一份我每次写任务代码都会过一遍的 checklist:
- 任务栈大小:先设大,后用高水位标记裁剪
- 优先级分配:实时控制 > 通信 > 业务 > 后台,别让高优先级任务死循环
- 任务删除:先释放动态资源,再调
vTaskDelete,最后句柄置 NULL - 任务间通信:能任务通知就别用信号量,能信号量就别用队列
- 调试开关:开发期打开
configCHECK_FOR_STACK_OVERFLOW和configUSE_TRACE_FACILITY
嗯,任务管理这部分就这些。你把这些吃透了,后面写中间件和状态机的时候,底子就稳了。