第二章: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);
删除任务时,记得释放任务申请的资源。我曾经在项目中直接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的任务有四种状态:运行态、就绪态、阻塞态、挂起态。我画了一张图帮你理解状态切换逻辑。
为什么会这样设计?因为嵌入式系统资源有限,不能让任务一直占着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),然后检查返回值。如果超时了,做异常处理。
portMAX_DELAY。结果网络模块初始化失败,事件永远不会来,任务永远阻塞。整个系统其他功能正常,但配网按钮按了没反应。后来改成超时等待+重试机制,问题解决。
好了,这一章的内容就到这里。任务创建与删除是基础,优先级与调度是核心,状态切换是理解系统行为的关键,任务通知与队列是通信的利器。把这些吃透了,你就能设计出稳定、高效的ESP32多任务系统。