DUDuuu.studio

Back

关于 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 飞控固件。

Betaflight SITL架构
图 1:Betaflight SITL 架构概述

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_sitl
bash

仓库目录结构:

~/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-dev
bash
  • websockify:提供 WebSocket → TCP 的代理功能,用于在浏览器中连接 Betaflight 地面站配置工具
  • socat:用于创建虚拟串口(pty),可将 Betaflight 的 TCP UART 映射为本地虚拟串口设备

确保网络可以正常访问 GitHub,否则克隆大型仓库时会中途闪退。

仿真链路架构#

在开始动手配置之前,我们先介绍整个仿真链路,这有助于后续遇到问题时定位原因。仿真链路由四个进程组成,它们通过 UDP 端口互相通信:

仿真链路架构图
图 2:仿真链路架构——四个进程通过 UDP 通信

端口分配与数据流(Betaflight 源码 sitl.c 第 192-195 行):

端口方向用途数据包类型
9001SITL → 外部Raw PWM(备用于 RealFlightBridge)servo_packet_raw
9002SITL → Gazebo电机转速指令 [0.0, 1.0](3D 模式为 [-1.0, 1.0])servo_packet
9003Gazebo → SITL飞行状态数据(IMU、位置、速度、姿态、气压)fdm_packet
9004Controller → SITL16 通道 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;
c

Gazebo 插件(BetaflightPlugin)在每个仿真更新周期中执行两个阶段:

  1. PreUpdate(仿真步进前):调用 ReceiveServoPacket() 从 UDP 9002 读取 servo_packet,将电机转速换算为关节力/速度指令
  2. 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 betaflight
bash

注意:我们使用的不是 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.elf
plaintext

验证:

./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/src
bash

然后克隆 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"
done
bash

步骤 2:适配 Gazebo Harmonic(需改 2 个文件的版本号,其余 3 项代码修复已在最新上游代码中完成)#

当前 OSRF vehicle_gateway 上游代码已包含了大部分 Gazebo Harmonic 兼容修复。只有 CMakeLists.txt 的库版本号仍需要手动修改。

必须修改 — 适配库版本号

需要改两个文件:CMakeLists.txtpackage.xml。二者中的版本号必须同步,否则 colcon 依赖解析会失败。

(1)编辑 ~/ros2_ws/src/vehicle_gateway/betaflight_gazebo/CMakeLists.txt

全文搜索替换以下两对版本号。不仅 find_package 行要改,target_link_libraries 中带版本的 target 名(如 gz-sim7::gz-sim7)也要同步替换:

  • 所有 gz-sim7gz-sim8
  • 所有 gz-transport12gz-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);
cpp

Configure 中气压传感器订阅(约第 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_gazebo
bash

验证: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
IRIS四旋翼模型
图 3:IRIS 四旋翼模型在 Gazebo 中

坐标系变换详解: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.bin
bash

为什么要删? 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.1
bash

等待出现 bind port 5761 for UART1,然后打开终端 2 运行配置脚本:

python3 ~/bf_sitl/config/bf_cli_config.py
bash

脚本内容(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.1
bash

为什么需要手动重启? 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名称功能
0ARM解锁/上锁飞控
1ANGLE自稳模式
2HORIZON半自稳模式
5HEADFREE无头模式
11GPS RESCUEGPS 救援返航
22AIRMODE空中模式
26CRASH FLIP反乌龟模式
27PREARM预解锁

示例——添加 ANGLE 模式在 AUX2(1500-2100):

aux 1 1 1 1500 2100
bash

启动仿真 —— 三个终端流程#

终端 1 —— Betaflight SITL:

cd ~/bf_sitl/config
~/betaflight/obj/main/betaflight_SITL.elf --ip 127.0.0.1
bash
SITL端口状态
图 4:SITL 端口监听状态

终端 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.sdf
bash
世界启动成功
图 5:Gazebo 世界启动成功

终端 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 1450
bash

视频 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)
python

MSP_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 RXLOSSRC 发送间隔超过 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-sim7package.xml 未改版本号package.xmlgz-sim7 改为 gz-sim8gz-transport12 改为 gz-transport13
编译插件失败,gz/msgs/imu.pb.h not foundgz-msgs10-dev 未安装sudo apt install libgz-msgs10-dev
编译插件失败,no matching function for Subscribestd::function + std::bind 未正确实施对照检查二的代码示例逐一核实
gz sim --versions 显示多版本混装了多个 Gazebo 版本只保留 gz-harmonic

总结#

本文完整介绍了 Betaflight SITL + Gazebo Harmonic 仿真环境的搭建流程,包括 SITL 编译、Gazebo 插件的四处关键适配修改、CLI 配置解锁、AUX 通道模式设置以及 Python 闭环定高控制器的实现。Betaflight 的 SITL 模式为飞控算法验证提供了无需硬件的完整闭环仿真能力,配合 Gazebo 物理引擎可以验证从传感器输入到电机输出的完整控制链路。如果需要在仿真中集成视觉感知,可以参考 无人机仿真环境调用 YOLO 的简单示例,为目标识别任务做准备。

欢迎在 GitHub 仓库 上提交改进和反馈!

参考资料#

Betaflight+Gazebo 软件在环仿真教程
https://duduuu.xyz/zh/posts/px4-ros2-betaflight-sitl
Author dudu
Published at 2026年7月11日
阅读
总访问