第4章:ROS2 核心概念¶
本章目标¶
本章是整个手册中最重要的一章。学完之后你将能够:
- 理解 ROS2 是什么,以及为什么机器人系统离不开它
- 掌握节点、话题、消息、服务、TF2、Launch 等核心概念
- 读懂
rm_vision自瞄系统中的每一行 ROS2 相关代码 - 使用命令行工具调试和监控 ROS2 系统
学习建议
本章信息量很大,建议分 2-3 次消化。每次学习一个完整的小节,配合"动手练习"亲自敲代码验证。光看不练等于没学。
4.1 ROS2 是什么¶
4.1.1 先说人话¶
ROS2(Robot Operating System 2)不是一个传统意义上的操作系统,它不会管理你的硬盘或内存。它是一个机器人软件开发框架 —— 一套帮你把复杂机器人程序拆成小块、让小块之间互相通信的工具。
你可以把它想象成机器人的"神经系统":大脑(决策模块)、眼睛(相机)、耳朵(传感器)各自独立工作,但通过神经系统交换信息。
4.1.2 为什么机器人需要 ROS2¶
假设你是一个视觉组新手,接到任务:写一个自动瞄准程序。你可能会这样想:
"我直接写一个 C++ main 函数,读图像、检测装甲板、算角度、发给电控,一气呵成!"
这样写当然可以,但随着系统变复杂,你会遇到一系列麻烦:
| 没有 ROS2 时的问题 | ROS2 怎么解决 |
|---|---|
| 所有代码塞在一个 main 函数里,上千行难以维护 | 拆成独立的节点,每个节点只做一件事 |
| 换相机要改一大片代码 | 节点之间通过话题通信,换相机只改一个节点 |
| 想调试中间结果很困难 | 随时用 ros2 topic echo 查看任何数据 |
| 多线程编程容易出 bug | ROS2 帮你管理线程和回调 |
| 无法分布式部署到多台机器 | ROS2 天然支持跨机器通信 |
| 每次测试都要重启整个程序 | 节点可以单独启动、停止、重启 |
4.1.3 图解:单进程 vs ROS2 多节点¶
没有 ROS2 —— 单进程架构:
所有功能耦合在一起,牵一发而动全身。某个环节出错,整个程序崩溃。
有 ROS2 —— 多节点分布式架构:
每个节点独立运行,通过话题交换数据。检测器挂了?跟踪器和串口不受影响。换相机?只改 camera_driver。调试?用命令行就能看到任何节点发出的数据。
4.2 节点(Node)¶
4.2.1 什么是节点¶
节点是 ROS2 中最小的执行单元。 一个节点就是一个进程(或进程内的一个组件),负责完成一个特定的任务。
设计原则:一个节点做一件事
这不是 ROS2 强制的,但这是最佳实践。就像 Unix 哲学一样:每个程序做好一件事,然后通过管道连接起来。
4.2.2 自瞄系统中的节点¶
在我们的自瞄系统 rm_vision 中,有几个关键节点:
| 节点名 | 职责 | 输入 | 输出 |
|---|---|---|---|
camera_driver |
采集图像 | 相机硬件 | /camera/image 话题 |
armor_detector |
检测装甲板 | 图像话题 | /detector/armors 话题 |
armor_tracker |
跟踪目标 | 装甲板列表 | /tracker/target 话题 |
serial_driver |
串口通信 | 目标角度 | 电控板 |
4.2.3 创建节点的代码¶
一个最基本的 ROS2 节点长这样:
#include <rclcpp/rclcpp.hpp>
// 继承 rclcpp::Node,创建你自己的节点类
class ArmorDetector : public rclcpp::Node
{
public:
ArmorDetector() : Node("armor_detector") // (1)!
{
RCLCPP_INFO(this->get_logger(), "自瞄检测器启动!");
}
};
int main(int argc, char * argv[])
{
rclcpp::init(argc, argv); // (2)!
auto node = std::make_shared<ArmorDetector>();
rclcpp::spin(node); // (3)!
rclcpp::shutdown(); // (4)!
return 0;
}
"armor_detector"是节点的名字,在整个 ROS2 网络中唯一标识这个节点- 初始化 ROS2 通信系统
spin让节点开始工作:接收消息、执行回调函数。这行代码会一直阻塞,直到你按 Ctrl+C- 清理资源,关闭通信
注意顺序
init 必须在最前面,shutdown 必须在最后面。spin 会阻塞主线程。这个模式是固定套路,几乎不用改。
4.2.4 节点的生命周期¶
节点启动后进入"运行"状态,不断处理回调(接收消息、执行定时器等)。收到关闭信号后清理退出。
4.2.5 查看正在运行的节点¶
打开终端,运行以下命令可以看到当前系统中所有正在运行的节点:
输出类似:
查看某个节点的详细信息:
输出会列出这个节点的发布者(publishers)、订阅者(subscribers)、服务(services)等。
4.2.6 动手练习:你的第一个节点¶
练习:创建并运行一个节点
- 在你的工作空间中创建一个新包(如果没有的话):
- 编辑
my_first_node/src/hello_node.cpp,写入上面的节点代码(把ArmorDetector改成HelloNode,日志内容改成你自己的话) - 编辑
CMakeLists.txt,添加可执行文件: - 编译并运行:
- 在另一个终端运行
ros2 node list,看看你的节点出现了吗?
4.3 话题(Topic)¶
4.3.1 什么是话题¶
话题是 ROS2 中节点之间传递数据的主要方式。 它使用发布/订阅(publish/subscribe)模式:
- 发布者(Publisher):往一个话题上发数据
- 订阅者(Subscriber):从一个话题上收数据
- 发布者和订阅者互相不知道对方是谁
/camera/image)) T -->|接收| S1[订阅者 B] T -->|接收| S2[订阅者 C] T -->|接收| S3[订阅者 D] style T fill:#bbdefb,stroke:#1565c0,stroke-width:2px
解耦
发布者不需要知道谁在订阅,订阅者也不需要知道谁在发布。这就是解耦 —— 两边可以独立开发、独立测试、独立替换。换相机的时候,检测器的代码一行都不用改。
4.3.2 自瞄中的话题流转¶
每一步的数据交换都是通过话题完成的。
4.3.3 创建发布者¶
#include "rm_interfaces/msg/armors.hpp" // 自定义消息类型
class ArmorDetector : public rclcpp::Node
{
public:
ArmorDetector() : Node("armor_detector")
{
// 创建一个发布者,往 "/detector/armors" 话题上发消息
publisher_ = this->create_publisher<rm_interfaces::msg::Armors>(
"/detector/armors", // (1)!
10 // (2)!
);
}
private:
rclcpp::Publisher<rm_interfaces::msg::Armors>::SharedPtr publisher_;
};
- 话题名称。
/detector/armors是我们约定的名字,你可以起任何名字,但团队里要统一 - 队列大小(QoS depth)。如果订阅者处理不过来,最多缓存 10 条消息,旧的会被丢弃
发布一条消息:
// 在某个函数中(比如检测到装甲板之后)
auto msg = rm_interfaces::msg::Armors();
msg.header.stamp = this->now();
msg.header.frame_id = "camera_optical_frame";
// ... 填充装甲板数据 ...
publisher_->publish(msg);
4.3.4 创建订阅者¶
class ArmorTracker : public rclcpp::Node
{
public:
ArmorTracker() : Node("armor_tracker")
{
// 订阅 "/detector/armors" 话题
subscription_ = this->create_subscription<rm_interfaces::msg::Armors>(
"/detector/armors", // (1)!
10, // (2)!
std::bind(&ArmorTracker::armors_callback, this, std::placeholders::_1) // (3)!
);
}
private:
// 每当收到新消息,这个函数就会被自动调用
void armors_callback(const rm_interfaces::msg::Armors::SharedPtr msg)
{
RCLCPP_INFO(this->get_logger(), "收到 %zu 个装甲板", msg->armors.size());
// 在这里做跟踪处理 ...
}
rclcpp::Subscription<rm_interfaces::msg::Armors>::SharedPtr subscription_;
};
- 必须和发布者的话题名一致,否则收不到
- 队列大小
- 回调函数 —— 每收到一条消息就调用一次
话题名称对应关系
发布者和订阅者使用相同的话题名,ROS2 会自动帮你们连起来。发布者不需要知道谁在订阅,订阅者也不需要知道谁在发布。
4.3.5 用命令行查看话题¶
# 列出所有话题
ros2 topic list
# 查看话题的消息类型
ros2 topic info /detector/armors
# 实时打印话题内容(调试神器!)
ros2 topic echo /detector/armors
# 手动往话题上发一条消息(用于测试)
ros2 topic pub /detector/armors rm_interfaces/msg/armors "{header: {frame_id: 'test'}}"
# 查看话题的发布频率
ros2 topic hz /camera/image
调试时最常用的命令
ros2 topic echo 是你调试时用得最多的命令。当检测器不工作时,先 echo 一下它发布的话题,看看有没有数据输出。如果没有,说明检测器本身出了问题;如果有但跟踪器不工作,说明问题在下游。
4.3.6 动手练习:发布者和订阅者¶
练习:写一对发布者和订阅者
- 写一个 Talker 节点,每秒发布一条消息 "Hello from talker, count: 1/2/3..."
- 写一个 Listener 节点,收到消息后打印出来
- 提示:使用
std_msgs/msg/String作为消息类型 - 运行后,在第三个终端用
ros2 topic echo查看消息 - 用
ros2 topic hz /chatter查看发布频率是不是 1Hz
4.4 消息(Message)¶
4.4.1 什么是消息¶
消息(Message)是话题上传递的数据结构。就像 C++ 里的 struct,只不过它是用 .msg 文件定义的,ROS2 会自动生成对应的 C++ 类。
消息文件放在功能包的 msg/ 目录下,编译时 ROS2 自动生成 C++ 头文件。你可以把 .msg 文件理解为"跨语言的结构体定义"。
4.4.2 自瞄系统中的消息定义¶
下面是我们自瞄系统中最核心的三个消息。请仔细理解每个字段的含义。
# Armor.msg —— 描述一个装甲板的所有信息
# 装甲板的类别(用于区分不同类型的目标)
# "1" "2" "3" "4" "5" 表示数字装甲板
# "outpost" 表示前哨站
# "base" 表示基地
string number
# 装甲板的颜色
# "blue" 或 "red"
string color
# 装甲板在图像中的位置(2D 像素坐标)
# 由四个角点围成的区域
geometry_msgs/Pose2D pose2d
# 装甲板在三维空间中的位置(3D 相机坐标系下)
# 单位:米。通过 PnP 解算得到
geometry_msgs/Point position
# 装甲板的旋转角度
# yaw 角(左右偏转),单位:弧度
float64 yaw
# Target.msg —— 跟踪器输出的最终目标信息
std_msgs/Header header
# 目标的 ID(跟踪器分配的唯一标识,用于区分不同目标)
int32 id
# 目标在惯性坐标系中的三维位置(单位:米)
geometry_msgs/Point position
# 目标的线速度(单位:米/秒)
geometry_msgs/Vector3 velocity
# 目标的旋转角度(yaw)和角速度
float64 yaw
float64 yaw_velocity
# 跟踪状态:0=LOST, 1=DETECTING, 2=TRACKING, 3=TEMP_LOST
uint8 tracking_state
4.4.3 为什么这样设计¶
这种层次化的设计不是随意的,背后有清晰的逻辑:
单个装甲板"] --> |"数组"| B["Armors
一帧中所有装甲板"] B --> |"跟踪处理"| C["Target
最终目标"] A -.-> |"每个 Armor 有
颜色、编号、位置"| A B -.-> |"附带 header
时间戳+坐标系"| B C -.-> |"附带速度和状态
用于预测和决策"| C
- Armor:最底层的信息单元,描述一个装甲板的所有属性
- Armors:一帧检测结果的集合,附带时间戳用于坐标变换
- Target:跟踪器的输出,包含速度预测和跟踪状态
4.4.4 消息在代码中的使用¶
// 创建一条 Armors 消息
auto armors_msg = rm_interfaces::msg::Armors();
armors_msg.header.stamp = this->now();
armors_msg.header.frame_id = "camera_optical_frame";
// 创建一个 Armor,填充数据
auto armor = rm_interfaces::msg::Armor();
armor.number = "3";
armor.color = "blue";
armor.position.x = 0.5; // 相机坐标系下,单位:米
armor.position.y = -0.1;
armor.position.z = 1.2;
armor.yaw = 0.3;
// 放入数组
armors_msg.armors.push_back(armor);
// 发布
publisher_->publish(armors_msg);
// 在回调函数中接收消息
void armors_callback(const rm_interfaces::msg::Armors::SharedPtr msg)
{
for (const auto & armor : msg->armors) {
RCLCPP_INFO(this->get_logger(),
"检测到 %s 色 %s 号装甲板,距离 %.2f 米",
armor.color.c_str(),
armor.number.c_str(),
armor.position.z);
}
}
4.4.5 用命令行查看消息结构¶
# 查看某个消息类型的详细结构
ros2 interface show rm_interfaces/msg/Armor
# 列出某个包中所有的消息类型
ros2 interface list | grep rm_interfaces
4.4.6 动手练习:自定义消息¶
练习:定义并使用自己的消息类型
- 在你的包中创建
msg/GameInfo.msg: - 修改
CMakeLists.txt,添加消息生成依赖: - 编译后用
ros2 interface show查看生成的 C++ 类型 - 写一个节点发布这条消息
4.5 服务(Service)¶
4.5.1 什么是服务¶
话题是"广播"模式:一个人说,多个人听,发送者不关心有没有人收到。但有时候你需要请求-应答模式:我问一个问题,你给我一个答案。
这就是服务(Service):
"请重置跟踪器" Server->>Server: 执行重置逻辑 Server-->>Client: 响应(Response)
"重置成功"
| 对比 | 话题(Topic) | 服务(Service) |
|---|---|---|
| 模式 | 发布/订阅(广播) | 请求/响应(一问一答) |
| 方向 | 单向 | 双向 |
| 使用场景 | 持续的数据流(图像、检测结果) | 偶尔的操作指令(重置、查询状态) |
| 类比 | 电台广播 | 打电话 |
4.5.2 自瞄中的服务例子¶
在自瞄系统中,最常见的服务是重置跟踪器:
当跟踪器跟踪错目标、或者机器人需要切换目标时,通过服务重置跟踪器状态。
其他可能用到的服务:
/detector/enable—— 启用/禁用检测器/camera/set_exposure—— 动态调整相机曝光/tracker/set_mode—— 切换跟踪模式
4.5.3 服务端代码¶
#include "std_srvs/srv/trigger.hpp"
class ArmorTracker : public rclcpp::Node
{
public:
ArmorTracker() : Node("armor_tracker")
{
// 创建服务:服务名 "/tracker/reset",收到请求时调用 reset_callback
reset_service_ = this->create_service<std_srvs::srv::Trigger>(
"/tracker/reset",
std::bind(&ArmorTracker::reset_callback,
this,
std::placeholders::_1,
std::placeholders::_2)
);
}
private:
void reset_callback(
const std_srvs::srv::Trigger::Request::SharedPtr request, // (1)!
std_srvs::srv::Trigger::Response::SharedPtr response) // (2)!
{
(void)request; // Trigger 服务的 Request 没有字段
// 执行重置逻辑
tracker_.reset();
response->success = true;
response->message = "跟踪器已重置";
RCLCPP_INFO(this->get_logger(), "跟踪器已重置!");
}
rclcpp::Service<std_srvs::srv::Trigger>::SharedPtr reset_service_;
};
- Request —— 客户端发来的请求数据
- Response —— 你需要填写的响应数据
4.5.4 客户端代码¶
// 从另一个节点中调用服务
auto client = this->create_client<std_srvs::srv::Trigger>("/tracker/reset");
auto request = std::make_shared<std_srvs::srv::Trigger::Request>();
// 异步调用
auto result = client->async_send_request(request);
4.5.5 动手练习:写一个简单的服务¶
练习:创建一个两数相加的服务
- 服务类型使用
example_interfaces/srv/AddTwoInts(ROS2 自带) - 服务端节点:等待请求,收到两个整数后返回它们的和
- 客户端节点:发送两个整数,打印返回的和
- 测试:用命令行调用
4.6 TF2 坐标变换¶
4.6.1 为什么需要坐标变换¶
这是 ROS2 中最重要也最容易让人困惑的概念之一。我们用一个场景来解释:
检测器在相机坐标系下发现了装甲板的位置 (x=0.5m, y=-0.1m, z=1.2m)。跟踪器需要知道装甲板在惯性坐标系(比如
odom)下的位置,才能做运动预测。但相机装在云台上,云台会转动。怎么转换?
答案是 TF2 —— ROS2 的坐标变换系统。
4.6.2 坐标系树¶
在自瞄系统中,坐标系的层级关系如下:
(惯性坐标系)
机器人里程计原点"] G["gimbal_link
(云台坐标系)
云台中心"] C["camera_link
(相机坐标系)
相机物理中心"] CO["camera_optical_frame
(相机光轴坐标系)
OpenCV 使用的坐标系"] O -->|"TF2: 通过 odom 数据
计算机器人位姿"| G G -->|"TF2: 云台 pitch/yaw
关节角度"| C C -->|"TF2: 固定旋转
相机安装角度"| CO style O fill:#e8f5e9,stroke:#2e7d32 style G fill:#fff3e0,stroke:#e65100 style C fill:#e3f2fd,stroke:#1565c0 style CO fill:#fce4ec,stroke:#c62828
每个箭头代表一个坐标变换(Transform):父坐标系到子坐标系的平移和旋转。
4.6.3 相机坐标系 vs 光轴坐标系¶
容易搞混的地方
ROS2 中有两个相机相关的坐标系,它们之间有一个固定的旋转:
- camera_link:X轴朝前,Y轴朝左,Z轴朝上(ROS 惯例)
- camera_optical_frame:X轴朝右,Y轴朝下,Z轴朝前(OpenCV 惯例)
这个旋转是固定的(90度),由 URDF 定义。OpenCV/PnP 的输出在 camera_optical_frame 中,所以我们的检测结果也标注这个坐标系。
4.6.4 TF2 的两个角色¶
| 角色 | 作用 | 自瞄中的例子 |
|---|---|---|
| 广播器(Broadcaster) | 发布坐标变换关系 | serial_driver 发布 odom -> gimbal_link 的变换(根据电控反馈的云台角度) |
| 监听器(Listener) | 查询坐标变换关系 | armor_tracker 查询 camera_optical_frame -> odom 的变换,把检测结果转换到惯性坐标系 |
4.6.5 监听器代码¶
#include "tf2_ros/buffer.h"
#include "tf2_ros/transform_listener.h"
class ArmorTracker : public rclcpp::Node
{
public:
ArmorTracker() : Node("armor_tracker")
{
// 创建 TF2 缓冲区和监听器
tf_buffer_ = std::make_unique<tf2_ros::Buffer>(this->get_clock());
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_);
}
private:
void armors_callback(const rm_interfaces::msg::Armors::SharedPtr msg)
{
// 查询从 camera_optical_frame 到 odom 的变换
geometry_msgs::msg::TransformStamped transform;
try {
transform = tf_buffer_->lookupTransform(
"odom", // 目标坐标系
"camera_optical_frame", // 源坐标系
msg->header.stamp, // 使用消息的时间戳
std::chrono::milliseconds(50) // 最大等待时间
);
} catch (tf2::TransformException & ex) {
RCLCPP_WARN(this->get_logger(), "TF2 变换失败: %s", ex.what());
return;
}
// 现在可以用这个变换,把装甲板坐标从相机坐标系转换到 odom 坐标系
// ...
}
std::unique_ptr<tf2_ros::Buffer> tf_buffer_;
std::shared_ptr<tf2_ros::TransformListener> tf_listener_;
};
4.6.6 用命令行查看 TF 树¶
# 查看所有坐标变换
ros2 run tf2_tools view_frames
# 查看某个具体变换
ros2 run tf2_ros tf2_echo odom camera_optical_frame
4.6.7 为什么需要 TF2¶
没有 TF2 时,你必须自己手动计算坐标变换矩阵。相机装在云台上,云台转动时,变换矩阵也跟着变 —— 你得自己写代码跟踪这些变化。
TF2 帮你做了这一切:
- 各个节点负责广播自己知道的变换(电控广播云台角度,URDF 定义固定安装关系)
- TF2 自动拼接整条变换链
- 任何节点都能查询任意两个坐标系之间的变换
- 还自动处理时间插值
一句话理解 TF2
检测结果在相机坐标系,弹道计算需要在惯性坐标系,TF2 是中间的桥梁。
4.6.8 动手练习:理解坐标变换¶
练习:查看自瞄系统的 TF 树
- 启动自瞄系统(或者仅启动
serial_driver和相关节点) - 运行
ros2 run tf2_tools view_frames,生成 TF 树的 PDF 图 - 用
ros2 run tf2_ros tf2_echo odom gimbal_link观察云台旋转时变换如何变化 - 思考:如果云台 pitch 角从 0 变到 30 度,
camera_optical_frame相对于odom的位置会怎么变化?
4.7 Launch 文件¶
4.7.1 什么是 Launch 文件¶
自瞄系统有好几个节点要启动。如果每次都要开 4-5 个终端分别 ros2 run,那太痛苦了。
Launch 文件可以一次性启动多个节点,还能设置参数、配置 QoS、指定延迟启动等。
ROS2 中 Launch 文件通常用 Python 编写(也有 XML 和 YAML 格式,但 Python 最灵活)。
4.7.2 自瞄系统的 Launch 文件详解¶
以下是 vision_bringup.launch.py 的核心代码,我们逐行分析:
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
IncludeLaunchDescription,
TimerAction, # (1)!
)
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node, ComposableNodeContainer, LoadComposableNodes
from launch_ros.descriptions import ComposableNode
def generate_launch_description():
# ========== 获取功能包路径 ==========
bringup_dir = get_package_share_directory('rm_vision_bringup')
# ========== 启动参数 ==========
# 通过命令行传入参数,控制启动行为
serial_port_arg = DeclareLaunchArgument(
'serial_port',
default_value='/dev/ttyACM0', # 串口设备路径
description='串口设备路径'
)
# ========== 1. 相机 + 检测器(Composable Node) ==========
# 它们运行在同一个进程内,避免图像数据的序列化开销
camera_detector_container = ComposableNodeContainer(
name='camera_detector_container',
namespace='',
package='rclcpp_components',
executable='component_container', # (2)!
composable_node_descriptions=[
ComposableNode(
package='camera_driver',
plugin='camera_driver::CameraDriver',
name='camera_driver',
parameters=[...],
),
ComposableNode(
package='armor_detector',
plugin='armor_detector::ArmorDetector',
name='armor_detector',
parameters=[...],
),
],
output='screen',
)
# ========== 2. 跟踪器节点(延迟 2.0 秒启动) ==========
armor_tracker = TimerAction(
period=2.0, # (3)!
actions=[
Node(
package='armor_tracker',
executable='armor_tracker_node',
name='armor_tracker',
parameters=[...],
output='screen',
)
]
)
# ========== 3. 串口驱动(延迟 1.5 秒启动) ==========
serial_driver = TimerAction(
period=1.5, # (4)!
actions=[
Node(
package='serial_driver',
executable='serial_driver_node',
name='serial_driver',
parameters=[{
'serial_port': LaunchConfiguration('serial_port'),
}],
output='screen',
)
]
)
# ========== 组装 Launch Description ==========
return LaunchDescription([
serial_port_arg,
camera_detector_container, # 立即启动
serial_driver, # 延迟 1.5s
armor_tracker, # 延迟 2.0s
])
TimerAction实现延迟启动component_container是承载 Composable Node 的容器进程(详见 4.8 节)- 跟踪器最后启动,因为它依赖检测器的话题数据
- 串口驱动也需要等一下,确保串口设备就绪
4.7.3 为什么要延迟启动¶
你可能会问:为什么不同时启动所有节点?
原因有几个:
| 节点 | 延迟原因 |
|---|---|
serial_driver 延迟 1.5s |
串口设备需要时间初始化,太早打开会报错"设备不存在" |
armor_tracker 延迟 2.0s |
跟踪器依赖检测器的输出,需要等检测器启动并开始发布数据 |
不是所有系统都需要延迟启动
延迟启动是一种"简单粗暴"的解决方案。更优雅的做法是使用 ROS2 的生命周期管理(Lifecycle Node),让节点自动等待依赖就绪。但对 RM 比赛来说,简单的延迟启动够用了。
4.7.4 启动 Launch 文件¶
# 基本启动
ros2 launch rm_vision_bringup vision_bringup.launch.py
# 传入参数
ros2 launch rm_vision_bringup vision_bringup.launch.py serial_port:=/dev/ttyUSB0
# 带日志级别
ros2 launch rm_vision_bringup vision_bringup.launch.py --ros-args --log-level debug
4.7.5 动手练习:编写 Launch 文件¶
练习:创建一个启动两个节点的 Launch 文件
- 创建
my_launch.py,启动你之前写的 Talker 和 Listener - 给 Listener 添加 2 秒延迟启动
- 运行 Launch 文件,确认两个节点都正常工作
- 用
ros2 node list验证
from launch import LaunchDescription
from launch.actions import TimerAction
from launch_ros.actions import Node
def generate_launch_description():
return LaunchDescription([
Node(
package='my_first_node',
executable='talker',
name='talker',
),
TimerAction(
period=2.0,
actions=[
Node(
package='my_first_node',
executable='listener',
name='listener',
)
]
),
])
4.8 Component(Composable Node)¶
4.8.1 为什么需要组件¶
普通情况下,每个节点是一个独立进程。节点之间通过话题通信时,数据需要:
- 序列化:发送方把 C++ 对象转成二进制字节流
- 传输:通过共享内存或网络发送
- 反序列化:接收方把字节流转回 C++ 对象
对于小消息(几十字节的状态数据),这个开销可以忽略。但图像数据呢?一张 640x512 的灰度图有 327KB,彩色图有 983KB。以 100Hz 传输,每秒将近 100MB 的数据要反复序列化/反序列化 —— 这是巨大的浪费。
Component(组件 / 可组合节点)的解决方案:让多个节点运行在同一个进程中,使用进程内通信,零拷贝。
4.8.2 进程内通信 vs 进程间通信¶
进程"] -->|"序列化 → 传输 → 反序列化"| P2["armor_detector
进程"] end subgraph "进程内通信(Component)" C1["camera_driver
组件"] -->|"共享指针
零拷贝"| C2["armor_detector
组件"] end style P1 fill:#ffcdd2,stroke:#c62828 style P2 fill:#ffcdd2,stroke:#c62828 style C1 fill:#c8e6c9,stroke:#2e7d32 style C2 fill:#c8e6c9,stroke:#2e7d32
进程内通信时,发布者直接把 SharedPtr(智能指针)传给订阅者,不拷贝任何数据。
4.8.3 自瞄中的应用¶
在 Launch 文件中,相机和检测器被放在同一个 ComposableNodeContainer 中:
ComposableNodeContainer(
name='camera_detector_container', # 一个容器进程
package='rclcpp_components',
executable='component_container',
composable_node_descriptions=[
# 这两个组件运行在同一个进程中
ComposableNode(
package='camera_driver',
plugin='camera_driver::CameraDriver', # (1)!
name='camera_driver',
),
ComposableNode(
package='armor_detector',
plugin='armor_detector::ArmorDetector',
name='armor_detector',
),
],
)
plugin参数是组件的注册名,格式为包名::类名
这样,相机发布的图像数据直接通过共享指针传给检测器,无需序列化。当检测器处理完后,/detector/armors 话题上的数据量很小,通过普通进程间通信传输给跟踪器就行。
4.8.4 如何编写一个 Component¶
#include "rclcpp/rclcpp.hpp"
#include "rclcpp_components/register_node_macro.hpp"
class ArmorDetector : public rclcpp::Node
{
public:
// 注意:构造函数必须接受 NodeOptions 参数
explicit ArmorDetector(const rclcpp::NodeOptions & options)
: Node("armor_detector", options)
{
// 和普通节点一样的初始化逻辑
}
};
// 注册为 Component(替代 main 函数中的 rclcpp::init + spin)
RCLCPP_COMPONENTS_REGISTER_NODE(ArmorDetector) // (1)!
- 这个宏把类注册为可加载的组件,ROS2 可以在运行时把它加载到任何 Container 进程中
4.8.5 动手练习:理解 Composable Node¶
练习:查看组件加载
- 启动自瞄系统
- 运行
ros2 component list,查看哪些组件被加载到了哪个 Container - 思考:如果把
armor_tracker也放进同一个 Container,有什么好处和坏处?
4.9 QoS(Quality of Service,服务质量)¶
4.9.1 什么是 QoS¶
QoS 是 ROS2 中控制通信行为的一组策略。它决定了:消息怎么传递、丢失了怎么办、历史消息要不要保留。
最常见的两个策略:
| 策略 | RELIABLE(可靠) | BEST_EFFORT(尽力而为) |
|---|---|---|
| 保证 | 保证每条消息都送达 | 不保证,丢了就丢了 |
| 适用场景 | 状态信息、控制指令 | 传感器数据流(图像、点云) |
| 性能 | 可能有延迟(等待重传) | 低延迟、高吞吐 |
| 类比 | 微信消息(保证送达) | 对讲机(说了就说了) |
4.9.2 为什么图像用 BEST_EFFORT¶
想象一下:
检测器在 100Hz 下运行。某一帧图像传输时丢了。如果用 RELIABLE 模式,ROS2 会重传这一帧。但 10ms 后下一帧就来了,那帧丢失的图像已经过时了 —— 重传它纯粹是浪费带宽和时间。
所以对于传感器实时数据流,使用 SensorDataQoS(本质就是 BEST_EFFORT + 小队列)。
4.9.3 代码中的 QoS 设置¶
// 图像话题 —— 使用 SensorDataQoS(BEST_EFFORT)
subscription_ = this->create_subscription<sensor_msgs::msg::Image>(
"/camera/image",
rclcpp::SensorDataQoS(), // (1)!
callback
);
// 状态话题 —— 使用默认的 RELIABLE(不指定就是默认)
publisher_ = this->create_publisher<rm_interfaces::msg::Target>(
"/tracker/target",
10 // (2)!
);
SensorDataQoS= BEST_EFFORT + KEEP_LAST(5),适合高频传感器数据- 不指定 QoS 时,默认是 RELIABLE + KEEP_LAST(10)
QoS 不匹配会导致收不到消息!
发布者用 RELIABLE,订阅者用 BEST_EFFORT —— 无法通信!Reliability 策略必须匹配。
发布者用 BEST_EFFORT,订阅者用 RELIABLE —— 无法通信!
这是新手最常遇到的坑之一。如果你 ros2 topic echo 看到数据,但代码里订阅收不到,先检查 QoS 是否匹配。
4.9.4 QoS 策略详解¶
ROS2 的 QoS 由多个策略组合而成:
| 策略 | 选项 | 含义 |
|---|---|---|
| Reliability | RELIABLE | 保证送达(重传) |
| BEST_EFFORT | 尽力而为(不重传) | |
| Durability | VOLATILE | 新订阅者收不到历史消息 |
| TRANSIENT_LOCAL | 新订阅者能收到最后几条历史消息 | |
| History | KEEP_LAST(n) | 只保留最近 n 条 |
| KEEP_ALL | 保留所有(慎用,会吃内存) |
RELIABLE / BEST_EFFORT"] D["Durability
VOLATILE / TRANSIENT_LOCAL"] H["History
KEEP_LAST / KEEP_ALL"] Dp["Depth
队列深度(整数)"] end
4.9.5 动手练习:验证 QoS 不匹配¶
练习:QoS 匹配实验
- 用
ros2 topic pub以默认 QoS 发布消息 - 写一个订阅者用
SensorDataQoS订阅 —— 能收到吗? - 反过来:发布者用
SensorDataQoS,订阅者用默认 QoS —— 能收到吗? - 用
ros2 topic info /your_topic --verbose查看 QoS 匹配情况
4.10 常用命令速查¶
4.10.1 话题相关¶
# 列出所有话题
ros2 topic list
# 列出话题及其消息类型
ros2 topic list -t
# 实时打印话题内容(调试最常用!)
ros2 topic echo /detector/armors
# 打印话题的某一个字段
ros2 topic echo /detector/armors --field armors
# 查看话题信息(发布者数量、订阅者数量、QoS)
ros2 topic info /detector/armors --verbose
# 查看发布频率
ros2 topic hz /camera/image
# 查看带宽占用
ros2 bw /camera/image
# 手动发布消息(测试用)
ros2 topic pub --once /chatter std_msgs/msg/String "{data: 'hello'}"
# 延迟计算
ros2 delay /camera/image /detector/armors
4.10.2 节点相关¶
# 列出所有节点
ros2 node list
# 查看节点详细信息(发布的/订阅的话题、服务等)
ros2 node info /armor_detector
# 查看节点的参数
ros2 param dump /armor_detector
4.10.3 参数相关¶
# 列出节点的所有参数
ros2 param list /armor_detector
# 获取参数值
ros2 param get /armor_detector use_multithread
# 设置参数值(运行时修改!)
ros2 param set /armor_detector use_multithread true
# 从文件加载参数
ros2 param load /armor_detector params.yaml
# 保存当前参数到文件
ros2 param dump /armor_detector > params.yaml
4.10.4 服务相关¶
# 列出所有服务
ros2 service list
# 列出服务及其类型
ros2 service list -t
# 调用服务
ros2 service call /tracker/reset std_srvs/srv/Trigger
# 查看服务类型定义
ros2 interface show std_srvs/srv/Trigger
4.10.5 TF 相关¶
# 查看所有坐标系
ros2 run tf2_ros tf2_echo odom camera_optical_frame
# 生成 TF 树的 PDF 文件
ros2 run tf2_tools view_frames
4.10.6 调试组合技¶
# 检查系统整体状态
ros2 node list # 有哪些节点在跑
ros2 topic list -t # 有哪些话题,类型是什么
ros2 topic hz /camera/image # 相机是否正常出图
ros2 topic echo /detector/armors # 检测器是否正常输出
ros2 topic hz /detector/armors # 检测器输出频率
ros2 topic echo /tracker/target # 跟踪器是否正常输出
调试思路
当系统不工作时,沿着数据流方向逐一排查:
- 相机出图了吗? →
ros2 topic hz /camera/image - 检测器收到图像了吗? →
ros2 topic echo /detector/armors - 跟踪器收到检测结果了吗? →
ros2 topic echo /tracker/target - 串口发送成功了吗? → 查看
serial_driver的日志
哪个环节没数据,问题就在那个环节。
4.11 全景回顾¶
让我们用一张图把本章所有概念串起来:
📷 组件"] DET["armor_detector
🔍 组件"] CAM -->|"/camera/image
SensorDataQoS
进程内零拷贝"| DET end subgraph "独立进程节点" TRK["armor_tracker
📍 节点"] SER["serial_driver
🔌 节点"] end DET -->|"/detector/armors
RELIABLE
Armors.msg"| TRK TRK -->|"/tracker/target
RELIABLE
Target.msg"| SER subgraph "TF2 坐标变换" TF["odom → gimbal_link
→ camera_link
→ camera_optical_frame"] end TRK -.->|"查询坐标变换"| TF SER -.->|"广播云台角度"| TF subgraph "服务" SVC["/tracker/reset
重置跟踪器"] end SVC -.->|"请求/响应"| TRK end L -.->|启动| CAM L -.->|启动| DET L -.->|延迟1.5s| SER L -.->|延迟2.0s| TRK style CAM fill:#e8f5e9,stroke:#2e7d32 style DET fill:#e3f2fd,stroke:#1565c0 style TRK fill:#fff3e0,stroke:#e65100 style SER fill:#f3e5f5,stroke:#6a1b9a style TF fill:#fce4ec,stroke:#c62828 style SVC fill:#f5f5f5,stroke:#616161
4.12 概念速查表¶
| 概念 | 一句话解释 | 自瞄中的例子 |
|---|---|---|
| Node | 最小执行单元,做一件事 | armor_detector 只负责检测 |
| Topic | 发布/订阅数据通道 | /detector/armors 传递检测结果 |
| Message | 话题上传递的数据结构 | Armors.msg 定义装甲板数组 |
| Service | 请求/响应式通信 | /tracker/reset 重置跟踪器 |
| TF2 | 坐标系之间的变换关系 | 相机坐标系 → 惯性坐标系 |
| Launch | 一次启动多个节点 | vision_bringup.launch.py |
| Component | 进程内组件,零拷贝通信 | 相机和检测器同进程 |
| QoS | 通信可靠性策略 | 图像用 BEST_EFFORT,状态用 RELIABLE |
4.13 常见问题 FAQ¶
Q: ROS2 和 ROS1 有什么区别?
简单来说:ROS2 用 DDS 通信(更可靠、更实时),支持多机通信,不需要 roscore(无中心节点),API 更现代。我们用 ROS2 是因为它的工程特性更适合比赛机器人。
Q: 为什么要用自定义消息,不用 ROS2 自带的?
ROS2 自带的消息类型(std_msgs、geometry_msgs)是通用的,无法表达"装甲板"这种领域特定概念。自定义消息让数据结构更清晰、更有意义。
Q: colcon build 很慢怎么办?
编译时只编译修改过的包:
如果只想检查编译是否通过(不安装):Q: 节点名和话题名有什么关系?
没有强制关系。节点名是 armor_detector,它发布的话题完全可以叫 /foo/bar。但好的实践是让话题名和节点名有关联,比如 /detector/armors 中的 detector 暗示它来自检测器。
本章小结¶
恭喜你读完了 ROS2 最核心的概念!让我们快速回顾:
- ROS2 是什么 —— 机器人软件框架,帮你把大系统拆成小节点
- 节点 —— 一个节点做一件事,独立运行
- 话题 —— 节点之间传递数据的通道,发布/订阅模式
- 消息 —— 话题上传递的数据结构,用
.msg文件定义 - 服务 —— 请求/响应模式,用于偶尔的操作指令
- TF2 —— 坐标系之间的变换关系,把检测结果从相机坐标系转换到惯性坐标系
- Launch —— 一次启动多个节点,支持延迟启动和参数配置
- Component —— 进程内通信,避免大数据的序列化开销
- QoS —— 通信质量策略,图像用 BEST_EFFORT,状态用 RELIABLE
- 常用命令 ——
ros2 topic echo是调试神器
下一步
现在你已经理解了 ROS2 的核心概念,下一章我们将学习 OpenCV 图像处理 —— 这是装甲板检测的直接工具。有了 ROS2 的基础,你就能理解检测器是如何接收图像、处理图像、并把结果发布出去的完整流程了。