简介:基于ROS的多机器人仿真(导航、编队)资源包面向机器人领域初学者与进阶学习者,适用于毕业设计、课程设计、工程实训或初期项目立项,旨在帮助用户从零搭建多机器人协同导航与编队仿真环境,并理解相关核心原理。压缩包内共包含七十个文件,以启动脚本、参数配置、模型描述、仿真场景和C++源码等为主,整体体积约一点二兆字节,目录按照机器人描述、导航配置、仿真世界等模块划分,便于按需查阅。资源不仅提供了完整的多机器人仿真系统,还包含可视化调试配置、地图文件以及多机器人启动脚本,可支撑从环境建模、传感器配置到导航编队算法的全流程实践,帮助读者快速上手并二次开发。这套资料已有三百二十九人学习下载,是经过检验的初学参考,适合作为课程项目或毕设的起步基础。
1. 多机器人仿真:能把两辆车放一起跑起来,才算真正碰过导航
我也接过不少 ROS 项目,最常被人问的一句话是:“我在单机上导航一直都能走,为什么两台车一起跑,在路口碰面就互相堵死、原地转圈,谁也走不动?”这其实已经超出了导航本身,属于多机器人协同的工程策略问题。基于 ROS 做多机器人仿真,核心价值就是把真实的坐标系冲突、控制时序、代价地图抢占这些问题提前暴露在 Gazebo 里,而不是等真车上了产线才翻车。常见做法是给每个机器人一套独立的命名空间,再叠一层编队决策,把“团队目的地”翻译成每个机器人自己的局部目标。这篇文章就围绕导航和编队这两块,从环境搭建讲到参数调优,再给出能直接照着抄的踩坑清单。适合正从单机导航跨向多机协同的 ROS 开发者,也适合被仿真性能折腾到怀疑人生的朋友。
2. 多机仿真底盘怎么搭:命名空间隔离、URDF 加载与三车同时启动
2.1 先画清楚三台机器人的 ROS 拓扑边界
多机器人仿真的第一原则是:除了共享地图和全局规划,其他所有节点都要能互相独立。我习惯把每台机器人包进一个 group 命名空间,例如robot_0、robot_1、robot_2,这样/robot_1/odom、/robot_1/cmd_vel、/robot_1/amcl自然就分开了,话题不会串。TF 树也要独立,base_link、odom、map这三级坐标在命名空间隔离后,不会出现两个机器人抢同一个map -> odom变换的情况。
这里有一个容易糊涂的点:map这个坐标系到底归谁管?实际上,所谓多机器人导航,并不是每个人都要维护一份map,而是只让一个全局 map_server 发布/map,每台机器人各自用自己的 AMCL 去估map -> odom。这个结构决定了后续所有参数的配法。
# 查看当前系统的命名空间和话题分布 rostopic list | grep robot_单机项目里通常不会刻意关心 namespace,因为只有一台车,全局话题就够用。但对多机来说,不做 namespace 隔离基本寸步难行。
2.2 用差速驱动 URDF 作为默认模型,绕开复杂动力学
我用的机器人模型不一定要复杂,一个带两个驱动轮、一个转向支撑轮的差速底盘就够了。在仿真里,导航和编队关注的是底盘响应,不是悬挂避震。URDF 里需要写清楚<gazebo>插件,把差速传动转换成nav_msgs/Odometry输出。平时可以直接拿现成的差速 URDF 改一改,重点改这几项:
| URDF 元素 | 推荐取值 | 说明 |
|---|---|---|
| wheel_joint | 连续旋转关节 | 给<limit velocity="1.5" effort="10"/> |
| max_steering_angle | 无 | 差速模型没有转向关节 |
| robot_mass | 2 kg | 太轻容易飘,太重启动慢 |
| wheel_friction | 1000 | 防止仿真里打滑空转 |
加载模型的 launch 片段通常长这样:
<launch> <arg name="robot_id" default="0" /> <group ns="robot_$(arg robot_id)"> <param name="robot_description" command="$(find robot_desc)/urdf/diff_wheel.urdf" /> <node name="robot_state_publisher" pkg="robot_state_publisher" type="robot_state_publisher" /> </group> </launch>这段代码的关键不是加载脚本,而是ns参数。机器人的所有 TF 和 topic 都被装进robot_0这个私有空间,后面robot_1即使复制一份也同样成立。robot_state_publisher负责把关节角换算成 TF,差速模型下它输出的/robot_x/odom就是导航定位用的主要观测。
2.3 一键拉起三台车:launch 文件编排的几个准则
写多机启动脚本时,我不会一个机器人写一个 launch,而是写一个总控 launch,用循环或 include 组合。Gazebo 端需要往 world 里 spawn 三台车,分别指定初始x、y坐标,避免刚启动就撞在一起。
# 一次性启动三台机器人的常用命令,robot_num 控制数量 roslaunch multi_robot_sim sim.launch robot_num:=3对应总控 launch 大概这样:
<launch> <arg name="robot_num" default="3" /> <arg name="world" default="$(find multi_robot_sim)/worlds/warehouse.world" /> <node name="gazebo" pkg="gazebo_ros" type="gazebo" args="$(arg world)" output="screen" /> <group ns="robot_0"> <include file="$(find multi_robot_sim)/launch/robot_bringup.launch"> <arg name="robot_id" value="0" /> <arg name="x" value="0.0" /> <arg name="y" value="0.0" /> </include> </group> <group ns="robot_1"> <include file="$(find multi_robot_sim)/launch/robot_bringup.launch"> <arg name="robot_id" value="1" /> <arg name="x" value="-1.0" /> <arg name="y" value="0.0" /> </include> </group> </launch>这段编排解决了最麻烦的启动顺序问题:先启动 gazebo,再 spawn 模型,最后拉起每个机器人自己的状态发布和底盘控制。如果你的仿真经常出现某台车“飘在天空”或“陷进地面”,多半是 spawn 时机和世界物理加载冲突。可以加一个rospy.wait_for_service("/gazebo/spawn_urdf")的前置检查,再继续往下走。
2.4 第一轮验证:三台车能否独立收到控制指令
模型加载完成后先别急着上导航,先做一次最基础的控制下放。我习惯给三台车分别发布不同的/cmd_vel,看 Gazebo 里它们是否按各自指令运动。注意,这三条话题都带 namespace,不能用原来的rostopic pub -r 10 /cmd_vel直接发,要写成:
rostopic pub -r 5 /robot_0/cmd_vel geometry_msgs/Twist \ "{linear: {x: 0.2}, angular: {z: 0.0}}"如果这一条能生效,表示每台车的底盘驱动、Gazebo 物理同步和 TF 链路都是通的。很多多机器人项目一上来自顾自跑导航,最后发现队形乱了,排查半天才意识到,最底层的每一台车本身就没能独立响应控制。这一步我每次都做,绝不跳过。
3. 导航三件套在车队里的正确打开方式:共享地图加独立定位
3.1 静态地图一次生成,车队直接复用
多机器人导航的第一前提是环境地图一致。单机建图很容易,但三台机器人同时建图,容易因为抢占、漂移等因素互相干扰。更稳的做法是先用一台车手动走一遍,把静态栅格地图保存下来,再让所有机器人加载同一张/map。仿真环境地图是固定场景,不必担心动态变化,那点时间成本非常划算。
# 单机建图后保存地图,常见保存命令 rosrun map_server map_saver -f warehouse_map生成的文件包括warehouse_map.pgm和warehouse_map.yaml。后续多机方案中,全局只用这一个 map_server 节点,发布到/map话题即可。这里注意,不同机器人如果各自启动一个 map_server,虽然也能运行,但全局规划信息就会分叉,A 车认为左边是墙,B 车认为右边是墙,编队就会变成“各说各话”。
3.2 move_base 多实例参数:全局代价地图必须共享
很多人在多机导航时拖着一堆 move_base 直接跑,然后发现所有机器人的全局路径都在地图上半部分重叠,局部又互不避让。问题出在参数配置上。我通常会让全局 costmap 共享静态层,也就是同一个/map来源,而局部 costmap 单独维护障碍物层,这样既能保证全局信息同步,又能让每台车对附近动态障碍有自己的反应。
# robot_1_local_costmap.yaml global_frame: map robot_base_frame: robot_1/base_link update_frequency: 5.0 publish_frequency: 2.0 static_map: false rolling_window: true width: 4.0 height: 4.0 resolution: 0.05 plugins: - {name: obstacle_layer, type: "costmap_2d::ObstacleLayer"} - {name: inflation_layer, type: "costmap_2d::InflationLayer"}请留意robot_base_frame,这里必须写带命名空间的坐标系。很多人直接套用单机配置,把base_link写死,结果换到robot_2上整张局部代价地图就跟着乱飘。update_frequency在这个场景建议调 5.0 左右,太高会让多车辆仿真负载明显上涨,太低又容易把其他机器人和障碍物漏掉。
全局 costmap 配置稍微不同,它是固定地图,不需要滚动窗口,直接用 map_server 提供的静态层:
# robot_1_global_costmap.yaml global_frame: map robot_base_frame: robot_1/base_link update_frequency: 1.0 publish_frequency: 1.0 static_map: true plugins: - {name: static_layer, type: "costmap_2d::StaticLayer"} - {name: inflation_layer, type: "costmap_2d::InflationLayer"}3.3 AMCL 三车同时定位:初始位姿不能偷懒
AMCL 是自适应的蒙特卡洛定位,单机使用时随便给一个大致初始位姿,很快就能收敛。但多机场景,尤其是仿真里多台机器人同时启动的阶段,如果初始位姿乱设,收敛期就可能互相把粒子算到对方身上。我给每台机器人的initial_pose_x、initial_pose_y都是严格对齐到 spawn 坐标的。
<node pkg="amcl" type="amcl" name="amcl" output="screen"> <param name="initial_pose_x" value="0.0" /> <param name="initial_pose_y" value="0.0" /> <param name="initial_pose_a" value="0.0" /> </node>注意,不同机器人的坐标值不同,robot_2 要填-1.0对应的那个位置,绝对不能复制粘贴。仿真环境里如果机器人发生了瞬时碰撞而被弹飞,AMCL 的粒子云容易发散,启动阶段尽量避免三台车靠太近。你可以等每台车都输出了稳定的/robot_x/amcl_pose,再开始规划。
3.4 在 rviz 里同时观察三车导航状态
单机调试只看一台车的 Path 和 Costmap,但多机调试必须一次看到所有车。Rviz 里取消勾选 “Fixed Frame” 之外的死板设置,直接将全局坐标系固定到map。然后同时添加三个Path显示,分别订阅/robot_0/move_base/global_plan、/robot_1/move_base/global_plan、/robot_2/move_base/global_plan。这样一眼就能看出编队或导航是否各自为政。
若发现某台车的路径明显穿墙,先检查它的全局代价地图是否真的引用了共享/map。如果路径抖动,怀疑 AMCL 粒子收敛不够,就把amcl里的min_particles提高一些,比如从默认 100 改为 500,这会牺牲一点 CPU,但收敛更稳定。
4. 多机器人编队:领航-跟随模型与最小可用的编队控制节点
4.1 为什么选领航-跟随,而不是集中式规划
编队在 ROS 里没有银弹,最常见的落地套路是两种:一个是形成了全局优化问题,所有机器人实时求解,但计算代价高;另一个是领航-跟随模式。实际项目里我给 90% 的客户推荐领航-跟随,理由很简单:它把多机器人协同拆成了“与车队的几何关系”,每个跟随者只需要知道领航者的位姿和自己期望的相对位置,不依赖跨机器的全局通信,非常适合起步阶段。仿真环境里,我们完全可以让领航者跑一条正常的目标点轨迹,跟随者通过订阅领航者位姿来推算自己的导航目标。
领航-跟随模型需要定义三个量:
- 前视距离
follow_dist,即跟随者期望与领航者保持的前后距离。 - 横向偏置
lateral_offset,即左或右错开的距离。 - 车体朝向
leader_yaw,假设各车最终朝向一致。
4.2 编队控制节点的代码实现:把位姿换算成 move_base 目标点
我写编队控制节点时,最简单可靠的做法是订阅领航者的Odometry,然后把它的位置加上一个固定偏移量,再发布到跟随者的move_base/goal。这样跟随者本身仍然用 move_base 完成局部路径规划,不会出现两套控制逻辑打架的情况。
#!/usr/bin/env python3 import rospy import math import tf from nav_msgs.msg import Odometry from geometry_msgs.msg import PoseStamped from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal import actionlib class LeaderFollower: def __init__(self, leader_ns, follower_ns): self.leader_ns = leader_ns self.follower_ns = follower_ns self.follow_dist = rospy.get_param("~follow_dist", 0.7) self.lateral_offset = rospy.get_param("~lateral_offset", 0.6) self.leader_pose = None self.client = actionlib.SimpleActionClient( follower_ns + "/move_base", MoveBaseAction) rospy.Subscriber(leader_ns + "/odom", Odometry, self.leader_cb) self.client.wait_for_server() def leader_cb(self, msg): # 只更新最近一次领航者位姿,不在这里反复发目标 self.leader_pose = msg.pose.pose def goal_from_leader(self): if self.leader_pose is None: return None yaw = tf.transformations.euler_from_quaternion([ self.leader_pose.orientation.x, self.leader_pose.orientation.y, self.leader_pose.orientation.z, self.leader_pose.orientation.w])[2] goal = PoseStamped() goal.header.frame_id = "map" goal.header.stamp = rospy.Time.now() goal.pose.orientation.w = 1.0 # 关键:把领航者位姿平移到跟随者期望位置 goal.pose.position.x = self.leader_pose.position.x \ - self.follow_dist * math.cos(yaw) \ + self.lateral_offset * math.cos(yaw + math.pi/2) goal.pose.position.y = self.leader_pose.position.y \ - self.follow_dist * math.sin(yaw) \ + self.lateral_offset * math.sin(yaw + math.pi/2) return goal def run(self): rate = rospy.Rate(5) while not rospy.is_shutdown(): goal_msg = MoveBaseGoal() goal_msg.target_pose = self.goal_from_leader() if goal_msg.target_pose: self.client.send_goal(goal_msg) rate.sleep() if __name__ == "__main__": rospy.init_node("formation_controller") controller = LeaderFollower("/robot_0", "/robot_1") controller.run()先说设计动机:跟随者发目标时,我没有直接用领航者的坐标原封不动丢给 move_base,而是基于领航者的朝向做坐标变换,这样编队队形能跟着领航者拐弯,否则车队一转弯,跟随者就挤成一团。follow_dist和lateral_offset两个参数分别控制纵向和横向间距,是编队最直接的手感来源。频率 5Hz 足够,太高会让 move_base 每轮都重查一遍全局路径,CPU 承受不住。
上面代码默认跟随者还依赖 move_base 的“导航到点”逻辑,如果设置的运动速度上限过高,车会冲过头然后不断回头修正。更稳妥的做法是把跟随者最大线速度限制在领航者的 70% 左右,例如领航者max_vel_x=0.5,跟随者设0.35,这样不会因为追击过猛而反复绕圈。
4.3 编队启动的顺序和队形恢复
启动编队前先让领航者走到一段稳定直道,再启动跟随者控制器,比三台车同时从静止状态下开始更容易收敛。队形被打乱时,最简单可靠的办法是停止所有机器人的新目标,让跟随者原地旋转 180°,先回到领航者后方,再重新进入编队状态。这个动作可以手工用一个rostopic pub /robot_1/cmd_vel从脚本控制,也可以写进控制节点里。
编队恢复过程中,跟随者把领航者看作障碍物,这是最常见的一种问题。默认 costmap 会把所有扫描到的东西都标为障碍,队形一乱,跟随者可能会离领航者很近,然后 move_base 规划出一个绕行轨迹,越绕越远。解决办法是在跟随者的obstacle_layer里给领航者的 frame 加一个黑名单,直接把领航者的点云过滤掉,或者简单地把队内机器人之间的最小距离预测放宽:
obstacle_layer: observation_sources: laser laser: data_type: LaserScan clearing: true marking: true如果编队队形干扰很频繁,可以改用Costmap2D的obstacle_range参数,把检测范围从 6 米降到 2.5 米。这个范围能保证绕开固定墙,同时减少把前面队友当成障碍的误判。
5. 多机器人仿真里的避坑手册:五种高频翻车现象与排查路径
5.1 TF 时间戳错乱,跟随者像得了帕金森
现象:跟随者车身抖动,速度指令来回翻转,控制台刷大量"Lookup would require extrapolation into the past"错误,机器人轨迹呈锯齿状,有时还会突然冲向一侧。
原因:最典型的是use_sim_time设置不统一。多机仿真必须用 Gazebo 里的仿真时间,如果某个节点没有从参数服务器读取/use_sim_time=True,它拿到的时间就是独立于仿真步进的真实墙钟时间,TF 查找时出现时间轴错位。
解决:保证 gazebo、move_base、AMCL、所有底盘节点都读同一个仿真时钟。总控 launch 里显式写下来:
<param name="/use_sim_time" value="true" />然后逐个节点检查,如果发现某条话题时间戳比 TF buffer 晚,可以调大robot_state_publisher的tf_prefix配合,或者把AMCL的transform_tolerance从默认 0.1 调到 0.2 秒。
5.2 全局代价地图出现幽灵障碍物
现象:机器人导航时明明眼前是空地,局部代价地图却显示有一片不可通行区域,路径规划的起点都被膨化到了一个奇怪的角度。
原因:当多台机器人都把/map作为全局静态层时,如果其中一台的robot_base_frame配置写错了命名空间,它加进 costmap 坐标系就歪了,代价地图会把其他机器人的位置当成固定障碍。或者 LASER 扫描把一个本不该存在的点云投影到了 map 坐标。
解决:把global_costmap和local_costmap分开写,检查其中的robot_base_frame是否包含正确前缀。如果仍然是幽灵障碍,用下面命令把激光话题单独打印一遍点云数量,确认是否每个 frame 都对应正确的车编号。
rostopic echo -n 5 /robot_1/scan --noarr看到header.frame_id那栏,如果写的是robot_1/base_link之外的坐标系,就要修正 URDF 里传感器坐标系的名字。
5.3 仿真负载过高,cmd_vel 丢帧导致编队失控
现象:三台车运行时,FPS 从 300 掉到 60,Gazebo 里小车移动卡顿,但靠后启动的一台始终跟不上队形,甚至会一直原地踏步。
原因:多车共同运行,Gazebo 物理步长和传感器回调都在抢 CPU。尤其激光雷达仿真特别费资源,如果三台车都开高分辨率三维激光,那负载直接就爆了。
解决:把仿真中的激光雷达换成单线激光,并降低update_rate到 10Hz。同时给每台车的cmd_vel订阅设置queue_size=1,这个参数别看小,它保证了控制指令是最新一帧,而不是排队积压到 5 帧前。代价地图的update_frequency也统一降到 5Hz 以下,这是我在三车仿真里试到过的性能与实时性的平衡点。
5.4 导航编队中跟随者总爱抢占领航者的目标点
现象:领航者发布一个目标点到/robot_0/move_base/goal,跟随者也会自说自话走到领航者前边去,整个队形从“跟随”变成了“超越”。
原因:跟随者的控制器代码里,相对位姿计算时没有考虑队形方向,把front和back算反了。追根到底是坐标系正方向没对齐,与雷达安装方向不同。
解决:在编队节点的回调里打印leader_pose.position,对比 Gazebo 里实际朝向,检查/tf中的yaw值。更简单的调试方法是将跟随者的lateral_offset调正成一个很大的数,比如 1.0,观察它是不是偏到了领航者左侧。如果方向反了,把偏移公式中cos(yaw + math.pi/2)换成cos(yaw - math.pi/2)即可。
5.5 队形一乱,move_base 把队友当墙绕着走
现象:跟随者与领航者稍微错位,跟随者的全局路径会绕一个大弯,导致队形越来越散。越是调整速度,越容易自动原地画圆。
原因:move_base 的局部代价地图把领航者识别为障碍,当领航者和跟随者靠得太近时,局部规划的膨胀层直接覆盖了所有可能通行的方向,于是机器人决定减速、转向、绕行。
解决:这是多机器人编队和导航之间最突出的一对矛盾。最有效的临时对策是给跟随者设置一个专门针对队友坐标系的过滤,直接把领航者的 scan 数据从跟随者的obstacle_layer排除。如果不想做精细化过滤,可以设置一个更大的队内间距,让机器人在物理上就不要靠太近。比如follow_dist=1.2,稳定性的收益会明显大于队形紧凑性的损失,仿真里我一般都先用足够松的队形去验证功能,最后再调近。
6. 从“能跑”到“可信”:验证编队和导航闭环的一套自我检查方法
仿真做完,不是看着三台车排好队走两步就算结束。我最常用的验证手法,是录制整段 rosbag,回放时把关键误差算出来。具体做法是同时记录/tf、/robot_0/odom、/robot_1/odom和/robot_2/odom。回放后用一段 Python 脚本读取这三个位姿,计算实际编队距离与期望follow_dist的偏差。最好的结果是偏差均值小于 0.1 米,峰值不超过 0.3 米。如果偏离这个区间,回去调速度增益或间距,不要靠肉眼判断。
另一个更落地的技巧,是给每台车的 move_base 配上不同的planner_frequency。如果三台车都用默认 0.0,也就是只在有新目标时重新规划一次,那么遇到动态障碍时反应迟钝。把领航者设为 0.5,跟随者设为 1.0,跟随者对突发障碍的响应就会更快,编队不容易脱节。
我还会在仿真里刻意设计一个“路口冲突”场景:两车同向出发,但最终要交叉换位。如果这是一个多机器人项目交付的验收项,我会记录它从发生冲突到完全消散的时长,这个时长既考验导航避障,也考验编队恢复。
最后给大家留一个我自己的习惯:每次交付多机器人仿真前,都会静默跑一整晚,第二天查看rostopic hz /robot_*/odom的频率稳定性。如果频率波动超过 10%,说明资源已经耗尽,真机只会更严重。这套多机器人仿真的链路里,最贵的东西不是代码,而是那台能让你安稳跑通整晚的电脑。
希望这篇基于 ROS 的多机器人仿真笔记,能帮你少走点弯路。
本文还有配套的精品资源,点击获取