3. ArduPilot固件架构深度解析:AP_HAL层、库与脚本、调度器与任务管理

ArduPilot 的架构,说白了就是一套「硬件隔离 + 模块复用」的设计。我最早接触它的时候,第一反应是:这代码怎么这么绕?后来踩了几个坑才明白,这种分层设计恰恰是它能在几十种飞控板上跑起来的关键。

今天咱们就拆开来看三层核心:AP_HAL 硬件抽象层、库与脚本体系、调度器与任务管理。这三层搞清楚了,你改代码、移植硬件、甚至自己写个外设驱动,心里就有底了。

ArduPilot 三层架构图 ArduPilot 固件三层架构 应用层(Libraries + Scripts) 导航、控制、通信、传感器融合、任务规划 C++ 库(AP_NavEKF, AC_AttitudeControl...)+ Lua 脚本 调度器与任务管理(Scheduler) 主循环 400Hz / 50Hz / 10Hz 任务队列 看门狗、时间片、优先级、状态机 硬件抽象层(AP_HAL) UART / SPI / I2C / PWM / GPIO / RCOutput ChibiOS / NuttX / Linux / ESP32 等板级支持包 💡 上层不直接操作硬件寄存器,全部通过 AP_HAL 接口调用

3.1 AP_HAL 层:硬件隔离的「万能转接头」

AP_HAL 的全称是 ArduPilot Hardware Abstraction Layer。它的作用很简单:让上层的飞控代码不用关心你用的是 STM32 还是 AVR,是 I2C 还是 SPI。

我当年第一次移植 ArduPilot 到一个国产飞控板上,最头疼的就是这层。每个板子的 GPIO 寄存器地址不一样,定时器配置更是千奇百怪。但只要把 AP_HAL 的接口实现一遍,上层代码一行都不用改。

AP_HAL 的核心接口

接口类 功能 典型实现
HAL_UART 串口收发 DMA + 环形缓冲区
HAL_SPI SPI 总线通信 全双工、片选管理
HAL_I2C I2C 总线通信 多主机、超时重试
HAL_GPIO 通用输入输出 中断、上下拉、速率控制
HAL_RCOutput PWM 输出(舵机/电调) 定时器比较输出、DShot
HAL_Scheduler 时间片与延时 微秒级延时、任务注册

关键设计原则:AP_HAL 只定义纯虚接口类,不包含任何业务逻辑。每个硬件平台(如 ChibiOS、NuttX、Linux)各自实现这些接口。你在上层调用 hal.uart->write() 时,实际调用的是当前平台的具体实现。

💡 我的习惯:在移植新板子时,我会先实现 HAL_Scheduler 和 HAL_UART,因为这两个接口能让你先跑起来控制台输出,后续调试就方便多了。

3.2 库与脚本:C++ 核心 + Lua 扩展

ArduPilot 的库体系非常庞大,但核心就几个:EKF 滤波器、姿态控制、位置控制、导航。这些库全部用 C++ 写成,编译时静态链接。

但 ArduPilot 从 4.0 开始引入了 Lua 脚本引擎。为什么?因为有些用户想自定义飞行动作,又不想重新编译固件。Lua 脚本可以在运行时加载、修改、甚至热替换。

核心库一览

  • AP_NavEKF:扩展卡尔曼滤波器,融合 GPS、IMU、磁力计、气压计数据。我调过最久的就是它——参数调不好,飞机就在天上晃。
  • AC_AttitudeControl:姿态控制,输入期望角度,输出电机混控。说白了就是 PID 加前馈。
  • AC_PosControl:位置控制,输入期望位置,输出期望姿态。悬停、航线飞行全靠它。
  • AP_Mission:任务管理器,解析航点、兴趣点、条件指令。
  • AP_Logger:数据记录,二进制格式,掉电不丢数据。

⚠️ 我曾经踩过的坑:Lua 脚本虽然方便,但性能有限。有一次我在脚本里写了个死循环,结果调度器被卡住,飞机直接失控。后来我规定:脚本里绝对不允许用 while true,所有循环必须带超时退出。

Lua 脚本的典型用法

