跳转至

第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 —— 单进程架构:

graph LR subgraph 单一进程 A[读取图像] --> B[检测装甲板] B --> C[跟踪目标] C --> D[计算角度] D --> E[串口发送] end style A fill:#ffcccc,stroke:#cc0000 style B fill:#ffcccc,stroke:#cc0000 style C fill:#ffcccc,stroke:#cc0000 style D fill:#ffcccc,stroke:#cc0000 style E fill:#ffcccc,stroke:#cc0000

所有功能耦合在一起,牵一发而动全身。某个环节出错,整个程序崩溃。

有 ROS2 —— 多节点分布式架构:

graph TB subgraph "camera_driver 节点" A[相机驱动] end subgraph "armor_detector 节点" B[装甲板检测] end subgraph "armor_tracker 节点" C[目标跟踪] end subgraph "serial_driver 节点" E[串口通信] end A -- "/camera/image" --> B B -- "/detector/armors" --> C C -- "/tracker/target" --> E style A fill:#e8f5e9,stroke:#2e7d32 style B fill:#e3f2fd,stroke:#1565c0 style C fill:#fff3e0,stroke:#e65100 style E fill:#f3e5f5,stroke:#6a1b9a

每个节点独立运行,通过话题交换数据。检测器挂了?跟踪器和串口不受影响。换相机?只改 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;
}
  1. "armor_detector" 是节点的名字,在整个 ROS2 网络中唯一标识这个节点
  2. 初始化 ROS2 通信系统
  3. spin 让节点开始工作:接收消息、执行回调函数。这行代码会一直阻塞,直到你按 Ctrl+C
  4. 清理资源,关闭通信

注意顺序

init 必须在最前面,shutdown 必须在最后面。spin 会阻塞主线程。这个模式是固定套路,几乎不用改。

4.2.4 节点的生命周期

stateDiagram-v2 [*] --> 初始化: rclcpp::init() 初始化 --> 运行: 创建Node对象 运行 --> 运行: spin() 处理回调 运行 --> 关闭: Ctrl+C / rclcpp::shutdown() 关闭 --> [*]

节点启动后进入"运行"状态,不断处理回调(接收消息、执行定时器等)。收到关闭信号后清理退出。

4.2.5 查看正在运行的节点

打开终端,运行以下命令可以看到当前系统中所有正在运行的节点:

ros2 node list

输出类似:

/armor_detector
/armor_tracker
/camera_driver
/serial_driver

查看某个节点的详细信息:

ros2 node info /armor_detector

输出会列出这个节点的发布者(publishers)、订阅者(subscribers)、服务(services)等。

4.2.6 动手练习:你的第一个节点

练习:创建并运行一个节点

  1. 在你的工作空间中创建一个新包(如果没有的话):
    cd ~/rm_ws/src
    ros2 pkg create --build-type ament_cmake my_first_node --dependencies rclcpp
    
  2. 编辑 my_first_node/src/hello_node.cpp,写入上面的节点代码(把 ArmorDetector 改成 HelloNode,日志内容改成你自己的话)
  3. 编辑 CMakeLists.txt,添加可执行文件:
    add_executable(hello_node src/hello_node.cpp)
    ament_target_dependencies(hello_node rclcpp)
    install(TARGETS hello_node DESTINATION lib/${PROJECT_NAME})
    
  4. 编译并运行:
    cd ~/rm_ws
    colcon build --packages-select my_first_node
    source install/setup.bash
    ros2 run my_first_node hello_node
    
  5. 在另一个终端运行 ros2 node list,看看你的节点出现了吗?

4.3 话题(Topic)

4.3.1 什么是话题

话题是 ROS2 中节点之间传递数据的主要方式。 它使用发布/订阅(publish/subscribe)模式:

  • 发布者(Publisher):往一个话题上发数据
  • 订阅者(Subscriber):从一个话题上收数据
  • 发布者和订阅者互相不知道对方是谁
