3. 任务管理与调度:任务创建/删除/挂起/恢复、任务优先级与堆栈配置、调度器启动与钩子函数、任务通知与事件组

各位同学,咱们今天聊点硬核的——任务管理。说白了,RTOS 的核心就是管好一堆任务,让它们看起来像在“同时运行”。我做了这么多年嵌入式,见过太多系统崩溃,十有八九都是任务没管好。

嗯,咱们从最基础的说起。

3.1 任务创建、删除、挂起与恢复

任务在 RTOS 里就是一个无限循环的函数。你想想看,一个单片机要处理按键、刷屏幕、读传感器,每个功能都得是个独立任务。

3.1.1 任务创建

以 FreeRTOS 为例,创建任务用 xTaskCreate()。原型长这样:

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

我在项目中遇到过一个问题:新手喜欢把堆栈设得特别大,觉得“反正内存够”。结果呢?一个项目里 20 个任务,每个堆栈 1024 字,光堆栈就吃掉 80KB RAM。对于 STM32F4 这种只有 128KB RAM 的芯片,基本就废了。

我的习惯:先给每个任务分配一个保守的堆栈大小(比如 128 字),然后用 uxTaskGetStackHighWaterMark() 在运行时查看实际使用峰值,再调整到 1.5 倍左右。

3.1.2 任务删除

任务自己可以调用 vTaskDelete(NULL) 来“自杀”。也可以让别人删它:

TaskHandle_t xTaskHandle;
// 创建时拿到句柄
xTaskCreate(vTaskFunction, "Task1", 256, NULL, 1, &xTaskHandle);
// 在别处删除
vTaskDelete(xTaskHandle);
注意:任务删除后,它占用的堆栈会被系统回收,但任务自己申请的动态内存(比如 malloc)不会自动释放。我曾经因为这个踩过坑——一个通信任务反复创建删除,最后内存泄漏导致系统挂死。

3.1.3 挂起与恢复

挂起就是让任务“睡觉”,但不删除它。恢复就是叫醒它。

vTaskSuspend(xTaskHandle);   // 挂起
vTaskResume(xTaskHandle);    // 恢复

挂起的任务不参与调度,优先级再高也没用。我常用这个做“低功耗模式切换”——系统要休眠时,把所有非关键任务挂起,只留一个唤醒任务。

3.2 任务优先级与堆栈配置

优先级是 RTOS 调度的心脏。FreeRTOS 的优先级数值越小,优先级越低。0 是空闲任务,最高优先级由 configMAX_PRIORITIES 决定。

优先级值 典型用途 说明
0 空闲任务 系统自动创建,不可删除
1-3 后台任务 数据记录、日志输出、状态检测
4-6 普通任务 传感器读取、协议处理、UI 刷新
7-9 实时任务 控制环路、通信响应、中断下半部

堆栈配置这块,我建议遵循“够用就好,留有余量”。

堆栈配置黄金法则:

  • 任务函数内部局部变量越多,堆栈需求越大
  • 函数调用嵌套深度每增加一层,大约多消耗 20-40 字节
  • 中断嵌套会额外消耗当前任务的堆栈
  • 浮点运算任务需要额外 100-200 字节保存 FPU 上下文

3.3 调度器启动与钩子函数

任务都创建好了,怎么让它们跑起来?调用 vTaskStartScheduler()。

int main(void) {
    // 硬件初始化
    HAL_Init();
    SystemClock_Config();
    
    // 创建任务
    xTaskCreate(vTaskLED, "LED", 128, NULL, 1, NULL);
    xTaskCreate(vTaskUART, "UART", 256, NULL, 2, NULL);
    
    // 启动调度器——这行之后,main 函数就不再执行了
    vTaskStartScheduler();
    
    // 如果跑到这里,说明内存不够启动调度器
    while(1);
}

调度器启动后,系统会先创建空闲任务,然后开始按优先级调度。这里有个细节:vTaskStartScheduler() 会先初始化系统时钟节拍(SysTick),然后切换到第一个最高优先级的就绪任务。

