3. 任务创建与管理:xTaskCreate API、任务栈分配、任务优先级设计

好,我们直接进入正题。任务,是RTOS里最核心的概念。说白了,你写的无人机飞控代码,最终都要拆成一个一个的任务,交给调度器去跑。这一节,我就带你手把手把任务创建、栈分配、优先级设计这几个硬骨头啃下来。

3.1 xTaskCreate API:任务的“出生证明”

在FreeRTOS里,创建一个任务,靠的就是 xTaskCreate() 这个函数。我刚开始用的时候,总觉得参数太多,记不住。后来发现,你只要理解每个参数是干嘛的,自然就记住了。

来看原型:

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

嗯,这里要注意一个坑:usStackDepth 的单位是“字”。在32位MCU上,一个字是4字节。所以如果你写 128,实际分配的栈大小是 128 * 4 = 512 字节。我见过有人直接写 1024,以为就是1024字节,结果栈溢出查了半天。

警告:栈深度单位是字,不是字节!在Cortex-M4上,一个字=4字节。写错了会导致栈分配过大或过小。

返回值也很关键。返回 pdPASS 表示创建成功,返回 errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY 说明堆空间不够。我在项目中遇到过,堆设小了,任务创建失败,飞控直接死机。后来我养成了一个习惯:每次创建任务后,都检查返回值。

TaskHandle_t xTaskPidHandle = NULL;
if (xTaskCreate(vTaskPidControl, "PID", 256, NULL, 5, &xTaskPidHandle) != pdPASS) {
    // 创建失败!必须处理
    vAssertCalled("PID task create failed");
}

3.2 任务栈分配:给每个任务一个“安全屋”

任务栈,说白了就是每个任务私有的“内存空间”。局部变量、函数调用、中断嵌套,全都要用这个栈。栈给大了,浪费RAM;给小了,栈溢出,系统崩溃。

怎么估算栈大小?我个人习惯用三步法:

  1. 静态分析:数一下任务函数里最大的局部变量数组。比如你定义了一个 float imu_data[6],那就是 6 * 4 = 24 字节。
  2. 函数调用链:任务函数里调用了哪些函数?每个函数有自己的栈帧。比如你调用了 vTaskDelay(),它内部可能还会调用其他函数,这些都要算进去。
  3. 中断嵌套:如果任务执行中被中断打断,中断服务函数也会用栈。特别是嵌套中断,栈消耗会翻倍。

举个例子,一个典型的姿态解算任务:

void vTaskAttitude(void *pvParameters) {
    float gyro[3], accel[3], mag[3];  // 3*4*3 = 36字节
    float quat[4];                     // 16字节
    float temp;                        // 4字节
    // 加上函数调用栈帧,大约需要 100-150字节
    // 再加上中断嵌套预留,我一般给 256字 = 1024字节
}
我的经验:先用 uxTaskGetStackHighWaterMark() 在调试阶段查看栈实际使用峰值。我一般留出 20%-30% 的余量。比如峰值用了 180字,我就设 256字。

我曾经在一个项目中,把GPS解析任务的栈设成了128字,结果跑着跑着就死机。查了两天,发现是栈溢出把任务控制块给覆盖了。从那以后,我每个任务都加上了栈水印检测。

3.3 任务优先级设计:谁先跑,谁后跑?

优先级设计,是飞控实时性的灵魂。优先级设错了,该先跑的任务跑不了,飞控就会抖、会炸机。

FreeRTOS的优先级规则很简单:数字越大,优先级越高。0是空闲任务,一般不用。最高优先级取决于 configMAX_PRIORITIES,我通常设32。

对于无人机飞控,我建议这样分配:

优先级 任务名称 说明
30-31 紧急保护任务 比如电池低压保护、遥控器信号丢失处理
20-25 传感器读取任务 IMU、GPS、气压计,要求高实时性
15-19 姿态解算任务 融合传感器数据,输出姿态
10-14 控制任务 PID计算,输出PWM
5-9 通信任务 数传、遥控器接收、日志输出
1-4 低优先级任务 LED闪烁、状态显示、参数保存

你想想看,如果传感器读取任务优先级比控制任务低,那控制任务拿到的就是过时的数据,飞控反应就会慢半拍。我见过一个案例,有人把GPS解析任务优先级设得比姿态解算还高,结果GPS数据更新慢,姿态解算被频繁打断,飞控直接震荡。

核心原则:实时性要求越高的任务,优先级越高。传感器读取 > 姿态解算 > 控制输出 > 通信 > 显示。

还有一个容易忽略的点:优先级反转。比如低优先级任务拿了互斥锁,高优先级任务等锁,结果中优先级任务抢占了CPU,导致高优先级任务迟迟拿不到锁。解决办法是用 vTaskPrioritySet() 临时提升优先级,或者用 xSemaphoreCreateMutex() 自带优先级继承机制。

嗯,这里要注意:不要把所有任务都设成同一个优先级。那样就退化成轮询了,实时性全无。我一般保持优先级差至少2-3级,给调度器留出切换空间。

3.4 实战:创建一个飞控任务

最后,我们结合一个实际例子,把上面三个知识点串起来。假设我们要创建一个姿态控制任务:

// 任务函数
void vTaskAttitudeControl(void *pvParameters) {
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xFrequency = 5;  // 200Hz

    for (;;) {
        // 等待到下一个周期
        vTaskDelayUntil(&xLastWakeTime, xFrequency);

        // 读取传感器数据(假设已经由更高优先级任务更新)
        // 执行姿态解算
        // 输出控制量
    }
}

// 创建任务
void vCreateAttitudeTask(void) {
    TaskHandle_t xAttitudeHandle = NULL;

    // 栈大小:256字 = 1024字节,足够容纳局部变量和函数调用
    // 优先级:16,介于传感器读取(20)和控制输出(12)之间
    if (xTaskCreate(
            vTaskAttitudeControl,
            "Attitude",
            256,
            NULL,
            16,
            &xAttitudeHandle) != pdPASS) {
        // 处理错误
        vErrorHandler();
    }
}

这个例子中,我用了 vTaskDelayUntil() 实现固定周期执行,而不是 vTaskDelay()。为什么?因为 vTaskDelayUntil() 能保证任务按固定频率执行,不会因为任务执行时间波动而累积误差。这在飞控里至关重要。

好了,任务创建与管理就讲到这里。记住三个关键点:栈大小要留余量、优先级要按实时性排序、创建后一定要检查返回值。下一节,我们会深入任务调度机制,看看FreeRTOS到底是怎么决定下一个该跑哪个任务的。