4、任务调度与管理:任务优先级划分、时间片轮转、抢占式调度、任务间通信

各位同学,咱们今天聊点实在的。飞控系统里,任务调度这块要是没整明白,飞机在天上分分钟给你脸色看。我做了这么多年飞控,见过太多因为调度问题炸机的案例了。说白了,调度就是决定「谁先跑、谁后跑、跑多久」的问题。

4.1 任务优先级划分——给任务排个座次

飞控里的任务,不是平起平坐的。有些任务晚1毫秒都不行,有些任务晚个几十毫秒也没事。所以,我们必须给任务排个优先级。

我个人习惯把飞控任务分成这么几档:

优先级等级 典型任务 响应要求
最高(0-1) IMU数据读取、紧急保护 <1ms
高(2-3) 姿态控制、位置控制 1-5ms
中(4-5) 导航解算、传感器融合 5-20ms
低(6-7) 日志记录、遥测发送 20-100ms

嗯,这里要注意:优先级不是越高越好。你把日志任务也设成最高优先级,那IMU数据反而可能被堵住。我在项目中遇到过有人把所有任务都设成同一个优先级,结果系统直接变成了「谁抢到算谁的」,乱成一锅粥。

避坑指南:我曾经在一个项目中,把姿态控制任务优先级设得比IMU读取还高。结果姿态控制跑的时候,IMU数据读不到,控制律算了个寂寞。后来我学乖了——数据采集任务的优先级,永远比使用数据的任务高一级。

4.2 时间片轮转——大家轮流用CPU

同优先级的任务怎么办?总不能一直让一个任务霸占CPU吧?这时候就需要时间片轮转了。

时间片轮转,说白了就是给每个任务分一个时间片。比如每个任务跑5ms,时间到了就换下一个。你想想看,如果三个同优先级的任务,每个跑5ms,那大家都能得到执行机会。

我一般这么配置时间片:

// 时间片配置示例
#define TIME_SLICE_MS 5  // 每个任务的时间片

// 任务创建时指定时间片
osThreadDef(attitude_control, AttitudeControlTask, osPriorityHigh, 0, 512);
osThreadDef(navigation, NavigationTask, osPriorityNormal, 0, 512);

// 启动调度器
osKernelStart();

但要注意,时间片不能设得太小。太小了,任务切换的开销反而比任务执行本身还大。我建议时间片至少是任务切换时间的10倍以上。

小技巧:如果你的MCU主频比较高(比如200MHz以上),时间片可以设到1ms。但如果是低频MCU,建议设到5-10ms。我在STM32F4上一般用5ms,在F7上用2ms。

4.3 抢占式调度——高优先级说了算

这才是飞控调度的核心。抢占式调度,就是高优先级任务可以随时打断低优先级任务。你想想看,如果飞机突然检测到姿态异常,难道还要等日志任务写完才能处理?当然不行。

抢占式调度的核心逻辑:

  1. 低优先级任务正在运行
  2. 高优先级任务就绪(比如定时器触发了)
  3. 调度器立即暂停低优先级任务
  4. 高优先级任务开始执行
  5. 高优先级任务完成后,低优先级任务继续

我记得有一次调试,发现姿态控制偶尔会延迟几毫秒。查了半天,原来是一个中断服务程序里调用了延时函数,把调度器给阻塞了。从那以后,我定了个规矩:中断服务程序里绝对不调用任何阻塞函数。

核心要点:抢占式调度下,高优先级任务的响应时间只取决于两个因素:一是中断关闭的时间,二是更高优先级任务的执行时间。所以,中断关闭的时间一定要严格控制,最好不超过10微秒。

4.4 任务间通信——让任务说上话

任务之间总得交换数据吧?比如IMU任务读到了数据,姿态控制任务要用。怎么传?有三种常用方式:队列、信号量、事件标志组。

4.4.1 队列——数据快递员

队列就是用来传递数据的。一个任务往里放,另一个任务往外取。先进先出,不会乱。

// 创建队列
osMessageQDef(imu_queue, 10, sizeof(IMU_Data_t));
osMessageQId imu_queue_id = osMessageCreate(osMessageQ(imu_queue), NULL);

// 发送任务(IMU读取)
void IMU_Task(void const * argument) {
    IMU_Data_t imu_data;
    while(1) {
        ReadIMU(&imu_data);
        osMessagePut(imu_queue_id, (uint32_t)&imu_data, 0);
        osDelay(1);  // 1ms读取一次
    }
}

// 接收任务(姿态控制)
void Attitude_Task(void const * argument) {
    osEvent event;
    IMU_Data_t *imu_data;
    while(1) {
        event = osMessageGet(imu_queue_id, osWaitForever);
        if(event.status == osEventMessage) {
            imu_data = (IMU_Data_t*)event.value.p;
            // 处理IMU数据
            UpdateAttitude(imu_data);
        }
    }
}

我个人习惯用队列传递传感器数据。为什么?因为队列自带缓冲,就算接收任务偶尔被高优先级任务抢占了,数据也不会丢。我曾经用全局变量传数据,结果数据被覆盖了,姿态解算直接飞了。

