3. 任务创建与管理:xTaskCreate、任务优先级、任务删除与挂起
任务,是 FreeRTOS 里最核心的概念。说白了,你写的每个功能模块,最终都要包装成一个任务交给调度器去跑。我刚开始用 FreeRTOS 时,总觉得任务就是「一个死循环函数」,后来踩了坑才明白——任务的创建、优先级分配、删除和挂起,每一步都有讲究。
这一节,我们就来把这些东西彻底讲透。
3.1 xTaskCreate:任务的「出生证明」
创建一个任务,用的就是 xTaskCreate()。它的原型长这样:
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 任务函数指针
const char * const pcName, // 任务名称(调试用)
configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字(不是字节)
void *pvParameters, // 传给任务函数的参数
UBaseType_t uxPriority, // 任务优先级
TaskHandle_t *pxCreatedTask // 返回的任务句柄
);
嗯,参数不少。但别怕,我一个个说。
3.1.1 任务函数长什么样?
任务函数必须是一个永不返回的死循环。为什么?因为 FreeRTOS 调度器不会帮你清理任务退出后的资源。如果你让任务函数 return 了,系统大概率会崩溃。
void vTaskLED(void *pvParameters)
{
// 初始化硬件
gpio_set_direction(GPIO_NUM_2, GPIO_MODE_OUTPUT);
while (1)
{
gpio_set_level(GPIO_NUM_2, 1);
vTaskDelay(pdMS_TO_TICKS(500));
gpio_set_level(GPIO_NUM_2, 0);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
注意看,vTaskDelay() 是让任务主动让出 CPU 的关键。没有它,低优先级任务永远得不到运行。
3.1.2 栈深度怎么定?
这是新手最容易翻车的地方。栈太小,任务跑着跑着就溢出;栈太大,浪费宝贵的 RAM。
我个人习惯:先给一个保守值,比如 2048 字(ESP32 一个字是 4 字节,所以 2048 字 = 8KB)。跑起来后用 uxTaskGetStackHighWaterMark() 查看剩余栈空间,再逐步调小。
3.1.3 任务句柄有什么用?
任务句柄就像任务的「身份证」。后面你要删除、挂起、恢复这个任务,都得靠它。如果你不需要操作这个任务,可以传 NULL。
TaskHandle_t xLEDTaskHandle = NULL;
xTaskCreate(vTaskLED, "LED", 2048, NULL, 1, &xLEDTaskHandle);
3.2 任务优先级:谁先跑?
FreeRTOS 是抢占式调度器。优先级高的任务,只要就绪,就会立刻抢走 CPU。优先级数字越大,优先级越高。
| 优先级值 | 含义 | 典型用途 |
|---|---|---|
| 0 | 最低优先级(空闲任务) | 系统空闲任务专用 |
| 1-5 | 低优先级 | 后台数据采集、日志输出 |
| 6-10 | 中优先级 | 传感器读取、状态更新 |
| 11-15 | 高优先级 | 通信协议处理、按键响应 |
| 16-24 | 实时优先级 | 控制环路、中断级任务 |
configMAX_PRIORITIES - 1。这个值在 FreeRTOSConfig.h 里定义,默认是 25。超过这个值,调度器会直接忽略。
你想想看,如果两个任务优先级相同,FreeRTOS 会怎么做?它会用时间片轮转,每个任务跑一个 tick 的时间,然后切换。但如果你把高优先级任务设计成死循环且不阻塞,低优先级任务就永远没机会跑。这就是所谓的「优先级反转」和「饥饿」问题。
我在项目中遇到过这种情况:一个高优先级的 WiFi 任务占着 CPU 不放,导致低优先级的传感器读取任务一直得不到执行,数据采集全乱了。后来加了一个 vTaskDelay() 才解决。
3.3 任务删除:什么时候该「杀」任务?
任务不是创建了就一定要跑到地老天荒。有些任务完成使命后,就该被清理掉。
void vTaskTempSensor(void *pvParameters)
{
// 读取一次温度
float temp = read_temperature();
// 把温度值存到全局变量
g_temperature = temp;
// 任务完成,删除自己
vTaskDelete(NULL);
}
注意 vTaskDelete(NULL) 的意思是「删除当前任务」。如果你有任务句柄,也可以删除其他任务:
vTaskDelete(xLEDTaskHandle);
configSUPPORT_DYNAMIC_ALLOCATION 为 1,并且 FreeRTOS 的堆管理机制正常工作。否则,频繁创建删除任务会导致内存碎片。
我曾经犯过一个错:在中断里直接调用 vTaskDelete()。结果系统直接挂掉。为什么?因为 vTaskDelete() 会触发上下文切换,而中断上下文不允许这样做。正确的做法是在中断里发一个信号量,让任务自己删除自己。
3.4 任务挂起与恢复:让任务「休眠」
挂起和删除不同。挂起的任务还在内存里,只是调度器不再调度它。恢复后,任务从挂起点继续执行。
// 挂起任务
vTaskSuspend(xLEDTaskHandle);
// 恢复任务
vTaskResume(xLEDTaskHandle);
挂起有什么用?我举两个场景:
- 省电模式: 设备进入低功耗模式前,挂起所有非必要任务,只保留唤醒检测任务。
- 调试: 某个任务行为异常,先挂起它,观察其他任务是否正常。
vTaskResume() 来恢复一个高优先级任务。结果那个任务一恢复就抢占了 CPU,导致中断处理被延迟。后来我改用 vTaskResumeFromISR(),并在中断退出时手动触发一次上下文切换,才解决问题。
3.5 知识结构图:任务生命周期
下面这张图,把任务的整个生命周期串起来了。从创建到运行,再到挂起、恢复、删除,一目了然。
3.6 实战:创建一个温控任务
把上面这些知识点串起来,写一个完整的例子。这个任务每 2 秒读取一次温度,如果温度超过阈值,就点亮报警灯。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#define TEMP_THRESHOLD 50.0f
#define ALARM_GPIO GPIO_NUM_4
TaskHandle_t xTempTaskHandle = NULL;
void vTaskTemperature(void *pvParameters)
{
float temperature = 0.0f;
// 初始化报警灯
gpio_set_direction(ALARM_GPIO, GPIO_MODE_OUTPUT);
gpio_set_level(ALARM_GPIO, 0);
while (1)
{
// 模拟读取温度传感器
temperature = read_temperature_sensor();
if (temperature > TEMP_THRESHOLD)
{
gpio_set_level(ALARM_GPIO, 1);
}
else
{
gpio_set_level(ALARM_GPIO, 0);
}
// 每 2 秒执行一次
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
void app_main(void)
{
xTaskCreate(
vTaskTemperature,
"TempTask",
2048,
NULL,
5,
&xTempTaskHandle
);
// 主函数可以继续做其他事情
}
- 任务函数必须是一个死循环,不能 return。
- 栈深度先给大一点,跑起来后再优化。
- 优先级不要滥用,高优先级任务必须主动让出 CPU。
- 删除任务前,确保任务不再被其他模块引用。
- 挂起和恢复是轻量级操作,适合临时暂停任务。