graph LR P1[发布者 A] -->|发送| T((话题
/camera/image)) T -->|接收| S1[订阅者 B] T -->|接收| S2[订阅者 C] T -->|接收| S3[订阅者 D] style T fill:#bbdefb,stroke:#1565c0,stroke-width:2px

解耦

发布者不需要知道谁在订阅,订阅者也不需要知道谁在发布。这就是解耦 —— 两边可以独立开发、独立测试、独立替换。换相机的时候,检测器的代码一行都不用改。

4.3.2 自瞄中的话题流转

sequenceDiagram participant Cam as camera_driver participant Det as armor_detector participant Trk as armor_tracker participant Ser as serial_driver Cam->>Det: /camera/image (图像) Det->>Det: OpenCV 检测装甲板 Det->>Trk: /detector/armors (装甲板列表) Trk->>Trk: Kalman 滤波跟踪 Trk->>Ser: /tracker/target (目标角度) Ser->>Ser: 串口发给电控板

每一步的数据交换都是通过话题完成的。

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_;
};
  1. 话题名称。/detector/armors 是我们约定的名字,你可以起任何名字,但团队里要统一
  2. 队列大小(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_;
};
  1. 必须和发布者的话题名一致,否则收不到
  2. 队列大小
  3. 回调函数 —— 每收到一条消息就调用一次

话题名称对应关系

发布者和订阅者使用相同的话题名,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 动手练习:发布者和订阅者

练习:写一对发布者和订阅者

  1. 写一个 Talker 节点,每秒发布一条消息 "Hello from talker, count: 1/2/3..."
  2. 写一个 Listener 节点,收到消息后打印出来
  3. 提示:使用 std_msgs/msg/String 作为消息类型
  4. 运行后,在第三个终端用 ros2 topic echo 查看消息
  5. ros2 topic hz /chatter 查看发布频率是不是 1Hz
// Talker 的关键代码
timer_ = this->create_wall_timer(
    std::chrono::seconds(1),
    [this]() {
        auto msg = std_msgs::msg::String();
        msg.data = "Hello from talker, count: " + std::to_string(count_++);
        publisher_->publish(msg);
    });

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
# Armors.msg —— 一帧图像中检测到的所有装甲板

# 消息头:包含时间戳和坐标系名称
# 时间戳用于 TF2 坐标变换,坐标系名标识数据来源
std_msgs/Header header

# 装甲板数组:可能检测到 0 个、1 个或多个装甲板
Armor[] armors
# 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 为什么这样设计

这种层次化的设计不是随意的,背后有清晰的逻辑:

graph TD A["Armor
单个装甲板"] --> |"数组"| 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 动手练习:自定义消息

练习:定义并使用自己的消息类型

  1. 在你的包中创建 msg/GameInfo.msg
    string game_stage    # "prepare", "battle", "end"
    int32  remaining_time
    bool   is_red_side
    
  2. 修改 CMakeLists.txt,添加消息生成依赖:
    find_package(rosidl_default_generators REQUIRED)
    rosidl_generate_interfaces(${PROJECT_NAME}
        "msg/GameInfo.msg"
    )
    
  3. 编译后用 ros2 interface show 查看生成的 C++ 类型
  4. 写一个节点发布这条消息

4.5 服务(Service)

4.5.1 什么是服务

话题是"广播"模式:一个人说,多个人听,发送者不关心有没有人收到。但有时候你需要请求-应答模式:我问一个问题,你给我一个答案。

这就是服务(Service):

sequenceDiagram participant Client as 客户端节点 participant Server as 服务端节点 Client->>Server: 请求(Request)
"请重置跟踪器" Server->>Server: 执行重置逻辑 Server-->>Client: 响应(Response)
"重置成功"
对比 话题(Topic) 服务(Service)
模式 发布/订阅(广播) 请求/响应(一问一答)
方向 单向 双向
使用场景 持续的数据流(图像、检测结果) 偶尔的操作指令(重置、查询状态)
类比 电台广播 打电话

4.5.2 自瞄中的服务例子

在自瞄系统中,最常见的服务是重置跟踪器

# 用命令行调用服务(测试时很有用)
ros2 service call /tracker/reset std_srvs/srv/Trigger

当跟踪器跟踪错目标、或者机器人需要切换目标时,通过服务重置跟踪器状态。

其他可能用到的服务:

  • /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_;
};
  1. Request —— 客户端发来的请求数据
  2. 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 动手练习:写一个简单的服务

