第二章:FreeRTOS基础——任务创建与删除、任务优先级与调度、任务状态与切换、任务通知与队列

各位同学,欢迎来到第二章。这一章我们聊聊FreeRTOS的核心——任务管理。说实话,很多初学者把FreeRTOS当成一个“黑盒子”,任务创建了就跑,删了就停。但实际项目中,任务调度、优先级设计、状态切换这些细节,才是决定系统稳定性的关键。我在ESP32上做过不少IoT设备,踩过任务堆栈溢出、优先级反转、队列死锁这些坑。今天我把这些经验揉碎了讲给你听。

2.1 任务创建与删除

任务,说白了就是一段独立运行的函数。FreeRTOS里每个任务都有自己的堆栈、优先级和状态。创建任务用xTaskCreate(),删除任务用vTaskDelete()。嗯,这里要注意:任务一旦创建,调度器就会根据优先级决定谁先跑。

// 任务函数原型
void vTaskFunction(void *pvParameters);

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

// 删除任务(传入NULL表示删除自身)
void vTaskDelete(TaskHandle_t xTaskToDelete);
我的习惯:创建任务时,堆栈大小宁多勿少。ESP32默认每个任务堆栈1024字(约4KB),但如果你任务里用了printf、sprintf这类函数,堆栈消耗会暴涨。我建议至少给2048字,调试阶段可以开大点,稳定后再优化。

删除任务时,记得释放任务申请的资源。我曾经在项目中直接vTaskDelete(NULL)删除了一个持有互斥锁的任务,结果其他任务永远等不到锁,系统直接卡死。所以,删除前一定要确保任务不持有任何临界资源。

2.2 任务优先级与调度

FreeRTOS是抢占式调度器。什么意思?高优先级任务就绪时,会立即打断正在运行的低优先级任务。优先级数值越大,优先级越高(0是最低)。

优先级值 含义 典型用途
0 空闲任务优先级 系统空闲钩子函数
1~5 低优先级 后台数据采集、日志记录
6~10 中优先级 协议处理、状态机
11~15 高优先级 实时控制、中断响应
configMAX_PRIORITIES-1 最高优先级 紧急任务(慎用)

你想想看,如果两个任务优先级相同,调度器会怎么做?答案是时间片轮转。每个任务运行一个时间片(通常1ms),然后切换到下一个。但ESP32的FreeRTOS默认关闭了时间片轮转,需要你在FreeRTOSConfig.h里打开configUSE_TIME_SLICING。

避坑指南:我曾经在一个多传感器采集项目里,把三个采集任务都设为相同优先级。结果发现某个传感器数据总是滞后。排查了半天,原来是时间片轮转导致高频率采集任务被低频率任务打断。解决方案:把高频采集任务优先级提高一级,或者用任务通知替代轮询。

2.3 任务状态与切换

FreeRTOS的任务有四种状态:运行态、就绪态、阻塞态、挂起态。我画了一张图帮你理解状态切换逻辑。

FreeRTOS任务状态切换图 运行态 (Running) 就绪态 (Ready) 阻塞态 (Blocked) 挂起态 (Suspended) 被抢占/时间片用完 调度器选择 等待事件/延时 事件到达/超时 vTaskSuspend() vTaskResume() 关键点:只有就绪态的任务才能被调度器选中进入运行态 阻塞态通常由 vTaskDelay()、队列读取、信号量等待等操作触发

为什么会这样设计?因为嵌入式系统资源有限,不能让任务一直占着CPU。比如一个任务等待传感器数据,如果它不主动阻塞,CPU就会空转浪费。所以,好的任务设计应该是:没事做就阻塞,有事做就快速处理。

经验之谈:我习惯在任务循环开头加一个vTaskDelay(pdMS_TO_TICKS(10)),即使这个任务不需要等待。为什么?因为如果不加延时,高优先级任务会一直占用CPU,低优先级任务永远得不到执行。这叫“任务饥饿”。加一个短延时,给调度器切换的机会。

2.4 任务通知与队列

任务之间怎么通信?两个主流方式:任务通知和队列。

任务通知是FreeRTOS的轻量级通信机制。每个任务都有一个32位的通知值,你可以用xTaskNotifyGive()发送通知,用ulTaskNotifyTake()等待通知。它比队列快,因为不需要复制数据,但只能点对点通信。

// 发送通知(在中断中也可用)
BaseType_t xTaskNotifyGive(TaskHandle_t xTaskToNotify);

// 等待通知(阻塞模式)
uint32_t ulTaskNotifyTake(BaseType_t xClearCountOnExit, TickType_t xTicksToWait);

// 示例:按键中断通知采集任务
void IRAM_ATTR button_isr_handler(void *arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xTaskNotifyGiveFromISR(xDataCollectTask, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void vDataCollectTask(void *pvParameters) {
    while(1) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // 等待通知
        // 执行数据采集
        read_sensor();
    }
}

队列则更灵活。它可以传递任意长度的数据,支持多对多通信。但代价是数据拷贝的开销。

// 创建队列
QueueHandle_t xQueueCreate(UBaseType_t uxQueueLength, UBaseType_t uxItemSize);

// 发送数据到队列尾部
BaseType_t xQueueSend(QueueHandle_t xQueue, const void *pvItemToQueue, TickType_t xTicksToWait);

// 从队列接收数据
BaseType_t xQueueReceive(QueueHandle_t xQueue, void *pvBuffer, TickType_t xTicksToWait);

// 示例:传感器数据队列
typedef struct {
    float temperature;
    float humidity;
    uint32_t timestamp;
} sensor_data_t;

QueueHandle_t xSensorQueue = xQueueCreate(10, sizeof(sensor_data_t));

// 发送任务
sensor_data_t data = {25.3, 60.1, xTaskGetTickCount()};
xQueueSend(xSensorQueue, &data, portMAX_DELAY);

// 接收任务
sensor_data_t received;
xQueueReceive(xSensorQueue, &received, portMAX_DELAY);
特性 任务通知 队列
数据传递 仅32位值 任意长度数据
通信模式 点对点 多对多
性能 快(无拷贝) 慢(有拷贝)
中断中使用 支持(FromISR版本) 支持(FromISR版本)
适用场景 简单事件通知、唤醒任务 复杂数据结构、多消费者
我的选择原则:如果只是通知任务“有数据来了”,用任务通知。如果需要传递具体数据(比如传感器读数),用队列。另外,队列的uxItemSize不要设太大,否则内存浪费严重。我一般把结构体控制在64字节以内,超过的话考虑用指针传递。

最后提醒一点:队列和任务通知都有阻塞超时机制。如果你设了portMAX_DELAY,任务会一直等下去。这在某些场景下很危险——比如等待一个永远不会到来的数据。我建议给超时时间,比如pdMS_TO_TICKS(1000),然后检查返回值。如果超时了,做异常处理。

我曾经踩过的坑:在一个WiFi配网任务中,我用队列等待网络事件,设了portMAX_DELAY。结果网络模块初始化失败,事件永远不会来,任务永远阻塞。整个系统其他功能正常,但配网按钮按了没反应。后来改成超时等待+重试机制,问题解决。

好了,这一章的内容就到这里。任务创建与删除是基础,优先级与调度是核心,状态切换是理解系统行为的关键,任务通知与队列是通信的利器。把这些吃透了,你就能设计出稳定、高效的ESP32多任务系统。