12431 字
34 分钟
ROS 1 与 ROS 2 分课教程:从消息通信到闭环移动
2026-11-01

这是一套保存在同一个 index.md 中的 ROS 教程。每课包含目标、必要代码、操作步骤和验收;不必一次读完。主线使用 Python,C++ 与部署相关内容放在选修和附录。

我们逐步搭建一台一维模拟机器人:发布速度,观察位置,用服务启停,再用 Action 根据反馈移动到目标。两代 ROS 使用相同的接口语义,便于比较。CMake、RViz 等工具只介绍实验所需内容。

阅读与验证范围

这是独立教学工程,不连接真实机器人,也不是 MRS 官方移植版。文中的文件路径和代码块由读者在独立工作空间中保存和执行。示例经过静态检查与替身逻辑检查,真实 ROS 构建、通信、仿真和博客页面渲染仍需在相应环境验证。

如何使用这篇教程#

先完成第 00~05 课,再学习 QoS 和闭环 Action。通过本课验收后再继续。Lifecycle、Composition、Security、Gazebo 和 C++ 按需选学。

课程内容路线
00 环境版本、终端、工作空间必学
01 ROS 1 数据链速度、位置、启停、参数、Launch必学
02 ROS 1 工程与接口包与三类接口定义必学
03 两代 ROS 对照概念与实现区别必学
04 ROS 2 数据链重建相同机器人必学
05 ROS 2 接口生成接口包、构建与类型必学
06 QoS 与执行器消息交付、回调调度前两节必学,其余选修
07 闭环 Action目标、反馈、取消与状态综合实践
08 坐标与模型TF2、URDF、RViz、传感器TF2 必学,其余选修
09 录制与排错bag、时钟、测试必学
10 MRS 源码对照真实工程综合实践
附录C++、最终目录和配置按需查询

贯穿全文的实验#

第一阶段:velocity_pub → cmd_vel → velocity_sub(模拟器)→ position
↑ set_enabled 服务
第二阶段:move_client → move_to Action → move_server
↓ cmd_vel
模拟器
↓ position
move_server

第一阶段是开环:发布器不关心机器人走到哪里。第二阶段由 Action 服务端读位置、计算误差、调整速度,构成闭环。Action 不直接修改模拟器位置。

接口类型约定
cmd_velgeometry_msgs/Twistlinear.x,单位 m/s
positiongeometry_msgs/PointStampedmap 坐标系下的 x,单位 m
set_enabledstd_srvs/SetBool是否执行接收到的速度
move_to自定义 MoveTo Action绝对目标 x、当前位置反馈、最终结果
一个阶段只保留一个速度来源

固定速度发布器与 Action 不能同时控制同一个 cmd_vel。切换阶段先停止原发布者;每个独立验收前重启模拟器并重新启用服务,让初始条件一致。

第 00 课:准备环境与学习地图#

本课目标与前置

无 ROS 前置知识;确认版本、Bash 终端和工作空间位置。

1. 版本与终端约定#

学习内容本文约定说明
ROS 1 独立实验ROS Noetic + Python 3,兼容的 Ubuntu 20.04 环境用于理解旧工程
ROS 1 课程案例课程提供的 Apptainer 环境跟随课程脚本,不替换其依赖
ROS 2 独立实验Ubuntu 24.04 + ROS 2 Jazzy + Python 3新代码的主要学习环境

Noetic 已于 2025-05-31 结束官方支持;这里保留它是为了学习现有 ROS 1 工程。Jazzy 支持 Ubuntu 24.04。依据:Noetic 公告、Jazzy 发布说明。

先根据 ROS 1 安装入口 或 ROS 2 Jazzy 安装指南 准备环境。本文不在 Windows PowerShell 中运行 ROS 命令;Windows 用户需要准备适合对应版本的 Linux、WSL 或课程容器环境。

以下命令均为 Linux Bash。每个新终端都要加载对应环境,且不要混用两代 ROS:

# ROS 1 的每个新终端执行;第二行要求 catkin 工作空间已经构建。只影响当前终端。
# ROS 1 终端;工作空间首次构建前只执行第一行
# 加载 Noetic 的工具和库路径;此时尚未加载自己的包。
source /opt/ros/noetic/setup.bash
# 加载当前构建产物,使 ROS 能找到新包、接口和可执行入口;不会自动构建。
source ~/catkin_ws/devel/setup.bash
# ROS 2 的每个新终端执行;第二行要求 colcon 已成功构建。不要与 ROS 1 环境混用。
# ROS 2 终端;工作空间首次构建前只执行第一行
# 加载 Jazzy 基础环境;后续工作空间会叠加在它之上。
source /opt/ros/jazzy/setup.bash
# 加载当前构建产物,使 ROS 能找到新包、接口和可执行入口;不会自动构建。
source ~/ros2_ws/install/setup.bash

可用 echo "$ROS_VERSION $ROS_DISTRO" 确认当前环境。除 ROS 本身外,练习分别需要 catkin、rosdep 或 colcon,以及文中使用的消息包;可视化章节额外需要 RViz、TF2 等工具。遇到缺失依赖,先查对应发行版的软件包说明。

2. 学习顺序与验收目标#

顺序学什么学完能做什么
1Node、Topic、Message、发布订阅写两个交换速度消息的节点
2Service、Client、Parameter请求启停,并解释配置读取时机
3自定义 msg、srv、action构建并检查自己的接口
4Action发送目标、观察反馈、取消任务
5Workspace、Package、Launch、Namespace、Remap同时组织两套互不串线的节点
6ROS 2 对照与 QoS、Executor重写同一实验,解释通信差异
7TF2、模型、RViz、bag检查坐标关系,录制与回放数据
8MRS 源码阅读从启动文件追踪到参数和输出话题

Python 是主教学语言;C++ 节点在后面给出小例子。先理解 ROS API,再扩展语言,不必一开始同时掌握两套完整实现。

本课验收

能辨认当前终端的 ROS 版本,知道新终端为什么需要 source。 未达到时先检查本课接口,不必继续增加新功能。

第 01 课:ROS 1 的第一条数据链#

本课目标与前置

完成第 00 课。分两次练习:先观察速度与位置,再学习服务、参数与 Launch。

1. Node、Topic 和 Message 是什么#

节点 Node 是参与 ROS 通信的计算单元。初学时可以把一个节点理解为一个正在运行的程序,例如相机驱动、规划器或速度接收器。

话题 Topic 是命名的数据通道;消息 Message 定义通道上传输的数据结构。Publisher 向话题发送消息,Subscriber 订阅话题并在收到消息时调用回调函数。发布者不需要写死订阅者地址,一个话题也可以有多个订阅者。

本节使用 geometry_msgs/Twist:

linear: x, y, z # 线速度,通常以 m/s 表示
angular: x, y, z # 角速度,通常以 rad/s 表示

Twist 本身没有时间戳或坐标系字段;采用什么参考系,需要接口双方约定。/cmd_vel 也只是常见话题名,ROS 不会因为用了这个名字就自动驱动机器人。

ROS 1 的 Master 提供节点注册和名称查询,消息数据通常由节点直接传输。独立实验首先在一个终端启动:

# ROS 1 独立终端 A 执行,保持运行;后续节点在其他终端启动。Ctrl+C 结束。
# 启动 ROS Master、参数服务器等基础组件;它负责发现/注册,不代替业务节点。
roscore

roscore 同时启动 ROS Master、参数服务器等基础组件。rosrun 用于运行一个包里的可执行程序;roslaunch 用于按启动文件组织多个节点,必要时可启动 Master。

2. 创建第一个包#

在第二个终端执行:

# ROS 1 建包终端执行;命令按顺序执行,最终回到工作空间根目录。
# 加载 Noetic 的工具和库路径;此时尚未加载自己的包。
source /opt/ros/noetic/setup.bash
# 创建列出的目录;-p 允许父目录尚不存在,且目录已存在时不报错。
mkdir -p ~/catkin_ws/src
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/catkin_ws/src
# 创建 catkin 包骨架;包名后面的名称会写入依赖声明,节点脚本仍需手工保存。
catkin_create_pkg ros1_basics rospy roscpp geometry_msgs std_srvs
# 创建列出的目录;-p 允许父目录尚不存在,且目录已存在时不报错。
mkdir -p ros1_basics/scripts ros1_basics/launch ros1_basics/config
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/catkin_ws
# 构建 catkin 工作空间;接口生成失败时先看报错,不要继续启动依赖这些接口的节点。
catkin_make
# 加载当前构建产物,使 ROS 能找到新包、接口和可执行入口;不会自动构建。
source devel/setup.bash

catkin_ws 是工作空间,src 放源代码,ros1_basics 是包。构建产生的 devel/setup.bash 把包加入当前终端的查找路径。首次练习选择 catkin_make,后面再解释它与 catkin build 的区别。

3. 发布速度消息#

保存为 ~/catkin_ws/src/ros1_basics/scripts/velocity_pub.py:

#!/usr/bin/env python3
# 检查 NaN/无穷等非有限输入,避免异常数值进入速度或位置计算。
import math
# ROS 1 Python 客户端库:节点、话题、服务、日志和时钟 API。
import rospy
# Twist 承载速度;PointStamped 额外携带坐标系、时间戳与位置。
from geometry_msgs.msg import Twist
# 程序入口负责初始化通信、创建节点、保持运行以及退出时释放资源。
def main():
# 向 ROS 1 注册节点名称;必须先初始化,再创建依赖节点身份的通信对象。
rospy.init_node('velocity_pub')
# 这里只在启动时读取一次;以后修改参数服务器/节点参数,不会自动更新这个变量。
speed = float(rospy.get_param('~speed', 0.5))
# 边界处拒绝非法数值及超范围值;不要让它们进入控制计算。
if not math.isfinite(speed) or abs(speed) > 1.0:
raise ValueError('speed must be finite and within [-1.0, 1.0] m/s')
# 创建相对名称的发布者,随 namespace 解析;保留对象供后续 publish 使用。
publisher = rospy.Publisher('cmd_vel', Twist, queue_size=10)
# 创建 10 Hz 节拍辅助对象,用于下面主动循环的间隔控制。
rate = rospy.Rate(10)
# ROS 1 退出标志置位后结束循环,不把程序写成永不退出的 while True。
while not rospy.is_shutdown():
message = Twist()
# 本例只控制 x 方向线速度,其余分量保持消息默认值零。
message.linear.x = speed
# 序列化并发送消息;调用返回不等于所有订阅者已处理完毕。
publisher.publish(message)
# 等待本轮剩余时间,避免无限高速发布占满 CPU。
rate.sleep()
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
try:
main()
# 响应 Ctrl+C/ROS 退出,让程序进入正常清理路径。
except rospy.ROSInterruptException:
pass

这段程序依次做了五件事:初始化节点、读取私有参数、创建发布者、填写消息、以约 10 Hz 发布。queue_size 是发送队列容量;Rate(10) 尝试控制循环频率,不代表硬实时保证。

4. 订阅速度,并提供启停服务#

Service 适合一次请求对应一次响应。本例使用 std_srvs/SetBool:请求字段 data 是布尔值;响应包含 success 和 message。

保存为 scripts/velocity_sub.py:

#!/usr/bin/env python3
# 检查 NaN/无穷等非有限输入,避免异常数值进入速度或位置计算。
import math
# 为可能并发的回调保护共享状态;锁不负责调度回调。
import threading
# monotonic 用于持续时间与超时,不受系统日期校时影响;sleep 只等待,不更新位置。
import time
# ROS 1 Python 客户端库:节点、话题、服务、日志和时钟 API。
import rospy
# Twist 承载速度;PointStamped 额外携带坐标系、时间戳与位置。
from geometry_msgs.msg import PointStamped, Twist
# 复用标准启停接口:请求 data,响应 success 与 message。
from std_srvs.srv import SetBool, SetBoolResponse
# 模拟器持有唯一的位置状态:订阅命令、定时积分、发布反馈;Action 只能通过接口控制它。
class VelocityReceiver:
# 构造阶段建立资源和初始状态;构造完成后再由 spin 驱动事件处理。
def __init__(self):
# 默认停用,必须显式调用服务后才执行速度,避免启动即运动。
self.enabled = False
# 模拟器位置只在节点创建时归零;停用、取消任务不会重置它。
self.x = 0.0
self.command = 0.0
# 记录最后一次收到速度命令的墙钟时刻,供 0.5 秒看门狗使用。
self.last_command = 0.0
# 积分起点使用单调墙钟;与消息中的 ROS 时间戳用途不同。
self.last_tick = time.monotonic()
# 保护多个回调共同访问的变量,读取和更新同一组状态都要使用同一把锁。
self.lock = threading.Lock()
# 创建相对名称的发布者,随 namespace 解析;保留对象供后续 publish 使用。
self.state_pub = rospy.Publisher('position', PointStamped, queue_size=10)
# 注册消息到达时执行的函数;此处只是建立订阅,不会主动读取一条消息。
self.subscription = rospy.Subscriber('cmd_vel', Twist, self.on_velocity)
# 注册服务名、类型和回调;客户端请求到来时才执行处理函数。
self.service = rospy.Service('set_enabled', SetBool, self.on_enable)
# 创建周期触发源;周期是调度目标,不保证每次都恰好按时执行。
self.timer = rospy.Timer(rospy.Duration(0.05), self.step)
# 服务回调只改变是否执行命令;停用不清空位置,也不会停止其他节点发布。
def on_enable(self, request):
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.lock:
self.enabled = request.data
if not self.enabled:
self.command = 0.0
# ROS 1 回调返回响应对象;字符串说明本次操作后的启用状态。
return SetBoolResponse(success=True, message='enabled' if request.data else 'disabled')
# 订阅回调只保存最新命令;真正的位置积分由定时器统一完成。
def on_velocity(self, message):
value = message.linear.x
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.lock:
# 只接受有限且绝对值不超过 1 m/s 的命令,否则按零速度处理。
self.command = value if math.isfinite(value) and abs(value) <= 1.0 else 0.0
# 记录接收时刻;这是命令新鲜度,不是消息自身携带的时间戳。
self.last_command = time.monotonic()
# 每个定时周期推进模拟状态并发布位置;没有新命令时也仍发布当前位置。
def step(self, event):
now = time.monotonic()
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.lock:
# 积分步长最大取 0.1 秒,避免调度卡顿后一次跳很远;因此不是精确动力学模拟。
dt = min(now - self.last_tick, 0.1)
self.last_tick = now
# 超过 0.5 秒没有新命令,就认为控制源失联。
fresh = now - self.last_command <= 0.5
# 必须同时满足“启用”和“命令未过期”;否则本周期采用零速度。
velocity = self.command if self.enabled and fresh else 0.0
# 一维运动积分:位置增量 = 速度 × 实际步长;单位为米。
self.x += velocity * dt
x = self.x
state = PointStamped()
# 声明位置属于 map;这里只填写参考系名称,不执行任何坐标变换。
state.header.frame_id = 'map'
# 使用 ROS 时钟标记状态生成时刻;本教程积分仍基于墙钟,保持 use_sim_time=false。
state.header.stamp = rospy.Time.now()
state.point.x = x
# 将当前位置发给观察工具或 Action 服务端,形成可见反馈。
self.state_pub.publish(state)
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
# 向 ROS 1 注册节点名称;必须先初始化,再创建依赖节点身份的通信对象。
rospy.init_node('velocity_sub')
receiver = VelocityReceiver()
# 保持节点存活以处理通信;不等于手工重复调用订阅回调。
rospy.spin()