练习:创建一个两数相加的服务

  1. 服务类型使用 example_interfaces/srv/AddTwoInts(ROS2 自带)
  2. 服务端节点:等待请求,收到两个整数后返回它们的和
  3. 客户端节点:发送两个整数,打印返回的和
  4. 测试:用命令行调用
    ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 3, b: 5}"
    

4.6 TF2 坐标变换

4.6.1 为什么需要坐标变换

这是 ROS2 中最重要也最容易让人困惑的概念之一。我们用一个场景来解释:

检测器在相机坐标系下发现了装甲板的位置 (x=0.5m, y=-0.1m, z=1.2m)。跟踪器需要知道装甲板在惯性坐标系(比如 odom)下的位置,才能做运动预测。但相机装在云台上,云台会转动。怎么转换?

答案是 TF2 —— ROS2 的坐标变换系统。

4.6.2 坐标系树

在自瞄系统中,坐标系的层级关系如下:

graph TB O["odom
(惯性坐标系)
机器人里程计原点"] 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 帮你做了这一切:

  1. 各个节点负责广播自己知道的变换(电控广播云台角度,URDF 定义固定安装关系)
  2. TF2 自动拼接整条变换链
  3. 任何节点都能查询任意两个坐标系之间的变换
  4. 还自动处理时间插值

一句话理解 TF2

检测结果在相机坐标系,弹道计算需要在惯性坐标系,TF2 是中间的桥梁。

4.6.8 动手练习:理解坐标变换

练习:查看自瞄系统的 TF 树

  1. 启动自瞄系统(或者仅启动 serial_driver 和相关节点)
  2. 运行 ros2 run tf2_tools view_frames,生成 TF 树的 PDF 图
  3. ros2 run tf2_ros tf2_echo odom gimbal_link 观察云台旋转时变换如何变化
  4. 思考:如果云台 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
    ])
  1. TimerAction 实现延迟启动
  2. component_container 是承载 Composable Node 的容器进程(详见 4.8 节)
  3. 跟踪器最后启动,因为它依赖检测器的话题数据
  4. 串口驱动也需要等一下,确保串口设备就绪

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 文件

  1. 创建 my_launch.py,启动你之前写的 Talker 和 Listener
  2. 给 Listener 添加 2 秒延迟启动
  3. 运行 Launch 文件,确认两个节点都正常工作
  4. 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 为什么需要组件

普通情况下,每个节点是一个独立进程。节点之间通过话题通信时,数据需要:

  1. 序列化:发送方把 C++ 对象转成二进制字节流
  2. 传输:通过共享内存或网络发送
  3. 反序列化:接收方把字节流转回 C++ 对象

对于小消息(几十字节的状态数据),这个开销可以忽略。但图像数据呢?一张 640x512 的灰度图有 327KB,彩色图有 983KB。以 100Hz 传输,每秒将近 100MB 的数据要反复序列化/反序列化 —— 这是巨大的浪费。

Component(组件 / 可组合节点)的解决方案:让多个节点运行在同一个进程中,使用进程内通信,零拷贝。

4.8.2 进程内通信 vs 进程间通信

graph TB subgraph "进程间通信(默认)" P1["camera_driver
进程"] -->|"序列化 → 传输 → 反序列化"| 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',
        ),
    ],
)
  1. 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)!
  1. 这个宏把类注册为可加载的组件,ROS2 可以在运行时把它加载到任何 Container 进程中

