4. 多任务创建与管理:用 xTaskCreate 搭建售货机的“三驾马车”

好,咱们进入实战环节。上一章我们聊了 FreeRTOS 的任务调度机制,这一章直接上手——用 xTaskCreate 创建出货、支付、UI 刷新这三个核心任务。

我个人习惯,拿到一个新项目,先不急着写业务逻辑。先把任务框架搭起来,让系统先“跑起来”。你想想看,任务都跑不顺,后面加功能全是坑。

4.1 任务创建:三行代码搞定?没那么简单

先看代码。这是我在一个自动售货机项目里实际用过的任务创建模板:

// 任务句柄
TaskHandle_t xHandleDispense = NULL;
TaskHandle_t xHandlePayment = NULL;
TaskHandle_t xHandleUI = NULL;

void AppTaskCreate(void)
{
    // 创建出货任务
    xTaskCreate(
        vTaskDispense,          // 任务函数
        "Dispense",             // 任务名(调试用)
        256,                    // 栈深度(单位:字,不是字节!)
        NULL,                   // 参数
        3,                      // 优先级(0最低,configMAX_PRIORITIES-1最高)
        &xHandleDispense        // 任务句柄
    );

    // 创建支付任务
    xTaskCreate(vTaskPayment, "Payment", 512, NULL, 2, &xHandlePayment);

    // 创建UI刷新任务
    xTaskCreate(vTaskUI, "UI_Refresh", 128, NULL, 1, &xHandleUI);
}

嗯,这里要注意。栈深度我写的是 256、512、128,单位是 (Word)。在 32 位 MCU 上,一个字 = 4 字节。所以 256 字 = 1024 字节。我见过有人直接写 256 字节,结果任务一跑就栈溢出,查了半天。

避坑指南: 我曾经在一个项目中,UI 任务栈只给了 64 字,结果一刷新界面就 HardFault。后来用 uxTaskGetStackHighWaterMark() 一查,剩余栈空间只剩 8 个字。所以建议:先给大一点,稳定后再优化。

4.2 任务状态切换:运行、就绪、阻塞、挂起

任务创建后,它不会一直跑。FreeRTOS 的任务有四种状态,我画个表格帮你理清:

状态 含义 典型场景
运行 当前正在使用 CPU 单核 MCU 同一时刻只有一个任务在运行
就绪 可以运行,但 CPU 被更高优先级任务占着 高优先级任务在跑,低优先级任务排队等
阻塞 等待某个事件(延时、信号量、队列等) vTaskDelay() 等待、等待按键按下
挂起 被主动暂停,不参与调度 vTaskSuspend() 暂停某个任务

说白了,任务就像公司里的员工:运行是正在干活,就绪是活干完了等新任务,阻塞是在等快递或等审批,挂起是被老板叫停。

4.3 实战:让三个任务“动”起来

光创建不行,得让任务有实际行为。我写个简化版的售货机任务逻辑:

// 出货任务
void vTaskDispense(void *pvParameters)
{
    for(;;)
    {
        // 等待出货信号(来自支付任务)
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

        // 执行出货动作
        Motor_Start();
        vTaskDelay(pdMS_TO_TICKS(2000));  // 出货耗时2秒
        Motor_Stop();

        // 通知UI刷新
        xTaskNotifyGive(xHandleUI);
    }
}

// 支付任务
void vTaskPayment(void *pvParameters)
{
    for(;;)
    {
        // 轮询支付状态(实际项目用中断+队列更合理)
        if(Coin_Detect())
        {
            // 扣款逻辑...
            // 通知出货任务
            xTaskNotifyGive(xHandleDispense);
        }
        vTaskDelay(pdMS_TO_TICKS(50));  // 每50ms检查一次
    }
}

// UI刷新任务
void vTaskUI(void *pvParameters)
{
    for(;;)
    {
        // 等待刷新通知
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);

        // 刷新屏幕显示
        LCD_Update();
    }
}
关键点: 注意看状态切换。支付任务每 50ms 检查一次硬币,这 50ms 里它处于 阻塞 状态。出货任务在等待通知时也是 阻塞 状态。只有真正干活时才会进入 运行 状态。这就是 RTOS 的“按需分配 CPU”。

4.4 任务挂起与恢复:什么时候用?

挂起状态我用的不多,但有一个场景特别典型——系统待机

我记得有一次做户外售货机,要求 30 秒无操作自动进入低功耗模式。这时候不能删除任务(删除再创建开销大),直接挂起:

void EnterLowPowerMode(void)
{
    vTaskSuspend(xHandlePayment);  // 暂停支付检测
    vTaskSuspend(xHandleUI);       // 关闭屏幕刷新
    // 出货任务本来就在阻塞,不用管
}

void ExitLowPowerMode(void)
{
    vTaskResume(xHandlePayment);
    vTaskResume(xHandleUI);
}

挂起的任务不参与调度,不消耗 CPU 时间。恢复时从挂起点继续执行,就像什么都没发生过。

小技巧: 调试时我喜欢用 vTaskSuspendAll() 暂停所有任务,然后单步检查全局变量。但注意,这个函数不能在中断里调用,否则会死锁。

4.5 任务优先级设计:别让“饿死”发生

优先级分配是个艺术。我一般遵循两个原则:

  1. 实时性要求高的给高优先级——比如支付任务,用户投币必须立刻响应。
  2. 耗时长的给低优先级——比如 UI 刷新,慢个 100ms 用户感觉不到。

但有个坑:如果高优先级任务一直不阻塞,低优先级任务会“饿死”。

我曾经犯过这个错。把支付任务优先级设得最高,结果它在一个死循环里轮询硬币状态,CPU 被它占满,UI 任务根本得不到执行。屏幕卡死,用户以为机器坏了。

解决方案?让高优先级任务主动让出 CPU:

void vTaskPayment(void *pvParameters)
{
    for(;;)
    {
        if(Coin_Detect())
        {
            // 处理支付...
        }
        else
        {
            // 没有硬币?让出CPU给其他任务
            taskYIELD();
        }
    }
}

或者更优雅的做法:用 vTaskDelay() 或阻塞在信号量上,而不是轮询。

4.6 总结:任务管理的“三板斧”

好了,这一章的内容就这些。总结一下我的经验:

  • 创建任务:栈大小给够,优先级想清楚。
  • 状态切换:多用阻塞,少用轮询。让 CPU 歇着。
  • 挂起恢复:低功耗场景的利器,别滥用。

下一章我们会深入任务间通信——队列和信号量。到时候你会看到,任务之间怎么“聊天”才不会打架。