-- 一个简单的 Lua 脚本示例:起飞后悬停 5 秒
function start()
  gcs:send_text(0, "Mission started!")
  vehicle:set_mode("GUIDED")
  vehicle:arm_and_takeoff(10)  -- 起飞到 10 米
end

function update()
  local alt = vehicle:get_altitude()
  if alt > 9.5 then
    gcs:send_text(0, "Hovering for 5 seconds...")
    return update, 5000  -- 5 秒后继续
  end
  return update, 100     -- 每 100ms 检查一次高度
end

你看,脚本里不需要关心底层硬件,也不需要编译。改完脚本直接上传到飞控的 SD 卡,重启就生效。这对快速原型验证来说,太方便了。

3.3 调度器与任务管理:飞控的「心脏」

调度器是 ArduPilot 最容易被忽视、但最重要的部分。它决定了:什么时候该做什么事。

ArduPilot 的调度器不是实时操作系统(RTOS)里的抢占式调度,而是 协作式 + 时间片轮转。什么意思?每个任务主动让出 CPU,调度器按固定频率轮询。

主循环频率

任务组 频率 典型任务
高速任务 400 Hz (2.5ms) 姿态控制、角速度环、电机混控
中速任务 50 Hz (20ms) 位置控制、导航、传感器融合
低速任务 10 Hz (100ms) 遥测发送、日志记录、参数检查
后台任务 1 Hz 或更低 SD 卡写入、校准、自检

为什么会这样设计?你想想看,姿态控制需要极高的实时性,但日志写入慢一点没关系。如果让日志写入占用了姿态控制的时间片,飞机就会抖。所以 ArduPilot 把任务按频率分层,每层有独立的队列。

调度器的核心代码片段

// 简化后的调度器主循环
void Scheduler::run()
{
    uint32_t now = micros();
    
    // 400Hz 任务
    if (now - _last_400hz >= 2500) {
        _last_400hz = now;
        run_fast_loop();  // 姿态控制、IMU 读取
    }
    
    // 50Hz 任务
    if (now - _last_50hz >= 20000) {
        _last_50hz = now;
        run_medium_loop();  // 位置控制、导航
    }
    
    // 10Hz 任务
    if (now - _last_10hz >= 100000) {
        _last_10hz = now;
        run_slow_loop();  // 遥测、日志
    }
    
    // 空闲时运行后台任务
    run_background_tasks();
}

注意:这个循环是 非抢占式 的。如果某个任务执行时间过长,后面的任务就会被延迟。所以 ArduPilot 要求每个任务必须在 1ms 内完成,否则调度器会报警。

看门狗与故障保护

调度器还内置了 看门狗(Watchdog)。如果某个任务卡死超过 100ms,看门狗会强制复位飞控。我遇到过最离谱的一次,是某个传感器驱动里有个 while(!ready) 循环,结果传感器坏了,循环永远出不来。看门狗救了我一命。

💡 避坑指南:如果你自己写外设驱动,一定要加超时机制。我曾经在 SPI 通信里忘了加超时,结果飞控在天上突然重启——因为看门狗咬人了。从那以后,我所有驱动都带 timeout_ms 参数。

3.4 三层如何协同工作?

咱们用一个实际场景串起来:飞控执行「起飞到 10 米」指令。

  1. AP_Mission 库 解析到起飞指令,调用 takeoff(10)。
  2. AC_PosControl 计算当前位置与目标位置的偏差,输出期望姿态(俯仰、横滚、偏航)。
  3. AC_AttitudeControl 把期望姿态转为期望角速度,再通过 PID 算出每个电机的油门值。
  4. AP_HAL::RCOutput 把油门值转为 PWM 或 DShot 信号,输出到电调。
  5. 整个过程由 调度器 控制节奏:400Hz 跑姿态控制,50Hz 跑位置控制,10Hz 更新遥测。

你看,每一层各司其职,互不干扰。这就是分层架构的魅力。

⚠️ 重要提醒:不要试图在 Lua 脚本里直接调用 AP_HAL 接口。脚本运行在沙箱中,只能访问有限的 API。如果你需要操作硬件,必须先在 C++ 层封装一个库,然后注册到 Lua 环境中。