4.8.5 动手练习:理解 Composable Node

练习:查看组件加载

  1. 启动自瞄系统
  2. 运行 ros2 component list,查看哪些组件被加载到了哪个 Container
  3. 思考:如果把 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)!
);
  1. SensorDataQoS = BEST_EFFORT + KEEP_LAST(5),适合高频传感器数据
  2. 不指定 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 保留所有(慎用,会吃内存)
graph LR subgraph QoS Profile R["Reliability
RELIABLE / BEST_EFFORT"] D["Durability
VOLATILE / TRANSIENT_LOCAL"] H["History
KEEP_LAST / KEEP_ALL"] Dp["Depth
队列深度(整数)"] end

4.9.5 动手练习:验证 QoS 不匹配

练习:QoS 匹配实验

  1. ros2 topic pub 以默认 QoS 发布消息
  2. 写一个订阅者用 SensorDataQoS 订阅 —— 能收到吗?
  3. 反过来:发布者用 SensorDataQoS,订阅者用默认 QoS —— 能收到吗?
  4. 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   # 跟踪器是否正常输出

调试思路

当系统不工作时,沿着数据流方向逐一排查:

  1. 相机出图了吗? → ros2 topic hz /camera/image
  2. 检测器收到图像了吗? → ros2 topic echo /detector/armors
  3. 跟踪器收到检测结果了吗? → ros2 topic echo /tracker/target
  4. 串口发送成功了吗? → 查看 serial_driver 的日志

哪个环节没数据,问题就在那个环节。


4.11 全景回顾

让我们用一张图把本章所有概念串起来:

graph TB subgraph "ROS2 自瞄系统全景图" subgraph "Launch 文件启动" L["vision_bringup.launch.py"] end subgraph "Container 进程(组件,零拷贝)" CAM["camera_driver
📷 组件"] 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_msgsgeometry_msgs)是通用的,无法表达"装甲板"这种领域特定概念。自定义消息让数据结构更清晰、更有意义。

Q: colcon build 很慢怎么办?

编译时只编译修改过的包:

colcon build --packages-select rm_interfaces
如果只想检查编译是否通过(不安装):
colcon build --packages-select rm_interfaces --cmake-args -DCMAKE_EXPORT_COMPILE_COMMANDS=ON

Q: 节点名和话题名有什么关系?

没有强制关系。节点名是 armor_detector,它发布的话题完全可以叫 /foo/bar。但好的实践是让话题名和节点名有关联,比如 /detector/armors 中的 detector 暗示它来自检测器。

Q: 如何查看某个话题的消息类型?

ros2 topic info /detector/armors
ros2 interface show rm_interfaces/msg/Armors

本章小结

恭喜你读完了 ROS2 最核心的概念!让我们快速回顾:

  1. ROS2 是什么 —— 机器人软件框架,帮你把大系统拆成小节点
  2. 节点 —— 一个节点做一件事,独立运行
  3. 话题 —— 节点之间传递数据的通道,发布/订阅模式
  4. 消息 —— 话题上传递的数据结构,用 .msg 文件定义
  5. 服务 —— 请求/响应模式,用于偶尔的操作指令
  6. TF2 —— 坐标系之间的变换关系,把检测结果从相机坐标系转换到惯性坐标系
  7. Launch —— 一次启动多个节点,支持延迟启动和参数配置
  8. Component —— 进程内通信,避免大数据的序列化开销
  9. QoS —— 通信质量策略,图像用 BEST_EFFORT,状态用 RELIABLE
  10. 常用命令 —— ros2 topic echo 是调试神器

下一步

现在你已经理解了 ROS2 的核心概念,下一章我们将学习 OpenCV 图像处理 —— 这是装甲板检测的直接工具。有了 ROS2 的基础,你就能理解检测器是如何接收图像、处理图像、并把结果发布出去的完整流程了。