第4章:任务创建与管理:任务函数原型、任务堆栈设置、任务优先级设计

好,咱们今天聊聊任务创建。这是RTOS里最基础,也是最容易出问题的地方。很多新手一上来就写任务,结果系统跑起来各种奇怪现象——任务不执行、堆栈溢出、优先级反转……说白了,都是这几个核心概念没吃透。

我个人习惯,在飞控项目里,任务创建前会先画一张任务关系图。哪个任务负责传感器采集,哪个做姿态解算,哪个处理遥控器信号。画清楚了再动手写代码,能省掉后面80%的调试时间。

4.1 任务函数原型——你的任务长什么样

每个RTOS的任务函数,其实就是一个无限循环的C函数。它的原型长这样:

void TaskFunction(void *pvParameters)
{
    // 初始化代码(只执行一次)
    
    while(1)
    {
        // 任务主体代码
        // 等待事件、处理数据、输出结果
        
        // 必须有阻塞点!否则低优先级任务永远没机会跑
        vTaskDelay(pdMS_TO_TICKS(10));  // 延时10ms
    }
}

这里有个关键点:任务函数绝对不能返回。一旦返回,RTOS会认为这个任务结束了,会把它从调度队列里移除。我在项目中遇到过,有个同事写任务时忘了加while(1),结果任务跑一遍就没了,系统直接卡死。排查了半天才发现问题。

任务函数的黄金法则:

  • 必须是一个无限循环
  • 循环体内必须有阻塞点(延时、等待信号量、等待消息队列等)
  • 不能使用局部大数组(容易导致堆栈溢出)
  • 参数pvParameters可以传递结构体指针,实现多实例任务

你想想看,如果没有阻塞点,这个任务就会一直占着CPU不放。其他任务怎么办?飞控里传感器采集任务优先级高,姿态解算任务优先级低,如果采集任务不阻塞,解算任务永远得不到CPU时间——那飞控就废了。

4.2 任务堆栈设置——给任务一个合适的家

堆栈,说白了就是任务自己的“临时仓库”。局部变量、函数调用时的返回地址、中断现场保护,全存在这里。堆栈设小了,任务跑着跑着就溢出了;设大了,RAM浪费严重。飞控的RAM本来就金贵,得精打细算。

我记得刚开始做飞控时,堆栈大小全靠猜。后来学乖了,用了个笨办法:

// 先给一个较大的堆栈,比如512字(注意是字,不是字节)
#define STACK_SIZE_SENSOR  512

// 创建任务
xTaskCreate(
    vSensorTask,          // 任务函数
    "Sensor",             // 任务名称(调试用)
    STACK_SIZE_SENSOR,    // 堆栈大小(单位:字)
    NULL,                 // 参数
    5,                    // 优先级
    NULL                  // 任务句柄
);

// 运行一段时间后,检查堆栈使用峰值
UBaseType_t uxHighWaterMark = uxTaskGetStackHighWaterMark(xSensorHandle);
// uxHighWaterMark 表示剩余的最小堆栈空间(单位:字)
// 如果这个值接近0,说明堆栈快溢出了

堆栈设置经验值(基于Cortex-M4,32位处理器):

任务类型 推荐堆栈大小(字) 说明
简单IO任务(如LED闪烁) 128-256 几乎没有局部变量
传感器采集任务 256-512 可能有I2C/SPI缓冲区
姿态解算任务 512-1024 涉及浮点运算、矩阵计算
通信协议解析任务 512-1024 可能有较大的协议缓冲区

我曾经在一个项目中,把姿态解算任务的堆栈设成了256字。结果飞控飞了3分钟,突然炸机——堆栈溢出了,把相邻任务的数据给踩了。从那以后,我每个任务都会用uxTaskGetStackHighWaterMark跑一遍,确认堆栈余量至少留30%。

堆栈溢出有多可怕?

  • 不会立刻报错,而是悄悄破坏其他任务的数据
  • 可能导致随机重启、计算错误、电机失控
  • 在飞控里,这就是炸机的直接原因
  • 建议:量产前做48小时压力测试,监控每个任务的堆栈水位