4.4.2 信号量——任务间的红绿灯

信号量不传数据,只传「信号」。比如告诉另一个任务:「我准备好了,你可以开始了」。

信号量有两种:

  • 二值信号量:只有0和1,像一把钥匙。谁拿到谁执行。
  • 计数信号量:可以计数,比如有3个资源,最多允许3个任务同时访问。
// 创建二值信号量
osSemaphoreDef(imu_ready_sem);
osSemaphoreId imu_ready_sem_id = osSemaphoreCreate(osSemaphore(imu_ready_sem), 0);

// IMU任务:读取完成后释放信号量
void IMU_Task(void const * argument) {
    while(1) {
        ReadIMU();
        osSemaphoreRelease(imu_ready_sem_id);  // 告诉姿态控制:数据准备好了
        osDelay(1);
    }
}

// 姿态控制任务:等待信号量
void Attitude_Task(void const * argument) {
    while(1) {
        osSemaphoreWait(imu_ready_sem_id, osWaitForever);  // 等IMU数据
        // 处理数据
        UpdateAttitude();
    }
}
使用建议:信号量适合做「同步」操作。比如一个任务等另一个任务完成某个步骤。但要注意,信号量不能传递具体数据,只能传递「事件发生了」这个信息。

4.4.3 事件标志组——多条件触发

有时候,一个任务要等好几个条件都满足了才能执行。比如姿态控制任务,要等IMU数据到了、GPS数据到了、气压计数据到了,三个条件都满足才能开始算。这时候事件标志组就派上用场了。

// 定义事件标志
#define EVENT_IMU_READY   0x0001
#define EVENT_GPS_READY   0x0002
#define EVENT_BARO_READY  0x0004

// 创建事件标志组
osEventFlagsDef(ctrl_event_flags);
osEventFlagsId_t ctrl_event_id = osEventFlagsNew(NULL);

// 各个传感器任务设置事件
void IMU_Task(void const * argument) {
    while(1) {
        ReadIMU();
        osEventFlagsSet(ctrl_event_id, EVENT_IMU_READY);
        osDelay(1);
    }
}

void GPS_Task(void const * argument) {
    while(1) {
        ReadGPS();
        osEventFlagsSet(ctrl_event_id, EVENT_GPS_READY);
        osDelay(10);  // GPS更新慢
    }
}

// 控制任务等待所有事件
void Control_Task(void const * argument) {
    uint32_t flags;
    while(1) {
        // 等待三个事件都发生
        flags = osEventFlagsWait(ctrl_event_id, 
                                 EVENT_IMU_READY | EVENT_GPS_READY | EVENT_BARO_READY,
                                 osFlagsWaitAll, osWaitForever);
        if(flags & (EVENT_IMU_READY | EVENT_GPS_READY | EVENT_BARO_READY)) {
            // 所有数据都到了,开始控制计算
            CalculateControl();
        }
    }
}

事件标志组的好处是灵活。你可以设置「与」条件(所有事件都发生)或者「或」条件(任意一个事件发生)。我在多传感器融合的项目里特别喜欢用这个,因为不同传感器的更新频率不一样,用事件标志组可以优雅地同步它们。

注意:事件标志组虽然好用,但别滥用。如果一个任务等的事件太多(比如超过8个),代码会变得很难维护。我一般控制在4个以内。另外,事件标志组是「一次性」的——你设置了事件,任务消费后事件就清零了。如果需要持续的状态,用全局变量更合适。

4.5 实际项目中的调度策略

说了这么多理论,咱们看看实际飞控里怎么用。以我常用的PX4架构为例:

// 实际飞控任务配置
// 最高优先级:传感器数据采集
osThreadDef(sensor_hub, SensorHubTask, osPriorityRealtime, 0, 1024);

// 高优先级:姿态与位置控制
osThreadDef(attitude_controller, AttitudeControllerTask, osPriorityHigh, 0, 2048);
osThreadDef(position_controller, PositionControllerTask, osPriorityHigh, 0, 2048);

// 中优先级:导航与状态估计
osThreadDef(ekf_estimator, EKFEstimatorTask, osPriorityNormal, 0, 4096);
osThreadDef(navigator, NavigatorTask, osPriorityNormal, 0, 2048);

// 低优先级:通信与日志
osThreadDef(mavlink_sender, MavlinkSenderTask, osPriorityLow, 0, 2048);
osThreadDef(logger, LoggerTask, osPriorityLow, 0, 1024);

这个配置我用了好几年,基本没出过问题。核心思路就是:数据采集 > 控制计算 > 状态估计 > 通信日志。优先级从高到低,层层递进。

最后说一句:调度策略没有银弹。不同的飞控硬件、不同的应用场景,调度策略都不一样。我建议你从最简单的开始,先跑起来,再用示波器或者逻辑分析仪看任务的实际执行时间,慢慢调优。别一上来就想搞个完美的调度器——那玩意儿不存在。