4. ArduPilot系统架构:调度器、任务、线程模型与uORB消息机制

飞控系统说到底,就是一个“实时多任务”系统。你得同时处理传感器数据、运行控制算法、响应遥控器指令、还要把数据发回地面站。这么多事情挤在一起,怎么保证不乱?

ArduPilot的答案很直接:调度器 + 线程模型 + uORB消息总线。这三样东西构成了整个飞控的骨架。我刚开始看代码时,第一反应是“这调度器怎么这么简单?”后来才发现,简单才可靠。

核心认知:ArduPilot不是跑在Linux上的通用程序,它是一个裸机级实时系统。调度器决定了“什么时候该干什么”,uORB决定了“数据怎么在不同模块间流动”。

4.1 调度器:飞控的“心脏起搏器”

调度器说白了就是一个大循环。它按固定频率检查每个任务是否该执行了。ArduPilot的调度器在 AP_Scheduler 库中实现,核心代码不到500行。

我给你们看个简化版的结构:

// 伪代码:调度器主循环
void Scheduler::loop() {
    uint32_t now = micros();
    for (每个任务) {
        if (任务->需要运行(now)) {
            任务->运行();
        }
    }
    // 剩余时间做低优先级任务
    run_idle_tasks();
}

嗯,就是这么直白。但直白不代表简单。调度器要解决几个关键问题:

  • 任务频率管理:每个任务都有自己的运行周期,比如姿态控制在400Hz,导航在100Hz
  • 时间片分配:不能让一个任务吃掉所有CPU时间
  • 过载保护:当任务执行超时,调度器要能检测并报警

我的经验:我曾经在调试一个四旋翼时,发现飞控偶尔会“卡顿”一下。查了两天才发现,是一个日志写入任务占用了太多时间。解决方案很简单——把日志写入放到低优先级任务里,调度器自然会在空闲时处理它。

4.2 任务:飞控的“工作单元”

每个任务就是一个函数指针 + 调度参数。ArduPilot用 Scheduler_Task 结构体来描述:

struct Scheduler_Task {
    void (*function)(void);  // 任务函数指针
    uint16_t rate_hz;        // 期望运行频率
    uint16_t max_time_micros; // 最大允许执行时间
};

任务表定义在 AP_Scheduler/AP_Scheduler.cpp 中。我截取一段典型的任务表:

static const AP_Scheduler::Task scheduler_tasks[] = {
    { &update_ahrs,          400, 400 },  // 姿态更新,400Hz
    { &update_attitude_control, 400, 400 },
    { &update_navigation,    100, 200 },  // 导航,100Hz
    { &update_gps,           10,  100 },  // GPS,10Hz
    { &update_rc_input,      50,  200 },  // 遥控器输入,50Hz
    { &update_gcs,           50,  200 },  // 地面站通信,50Hz
    { &update_logging,       10,  500 },  // 日志记录,10Hz
};

你看,每个任务都明确标注了频率和最大耗时。调度器就是靠这个表来分配CPU时间的。

注意:任务的实际执行频率不一定是标称频率。如果某个任务执行超时,调度器会跳过它的下一次执行。这就是所谓的“任务抖动”。在调试时,我经常用 @SYS/STATUS 消息查看任务的实际执行情况。

4.3 线程模型:单线程 vs 多线程

ArduPilot默认是单线程模型。所有任务都在一个主循环里串行执行。为什么不用多线程?

  • 确定性:单线程没有竞态条件,不需要锁,代码简单可靠
  • 资源占用低:不需要线程栈,内存占用小
  • 调试容易:出问题就是顺序执行,不会出现“死锁”这种头疼的问题

但单线程也有代价:如果一个任务卡住,整个系统就卡住了。所以ArduPilot对每个任务都做了时间监控。

不过,在 ChibiOS 和 Linux 平台上,ArduPilot也支持多线程。比如在Linux上,传感器读取、控制输出、日志写入可以跑在不同的线程里。但核心控制逻辑仍然在主线程中。

