
涉足过 PX4 的朋友们大多有类似经历看了一堆 MAVLink 协议介绍觉得 Offboard 模式不过就是“上位机发指令飞控照做”可真到试飞那一天飞机要么死活不肯解锁要么刚切到 Offboard 就一头往下扎要么飞到一半莫名退出模式自己开始降落。这篇文章把我用 MAVLink 驱动 PX4 无人机跑 Offboard 模式的完整过程整理出来会讲到 Offboard 到底怎么工作、开发环境怎么选、如何用 Python 写第一个能起飞的例子以及几个几乎每个人都会踩的坑和相应解决办法尽量让第一次接触的人也能少走弯路。1. Offboard 模式不是“自动驾驶”而是“让飞控听你的”1.1 从飞行模式输入来源说起PX4 的飞行模式看起来很多但本质上是在做同一件事决定“期望值从哪来”。手动模式里期望值来自遥控器摇杆定高模式里期望值来自姿态/油门通道Auto 模式里期望值来自机载任务脚本而 Offboard 模式里期望值完全来自外部设备——可以是一台跑着 Python 脚本的机载电脑也可以是地面站甚至可以是一架另一台无人机。很多刚接触的人会把 Offboard 理解成“用代码接管飞控的全部控制权”这个说法有一半是对的但很容易误导。更准确的说法是飞控的估计、姿态控制、位置控制、电机混控这些内部逻辑全部照常工作唯一改变的是“期望值”这个输入信号换成了外部给的 MAVLink 消息。打个比方这就像一辆车的发动机、刹车、转向助力都还在但握方向盘的人从司机换成了副驾驶。正因为自己开车的人和副驾驶之间的信息传递很容易出问题PX4 对 Offboard 模式的输入信号做了非常苛刻的约束。它要求外部设备持续、稳定、以固定频率发送设定值否则就认为“副驾驶失联了”然后按失效保护逻辑执行动作。这个安全机制保护了飞机也坑了无数没搞清楚状态机的开发者。1.2 PX4 为什么对 Offboard 设定值要求这么严格PX4 的 Offboard 切换流程并不是“收到一条指令就切过去”这么简单它内部有一个完整的状态检查外部程序连接后必须先持续发送设定值持续一段时间一般要求 1 秒以上飞控才会认为这条链路是可靠的收到用户或地面站的模式切换指令后飞控会再次检查当前是否有有效设定值在持续到达确认无误后才会接受 Offboard 模式切换成功后飞控持续监控设定值到达频率一旦断流超过阈值立刻退出 Offboard退出后按失效保护策略执行默认可能是降落或保持位置。这里的核心逻辑是飞控的定位是“安全底线”外部程序随时可能死机、断线、发错数据飞控不能无限信任一个突然消失的输入源。所以你在写代码时需要把“持续发送设定值”这件事当成一个独立的、有优先级的线程来做而不是在主循环里顺手发一条。理解了这个状态机你就能解释很多诡异现象。比如飞机切到 Offboard 后 3 秒才掉回降落往往是程序里的设定值发送循环被耗时操作阻塞了又比如刚切过去就掉回来大概率是切换前没有持续发送设定值飞控的第一条检查就没通过。1.3 MAVLink 消息里真正关键的 3 条MAVLink 是个庞大的协议族但做 Offboard 控制你真正绕不开的就三条消息消息作用关键字段HEARTBEAT建立连接、判断在线状态base_mode、custom_modeSET_POSITION_TARGET_LOCAL_NED发送位置/速度/加速度设定值type_mask、x/y/z、vx/vy/vzCOMMAND_LONG切换模式、解锁、解锁、起飞等command、param1~param7其中 SET_POSITION_TARGET_LOCAL_NED 里最容易搞错的就是 type_mask。这个掩码用二进制位表示哪些字段有效、哪些字段被忽略1 代表“忽略这个字段”0 代表“使用这个字段”。很多人第一次看代码时一头雾水后面我会在代码拆解里详细说明。2. 开发环境选型pymavlink、MAVSDK 还是 DroneKit2.1 三条路线怎么选写 Offboard 程序网上能搜到三套主流方案pymavlink、MAVSDK-Python、DroneKit-Python。它们的区别不只是语法而是抽象层级和适用场景。方案上手难度灵活性维护状态适用场景pymavlink中等高活跃想理解 MAVLink 底层、需要深度调试MAVSDK-Python低中活跃快速开发标准无人机组网应用DroneKit-Python低低基本停滞ArduPilot 用户或历史遗留项目我的建议很直接如果你想从原理上搞懂 Offboard别绕过 pymavlink。它是直接对着 MAVLink 字节流编程每一条消息你都知道它长什么样、字段是什么、发出去以后飞控会怎么处理。等你把 pymavlink 玩熟了再去看 MAVSDK 反而会觉得它做得很好因为你能猜到它底层帮你封装了什么。如果你一上来就用 MAVSDK 这种高层库代码会短很多但遇到奇怪的问题时你没法直接看协议层发生了什么只能靠经验猜。这篇博文后面的代码示例都基于 pymavlink原因就在这里。2.2 连接飞控的硬件与串口准备连接方式取决于你是真机调试还是仿真开发。仿真场景下PX4 SITL 启动后会在本机打开 UDP 端口最常用的连接方式是pip install pymavlink mavproxy.py --master/dev/ttyACM0 --baudrate115200如果是 PX4 SITL Gazebo 仿真环境启动仿真是make px4_sitl gazebo地面站默认连 14550 端口。我们的 Python 脚本可以作为地面站连接它mavutil.mavlink_connection(udpin:0.0.0.0:14550)真机场景下常见链路有三种USB 直连飞控通常设备节点是/dev/ttyACM0波特率 115200适合快速配置和短距离调试数传模块设备节点可能是/dev/ttyUSB0波特率 57600适合地面站远程调试树莓派等机载电脑串口直连飞控 TELEM 口适合真正自主飞行场景。很多新手的第一个坑就出在这串口节点找到了但飞控上没使能对应的 MAVLink 通道。PX4 里串口和 MAVLink 通道是绑定的比如 Pixhawk 的 TELEM1 默认对应MAV_0_CONFIGTELEM2 对应MAV_1_CONFIG。如果你把机载电脑接在 TELEM2 上固件里却没配置飞控根本不会往这个口发数据。打开 QGC 的参数界面搜MAV_就能找到对应配置。2.3 飞行前参数别让飞控在第一步就把你拦下来在跑代码之前有几个参数我建议你先在 QGC 里确认一遍。不同 PX4 版本参数名可能不同但搜索关键字基本都能找到COM_RC_IN_MODE决定 Offboard 模式下是否允许无遥控器操作。如果你打算纯地面站或者机载电脑控制需要按实际需要调整这个参数否则解锁会被拒绝COM_OBL_ACT或NAV_OFFBOARD_LOSS_ACTOffboard 失去信号时的动作。早期 PX4 版本里叫NAV_OFFBOARD_LOSS_ACT后来统一成了COM_OBL_ACT可以设成 Hold、Land、Terminate 等。开发阶段我建议设成 Hold这样断线时飞机不会突然往下掉而是先原地悬停给你排查问题的机会NAV_RCL_ACT遥控器丢失时的失效保护动作。如果你在 Offboard 模式里还需要遥控器做后备这个参数很关键。参数这个东西真机上每一种都不建议照抄别人的因为你的飞机重量、动力、任务场景都不同。唯一能照抄的是排查思路先在仿真里调好再上真机验证。3. 手写第一个 Offboard 控制程序起飞、悬停、降落的完整代码拆解3.1 启动流程设计我写的第一个 Offboard 程序目标就是让无人机起飞到指定高度然后悬停最后安全降落。整个流程分五步等待飞控心跳确认 MAVLink 链路建立提前发出目标位置的设定值持续 3 秒让飞控通过模式切换前的设定值检查发送 COMMAND_LONG 指令切换到 Offboard 模式发送解锁指令保持按 10Hz 频率发送目标位置让无人机悬停。这里有个非常关键的细节先连续发送设定值再切换模式顺序绝不能反。很多网上的示例代码是反的在仿真里偶尔能跑通是因为时序刚好够但到了真机上飞控的检查更严格立刻就会出问题。3.2 完整代码示例下面这段代码就是完整的可运行脚本我用 Python 3.8 pymavlink 测试过。注释里标注了最容易理解错的地方。#!/usr/bin/env python3 PX4 Offboard control demo with MAVLink (pymavlink) 使用本地NED坐标系起飞并悬停在起飞点上空2米处。 import time from pymavlink import mavutil # 连接参数 # 仿真用: udpin:0.0.0.0:14550 # 真机USB: /dev/ttyACM0, baud115200 CONNECTION udpin:0.0.0.0:14550 CONNECTION_BAUD 115200 # 目标位置NED坐标系Z轴向下-2代表离起飞点2米高处 TARGET_X 0.0 TARGET_Y 0.0 TARGET_Z -2.0 def get_mavlink_connection(): mav mavutil.mavlink_connection(CONNECTION, baudCONNECTION_BAUD) mav.wait_heartbeat() print(heartbeat received (sys %u / comp %u) % (mav.target_system, mav.target_component)) return mav def send_position_target(mav, x, y, z): 发送本地NED坐标下的位置设定值。 type_mask 0b0000111111111000 表示位置字段有效 速度、加速度、偏航字段全部被忽略。 mav.mav.set_position_target_local_ned_send( 0, mav.target_system, mav.target_component, mavutil.mavlink.MAV_FRAME_LOCAL_NED, 0b0000111111111000, x, y, z, 0, 0, 0, # vx, vy, vz 0, 0, 0, # afx, afy, afz 0, 0 # yaw, yaw_rate ) def set_mode_offboard(mav): 切换到PX4的Offboard模式。 mav.mav.command_long_send( mav.target_system, mav.target_component, mavutil.mavlink.MAV_CMD_DO_SET_MODE, 0, mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, 6, # PX4 custom main mode: OFFBOARD 6 0, 0, 0, 0, 0 ) def arm(mav): 解锁电机。 mav.mav.command_long_send( mav.target_system, mav.target_component, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, 0, 1, # 1表示解锁 0, 0, 0, 0, 0, 0 ) def disarm(mav): 锁定电机。 mav.mav.command_long_send( mav.target_system, mav.target_component, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, 0, 0, # 0表示锁定 0, 0, 0, 0, 0, 0 ) def main(): mav get_mavlink_connection() # 第一步先连续发送位置设定值持续约3秒 print(send initial position setpoints...) for _ in range(30): send_position_target(mav, TARGET_X, TARGET_Y, TARGET_Z) time.sleep(0.1) # 第二步切换到 Offboard 模式 print(switch to OFFBOARD...) set_mode_offboard(mav) time.sleep(1) # 第三步解锁 print(arm...) arm(mav) time.sleep(1) # 第四步保持10Hz发送位置设定值维持悬停 print(hold position at NED(%.1f, %.1f, %.1f) % (TARGET_X, TARGET_Y, TARGET_Z)) try: while True: send_position_target(mav, TARGET_X, TARGET_Y, TARGET_Z) time.sleep(0.1) except KeyboardInterrupt: print(interrupted, start landing...) # 第五步把目标高度降到0米让无人机下落 for _ in range(100): send_position_target(mav, TARGET_X, TARGET_Y, 0.0) time.sleep(0.1) disarm(mav) if __name__ __main__: main()3.3 代码里的细节解读先看type_mask 0b0000111111111000这个掩码。从二进制位来看从最低位开始第 0 到第 2 位对应 X、Y、Z 位置字段这一部分设为 0代表位置字段有效第 3 位到第 11 位对应速度、加速度、偏航和偏航率全部设为 1代表这些字段被忽略。换句话说这个掩码的意思是“我只告诉飞控我要飞到哪个点速度、加速度、机头朝向我自己不管”。如果你希望同时指定机头朝向就需要把掩码里 yaw 对应的位改成 0也就是不忽略偏航字段。很多人第一次写完代码发现飞机始终朝着一个固定方向就是掩码设得不对。这个问题一定要先做成参数化方便后面调试。再看为什么先循环发 30 次设定值再切模式。PX4 的 Commander 模块在收到 Offboard 切换指令时会检查设定值是否已经在稳定发送一般是要求至少 1 秒以上。我在代码里用了 30 次 × 0.1 秒 3 秒留了足够的余量。你甚至可以发 5 秒没坏处只是起飞会慢一点。解锁放在切换模式之后也是刻意的。有些固件版本里如果在其他模式下解锁再切 Offboard会因为模式切换过程中的状态抖动导致解锁失效。先切模式再解锁是最稳的流程。如果飞控配置了硬件安全开关你还需要先物理触发安全开关否则飞控会拒绝解锁。实操提醒不要用异常退出后直接 disarm 的方式降落。代码里except KeyboardInterrupt后把目标高度设为 0 米再发送 10 秒这是模拟降落动作。真实生产中应该使用MAV_CMD_NAV_LAND指令或者切换回具备高度反馈的模式才够安全。4. 实测中的大坑为什么你的飞机会突然退出 Offboard 模式4.1 设定值断流最容易被忽略的“隐形杀手”我第一次真机测试就遇到了一个诡异现象飞机切到 Offboard 后正常悬停了大约 20 秒然后毫无征兆地自己开始降落。日志里没有任何报错QGC 显示模式从 Offboard 变成了 Land。排查到最后原因其实很简单我的主循环里除了发设定值还跑了一段图像识别代码处理一帧图像要 0.5 秒左右导致设定值发送间隔被拉到了 500 毫秒以上。PX4 对 Offboard 设定值的最低要求是 2Hz但实际上为了控制稳定最好按 10Hz 到 30Hz 发送。一旦低于某个阈值飞控就认为外部程序失联启动失效保护。这个问题的解决办法也很明确把设定值发送放到一个独立的线程里用固定频率循环发送主线程去干规划、图像处理等耗时操作。如果代码里实在无法保证频率至少要在程序里记录最后一次成功发送设定值的时间一旦超过 500 毫秒就停止任务并报警。4.2 失效保护参数设置导致的“秒降”还有个更隐蔽的坑是我一个朋友踩过的。他刚写完 Offboard 程序测试时只要一断点调试飞机就立刻往下掉他一度以为是代码里有什么 bug 把目标高度清零了。后来查日志才发现他用的 PX4 固件里COM_OBL_ACT设成了 Land。也就是说当他暂停脚本、设定值断流的一瞬间飞控判定 Offboard 失联执行的失效保护就是降落。这个是默认配置在开发阶段很危险因为你经常需要停下来看数据脚本一停飞机就掉。我现在的习惯是开发调试阶段把COM_OBL_ACT设成 Hold也就是失联后原地悬停这样即便脚本断了飞机也不会乱跑留出足够时间重新连接。等代码完全稳定再根据实际任务需要改成 Land 或 Terminate。这里要特意提醒一句Terminate 选项意味着飞控直接切断电机输出除非你确定这是你要的否则不要碰。4.3 坐标系和掩码飞机为什么朝反方向飞我见过最典型的一个“朝反方向飞”案例是坐标系搞错了。PX4 默认使用的是 NED北东地坐标系X 轴朝北、Y 轴朝东、Z 轴朝下。所以在发位置设定值时目标高度必须是负数比如z -2是飞到起飞点上方 2 米。有人习惯写正数结果就是无人机起飞后一个扎猛子往地里钻特别吓人。另一个容易混淆的是MAV_FRAME_LOCAL_NED和MAV_FRAME_LOCAL_OFFSET_NED。前者是相对起飞点的绝对坐标比如(0, 0, -2)表示起飞点正上方 2 米后者是相对当前位置的偏移量每一次发送都是“在当前坐标基础上再移动”。如果你本来想用绝对坐标定位却用了 OFFSET 坐标系飞机就会每次收到设定值都往同一个方向偏移一点最终越飞越偏。排查这类问题我的方法是先在 SITL 仿真里飞一次把目标点设得远一点比如 20 米看飞机的运动方向是否和预期一致。如果方向反了优先检查 X/Y/Z 符号和坐标系而不是检查控制增益。4.4 解锁被拒、模式切不过去善用 ACK 和日志还有一种常见情况代码流程看着没问题但飞控就是不给解锁或者 Offboard 模式切不过去。这时候别急着改代码先看COMMAND_ACK。pymavlink 发送的 COMMAND_LONG 指令飞控处理完后会回一条 COMMAND_ACK 消息里面带一个 result 字段。我们可以用一段简单的代码把它打出来ack mav.recv_match(typeCOMMAND_ACK, blockingTrue, timeout3) if ack: print(command ack result:, ack.result)result 为 0 表示接受非零则对应不同的问题类型。比如模式切换被拒绝常见原因有 GPS 定位没准备好、安全开关未触发、当前条件不满足 Offboard 切换要求等。把 ACK 打出来以后基本上能定位到问题方向不用瞎猜。另外还要提醒一句在真机上调试时最好在 QGC 的 MAVLink Inspector 窗口里同时观察消息流。脚本发送的每条消息、飞控回复的每条消息都能看到尤其是某些中间态异常肉眼扫一眼比看日志更快。4.5 真机特有的几个坑除了协议和参数层面的问题真机调试还有几个很现实的小坑USB 线质量差会导致调试中断连一断设定值就断飞控立刻退出 Offboard。调试阶段建议用短一点、带磁环的 USB 线电池电压低时PX4 可能拒绝解锁或提前降级控制性能。我习惯在起飞前看 QGC 的电压提示电压低于 3.7V/节就换电池桨叶装反、电机转向不对这类机械问题会让飞机在地面翻滚。这不是 Offboard 代码的问题但很多新手会先怀疑程序排查半天才发现是机械问题。真机调试前我强烈建议在模拟器里把整套程序跑通然后把电机桨叶转到锁定状态在地面先测试解锁、油门响应再做小范围悬停测试最后才考虑自主飞行。5. 进阶玩法与飞行前检查清单从位置控制到机体坐标系速度控制5.1 从位置控制切换到速度控制当你把位置控制跑通之后Offboard 真正的价值才开始体现。位置控制适合“飞到一个点”但很多实际任务需要的是“以某个速度运动”比如视觉跟踪、避障、动态目标跟随。这种场景下你需要发送速度设定值。速度控制的代码和位置控制很相似唯一的关键点是改变type_mask让它只保留速度字段def send_velocity_target(mav, vx, vy, vz): 发送本地NED坐标系下的速度设定值。 type_mask 0b0000111111000111 位置字段忽略速度字段有效。 mav.mav.set_position_target_local_ned_send( 0, mav.target_system, mav.target_component, mavutil.mavlink.MAV_FRAME_LOCAL_NED, 0b0000111111000111, 0, 0, 0, vx, vy, vz, 0, 0, 0, 0, 0 )注意几个差异点。第一速度控制里位置字段被忽略所以 x/y/z 传 0 也没关系第二速度值同样是 NED 坐标系vz 正值是下降负值是上升和位置控制里“z 为负代表高”是同一个逻辑第三速度控制下飞控会尝试保持这个速度但会不会漂移取决于外部有没有闭环。如果你只是在开环发速度飞机很可能因为风、姿态误差逐渐偏离目标位置这在真机测试时一定要心里有数。5.2 姿态控制比位置控制更底层的玩法再往下就是姿态控制用的消息是SET_ATTITUDE_TARGET。这个模式下你可以通过四元数直接指定期望姿态角还能控制一个 0 到 1 之间归一化的油门值。相比位置控制姿态控制更底层、更激进通常用于特技飞行、倒飞、需要做俯仰翻滚机动的场景。这里最容易犯的错是把 thrust 当成油门百分比直接填了一个很大的值飞机起飞后瞬间冲到高空。实际它只是一个归一化的推力系数具体对应多少转速跟气动、动力系统、混控都有关系需要一点点调。我的建议是除非你有明确需求否则不要一上来就写姿态控制。先把位置控制和速度控制跑熟理解飞控的响应特性再考虑姿态层面的操作。姿态模式下的一点失误放大到物理世界就是炸机这个代价相比学习成本来说太大。5.3 我的飞行前检查清单最后分享一份我现在每次做 Offboard 飞行前都会过的检查清单很多时候排错不如预防先在 PX4 SITL 仿真里完整跑一遍程序确认设定值发送频率、模式切换、失效保护行为都符合预期起飞前看 GPS 星数或 RTK 状态定位精度不够不要起飞确认COM_OBL_ACT等失效保护参数的值是当前任务可接受的开发阶段用 Hold如果有遥控器确认某一通道能随时切回手动模式作为最后一道保险检查脚本里有没有设置发送看门狗或超时保护保证任何意外都能让飞控进入安全状态真机通电后先做解锁测试确认电机响应方向正确再装桨最后再扫一眼代码里的坐标系和 type_mask别让低级错误浪费一次危险的试飞。说实话第一次真正把无人机切到 Offboard 成功飞起来那一瞬间成就感很强但整个过程里的安全感不是代码给的而是对失效保护机制的理解给的。建议你在每换一块飞控板、每升一次固件之后都重新走一遍上文提到的参数确认流程。因为 PX4 不同版本之间参数名的变化真的不小——COM_OBL_ACT在旧版本里可能根本不叫这个名字你搜不到的时候不要慌先记住它的作用再去参数列表里找对应的新名字。能把这个过程跑顺Offboard 模式就算真正入门了。