3.3.1 钩子函数

钩子函数是 RTOS 留给用户的“后门”。FreeRTOS 提供了几个:

  • 空闲任务钩子:空闲任务每次循环都会调用。适合做低功耗休眠、CPU 利用率统计。
  • 栈溢出钩子:检测到堆栈溢出时触发。调试神器。
  • 调度器挂起钩子:调度器挂起/恢复时调用。
// 空闲钩子示例:进入休眠
void vApplicationIdleHook(void) {
    __WFI();  // 等待中断,进入休眠
}

// 栈溢出钩子示例:打印错误
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) {
    printf("Stack overflow in task: %s\n", pcTaskName);
    // 这里可以记录错误,然后复位系统
}
我曾经...在一个产品中遇到随机死机,查了三天找不到原因。最后打开栈溢出钩子,发现是一个通信任务的堆栈在极端数据包长度下溢出了。从那以后,我每个项目必开栈溢出钩子。

3.4 任务通知与事件组

任务之间怎么通信?除了队列和信号量,FreeRTOS 还提供了两种轻量级机制:任务通知和事件组。

3.4.1 任务通知

任务通知比信号量快 45% 左右,而且不需要额外创建对象。每个任务都有一个 32 位的通知值。

// 发送通知
xTaskNotifyGive(xTaskHandle);  // 相当于给信号量

// 等待通知
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);  // 清空通知值,无限等待

任务通知的几种模式:

模式 函数 用途
发送通知(递增) xTaskNotifyGive() 类似二值信号量
设置特定位 xTaskNotify() 事件标志
覆写通知值 xTaskNotify() 传递数据
等待通知 ulTaskNotifyTake() 阻塞等待
注意:任务通知只能一对一通信。一个任务只能被一个发送者通知。如果你需要一对多广播,请用事件组或队列。

3.4.2 事件组

事件组就是一堆二进制标志位,最多 24 位(FreeRTOS 保留高 8 位)。多个任务可以等待同一个事件组,也可以设置不同的事件位。

EventGroupHandle_t xEventGroup;

// 创建事件组
xEventGroup = xEventGroupCreate();

// 任务 A:等待事件位 0 和 1 同时置位
EventBits_t uxBits = xEventGroupWaitBits(
    xEventGroup,          // 事件组句柄
    (1 << 0) | (1 << 1), // 等待位 0 和 1
    pdTRUE,               // 退出时清除这些位
    pdTRUE,               // 等待所有位(pdFALSE 表示任意一个即可)
    portMAX_DELAY         // 无限等待
);

// 任务 B:设置事件位 0
xEventGroupSetBits(xEventGroup, (1 << 0));

// 任务 C:设置事件位 1
xEventGroupSetBits(xEventGroup, (1 << 1));

事件组的典型应用场景:

  • 等待多个外设初始化完成
  • 多条件触发任务执行
  • 同步多个任务的执行阶段

选型建议:

  • 简单的一对一同步 → 任务通知(最快、最省内存)
  • 一对多同步或复杂条件组合 → 事件组
  • 需要传递数据 → 队列
  • 资源互斥访问 → 互斥量

3.5 本章知识体系

下面这张图把任务管理的核心脉络串起来了:

任务管理与调度知识体系 任务管理与调度核心知识体系 调度器 (Scheduler) 任务生命周期 创建 删除 挂起 恢复 调度机制 优先级 堆栈配置 钩子函数 启动调度器 任务通信 任务通知 事件组 队列/信号量 核心原则:高优先级任务优先执行,同优先级任务时间片轮转,空闲任务处理低功耗

嗯,任务管理这块内容不少,但核心就三件事:创建好任务、配好优先级、选对通信方式。你想想看,只要把这三点拿捏住,大部分 RTOS 项目都不会出大问题。

我个人的经验是:先画任务关系图,再写代码。别急着敲键盘,把每个任务的优先级、堆栈大小、通信方式列个表格,评审一遍再动手。这样能省掉后面至少一半的调试时间。