特性 单线程(默认) 多线程(ChibiOS/Linux)
确定性 高 中
资源占用 低 高
竞态风险 无 有
适用场景 STM32等MCU 树莓派、NuttX

我的建议:除非你明确需要多线程(比如在Linux上跑视觉处理),否则就用单线程。少一个线程,少一堆bug。我在一个项目中试过把传感器读取放到独立线程,结果因为共享数据没加锁,飞控偶尔会“抽风”。后来改回单线程,问题全没了。

4.4 uORB消息机制:模块间的“高速公路”

uORB是“micro Object Request Broker”的缩写。它最初来自PX4,后来被ArduPilot借鉴。uORB解决的核心问题是:不同模块之间怎么高效、安全地交换数据?

你想想看,姿态模块需要IMU数据,导航模块需要姿态数据,控制模块需要导航数据。如果每个模块都直接调用对方的函数,代码就乱成一锅粥了。

uORB的做法是:发布-订阅模式。

  • 发布者:传感器驱动、姿态估计等模块,把数据“发布”到uORB主题
  • 订阅者:控制、导航等模块,从uORB主题“订阅”需要的数据

看个例子:

// 发布IMU数据
void publish_imu_data() {
    orb_publish(ORB_ID(sensor_accel), &accel_data);
    orb_publish(ORB_ID(sensor_gyro), &gyro_data);
}

// 订阅IMU数据
void subscribe_imu_data() {
    int sub = orb_subscribe(ORB_ID(sensor_accel));
    orb_copy(ORB_ID(sensor_accel), sub, &accel_data);
}

uORB的好处很明显:

  • 解耦:发布者和订阅者互不知道对方存在
  • 高效:数据只拷贝一次,通过指针传递
  • 安全:uORB内部做了线程安全处理

关键点:uORB在ArduPilot中主要用于传感器数据、姿态估计结果、控制指令的传递。但在核心控制回路中,ArduPilot仍然使用直接函数调用(比如AHRS直接调用姿态控制),因为这样延迟更低。

4.5 整体架构图

下面这张图展示了调度器、任务、uORB之间的关系:

ArduPilot调度器与uORB架构图 ArduPilot调度器与uORB架构 调度器主循环 (Scheduler::loop) 姿态控制 (400Hz) 导航 (100Hz) GCS通信 (50Hz) uORB 消息总线 sensor_accel | sensor_gyro | attitude | position | rc_input | ... AHRS (订阅IMU) 位置估计 (订阅GPS) 日志 (订阅所有) 执行器输出 (PWM/DShot/UAVCAN) 调度器按频率调度任务 → 任务通过uORB发布/订阅数据 → 最终输出到执行器

4.6 避坑指南

我在实际项目中踩过几个坑,分享给你们:

  • 任务超时:如果某个任务执行时间超过 max_time_micros,调度器会打印警告。但不会停止任务。我曾经遇到一个情况:日志写入任务偶尔超时,导致后续任务被延迟。解决方案是降低日志频率,或者把日志放到空闲任务里。
  • uORB主题冲突:多个模块发布同一个主题时,后发布的会覆盖前面的。我在调试时发现姿态数据偶尔“跳变”,查了半天发现是两个模块都在发布 attitude 主题。后来统一了发布者。
  • 线程安全:如果你在多线程环境下使用uORB,记得检查 orb_publish 和 orb_copy 是否是线程安全的。ArduPilot的uORB实现默认是线程安全的,但如果你自己加线程,还是要小心。

重要:不要试图在任务函数里做阻塞操作(比如 delay() 或 while(1))。这会卡死整个调度器。如果你需要等待某个事件,用 hal.scheduler->delay() 或者状态机的方式实现。

4.7 小结

调度器、任务、线程模型、uORB,这四个概念构成了ArduPilot的运行时骨架。理解它们,你就理解了飞控代码是怎么“活”起来的。

我个人习惯在调试新硬件时,先打印出任务执行统计,看看每个任务实际跑了多少时间。如果某个任务占比超过50%,就要考虑优化了。记住:飞控的CPU时间不是无限的,每一微秒都要用在刀刃上。