关于 ROS2:本教程的仿真链路(Gazebo ↔ SITL ↔ Controller)通过 UDP 直连,运行时完全不需要 ROS2。ROS2 仅在编译 Gazebo 插件时用到(因为上游 vehicle_gateway 项目使用 colcon 构建系统),插件编译完成后不再依赖 ROS2。
教程较为复杂,主要是讲解其中的框架。完整代码和配置文件欢迎在 GitHub 仓库 ↗ 上找到,欢迎提交 commit 改进。如果喜欢,麻烦 star 一下支持作者。
简介#
Betaflight 是目前无人机竞速(FPV Racing)和自由飞(Freestyle)领域最流行的开源飞控固件,广泛运行在 STM32/AT32 等 MCU 上。Betaflight 提供 SITL(Software-In-The-Loop,软件在环)模式,允许开发者在 Gazebo 物理仿真引擎中运行完整的 Betaflight 飞控固件。
SITL 的核心思路是:将 Betaflight 固件编译为 x86_64 Linux 可执行文件,飞控的传感器输入(IMU、气压计、GPS)来自 Gazebo 仿真环境通过 UDP 发送的仿真数据,飞控的电机输出(PWM 指令)同样通过 UDP 发回 Gazebo 驱动物理引擎中的螺旋桨。这样开发者可以在 PC 上完整验证飞控逻辑和控制算法,无需真实硬件。
本文提供一个在 Ubuntu 22.04 上搭建 Betaflight SITL + Gazebo Harmonic + Python 自主控制仿真环境的完整流程。在开始之前,感谢 OSRF vehicle_gateway 项目和 Betaflight 社区提供的开源代码和文档,本教程大量参考了这些资料。
前置环境#
按照笔者之前的 ROS2+PX4 仿真教程 完成了以下基础环境的配置:
- Ubuntu 22.04 系统
- ROS2 Humble(推荐使用鱼香ROS一键安装:
wget http://fishros.com/install -O fishros && . fishros) - Gazebo Harmonic(gz-sim8,版本 8.9.0)
- ROS2-Gazebo 通信桥
ros-humble-ros-gzharmonic
在终端中确认环境正确:
ros2 --version # 输出应包含 "humble"
gz sim --versions # 输出应为 "Gazebo Sim 8.9.0"
dpkg -l | grep ros-humble-ros-gzharmonic # 应显示已安装bash克隆本教程配套仓库:
cd ~
git clone https://github.com/Jinyao-Chen/bf_sitl.git ~/bf_sitlbash仓库目录结构:
~/bf_sitl/
├── autonomy/
│ ├── msp_reader.py # MSP 遥测读取器
│ ├── msp_closed_loop_controller.py # 闭环定高控制器
│ └── autonomous_controller.py # 开环起飞控制器
├── worlds/
│ └── betaflight_world.sdf # Gazebo 世界文件
├── config/
│ └── bf_cli_config.py # 首次配置脚本
└── betaflight_gazebo_patches/ # 插件补丁(供编译参考)plaintext安装必要工具和 Gazebo 开发包(编译插件必需,运行时 gz-harmonic 不包含这些头文件):
pip3 install --user websockify
sudo apt-get install -y socat
# Gazebo Harmonic 开发包——编译自定义 Gazebo 插件必需!
sudo apt-get install -y \
libgz-sim8-dev \
libgz-transport13-dev \
libgz-msgs10-dev \
libgz-math7-dev \
libgz-plugin2-dev \
libsdformat14-devbashwebsockify:提供 WebSocket → TCP 的代理功能,用于在浏览器中连接 Betaflight 地面站配置工具socat:用于创建虚拟串口(pty),可将 Betaflight 的 TCP UART 映射为本地虚拟串口设备
确保网络可以正常访问 GitHub,否则克隆大型仓库时会中途闪退。
仿真链路架构#
在开始动手配置之前,我们先介绍整个仿真链路,这有助于后续遇到问题时定位原因。仿真链路由四个进程组成,它们通过 UDP 端口互相通信:
端口分配与数据流(Betaflight 源码 sitl.c 第 192-195 行):
| 端口 | 方向 | 用途 | 数据包类型 |
|---|---|---|---|
| 9001 | SITL → 外部 | Raw PWM(备用于 RealFlightBridge) | servo_packet_raw |
| 9002 | SITL → Gazebo | 电机转速指令 [0.0, 1.0](3D 模式为 [-1.0, 1.0]) | servo_packet |
| 9003 | Gazebo → SITL | 飞行状态数据(IMU、位置、速度、姿态、气压) | fdm_packet |
| 9004 | Controller → SITL | 16 通道 RC 遥控信号 [1000-2000] | rc_packet |
| TCP 5761 | 双向 | MSP 协议(配置 + 遥测回传) | N/A |
数据包结构体(Betaflight 源码 target.h 第 224-246 行):
// fdm_packet: Gazebo 插件 → SITL
typedef struct {
double timestamp;
double imu_angular_velocity_rpy[3]; // 角速度 (rad/s)
double imu_linear_acceleration_xyz[3]; // 线加速度 (m/s²)
double imu_orientation_quat[4]; // 姿态四元数 (w,x,y,z)
double velocity_xyz[3]; // 速度 (m/s, ENU 坐标系)
double position_xyz[3]; // 位置
double pressure; // 气压 (Pa)
} fdm_packet;
// servo_packet: SITL → Gazebo 插件
typedef struct {
float motor_speed[4]; // [0.0, 1.0] 或 3D 模式 [-1.0, 1.0]
} servo_packet;
// rc_packet: Controller → SITL
typedef struct {
double timestamp;
uint16_t channels[16]; // PWM [1000, 2000]
} rc_packet;cGazebo 插件(BetaflightPlugin)在每个仿真更新周期中执行两个阶段:
- PreUpdate(仿真步进前):调用
ReceiveServoPacket()从 UDP 9002 读取servo_packet,将电机转速换算为关节力/速度指令 - PostUpdate(仿真步进后):从 IMU 传感器获取角速度和加速度数据,从 Gazebo Entity Component Manager 获取位姿和线速度,填充为
fdm_packet,进行坐标系变换后通过 UDP 发送到 127.0.0.1:9003
Betaflight SITL 源码下载及编译#
cd ~
git clone https://github.com/betaflight/betaflight.git
cd betaflightbash注意:我们使用的不是 4.5.x 版,而是 master 分支(版本号 2026.6.0-alpha),因为 4.5.x 正式版的 SITL 代码缺少 Gazebo 的原生支持(
ENABLE_GAZEBO_BRIDGE特性在 4.5.4 之后才引入 master)。
与编译 PX4 需要 ARM 交叉编译工具链不同,SITL 模式直接使用主机 GCC 编译:
make TARGET=SITL -j$(nproc)bash编译成功标志:
Linking SITL
text data bss dec hex filename
389784 21804 77376 488964 77604 ./obj/main/betaflight_SITL.elfplaintext验证:
./obj/main/betaflight_SITL.elf --help
# 输出: Betaflight SITL Usage: ./obj/main/betaflight_SITL.elf [options]bash为什么 SITL 可以单独运行?因为 Betaflight SITL 用软件模拟了真实飞控的硬件外设:虚拟 IMU(accgyro_virtual.c)接收 Gazebo 的角速度和加速度数据;虚拟气压计(barometer_virtual.c)接收气压/高度数据;虚拟 GPS(gps_virtual.c)接收位置数据;虚拟 EEPROM(文件读写)保存配置为 eeprom.bin;虚拟串口(serial_tcp.c)以 TCP 替代真实 UART。
编译完成后建议备份:zip -r betaflight.zip betaflight/
Gazebo 插件的获取与编译#
Betaflight 本身不包含 Gazebo 插件。我们需要 OSRF 的 vehicle_gateway 项目中的 betaflight_gazebo 插件。需要注意:vehicle_gateway 官方针对 Gazebo Garden(gz-sim7),而我们的环境是 Gazebo Harmonic(gz-sim8),需要进行适配修改。
步骤 1:创建 ROS2 工作空间并克隆插件#
说明:Gazebo 插件
libBetaflightPlugin.so需要通过 colcon(ROS2 的构建工具)编译,因此需要放在一个 ROS2 工作空间中。如果你已有~/ros2_ws,可以直接使用;如果没有,执行以下命令创建:
mkdir -p ~/ros2_ws/srcbash然后克隆 vehicle_gateway 并跳过不需要的子包:
cd ~/ros2_ws/src
git clone https://github.com/osrf/vehicle_gateway.git vehicle_gateway
cd ~/ros2_ws/src/vehicle_gateway
for pkg in gz_aerial_plugins vehicle_gateway vehicle_gateway_betaflight \
vehicle_gateway_demo vehicle_gateway_integration_test vehicle_gateway_multi \
vehicle_gateway_px4 vehicle_gateway_python vehicle_gateway_python_helpers \
vehicle_gateway_sim_performance px4_sim betaflight_configurator \
betaflight_demo qgroundcontrol; do
touch "$pkg/COLCON_IGNORE"
donebash步骤 2:适配 Gazebo Harmonic(需改 2 个文件的版本号,其余 3 项代码修复已在最新上游代码中完成)#
当前 OSRF vehicle_gateway 上游代码已包含了大部分 Gazebo Harmonic 兼容修复。只有 CMakeLists.txt 的库版本号仍需要手动修改。
必须修改 — 适配库版本号
需要改两个文件:CMakeLists.txt 和 package.xml。二者中的版本号必须同步,否则 colcon 依赖解析会失败。
(1)编辑 ~/ros2_ws/src/vehicle_gateway/betaflight_gazebo/CMakeLists.txt
全文搜索替换以下两对版本号。不仅 find_package 行要改,target_link_libraries 中带版本的 target 名(如 gz-sim7::gz-sim7)也要同步替换:
- 所有
gz-sim7→gz-sim8 - 所有
gz-transport12→gz-transport13
(2)编辑 ~/ros2_ws/src/vehicle_gateway/betaflight_gazebo/package.xml
将 <depend> 中的版本号也同步更新:
<depend>gz-sim7</depend>→<depend>gz-sim8</depend><depend>gz-transport12</depend>→<depend>gz-transport13</depend>
为什么? Gazebo Garden (gz-sim7, gz-transport12) 和 Gazebo Harmonic (gz-sim8, gz-transport13) 的 ABI 不兼容,插件必须链接与 Gazebo 服务器进程相同版本的库。gz-math7 和 gz-plugin2 两个版本共享,无需修改。
package.xml不改的话 colcon 会尝试用 rosdep 解析gz-sim7,在只装了 Harmonic 的环境中必然失败。
验证项 — 以下三项已在最新上游代码中修复,无需手动改,但建议逐项确认你的克隆包含它们:
检查一:头文件引用(BetaflightPlugin.cpp 第 35-39 行)
~/ros2_ws/src/vehicle_gateway/betaflight_gazebo/src/BetaflightPlugin.cpp 中应已有以下头文件:
#include <functional>
#include <gz/msgs/imu.pb.h>
#include <gz/msgs/fluid_pressure.pb.h>
#include <gz/msgs/double.pb.h>cpp背景说明:Gazebo Harmonic (gz-sim8) 的
System.hh不再传递包含 protobuf 消息头文件,因此需要显式 include。上游已修复,如果你的克隆缺少这几行,手动补上。
检查二:IMU 和气压传感器订阅(PreUpdate 第 440-443 行,Configure 第 296-301 行)
IMU 订阅在 PreUpdate 函数中(约第 440 行),气压传感器订阅在 Configure 函数中(约第 296 行)。二者已使用 std::function + std::bind 写法,无需改动:
PreUpdate 中 IMU 订阅(约第 440 行):
std::function<void(const gz::msgs::IMU&)> imuCb =
std::bind(&BetaFlightPluginPrivate::ImuCb,
this->dataPtr.get(), std::placeholders::_1);
this->dataPtr->node.Subscribe(imuTopicName, imuCb);cppConfigure 中气压传感器订阅(约第 296 行):
std::function<void(const gz::msgs::FluidPressure&)> airPressureCb =
std::bind(&BetaFlightPluginPrivate::onAirPressureMessageReceived,
this->dataPtr.get(), std::placeholders::_1);
this->dataPtr->node.Subscribe(
"/world/empty_betaflight_world/model/iris_with_Betaflight/model/iris_with_standoffs/"
"link/imu_link/sensor/air_pressure_sensor/air_pressure",
airPressureCb);cpp背景说明:Gazebo Garden 的 gz-transport12 允许
Node::Subscribe直接接受成员函数指针进行模板推导。gz-transport13 收紧后,必须用std::function显式包装。IMU 回调ImuCb签名为void(const gz::msgs::IMU&),气压回调onAirPressureMessageReceived签名为void(const gz::msgs::FluidPressure&)。
检查三:PostUpdate 死锁修复(PostUpdate 第 478-484 行)
PostUpdate 函数中不应包含 betaflightOnline 条件:
if (!_info.paused && _info.simTime > this->dataPtr->lastControllerUpdateTime)
{
double t = ...;
this->SendState(t, _ecm);
...
}cpp如果出现 && this->dataPtr->betaflightOnline,请删除。原因:初始时 SITL 等 Gazebo 发 fdm_packet,而 Gazebo 因为 betaflightOnline == false 不发 fdm_packet,形成死锁。
步骤 3:编译插件#
cd ~/ros2_ws
source /opt/ros/humble/setup.bash
colcon build --packages-select betaflight_gazebobash验证:ls -lh ~/ros2_ws/install/betaflight_gazebo/lib/libBetaflightPlugin.so(约 8.7M)
仿真世界的配置#
世界文件和无人机模型已包含在本教程配套仓库中。只需软链接 IRIS 四旋翼模型(由 vehicle_gateway 提供):
ln -sf ~/ros2_ws/src/vehicle_gateway/betaflight_sim/models/iris_with_standoffs \
~/ros2_ws/src/vehicle_gateway/betaflight_sim/models/bash世界文件位于 ~/bf_sitl/worlds/betaflight_world.sdf,核心插件配置:
<plugin name="BetaFlightPlugin" filename="BetaflightPlugin">
<fdm_addr>127.0.0.1</fdm_addr>
<fdm_port_in>9002</fdm_port_in>
<listen_addr>127.0.0.1</listen_addr>
<modelXYZToAirplaneXForwardZDown>0 0 0 3.141593 0 0</modelXYZToAirplaneXForwardZDown>
<gazeboXYZToNED>0 0 0 3.141593 0 0</gazeboXYZToNED>
<imuName>iris_with_standoffs::imu_link::imu_sensor</imuName>
<control channel="0">
<jointName>iris_with_standoffs::rotor_0_joint</jointName>
<multiplier>838</multiplier>
</control>
<!-- channels 1-3 similarly configured, multipliers: 838, -838, -838 -->
</plugin>xml
坐标系变换详解:Gazebo 使用 ENU(X=East, Y=North, Z=Up),Betaflight 期望 NED(X=North, Y=East, Z=Down)。
gazeboXYZToNED的 Yaw=180° 进行朝向旋转,插件内部的SendState()进一步应用Rz(π/2)旋转完成完整变换。
配置解锁 —— 让飞控能够 ARM#
Betaflight 的安全设计原则是”默认禁止一切飞行操作”——出厂固件没有配置 ARM(解锁)开关,即使收到 RC 信号中 AUX1=2000,飞控也不知道这表示”解锁请求”。我们需要告诉飞控:当 AUX1 通道值在 1700-2100 范围时,激活 ARM 模式。
配置保存在 eeprom.bin 文件中(相当于飞控的”设置记忆”),通过 TCP 端口 5761 的 CLI(命令行接口)进行设置。
第一步:清除旧配置(重要!)#
如果你之前运行过 SITL,旧的 eeprom.bin 可能包含错误配置(典型问题是接收机模式被改成了串口 RX_SERIAL 而非 UDP),导致飞控永久无法解锁。首次配置前务必删除旧文件:
cd ~/bf_sitl/config
rm -f eeprom.binbash为什么要删?
eeprom.bin是飞控的持久化存储。SITL 每次启动时优先读取eeprom.bin,只有在文件不存在时才使用出厂默认值。出厂默认已正确启用 UDP 接收机模式(FEATURE_RX_UDP),确保飞控能通过 UDP 端口 9004 接收 RC 遥控信号。如果旧的eeprom.bin里存了错误的接收机设置,SITL 就会忽略 RC 信号,飞控永远显示 RXLOSS、永远无法解锁。
第二步:启动 SITL 并运行配置脚本#
在终端 1 中启动 SITL(此时不需要 Gazebo):
cd ~/bf_sitl/config
~/betaflight/obj/main/betaflight_SITL.elf --ip 127.0.0.1bash等待出现 bind port 5761 for UART1,然后打开终端 2 运行配置脚本:
python3 ~/bf_sitl/config/bf_cli_config.pybash脚本内容(bf_cli_config.py,仓库 config/ 目录下):
#!/usr/bin/env python3
"""Configure Betaflight SITL via CLI — set ARM on AUX1"""
import socket, time, sys
HOST, PORT = '127.0.0.1', 5761
def recv_all(sock, timeout=1.0):
"""读取所有可用数据,超时后返回"""
sock.settimeout(timeout)
data = b''
try:
while True:
chunk = sock.recv(4096)
if not chunk: break
data += chunk
except socket.timeout: pass
except Exception: pass
return data
def main():
# 1. 等待 SITL 的 TCP 端口就绪(最多等 30 秒)
print("Waiting for SITL TCP port 5761...")
for i in range(30):
try:
s = socket.socket(); s.settimeout(1)
s.connect((HOST, PORT)); s.close()
print(f"Connected after {i+1}s"); break
except Exception:
time.sleep(1)
else:
print("ERROR: SITL not ready after 30s"); sys.exit(1)
# 2. 建立 TCP 连接,排空初始欢迎信息
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect((HOST, PORT))
time.sleep(1)
recv_all(sock, 0.5)
# 3. 发送单独一个 '#' 进入 CLI 模式
# 注意:只发 '#',不带 \n。Betaflight 收到 '#'(而非 MSP 帧头 '$')时进入 CLI
print("Entering CLI mode...")
sock.sendall(b'#')
time.sleep(2)
resp = recv_all(sock, 1.0)
if resp:
print(resp.decode('utf-8', errors='replace')[:300])
# 4. 配置 ARM 到 AUX1(通道值 1700-2100 时触发 ARM 模式)
# aux 0 0 0 1700 2100 参数:
# 槽位0, 模式0(ARM), AUX通道0(AUX1=第5通道), 触发范围1700-2100μs
print("\nSetting ARM on AUX1 (1700-2100)...")
sock.sendall(b'aux 0 0 0 1700 2100\n')
time.sleep(0.5)
resp = recv_all(sock, 1.0)
if resp:
print(resp.decode('utf-8', errors='replace')[-200:])
# 5. 保存到 eeprom.bin 并重启飞控固件
print("Saving to EEPROM...")
try:
sock.sendall(b'save\n')
time.sleep(1)
resp = recv_all(sock, 3.0)
if resp:
print(resp.decode('utf-8', errors='replace')[-300:])
except Exception as e:
print(f"Save: {e} (reboot is normal)")
try: sock.close()
except: pass
print("\nDone! ARM on AUX1 configured.")
if __name__ == '__main__':
main()python第三步:重启 SITL 使配置生效#
save 命令会让飞控内部重新初始化,但 Linux 进程本身不会自动退出。你需要在终端 1 按 Ctrl+C 手动停止,然后重新启动:
~/betaflight/obj/main/betaflight_SITL.elf --ip 127.0.0.1bash为什么需要手动重启?
save让飞控固件内部重新初始化并加载新配置到内存,但终端中运行的 Linux 进程仍然占用着端口。Ctrl+C 杀掉进程再重启,确保端口绑定和运行状态都是干净的。
重启后日志中应出现 [FLASH_Unlock] loaded 'eeprom.bin',表示配置已正确加载。至此 ARM 配置完成,后续正常启动仿真时直接跳到下一步即可。
aux 0 0 0 1700 2100 命令含义:第 1 个 0 = 配置槽位编号;第 2 个 0 = 模式 ID(0 = ARM);第 3 个 0 = AUX 通道索引(0 = AUX1);1700 2100 = 触发范围(当 AUX1 通道值在此范围内时激活 ARM 模式)。
AUX 通道模式对照表#
aux <槽位> <模式ID> <AUX通道> <范围低> <范围高>
常用模式 ID:
| ID | 名称 | 功能 |
|---|---|---|
| 0 | ARM | 解锁/上锁飞控 |
| 1 | ANGLE | 自稳模式 |
| 2 | HORIZON | 半自稳模式 |
| 5 | HEADFREE | 无头模式 |
| 11 | GPS RESCUE | GPS 救援返航 |
| 22 | AIRMODE | 空中模式 |
| 26 | CRASH FLIP | 反乌龟模式 |
| 27 | PREARM | 预解锁 |
示例——添加 ANGLE 模式在 AUX2(1500-2100):
aux 1 1 1 1500 2100bash启动仿真 —— 三个终端流程#
终端 1 —— Betaflight SITL:
cd ~/bf_sitl/config
~/betaflight/obj/main/betaflight_SITL.elf --ip 127.0.0.1bash
终端 2 —— Gazebo Harmonic:
export GZ_SIM_SYSTEM_PLUGIN_PATH=$HOME/ros2_ws/install/betaflight_gazebo/lib
export GZ_SIM_RESOURCE_PATH=$HOME/ros2_ws/src/vehicle_gateway/betaflight_sim/models
gz sim -r -v 4 ~/bf_sitl/worlds/betaflight_world.sdfbash
终端 3 —— 闭环定高控制器:
# 闭环定高控制器(推荐,需 MSP 遥测)
python3 ~/bf_sitl/autonomy/msp_closed_loop_controller.py \
--target-alt 2.0 --hover-base 1450 --kp 80 --ki 5 --kd 40
# 或开环起飞控制器(无需 MSP 遥测,仅 RC 控制)
python3 ~/bf_sitl/autonomy/autonomous_controller.py --hover-throttle 1450bash视频 1:起飞演示——闭环定高控制
RC 包发送频率:控制器必须以 ≥50Hz 的频率发送 RC 包。Betaflight 接收机超时窗口约 100ms,超时即触发 RXLOSS。本控制器在独立线程中以 100Hz 运行。 高度符号约定:Betaflight 的
getEstimatedAltitudeCm()返回 NED 高度(正值=向下),控制器内部取反使”向上为正”。
控制器代码解析#
控制器代码均已包含在本教程配套仓库的 autonomy/ 目录中,克隆后即可使用,无需手动复制。
msp_closed_loop_controller.py(闭环定高控制器)#
#!/usr/bin/env python3
"""
Closed-loop altitude hold controller for Betaflight SITL.
- Reads drone state via MSP over TCP 5761 (altitude, attitude, IMU, battery)
- Sends RC via UDP 9004 (100Hz)
- PID altitude control with attitude monitoring
This is the SAME architecture used on real hardware (TCP -> UART serial).
"""
import socket, struct, time, signal, sys, argparse, threading
from msp_reader import MSPReader
BF_IP, BF_PORT = '127.0.0.1', 9004
NUM_CH = 16
class ClosedLoopController:
def __init__(self, target_alt=2.0, hover_base=1450, kp=80, ki=5, kd=40):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.msp = MSPReader()
self.target_alt = target_alt
self.hover_base = hover_base
self.kp, self.ki, self.kd = kp, ki, kd
self.integral = 0.0
self.last_error = 0.0
self.lock = threading.Lock()
self.throttle = 1000
self.aux1 = 1000
self.running = True
signal.signal(signal.SIGINT, self.stop)
signal.signal(signal.SIGTERM, self.stop)
def stop(self, *args):
print("\nDisarming...")
self.running = False
with self.lock:
self.throttle = 1000
self.aux1 = 1000
time.sleep(0.3)
self.msp.stop()
self.sock.close()
sys.exit(0)
def send_rc(self, channels):
ts = time.time()
pkt = struct.pack('<d' + 'H' * NUM_CH, ts, *channels)
try:
self.sock.sendto(pkt, (BF_IP, BF_PORT))
except:
pass
def rc_thread(self):
"""Send RC at 100Hz independently of state reading"""
while self.running:
with self.lock:
t, a = self.throttle, self.aux1
ch = [1500] * NUM_CH
ch[2], ch[4] = t, a
self.send_rc(ch)
time.sleep(0.01)
def run(self):
print("=" * 60)
print("Betaflight SITL Closed-Loop Controller (MSP + UDP)")
print(f"Target altitude: {self.target_alt}m")
print(f"Hover base: {self.hover_base} PID: kp={self.kp} ki={self.ki} kd={self.kd}")
print("=" * 60)
if not self.msp.start():
print("ERROR: Failed to connect MSP reader. Is SITL running?")
sys.exit(1)
threading.Thread(target=self.rc_thread, daemon=True).start()
# Phase 1: Disarmed init (3s)
print("\nPhase 1: Init (disarmed, 3s)...")
with self.lock:
self.throttle = 1000
self.aux1 = 1000
time.sleep(3)
# Phase 2: Arm (2s) -- throttle MUST be 1000 for arming
print("Phase 2: Arming (2s)...")
with self.lock:
self.throttle = 1000
self.aux1 = 2000
time.sleep(2)
# Phase 3: Altitude hold with PID
print(f"Phase 3: Altitude hold at {self.target_alt}m...")
last_time = time.time()
while self.running:
state = self.msp.get_state()
now = time.time()
dt = now - last_time
if dt <= 0 or dt > 1.0:
dt = 0.05
# Get altitude from MSP (cm -> m).
# Betaflight reports NED (positive=down), negate so "up" is positive.
alt = -state.get('alt_cm', 0) / 100.0 if state else 0.0
# PID control
error = self.target_alt - alt
self.integral += error * dt
self.integral = max(-200, min(200, self.integral))
deriv = (error - self.last_error) / dt
pid = self.kp * error + self.ki * self.integral + self.kd * deriv
thr = int(self.hover_base + pid)
thr = max(1000, min(1900, thr))
with self.lock:
self.throttle = thr
self.aux1 = 2000
# Display
roll = state.get('roll_deg', 0)
pitch = state.get('pitch_deg', 0)
volt = state.get('voltage_v', 0)
rssi = state.get('rssi', 0)
mode = state.get('flight_mode', 0)
armed = bool(mode & (1 << 0)) if 'flight_mode' in state else False
print(f"\r alt={alt:.2f}m err={error:+.2f} thr={thr} "
f"r={roll:.0f}° p={pitch:.0f}° "
f"v={volt:.1f}V RSSI={rssi} armed={armed} ",
end='', flush=True)
self.last_error = error
last_time = now
time.sleep(0.05)
def main():
p = argparse.ArgumentParser()
p.add_argument('--target-alt', type=float, default=2.0)
p.add_argument('--hover-base', type=int, default=1450)
p.add_argument('--kp', type=float, default=80)
p.add_argument('--ki', type=float, default=5)
p.add_argument('--kd', type=float, default=40)
args = p.parse_args()
ClosedLoopController(args.target_alt, args.hover_base,
args.kp, args.ki, args.kd).run()
if __name__ == '__main__':
main()python核心逻辑:
- RC 发送线程(100Hz):从共享变量读取油门和 AUX 值,按 AETR 通道映射组装 16 通道数组,用
struct.pack封包通过 UDP 9004 发送 - MSP 遥测线程(~20Hz):循环发送 MSPv1 请求帧,解析响应获取高度、姿态、电压等
- 主 PID 循环(20Hz):读取高度,与目标值比较得 error,经 PID 计算叠加到悬停油门基准值
MSPv1 请求帧格式(6 字节):$ M < size(0) cmd CRC(cmd)。响应帧:$ M > size cmd payload(N字节) CRC。
真机对应关系:UDP 9004 → UART 串口(MSP_SET_RAW_RC),TCP 5761 → UART 串口(MSP 遥测)。msp_reader.py 和 PID 逻辑完全复用,仅将 socket 替换为 serial。
msp_reader.py(MSP 遥测读取器)#
MSP 遥测读取器是整个控制链路的关键组件。它通过 TCP 5761 端口连接 SITL,循环查询 5 条 MSP 命令(高度、姿态、IMU、状态、电压),将飞控遥测数据解析为 Python 字典供控制器使用。
#!/usr/bin/env python3
"""
MSP telemetry reader for Betaflight SITL.
Connects to TCP 5761 and queries multiple MSP commands to read full drone state.
"""
import socket, struct, time, threading, select
class MSPReader:
"""Connects to SITL TCP 5761 and reads full drone telemetry via MSP"""
MSP_CMDS = {
'altitude': (109, '<ih'), # alt_cm (int32), vario_cm_s (int16)
'attitude': (108, '<hhh'), # roll_ddeg, pitch_ddeg, yaw_deg
'raw_imu': (102, '<hhhhhhhhh'), # acc[3], gyro[3], mag[3] (raw int16)
'status': (101, '<HHHIB'), # cycleTime, i2cErr, sensors, mode, profile
'analog': (110, '<BHHh'), # voltage_dV, mAh, rssi, amperage_cA
}
def __init__(self, host='127.0.0.1', port=5761):
self.state = {}
self.state_lock = threading.Lock()
self.running = False
self.host, self.port = host, port
self.sock = None
def connect(self, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(0.5)
self.sock.connect((self.host, self.port))
print(f"[MSP] Connected to {self.host}:{self.port}")
return True
except:
self.sock.close()
time.sleep(0.5)
print("[MSP] Connection timeout")
return False
def _build_msp_request(self, cmd):
"""Build MSPv1 request frame (no payload). CRC = cmd ^ 0x00."""
return b'$M<' + bytes([0]) + bytes([cmd]) + bytes([cmd])
def _parse_msp_response(self, data, cmd_id, fmt):
"""Parse MSP response. Handles both v1 and v2 framing."""
idx = data.find(b'$M>')
if idx < 0:
idx = data.find(b'$X>')
if idx < 0:
return None
if data[idx:idx+3] == b'$M>':
if idx + 5 > len(data): return None
size = data[idx + 3]
cmd = data[idx + 4]
if cmd != cmd_id or idx + 5 + size > len(data): return None
payload = data[idx + 5: idx + 5 + size]
try:
# slice to exact format size — MSP payload may be larger
return struct.unpack(fmt, payload[:struct.calcsize(fmt)])
except: return None
elif data[idx:idx+3] == b'$X>':
if idx + 8 > len(data): return None
cmd = data[idx + 4] | (data[idx + 5] << 8)
size = data[idx + 6] | (data[idx + 7] << 8)
if cmd != cmd_id or idx + 8 + size > len(data): return None
payload = data[idx + 8: idx + 8 + size]
try:
return struct.unpack(fmt, payload[:struct.calcsize(fmt)])
except: return None
return None
def _read_all(self):
data = b''
try:
while True:
ready, _, _ = select.select([self.sock], [], [], 0.01)
if not ready: break
chunk = self.sock.recv(4096)
if not chunk: break
data += chunk
except: pass
return data
def _send_and_recv(self, cmd_id, fmt):
try:
self.sock.sendall(self._build_msp_request(cmd_id))
for _ in range(5):
time.sleep(0.02)
data = self._read_all()
if data:
result = self._parse_msp_response(data, cmd_id, fmt)
if result is not None: return result
except: pass
return None
def query_all(self):
result = {}
r = self._send_and_recv(109, '<ih')
if r: result['alt_cm'] = r[0]; result['vario_cm_s'] = r[1]
r = self._send_and_recv(108, '<hhh')
if r: result['roll_deg'] = r[0] / 10.0; result['pitch_deg'] = r[1] / 10.0; result['yaw_deg'] = r[2]
r = self._send_and_recv(102, '<hhhhhhhhh')
if r: result['acc_x'] = r[0]; result['acc_y'] = r[1]; result['acc_z'] = r[2]; result['gyro_x'] = r[3]; result['gyro_y'] = r[4]; result['gyro_z'] = r[5]
r = self._send_and_recv(101, '<HHHIB')
if r: result['cycle_time_us'] = r[0]; result['sensors'] = r[2]; result['flight_mode'] = r[3]; result['pid_profile'] = r[4]
r = self._send_and_recv(110, '<BHHh')
if r: result['voltage_v'] = r[0] / 10.0; result['mah'] = r[1]; result['rssi'] = r[2]
return result
def reader_thread(self):
self.running = True
while self.running:
result = self.query_all()
if result:
with self.state_lock: self.state.update(result)
time.sleep(0.05)
def start(self):
if not self.connect(): return False
t = threading.Thread(target=self.reader_thread, daemon=True); t.start()
return True
def stop(self):
self.running = False
if self.sock: self.sock.close()
def get_state(self):
with self.state_lock: return dict(self.state)pythonMSP_STATUS 格式要点:
<HHHIB>= cycleTime(2B) + i2cError(2B) + sensors(2B) + flightModeFlags(4B) + pidProfile(1B) = 11 字节。flightModeFlags的 bit 0 对应 BOXARM(ARM 状态)。注意struct.unpack要求 buffer 大小精确匹配格式,必须用payload[:struct.calcsize(fmt)]截断,因为 MSP_STATUS 的实际 payload 远超 11 字节。
常见问题排查汇总#
| 现象 | 最可能的原因 | 排查方法 |
|---|---|---|
| Gazebo 启动后 SITL 无 new fdm 消息 | 插件未加载或 UDP 不通 | gz sim -v 4 查看是否出现 “Loaded system [BetaFlightPlugin]“ |
| SITL 一直显示 RXLOSS(RC 已发但 RXLOSS 不消失) | 旧 eeprom.bin 接收机模式设置错误 | rm ~/bf_sitl/config/eeprom.bin 删除后重启 SITL |
SITL 一直显示 RXLOSS(无 new rc 消息) | RC 控制器未启动 | 确认控制器已在终端 3 运行,SITL 终端应出现 new rc |
| SITL 显示 Arming disabled: THROTTLE | 解锁时油门通道值过高 | 检查 Phase 2 阶段 ch[2] 是否为 1000 |
| SITL 显示 FAILSAFE RXLOSS | RC 发送间隔超过 100ms | 确保以 ≥50Hz 发送。阻塞操作(如文件读写)必须放入独立线程 |
| 螺旋桨转了但不起飞 | 悬停油门过低 | 逐步增加 --hover-base,IRIS 约需 1450-1600 |
| 闭环控制器 alt=0.00 不变 | MSP 响应解析失败或高度符号错误 | 单独跑 msp_reader.py 验证 MSP 通信 |
| 闭环控制器 alt 数值正确但飞机失控 | PID 参数过于激进 | 从小参数开始:--kp 2 --ki 1 --kd 1 |
| 编译 Betaflight SITL 失败 | 缺少构建依赖 | sudo apt install build-essential |
编译插件时 Could not find gz-sim8 | 缺少 Gazebo 开发包 | sudo apt install -y libgz-sim8-dev libgz-transport13-dev libgz-msgs10-dev libgz-math7-dev libgz-plugin2-dev libsdformat14-dev |
编译插件时 colcon 报 Cannot find package gz-sim7 | package.xml 未改版本号 | 将 package.xml 中 gz-sim7 改为 gz-sim8,gz-transport12 改为 gz-transport13 |
编译插件失败,gz/msgs/imu.pb.h not found | gz-msgs10-dev 未安装 | sudo apt install libgz-msgs10-dev |
| 编译插件失败,no matching function for Subscribe | std::function + std::bind 未正确实施 | 对照检查二的代码示例逐一核实 |
gz sim --versions 显示多版本 | 混装了多个 Gazebo 版本 | 只保留 gz-harmonic |
总结#
本文完整介绍了 Betaflight SITL + Gazebo Harmonic 仿真环境的搭建流程,包括 SITL 编译、Gazebo 插件的四处关键适配修改、CLI 配置解锁、AUX 通道模式设置以及 Python 闭环定高控制器的实现。Betaflight 的 SITL 模式为飞控算法验证提供了无需硬件的完整闭环仿真能力,配合 Gazebo 物理引擎可以验证从传感器输入到电机输出的完整控制链路。如果需要在仿真中集成视觉感知,可以参考 无人机仿真环境调用 YOLO 的简单示例,为目标识别任务做准备。
欢迎在 GitHub 仓库 ↗ 上提交改进和反馈!
参考资料#
- Betaflight SITL 官方文档 ↗
- OSRF vehicle_gateway 仓库 ↗
- Betaflight 源码 — SITL 平台实现 — sitl.c、target.h、udplink.c
- Gazebo Harmonic 安装文档 ↗
- ROS2 + Gazebo 桥接包 — ros-humble-ros-gzharmonic API 文档
- Betaflight Configurator ↗