简介基于ROS2与Gazebo的多机器人协同导航与动态编队仿真平台面向机器人领域研究人员、算法工程师及高校学生集成ROS2通信机制与Gazebo物理引擎用于解决多智能体在复杂动态室内外环境中的自主路径规划、实时避障与队形保持问题并验证分布式控制算法与编队策略。资源共70个文件包括18个launch启动文件、5个world仿真场景、5个xacro机器人描述、7个yaml配置、5个cpp源码、rviz可视化配置及说明文档等压缩包仅1.2MB其中launch负责启动多任务节点world搭建物理仿真环境xacro定义机器人模型yaml配置导航参数cpp实现核心算法目录按ares_description、ares_navigation、ares_gazebo等模块划分结构清晰便于按需检索。已有85人学习下载。平台提供完整的URDF机器人模型、仿真世界、导航配置与遥控脚本覆盖功能包配置、场景搭建、算法实现到可视化全流程研究者可修改参数或自定义编队策略在动态障碍环境中反复测试队形变换与协同导航效果低成本、安全地完成算法评估与学术实验是分布式多机器人系统研究的高效工具。1. 为什么在 ROS2 与 Gazebo 中做多机器人协同仿真而不是直接搭车队基于ROS2与Gazebo的多机器人协同导航与动态编队仿真平台听起来是个很重的名字但它解决的实际问题很朴素在部署真实车队之前用仿真先把多智能体的路径规划、避障和队形保持算法跑通。我们常说的“复杂室内外环境”在仿真里就是不同密度的静态障碍物、动态闯入者和地面特征差异而“分布式机器人系统控制算法验证”落到工程上是两台以上机器人能不能在同一个世界里互不干扰地规划、会不会在窄道死锁、编队误差能否收敛。这个方向最初劝退人的地方不在算法而在环境。单机导航随便找个 turtlebot3 包就能跑一旦进入多机协同命名空间隔离、TF 坐标树、Nav2 多实例、动态障碍的相互感知都会冒出来。这篇文章把所有经验浓缩成一条可复现路径从装环境开始到把两台机器人的编队策略放进 Gazebo 里验证最后告诉你怎么量化“队形保持”到底好不好。2. 从单机导航到多机协同命名空间、坐标树与编队原理2.1 为什么 Gazebo 在多机器人验证里几乎不可替代仿真平台选择是每个项目一开始就要回答的问题。纯算法验证可以跑 Stage、CARLA、甚至 Webots但做多机器人导航与编队Gazebo 依然是大多数人绕不开的默认选项。原因是它同时给三样东西物理引擎、传感器模型和可复现场景。真实车队里最让人头疼的轮子打滑、激光雷达盲区、碰撞体干涉在 Gazebo 里都能用插件模拟出来。很多组队在 Wokwi 这类嵌入式仿真里做过单片机控制但 Wokwi 的目标是 MCU 级逻辑验证它没有连续物理世界也没有 Nav2 这种直接可用的导航栈。Gazebo 配合 ROS2 的成熟生态最大价值是让你把注意力放在策略层不必为建图、定位、运动控制每行都重写。对做算法验证的研究者来说Gazebo 还能通过修改 world 文件和传感器参数生成大量差异性场景这在真实平台上成本极高。2.2 多机系统的最小组成每个机器人要有自己的“世界视图”在进入代码前先要把多机系统的基本组成想清楚。以最常见的差速底盘为例一整套独立的机器人至少要包含URDF/Xacro 模型、Gazebo 插件、传感器驱动节点、里程计来源、Nav2 导航栈、以及 RViz2 观察工具。当你把这一套东西复制成两台机器人时最大的问题不是物理模型而是“命名空间”。在 ROS1 时代大家靠tf_prefix拉开帧和话题到了 ROS2我更习惯直接给每个机器人分配独立命名空间并在 URDF 里为所有 frame_id 加前缀。这样/robot1/odom和/robot2/odom互不覆盖TF 树上的robot1_base_link、robot2_base_link也清清楚楚。有效编程实践中一个命令能够做到xacro:macro namemobile_robot paramsprefix link name${prefix}_base_link visual origin xyz0 0 0 rpy0 0 0/ geometry cylinder radius0.105 length0.06/ /geometry /visual collision origin xyz0 0 0 rpy0 0 0/ geometry cylinder radius0.105 length0.06/ /geometry /collision /link gazebo reference${prefix}_base_link sensor name${prefix}_lidar typeray plugin filenamelibgazebo_ros_ray_sensor.so namegazebo_ros_ray_sensor ros namespace${prefix}/namespace remappingscan:lidar/scan/remapping /ros ... /plugin /sensor /gazebo /xacro:macro这里paramsprefix是关键。同一个宏可以生成 robot1 和 robot2但所有 frame 名称都带前缀Gazebo 插件里的 sensor 话题也会被自动映射到各自命名空间下。注意传感器插件里必须做ros段的重映射否则两台激光雷达会默认发布到同一个scan话题把两侧点云混成一团。2.3 协同导航与编队控制的基础模型领航者-跟随者、虚拟结构、行为法动态编队算法大体有三条路领航者-跟随者、虚拟结构、行为法。在 ROS2 里最常落地的是领航者-跟随者因为它和“分层导航”的天然结构最匹配领航者负责全局路径跟随者把“与领航者保持相对位姿”转换成自己的目标点然后继续交给 Nav2 去执行。虚拟结构适合所有人一起前进比如一组机器人保持八卦阵或菱形队形队形整体移动时每个机器人都有明确的参考点。优点是队形误差直观缺点是障碍物割裂队形时恢复慢。行为法则把避障、保队形、追踪目标写成加权行为灵活性最高但调参最玄学。在仿真平台上验证编队策略通常不需要一开始就把三者都实现而是先跑通领航者-跟随者再逐步叠加队形切换逻辑。导航层已经由 Nav2 完成了编队层要做的只是控制目标点生成频率以及决定什么时候把队形约束让位于避障约束。2.4 室内外复杂环境建模时的差异化处理标题里的“复杂室内外环境”不是空话。室内环境问题主要是过道宽度、门框、密集障碍物室外环境则是开放空间带来的定位退化以及地面摩擦、坡度带来的运动模型差异。Gazebo 里实现这两类环境要准备两份 world 文件室内用 0.2 米分辨率小网格代价地图室外用更稀疏的障碍物并增加激光雷达最大探测距离。很多新手会把室内外的地图直接混用结果机器人在“室外”里因为看不到边界而迷失或者在“室内”里因为开放空间太少而频繁重新规划。我一般会在 world 里明确划分区域室内段布置墙壁和高密度柱状障碍室外段布置稀疏的车辆模型和缓坡再加入一两个动态行人模型用来触发编队避障策略。3. 搭建多机器人仿真环境Gazebo 世界、初始化位姿与 Nav2 启动3.1 版本选型Ubuntu 22.04 ROS2 Humble Gazebo FortressROS2 与 Gazebo 的版本搭配决定了整个项目起跑线。目前最稳的组合是 Ubuntu 22.04、ROS2 Humble、Gazebo Fortress。Humble 是长期维护版本社区资料最多Fortress 也是 Gazebo classic 时代后期比较成熟的发行版兼容性比 Ignition 早期版本好得多。安装时建议把桌面版和导航相关包一起装上避免后面反复补包sudo apt update sudo apt install ros-humble-desktop sudo apt install ros-humble-gazebo-ros-pkgs sudo apt install ros-humble-nav2-bringup sudo apt install ros-humble-turtlebot3-gazebo sudo apt install ros-humble-robot-state-publisher这里ros-humble-nav2-bringup是必须的它提供 Nav2 的标准启动入口。turtlebot3-gazebo虽然不是编队平台的核心但它包含现成的 TurtleBot3 模型和世界适合用来做第一版冒烟测试。装完后把 TurtleBot3 需要的环境变量写进~/.bashrcecho export TURTLEBOT3_MODELburger ~/.bashrc echo export GAZEBO_MODEL_PATH$GAZEBO_MODEL_PATH:/opt/ros/humble/share/turtlebot3_gazebo/models ~/.bashrc注意 GAZEBO_MODEL_PATH 必须保留原有内容否则 Gazebo 会找不到其它模型库。安装完成后可以用ros2 pkg list | grep gazebo_ros快速确认插件是否装齐只要能看到gazebo_ros、gazebo_plugins和gazebo_ros_pkgs就说明基础环境正常。3.2 创建多机器人世界两个机器人不能挤在一起出生多机器人仿真的世界文件可以直接复用 turtlebot3_world但机器人出生点必须分开。我习惯先写一个最小 launch 文件把 Gazebo 世界启动、两套 robot_state_publisher、两次 spawn_entity 放到同一个流程里from launch import LaunchDescription from launch.actions import IncludeLaunchDescription from launch.launch_description_sources import PythonLaunchDescriptionSource from launch_ros.actions import Node from ament_index_python.packages import get_package_share_directory def generate_launch_description(): gazebo_launch IncludeLaunchDescription( PythonLaunchDescriptionSource( /opt/ros/humble/share/gazebo_ros/launch/gazebo.launch.py ), launch_arguments{world: worlds/multi_robot_world.world, verbose: true}.items() ) robot1_state Node( packagerobot_state_publisher, executablerobot_state_publisher, namespacerobot1, parameters[{robot_description: robot1_xacro, frame_prefix: robot1_}] ) robot1_spawn Node( packagegazebo_ros, executablespawn_entity.py, arguments[-entity, robot1, -topic, robot1_description, -x, 0.0, -y, 0.0, -z, 0.3], outputscreen ) robot2_state Node( packagerobot_state_publisher, executablerobot_state_publisher, namespacerobot2, parameters[{robot_description: robot2_xacro, frame_prefix: robot2_}] ) robot2_spawn Node( packagegazebo_ros, executablespawn_entity.py, arguments[-entity, robot2, -topic, robot2_description, -x, 2.0, -y, 1.5, -z, 0.3], outputscreen ) return LaunchDescription([ gazebo_launch, robot1_state, robot1_spawn, robot2_state, robot2_spawn, ])这段代码的逻辑是先启动一个共享世界再分别发布两台机器人的描述最后用spawn_entity.py把机器人实体放进 Gazebo。出生点取了(0,0)和(2,1.5)之间的距离超过车体直径避免初始碰撞。-topic robot1_description这里读的是 robot_state_publisher 发布到各自命名空间的描述数据。如果你的模型是纯 SDF也可以把-topic换成-file指向本地 SDF 文件但对带关节和传感器的复杂模型我更推荐统一走 robot_description。3.3 用 Nav2 为每台机器人独立导航命名空间与参数文件Nav2 的bringup_launch.py支持传入 namespace所以两台机器人可以共用一个地图但各自维护代价地图和规划器。先分别启动 AMCL 和 Nav2 是最容易排查的路径ros2 launch nav2_bringup bringup_launch.py \ namespace:robot1 \ map:/path/to/map.yaml \ use_sim_time:True \ autostart:True ros2 launch nav2_bringup bringup_launch.py \ namespace:robot2 \ map:/path/to/map.yaml \ use_sim_time:True \ autostart:True这里use_sim_time:True是必须项它让所有导航节点使用 Gazebo 的仿真时钟而不是系统墙钟。若跳过这项代价地图和规划器会出现“transform timeout”表现是 RViz 里机器人莫名其妙漂移。两台机器人共用一张静态地图没问题但代价地图里的障碍物信息来自各自的scan话题因此 robot2 会被 robot1 的激光雷达识别为动态障碍反之亦然。这个行为在前期是有用的它能防止两台机器人真的撞上但到编队阶段这种“互相排斥”又会变成队形震荡的根源。后面第 5 章详细讲怎么处理。3.4 用 RViz2 同时观察两台机器人固定坐标系与显示开关RViz2 不启动也能导航但调试时看不到代价地图和路径就很难受。多机器人的 RViz2 配置第一件事是把 Fixed Frame 设成world而不是默认的map。因为 Gazebo 世界是共享的world是所有坐标系的最顶层。在 RViz2 里添加两条 Display第一条 TF 选择 robot1 前缀的帧第二条选择 robot2 前缀的帧。它们的 Map 和 Path 话题也要分别选/robot1/map、/robot1/plan、/robot2/map、/robot2/plan。直接复制同一个 RViz 配置文件再批量替换前缀通常比手动添加更快。启动 RViz2 后如果只看到机器人模型但看不到 LaserScan说明 scan 话题的 namespace 设置出了问题。Gazebo 传感器插件发布的是/robot1/lidar/scan还是/robot1/scan取决于你 URDF 里的 remapping 写法Nav2 节点的scan_topic参数必须与之一致。4. 编队保持与动态避障的实现领航者-跟随者代码和参数调整4.1 领航者-跟随者的最小代码把领航者的位姿换算成跟随者的目标Nav2 已经解决了单机的全局路径规划和局部避障编队层的任务就是为跟随者持续计算目标点。最直接的做法是订阅领航者的amcl_pose把编队偏移量旋转到领航者的当前朝向上再用 Nav2 的 action client 把结果发给跟随者import math import rclpy from rclpy.node import Node from rclpy.action import ActionClient from geometry_msgs.msg import PoseStamped from nav2_msgs.action import NavigateToPose from std_msgs.msg import Header class LeaderFollower(Node): def __init__(self): super().__init__(leader_follower) self.sub self.create_subscription( PoseStamped, /robot1/amcl_pose, self.leader_cb, 10) self.client ActionClient( self, NavigateToPose, /robot2/navigate_to_pose) # 跟随点在领航者坐标系下的相对位置右后方 1.2 米 self.offset_x -1.0 self.offset_y -1.2 def leader_cb(self, msg): yaw self._quat_to_yaw(msg.pose.orientation) cos_yaw math.cos(yaw) sin_yaw math.sin(yaw) # 把编队偏移从领航者局部坐标转到世界坐标 wx msg.pose.position.x self.offset_x * cos_yaw - self.offset_y * sin_yaw wy msg.pose.position.y self.offset_x * sin_yaw self.offset_y * cos_yaw goal PoseStamped() goal.header Header() goal.header.stamp self.get_clock().now().to_msg() goal.header.frame_id map goal.pose.position.x wx goal.pose.position.y wy goal.pose.orientation.z math.sin(yaw / 2) goal.pose.orientation.w math.cos(yaw / 2) nav_goal NavigateToPose.Goal() nav_goal.pose goal self.client.send_goal_async(nav_goal) def _quat_to_yaw(self, q): return math.atan2(2 * (q.w * q.z q.x * q.y), 1 - 2 * (q.y * q.y q.z * q.z)) def main(): rclpy.init() node LeaderFollower() rclpy.spin(node) if __name__ __main__: main()这段代码需要你在setup.py里把文件注册为可执行节点然后colcon build后执行。这里的send_goal_async不会阻塞主循环所以编队控制器能以 10Hz 以上频率持续发送目标点。但有个细节要特别说如果直接以 10Hz 频率给 Nav2 发新目标Nav2 会不断取消上一目标并重新规划表现为跟随者左右剧烈摇摆。实际工程里我会加一个更新门槛只有当新目标与机器人当前位置的距离差超过 0.15 米或者领航者航向变化超过 5 度才发送下一目标。这能让行为树的“重新规划”次数大幅下降。4.2 避障时的队形优先级目标点被占据怎么办编队保持与动态避障的冲突点在窄道场景里尤其明显。跟随者计算出的编队目标点可能落在障碍物中间Nav2 会回答“路径不可达”然后行为树不停重试。常见的兜底策略是“队形降级”先判断目标点附近的代价地图栅格值若超过安全阈值就把跟随者目标点改为领航者当前状态也就是临时退化为追尾模式def is_target_blocked(self, x, y): # 从 /robot2/local_costmap/costmap 获取代价这里假设 costmap 已缓存 cost self.costmap.get_cost_at_xy(x, y) return cost 60 # 60 以上表示接近障碍物或膨胀区我不喜欢在编队代码里直接读 Nav2 的代价地图因为这把耦合变高了。更稳的做法是订阅/robot2/global_costmap/costmap把栅格数组转成二维 byte 数组查询目标点对应的 cost。这样既能实现“目标被占用就降级”又不会破坏 Nav2 内部状态。还有一个常见坑是队形切换时领航者转弯太快跟随者的路径出现大回环。这种现象的根源是跟随目标点计算只用了领航者当前位姿没有预测未来位置。把领航者近 0.5 秒的速度加速度考虑进来跟随者的路径会平滑很多。具体做法是在订阅回调里保留最近十帧位姿用最小二乘拟合领航者速度再把速度项加进 offset 方程。4.3 影响队形质量的 Nav2 参数从速度上限到膨胀半径同样的编队算法放在默认 Nav2 参数和调过的参数下效果差得很远。下面是几个我每次做编队必调的参数全部在 nav2_params.yaml 里修改参数推荐值影响max_vel_x0.5限制编队最高速度太快会让小误差被放大max_vel_theta1.0转弯太快会造成跟随者内切acc_lim_x0.3加减速太突兀会拉大编队误差xy_goal_tolerance0.2过大导致编队位置歪过小导致无法收敛yaw_goal_tolerance0.1影响编队朝向同步inflation_radius0.3决定动态障碍周围的安全带太大会挡住整个窄道cost_scaling_factor3.0小则膨胀梯度平缓适合编队绕障DWA 局部规划器里max_vel_x必须小于 1.0因为多机器人世界里考虑到通讯延迟高速运行的跟随者往往在收到编队目标时已经冲过头了。xy_goal_tolerance设成 0.2 米看起来很大但 Nav2 的行为树是以“到达容忍半径”作为成功条件设置太严格会让跟随者绕着最终目标点反复画圈造成调试时误以为队形发散。4.4 用 rosbag 记录编队误差用数据而不是画面评估结果编队保持好不好单看 RViz2 画面会骗人。我通常同时录制这几路话题仿真结束后统一回放ros2 bag record -o formation_test \ /robot1/amcl_pose \ /robot2/amcl_pose \ /robot1/local_costmap/costmap \ /robot2/local_costmap/costmap \ /robot1/odom \ /robot2/odom \ /tf录制结束后把 rosbag 转成 CSV再用 Python 计算编队误差序列效率比一节节看录像高得多。评估时看两个指标编队误差均值、最大误差波动幅度。只要跟随者稳态误差小于 0.2 米且没有周期性抖动这个编队策略就算能拿出手。5. 避坑指南多机器人协同仿真最容易翻车的六个细节5.1 画面里只有一台机器人另一台莫名消失或死掉现象Gazebo 启动后RViz2 只看到 robot1robot2 没有任何话题传来Gazebo 图形界面里 robot2 要么没出现要么出现后立刻下坠。原因最常见是 spawn 参数写错或出生点高度太低。robot2 的-z 0.3是相对于世界原点的高度如果模型碰撞体最低点低于地面Gazebo 物理引擎会把它压进地面然后反跳甚至直接禁用碰撞。另一个原因是两台机器人用了同一个-entity名字后启动的会覆盖先启动的模型。解决给每台机器人分配自己的-entity名例如 robot1、robot2。出生点 z 值设为 0.3 而不是 0.1TurtleBot3 这种底部自带轮子的模型需要额外抬高一点。启动后先执行ros2 topic list | grep /robot2确认话题存在再打开 Gazebo 左下角模型树检查实体是否加载。5.2 TF 报错“Frame robot2_base_link not found”或机器人在地图上漂移现象RViz2 里 robot2 的模型显示在 Gazebo 相同位置但 Nav2 的全局规划器一直不给 robot2 规划路径终端刷 “Transform cache timeout”。原因多机器人系统最容易出问题的地方是 TF 树。robot2_state_publisher如果没设置frame_prefix它广播的是base_link而不是robot2_base_linkNav2 在 map 到 odom 的变换里找到了错误的帧就会卡在等待 TF。use_sim_time没开也会造成时间戳不同步但表现是“过期”而不是“缺失”。解决第二步在robot2_state_publisher参数里加frame_prefix: robot2_同时确保每台 Nav2 的参数文件里 odom topic 是/robot2/odom。用ros2 run tf2_tools view_frames生成 TF 树如果看到robot2_base_link没挂在 map 下说明 AMCL 或 odom 源没配对。5.3 扫描话题名对不上Nav2 总说“No laser scan received”现象RViz2 能显示激光点云但 Nav2 没有任何反应终端提示收到 0 scan 数据。原因Gazebo ray sensor 插件在namespacerobot2下会把 scan 发布到/robot2/lidar/scan但 Nav2 参数文件里的scan_topic还写的是默认的scan。点云显示来自 raw topicNav2 订阅的是另一个话题两边自然对不上。解决统一扫描话题重映射。我在 URDF 里把scan重映射成lidar/scan然后在 nav2_params.yaml 把scan_topic写成/robot2/lidar/scan。如果你看到/robot2/scan存在还要检查是否多了一层 namespaceGazebo 插件里的namespace和 URDF 里的 prefix 不能重复加。5.4 两台机器人同时过窄道时互锁谁也不给谁让路现象领航者和跟随者进入同一走廊robot1 在前robot2 在后两车都停住代价地图反复出现红色障碍块路径被重新规划成回头路。原因Nav2 局部代价地图把对方机器人当作动态障碍物双方都在等一个不可能出现的空隙。这是多机器人协同最典型的“孤立式避障”问题单机避障能力越强协同死锁越严重。解决在编队模式下把匹配的编队成员从局部代价地图的 obstacle layer 里过滤掉或者给那个区域加一个临时“车辆编号”标签让局部规划器忽略同队车辆。成本最低的做法是如果机器人间距小于 0.5 米让跟随者关闭局部代价地图的动态障碍层只依赖全局代价地图避障。代价是可能撞到动态行人但至少编队不会卡死。真正稳健的方案是引入协调速度层在 Nav2 之上再加一个速度协商节点确保走廊里只有一台机器人拥有通行权。5.5 Gazebo 屏幕闪烁或 GPU 占用过高仿真越跑越卡现象在虚拟机或低配显卡上启动 Gazebo窗口闪烁、画面撕裂运行几分钟后整个系统卡死。原因虚拟机没有开启 3D 加速Gazebo 的渲染线程反复重绘导致画面闪烁同时多个 ray sensor 高频更新会占满 CPU。这个问题在热词“vmware打开gazebo屏幕闪烁怎么办”里被反复问本质都是图形加速配置问题。解决如果不需要看图形界面直接关闭 Gazebo client只跑 server。启动命令改成gzserver world文件或把 launch 里gzclient的节点注释掉。如果必须看界面在虚拟机的显示设置里打开 3D 加速并给 Gazebo 设置环境变量export LIBGL_ALWAYS_SOFTWARE1强制软渲染虽然帧率下降但不再闪烁。仿真中减少激光雷达的update_rate从 10Hz 降到 5Hz对编队验证的影响很小却能省下大量 CPU。5.6 跟随者目标点频繁切换车头左右震荡现象领航者转弯时跟随者没有走平滑弧线而是原地转向、左右摆动队形误差反而变大。原因编队控制器以过高频率发送新目标Nav2 每次收到新目标都会取消当前目标并重新规划。规划频率一高DWA 控制器在多个候选轨迹间反复切换自然表现为车头震荡。解决在编队节点里加入目标更新门槛只有目标点位移超过 0.15 米或航向变化超过 5 度才发送。另一个做法是给 Nav2 的目标 topic 加一层蓄水池用滑动窗口平均。两法我都试过前者简单有效后者适合需要更高平滑度的人。调完参数后看 rosbag震荡频率会明显下降。6. 验证仿真结果的天花板用 rosbag 数据说话仿真跑到第五版你会发现“能走通”不等于“算法有效”。我现在的验证流程里最后一步永远是回到数据上。三个手段最值得保留第一录制 rosbag 并计算轨迹误差指标第二多次随机化初始障碍物位置确认队形误差不会受轻微环境变化影响第三把每一次实验的启动参数、地图版本、编队偏移量、Nav2 参数全部写进一个exp_config.yaml避免黑匣子式复现。轨迹误差的计算可以很简单import pandas as pd leader pd.read_csv(leader_odom.csv) follower pd.read_csv(follower_odom.csv) dx leader[x] - follower[x] dy leader[y] - follower[y] formation_error (dx - 1.0) ** 2 (dy - 1.2) ** 2 print(平均误差:, formation_error.mean()) print(最大波动:, formation_error.max() - formation_error.min())这段代码的核心是把“编队保持”从感官判断变成可量化指标。仿真平台存在的意义是让你在真实车队上线前就避开最昂贵的问题所以每一次调参都要留下对应 bag 和配置否则失败了也说不清是哪一层导致的。我最常做的一个收尾动作是跑完一个场景后强制自己只留 bag 文件和exp_config.yaml关掉 RViz2 重新描述刚才的队形误差均值。如果描述不出来说明这次实验的结果我还不能完全理解那就需要回到仿真里把参数和话题链路再核对一遍。希望这个习惯也能帮到你少走弯路。本文还有配套的精品资源点击获取