DUDuuu.studio

Back

引言#

书接上文,我们基于已经介绍过的在ROS2+PX4环境下启动多机仿真的流程,介绍一下如何使用Offboard模式在ROS2中控制多机仿真。在多机仿真中,有一些细小的命名规则和代码细节需要注意,本文会一一讲解。如果读者在仿真过程中发现没有能够成功实现多无人机仿真,可以对照本文进行问题排查。

通信说明#

在ROS2中进行多机通信会更加便捷,在此处我们参考一下PX4官方文档对此部分给出的说明:

PX4官方文档多机通信说明
图 1:PX4官方文档对多机通信的说明

XRCE-DDS允许多个客户端通过 UDP 连接到同一个代理。这在模拟中特别有用,因为只需要启动一个代理。

因此,在我们前一篇文章添加通信时,其实已经为不同的无人机实例分配好了一个独立的px4_instance编号,对应着我们启动命令的 -i 0/1/2...。因此我们不再需要手动的去为各个实例分配编号。现在,我们开始进行Offboard控制。

启动仿真#

我们仿照前一篇文章的流程,启动两个x_500无人机实例。按照步骤启动QGC地面站、Gazebo仿真环境。打开两个终端启动实例,分别放置在(0,0)和(0,2)处,px4_instance编号分别为0,1:

# 第一个终端
cd ~/PX4-Autopilot
PX4_GZ_STANDALONE=1 PX4_SYS_AUTOSTART=4001 PX4_GZ_MODEL_POSE="0,0" PX4_SIM_MODEL=gz_x500 ./build/px4_sitl_default/bin/px4 -i 0

# 第二个终端
cd ~/PX4-Autopilot
PX4_GZ_STANDALONE=1 PX4_SYS_AUTOSTART=4001 PX4_GZ_MODEL_POSE="0,2" PX4_SIM_MODEL=gz_x500 ./build/px4_sitl_default/bin/px4 -i 1
bash

此时我们在终端输入:

ros2 topic list
bash

可以查看px4实例的话题名称:

ROS2话题列表
图 2:ROS2话题列表,可见两个PX4实例的话题

我们可以发现,px4的两个实例的话题成功发布,现在我们需要记录一下这个命名规则,后续编写代码时需要用到:

命名规则:当实例编号为0时,话题的名称以 /fmu/ 开始;而当实例编号大于等于1时,话题的名称以 /px4_i/fmu/ 开始,其中 i 为我们启动仿真时的实例编号。这个规则需要注意。

启动通信#

现在我们为两个无人机添加通信,按照上一篇文章和本文开头的说明,使用:

MicroXRCEAgent udp4 -p 8888
bash
uXRCE-DDS Agent多客户端连接
图 3:启动一个通信客户端即可完成两个无人机实例的通信

启动一个通信客户端即可完成通信,此时我们根据终端的输出可以发现通信成功,同时有两个客户端的ID,一个为 0x00000001,一个为 0x00000002,这与两个实例的MAVLINK ID相关,此处可以在官方文档上找到答案:

MAVLINK ID对应表
图 4:UXRCE_DDS_KEY与MAVLINK ID对应关系
target_system对应表
图 5:target_system与MAVLINK ID对应关系

根据第一张图我们得知,在启动通信客户端时,自动为两个实例分配了UXRCE_DDS_KEY值,且与MAVLINK ID相同,值均为 px4_instance + 1,因此我们的0号无人机对应的是1,而1号无人机对应的是2。

根据第二张图我们得知,target_system 编号与MAVLINK ID相同,值也为 px4_instance + 1

至此,关于多机通信的一些命名规则,以及一些容易被忽视的细节问题就解决好了。

双机Offboard代码解释#

我们编写一个双无人机编队的飞行代码,使用Offboard模式进行外部控制。

#include <px4_msgs/msg/offboard_control_mode.hpp>
#include <px4_msgs/msg/trajectory_setpoint.hpp>
#include <px4_msgs/msg/vehicle_command.hpp>
#include <rclcpp/rclcpp.hpp>
#include <stdint.h>

#include <chrono>
#include <cmath>
#include <iostream>

using namespace std::chrono;
using namespace std::chrono_literals;
using namespace px4_msgs::msg;
cpp

添加头文件和命名空间,其中 px4_msgs 是我们使用最多的px4的消息类型,我们主要使用其中的三个:OffboardControlModeTrajectorySetpointVehicleCommand