现在接收器是一台一维模拟机器人:收到速度后,定时器按 x = x + velocity × dt 更新位置,再发布到 position。PointStamped 含坐标系、时间戳和三维点;本例只改变 x。

执行顺序是:on_velocity 保存命令 → step 每 0.05 秒读取命令 → 启用时积分 → 发布位置。spin() 让程序保持运行,等待消息触发回调;它不会主动调用一次订阅函数。没有新消息时,订阅回调不执行,但定时器仍会发布当前位置。

ROS 1 的订阅、服务和定时器可能来自不同线程,所以用锁保护共享状态。超过 0.5 秒没有新命令时,模拟速度归零。停用服务后,下一次积分不再采用速度。dt 最大取 0.1 秒,避免卡顿后大幅跳变。

模型边界

积分与超时采用单调墙钟,消息时间戳采用 ROS 时钟。本实验保持默认 use_sim_time=false,不支持用 /clock 控制步进,也不是动力学仿真。重启接收器才会将位置重置为零。

给脚本执行权限:

# ROS 1 终端执行;先确认前文两个脚本已保存。绝对路径不依赖当前目录。
# 赋予脚本执行权限,使 rosrun 能直接启动;这不会运行脚本。
chmod +x ~/catkin_ws/src/ros1_basics/scripts/velocity_pub.py
# 赋予脚本执行权限,使 rosrun 能直接启动;这不会运行脚本。
chmod +x ~/catkin_ws/src/ros1_basics/scripts/velocity_sub.py

在两个加载 ROS 1 工作空间的终端分别运行:

# ROS 1 发布者终端执行并保持运行;根命名空间下输出 /cmd_vel。
# 按“包名 可执行文件”启动节点;持续发布速度,Ctrl+C 停止。
rosrun ros1_basics velocity_pub.py
# ROS 1 模拟器终端执行并保持运行;初始 disabled,位置不会立即增长。
# 启动速度接收/位置模拟节点;用 set_enabled 服务启用后,位置才会更新。
rosrun ros1_basics velocity_sub.py

第三个终端观察并调用服务:

# ROS 1 观察终端执行;发布者、模拟器和 Master 应已运行。hz 会持续运行,Ctrl+C 后再执行下一条。
# 列出当前可见节点,先确认预期程序确实出现在 ROS 图中。
rosnode list
# 查看指定节点发布/订阅的话题和服务;用于核对连接关系。
rosnode info /velocity_pub
# 查询运行中话题的消息类型;输出类型名可交给 rosmsg show。
rostopic type /cmd_vel
# 显示消息字段结构;这是类型检查,不是读取某一条运行时消息。
rosmsg show geometry_msgs/Twist
# -n 1 表示收到一条后退出;检查实际数值和字段,不只看话题名。
rostopic echo -n 1 /cmd_vel
# 测量观察端收到消息的频率,预期接近 10 Hz;它不是硬实时保证。
rostopic hz /cmd_vel
# 确认服务类型;本例应为 std_srvs/SetBool。
rosservice type /set_enabled
# 发送一次启用请求;确认成功后观察位置增长,等待约 2 秒再执行停用请求。
rosservice call /set_enabled "data: true"
# 发送一次停用请求;模拟器位置应保持,速度话题本身仍可能持续发布。
rosservice call /set_enabled "data: false"

再开终端运行 rostopic echo /position/point/x。位置初始不变,启用后约以 0.5 m/s 增长,停用后保持最后的位置。服务改变是否执行速度,不会停止发布者。

分三次观察:先看位置静止,再启用约 2 秒,最后停用,位置增量应接近 1 m。启用时关闭发布者,约 0.5 秒后位置也应停止增长。

5. 用代码调用服务#

命令行本身就是一个 Client。Python 中的调用方式如下,可保存为 scripts/enable_client.py,用 python3 执行:

#!/usr/bin/env python3
# ROS 1 Python 客户端库:节点、话题、服务、日志和时钟 API。
import rospy
# 复用标准启停接口:请求 data,响应 success 与 message。
from std_srvs.srv import SetBool
# 向 ROS 1 注册节点名称;必须先初始化,再创建依赖节点身份的通信对象。
rospy.init_node('enable_client')
try:
# 最多等待 5 秒发现服务;这是等待服务上线,不是等待业务执行完成。
rospy.wait_for_service('set_enabled', timeout=5.0)
# 创建 ROS 1 同步服务代理,调用时按服务定义序列化请求。
client = rospy.ServiceProxy('set_enabled', SetBool)
# 发送 data=true 并同步等待响应;适合短操作,不适合长时间运动任务。
response = client(True)
rospy.loginfo('success=%s, message=%s', response.success, response.message)
except (rospy.ROSException, rospy.ServiceException) as error:
rospy.logerr('set_enabled failed: %s', error)

wait_for_service 等待服务出现,ServiceProxy 创建调用代理,client(True) 同步等待响应。这个结构适合短操作;“飞到某个位置”通常需要持续反馈和取消,应改用 Action。

6. Parameter、Namespace 与 Remap#

Parameter 是配置数据,例如速度、超时时间或地图文件名。ROS 1 参数保存在参数服务器里:

# ROS 1 观察终端执行;如果仅使用程序内部默认值,服务器里可能没有这个参数。
# 读取参数服务器的实际值;程序内部默认值不一定存在于服务器中。
rosparam get /velocity_pub/speed

上面可能提示不存在:get_param('~speed', 0.5) 的默认值只在程序中使用,不会自动写入参数服务器。可以在重启发布器时显式传入:

# 停止原发布者后,在 ROS 1 发布者终端执行;参数在本例启动时读取。
# _speed 是 ROS 1 私有参数赋值,最终位于 /velocity_pub/speed;单位 m/s。
rosrun ros1_basics velocity_pub.py _speed:=0.2

随后 rosparam get /velocity_pub/speed 应返回 0.2。执行 rosparam set /velocity_pub/speed 0.8 会修改服务器中的值,但本例只在启动时读取,运行中的速度不会自动改变。动态调参需要重新读取或专门的更新机制。

名称分为三种:

写法在命名空间 /uav1、节点名 velocity_pub 下的含义
cmd_vel相对名称,解析为 /uav1/cmd_vel
/cmd_vel绝对名称,始终为 /cmd_vel
~speed私有名称,解析为 /uav1/velocity_pub/speed

Namespace 用于组织接口;Remap 用于把代码里的接口名称改接到其他通道。停止前面的发布器后可以尝试:

# 停止旧发布者后运行;订阅者需要使用同样的命名空间和话题映射。
# __ns 改节点命名空间;cmd_vel:=velocity_cmd 重接话题,最终为 /uav1/velocity_cmd。
rosrun ros1_basics velocity_pub.py __ns:=/uav1 cmd_vel:=velocity_cmd

最终话题是 /uav1/velocity_cmd。订阅端也需要相同的命名空间和重映射才能接通。命名空间不会自动修改消息内部的 frame_id。

7. 用 Launch 一次启动一组节点#

保存 config/velocity.yaml:

# ROS 1 节点私有参数文件;在 node 内加载,speed 单位为 m/s。
speed: 0.5

保存 launch/demo.launch:

<!-- ROS 1 启动描述根元素;下面列出启动参数与节点。 -->
<launch>
<!-- 声明 Launch 参数,命令行可覆盖;它不是自动写入的 ROS 参数。 -->
<arg name="robot" default="uav1"/>
<!-- 将组内节点与相对接口名称放入选定命名空间。 -->
<group ns="$(arg robot)">
<!-- pkg/type 指定程序,name 指定节点名,output=screen 将日志显示到终端。 -->
<node pkg="ros1_basics" type="velocity_pub.py" name="velocity_pub" output="screen">
<!-- 把 YAML 加载到该节点的私有参数空间;文件位置通过 ROS 包查找解析。 -->
<rosparam command="load" file="$(find ros1_basics)/config/velocity.yaml"/>
</node>
<!-- pkg/type 指定程序,name 指定节点名,output=screen 将日志显示到终端。 -->
<node pkg="ros1_basics" type="velocity_sub.py" name="velocity_sub" output="screen"/>
</group>
</launch>

关闭前面手动启动的两个示例节点,再运行:

# ROS 1 启动终端执行并保持运行;先停掉前文手动启动的重复实例。
# 加载 launch 并启动整组节点;robot:=uav1 是 Launch 参数,用于组织命名空间。
roslaunch ros1_basics demo.launch robot:=uav1

另一个终端可以再启动 robot:=uav2。使用 rosservice call /uav1/set_enabled "data: true" 只启用第一套接收器。

arg 是 Launch 参数,param/rosparam 才是节点读取的 ROS 参数。YAML 放在 <node> 内,所以 speed 会进入该节点的私有参数空间。

本阶段验收:能画出两个节点和一个话题;能说明服务启停改变了谁;能启动两组节点并确认消息不串线。把 speed 改成 2.0 应明确报错;设为 0.0 应合法发布零速度。

对照 MRS: 课程规划器同样先读取参数,再创建 publisher;它发布的是轨迹,不是本例速度。读懂“谁发布、谁订阅、类型是什么”之后,再研究轨迹如何计算。来源:planner.py。

本课验收

位置从静止到增长再到静止;停发命令后约 0.5 秒停止增长;双 namespace 不串线。 未达到时先检查本课接口,不必继续增加新功能。

第 02 课:ROS 1 工程结构与接口#

本课目标与前置

完成第 01 课。只生成并检查接口,Action 服务端统一留到第 07 课。

1. 工作空间与包各负责什么#

catkin_ws/
├── src/
│ ├── ros1_basics/ # 本文节点和启动文件
│ └── robot_msgs/ # 自定义消息、服务和动作
├── build/ # 构建中间结果
└── devel/ # 开发环境和生成接口

package.xml 声明包名、版本、维护信息和依赖;CMakeLists.txt 告诉构建系统怎样生成接口、编译和安装程序。CMake 是构建配置工具,本文只使用必要规则,不展开语法教程。

catkin_make 和 catkin build 都能构建 catkin 工作空间;后者来自 catkin_tools,支持更细的按包构建和日志管理。本文统一用前者,不在同一套已有构建目录中随意切换工具。

rosdep 根据包声明安装系统依赖,不负责下载你的应用源代码。已经完成 rosdep 初始化和索引更新后,可以在工作空间根目录执行:

# 对应 ROS 版本的工作空间根目录执行;rosdep 已初始化并更新索引。此命令会安装依赖。
# --from-paths src 扫描包依赖;--ignore-src 跳过源码已有包,-r 遇到错误继续,-y 自动确认安装。
rosdep install --from-paths src --ignore-src -r -y

这是会安装依赖的命令,应在对应的学习环境中执行。若依赖解析失败,先核对发行版和包声明,不要直接删除依赖掩盖问题。

工程扩大后可按职责拆为 robot_description(模型)、robot_bringup(启动配置)、robot_control(执行)、robot_planner(规划)、robot_msgs(接口)。拆包服务于清晰的依赖边界,不要求第一天创建所有包。

2. Message、Service、Action 三种接口#

接口文件组成适合用途
Message.msg字段列表发布一个观察点
Service.srv请求 --- 响应检查目标是否合法
Action.action目标 --- 结果 --- 反馈执行移动并返回进度

创建接口包和目录:

# ROS 1 建包终端执行;先有 catkin_ws/src,再创建接口包及三类接口目录。
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/catkin_ws/src
# 创建 catkin 包骨架;包名后面的名称会写入依赖声明,节点脚本仍需手工保存。
catkin_create_pkg robot_msgs std_msgs actionlib_msgs message_generation message_runtime
# 创建列出的目录;-p 允许父目录尚不存在,且目录已存在时不报错。
mkdir -p robot_msgs/msg robot_msgs/srv robot_msgs/action

保存 robot_msgs/msg/Waypoint.msg:

# Header 携带参考系和时间戳;坐标值应与 frame_id 一致。
# x/y/z 是位置分量,单位米。
std_msgs/Header header
float64 x
float64 y
float64 z

保存 robot_msgs/srv/CheckTarget.srv:

# 请求部分:待检查的目标坐标,单位米。
float64 x
float64 y
float64 z
---
# 响应部分:是否合法及说明;定义接口不会自动创建服务端。
bool accepted
string message

保存 robot_msgs/action/MoveTo.action:

# Goal:map 坐标系中的绝对目标 x,不是位移增量。
float64 target_x
---
# Result:是否到达,以及最后一次测得的位置。
bool reached
float64 final_x
---
# Feedback:执行过程中不断更新的当前位置。
float64 current_x

这里约定位置单位为米。CheckTarget 只是定义接口,此时还没有对应服务端;创建 .srv 文件不会自动运行服务。MoveTo 为了专注 Action 机制只使用一维位置。

3. 生成 ROS 1 接口#

将接口包的 CMakeLists.txt 写为:

# 声明构建所需的最低 CMake 版本;不代表会自动安装 CMake。
cmake_minimum_required(VERSION 3.0.2)
# 声明项目/包名,后续 PROJECT_NAME 使用这里的值。
project(robot_msgs)
# 查找 catkin 及生成消息需要的包;后续多行是本次构建所需组件。
find_package(catkin REQUIRED COMPONENTS
std_msgs actionlib_msgs message_generation
)
# 列出本包需要生成的 .msg 文件,不会自动扫描任意目录。
add_message_files(FILES Waypoint.msg)
# 列出请求/响应接口定义。
add_service_files(FILES CheckTarget.srv)
# 列出动作定义;构建时生成目标、结果、反馈等配套类型。
add_action_files(FILES MoveTo.action)
# 按定义生成 ROS 1 语言接口,依赖包含标准消息与 Action 基础类型。
generate_messages(DEPENDENCIES std_msgs actionlib_msgs)
# 向使用本包的下游包导出运行期接口依赖。
catkin_package(CATKIN_DEPENDS std_msgs actionlib_msgs message_runtime)

确认生成的 package.xml 保留 catkin 构建依赖、std_msgs 和 actionlib_msgs 依赖,并包含以下两项;已经存在的条目不要重复添加:

<!-- ROS 1 构建时生成接口代码需要的依赖。 -->
<build_depend>message_generation</build_depend>
<!-- ROS 1 使用已生成接口时需要的运行依赖。 -->
<exec_depend>message_runtime</exec_depend>

