简介:面向毕业设计、课程设计和期末大作业中的无人船集群控制场景,这份工程包基于机器人操作系统二(简称为ROS2)提供了一份完整可参考的实现。资源共包含七十四份文件,其中以二十二份C++源程序、十份Python脚本、七份扩展名为Xacro的船体模型文件、五份YAML参数文件以及若干消息、服务接口定义文件为主,整个压缩包大小约为二十六兆字节。包内按照启动调度、接口定义、运动控制、通信协调、描述建模五大模块组织,启动调度部分解决多节点初始化和状态检查的问题,接口定义部分规定了船间与控制中心的数据交换格式,运动控制部分给出比例积分微分控制、模型预测控制等算法的实现,通信协调部分兼顾点对点与广播式通信,描述建模部分则提供船体结构和传感器布局的三维模型,便于仿真与实船部署。目前已有五十三人学习使用,对理解分布式节点协作、多船协同策略以及机器人工程结构相当有帮助。读者可通过这些模块化的源码和配置,快速生成自己的无人船集群原型,并在此基础上扩展新的控制算法或通信协议。
1. 基于ROS2的无人船集群控制系统:多船协同为什么比单船难一个数量级
一条无人船做水质采样,代码量可以压在两千行以内;三条无人船组成集群去巡河,通信、编队、时序对齐能把人磨掉两层皮。这套基于ROS2的无人船集群控制系统,就是把“多船协同”这件事拆成可复现的工程:DDS分布式通信、领航-跟随编队控制、Gazebo水面仿真、RViz2状态可视化以及串口桥接ESP32的实船对接,节点代码、参数和launch文件都在压缩包里。适合正在做ROS2毕业设计、课程设计或期末大作业的人——想快速跑通集群demo,又不想从零去磕通信和编队细节。
2. 系统架构与通信设计:DDS分布式通信凭什么扛住多船组网
2.1 为什么选ROS2而不是ROS1:roscore单点就是集群的命门
如果你用过ROS1,一定记得master/roscore这套中心注册机制。所有节点启动后要先连到roscore登记自己的话题和服务,一旦master挂了,整条船的节点全部失联。多船场景下这个单点被无限放大:领航船负责跟岸基通信,跟随船要实时订阅领航船的位姿,如果其中一条船上的roscore崩溃,整个编队的cmd_vel就断了,剩下几条船只能原地漂着。
ROS2把通信层换成了DDS,没有中心节点,每个节点在网络上自己宣告存在、自己发现话题。船与船之间的数据流是点对点的,任一条船掉线,其他船的计算和通信照常进行,最多是订阅端超时告警。对无人船集群来说,这个去中心化特性意味着系统的可用性从“单点决定”变成了“多点冗余”。
实际部署时,每条船只需要一个能跑ROS2的工控机或树莓派,通过WiFi或4G路由器组成同一个局域网。DDS的发现协议在同一个网段下开箱即用,不需要像ROS1那样手动配置ROS_MASTER_URI。换句话说,你用三台电脑插同一个交换机就能把多船通信环境搭出来,这也是我建议课程设计先这么做验证的原因。
2.2 节点拓扑与话题/服务/动作分工
这套系统的节点划分很清晰,每艘船至少起两个节点:一个是船载控制节点,负责接收编队指令并解算速度;一个是传感器/定位节点,负责发布船舶当前位姿。领航船额外多一个航迹规划节点,岸基监控端再起一个可视化节点。
话题、服务、动作各有各的用处,别混着用。周期性数据走话题,一次性请求走服务,长时间执行且需要反馈的任务走动作。下面是这套工程里常见的接口设计:
| 通信类型 | 接口名 | 消息/服务类型 | 用途 |
|---|---|---|---|
| 话题 | /leader/pose | geometry_msgs/Pose2D | 领航船实时位姿 |
| 话题 | /followerX/cmd_vel | geometry_msgs/TwistStamped | 跟随船速度指令 |
| 话题 | /all_ships/status | std_msgs/String | 各船状态心跳 |
| 服务 | /mission/set_goal | 自定义srv | 单次下发目标点 |
| 动作 | /mission/execute | 自定义action | 长时巡河任务与进度反馈 |
位姿流用话题发布是正确的选择,因为它每一帧都得更新,而且最新值覆盖旧值即可,不需要应答。下发目标点这种一次性操作用服务最合适:请求发过去,返回是否接受,逻辑简单不易出错。巡河这种要跑几分钟甚至几十分钟的任务就得用动作,客户端可以随时取消,服务端还能反馈当前进度条,这在期末答辩演示时很加分。
消息类型上,Pose2D比Odometry轻量得多。集群编队只需要x、y和航向角theta,不需要速度协方差和里程计噪声模型,能用轻量消息就不背多余的数据。
2.3 多机通信配置:ROS_DOMAIN_ID与发现协议
多船通信最容易翻车的地方不在代码而在网络配置。你可能会遇到:三条船插在同一个交换机上,ros2 node list就是互相看不到。别急着怀疑代码,先查下面这几项。
先设置ROS_DOMAIN_ID。这个环境变量相当于DDS的“虚拟网络分段”,ID不同的节点即使在同一网段也互不发现。我一般用42这种大编号避免跟周围其他ROS2设备冲突。同时指定DDS实现,不同RMW实现之间默认不互通,团队内部统一成fastrtps比较省事。
echo "export ROS_DOMAIN_ID=42" >> ~/.bashrc echo "export RMW_IMPLEMENTATION=rmw_fastrtps_cpp" >> ~/.bashrc source ~/.bashrc # 验证发现是否正常 ros2 node list ros2 topic list ros2 topic info /leader/pose注意,如果node list只看到本机节点,说明DDS发现被网络隔离了。检查一下这几件事:各船是否在同一子网、UDP的7400~7500端口是否被防火墙拦截、AP的AP隔离是否开启。这三个问题我每一轮都遇到至少一个,尤其是学生实验室的公共WiFi经常开了AP隔离,导致多台电脑能上外网但互相ping不通。
3. 编队控制实现:领航-跟随法把集群问题拆成单车问题
3.1 领航-跟随模型:把集群误差拆到每艘船
编队控制有很多种思路:基于行为的、基于虚拟结构的、基于人工势场的。毕设和课设最推荐的是领航-跟随法,原因很实际——它把多船协同问题拆成了“每艘船各自追踪一个参考点”的单船控制问题。
具体做法是:领航船按自己的航迹正常跑,跟随船根据期望的相对距离和相对角度,实时计算出自己的期望位置,然后用控制器去追这个位置。队形参数用两个数描述,一个是期望距离d,一个是期望方位角α。这两个数一改,纵列、横排、楔形队形就都出来了。
期望位置的计算依赖领航船的实时位姿。假设领航船位姿是(x_l, y_l, θ_l),跟随船的期望位置是:
x_d = x_l + d * cos(θ_l + α)
y_d = y_l + d * sin(θ_l + α)
注意α是相对领航船航向的角度,不是绝对角度。这样编队会跟着领航船一起转向,队形不会散。如果你把α写成绝对角,船一转弯队形就乱成一团,这是最典型的初学错误。
3.2 速度解算:PD控制器输出与参数标定
有了期望位置和自身位置,接下来就是控制律。船舶在水面运动可以近似为差速运动模型:线速度v和角速度ω。控制目标是把位置误差收敛到零,同时保证船头方向合理。这里用PD控制就足够了,不需要上PID积分项——水面上有浪流扰动时积分项反而容易引起低频振荡。
坐标变换是关键一步。位置误差是在全局坐标系下计算的,要投影到船体坐标系才能得到“前方误差”和“侧向误差”。前方误差驱动线速度,侧向误差驱动角速度,这就是一个典型的视线法(Line of Sight)控制。
# follower_controller.py 编队控制核心片段 import math import rclpy from rclpy.node import Node from geometry_msgs.msg import Pose2D, TwistStamped class FollowerController(Node): def __init__(self, boat_id, desired_dist, desired_angle): super().__init__(f'follower_{boat_id}_controller') self.dist = desired_dist self.angle = math.radians(desired_angle) # PD参数:线速度环和角速度环分开调 self.kp_x = 1.2 self.kp_y = 0.8 self.max_v = 2.0 self.max_w = 1.2 self.leader_pose = None self.own_pose = Pose2D() self.leader_sub = self.create_subscription( Pose2D, '/leader/pose', self.leader_cb, 10) self.own_sub = self.create_subscription( Pose2D, f'/follower{boat_id}/pose', self.own_cb, 10) self.cmd_pub = self.create_publisher( TwistStamped, f'/follower{boat_id}/cmd_vel', 10) self.timer = self.create_timer(0.1, self.control_loop) def leader_cb(self, msg): self.leader_pose = msg def own_cb(self, msg): self.own_pose = msg def control_loop(self): if self.leader_pose is None: return # 领航船未上线,不发送控制指令 # 期望位置:领航船前方 dist 处且偏转 angle l = self.leader_pose x_des = l.x + self.dist * math.cos(l.theta + self.angle) y_des = l.y + self.dist * math.sin(l.theta + self.angle) # 全局坐标系误差 dx = x_des - self.own_pose.x dy = y_des - self.own_pose.y # 投影到本船坐标系 c, s = math.cos(self.own_pose.theta), math.sin(self.own_pose.theta) err_x = dx * c + dy * s # 船头方向误差 err_y = -dx * s + dy * c # 侧向误差 # PD输出,限幅 v = max(min(self.kp_x * err_x, self.max_v), 0.0) omega = max(min(self.kp_y * err_y / 2.0, self.max_w), -self.max_w) msg = TwistStamped() msg.header.stamp = self.get_clock().now().to_msg() msg.twist.linear.x = v msg.twist.angular.z = omega self.cmd_pub.publish(msg)这段代码的逻辑很直白:每100毫秒算一次期望位置、算一次误差、发一次速度指令。kp_x和kp_y是核心参数:kp_x决定船多快追上领航船,太大容易超调,太小追得慢;kp_y决定船多快纠正侧向偏差,这个值过大船会蛇形前进。我给的初始参数是偏保守的,实测时先跑直线编队,再把kp_y从0.4往上涨。
v只允许正的最大2.0,是因为大多数船用螺旋桨没有倒车能力,退船靠的是左右差速,不靠反转。如果你的船模支持倒车,把下限放开即可。
3.3 任务分配与状态机切换
编队只是“保持队形”,真正的任务还得有人来分配。系统的任务层用了一个轻量级状态机:待命(IDLE)→编队巡航(FORMATION_CRUISE)→返航(RETURN)→结束(FINISH)。
任务下发的入口是那个/mission/set_goal服务。岸基发来一个目标点,领航船先执行自己的路径规划,同时通过动作服务向所有跟随船广播任务状态。跟随船在自己的状态机里只有两件事:等待指令、执行编队跟踪。这个设计的优势在于,任务逻辑集中在领航船上,跟随船不需要知道自己要去哪个点,只需要知道“我在领航船的那个方位”。
状态切换要加保护条件。比如从IDLE到FORMATION_CRUISE前,必须确认所有跟随船的status心跳都是ALIVE且位姿有效。少了这个保护,就会出现命令已经下发、某些船还没启动、结果编队断成两截的情况。
4. 仿真到实船:Gazebo水面环境、RViz2可视化与串口桥接ESP32
4.1 Gazebo水面场景搭建:别一上来就跟浮力较劲
Gazebo跑无人船,第一个认知要纠正:经典Gazebo对水面浮力的原生支持并不好,就别想着第一步就搞六自由度风浪流体仿真的船体了。课程设计、毕业设计这个层级,最务实的做法是把船当作差分机器人先跑通编队逻辑,水面特性通过添加阻尼插件来近似。
具体拆解下来是三层:船体URDF描述文件、Gazebo差速驱动插件、水面阻尼参数。船体URDF用一个简单的长方体壳体加两个推进器组成,差速驱动可以复用轮式机器人的gazebo_ros2_control插件,把轮子的位置放到船尾两侧,前进后退的动力学行为跟喷水推进的差异在这个精度下可以忽略。
水面阻尼怎么模拟?在URDF的gazebo标签里给base_link加阻尼系数。船在水面运动受水的粘性阻力,线速度方向加一个与速度成正比的阻力项,角速度方向同样处理。调试的时候从零开始加——没有阻尼船会越漂越快,阻力系数过大会显得像在泥浆里开。我习惯把平移阻尼先设2.0、旋转阻尼1.5,再通过rviz2观察速度响应曲线微调。
<gazebo reference="base_link"> <mu1>0.1</mu1> <mu2>0.1</mu2> <kp>1000.0</kp> <kd>100.0</kd> <minDepth>0.05</minDepth> <maxDepth>1.0</maxDepth> </gazebo>这套参数是给Gazebo的浮力模型用的。minDepth和maxDepth控制船体吃水上下限,kp是浮力刚度,kd是阻尼。想省事就直接零浮力处理,让模型贴地运动,用摩擦系数模拟水面阻力,跑编队逻辑完全够用。
4.2 RViz2看什么:TF树与编队误差可视化
RViz2在无人船项目里不是拿来炫酷的,是拿来排障的。起三个插件就够:RobotModel显示每艘船的三维模型、TF显示船体坐标系之间的关系、Topic可视化实时显示/leader/pose和所有跟随船的位置数据。
排障最常用的操作是看TF树。如果你看到follower1的坐标系跟leader的坐标系没有连接关系,说明两船之间的位置数据没打通,问题出在通信层;如果TF树正常但模型位置不动,问题出在驱动层或仿真物理没更新。RViz2的Fixed Frame设置成map或者world,别默认成某艘船的base_link,否则你会看到全世界的坐标系都在绕着你转。
Gazebo给的是世界坐标,编队误差要自己算。我的习惯是单独起一个analysis节点,订阅所有船的位姿,实时计算每艘船相对期望位置的误差,发布成Float32MultiArray话题,用rqt_plot画出来。期末答辩时这张误差收敛曲线,比任何文字说明都有说服力。
4.3 串口桥接:把速度指令发给ESP32控制板
仿真验证完,下一步就是接实船。最常见的控制方案是工控机跑ROS2负责决策和编队,底层用ESP32通过串口控制电机驱动板。这里需要一个桥接节点,把ROS2的cmd_vel话题翻译成ESP32认得的串口协议帧。
协议帧的格式必须两端约定一致。我惯用的是0xAA 0x55开头,接着是线速度和角速度的整数表示(单位cm/s,乘以100转整数),最后是校验和。ESP32那边收满5个字节再校验和正确才执行,这样能滤掉大部分串口噪声。
# serial_bridge.py ROS2到ESP32的串口桥接 import serial import rclpy from rclpy.node import Node from geometry_msgs.msg import TwistStamped class SerialBridge(Node): def __init__(self, port='/dev/ttyUSB0', baud=115200): super().__init__('serial_bridge') self.sub = self.create_subscription( TwistStamped, 'cmd_vel', self.cmd_cb, 10) try: self.ser = serial.Serial(port, baud, timeout=0.1) self.get_logger().info(f'串口 {port} 打开成功') except serial.SerialException as e: self.get_logger().error(f'串口 {port} 打开失败: {e},检查权限和占用') def cmd_cb(self, msg): # 协议帧: AA 55 [v_int] [w_int] [checksum] v_int = int(msg.twist.linear.x * 100) & 0xFF w_int = int(msg.twist.angular.z * 100) & 0xFF checksum = (v_int + w_int) & 0xFF frame = bytes([0xAA, 0x55, v_int, w_int, checksum]) try: self.ser.write(frame) except serial.SerialException: self.get_logger().warn('串口写入失败,检查USB连接', throttle=2.0) rclpy.init() node = SerialBridge() rclpy.spin(node)注意把串口的设备名写对。Linux下常见的ttyUSB0、ttyACM0要对应你的USB转串口芯片,CH340大概率是ttyUSB0,ESP32原生USB口是ttyACM0。另外串口权限问题很常见,当前用户不在dialout组里就打不开设备,执行sudo usermod -aG dialout $USER后重新登录即可。
5. 避坑与常见问题排查:五个高频翻车点
5.1 多船节点互相发现不了
现象:三条船连同一个路由器,ros2 node list只能看到本机的节点。
原因:ROS_DOMAIN_ID不一致、RMW实现不同、AP隔离或防火墙拦截UDP 7400~7500端口。三分之一的情况是有人开了AP隔离,三分之一是复制别人的.bashrc带过来不同的环境变量,剩下的是防火墙。
解决:统一在~/.bashrc里写死ROS_DOMAIN_ID和RMW_IMPLEMENTATION,重启终端;然后在每条船上执行ping命令确认两两互通;最后用ros2 doctor检查DDS通信链路。如果确认是AP隔离,直接换物理交换机,不折腾。
5.2 编队命令发布成功但船不动
现象:echo /follower1/cmd_vel有数据,但Gazebo里的船模型纹丝不动。
原因:大半是控制话题名对不上。Gazebo插件的cmd_vel话题名是/cmd_vel,而编队控制器发到/follower1/cmd_vel,桥接或转发节点没接上。少半是控制器发布TwistStamped而插件订阅的是Twist类型,类型不匹配。
解决:用ros2 topic list对比发布端和订阅端的话题名,用ros2 topic info查看类型。惯用的方案是加一个话题重映射参数,在launch文件里把/followerX/cmd_vel重映射到各船的/cmd_vel。
5.3 船走蛇形,左右晃个不停
现象:跟随船一直在期望位置附近振荡,轨迹呈S形。
原因:kp_y过大或者控制频率太高,位置环输出未做限幅,侧向误差在小范围内被持续放大。水面上船体转动惯量比轮式机器人要大得多,同样的PD参数在AGV上稳,在船上就发散。
解决:把kp_y降一半,同时给输出加低通滤波。我一般把角速度指令做一阶滤波,滤波器系数0.7,效果立竿见影。还不行就降低控制频率,从100ms改成200ms,给执行机构一点反应时间。
5.4 ESP32串口收到的数据全是乱码
现象:串口监视器里看到0x00 0xFF之类的杂乱字节,或者帧校验永远不对。
原因:波特率不一致是头号原因,ROS2桥接端115200,ESP32端9600。其次是电平不匹配——工控机USB转串口通常是3.3V TTL电平,跟ESP32的串口电平对了但跟某些5V Arduino不对。还有接地问题。
解决:两端统一波特率,写死不要用自动协商。确认电平匹配。检查帧头帧尾格式是否按字节严格解析,别在Python里把负数直接塞进bytes,先把负数取模转成0~255再发。
5.5 Gazebo水面场景帧率极低或直接崩溃
现象:启动gazebo后CPU占用100%,画面卡到没法看,或者直接段错误退出。
原因:水面光照插件、粒子效果和浮力物理同时开,性能吃满。再有就是模型文件里引用了不存在的网格文件导致加载崩溃。
解决:把水面视觉效果全部关掉,用纯色平面代替水面纹理,关闭粒子效果的headlight插件。URDF里不要引用外部STL网格,用简单几何体拼船体。物理更新频率drop到500Hz以下,控制逻辑用仿真时间还是真实时间也要统一,不然控制周期就乱了。
6. 进阶:给集群加上水面动态避障与日志验证
编队跑通之后加动态避障,是让这个项目从“能做”变成“做得像回事”的关键一步。水面环境的障碍物跟地面不同:静态岸边可以预标定,但漂浮物、浮标、临时船只都需要传感器实时检测。Nav2里那套基于costmap_2d的做法可以借用,但别整个照搬。Nav2的全局规划器在结构化道路场景下表现很好,水面空旷场景反而会频繁重新规划。
我建议的轻量方案是:每艘船装一个单线激光雷达扫描水面,把扫描到的障碍物点云投影成2D costmap,然后在编队控制器的输出端加一个避障优先级层——正常情况下编队指令优先,当costmap检测到前方1.5米内有障碍物时,避障层接管输出,进行局部绕行,绕过后再切回编队指令。这样不引入全局路径规划的重计算延迟,也能应对大多数动态浮标场景。
验证这部分很重要,别只看眼睛觉得“好像没撞上”。用ros2 bag录一段编队+避障的完整数据,结束后回放分析编队误差。
ros2 bag record -a -o formation_test.bag # 跑完任务后 Ctrl+C 结束录制 ros2 bag play formation_test.bag ros2 run rqt_plot rqt_plot /follower1/error_x /follower1/error_yrqt_plot里如果两条误差曲线在避障段有一个明显凸起、避障完成后又收敛回零附近,说明避障层和编队控制器配合正常;如果避障后误差不回零,大概率是避障绕行结束后队形重对齐逻辑没触发,需要在状态机里加一个“绕行完成、恢复编队”的显式切换。
这套系统我前后带过三批学生跑,每个人都会在最基础的DDS发现配置上卡一次。从那以后,我每次启动多船仿真前都强制自己先执行一遍ros2 node list,数清楚节点数量再谈后面的控制参数——这个习惯替我挡掉了至少十次无效调试。希望帮到你。
本文还有配套的精品资源,点击获取