class MultiOffboardControl : public rclcpp::Node
{
public:
    MultiOffboardControl() : Node("multi_offboard_control")
    {
        offboard_control_mode_publisher_uav0_ = this->create_publisher<OffboardControlMode>("/fmu/in/offboard_control_mode", 10);
        trajectory_setpoint_publisher_uav0_ = this->create_publisher<TrajectorySetpoint>("/fmu/in/trajectory_setpoint", 10);
        vehicle_command_publisher_uav0_ = this->create_publisher<VehicleCommand>("/fmu/in/vehicle_command", 10);

        offboard_control_mode_publisher_uav1_ = this->create_publisher<OffboardControlMode>("/px4_1/fmu/in/offboard_control_mode", 10);
        trajectory_setpoint_publisher_uav1_ = this->create_publisher<TrajectorySetpoint>("/px4_1/fmu/in/trajectory_setpoint", 10);
        vehicle_command_publisher_uav1_ = this->create_publisher<VehicleCommand>("/px4_1/fmu/in/vehicle_command", 10);

        offboard_setpoint_counter_ = 0;
        time_start_ = this->get_clock()->now();

        auto timer_callback = [this]() -> void {
            if (offboard_setpoint_counter_ == 10 || offboard_setpoint_counter_ % 50 == 0) {
                this->publish_vehicle_command_uav0(VehicleCommand::VEHICLE_CMD_DO_SET_MODE, 1, 6);
                this->arm_uav0();
                this->publish_vehicle_command_uav1(VehicleCommand::VEHICLE_CMD_DO_SET_MODE, 1, 6);
                this->arm_uav1();
            }

            publish_offboard_control_mode_uav0();
            publish_trajectory_setpoint_uav0();
            publish_offboard_control_mode_uav1();
            publish_trajectory_setpoint_uav1();

            if (offboard_setpoint_counter_ < 11) {
                offboard_setpoint_counter_++;
            }
        };
        timer_ = this->create_wall_timer(100ms, timer_callback);
    }
cpp

随后定义 MultiOffboardControl 类,将节点命名为 "multi_offboard_control" 并进行初始化。在初始化时创建了发布者,发布两个无人机实例对应的ROS2话题,消息队列长为10。这个地方就要与上文我们介绍过的话题命名规则相对应,第0号无人机的话题以 /fmu/ 开头,后续以 /px4_i/fmu/ 开头,其中 i 为每个无人机实例的编号,此处需要小心。

随后是一个定时器的逻辑,我们使用一个100ms的定时器,在第十次循环发送解锁无人机的指令,后续持续发布Offboard控制指令,向无人机发送控制模式和轨迹信息。

注意:此处的定时器频率需要设定一个合适的值,由于Offboard是外部控制模式,其本身有很大的安全性挑战,所以其要求一个至少为2Hz的消息频率;此处我们是100ms(10Hz),满足要求。同时,如果我们使用的消息频率较低,且只使用位置控制(position_control),有可能会造成轨迹上较大的偏差。

    void arm_uav0() { publish_vehicle_command_uav0(VehicleCommand::VEHICLE_CMD_COMPONENT_ARM_DISARM, 1.0); }
    void disarm_uav0() { publish_vehicle_command_uav0(VehicleCommand::VEHICLE_CMD_COMPONENT_ARM_DISARM, 0.0); }
    void arm_uav1() { publish_vehicle_command_uav1(VehicleCommand::VEHICLE_CMD_COMPONENT_ARM_DISARM, 1.0); }
    void disarm_uav1() { publish_vehicle_command_uav1(VehicleCommand::VEHICLE_CMD_COMPONENT_ARM_DISARM, 0.0); }

private:
    rclcpp::TimerBase::SharedPtr timer_;
    rclcpp::Publisher<OffboardControlMode>::SharedPtr offboard_control_mode_publisher_uav0_;
    rclcpp::Publisher<TrajectorySetpoint>::SharedPtr trajectory_setpoint_publisher_uav0_;
    rclcpp::Publisher<VehicleCommand>::SharedPtr vehicle_command_publisher_uav0_;
    rclcpp::Publisher<OffboardControlMode>::SharedPtr offboard_control_mode_publisher_uav1_;
    rclcpp::Publisher<TrajectorySetpoint>::SharedPtr trajectory_setpoint_publisher_uav1_;
    rclcpp::Publisher<VehicleCommand>::SharedPtr vehicle_command_publisher_uav1_;
    uint64_t offboard_setpoint_counter_;
    rclcpp::Time time_start_;
    float leader_position_[3] = {0.0, 0.0, -5.0};
cpp

arm_uav0()disarm_uav0() 两个函数是用于第0号无人机解锁和上锁,我们在上一篇文章的px4终端曾经看到过这些命令。

随后我们为两个无人机实例分别编写运动轨迹的逻辑,此处我们用一个最简单的leader-follower算法,即初始时 leader_position(0, 0, -5),后续我们选取第0号无人机为leader,按照半径为5、周期为20秒,在5米空中进行盘旋运动,同时将它的实时位置作为1号无人机(follower)的目标位置进行发送,让其在第0号无人机后方2米、右侧2米进行跟随伴飞。

我们可以看到,两个无人机实例分别对应三个函数,分别是 publish_offboard_control_mode_uav0/1()publish_trajectory_setpoint_uav0/1() 以及 publish_vehicle_command_uav0/1(),与我们之前讲的三种主要的msgs消息类型相对应。他们分别用来发布控制模式、运动轨迹及速度和加速度、车辆命令(解锁、起飞、切换模式等信息),我们一一来看:

    void publish_offboard_control_mode_uav0()
    {
        OffboardControlMode msg{};
        msg.position = true;
        msg.velocity = false;
        msg.acceleration = false;
        msg.attitude = false;
        msg.body_rate = false;
        msg.timestamp = this->get_clock()->now().nanoseconds() / 1000;
        offboard_control_mode_publisher_uav0_->publish(msg);
    }
cpp

publish_offboard_control_mode_uav0() 函数是向第0号无人机发送控制模式的函数,函数的内部我们将 msg.position 的值设为 true,用以启动Offboard中的位置控制模式,即向无人机发布轨迹信息,其他值设为 false,代表我们暂时不使用速度控制、加速度控制、姿态控制等。如果需要对无人机进行更细致的飞行控制算法的研究,可以在此进行修改,使用多种控制模式。

    void publish_trajectory_setpoint_uav0()
    {
        auto now = this->get_clock()->now();
        double t = (now - time_start_).seconds();
        const double radius = 5.0;
        const double period = 20.0;
        double omega = 2.0 * M_PI / period;

        TrajectorySetpoint msg{};
        msg.position = {
            static_cast<float>(radius * std::cos(omega * t)),
            static_cast<float>(radius * std::sin(omega * t)),
            -5.0f
        };
        msg.yaw = -omega * t;
        msg.timestamp = this->get_clock()->now().nanoseconds() / 1000;
        trajectory_setpoint_publisher_uav0_->publish(msg);

        leader_position_[0] = msg.position[0];
        leader_position_[1] = msg.position[1];
        leader_position_[2] = msg.position[2];
        RCLCPP_INFO(this->get_logger(), "uav0 position: x=%f, y=%f, z=%f",
                    leader_position_[0], leader_position_[1], leader_position_[2]);
    }
cpp

publish_trajectory_setpoint_uav0() 函数是向第0号无人机发送轨迹信息的函数,第0号无人机作为我们选定的领航者,按照固定的角速度进行盘旋飞行。这个函数的轨迹逻辑较为简单,我们不再赘述,可以在上述的示例中找到详细的代码。

此处有一个小的说明,在该函数中有一个 msg.yaw,这表示四旋翼无人机的偏航角,这是使用欧拉角进行姿态表示。在四旋翼中,我们使用NED右手坐标系,取逆时针为旋转正方向,用英文roll、pitch、yaw分别表示无人机的滚转角、俯仰角和偏航角。在两架无人机的轨迹代码中,我们添加了偏航角的表达,这是为了让飞机沿着飞行轨迹的法向/切向进行运动,特此说明。在此处给出一个欧拉角的表示方法示意图:

右手NED坐标系表示
图 6:右手NED坐标系表示,x为机头方向
欧拉角参考图
图 7:欧拉角表示方法(图源:北京航空航天大学可靠飞行控制研究组)
    void publish_vehicle_command_uav0(uint16_t command, float param1 = 0.0, float param2 = 0.0)
    {
        VehicleCommand msg{};
        msg.param1 = param1;
        msg.param2 = param2;
        msg.command = command;
        msg.target_system = 1;
        msg.target_component = 1;
        msg.source_system = 1;
        msg.source_component = 1;
        msg.from_external = true;
        msg.timestamp = this->get_clock()->now().nanoseconds() / 1000;
        RCLCPP_INFO(this->get_logger(), "uav0 command: %u, param1=%f, param2=%f, target_system=%u",
                    command, param1, param2, msg.target_system);
        vehicle_command_publisher_uav0_->publish(msg);
    }
cpp

publish_vehicle_command_uav0() 函数是向第0号无人机发送车辆命令的函数。在这个函数中,我们需要关注的有两个比较重要的参数:msg.target_systemmsg.from_external;前一个参数是我们在文章开头时就已经提到过的,他的命名规则为 px4_instance + 1,与MAVLINK ID、UXRCE_DDS_KEY的值均相同。因此,第0号无人机的 target_system 值为1,第1号无人机的 target_system 值为2,以此类推,在后续添加多个无人机实例时,在 vehicle_command 函数里修改对应的值即可。第二个参数 from_external 是外部控制指令的参数,我们设为 true,因为我们此时使用的是外部控制模式。