在 ros1_basics/package.xml 的 <package> 内加入 robot_msgs 和 actionlib 依赖:

<!-- 节点消费自定义 ROS 1 接口,显式声明构建与运行依赖。 -->
<depend>robot_msgs</depend>
<!-- 节点使用 ROS 1 动作协议客户端/服务端。 -->
<depend>actionlib</depend>

构建并检查:

# 保存接口定义和构建配置后执行;生成类型后,其他运行终端也需重新 source。
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/catkin_ws
# 构建 catkin 工作空间;接口生成失败时先看报错,不要继续启动依赖这些接口的节点。
catkin_make
# 加载当前构建产物,使 ROS 能找到新包、接口和可执行入口;不会自动构建。
source devel/setup.bash
# 显示消息字段结构;这是类型检查,不是读取某一条运行时消息。
rosmsg show robot_msgs/Waypoint
# 显示服务请求与响应字段,--- 分隔两部分。
rossrv show robot_msgs/CheckTarget
# 显示消息字段结构;这是类型检查,不是读取某一条运行时消息。
rosmsg show robot_msgs/MoveToGoal

ROS 1 的 Action 会生成若干消息类型,例如 MoveToAction、MoveToGoal、MoveToResult 和 MoveToFeedback。Python 代码从 robot_msgs.msg 导入它们。

还可以直接用 CLI 测试自定义消息。一个终端执行 rostopic echo /waypoint,另一个执行:

# ROS 1 发布终端执行并保持运行;观察终端运行 rostopic echo /waypoint。
# -r 1 表示每秒发布一次;YAML 给出字段值,header 仅指定 frame,未提供有效采样时间。
rostopic pub -r 1 /waypoint robot_msgs/Waypoint \
"{header: {frame_id: 'map'}, x: 1.0, y: 2.0, z: 3.0}"

预期收到三维坐标。这条命令只演示字段传输,未填写有效采样时间;真实传感器或规划状态消息应按语义填写时间戳。检查接口定义与检查运行中的话题,是两个不同步骤。

对照 MRS: 查看课程的 package.xml 和 planner.launch,区分依赖声明与启动配置。启动文件中把私有输出话题重映射为最终名称,与第 01 课的相对名、私有名规则相同。来源:planner.launch。

本课验收

能查看三类接口并传输 Waypoint,解释接口文件为什么不会自己运行。 未达到时先检查本课接口,不必继续增加新功能。

第 03 课:ROS 1 与 ROS 2 对照#

本课目标与前置

完成第 02 课。本课不背命令表,重点理解参数、发现、构建与生成接口。

ROS 2 仍然有节点、话题、服务和动作,但不是把所有命令加上数字 2 就完成迁移。节点发现、通信策略、参数作用域、构建和回调调度都发生了变化。

ROS 1ROS 2迁移时注意
roscore / ROS Master常用 DDS RMW 下的分布式发现普通 ROS 2 程序不启动 Master
catkinament + colconament 提供包构建支持,colcon 编排工作空间构建
roscpp / rospyrclcpp / rclpyAPI 与节点生命周期需要重写
rosrunros2 run使用包名和可执行入口
roslaunchros2 launch常用 Python,也支持 XML、YAML
rostopicros2 topic增加端点 QoS 检查
rosserviceros2 service服务类型名称含 /srv/
rosparamros2 param参数归属具体节点
rosnoderos2 node节点图仍是排查入口
rosmsg / rossrvros2 interface统一查看 msg、srv、action
rosbagros2 bag不要假设录制文件格式直接兼容
actionlibROS 2 Action导入方式、状态与调用 API 不同
TCPROS / UDPROSRMW 抽象,常见实现基于 DDS中间件可替换,注意发现与网络配置
latch=TrueTransient Local 等 QoS 设置保留历史样本需要两端匹配
NodeletComponent / CompositionROS 2 标准组件工作流主要基于 rclcpp
dynamic_reconfigure 等调参方式参数服务、参数校验回调Lifecycle 另负责节点状态管理

ROS 2 CLI 的 daemon 帮助查询和缓存图信息,不等价于 ROS 1 Master。ROS 1 的 .msg 定义也不能直接作为 ROS 2 已生成的运行时类型使用,必须用对应工具重新生成。

本课验收

能说明换命令前缀为什么不等于完成迁移。 未达到时先检查本课接口,不必继续增加新功能。

第 04 课:用 ROS 2 重建同一台机器人#

本课目标与前置

完成第 03 课。在干净的 ROS 2 环境从建包开始,复现第 01 课的行为。

1. 创建 Python 包#

在干净的 ROS 2 终端执行,不需要 roscore:

# 干净的 ROS 2 建包终端执行;只创建包骨架,不会自动生成本教程的节点代码。
# 加载 Jazzy 基础环境;后续工作空间会叠加在它之上。
source /opt/ros/jazzy/setup.bash
# 创建列出的目录;-p 允许父目录尚不存在,且目录已存在时不报错。
mkdir -p ~/ros2_ws/src
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/ros2_ws/src
# --build-type 指定包构建方式;--dependencies 写入依赖声明,不能代替环境依赖检查。
ros2 pkg create --build-type ament_python --license Apache-2.0 ros2_basics \
--dependencies rclpy geometry_msgs std_srvs

主要结构如下:

ros2_basics/
├── package.xml
├── setup.py
├── setup.cfg
├── resource/ros2_basics
└── ros2_basics/
└── __init__.py

setup.py 声明 Python 包、资源和可执行入口;setup.cfg 让入口安装到 ROS 2 约定的位置。保留生成器创建的这些文件。

2. ROS 2 发布者#

保存为 ~/ros2_ws/src/ros2_basics/ros2_basics/velocity_pub.py:

# 检查 NaN/无穷等非有限输入,避免异常数值进入速度或位置计算。
import math
# ROS 2 Python 客户端库;回调需要通过 executor/spin 获得执行机会。
import rclpy
# Node 封装节点身份、参数以及发布/订阅等 ROS 资源。
from rclpy.node import Node
# Twist 承载速度;PointStamped 额外携带坐标系、时间戳与位置。
from geometry_msgs.msg import Twist
# 把发布器与定时器的生命周期绑定到节点对象,便于统一管理。
class VelocityPublisher(Node):
# 构造阶段建立资源和初始状态;构造完成后再由 spin 驱动事件处理。
def __init__(self):
# 创建具有指定默认名称的 ROS 2 节点;名称仍可通过启动参数重映射。
super().__init__('velocity_pub')
# 声明浮点参数及默认值;命令行的 -p speed:=... 可在启动时覆盖。
self.declare_parameter('speed', 0.5)
# 这里只在启动时读取一次;以后修改参数服务器/节点参数,不会自动更新这个变量。
self.speed = self.get_parameter('speed').value
# 边界处拒绝非法数值及超范围值;不要让它们进入控制计算。
if not math.isfinite(self.speed) or abs(self.speed) > 1.0:
raise ValueError('speed must be finite and within [-1.0, 1.0] m/s')
# 创建相对名称的发布者,随 namespace 解析;保留对象供后续 publish 使用。
self.publisher = self.create_publisher(Twist, 'cmd_vel', 10)
# 创建周期触发源;周期是调度目标,不保证每次都恰好按时执行。
self.timer = self.create_timer(0.1, self.publish_velocity)
# 定时器回调只构造并发布一条消息,不在这里再启动循环。
def publish_velocity(self):
message = Twist()
# 本例只控制 x 方向线速度,其余分量保持消息默认值零。
message.linear.x = self.speed
# 序列化并发送消息;调用返回不等于所有订阅者已处理完毕。
self.publisher.publish(message)
# 程序入口负责初始化通信、创建节点、保持运行以及退出时释放资源。
def main(args=None):
# 初始化 ROS 2 上下文并处理传入的 ROS 命令行参数。
rclpy.init(args=args)
node = None
try:
node = VelocityPublisher()
# 由执行器处理已就绪的订阅、服务、定时器等事件,直到退出。
rclpy.spin(node)
# 响应 Ctrl+C/ROS 退出,让程序进入正常清理路径。
except KeyboardInterrupt:
pass
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
if node is not None:
# 释放节点及其通信资源;不要只结束循环而遗留节点对象。
node.destroy_node()
# 上下文仍有效才调用后续通信/关闭操作,避免重复 shutdown。
if rclpy.ok():
# 关闭 ROS 2 通信上下文;不是对真实机器人的停止确认。
rclpy.shutdown()
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
main()

super().__init__ 建立节点;declare_parameter 声明参数及其默认类型;create_timer 周期性调度回调。传入整数 10 指定 KEEP_LAST 深度,并采用其他默认 QoS 策略,不表示只有一种通信设置。

本例不再手写 while + Rate,而是让 executor 在 spin 中处理定时器。保存 publisher、timer 等对象为实例属性,也便于管理资源和理解节点结构。

3. ROS 2 订阅者与启停服务#

保存 ros2_basics/velocity_sub.py:

# 检查 NaN/无穷等非有限输入,避免异常数值进入速度或位置计算。
import math
# monotonic 用于持续时间与超时,不受系统日期校时影响;sleep 只等待,不更新位置。
import time
# ROS 2 Python 客户端库;回调需要通过 executor/spin 获得执行机会。
import rclpy
# Node 封装节点身份、参数以及发布/订阅等 ROS 资源。
from rclpy.node import Node
# Twist 承载速度;PointStamped 额外携带坐标系、时间戳与位置。
from geometry_msgs.msg import PointStamped, Twist
# 复用标准启停接口:请求 data,响应 success 与 message。
from std_srvs.srv import SetBool
# 模拟器持有唯一的位置状态:订阅命令、定时积分、发布反馈;Action 只能通过接口控制它。
class VelocityReceiver(Node):
# 构造阶段建立资源和初始状态;构造完成后再由 spin 驱动事件处理。
def __init__(self):
# 创建具有指定默认名称的 ROS 2 节点;名称仍可通过启动参数重映射。
super().__init__('velocity_sub')
# 默认停用,必须显式调用服务后才执行速度,避免启动即运动。
self.enabled = False
# 模拟器位置只在节点创建时归零;停用、取消任务不会重置它。
self.x = 0.0
self.command = 0.0
# 记录最后一次收到速度命令的墙钟时刻,供 0.5 秒看门狗使用。
self.last_command = 0.0
# 积分起点使用单调墙钟;与消息中的 ROS 时间戳用途不同。
self.last_tick = time.monotonic()
# 创建相对名称的发布者,随 namespace 解析;保留对象供后续 publish 使用。
self.state_pub = self.create_publisher(PointStamped, 'position', 10)
# 注册消息到达时执行的函数;此处只是建立订阅,不会主动读取一条消息。
self.subscription = self.create_subscription(
Twist, 'cmd_vel', self.on_velocity, 10)
# 注册服务名、类型和回调;客户端请求到来时才执行处理函数。
self.service = self.create_service(SetBool, 'set_enabled', self.on_enable)
# 创建周期触发源;周期是调度目标,不保证每次都恰好按时执行。
self.timer = self.create_timer(0.05, self.step)
# 订阅回调只保存最新命令;真正的位置积分由定时器统一完成。
def on_velocity(self, message):
value = message.linear.x
# 只接受有限且绝对值不超过 1 m/s 的命令,否则按零速度处理。
self.command = value if math.isfinite(value) and abs(value) <= 1.0 else 0.0
# 记录接收时刻;这是命令新鲜度,不是消息自身携带的时间戳。
self.last_command = time.monotonic()
# 服务回调只改变是否执行命令;停用不清空位置,也不会停止其他节点发布。
def on_enable(self, request, response):
self.enabled = request.data
if not self.enabled:
self.command = 0.0
# success 表示启停请求已处理,不表示机器人已经到达某个目标。
response.success = True
response.message = 'enabled' if self.enabled else 'disabled'
return response
# 每个定时周期推进模拟状态并发布位置;没有新命令时也仍发布当前位置。
def step(self):
now = time.monotonic()
# 积分步长最大取 0.1 秒,避免调度卡顿后一次跳很远;因此不是精确动力学模拟。
dt = min(now - self.last_tick, 0.1)
self.last_tick = now
# 超过 0.5 秒没有新命令,就认为控制源失联。
fresh = now - self.last_command <= 0.5
# 必须同时满足“启用”和“命令未过期”;否则本周期采用零速度。
velocity = self.command if self.enabled and fresh else 0.0
# 一维运动积分:位置增量 = 速度 × 实际步长;单位为米。
self.x += velocity * dt
state = PointStamped()
# 声明位置属于 map;这里只填写参考系名称,不执行任何坐标变换。
state.header.frame_id = 'map'
# 使用 ROS 时钟标记状态生成时刻;本教程积分仍基于墙钟,保持 use_sim_time=false。
state.header.stamp = self.get_clock().now().to_msg()
state.point.x = self.x
# 将当前位置发给观察工具或 Action 服务端,形成可见反馈。
self.state_pub.publish(state)
# 程序入口负责初始化通信、创建节点、保持运行以及退出时释放资源。
def main(args=None):
# 初始化 ROS 2 上下文并处理传入的 ROS 命令行参数。
rclpy.init(args=args)
node = VelocityReceiver()
try:
# 由执行器处理已就绪的订阅、服务、定时器等事件,直到退出。
rclpy.spin(node)
# 响应 Ctrl+C/ROS 退出,让程序进入正常清理路径。
except KeyboardInterrupt:
pass
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
# 释放节点及其通信资源;不要只结束循环而遗留节点对象。
node.destroy_node()
# 上下文仍有效才调用后续通信/关闭操作,避免重复 shutdown。
if rclpy.ok():
# 关闭 ROS 2 通信上下文;不是对真实机器人的停止确认。
rclpy.shutdown()
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
main()

ROS 2 服务回调收到 request 与 response,填充后返回 response。业务过程保持一致:保存速度、按时间积分、发布位置。

spin 中的 executor 挑选已就绪的回调执行。这里使用默认单线程执行器,回调不会同时修改位置,因此不必照搬 ROS 1 的锁;改变并发方式时必须重新检查这个前提。积分使用墙钟,实验保持默认 use_sim_time=false。

4. 注册入口、构建、运行#

在 setup.py 的 entry_points 中写入:

# 入口格式为“命令名 = Python 模块:函数”;ros2 run 通过该入口调用 main。
entry_points={
# 入口格式为“命令名 = Python 模块:函数”;ros2 run 通过该入口调用 main。
'console_scripts': [
'velocity_pub = ros2_basics.velocity_pub:main',
'velocity_sub = ros2_basics.velocity_sub:main',
],
},

以上是 setup() 的参数片段,保留其余生成内容。然后:

# ROS 2 构建终端执行;保存脚本和入口配置后再构建,最后一条会持续运行发布者。
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/ros2_ws
# --packages-select 只选择指定包,依赖需已可用;--symlink-install 尽可能使用符号链接,安装规则变化仍须重建。
colcon build --packages-select ros2_basics --symlink-install
# 加载当前构建产物,使 ROS 能找到新包、接口和可执行入口;不会自动构建。
source install/setup.bash
# 运行包内入口;--ros-args -p speed:=0.5 为该节点覆盖浮点参数默认值。
ros2 run ros2_basics velocity_pub --ros-args -p speed:=0.5

另一个加载环境的终端运行 ros2 run ros2_basics velocity_sub。第三个终端检查:

# ROS 2 观察终端执行;发布者和模拟器应已启动。hz 持续运行,Ctrl+C 后执行服务调用。
# 列出当前 Domain 内可发现的节点;跨域或环境混用可能导致看不到节点。
ros2 node list
# 检查节点的发布者、订阅者、服务与动作端点。
ros2 node info /velocity_pub
# 显示生成后的接口字段,确认类型已被当前终端发现。
ros2 interface show geometry_msgs/msg/Twist
# --once 收到一条即退出;若指定 Transient Local 与 Reliable,是为了匹配保留样本的发布者。
ros2 topic echo /cmd_vel --once
# 观察接收频率;此命令持续运行,结束后再执行下一条。
ros2 topic hz /cmd_vel
# 请求启用,响应 success=true 后观察位置并等待约 2 秒,再执行停用。
ros2 service call /set_enabled std_srvs/srv/SetBool "{data: true}"
# 请求停用;随后 position 应保持当前位置,不会自动归零。
ros2 service call /set_enabled std_srvs/srv/SetBool "{data: false}"
# 读取指定节点的参数;本例只在构造阶段复制参数,运行时修改不自动改变业务成员。
ros2 param get /velocity_pub speed

运行 ros2 topic echo /position 观察 point.x。禁用时位置不变,启用后增长,再禁用后保持;验收结果应与 ROS 1 一致。speed:=0.0 是合法边界;speed:=2.0 应启动失败;使用 0.5 这样的浮点值,避免把声明为浮点数的参数传成整数。

5. ROS 2 异步服务客户端#

保存为包目录内 enable_client.py,加载环境后可直接使用 python3 执行此文件;也可以按相同规则注册入口:

# ROS 2 Python 客户端库;回调需要通过 executor/spin 获得执行机会。
import rclpy
# 复用标准启停接口:请求 data,响应 success 与 message。
from std_srvs.srv import SetBool
# 程序入口负责初始化通信、创建节点、保持运行以及退出时释放资源。
def main():
# 初始化 ROS 2 上下文并处理传入的 ROS 命令行参数。
rclpy.init()
node = rclpy.create_node('enable_client')
try:
client = node.create_client(SetBool, 'set_enabled')
# 最多等待 5 秒发现服务;这是等待服务上线,不是等待业务执行完成。
if not client.wait_for_service(timeout_sec=5.0):
raise RuntimeError('set_enabled server unavailable')
request = SetBool.Request()
request.data = True
# 异步发送请求并立即得到 Future;需要后面的 spin 来接收响应。
future = client.call_async(request)
# 在主流程中处理事件直到该 Future 完成或超时;不要直接照搬到互斥回调内部。
rclpy.spin_until_future_complete(node, future, timeout_sec=5.0)
# 检查等待是否真正完成;超时不等于服务端没有执行,也不等于机器人停止。
if not future.done():
raise TimeoutError('set_enabled response timed out')
# Future 已完成后读取响应;服务调用异常也可能在此处传播。
response = future.result()
node.get_logger().info(f'success={response.success}, message={response.message}')
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
# 释放节点及其通信资源;不要只结束循环而遗留节点对象。
node.destroy_node()
# 上下文仍有效才调用后续通信/关闭操作,避免重复 shutdown。
if rclpy.ok():
# 关闭 ROS 2 通信上下文;不是对真实机器人的停止确认。
rclpy.shutdown()
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
main()

Future 表示一个稍后完成的结果。发起请求后仍需要 executor 处理通信事件,因此本例在主流程里 spin 等待。不要把这个同步等待结构直接塞进一个互斥回调里,否则可能阻止响应回调运行。

6. 参数、Launch 和资源安装#

ROS 2 参数归属节点,可以执行 ros2 param list、ros2 param describe /velocity_pub speed 和 ros2 param dump /velocity_pub 查看。上面的代码把值复制到 self.speed,所以后续 ros2 param set 即使成功,也不会自动更新这个成员。

要支持动态调参,应校验新值并在变更成功后更新业务状态,例如使用参数校验与变更后回调。校验失败应返回原因;不要在尚未通过完整校验时先修改算法状态。本教程先保持启动时配置,以免把参数机制与控制逻辑混在一起。

在包根目录创建 launch、config,保存 config/velocity.yaml:

# ROS 2 参数文件:/** 匹配节点名称,ros__parameters 下才是参数值。
/**:
ros__parameters:
speed: 0.5

ros__parameters 是 ROS 2 参数文件的结构;这里的节点通配写法便于同一文件配合不同 namespace,且下文只把它传给发布者。

保存 launch/demo.launch.py:

# LaunchDescription 收集本次启动要执行的动作,不直接在导入时启动节点。
from launch import LaunchDescription
# 声明外部可覆盖的启动参数,例如 robot:=uav2。
from launch.actions import DeclareLaunchArgument
# 启动时才解析参数与路径,避免在脚本加载时把值写死。
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
# 这里的 Node 是“启动一个节点进程”的 Launch 动作,不是 rclpy.Node 对象。
from launch_ros.actions import Node
# 查找包安装后的 share 目录,避免硬编码源码目录。
from launch_ros.substitutions import FindPackageShare
# ros2 launch 调用此函数取得启动描述,再按描述创建进程。
def generate_launch_description():
# 保存启动参数的替换表达式,实际字符串在 launch 执行时解析。
robot = LaunchConfiguration('robot')
# 从安装后的包资源目录拼接 YAML 路径,不依赖运行命令时所在目录。
config = PathJoinSubstitution([
FindPackageShare('ros2_basics'), 'config', 'velocity.yaml'])
# 列表中先声明启动参数,再启动发布者和模拟器。
return LaunchDescription([
DeclareLaunchArgument('robot', default_value='uav1'),
# namespace 隔离接口;参数文件仅传给需要读取 speed 的发布者。
Node(package='ros2_basics', executable='velocity_pub',
name='velocity_pub', namespace=robot, parameters=[config], output='screen'),
# namespace 隔离接口;参数文件仅传给需要读取 speed 的发布者。
Node(package='ros2_basics', executable='velocity_sub',
name='velocity_sub', namespace=robot, output='screen'),
])

在 setup.py 顶部加入 from glob import glob,并向已有 data_files 列表追加两项,保留 resource 索引和 package.xml 的安装项:

# 将资源安装到包的 share 目录;增加/修改此安装规则后需要重新构建。
('share/ros2_basics/launch', glob('launch/*.launch.py')),
# 将资源安装到包的 share 目录;增加/修改此安装规则后需要重新构建。
('share/ros2_basics/config', glob('config/*.yaml')),

在 package.xml 中补充启动依赖:

<!-- 启动 Python Launch 描述需要的运行库。 -->
<exec_depend>launch</exec_depend>
<!-- 启动 Python Launch 描述需要的运行库。 -->
<exec_depend>launch_ros</exec_depend>

重新构建、加载工作空间,再运行:

# ROS 2 启动终端执行并保持运行;启动文件和 YAML 必须已安装到工作空间。
# 启动已安装的 Python launch 文件;robot 参数决定该组节点的命名空间。
ros2 launch ros2_basics demo.launch.py robot:=uav1

另一终端用 robot:=uav2 启动第二套。检查 /uav1/cmd_vel、/uav2/cmd_vel 和各自的 /set_enabled 服务。若命令行启动时要重映射,可写 --ros-args -r __ns:=/uav1 -r cmd_vel:=velocity_cmd;Launch 中的 Node 则支持 remappings=[('cmd_vel', 'velocity_cmd')]。

对照 MRS: ROS 1 的私有参数读取需要迁移成 ROS 2 节点参数声明与读取;加载 YAML 的结构和 Launch API 也要调整。迁移先保持参数含义不变,不同时修改规划算法。

本课验收

位置增长/暂停与 ROS 1 一致,能解释 timer、回调和 spin 的执行顺序。 未达到时先检查本课接口,不必继续增加新功能。

第 05 课:生成 ROS 2 自定义接口#

本课目标与前置

完成第 04 课,复用第 02 课的接口文本;不要复制 ROS 1 生成文件。

1. 创建接口包#

ROS 2 Python 节点可以使用 CMake 接口包生成的类型。接口生成包和节点实现包分离,有助于其他语言复用接口。采用官方推荐的 自定义接口流程。

# ROS 2 建包终端执行;这是 ament_cmake 接口包,供 Python/C++ 节点共同使用。
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/ros2_ws/src
# --build-type 指定包构建方式;--dependencies 写入依赖声明,不能代替环境依赖检查。
ros2 pkg create --build-type ament_cmake --license Apache-2.0 robot_interfaces
# 创建列出的目录;-p 允许父目录尚不存在,且目录已存在时不报错。
mkdir -p robot_interfaces/msg robot_interfaces/srv robot_interfaces/action

把第 02 课中的 Waypoint.msg、CheckTarget.srv、MoveTo.action 定义分别保存到对应目录。只复用文本定义,不复制 ROS 1 生成的 Python 文件。

接口包 CMakeLists.txt:

# 声明构建所需的最低 CMake 版本;不代表会自动安装 CMake。
cmake_minimum_required(VERSION 3.8)
# 声明项目/包名,后续 PROJECT_NAME 使用这里的值。
project(robot_interfaces)
# 加载 ROS 2 的 CMake 构建支持。
find_package(ament_cmake REQUIRED)
# 加载 ROS 2 接口代码生成器,供 Python/C++ 共同使用。
find_package(rosidl_default_generators REQUIRED)
# 查找 Header 所在的标准消息包。
find_package(std_msgs REQUIRED)
# 查找 ROS 2 Action 基础协议消息依赖。
find_package(action_msgs REQUIRED)
# 为列出的 ROS 2 接口生成可导入类型;DEPENDENCIES 对应字段引用的外部包。
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/Waypoint.msg"
"srv/CheckTarget.srv"
"action/MoveTo.action"
DEPENDENCIES std_msgs
)
# 向下游导出使用生成接口所需的运行期依赖。
ament_export_dependencies(rosidl_default_runtime)
# 完成 ROS 2 包导出,通常放在构建配置末尾。
ament_package()

在生成的 package.xml 内保留 ament_cmake 和 export 信息,加入:

<!-- 声明构建/生成工具;运行依赖与它承担不同职责。 -->
<buildtool_depend>rosidl_default_generators</buildtool_depend>
<!-- 使用生成后的 ROS 2 接口所需运行支持。 -->
<exec_depend>rosidl_default_runtime</exec_depend>
<!-- 接口引用标准 Header,需要依赖其消息定义。 -->
<depend>std_msgs</depend>
<!-- ROS 2 Action 基础协议所需类型。 -->
<depend>action_msgs</depend>
<!-- 将此包归入 ROS 2 接口包组,满足接口生成工具的元数据要求。 -->
<member_of_group>rosidl_interface_packages</member_of_group>

在 ros2_basics/package.xml 中加入 <depend>robot_interfaces</depend>,然后:

# ROS 2 工作空间构建终端执行;依赖关系应已写入 package.xml。
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/ros2_ws
# --packages-up-to 同时构建目标及其工作空间依赖;--symlink-install 尽可能使用符号链接,安装规则变化仍须重建。
colcon build --packages-up-to ros2_basics --symlink-install
# 加载当前构建产物,使 ROS 能找到新包、接口和可执行入口;不会自动构建。
source install/setup.bash
# 显示生成后的接口字段,确认类型已被当前终端发现。
ros2 interface show robot_interfaces/msg/Waypoint
# 显示生成后的接口字段,确认类型已被当前终端发现。
ros2 interface show robot_interfaces/srv/CheckTarget
# 显示生成后的接口字段,确认类型已被当前终端发现。
ros2 interface show robot_interfaces/action/MoveTo

--packages-up-to 会构建目标包及工作空间内的依赖。Python 的导入路径分别是 robot_interfaces.msg、robot_interfaces.srv、robot_interfaces.action;例如 Action 使用 MoveTo.Goal(),不同于 ROS 1 的 MoveToGoal()。

对应 ROS 1 的自定义消息实验,ROS 2 可以执行:

# ROS 2 发布终端执行;在另一个已加载接口包的终端 echo /waypoint。
# 按 1 Hz 发布自定义消息;类型名含 /msg/,YAML 字段必须与生成定义一致。
ros2 topic pub /waypoint robot_interfaces/msg/Waypoint \
"{header: {frame_id: 'map'}, x: 1.0, y: 2.0, z: 3.0}" --rate 1

另一个终端运行 ros2 topic echo /waypoint --once,确认字段一致。这里同样只演示数据结构,没有为消息生成有效采样时间。

本课验收

能构建 robot_interfaces 并检查消息、服务、动作字段。 未达到时先检查本课接口,不必继续增加新功能。

第 06 课:QoS 与回调调度#

本课目标与前置

完成第 05 课。先读前两节,再进入第 07 课;其余是选修概念。

1. QoS:话题存在不代表消息一定能到达#

QoS(Quality of Service)描述端点期望的通信行为。先理解以下四项:

策略常见值要回答的问题
HistoryKeep Last / Keep All保留最近若干条,还是在资源限制内保留全部?
Depth队列深度Keep Last 时保留多少条?
ReliabilityReliable / Best Effort是否重试以提高交付可靠性?
DurabilityVolatile / Transient Local是否为后加入的订阅者保留样本?

实时传感器常优先获取最新数据,可以接受部分丢失;规划结果只发布一次,则可能需要后加入的订阅者读到它。应根据用途选择,不存在所有话题都适合的配置。

发布者订阅者可靠性是否兼容
Best EffortReliable否
ReliableBest Effort是
ReliableReliable是
Best EffortBest Effort是

持久性也需要匹配:Transient Local 发布者搭配 Volatile 订阅者可收新消息,但不保证补发历史数据;要接收保留的历史数据,订阅者也使用 Transient Local。发布者为 Volatile、订阅者要求 Transient Local 时不兼容。依据:官方 QoS 说明源码。

可以用两个终端做一个小实验,依赖 std_msgs。先启动发布者,第一次发布后再启动订阅者:

# ROS 2 发布终端执行并保持存活;等第一次发布后,再启动后面的订阅命令。
# 每 10 秒发送一条字符串;Transient Local 保留历史样本,Reliable 指定可靠交付策略。
ros2 topic pub /mission_note std_msgs/msg/String "{data: 'inspection ready'}" \
--rate 0.1 --qos-durability transient_local --qos-reliability reliable
# ROS 2 订阅终端执行;上一条发布器应仍在运行,以验证晚加入者接收保留样本。
# --once 收到一条即退出;若指定 Transient Local 与 Reliable,是为了匹配保留样本的发布者。
ros2 topic echo /mission_note std_msgs/msg/String \
--qos-durability transient_local --qos-reliability reliable --once
# -v 展示端点 QoS 等详细信息,排查有话题却收不到消息的问题。
ros2 topic info /mission_note -v

发布周期是 10 秒,而新订阅者应能接收已经保留的消息,不必等待下一周期。保留发布者运行;Transient Local 不是磁盘持久化。

MRS 规划器里的 latch=True 正是适合用这种思路迁移的场景。只把 ROS 1 publisher API 换成 ROS 2 默认配置,会改变“后来启动也能收到结果”的行为。

2. Executor 与 Callback Group#

Executor 负责执行订阅、定时器、服务等回调。Callback Group 描述哪些回调允许并发:

  • SingleThreadedExecutor:单线程处理回调,易于理解,但长回调会阻塞其他工作。
  • MultiThreadedExecutor:提供多个执行线程,是否实际并发还取决于回调组。
  • MutuallyExclusiveCallbackGroup:同一组内不并发执行。
  • ReentrantCallbackGroup:允许并发,包括同类回调重入,业务代码必须处理共享状态。

典型问题:一个服务回调同步等待另一个服务,而响应处理又被同一互斥组阻塞。优先采用异步调用和清晰的状态转换,而不是只增加线程数。Python CPU 密集规划也不一定因线程变多而加速;必要时将算法放入进程或 C++ 模块。

3. Lifecycle、Composition 与进程内通信#

Lifecycle Node 把节点分成未配置、未激活、已激活、已结束等状态,通过 configure、activate、deactivate、cleanup 等转换管理资源。它适用于需要按顺序配置、启用设备和算法的系统。普通节点不会因为调用 ros2 lifecycle 就自动具备生命周期能力。

Composition 让多个组件在同一进程中运行,减少进程间开销;标准组件加载工作流主要使用 rclcpp。Intra-process communication 则是同进程数据传递优化,仍受消息所有权、类型、QoS 和配置影响,不能简单等同于“所有传输零拷贝”。

这两项应在基本通信正确之后学习。先能解释节点为什么收到或收不到消息,再考虑部署与性能优化。

4. DDS Domain 与 ROS 2 Security#

同一个协作实验中的终端使用一致的 Domain,例如在启动节点前都执行:

# 参与同一 ROS 2 实验的每个终端都在启动节点前执行;数值一致才能按该域发现彼此。
# 只改变本终端及其子进程的 Domain;相同系统各终端保持一致,它不是安全认证设置。
export ROS_DOMAIN_ID=23

不同 Domain 通常隔离发现。要区分同一系统中的两架无人机,优先使用 namespace;不要把它们放进不同 Domain 后再期待自动发现彼此。

Domain ID 不是身份认证或访问控制。ROS 2 Security 基于受支持中间件的安全能力,可配置认证、加密和访问策略;需要密钥库、enclave 与授权文件等准备。学习时按 官方安全教程 在独立环境中实验,不把“设置一个环境变量”当成已经完成保护。

对照 MRS: 课程规划器使用 latch=True 发布结果。迁移时先保留“可视化工具晚启动也能收到结果”的语义,再选择 QoS;发布函数名相似不代表交付行为相同。

本课验收

能验证晚加入订阅者收到保留数据,解释长回调如何影响反馈和取消。 未达到时先检查本课接口,不必继续增加新功能。

第 07 课:根据位置反馈执行 Action#

本课目标与前置

完成第 01~06 课的节点和接口。两代实验分别运行,不要求两代 ROS 互通。

先理解时序,再读代码#

客户端发送目标 → 服务端接受或拒绝
↓ 接受
读取 position → 计算位置误差 → 发布 cmd_vel
↑ ↓
└───── 模拟器更新位置 ───────┘
↓
到达 / 取消 / 状态超时 → 发布零速度 → 返回最终结果

反馈是执行中的观测,结果是最终状态。Future 是尚未完成的响应:接受目标和完成任务对应两个不同阶段,不要混为一谈。先看正常流程,再学习取消,最后回看锁和回调组。

为什么这里才引入并发

任务执行期间还要接收位置和取消。ROS 1 的执行回调可能与订阅回调并发;ROS 2 需要为这些回调留下执行机会。锁用于保护共享状态,不是 Action 本身的定义。

运行终端核对表#

每个终端先加载对应 ROS 和工作空间环境。下表实验使用根命名空间;不要同时保留 Launch 中的 uav1 实例来代替它。

终端ROS 1ROS 2
A:基础服务roscore不需要 Master
B:模拟器rosrun ros1_basics velocity_sub.pyros2 run ros2_basics velocity_sub
C:启用后观察位置rosservice call /set_enabled “data: true”,再 rostopic echo /positionros2 service call /set_enabled std_srvs/srv/SetBool “{data: true}“,再 ros2 topic echo /position
D:Action 服务端rosrun ros1_basics move_server.pyros2 run ros2_basics move_server
E:发送目标rosrun ros1_basics move_client.py本课 CLI 或 Python 客户端

运行 E 前确认 D 已启动、B 已启用,而且没有 velocity_pub 或 velocity_cpp 仍在发布。验证取消后,观察 position 连续几条消息是否保持不变;收到取消结果不等于真实设备已停止,本例只验证模拟状态。

失败实验: 关闭 B,应在状态过期后中止;不启用 B,位置不前进,客户端会在等待超时后取消;非法目标应拒绝或中止。每次重新启动 B 后都要重新调用启用服务。

ROS 1:接入已经写好的模拟器#

Action 是带任务状态、反馈和取消机制的通信方式。客户端发 Goal;服务端持续发 Feedback;任务结束产生 Result。应区分“目标被接收”和“任务已成功”。

保存到 ros1_basics/scripts/move_server.py:

#!/usr/bin/env python3
# 检查 NaN/无穷等非有限输入,避免异常数值进入速度或位置计算。
import math
# 为可能并发的回调保护共享状态;锁不负责调度回调。
import threading
# monotonic 用于持续时间与超时,不受系统日期校时影响;sleep 只等待,不更新位置。
import time
# ROS 1 Action 客户端与服务端,管理目标、反馈、取消和最终状态。
import actionlib
# ROS 1 Python 客户端库:节点、话题、服务、日志和时钟 API。
import rospy
# Twist 承载速度;PointStamped 额外携带坐标系、时间戳与位置。
from geometry_msgs.msg import PointStamped, Twist
# 从 ROS 1 接口包生成的 Python 类型导入,不是手工编写这些消息类。
from robot_msgs.msg import MoveToAction, MoveToFeedback, MoveToResult
# 闭环任务服务端:依据 position 测量计算命令,不能直接修改模拟器的 x。
class MoveServer:
# 构造阶段建立资源和初始状态;构造完成后再由 spin 驱动事件处理。
def __init__(self):
# 保护多个回调共同访问的变量,读取和更新同一组状态都要使用同一把锁。
self.lock = threading.Lock()
# None 表示尚无测量,不能把默认 0 当作已经收到的位置。
self.x = None
self.last_state = 0.0
# 创建相对名称的发布者,随 namespace 解析;保留对象供后续 publish 使用。
self.publisher = rospy.Publisher('cmd_vel', Twist, queue_size=10)
# 注册消息到达时执行的函数;此处只是建立订阅,不会主动读取一条消息。
self.subscription = rospy.Subscriber('position', PointStamped, self.on_position)
# 建立动作端点,分别绑定执行/接收/取消行为;这一步不发送运动目标。
self.server = actionlib.SimpleActionServer(
'move_to', MoveToAction, execute_cb=self.execute, auto_start=False)
# ROS 1 使用 auto_start=False,完成对象初始化后再开始接收目标。
self.server.start()
# 接收外部位置测量,并记下本机收到消息的时刻,用于检测反馈中断。
def on_position(self, message):
# 只接收约定参考系内的有限位置;不匹配时不更新新鲜度,随后会触发超时。
if message.header.frame_id == 'map' and math.isfinite(message.point.x):
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.lock:
self.x = message.point.x
# 记录最后一次有效反馈的接收时刻;仅代表接收新鲜度,不是网络延迟估计。
self.last_state = time.monotonic()
# 耗时任务回调:持续读取反馈,直到到达、取消、状态过期或总体超时。
def execute(self, goal):
# 结果默认未到达;只有反馈证明误差满足阈值时才设置 reached。
result = MoveToResult(reached=False, final_x=0.0)
# 边界处拒绝非法数值及超范围值;不要让它们进入控制计算。
if not math.isfinite(goal.target_x) or abs(goal.target_x) > 10.0:
# 显式进入失败终态,不能以 reached=false 冒充成功状态。
self.server.set_aborted(result, 'target_x must be finite and within [-10, 10]')
return
# 记录整次任务的起点,分别用于首次反馈等待和总体超时。
started = time.monotonic()
# 默认按失败结束,成功/取消必须由后续条件显式确认。
status = 'aborted'
reason = 'shutdown or timeout'
try:
# ROS 1 退出标志置位后结束循环,不把程序写成永不退出的 while True。
while not rospy.is_shutdown():
now = time.monotonic()
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.lock:
# 一次性取得位置与对应接收时间,防止读到来自不同更新周期的组合。
x, stamp = self.x, self.last_state
if x is not None:
result.final_x = x
# 每轮主动检查取消;ROS 1 这里也包含被新目标替代的情况。
if self.server.is_preempt_requested():
status, reason = 'canceled', 'cancel or replacement requested'
break
# 启动后先允许最多 2 秒等待第一条有效反馈,不立即把尚未发现消息当成失败。
if x is None and now - started < 2.0:
# 暂停控制循环约 50 ms;位置仍由独立模拟器积分,其他回调需有机会运行。
time.sleep(0.05)
continue
# 无反馈、反馈超过 0.5 秒未更新、或任务超过 60 秒,任一成立就中止。
if x is None or now - stamp > 0.5 or now - started > 60.0:
reason = 'missing/stale position or movement timeout'
break
# 目标是 map 中的绝对 x;用目标减测量,而不是在本地给位置加步长。
error = goal.target_x - x
# 发送当前测量作为进度;反馈与最终 Result 是不同消息。
self.server.publish_feedback(MoveToFeedback(current_x=x))
# 反馈误差不超过 2 cm 才认为到达;不是根据运行时间猜测成功。
if abs(error) <= 0.02:
result.reached = True
status = 'success'
break
command = Twist()
# 比例系数 2.0 将位置误差映射为速度,再限幅到 ±0.5 m/s。
command.linear.x = max(-0.5, min(0.5, 2.0 * error))
self.publisher.publish(command)
# 暂停控制循环约 50 ms;位置仍由独立模拟器积分,其他回调需有机会运行。
time.sleep(0.05)
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
# 发送全零速度作为结束命令;真实设备仍需状态确认,本模拟器另有命令超时兜底。
self.publisher.publish(Twist())
if status == 'success':
# 显式进入成功终态;客户端随后可以取得最终结果。
self.server.set_succeeded(result)
elif status == 'canceled':
# 显式标记任务已取消/被替代,区别于仅接收到取消请求。
self.server.set_preempted(result, reason)
else:
# 显式进入失败终态,不能以 reached=false 冒充成功状态。
self.server.set_aborted(result, reason)
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
# 向 ROS 1 注册节点名称;必须先初始化,再创建依赖节点身份的通信对象。
rospy.init_node('move_server')
server = MoveServer()
# 保持节点存活以处理通信;不等于手工重复调用订阅回调。
rospy.spin()

Action 不再直接修改位置,而是读取模拟器发布的 position,计算误差,向 cmd_vel 发布速度。只有反馈位置与目标相差不超过 0.02 m 才成功。目标是 map 坐标系下的绝对位置。比例系数 2.0 和速度限幅 ±0.5 m/s 只是教学控制规则。

状态初始化最多等待 2 秒;状态超过 0.5 秒未更新,或任务超过 60 秒,就中止。无状态失败时,结果中的默认 final_x=0 不代表测量。

切换前关闭固定速度发布者和 demo.launch。 保留 Master,在终端 A 重新运行 rosrun ros1_basics velocity_sub.py,终端 B 执行 rosservice call /set_enabled "data: true"。此时模拟器从零开始且已启用,之后才启动 Action 服务端。

保存 scripts/move_client.py:

#!/usr/bin/env python3
# ROS 1 Action 客户端与服务端,管理目标、反馈、取消和最终状态。
import actionlib
# ROS 1 Python 客户端库:节点、话题、服务、日志和时钟 API。
import rospy
# 从 ROS 1 接口包生成的 Python 类型导入,不是手工编写这些消息类。
from robot_msgs.msg import MoveToAction, MoveToGoal
# 向 ROS 1 注册节点名称;必须先初始化,再创建依赖节点身份的通信对象。
rospy.init_node('move_client')
# 客户端连接动作名称与类型;此时还未发送目标。
client = actionlib.SimpleActionClient('move_to', MoveToAction)
# 最多等待 5 秒发现 Action 服务端;服务端不存在时不盲目发送目标。
if not client.wait_for_server(rospy.Duration(5.0)):
raise RuntimeError('move_to server unavailable')
# 发送 ROS 1 目标,并注册每次反馈到来时执行的显示函数。
client.send_goal(
MoveToGoal(target_x=1.0),
feedback_cb=lambda feedback: rospy.loginfo('x=%.2f', feedback.current_x),
)
# 只在主流程等待最多 5 秒;到期未结束则发起取消,而不是假定任务已停止。
if not client.wait_for_result(rospy.Duration(5.0)):
# 向 ROS 1 服务端请求取消,后面仍需要等待其终态反馈。
client.cancel_goal()
# 给服务端额外时间处理取消,随后查看状态与结果。
client.wait_for_result(rospy.Duration(2.0))
rospy.loginfo('state=%s, result=%s', client.get_state(), client.get_result())

执行权限和运行方式:

# ROS 1 Action 服务端终端执行;模拟器已启动并启用,固定速度发布者已停止。
# 赋予脚本执行权限,使 rosrun 能直接启动;这不会运行脚本。
chmod +x ~/catkin_ws/src/ros1_basics/scripts/move_server.py
# 赋予脚本执行权限,使 rosrun 能直接启动;这不会运行脚本。
chmod +x ~/catkin_ws/src/ros1_basics/scripts/move_client.py
# 启动动作服务端并等待目标;此命令不会自行发送目标。
rosrun ros1_basics move_server.py

另一个终端运行 rosrun ros1_basics move_client.py。从零开始,正常目标 1.0 应在 5 秒等待内完成;改为 8.0 后客户端约 5 秒超时并取消;改为 20.0 后服务端返回 ABORTED。这里 SimpleActionServer 是接受目标后再进行业务校验,所以越界对应中止,不是 ROS 2 示例中的接收前拒绝。

ROS 2:保持相同闭环语义#

保存到 ros2_basics/move_server.py:

# 检查 NaN/无穷等非有限输入,避免异常数值进入速度或位置计算。
import math
# 为可能并发的回调保护共享状态;锁不负责调度回调。
import threading
# monotonic 用于持续时间与超时,不受系统日期校时影响;sleep 只等待,不更新位置。
import time
# ROS 2 Python 客户端库;回调需要通过 executor/spin 获得执行机会。
import rclpy
# Twist 承载速度;PointStamped 额外携带坐标系、时间戳与位置。
from geometry_msgs.msg import PointStamped, Twist
# 动作 API 将接收目标、执行、取消分开处理;客户端 Future 表示尚未到达的响应。
from rclpy.action import ActionServer, CancelResponse, GoalResponse
# 允许 Action 回调重入,以免长执行回调阻挡取消请求;共享数据需另行加锁。
from rclpy.callback_groups import ReentrantCallbackGroup
# 多线程执行器提供执行线程,但是否能并发仍由回调组约束。
from rclpy.executors import MultiThreadedExecutor
# Node 封装节点身份、参数以及发布/订阅等 ROS 资源。
from rclpy.node import Node
# ROS 2 生成的 Action 类型,其 Goal/Result/Feedback 是嵌套类型。
from robot_interfaces.action import MoveTo
# 闭环任务服务端:依据 position 测量计算命令,不能直接修改模拟器的 x。
class MoveServer(Node):
# 构造阶段建立资源和初始状态;构造完成后再由 spin 驱动事件处理。
def __init__(self):
# 创建具有指定默认名称的 ROS 2 节点;名称仍可通过启动参数重映射。
super().__init__('move_server')
# 用独占锁表示已接受一个任务;目标接收成功后直到任务退出才释放。
self.busy = threading.Lock()
# 保护多个回调共同访问的变量,读取和更新同一组状态都要使用同一把锁。
self.state_lock = threading.Lock()
# None 表示尚无测量,不能把默认 0 当作已经收到的位置。
self.x = None
self.last_state = 0.0
# 创建相对名称的发布者,随 namespace 解析;保留对象供后续 publish 使用。
self.publisher = self.create_publisher(Twist, 'cmd_vel', 10)
# 注册消息到达时执行的函数;此处只是建立订阅,不会主动读取一条消息。
self.subscription = self.create_subscription(
PointStamped, 'position', self.on_position, 10)
# 建立动作端点,分别绑定执行/接收/取消行为;这一步不发送运动目标。
self.server = ActionServer(
self, MoveTo, 'move_to', self.execute,
goal_callback=self.on_goal,
cancel_callback=self.on_cancel,
callback_group=ReentrantCallbackGroup(),
)
# 接收外部位置测量,并记下本机收到消息的时刻,用于检测反馈中断。
def on_position(self, message):
# 只接收约定参考系内的有限位置;不匹配时不更新新鲜度,随后会触发超时。
if message.header.frame_id == 'map' and math.isfinite(message.point.x):
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.state_lock:
self.x = message.point.x
# 记录最后一次有效反馈的接收时刻;仅代表接收新鲜度,不是网络延迟估计。
self.last_state = time.monotonic()
# 在执行前校验范围和忙闲状态;拒绝的目标不会进入 execute。
def on_goal(self, request):
# 边界处拒绝非法数值及超范围值;不要让它们进入控制计算。
if not math.isfinite(request.target_x) or abs(request.target_x) > 10.0:
return GoalResponse.REJECT
# 非阻塞占用任务槽;已有任务时立即拒绝,而不是让目标无限排队。
if not self.busy.acquire(blocking=False):
return GoalResponse.REJECT
return GoalResponse.ACCEPT
# 允许取消只是接收请求;执行循环随后还要检查标志并进入 canceled 终态。
def on_cancel(self, goal_handle):
return CancelResponse.ACCEPT
# 耗时任务回调:持续读取反馈,直到到达、取消、状态过期或总体超时。
def execute(self, goal_handle):
# 结果默认未到达;只有反馈证明误差满足阈值时才设置 reached。
result = MoveTo.Result()
# 记录整次任务的起点,分别用于首次反馈等待和总体超时。
started = time.monotonic()
# 默认按失败结束,成功/取消必须由后续条件显式确认。
status = 'aborted'
try:
# 只在 ROS 2 上下文有效期间继续任务。
while rclpy.ok():
now = time.monotonic()
# 在锁内读取/修改共享状态;离开 with 自动释放,避免计算期间被另一回调改写。
with self.state_lock:
# 一次性取得位置与对应接收时间,防止读到来自不同更新周期的组合。
x, stamp = self.x, self.last_state
if x is not None:
result.final_x = x
# 每轮主动检查取消;ROS 1 这里也包含被新目标替代的情况。
if goal_handle.is_cancel_requested:
status = 'canceled'
break
# 启动后先允许最多 2 秒等待第一条有效反馈,不立即把尚未发现消息当成失败。
if x is None and now - started < 2.0:
# 暂停控制循环约 50 ms;位置仍由独立模拟器积分,其他回调需有机会运行。
time.sleep(0.05)
continue
# 无反馈、反馈超过 0.5 秒未更新、或任务超过 60 秒,任一成立就中止。
if x is None or now - stamp > 0.5 or now - started > 60.0:
self.get_logger().error('missing/stale position or movement timeout')
break
feedback = MoveTo.Feedback()
feedback.current_x = x
# 发送当前测量作为进度;反馈与最终 Result 是不同消息。
goal_handle.publish_feedback(feedback)
# 目标是 map 中的绝对 x;用目标减测量,而不是在本地给位置加步长。
error = goal_handle.request.target_x - x
# 反馈误差不超过 2 cm 才认为到达;不是根据运行时间猜测成功。
if abs(error) <= 0.02:
result.reached = True
status = 'success'
break
command = Twist()
# 比例系数 2.0 将位置误差映射为速度,再限幅到 ±0.5 m/s。
command.linear.x = max(-0.5, min(0.5, 2.0 * error))
self.publisher.publish(command)
# 暂停控制循环约 50 ms;位置仍由独立模拟器积分,其他回调需有机会运行。
time.sleep(0.05)
# 上下文仍有效才调用后续通信/关闭操作,避免重复 shutdown。
if rclpy.ok():
# 发送全零速度作为结束命令;真实设备仍需状态确认,本模拟器另有命令超时兜底。
self.publisher.publish(Twist())
if status == 'success':
# 显式进入成功终态;客户端随后可以取得最终结果。
goal_handle.succeed()
elif status == 'canceled':
# 显式标记任务已取消/被替代,区别于仅接收到取消请求。
goal_handle.canceled()
else:
# 显式进入失败终态,不能以 reached=false 冒充成功状态。
goal_handle.abort()
return result
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
# 任务退出后释放占用状态,使下一次合法目标能够被接受。
self.busy.release()
# 程序入口负责初始化通信、创建节点、保持运行以及退出时释放资源。
def main(args=None):
# 初始化 ROS 2 上下文并处理传入的 ROS 命令行参数。
rclpy.init(args=args)
node = MoveServer()
# 一个线程可等待运动,另一个线程继续处理位置和取消;回调组也必须允许相应并发。
executor = MultiThreadedExecutor(num_threads=2)
# 把节点交给此执行器管理;创建线程池本身不会自动包含节点。
executor.add_node(node)
try:
# 由执行器处理已就绪的订阅、服务、定时器等事件,直到退出。
executor.spin()
# 响应 Ctrl+C/ROS 退出,让程序进入正常清理路径。
except KeyboardInterrupt:
pass
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
# 上下文仍有效才调用后续通信/关闭操作,避免重复 shutdown。
if rclpy.ok():
# 关闭 ROS 2 通信上下文;不是对真实机器人的停止确认。
rclpy.shutdown()
# 等待执行器任务退出,再销毁动作服务端与节点。
executor.shutdown()
# 显式释放 Action 资源,随后再销毁所属节点。
node.server.destroy()
# 释放节点及其通信资源;不要只结束循环而遗留节点对象。
node.destroy_node()
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
main()

位置订阅更新测量缓存,执行回调读取缓存并发送速度。执行回调等待机器人运动时,必须继续处理订阅和取消,因此这里使用多线程 executor 与可重入 Action 回调组。state_lock 保护测量缓存,busy 限制同时只有一个任务,职责不同。

time.sleep 只让控制循环短暂等待,不更新位置。另一线程处理状态与取消。真实设备还需要处理控制权、异常和停止确认,本例用于理解 ROS 交互。

与 ROS 1 的单目标服务器不同,这个 ROS 2 示例会拒绝忙时的新目标,不会自动抢占。取消请求被允许后,执行循环还需要检查并调用 canceled();仅返回 CancelResponse.ACCEPT 不代表动作已经停止。用法可对照 ROS 2 官方 Action 示例。

在 setup.py 的 console_scripts 中追加:

'move_server = ros2_basics.move_server:main',

重新构建并加载环境后,关闭 demo.launch 和固定速度发布器。终端 A 运行 ros2 run ros2_basics velocity_sub;终端 B 执行 ros2 service call /set_enabled std_srvs/srv/SetBool "{data: true}"。保持 A 运行,终端 C 启动:

# ROS 2 Action 服务端终端执行;模拟器已启动并启用,不能同时保留固定速度发布器。
# 运行已注册的入口;--ros-args 后为 ROS 参数/重映射。Action 服务端只等待目标,不主动移动。
ros2 run ros2_basics move_server

另一个终端:

# ROS 2 客户端终端执行;服务端已启动。send_goal 会显示反馈并等待最终结果。
# -t 同时显示动作名称和类型,用于检查接口发现是否正常。
ros2 action list -t
# 查看动作的服务端/客户端数量,确认目标应发送到哪个服务端。
ros2 action info /move_to
# 目标为 map 中绝对 x=1.0 m;--feedback 显示执行中位置,最终应返回到达结果。
ros2 action send_goal /move_to robot_interfaces/action/MoveTo "{target_x: 1.0}" --feedback

预期看到位置反馈和成功结果。模拟器刚重启、位置为零时,目标为 0.0 立即成功;目标为 20.0 时被拒绝。两个目标同时发送时,后到的目标会被拒绝。

3. 客户端如何取消目标#

保存 ros2_basics/move_client.py。加载环境后,使用 python3 执行这个文件:

# ROS 2 Python 客户端库;回调需要通过 executor/spin 获得执行机会。
import rclpy
# 动作 API 将接收目标、执行、取消分开处理;客户端 Future 表示尚未到达的响应。
from rclpy.action import ActionClient
# ROS 2 生成的 Action 类型,其 Goal/Result/Feedback 是嵌套类型。
from robot_interfaces.action import MoveTo
# 程序入口负责初始化通信、创建节点、保持运行以及退出时释放资源。
def main():
# 初始化 ROS 2 上下文并处理传入的 ROS 命令行参数。
rclpy.init()
node = rclpy.create_node('move_client')
# 客户端连接动作名称与类型;此时还未发送目标。
client = ActionClient(node, MoveTo, 'move_to')
try:
# 最多等待 5 秒发现 Action 服务端;服务端不存在时不盲目发送目标。
if not client.wait_for_server(timeout_sec=5.0):
raise RuntimeError('move_to server unavailable')
goal = MoveTo.Goal()
# 用较远的 8 m 目标演示 5 秒等待后取消;改为 1 m 可验证正常完成。
goal.target_x = 8.0
# 此 Future 等待“是否接受目标”;任务结果还要另外调用 get_result_async。
future = client.send_goal_async(
goal,
feedback_callback=lambda event: node.get_logger().info(
f'x={event.feedback.current_x:.2f}'),
)
# 在主流程中处理事件直到该 Future 完成或超时;不要直接照搬到互斥回调内部。
rclpy.spin_until_future_complete(node, future, timeout_sec=5.0)
# 检查等待是否真正完成;超时不等于服务端没有执行,也不等于机器人停止。
if not future.done():
raise TimeoutError('goal response timed out; server acceptance is unknown')
# 得到目标句柄,用它检查接受状态、获取结果或取消这个具体目标。
handle = future.result()
# 拒绝的目标不会执行;不要继续等待它的运动结果。
if not handle.accepted:
raise RuntimeError('goal rejected')
# 结果 Future 对应任务最终结束,不是目标接收响应。
result_future = handle.get_result_async()
# 在主流程中处理事件直到该 Future 完成或超时;不要直接照搬到互斥回调内部。
rclpy.spin_until_future_complete(node, result_future, timeout_sec=5.0)
# 检查等待是否真正完成;超时不等于服务端没有执行,也不等于机器人停止。
if not result_future.done():
# 请求取消当前目标;取消响应与最终结果仍是两个阶段。
cancel_future = handle.cancel_goal_async()
# 在主流程中处理事件直到该 Future 完成或超时;不要直接照搬到互斥回调内部。
rclpy.spin_until_future_complete(node, cancel_future, timeout_sec=2.0)
# 在主流程中处理事件直到该 Future 完成或超时;不要直接照搬到互斥回调内部。
rclpy.spin_until_future_complete(node, result_future, timeout_sec=2.0)
# 检查等待是否真正完成;超时不等于服务端没有执行,也不等于机器人停止。
if not result_future.done():
raise TimeoutError('no terminal result; cancellation is not confirmed')
# 终态结果包含 status 和业务 result;两者都要检查。
response = result_future.result()
node.get_logger().info(f'status={response.status}, result={response.result}')
# 无论正常完成还是发生异常,都执行本分支的资源/状态清理。
finally:
# 释放 ROS 2 Action 客户端端点。
client.destroy()
# 释放节点及其通信资源;不要只结束循环而遗留节点对象。
node.destroy_node()
# 上下文仍有效才调用后续通信/关闭操作,避免重复 shutdown。
if rclpy.ok():
# 关闭 ROS 2 通信上下文;不是对真实机器人的停止确认。
rclpy.shutdown()
# 直接执行此文件时调用入口;被别的模块导入时不自动启动节点。
if __name__ == '__main__':
main()

每次验收前重启模拟器并重新启用服务。从零开始时,目标 8.0 来不及在 5 秒内完成,客户端请求取消并等待最终状态;目标改成 1.0 则成功。ROS 2 Action 状态中,SUCCEEDED 为 4、CANCELED 为 5、ABORTED 为 6。

“等待结果超时”只说明客户端没有及时收到结果;不能据此宣称机器人停止。实际设备需要明确的取消结果和状态确认。

对照 MRS: 本文 Action 是新设计的教学接口,不表示课程已有 move_to 服务端。课程的轨迹执行包含更多状态与约束;可以借用目标、反馈、结果的思路理解任务管理,但不能把这段一维控制器直接接到无人机上。

本课验收

目标 1 m 到达误差不超过 0.02 m;长目标可取消;反馈中断应中止;服务停用会暂停位置变化。 未达到时先检查本课接口,不必继续增加新功能。

第 08 课:坐标系、模型与可视化#

本课目标与前置

先做 TF2 实验。URDF、RViz 和传感器按需学习,Gazebo/SLAM 为扩展练习。

1. TF 与 TF2:同一个点属于哪个坐标系#

坐标 [1, 0, 0] 本身不完整,还需要说明“相对于谁”。机器人可能同时使用 map、odom、base_link、camera_link 和 laser_link。

TF 是 ROS 坐标变换体系,TF2 是后续实现,ROS 1 和 ROS 2 都可以使用。一个变换包含父子坐标系、平移、旋转和时间信息;查询某时刻的变换需要对应时间的数据。修改消息的 frame_id 字符串不会改变坐标数值,更不会替你完成变换。

先用固定传感器安装位置做实验。ROS 1 中:

# ROS 1 静态 TF 发布终端执行并保持运行;不要同时运行发布相同变换的 robot_state_publisher。
# 参数依次为 x/y/z、yaw/pitch/roll、父 frame、子 frame;本例旋转全为零。
rosrun tf2_ros static_transform_publisher 0.2 0 0.1 0 0 0 base_link laser_link

ROS 2 中:

# ROS 2 静态 TF 发布终端执行并保持运行;坐标单位为米,角度单位为弧度。
# 发布 base_link 到 laser_link 的固定安装关系;命名参数避免混淆平移与欧拉角顺序。
ros2 run tf2_ros static_transform_publisher \
--x 0.2 --y 0 --z 0.1 --roll 0 --pitch 0 --yaw 0 \
--frame-id base_link --child-frame-id laser_link

另开相应终端查询:

# ROS 1 查询终端执行;前面的静态变换发布者应保持运行。Ctrl+C 结束查询。
# ROS 1
# 持续查询两个坐标系的相对变换;预期平移约为 (0.2, 0, 0.1)。
rosrun tf tf_echo base_link laser_link
# ROS 2 查询终端执行;前面的静态变换发布者应保持运行。Ctrl+C 结束查询。
# ROS 2
# 查询并显示两个 frame 的变换;检查平移、旋转以及 frame 是否存在。
ros2 run tf2_ros tf2_echo base_link laser_link

应看到约 (0.2, 0, 0.1) 的平移与单位旋转。ROS 1 查询命令使用 tf 工具包;ROS 2 的命名参数用法参见 TF2 静态广播教程。

2. URDF、Xacro、Joint State 和 Robot State Publisher#

工具或数据作用
URDF用 link 与 joint 描述机器人结构
Xacro用参数和宏减少 URDF 重复内容,最终生成 URDF
sensor_msgs/JointState描述关节名称、位置以及可选速度和力矩
robot_state_publisher结合模型与关节状态发布连杆之间的 TF
joint_state_publisher可在没有实际驱动时提供关节状态,常用于模型检查

一个最小 URDF 可以写成:

<!-- URDF 根元素:只描述结构,下面没有驱动或物理仿真插件。 -->
<robot name="demo_robot">
<!-- 机体参考连杆;visual 只定义显示外观。 -->
<link name="base_link">
<!-- 显示几何,不会自动提供碰撞模型或惯性参数。 -->
<visual>
<!-- 长方体边长单位为米,作为机体的简单显示模型。 -->
<geometry><box size="0.4 0.3 0.1"/></geometry>
</visual>
</link>
<!-- 激光传感器参考系,此处没有添加传感器数据源。 -->
<link name="laser_link"/>
<!-- 固定关节描述安装关系,模型发布器可据此发布静态变换。 -->
<joint name="laser_mount" type="fixed">
<!-- 父参考系:传感器的安装位置相对它表达。 -->
<parent link="base_link"/>
<!-- 子参考系:这个 joint 决定它相对父系的位姿。 -->
<child link="laser_link"/>
<!-- 平移单位米,rpy 为滚转/俯仰/偏航弧度;全零表示无旋转。 -->
<origin xyz="0.2 0 0.1" rpy="0 0 0"/>
</joint>
</robot>

这是结构示例,不含仿真传感器或驱动。把该模型交给 robot_state_publisher 后,固定关节会产生相应 TF;可动关节则需要匹配名称的关节状态。若由它发布 base_link → laser_link,应停止前面的同一变换静态发布器,避免重复来源。

模型中的关节状态不等于机器人在世界中的位姿。odom → base_link 通常来自里程计,map → odom 通常来自定位或建图系统。

3. RViz 与 rqt:显示和观察,不替代算法#

ROS 1 使用 rviz,ROS 2 使用 rviz2。RViz 是数据可视化工具;它不会自动生成地图、规划路径或模拟物理运动。

在上述 TF 实验中,启动 RViz,将 Fixed Frame 设为 base_link,添加 TF 显示项,就可以观察 laser_link。后续可加入 RobotModel、LaserScan、Image、Path 和 Map 等显示项。看不到数据时按“话题是否有消息 → frame 是否存在 → 时间是否匹配 → QoS 是否兼容”检查。

rqt 是图形工具框架。rqt_graph 用来查看节点连接关系,rqt_plot 可查看数值随时间变化;先看图,再用 CLI 核实类型与频率。图上有连线不等于数据符合业务约束。

4. 传感器模拟与 Gazebo 扩展练习#

数据常见 ROS 类型首先检查
相机sensor_msgs/Image、CameraInfo编码、标定信息、时间戳
激光雷达sensor_msgs/LaserScan 或 PointCloud2frame、量程、采样方向
IMUsensor_msgs/Imu单位、坐标约定、协方差
GPS/GNSSsensor_msgs/NavSatFix经纬度、状态、协方差

模拟器负责生成这些数据,ROS 负责传输;没有驱动或仿真插件,就不会自动出现 /scan 或 /imu/data。

可选综合练习是“在 Gazebo 中运行带激光雷达的移动机器人,接入 SLAM,并在 RViz 中显示地图和坐标系”。先完成以下接口链,再研究模型细节:

速度命令 → 仿真机器人 → 激光与里程计 → SLAM → 地图
↓ ↓
odom→base_link map→odom

这需要额外的机器人模型、仿真驱动与 SLAM 软件,不是运行 RViz 即可完成。ROS 1 的 Gazebo Classic 教程与 ROS 2 的现代 Gazebo 插件不能直接混用;Jazzy 的推荐配套为 Harmonic,见 Gazebo 官方搭配说明。

Nav2 是 ROS 2 的移动机器人导航框架,组合定位、规划、控制和行为管理等模块,通常要求正确的地图、传感器和 TF。它不是 ROS 通信入门的先修课,也不是直接替代 MRS 三维无人机控制的工具。

本课验收

能查询静态坐标变换,解释改 frame_id 为什么不会改变坐标值。 未达到时先检查本课接口,不必继续增加新功能。

第 09 课:录制、回放与排错#

本课目标与前置

使用第 01 或 04 课的速度实验。回放速度前关闭 Action 和实时速度发布器。

1. 先录制已经存在的速度话题#

本节假设正在运行 Launch 启动的 /uav1/cmd_vel。在读者自己的实验目录中执行,输出名应选择尚不存在的新文件或目录。

ROS 1:

# ROS 1 录制终端,在可写的实验目录执行;/uav1/cmd_vel 已有消息,输出文件名不要与已有记录重复。
# -O 指定 bag 文件;只录制列出的话题。录制数秒后 Ctrl+C 正常结束并写完文件。
rosbag record -O velocity_demo.bag /uav1/cmd_vel
# 录制几秒后 Ctrl+C
# 检查话题类型、消息数量与时长,确认确实录到了数据。
rosbag info velocity_demo.bag
# 停止实时发布者,再回放;接收器可继续运行
# 按记录的节奏重新发布;先停止实时发布者,避免命令来源混杂。
rosbag play velocity_demo.bag

ROS 2:

# ROS 2 录制终端,在可写的实验目录执行;/uav1/cmd_vel 已有消息,输出目录必须是新名称。
# -o 指定新的输出目录;记录列出的话题,Ctrl+C 正常关闭记录。
ros2 bag record -o velocity_demo /uav1/cmd_vel
# 录制几秒后 Ctrl+C
# 检查记录元信息和消息数量;目录存在不等于数据完整。
ros2 bag info velocity_demo
# 停止实时发布者,再回放
# 重发记录中的消息;本次记录不恢复服务启用状态或模拟器初始位置。
ros2 bag play velocity_demo

回放会重新发布消息;本例的启停服务状态与模拟器初始位置没有包含在这次只录制速度话题的操作中。若接收器未启用,位置保持不变,这是预期行为。

2. 录制传感器数据时还需要什么#

先确认驱动或模拟器确实在发布传感器话题,例如用 rostopic list 或 ros2 topic list 查到 /scan。在下面约定的话题存在后,才使用:

# ROS 1 传感器实验终端执行;这些话题须由驱动/仿真器实际发布,不能只创建空记录。
# ROS 1:LaserScan、里程计和坐标变换
# -O 指定 bag 文件;只录制列出的话题。录制数秒后 Ctrl+C 正常结束并写完文件。
rosbag record -O sensor_demo.bag /scan /odom /tf /tf_static
# ROS 2 传感器实验终端执行;先确认真实话题名、类型与 QoS,再按实际情况修改列表。
# ROS 2:同样的数据集合
# -o 指定新的输出目录;记录列出的话题,Ctrl+C 正常关闭记录。
ros2 bag record -o sensor_demo /scan /odom /tf /tf_static

话题名依实际系统调整。静态 TF、迟到订阅与传感器 QoS 都可能影响录制完整性,录完应查看消息数量并试回放,不只确认文件存在。

需要按记录的时间驱动算法时,ROS 1 通常在启动相关节点前设置 /use_sim_time=true,使用 rosbag play --clock;ROS 2 给相关节点设置 use_sim_time:=true,使用 ros2 bag play sensor_demo --clock。没有 /clock 来源时,启用模拟时间的节点可能一直等待。录制不同频率的数据也不意味着它们自动时间同步。

3. 学会最小排查顺序#

问题ROS 1 检查ROS 2 检查
包或可执行文件找不到rospack find,检查权限与 setupros2 pkg executables,检查入口与 install
节点没出现rosnode list、节点日志、Master 环境ros2 node list、Domain、发现与网络
消息收不到rostopic info/type/echoros2 topic info -v、类型与 QoS
参数不起作用rosparam get,检查读取时机ros2 param get/describe,检查类型与回调
服务不可用rosservice list/typeros2 service list -t
Action 不结束检查反馈、取消和终态ros2 action info,检查 executor 与终态
环境异常版本、路径、ROS 环境变量ros2 doctor --report,发行版与中间件信息

ros2 doctor 可提供诊断线索,但不会证明你的任务逻辑正确。

4. Testing 应覆盖哪些行为#

测试先围绕接口语义,不必每次启动完整仿真:

场景应验证的行为
Topic 正常路径发布频率近似符合设定,类型和数值正确
参数边界speed 为 0 合法,超出允许范围应失败
Service启停响应与位置增长/暂停一致,服务缺失时报告超时
Action每次从零开始:目标 1 成功、0 立即成功、20 中止或拒绝;长目标可取消,状态中断会中止
多实例uav1 与 uav2 的话题和服务互不串线
QoS后加入者按约定获得保留数据,不兼容配置能被诊断
bag停止实时发布后,仍能从录制重现数据

算法纯函数适合单元测试,节点交互适合集成测试。ROS 1 可用 rostest;ROS 2 可用 pytest 和 launch_testing。已有测试时,ROS 2 可执行 colcon test --packages-select ros2_basics 与 colcon test-result --verbose。生成器自带的格式检查不能代替上述功能验收;本文没有提供完整自动集成测试包。

本课验收

能重放已录制数据,并区分环境、通信和业务逻辑问题。 未达到时先检查本课接口,不必继续增加新功能。

第 10 课:对照 MRS Summer School 源码#

本课目标与前置

已经理解参数、名称、Launch 和 QoS。先观察原项目,不必修改规划算法。

1. 先验证它使用了什么#

截至 2026-11-01 查阅的课程 master 分支,规划包的 package.xml 依赖 catkin 和 rospy;planner.py 使用 rospy.init_node、get_param 和 Publisher。因此我们可以用前面学过的 ROS 1 知识解释它。

课程目标是两架无人机完成带障碍的巡检任务。本文只借用其 ROS 接口组织;“路径规划”和“轨迹生成”先作为黑盒:输入问题和约束,输出路径与轨迹。

2. 按 ROS 数据流阅读三个位置#

入口用已经学过的概念解释
mrim_task/mrim_planner/package.xml这个包依赖谁、采用什么构建方式
mrim_task/mrim_planner/launch/planner.launch节点名、配置加载、Remap 与其他节点启动
mrim_task/mrim_planner/scripts/planner.py参数读取、问题加载、算法调用、消息发布

planner.launch 先加载 manager 的默认配置,再加载对应运行类型的 planner 配置。这解释了为什么只看一个 YAML 文件可能误判最终参数值。

planner.py 中的 ~problem/name 是私有参数;~trajectory_1_out 是私有输出话题。Launch 把它重映射成 ~trajectory_1,在该启动文件指定的节点名下,通常得到 /mrim_planner/trajectory_1。外层 namespace 改变时,最终名称也会改变。

发布者使用 latch=True,发布规划结果后保持运行。这正好对应前文 ROS 2 的 Transient Local 学习点,而不是简单的“换掉导入语句”。

3. 在课程环境中做一次只观察接口的实验#

按 课程 README 准备 Apptainer 镜像和依赖后,在课程仓库根目录运行:

# 课程提供的 Linux/Apptainer 环境中执行,当前目录必须是 summer-school-2026 仓库根目录。
# 使用课程脚本启动离线展示;它依赖课程环境,不是本教程的 ros2_basics 包。
./simulation/run_offline.sh

在已经加载课程 ROS 环境、能访问该节点图的终端中检查:

# 已加载课程 ROS 1 环境的观察终端执行;离线实验正在运行,节点名称以实际列表为准。
# 列出当前可见节点,先确认预期程序确实出现在 ROS 图中。
rosnode list
# 查看指定节点发布/订阅的话题和服务;用于核对连接关系。
rosnode info /mrim_planner
# 读取参数服务器的实际值;程序内部默认值不一定存在于服务器中。
rosparam get /mrim_planner/problem/name
# 查看话题类型与发布/订阅节点,确认它由谁提供。
rostopic info /mrim_planner/trajectory_1
# 查询运行中话题的消息类型;输出类型名可交给 rosmsg show。
rostopic type /mrim_planner/trajectory_1
# -n 1 表示收到一条后退出;检查实际数值和字段,不只看话题名。
rostopic echo -n 1 /mrim_planner/trajectory_1

根据 rostopic type 返回的实际类型,再执行 rosmsg show 查看字段。这一步比猜测消息里一定存在速度或时间字段更可靠。

离线模式适合观察数据流;需要动力学仿真时再阅读课程 run_simulation.sh 的说明。课程使用 MRS Multirotor Simulator,与前文可选的 Gazebo 移动机器人练习是不同环境。

4. 小范围 ROS 2 迁移练习#

选择一个“读取配置、生成结果、发布消息”的小模块,按下面顺序迁移:

  1. 写清输入输出的单位、frame、时间和失败语义,先保留算法行为。
  2. 将纯计算从 rospy 参数和消息依赖中分离。
  3. 用 rclpy.Node 加载参数,重新生成或适配 ROS 2 接口。
  4. 为一次性规划结果配置 QoS,为耗时任务决定是否需要 Action。
  5. 用 Launch 建立 namespace,再用 CLI 和录制数据验证。

不能把 TrajectoryReference 直接替换成 nav_msgs/Path 就宣称兼容:Path 适合表达与显示路径,不自动包含课程执行端要求的完整轨迹语义。也不能把 gps_origin 改名成 map 就认为坐标已经转换。

初学阶段在独立 ROS 2 工程中验证模块即可。若以后要与原 ROS 1 系统在线互通,再专门评估 ros1_bridge 的版本组合、构建依赖及自定义消息映射;桥接不会自动完成整个 MRS 系统迁移。

本课验收

能从启动文件追踪到最终参数和轨迹话题,说明迁移需保持什么行为。 未达到时先检查本课接口,不必继续增加新功能。

附录:C++ 节点与工程核对#

选修

Python 已能完成主线。C++ 示例只用于验证跨语言通信,不与其他速度发布器同时控制相同话题。

ROS 1 C++ 发布者#

下面是同一速度发布器的 C++ 最小版本,保存到 ros1_basics/src/velocity_cpp.cpp,先创建 src 目录:

// 引入 ROS API、消息或标准库声明;编译和链接依赖仍需在 CMake 中配置。
#include <ros/ros.h>
#include <geometry_msgs/Twist.h>
// 入口函数:初始化 ROS,建立节点与发布者,然后保持回调或循环运行。
int main(int argc, char** argv) {
// ROS 1 初始化节点身份与通信环境。
ros::init(argc, argv, "velocity_cpp");
// ROS 1 用 NodeHandle 创建发布/订阅等资源,相对名从其命名空间解析。
ros::NodeHandle node;
// 创建 cmd_vel 发布者,队列深度 10;话题类型与 Python 节点一致才能互通。
auto publisher = node.advertise<geometry_msgs::Twist>("cmd_vel", 10);
// 主动循环的目标频率为 10 Hz,不是硬实时保证。
ros::Rate rate(10);
// ROS 1 运行期间持续发布,退出标志置位后结束循环。
while (ros::ok()) {
geometry_msgs::Twist message;
// 只设置前向速度 0.5 m/s,其余分量保持零。
message.linear.x = 0.5;
// ROS 1 发布消息,数据随后由订阅端回调处理。
publisher.publish(message);
// 在主动循环中处理可用回调,避免回调永远得不到执行。
ros::spinOnce();
// 等待剩余周期,避免忙循环高速刷消息。
rate.sleep();
}
return 0;
}

在 ros1_basics/CMakeLists.txt 已有的 find_package、catkin_package() 后补充:

# 添加依赖包导出的头文件搜索路径。
include_directories(${catkin_INCLUDE_DIRS})
# 把指定 C++ 源文件编译为命名的可执行目标。
add_executable(velocity_cpp src/velocity_cpp.cpp)
# 链接 ROS 1 依赖库,只有头文件可见还不足以生成可执行文件。
target_link_libraries(velocity_cpp ${catkin_LIBRARIES})

重新 catkin_make 并加载环境后,可运行 rosrun ros1_basics velocity_cpp。先停掉 Python 发布器,避免同时向同一话题发布。这里的 ros::init、advertise、publish 分别对应 Python 中的初始化、创建发布者和发送消息。

对于 Python,源脚本加执行权限即可用于本节开发实验;正式安装包时还应在 CMakeLists.txt 使用 catkin_install_python(PROGRAMS ... DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}) 安装实际脚本。省略号表示文件列表说明,不是可直接复制的 CMake 命令。

ROS 2 C++ 发布者#

rclpy 适合快速写实验,rclcpp 常用于需要 C++ 生态或性能控制的模块。ROS 接口相同,两种语言节点可以互通。

创建独立 C++ 包:

# ROS 2 建包终端执行;只创建 C++ 包骨架,随后保存代码并补充 CMake 规则。
# 切换执行目录;后面的相对路径、构建命令都以此目录为基准。
cd ~/ros2_ws/src
# --build-type 指定包构建方式;--dependencies 写入依赖声明,不能代替环境依赖检查。
ros2 pkg create --build-type ament_cmake --license Apache-2.0 ros2_cpp_basics \
--dependencies rclcpp geometry_msgs

保存 ros2_cpp_basics/src/velocity_cpp.cpp:

// 引入 ROS API、消息或标准库声明;编译和链接依赖仍需在 CMake 中配置。
#include <chrono>
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "geometry_msgs/msg/twist.hpp"
// 入口函数:初始化 ROS,建立节点与发布者,然后保持回调或循环运行。
int main(int argc, char** argv) {
// ROS 2 初始化通信上下文并解析 ROS 参数。
rclcpp::init(argc, argv);
// ROS 2 节点对象,shared_ptr 保持节点在 spin 期间存活。
auto node = std::make_shared<rclcpp::Node>("velocity_cpp");
// 创建 cmd_vel 发布者,队列深度 10;话题类型与 Python 节点一致才能互通。
auto publisher = node->create_publisher<geometry_msgs::msg::Twist>("cmd_vel", 10);
// 每 100 ms 调度一次 lambda;按值捕获 publisher,使回调能使用它。
auto timer = node->create_wall_timer(std::chrono::milliseconds(100), [publisher]() {
geometry_msgs::msg::Twist message;
// 只设置前向速度 0.5 m/s,其余分量保持零。
message.linear.x = 0.5;
// ROS 2 发布消息;发送并不等于订阅端已经完成处理。
publisher->publish(message);
});
// 把回调调度交给执行器,定时器也在此期间运行。
rclcpp::spin(node);
// 结束通信上下文并允许资源清理;不是机器人停止确认。
rclcpp::shutdown();
return 0;
}

在生成的 CMakeLists.txt 中,已有 find_package 之后、ament_package() 之前加入:

# 把指定 C++ 源文件编译为命名的可执行目标。
add_executable(velocity_cpp src/velocity_cpp.cpp)
# 将 rclcpp 和消息包的头文件/链接要求绑定到该目标。
ament_target_dependencies(velocity_cpp rclcpp geometry_msgs)
# 将目标安装到 ROS 2 约定的 lib/包名 下,供 ros2 run 查找。
install(TARGETS velocity_cpp DESTINATION lib/${PROJECT_NAME})

在工作空间根目录构建 colcon build --packages-select ros2_cpp_basics,加载环境后运行 ros2 run ros2_cpp_basics velocity_cpp。关闭其他速度发布器,保留之前的 Python 订阅者,即可验证跨语言通信。

主线完成后的文件清单#

这些文件都由前面的课程逐步创建,目录树不是要求一次性创建全部内容。两个工作空间相互独立;课程仓库使用自己的环境。

~/catkin_ws/src/
├── ros1_basics/
│ ├── package.xml、CMakeLists.txt
│ ├── config/velocity.yaml
│ ├── launch/demo.launch
│ └── scripts/
│ ├── velocity_pub.py、velocity_sub.py
│ ├── enable_client.py
│ └── move_server.py、move_client.py
└── robot_msgs/
├── package.xml、CMakeLists.txt
├── msg/Waypoint.msg
├── srv/CheckTarget.srv
└── action/MoveTo.action
~/ros2_ws/src/
├── ros2_basics/
│ ├── package.xml、setup.py、setup.cfg
│ ├── resource/ros2_basics
│ ├── config/velocity.yaml
│ ├── launch/demo.launch.py
│ └── ros2_basics/
│ ├── __init__.py
│ ├── velocity_pub.py、velocity_sub.py
│ ├── enable_client.py
│ └── move_server.py、move_client.py
└── robot_interfaces/
├── package.xml、CMakeLists.txt
├── msg/Waypoint.msg
├── srv/CheckTarget.srv
└── action/MoveTo.action

ROS 2 最终 setup.py:核对完整文件#

前面的课程解释逐项添加的原因;主线全部完成后,可以用下列完整文件核对,避免插错列表位置。保留生成器提供的 setup.cfg、resource 文件和 init.py。

# 收集要安装的 Launch/YAML 文件;匹配结果来自包根目录。
from glob import glob
# Python 打包工具,用于声明可安装模块、资源和命令行入口。
from setuptools import find_packages, setup
package_name = 'ros2_basics'
# 以下是完整 Python 包配置;此处声明安装内容,不启动 ROS 节点。
setup(
name=package_name,
version='0.0.0',
# 收集 Python 模块并排除测试目录,保留 __init__.py 使包可导入。
packages=find_packages(exclude=['test']),
# 安装 ROS 包索引、package.xml、Launch 和 YAML;源码存在不代表运行时能找到。
data_files=[
# 资源索引标记让 ROS 2 能发现这个包,必须保留生成器创建的 resource 文件。
('share/ament_index/resource_index/packages', ['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
('share/' + package_name + '/launch', glob('launch/*.launch.py')),
('share/' + package_name + '/config', glob('config/*.yaml')),
],
# 这里只声明 Python 打包依赖;ROS 包依赖仍在 package.xml 中声明。
install_requires=['setuptools'],
zip_safe=True,
maintainer='student',
maintainer_email='student@example.com',
description='ROS communication and closed-loop teaching examples',
license='Apache-2.0',
# 入口格式为“命令名 = Python 模块:函数”;ros2 run 通过该入口调用 main。
entry_points={
# 入口格式为“命令名 = Python 模块:函数”;ros2 run 通过该入口调用 main。
'console_scripts': [
'velocity_pub = ros2_basics.velocity_pub:main',
'velocity_sub = ros2_basics.velocity_sub:main',
'enable_client = ros2_basics.enable_client:main',
'move_server = ros2_basics.move_server:main',
'move_client = ros2_basics.move_client:main',
],
},
)

两个节点包的最终 package.xml#

下面是本文学习工程的完整清单,接口包自身仍使用第 02、05 课的配置。如果修改的是已有工程,应合并所需依赖,保留自己的维护者、测试和其他依赖。

ROS 1 的 ros1_basics/package.xml:

<?xml version="1.0"?>
<!-- 包元数据根元素,format 指定 package.xml 规范版本。 -->
<package format="2">
<name>ros1_basics</name>
<version>0.0.0</version>
<description>ROS 1 teaching examples</description>
<!-- 示例维护者信息;自己的工程应填写实际维护者。 -->
<maintainer email="student@example.com">student</maintainer>
<!-- 该示例工程的许可证声明,不改变整篇博客的许可证。 -->
<license>Apache-2.0</license>
<!-- 声明构建/生成工具;运行依赖与它承担不同职责。 -->
<buildtool_depend>catkin</buildtool_depend>
<depend>rospy</depend>
<depend>roscpp</depend>
<!-- 节点使用 Twist 和 PointStamped,需要几何消息包。 -->
<depend>geometry_msgs</depend>
<!-- 启停服务使用 SetBool 标准接口。 -->
<depend>std_srvs</depend>
<!-- 节点消费自定义 ROS 1 接口,显式声明构建与运行依赖。 -->
<depend>robot_msgs</depend>
<!-- 节点使用 ROS 1 动作协议客户端/服务端。 -->
<depend>actionlib</depend>
</package>

ROS 2 的 ros2_basics/package.xml:

<?xml version="1.0"?>
<!-- 包元数据根元素,format 指定 package.xml 规范版本。 -->
<package format="3">
<name>ros2_basics</name>
<version>0.0.0</version>
<description>ROS 2 teaching examples</description>
<!-- 示例维护者信息;自己的工程应填写实际维护者。 -->
<maintainer email="student@example.com">student</maintainer>
<!-- 该示例工程的许可证声明,不改变整篇博客的许可证。 -->
<license>Apache-2.0</license>
<!-- 声明构建/生成工具;运行依赖与它承担不同职责。 -->
<buildtool_depend>ament_python</buildtool_depend>
<depend>rclpy</depend>
<!-- 节点使用 Twist 和 PointStamped,需要几何消息包。 -->
<depend>geometry_msgs</depend>
<!-- 启停服务使用 SetBool 标准接口。 -->
<depend>std_srvs</depend>
<!-- 消费自定义 ROS 2 接口,使 colcon 能正确排序依赖。 -->
<depend>robot_interfaces</depend>
<!-- 启动 Python Launch 描述需要的运行库。 -->
<exec_depend>launch</exec_depend>
<!-- 启动 Python Launch 描述需要的运行库。 -->
<exec_depend>launch_ros</exec_depend>
<!-- 告诉 ROS 2 此节点包采用 ament_python 安装方式。 -->
<export><build_type>ament_python</build_type></export>
</package>

配置修改后重新构建,再在运行终端重新 source。ROS 1 使用 catkin_make;ROS 2 使用 colcon build —packages-up-to ros2_basics —symlink-install。只改源码但遗漏安装配置、或在旧终端使用旧工作空间,都会造成“文件存在,ROS 却找不到”的现象。

学习完成清单与继续阅读#

学完这篇教程后,尝试不看代码回答以下问题:

  • 为什么 /cmd_vel 话题有消息,机器人却可能不动?
  • 为什么修改参数值后,本例速度没有立即变化?
  • Service 返回成功和 Action 执行成功有什么区别?
  • 为什么新启动的 RViz 或订阅者收不到曾经发布过的结果?
  • 为什么有多个 executor 线程,两个回调仍可能不能并发?
  • 为什么两个 namespace 下的机器人仍可能发生 TF 名称冲突?
  • 为什么只录制传感器消息、不录 TF,会使回放难以解释?

能用自己的节点实验解释这些问题,再进入规划与导航项目,学习会更扎实。

后续按 ROS 2 官方 Tutorials 的顺序补充 CLI、客户端库、TF2、URDF、Testing、rosbag2、Composition 与 Lifecycle。ROS 1 的概念和 API 可查 ROS Wiki Tutorials。课程工程则从 MRS Summer School 2026 的启动文件与规划器入口继续阅读。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ROS 1 与 ROS 2 分课教程:从消息通信到闭环移动
https://minikou.cloud/posts/rose/
作者
minikou
发布于
2026-11-01
许可协议
Unlicensed

部分信息可能已经过时

目录