3. ArduPilot固件架构深度解析:AP_HAL层、库与脚本、调度器与任务管理
ArduPilot 的架构,说白了就是一套「硬件隔离 + 模块复用」的设计。我最早接触它的时候,第一反应是:这代码怎么这么绕?后来踩了几个坑才明白,这种分层设计恰恰是它能在几十种飞控板上跑起来的关键。
今天咱们就拆开来看三层核心: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 米」指令。
- AP_Mission 库 解析到起飞指令,调用
takeoff(10)。 - AC_PosControl 计算当前位置与目标位置的偏差,输出期望姿态(俯仰、横滚、偏航)。
- AC_AttitudeControl 把期望姿态转为期望角速度,再通过 PID 算出每个电机的油门值。
- AP_HAL::RCOutput 把油门值转为 PWM 或 DShot 信号,输出到电调。
- 整个过程由 调度器 控制节奏:400Hz 跑姿态控制,50Hz 跑位置控制,10Hz 更新遥测。
你看,每一层各司其职,互不干扰。这就是分层架构的魅力。
⚠️ 重要提醒:不要试图在 Lua 脚本里直接调用 AP_HAL 接口。脚本运行在沙箱中,只能访问有限的 API。如果你需要操作硬件,必须先在 C++ 层封装一个库,然后注册到 Lua 环境中。