    void publish_offboard_control_mode_uav1()
    {
        OffboardControlMode msg{};
        msg.position = true;
        msg.velocity = false;
        msg.acceleration = false;
        msg.attitude = false;
        msg.body_rate = false;
        msg.timestamp = this->get_clock()->now().nanoseconds() / 1000;
        offboard_control_mode_publisher_uav1_->publish(msg);
    }

    void publish_trajectory_setpoint_uav1()
    {
        const float offset_x = 2.0;
        const float offset_y = 2.0;
        const float offset_z = 0.0;

        TrajectorySetpoint msg{};
        msg.position = {
            leader_position_[0] + offset_x,
            leader_position_[1] + offset_y,
            leader_position_[2] + offset_z
        };
        msg.yaw = -std::atan2(msg.position[1], msg.position[0]);
        msg.timestamp = this->get_clock()->now().nanoseconds() / 1000;
        RCLCPP_INFO(this->get_logger(), "uav1 setpoint: x=%f, y=%f, z=%f",
                    msg.position[0], msg.position[1], msg.position[2]);
        trajectory_setpoint_publisher_uav1_->publish(msg);
    }

    void publish_vehicle_command_uav1(uint16_t command, float param1 = 0.0, float param2 = 0.0)
    {
        VehicleCommand msg{};
        msg.param1 = param1;
        msg.param2 = param2;
        msg.command = command;
        msg.target_system = 2;
        msg.target_component = 1;
        msg.source_system = 1;
        msg.source_component = 1;
        msg.from_external = true;
        msg.timestamp = this->get_clock()->now().nanoseconds() / 1000;
        RCLCPP_INFO(this->get_logger(), "uav1 command: %u, param1=%f, param2=%f, target_system=%u",
                    command, param1, param2, msg.target_system);
        vehicle_command_publisher_uav1_->publish(msg);
    }
};
cpp

第1号无人机(follower)的三个函数与leader同理,只是话题前缀改为 /px4_1/fmu/target_system 改为2,轨迹设定为leader当前位置加上固定偏移量 (2.0, 2.0, 0.0),实现后方右侧伴飞。

int main(int argc, char *argv[])
{
    std::cout << "Starting multi offboard control node..." << std::endl;
    setvbuf(stdout, NULL, _IONBF, BUFSIZ);
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<MultiOffboardControl>());
    rclcpp::shutdown();
    return 0;
}
cpp

最后运行 main() 函数,rclcpp::init 用于初始化ROS2环境,rclcpp::spin 用于运行 multi_offboard_control 节点,最后在程序结束时使用 rclcpp::shutdown 清理环境。整个代码流程结束。

我们按照上一篇文章所述将代码编译成可执行文件并运行,可以发现两架无人机成功按照我们的代码逻辑进行飞行,并保持一定的编队轨迹,证明我们的双无人机编队仿真代码成功运行。以下是仿真视频:

视频 1:双无人机按编队绕定点进行盘旋运动,周期为20秒

总结#

在ROS2中,无人机编队的仿真会更加的容易,主要体现在其对于通信的集成,不需要我们再单独地为每架无人机实例添加通信。但是,在编写实际工程代码时,一些细微的命名规则的混淆往往会造成失败,主要体现在ROS2话题的命名规则、MAVLINK ID的命名规则、以及 target_system 的命名规则。本文参照PX4的官方文档,对这些规则进行了介绍,读者朋友们可以参考和排查。

同时,本文为了简明扼要地介绍多无人机Offboard控制流程,选择了一个简单的leader-follower两机的编队,在实际工程和科学研究中,这是远远不能够满足现实产品需求的。在后续文章中,我们将引入 分布式通信与二阶一致性协议,利用 LMI 方法求解反馈增益矩阵,实现更高鲁棒性的多机编队协同控制。期待读者朋友们的优秀成果。

最后,再次感谢读者朋友们的支持和耐心的阅读,欢迎大家对本文进行批评指正、补充及建议。

参考资料#

ROS2+PX4 多机OFFBOARD模式教程
https://duduuu.xyz/zh/posts/px4-ros2-multi-offboard
Author dudu
Published at 2026年7月10日
阅读
总访问