4.3 任务优先级设计——谁先跑,谁后跑

优先级设计,是RTOS里最考验功力的地方。设高了,低优先级任务饿死;设低了,实时性要求高的任务响应不过来。飞控里尤其敏感——传感器数据晚到1ms,姿态解算就可能出错。

我一般把任务分成三个等级:

// 优先级定义(数值越大,优先级越高)
#define PRIO_CRITICAL   5   // 关键任务:传感器采集、PWM输出
#define PRIO_NORMAL     3   // 普通任务:姿态解算、控制律计算
#define PRIO_BACKGROUND 1   // 后台任务:日志记录、状态上报

为什么这么分?

  • 传感器采集(优先级5):必须准时读取,否则数据就丢了。IMU数据通常需要1000Hz采样,延时1ms都不行。
  • 姿态解算(优先级3):可以稍微等一等,但不能等太久。一般200-500Hz更新就够了。
  • 日志记录(优先级1):什么时候跑都行,数据晚几秒记录也没关系。

这里有个常见的坑:优先级反转。举个例子,高优先级的姿态解算任务要等低优先级的传感器采集任务的数据,但传感器采集任务被中等优先级的日志任务抢占了CPU——结果高优先级任务反而被低优先级任务阻塞了。

避免优先级反转的三种方法:

  1. 优先级继承:当高优先级任务等待低优先级任务持有的资源时,临时提升低优先级任务的优先级。FreeRTOS的互斥量支持这个特性。
  2. 优先级天花板:给每个资源设定一个最高优先级,任何任务访问该资源时,优先级临时提升到这个值。
  3. 关中断保护:对于非常短的临界区,直接关中断。但关中断时间不能超过10μs,否则影响实时性。

我个人习惯用优先级继承。FreeRTOS的互斥量默认就支持,只需要把xSemaphoreCreateBinary换成xSemaphoreCreateMutex就行。省心,而且不会引入额外的复杂度。

嗯,这里要注意:优先级不是越多越好。我见过有人设了32个优先级,结果调度开销比任务执行时间还长。飞控里一般5-8个优先级就够用了。多了反而增加系统不确定性。

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

最后,咱们把上面这些知识串起来,写一个完整的传感器采集任务:

// 任务函数
void vSensorTask(void *pvParameters)
{
    // 局部变量——注意不要太大
    IMU_Data_t xImuData;
    TickType_t xLastWakeTime = xTaskGetTickCount();
    
    // 初始化传感器
    IMU_Init();
    
    // 任务主循环
    for(;;)
    {
        // 读取IMU数据(I2C通信,可能阻塞)
        IMU_Read(&xImuData);
        
        // 将数据发送给姿态解算任务(通过消息队列)
        xQueueSend(xAttitudeQueue, &xImuData, 0);
        
        // 精确延时:每1ms执行一次(1000Hz)
        vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(1));
    }
}

// 创建任务
void vCreateTasks(void)
{
    // 传感器采集任务:优先级最高,堆栈512字
    xTaskCreate(
        vSensorTask,
        "Sensor",
        512,
        NULL,
        5,              // 最高优先级
        &xSensorHandle
    );
    
    // 姿态解算任务:优先级中等,堆栈1024字
    xTaskCreate(
        vAttitudeTask,
        "Attitude",
        1024,
        NULL,
        3,
        &xAttitudeHandle
    );
    
    // 日志任务:优先级最低,堆栈256字
    xTaskCreate(
        vLogTask,
        "Logger",
        256,
        NULL,
        1,
        NULL
    );
    
    // 启动调度器
    vTaskStartScheduler();
}

这个例子看起来简单,但每个细节都有讲究:

  • vTaskDelayUntil保证精确的1ms周期,不会因为任务执行时间波动而累积误差
  • 消息队列传递数据,避免了全局变量带来的同步问题
  • 堆栈大小根据任务复杂度分别设置,不浪费RAM

好了,任务创建这块就聊到这儿。下一章咱们讲任务同步与通信——信号量、消息队列、事件标志组,这些才是让任务们协同工作的关键。到时候我会分享一个我踩过的坑:用全局变量做任务间通信,结果数据被撕成两半……