飞控二次开发这件事,我见过太多人走弯路。拿到一块 Pixhawk 或者 SpeedyBee F405,第一反应就是去 GitHub 拉源码,然后对着 ArduPilot 几十万行 C++ 发呆,或者一头扎进 PX4 的模块树里出不来。不是说啃源码不对,而是"二次开发"这四个字的含义远比"改固件"要宽。以我自己的经验,从外挂树莓派到自定义模块,中间隔着好几条完全不同的技术路径,每一条的工作量、风险、适用场景都不一样。这篇文章就把这些路径一次性讲透,帮你在动手之前先想明白:你到底需要开发的是什么,以及哪条路最适合你。
1. 飞控二次开发的地图:先知道自己站在哪一层
1.1 "啃源码"只是其中一种,还是最难的那种
先说一个很反直觉的事实:多数实际项目里的"飞控二次开发",根本不需要改飞控固件源码。航拍无人机要做的目标跟踪、物流无人机的航线规划、巡检无人机的数据采集,这些功能全部可以在飞控之外的"第二台电脑"上完成。飞控在这套架构里只扮演一个稳定飞行和响应指令的执行者,真正的业务逻辑放在外挂设备上。改源码只是所有路径里最深入、最灵活、也最难维护的一种,不应该成为你起步的默认选项。
回想我自己第一次做飞控二次开发,是给一台四旋翼加一个视觉降落功能。当时我的第一反应也是去看 PX4 源码,想在里面加一个视觉识别模块。结果光是搭建交叉编译环境就折腾了两个晚上,还没等我把代码写出来,一个前辈提醒我:Pixhawk 上还有一个空闲的串口,你直接用树莓派跑 OpenCV,识别结果通过 MAVLink 发给飞控不就行了?那一瞬间我意识到,我一直以来都在用最费力的方式解决一个本可以很简单的需求。
1.2 从外挂到固件的四层开发路径
我把常见的飞控二次开发方式按"离飞控核心的远近"分成四个层级:
| 层级 | 开发方式 | 是否改固件 | 典型工具 | 难度 | 风险 |
|---|---|---|---|---|---|
| 第一层 | 地面站参数配置 | 否 | Mission Planner / QGroundControl | 极低 | 极低 |
| 第二层 | 外挂协处理器的 MAVLink 开发 | 否 | 树莓派 / Jetson Nano / 单片机 | 中 | 低 |
| 第三层 | 在固件内扩展新模块 | 是 | PX4 模块 / ArduPilot 库 | 高 | 中 |
| 第四层 | 深度改造飞行控制算法 | 是 | 姿态解算、EKF、控制器 | 极高 | 高 |
注意,这四层之间不是互相替代的关系,而是递进的关系。一个成熟的行业应用,往往是先在第二层把业务逻辑跑通,确认算法有效,再把最核心、对实时性要求最高的部分下沉到第三层甚至第四层。直接从第四层起步,等于在业务验证之前就先啃了最难的技术骨头。
1.3 一句话判断自己该走哪条路
判断依据可以归结为一个问题:你的功能需要多快的响应速度?
- 响应速度在几十毫秒到秒级就够用的(视觉识别、路径规划、物流投放、航测数据采集),走外挂方案,用树莓派或者高性能单片机。
- 响应速度要到毫秒级甚至更快、必须和姿态控制强耦合的(比如特殊的避障策略、故障保护逻辑、新的控制算法),才需要进固件层。
我见过一个做电力巡检的团队,他们想在无人机碰到电线之前自动刹车。最初打算在 PX4 源码里加一个激光雷达数据处理模块,后来发现这套系统的决策周期其实在 100ms 量级就够了,最后用树莓派加一个串口转发的方案轻松搞定,开发周期从两个月缩短到两周。这就是先想清楚需求边界的好处。
2. 外挂树莓派:不动固件的"旁路开发"
2.1 这套方案的架构逻辑:飞控当"执行者",树莓派当"大脑"
外挂方案的架构可以这样理解:树莓派是大脑,飞控是脊髓反射弧。飞控本身依然在干它最擅长的事——姿态解算、电机控制、定高定点;树莓派则负责一切"需要思考"的工作——图像识别、路径规划、传感器融合、任务调度。二者通过串口(UART)相连,用 MAVLink 协议对话。
这么做的好处非常明显:
- 飞控固件完全不用动,稳定性有保障,飞控厂商做的所有安全机制原封不动。
- 开发环境友好。树莓派上跑的是完整 Linux,Python、C++、ROS 随便用,出问题调试起来比嵌入式环境舒服太多。
- 功能迭代快。想改逻辑改代码就行,不用重新编译烧录固件,也不用担心刷固件把飞机刷成砖。
代价是增加了一台外设的重量和功耗,而且通信链路上多了一环,会有一定的延迟。但绝大多数二次开发场景根本感知不到这点延迟。
2.2 树莓派和飞控怎么接:一根线的事
接线远比想象中简单。以树莓派 4B 和 Pixhawk 为例:
- 飞控的 TELEM1 或者 TELEM2 串口 —— 这是飞控对外通信的串口,一般是 JST-GH 1.25mm 接口。
- 树莓派的 UART 引脚(GPIO 14 的 TXD 和 GPIO 15 的 RXD)—— 注意交叉连接:飞控 TX 接树莓派 RX,飞控 RX 接树莓派 TX。
- 两边必须共地(GND 相连),否则通信会出现随机乱码。
还有几个容易被忽略的细节:
- 电平问题。树莓派的 GPIO 是 3.3V 电平,Pixhawk 的串口也是 3.3V,可以直接连。如果你用的是 Arduino 的 5V 串口,就要加电平转换芯片,不然有烧毁飞控串口引脚的风险。
- 树莓派默认的串口可能被分配给了 Linux 控制台,需要先做两处修改:一是用
raspi-config关闭串口终端登录,二是把/boot/config.txt里的enable_uart=1打开。这步不做,你会发现树莓派发出去的数据全被系统控制台的日志占用了。 - 飞控这边需要在 Mission Planner 或者 QGroundControl 里把对应的串口协议设置为 MAVLink,波特率给到 57600 或者 115200。默认 9600 不是不能用,但跑 MAVLink 高频消息时容易丢包。
2.3 基于 MAVLink 的核心开发方式
飞控与树莓派之间的对话语言是 MAVLink,这是一套专门为飞行器设计的轻量级消息协议。它的设计类似一个"航空报文系统":每一条消息都有固定的编号(消息 ID)、格式和载荷,通信双方只要都会这套报文,就能互相理解。
开发时你需要重点掌握几组消息:
| 功能方向 | 关键消息 | 说明 |
|---|---|---|
| 获取状态 | HEARTBEAT | 心跳包,判断飞控是否在线 |
| 获取姿态 | ATTITUDE | 飞控当前四元数姿态 |
| 获取位置 | GLOBAL_POSITION_INT | 全球经纬度和相对高度 |
| 手动控制 | MANUAL_CONTROL/RC_CHANNELS_OVERRIDE | 直接给通道值,模拟遥控器 |
| 自动控制 | SET_POSITION_TARGET_LOCAL_NED | 给定目标位置 / 速度 / 加速度 |
| 航点任务 | MISSION_ITEM_INT | 上传航点任务 |
实现方式上有两个成熟方案:一个是用 pymavlink 库直接写 Python 脚本,适合快速验证逻辑;另一个是用 MAVSDK(官方提供的跨语言库),API 设计更友好。我自己习惯先用 pymavlink 写一个 demo 跑通链路,然后再迁移到 ROS 里做正式开发。
给你一个最简的 Python 示例,让飞控解锁并在本地坐标系下飞到指定位置:
from pymavlink import mavutil # 连接树莓派串口,波特率与飞控设置保持一致 master = mavutil.mavlink_connection('/dev/serial0', baud=57600) master.wait_heartbeat() print("飞控已连接") # 切换到 GUIDED 模式(自动控制必须在这个模式下) mode = 'GUIDED' mode_id = master.mode_mapping()[mode] master.set_mode(mode_id) master.arducopter_arm() master.motors_armed_wait() print("电机已解锁") # 发送本地坐标系下的目标位置 master.mav.set_position_target_local_ned_send( 0, 0, 0, mavutil.mavlink.MAV_FRAME_LOCAL_NED, 0b0000111111111000, # 只使用位置分量 5, 0, -3, # x=5米, y=0米, z=-3米(注意NED坐标系z轴向下) 0, 0, 0, # 速度 0, 0, 0, # 加速度 0, 0 # yaw相关 ) print("目标位置已发送")写这行代码很容易,但里面有几个坑值得说一下:
- NED 坐标系里 z 轴是向下的,所以"往高处飞"要传负的 z 值。
- 参数
0b0000111111111000是"类型掩码",它告诉飞控"这次只使用位置分量,速度加速度都不用"。掩码写错,飞控可能根本不会执行你的指令,或者行为完全不是你想的那样。 - 如果电机解锁失败,检查一下飞控是否处于"未校准"或者"报错"状态,以及地面上是否有足够的安全检测(很多飞控默认检测到震动异常会拒绝解锁)。
2.4 外挂方案的边界:什么时候该换路
外挂方案不是万能的。遇到下面几种情况,你就得认真考虑向固件层下沉:
- 你的控制周期必须跑到 1ms 级别,树莓派上的 Linux 调度无法保证这种实时性。
- 你要读取的传感器挂在飞控的总线上(比如 IMU 的数据通过飞控的 SPI 接口访问),树莓派直接在物理上够不到。
- 飞行器对载重和功耗极其敏感,没有余量再背一个协处理器。
我见过一个做农用无人机的团队,想实现"断药自动补喷"功能。最初外挂树莓派做,但每次断药检测到动作执行要经过"传感器 → 飞控 → 树莓派 → 飞控 → 电机"这条链路,累计延迟接近 300ms,喷洒不均匀被农户投诉了好几次。后来他们把断药检测逻辑直接写成 PX4 的一个模块,延迟降到 20ms 以内,问题立刻解决。这就是一个典型的"该下沉了"的案例。
3. 再进一步:PX4 Offboard 模式与 uORB 通信
3.1 Offboard 模式和普通自稳模式的区别
如果你决定往固件方向走,第一步不是直接去改源码,而是先学会用 PX4 的 Offboard 模式。这个模式是一个介于"外挂"和"改固件"之间的桥梁:代码跑在树莓派上,不碰固件;但控制指令通过 MAVLink 进入飞控后,直接由飞控内部的控制器执行,绕过了用户遥控器的输入。
从代码角度看,你要做的和第二章展示的很像,核心区别在于控制链路的等级。在 GUIDED 模式下,飞控按"是否收到心跳"来运行;在 Offboard 模式下,飞控每隔一段时间非常严格地"盯着"外部指令流,一旦超过一定时间(默认是 500ms)没有收到新的目标指令,它会认为通信中断,立刻触发失控保护。
3.2 uORB:PX4 内部的消息总线
如果你继续走下去,迟早会碰到 PX4 的 uORB。可以把 uORB 理解成 PX4 内部的"消息总线",就像电脑主板上的总线一样,把各个模块连接起来。传感器数据、姿态估计、控制指令,全部以"主题"(topic)的形式在 uORB 上发布和订阅。
举几个常见的 uORB 主题:
| 主题名 | 数据类型 | 内容 |
|---|---|---|
sensor_combined | sensor_combined_s | 融合后的 IMU 数据 |
vehicle_attitude | vehicle_attitude_s | 当前姿态四元数 |
vehicle_local_position | vehicle_local_position_s | 本地坐标系位置 |
vehicle_command | vehicle_command_s | 指令(解锁、模式切换等) |
actuator_controls | actuator_controls_s | 归一化的作动器控制量 |
这套机制的精妙之处在于模块解耦。比如你写一个新的避障模块,不需要知道避障信息最终怎么变成电机转速的,你只需要把"前方有障碍物"发布到 uORB 上,任何订阅这个主题的模块(比如任务控制器)都能收到。这就像在微信群里发一条消息,你不用管谁在看,谁需要谁就会响应。
3.3 一个能跑的 offboard 控制例子
这里给你一个最简的 offboard 控制脚本(完整版可以从 MAVSDK 官方示例里找到)。核心是用 MAVSDK 的offboard插件,定期推送位置指令:
import asyncio from mavsdk import System from mavsdk.offboard import PositionNedYaw async def run(): drone = System() # 连接树莓派串口 await drone.connect(system_address="serial:///dev/serial0:57600") print("等待飞控连接...") async for state in drone.core.connection_state(): if state.is_connected: print("已连接") break # 起飞并切换到 offboard 模式 await drone.offboard.set_position_ned(PositionNedYaw(0.0, 0.0, -2.0, 0.0)) await drone.offboard.start() print("Offboard 模式已启动") # 循环发送目标位置,防止超时触发失控保护 for i in range(200): await drone.offboard.set_position_ned( PositionNedYaw(5.0, 0.0, -2.0, 0.0) ) await asyncio.sleep(0.2) asyncio.run(run())注意代码里那个 for 循环不是随便写的。Offboard 模式有"指令超时"机制,飞控如果 500ms 收不到新指令,会认为外部的"大脑"崩溃了,自动退出 Offboard 并切换到安全模式。所以真实项目中,最好是开一个独立的线程,以 10Hz 以上的频率持续推送指令,而不是发完一次就完事。这是所有 offboard 开发里最常踩、也最容易在测试中炸机的坑。
4. 固件层开发:ArduPilot 和 PX4 的两种扩展思路
4.1 ArduPilot:参数、库函数与 Lua 脚本
ArduPilot 和 PX4 体系的设计哲学有明显差异。ArduPilot 更像是"一套完整的自动驾驶仪配置 + 库函数的集合",它的二次开发方式有两条温和的路径。
第一条是极轻量的"参数级开发"。ArduPilot 有着极其丰富的参数系统,很多功能可以通过在 Mission Planner 地面站里配置参数来实现,比如设置 PID、调整混控器输出、配置 RC 通道功能等。一个有趣的现象是,很多团队花了几个月做"二次开发",最后发现只需要修改几个参数的默认值就能满足需求。
第二条是Lua 脚本。ArduPilot 从 4.1 版本开始在部分板卡上支持 Lua 脚本(尤其是 F405/F427 这类资源较少的飞控,新版本对 Lua 的支持也越来越好)。Lua 脚本可以在不编译固件的情况下,运行在飞控之上,操作部分 APM 内部的数据和参数。比如下面这个极简示例,读取当前空速并据此改变一个辅助通道的输出:
-- 每 200ms 运行一次 local PERIOD_MS = 200 function update() -- 读取当前空速(单位:m/s) local airspeed = ahrs:airspeed_estimate() if airspeed then -- 按比例映射到 PWM 输出,这里仅示意 local pwm = 1000 + math.min(math.max(airspeed*5, 0), 1000) SRV_Channels:set_output_pwm(8, pwm) end return update, PERIOD_MS end return update, PERIOD_MSLua 方案的好处是:脚本文件可以放 SD 卡里,飞控上电后自动加载,想改逻辑直接把 SD 卡拔下来编辑再插回去就行,不用重新烧固件。缺点是执行效率和实时性不如 C++,复杂任务处理不了。
4.2 PX4:从模块到飞行栈
PX4 的思路更接近"一个模块化的实时操作系统"。它本身基于 NuttX RTOS,各个功能(传感器驱动、姿态估计、位置估计、任务控制、混控器等)都是独立的任务,通过 uORB 通信。
PX4 的二次开发通常分为两种:
- 写一个全新的独立模块。这个模块运行在 PX4 内部,订阅 uORB 消息,处理业务逻辑,再发布新的 uORB 消息。这种方式不需要动飞行控制算法的核心代码,只在你自己的模块里做文章。
- 深度修改飞行控制算法。比如你想改 EKF 滤波器、改姿态控制器,这就不是"加模块"那么简单了,你需要深入理解整个飞行栈的内部逻辑。这块工作的难度会突然跳升一个数量级,因为姿态控制、位置控制的耦合关系极其复杂,改一处可能影响全局稳定性。
4.3 搞固件层最容易被坑的三个地方
在固件层动手之前,把下面三个坑提前告诉你,能帮你省不少时间:
- 版本地狱。PX4 和 ArduPilot 都在快速迭代,你写的模块在某个版本上能编译,换个版本可能接口就变了。我在 2023 年写的一个 PX4 v1.13 模块,到 v1.14 时就需要改不少 API 调用。建议把项目用的固件版本固定下来,不要追新。
- 板卡资源差异。同样是 F405,不同飞控板的 Flash 和 RAM 差异很大。你写的模块如果在 QGroundControl 里能编译,烧到板子上却运行不起来,先去看看内存够不够。以前遇到过编译通过、上电直接跑飞的情况,最后发现是任务栈分配太小导致溢出。
- 烧录风险。刷固件操作本身有变砖风险,尤其在一些 Clone 版飞控上,bootloader 可能不兼容。所以固件层开发一开始,一定要准备一块便宜的备用飞控,并且养成熟练的"拿回原厂固件恢复"的操作习惯。
5. 从零写一个自定义模块的完整路径
5.1 模块骨架:一个最小可编译的模块
下面我用 PX4 为例,带你走一遍"自定义模块"的完整过程。新建一个模块其实是在给 PX4 加一个"能编译、能运行、能订阅消息"的新任务。
在src/examples下建一个目录px4_custom_module,里面有下面两个文件。先看CMakeLists.txt:
px4_add_module( MODULE modules__px4_custom_module MAIN px4_custom_module_main STACK_MAIN 2048 COMPILE_FLAGS SRCS px4_custom_module.c DEPENDS platform__uorb )再看核心 C 文件px4_custom_module.c的主体结构:
#include <px4_platform_common/px4_config.h> #include <px4_platform_common/tasks.h> #include <uORB/uORB.h> #include <uORB/topics/vehicle_attitude.h> static void custom_module_main(int argc, char *argv[]) { // 订阅姿态主题 int att_sub = orb_subscribe(ORB_ID(vehicle_attitude)); while (!should_exit()) { struct vehicle_attitude_s att; orb_copy(ORB_ID(vehicle_attitude), att_sub, &att); // 打印姿态四元数 PX4_INFO("att quaternion: [%.3f, %.3f, %.3f, %.3f]", (double)att.q[0], (double)att.q[1], (double)att.q[2], (double)att.q[3]); px4_usleep(200000); // 5Hz } orb_unsubscribe(att_sub); } int px4_custom_module_main(int argc, char *argv[]) { // 启动任务 return px4_task_spawn_cmd("custom_module", SCHED_DEFAULT, SCHED_PRIORITY_DEFAULT, 2048, custom_module_main, argv); }这段代码的逻辑是:模块启动一个任务,订阅飞控的姿态消息,每 200ms 打印一次姿态。所有 PX4 模块基本长这个样子——订阅输入、处理数据、发布输出,只是业务逻辑各有不同。
5.2 消息订阅与发布:真正干活的代码
仅仅打印姿态不算"干活",真正的二次开发往往要"消费数据"并"产出新数据"。PX4 的 uORB 发布机制也非常直接。假设你的模块要发布一个自定义的消息,先在msg/目录下定义你的消息类型:
# custom_data.msg uint64 timestamp # 时间戳 float32 target_x # 目标位置 x float32 target_y # 目标位置 y float32 target_z # 目标位置 z然后重新编译,PX4 会自动生成对应的 C 语言结构体和 ORB_ID。在代码里发布消息只要这样:
#include <uORB/topics/custom_data.h> struct custom_data_s data; data.timestamp = hrt_absolute_time(); data.target_x = 1.0f; data.target_y = 2.0f; data.target_z = -3.0f; // 第一次发布需要先 advertise static struct custom_data_s custom_data; static orb_advert_t data_pub = orb_advertise(ORB_ID(custom_data), &custom_data); // 后续用 orb_publish 更新 orb_publish(ORB_ID(custom_data), data_pub, &data);你发布出去的custom_data主题,任何其他模块(包括地面站,QGroundControl 也可以通过 MAVLink 透传看到)都可以订阅。这就是 PX4 模块扩展的套路:它不需要你改动飞控原本的核心,只需要你在旁边挂一个新的功能块,和一个新的"群聊频道"。
5.3 编译、烧录和仿真验证
写完代码后,下一步是编译。强烈建议先使用 PX4 自带的 SITL(软件在环仿真)来验证模块逻辑,再烧到真机。
SITL 的启动流程(在 Ubuntu 环境下)大概是:
cd PX4-Autopilot make px4_sitl gazebo启动之后,你的自定义模块会自动加载(前提是你把它加进了编译列表),然后在 QGroundControl 里可以看到飞控已经运行在仿真模式下。此时可以模拟飞行,看你的模块是否按预期打印数据、发布消息。
SITL 最大的价值是提供了"几乎真实"的运行环境,你可以在这里验证消息订阅、发布、任务调度等所有逻辑。但注意,真机上最常见的硬件故障(比如总线冲突、引脚复用错误)SITL 是模拟不出来的,所以第一次上真机前要格外谨慎。
真机烧录有两种方式:一种是用 QGroundControl 直接刷写生成的.px4固件;另一种是用命令行工具。无论哪一种,刷完后一定要在地面站上重新执行传感器校准,因为固件变化可能导致传感器零漂等参数失效。我自己第一次刷完没校准就起飞,结果飞机在地面疯狂晃,万幸是系留测试,没出事。
6. 路径选择建议:先做减法,再做加法
站在今天的视角回头看,最能帮你省时间的其实是"选路"这件事——不是技术难度,而是你对自己需求的判断。
如果你只是想让现有无人机加点功能——比如给一台成品 FPV 加个简单的定高、加一个自定义的灯效逻辑、或者做一个简单的航点任务——先用地面站参数配置解决,解决不了再看外挂方案。
如果你要做视觉识别、目标跟踪、自主航线这类"有智能"的功能——几乎不用犹豫,直接上外挂树莓派或 NVIDIA Jetson 方案。这类功能的核心在算法,不在飞控。把算法跑在 Linux 环境里,开发效率高一个量级。
如果你要做的功能必须在飞控内部完成——比如要读取 IMU 原始数据做新算法、要基于飞机的实时状态做特殊控制——再考虑 PX4 模块或 ArduPilot 的 Lua。不过就算是这样,也可以先用 SITL 仿真把算法逻辑跑通,再下到固件层,不要一上来就面对真实飞控的复杂环境。
如果你是初学者、刚开始接触飞控开发——请一定先做一遍"外挂树莓派 + MAVLink"的例子。这个过程会让你理解飞控是怎么对外通信的、MAVLink 消息长什么样、解锁和起飞到底需要哪些条件。这些知识后面无论走哪条路都用得上,而且是很多啃源码的老手可能都讲不清楚的"暗知识"。
最后分享一个我项目里养成的习惯:每接到一个新需求,先画一张简单的数据流图——数据从哪里来(传感器/遥控器/外部指令),经过什么处理,最终到哪里去(电机/地面站/外部设备),然后看这张图经过了飞控的哪个环节。如果飞控只是数据通路,就走外挂;如果飞控本身就是数据源或执行器,才考虑固件层。用这个方法,我后来几乎没再做过无用功。希望这篇文章也能帮你少走那些我已经走过的弯路。