4. 任务调度器(Scheduler):调度器设计思想、任务优先级、运行频率、任务注册与执行机制

飞控系统里,任务调度器是个什么角色?

说白了,它就是飞控的「大脑皮层」。你想想看,一个飞控要同时处理传感器数据、运行姿态解算、控制电机输出、处理遥控器信号、还要跟地面站通信……这么多事情挤在一起,谁先干、谁后干、谁可以慢点干、谁必须准时干——这就是调度器要解决的问题。

我刚开始接触 ArduPilot 时,第一反应是:这不就是个 while 循环里调函数吗?后来踩了坑才发现,事情没那么简单。

4.1 调度器的设计思想

ArduPilot 的调度器,核心思想就四个字:分时协作。

它不像 RTOS 那样靠抢占式调度,而是用一种「跑马灯」式的轮询机制。每个任务注册时告诉调度器:我多久跑一次、我优先级多高。调度器就按这个表,一圈一圈地跑。

为什么这么设计?

我记得第一次在 STM32F4 上移植 ArduPilot 时,硬件资源很紧张。如果用抢占式调度,每个任务都要独立栈空间,光栈开销就能吃掉一半 RAM。而 ArduPilot 的协作式调度,所有任务共享主循环栈,内存利用率高得多。

核心设计原则:

  • 确定性:每个任务的执行间隔是固定的,不依赖外部中断
  • 低开销:调度器本身不创建线程,不涉及上下文切换
  • 可预测:任务执行顺序由注册顺序和优先级共同决定

当然,这种设计也有代价。如果一个任务执行时间过长,后面的任务就会被堵住。所以 ArduPilot 要求每个任务必须「快进快出」——跑完就退,别占着茅坑不拉屎。

4.2 任务优先级

ArduPilot 的任务优先级,不是 RTOS 那种 0-255 的数字优先级。它只有两个层级:高优先级和低优先级。

优先级 调度策略 典型任务
高优先级 每次主循环都执行 IMU 读取、姿态控制、电机输出
低优先级 按注册频率执行 GPS 处理、日志记录、遥测发送

高优先级任务,说白了就是「死任务」——每次主循环必须跑。低优先级任务则是「活任务」——调度器根据时间戳判断这次该不该跑。

我曾经犯过一个错误:把日志写入任务设成了高优先级。结果每次主循环都要写一次日志,飞控直接卡死。后来改成低优先级,设成 100Hz,问题就解决了。

避坑指南:

我曾经把 GPS 解析任务设成高优先级,结果 IMU 数据读取被延迟,飞控直接炸机。记住:高优先级只留给对实时性要求极高的任务,比如传感器读取和控制输出。

4.3 运行频率

每个任务注册时,都要告诉调度器:我期望的运行频率。单位是 Hz。

比如 IMU 读取通常是 1000Hz,姿态控制 400Hz,GPS 处理 10Hz。调度器内部维护一个 tick 计数器,每次主循环加 1。任务执行时,调度器检查当前 tick 是否到了该任务的下一次执行时间。

// 任务注册时的频率设置示例
void AP_Scheduler::register_task(task_t &task, uint16_t freq_hz)
{
    // freq_hz = 0 表示每次主循环都执行(高优先级)
    if (freq_hz == 0) {
        task.flags |= TASK_FLAG_HIGH_PRIORITY;
        return;
    }
    
    // 计算 tick 间隔
    // 假设主循环频率为 1000Hz
    // 那么 400Hz 的任务,每 2.5 个 tick 执行一次
    task.interval_ticks = main_loop_hz / freq_hz;
    task.next_run_tick = 0;
}

这里有个细节:主循环频率本身不是固定的。ArduPilot 会尽量跑满 CPU,但实际频率受任务执行时间影响。所以调度器用的是「相对 tick」,而不是「绝对时间」。

我建议你在调试时,用 SCHEDULER_DEBUG 宏打开调度器日志。这样能看到每个任务的实际执行频率和耗时,方便调优。

4.4 任务注册与执行机制

任务注册,说白了就是把函数指针塞进一个数组里。ArduPilot 用了一个静态数组 _tasks[] 来存储所有注册的任务。

// 任务注册表结构
typedef struct {
    void (*function)(void);   // 任务函数指针
    uint16_t interval_ticks;  // 执行间隔(tick数)
    uint16_t next_run_tick;   // 下次执行 tick
    uint8_t  priority;        // 优先级标志
    uint16_t max_time_us;     // 最大执行时间(微秒)
    const char *name;         // 任务名称(调试用)
} task_t;

// 注册示例
static const AP_Scheduler::Task scheduler_tasks[] = {
    { &read_imu,      1,    0, 0, 500,  "read_imu" },
    { &attitude_control, 2, 0, 0, 1000, "att_control" },
    { &gps_update,    100,  0, 0, 2000, "gps_update" },
    { &log_write,     50,   0, 0, 5000, "log_write" },
};

执行机制就更直接了。主循环里,调度器遍历这个数组,对每个任务做两件事:

  1. 检查当前 tick 是否 >= 任务的 next_run_tick
  2. 如果是,执行任务函数,更新 next_run_tick

嗯,这里要注意:高优先级任务没有 next_run_tick 检查,每次循环都执行。低优先级任务如果超时了,调度器会跳过这次执行,等下一个周期。

调试技巧:

我习惯在 AP_Scheduler::run() 里加一个看门狗喂狗操作。如果某个任务卡住了,看门狗会复位飞控,至少不会让飞机在天上失控。

4.5 调度器核心流程图

下面这张图,是我当年画在纸上的调度器逻辑。现在用 SVG 重画一遍,希望能帮你理清思路。

ArduPilot 任务调度器核心流程 任务调度器核心执行流程 主循环开始 遍历 _tasks[] 数组 高优先级? (freq_hz == 0) 立即执行任务 tick 到达? (cur_tick >= next_run) 执行任务 跳过本次 数组遍历完? 下一个任务 主循环结束 高优先级任务每次循环都执行 低优先级任务按 tick 间隔执行

4.6 实际项目中的调度器调优

光讲理论没意思,我分享几个实际项目中的经验。

经验一:任务执行时间要可控

我曾经在 gps_update 任务里做了 NMEA 协议解析,结果解析到一半被调度器打断(虽然协作式调度不会打断,但任务本身执行时间太长,会阻塞其他任务)。后来我把解析拆成多个小步骤,每次只解析一个字段,分多次执行完。

经验二:善用 max_time_us

每个任务注册时都有 max_time_us 字段。调度器会统计任务实际执行时间,如果超过这个值,会在调试日志里报警。我习惯把这个值设成任务期望执行时间的 1.5 倍,留点余量。

经验三:高优先级任务要精简

高优先级任务每次循环都执行,所以必须短小精悍。我见过有人把 read_imu 里塞了 SPI 通信、数据校验、滤波、甚至姿态解算——结果一次执行就花了 2ms,主循环频率直接掉到 500Hz 以下。

调度器调优检查清单:

  • 高优先级任务执行时间 < 主循环周期的 20%
  • 低优先级任务不要阻塞超过 1ms
  • 日志写入等 I/O 任务频率不要超过 50Hz
  • 定期检查调度器日志,看是否有任务超时

嗯,说到这,我想起一个经典案例。有个开发者把 log_write 设成了 1000Hz,结果 SD 卡写入速度跟不上,任务越积越多,最后调度器直接崩了。后来改成 10Hz,一切正常。

所以,调度器调优的核心就一句话:让每个任务在正确的时间做正确的事。