ROS2通信机制实战——从“单向传数据”到“双向发请求”(二)

发布于:2025-08-29 ⋅ 阅读:(12) ⋅ 点赞:(0)

第2天:ROS2通信机制实战——从“单向传数据”到“双向发请求”

做机器人开发时:“为什么控制机器人前进用话题,而让机器人报位置要用服务?”其实答案很简单——ROS2的通信机制是“按需设计”的:话题适合高频、单向的数据传输(比如实时发控制指令),服务适合低频、需要反馈的交互(比如查询状态)。
在这里插入图片描述

上午:话题通信——让数据“单向流动”

话题是ROS2里用得最多的通信方式,比如激光雷达的点云、机器人的速度指令,都是通过话题传输的。它的核心是“发布-订阅”模式,就像电台广播:一个电台(发布者)发信号,无数收音机(订阅者)都能听——但收音机不能给电台回消息,这就是“单向”的意思。

一、先搞懂话题的“底层逻辑”

1. 三个关键特性:记熟了才不会用错
  • 单向异步:发布者发数据时,不用等订阅者接收,自己该干啥干啥;订阅者收数据时,也不用管发布者是谁——我当年做AGV项目时,激光雷达节点(发布者)就算断了,导航节点(订阅者)最多报“没数据”,不会直接崩溃,这就是异步的优势。
  • 多对多通信:一个话题可以有多个发布者,也可以有多个订阅者。比如“机器人位置”这个话题,既能让导航节点订阅,也能让监控节点订阅;甚至可以有两个发布者(GPS和里程计)同时发位置数据,订阅者按需选择。
  • 消息接口要统一:发布者和订阅者必须用同一个“消息格式”(比如都用geometry_msgs/msg/Twist),不然数据传过去也是“乱码”。这就像电台用FM92.5广播,收音机也得调到这个频率才能听清——我当年就因为把Twist写成Pose,调试了半小时才发现接口不匹配。
2. 用CLI工具“玩转”话题:先看再动手

在写代码前,先用ROS2自带的命令行工具(CLI)熟悉话题的操作——这是排查问题的“神器”,比如节点没收到数据时,先用CLI确认话题是否存在、数据是否正常。

前提:先启动两个节点,做后续测试用

  • 终端1:ros2 run turtlesim turtlesim_node(启动小乌龟仿真节点,作为订阅者,接收速度指令)
  • 终端2:ros2 run turtlesim turtle_teleop_key(启动键盘控制节点,作为发布者,发速度指令)

接下来用三个核心命令实战:

  1. 查话题列表:有哪些话题在运行?
    ros2 topic list,会看到/turtle1/cmd_vel(速度指令话题)、/turtle1/pose(乌龟位置话题)等。加-t参数还能看话题类型:ros2 topic list -t,输出/turtle1/cmd_vel [geometry_msgs/msg/Twist]——这就知道了,/turtle1/cmd_vel用的是Twist格式的消息。

  2. 看话题数据:数据到底长啥样?
    想知道小乌龟的位置,输ros2 topic echo /turtle1/pose,会实时打印x(x坐标)、y(y坐标)、theta(角度)等数据。按终端2的方向键控制乌龟移动,会看到数据跟着变——这就是“订阅者实时接收数据”的直观表现。

  3. 手动发话题:不用代码也能控制节点?
    有时候想快速测试节点是否正常,不用写发布者代码,直接用CLI发数据。先停掉终端2的键盘节点(Ctrl+C),然后输:

ros2 topic pub /turtle1/cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.2, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.5}}"

看仿真窗口:乌龟会以0.2m/s的速度前进,同时以0.5rad/s的速度左转——这说明手动发布的话题被turtlesim节点正确接收了。

二、用C++写话题节点:从发布到订阅

光用CLI不够,实际开发要自己写节点。下面带大家写一个“发布者发字符串消息,订阅者收消息并打印”的例子——这是最基础的模板,后续不管是发激光数据还是速度指令,都能套用这个框架。

1. 先搭环境:创建功能包

首先创建工作空间和C++功能包(依赖rclcppstd_msgsstd_msgs里有常用的字符串、整数等消息类型):

mkdir -p topic_ws/src && cd topic_ws/src
# 创建C++功能包,依赖rclcpp(ROS2 C++核心库)和std_msgs(标准消息库)
ros2 pkg create topic_cpp --build-type ament_cmake --dependencies rclcpp std_msgs
cd ..  # 回到工作空间根目录
2. 写发布者节点:每500ms发一次“Hello Topic!”

发布者的核心逻辑是:初始化节点→创建发布者→用定时器定时发消息。
文件路径:src/topic_cpp/src/topic_publisher.cpp,代码如下(关键步骤已标注释):

#include "rclcpp/rclcpp.hpp"  // ROS2 C++核心头文件
#include "std_msgs/msg/string.hpp"  // 字符串消息头文件

// 定义发布者节点类,继承自rclcpp::Node(所有ROS2节点都要继承这个类)
class TopicPublisherNode : public rclcpp::Node
{
public:
  // 构造函数:初始化节点名、发布者、定时器
  TopicPublisherNode(std::string node_name) : Node(node_name)
  {
    // 打印日志,告诉用户节点启动了(RCLCPP_INFO是ROS2的日志宏)
    RCLCPP_INFO(this->get_logger(), "%s节点已启动!", node_name.c_str());
    
    // 创建发布者:话题名"chatter",QoS队列大小10(后面讲QoS,先设10就行)
    publisher_ = this->create_publisher<std_msgs::msg::String>("chatter", 10);
    
    // 创建定时器:每500ms触发一次回调函数(发消息)
    timer_ = this->create_wall_timer(
      std::chrono::milliseconds(500),  // 时间间隔500ms
      std::bind(&TopicPublisherNode::timer_callback, this)  // 绑定回调函数
    );
  }

private:
  // 定时器回调函数:每次触发就发布一条消息
  void timer_callback()
  {
    auto msg = std_msgs::msg::String();  // 创建字符串消息对象
    msg.data = "Hello Topic!";  // 设置消息内容
    RCLCPP_INFO(this->get_logger(), "发布消息:%s", msg.data.c_str());  // 打印发布的消息
    publisher_->publish(msg);  // 发布消息到"chatter"话题
  }

  // 声明发布者指针(类型是std_msgs::msg::String的发布者)
  rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
  // 声明定时器指针
  rclcpp::TimerBase::SharedPtr timer_;
};

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);  // 初始化ROS2环境
  // 创建发布者节点对象(节点名"topic_publisher")
  auto node = std::make_shared<TopicPublisherNode>("topic_publisher");
  rclcpp::spin(node);  // 循环运行节点,处理回调(比如定时器回调)
  rclcpp::shutdown();  // 关闭ROS2环境
  return 0;
}
3. 写订阅者节点:接收“chatter”话题的消息

订阅者的核心逻辑是:初始化节点→创建订阅者→绑定回调函数(收到消息后执行)。
文件路径:src/topic_cpp/src/topic_subscriber.cpp,代码如下:

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

// 定义订阅者节点类
class TopicSubscriberNode : public rclcpp::Node
{
public:
  TopicSubscriberNode(std::string node_name) : Node(node_name)
  {
    RCLCPP_INFO(this->get_logger(), "%s节点已启动!", node_name.c_str());
    
    // 创建订阅者:话题名"chatter",QoS队列大小10,绑定回调函数
    subscriber_ = this->create_subscription<std_msgs::msg::String>(
      "chatter",  // 要订阅的话题名(必须和发布者一致)
      10,         // QoS队列大小
      // 绑定回调函数:收到消息后调用topic_callback
      std::bind(&TopicSubscriberNode::topic_callback, this, std::placeholders::_1)
    );
  }

private:
  // 话题回调函数:收到消息后执行(msg是收到的消息)
  void topic_callback(const std_msgs::msg::String::SharedPtr msg)
  {
    RCLCPP_INFO(this->get_logger(), "收到消息:%s", msg->data.c_str());  // 打印收到的消息
  }

  // 声明订阅者指针
  rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscriber_;
};

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<TopicSubscriberNode>("topic_subscriber");
  rclcpp::spin(node);  // 循环等待消息,收到消息就触发回调
  rclcpp::shutdown();
  return 0;
}
4. 配置编译规则:让ROS2认识你的节点

写好代码后,要告诉编译器“怎么编译这些文件”,需要修改CMakeLists.txt(C++项目的编译配置文件)。
文件路径:src/topic_cpp/CMakeLists.txt,在文件末尾添加以下内容:

# 1. 找到依赖包(告诉编译器需要用rclcpp和std_msgs)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

# 2. 编译发布者节点:把topic_publisher.cpp编译成可执行文件
add_executable(topic_publisher src/topic_publisher.cpp)
# 链接依赖包(让可执行文件能调用rclcpp和std_msgs的函数)
ament_target_dependencies(topic_publisher rclcpp std_msgs)

# 3. 编译订阅者节点:同理
add_executable(topic_subscriber src/topic_subscriber.cpp)
ament_target_dependencies(topic_subscriber rclcpp std_msgs)

# 4. 安装可执行文件:把编译好的节点放到install目录(ROS2能找到的地方)
install(TARGETS
  topic_publisher
  topic_subscriber
  DESTINATION lib/${PROJECT_NAME}  # 安装路径(固定格式)
)
5. 编译运行:验证通信是否成功
  • 编译:回到工作空间根目录,执行编译命令(只编译topic_cpp功能包,节省时间):
    colcon build --packages-select topic_cpp
    
  • 运行发布者:打开终端1,加载环境变量并启动发布者:
    source install/setup.bash && ros2 run topic_cpp topic_publisher
    
    会看到日志:topic_publisher节点已启动! 发布消息:Hello Topic!(每500ms一条)。
  • 运行订阅者:打开终端2,同理启动订阅者:
    source install/setup.bash && ros2 run topic_cpp topic_subscriber
    
    会看到日志:topic_subscriber节点已启动! 收到消息:Hello Topic!——这说明发布者和订阅者通信成功了!

下午:服务通信——需要“反馈”就用它

如果说话题是“广播”,那服务就是“打电话”:你(客户端)打给客服(服务端),说“我要查账单”(发送请求),客服查完后告诉你“账单金额是XX”(返回响应)——必须等客服回复,你才知道结果,这就是“双向同步”的特点。

一、服务的核心逻辑:和话题的关键区别

1. 三个核心特性:别和话题搞混
  • 双向同步:客户端发送请求后,会一直等服务端的响应,期间不会做其他事;服务端必须处理完请求,才能给客户端回复——我当年做机器人“归位”功能时,客户端发“归位请求”,必须等服务端返回“已归位”,才会执行下一步,这就是同步的必要性。
  • 1对1通信:一个服务名只能对应一个服务端(比如“查位置”服务,不能有两个服务端同时提供),但可以有多个客户端调用同一个服务端——比如多个节点都想查机器人位置,都能调用“查位置”服务。
  • 服务接口分“请求”和“响应”:和话题的“单一消息格式”不同,服务接口(.srv文件)分两部分,用---分隔,上面是请求字段,下面是响应字段。比如“两数相加”服务的接口:
    int64 a  # 请求:第一个数
    int64 b  # 请求:第二个数
    ---
    int64 sum  # 响应:两数之和
    
2. 用CLI工具测试服务:先看后写

前提:启动一个官方的服务端节点(提供“两数相加”服务)
终端1:ros2 run examples_rclpy_minimal_service service——这个节点提供/add_two_ints服务,功能是接收两个整数,返回它们的和。

用三个核心命令实战:

  1. 查服务列表:有哪些服务在运行?
    ros2 service list,会看到/add_two_ints(两数相加服务)——这就是我们要调用的服务。

  2. 查服务类型:服务用的是什么接口?
    ros2 service type /add_two_ints,输出example_interfaces/srv/AddTwoInts——这说明该服务用的是AddTwoInts接口,包含a(请求)、b(请求)、sum(响应)三个字段。

  3. 手动调用服务:不用代码也能发请求?
    ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 5, b: 10}"——意思是“调用/add_two_ints服务,请求参数a=5b=10”。
    终端1(服务端)会输出“收到请求:a=5, b=10”,终端2(客户端)会输出“响应:sum=15”——这说明服务调用成功了。

二、自定义服务接口:满足个性化需求

官方的服务接口(比如AddTwoInts)不够用怎么办?比如我们想让机器人“移动指定距离后返回当前位置”,就需要自定义一个服务接口。下面带大家从0到1创建自定义服务接口。

1. 创建接口功能包:专门放自定义接口

接口功能包必须用ament_cmake编译类型,且依赖rosidl_default_generators(用于生成接口代码):

# 回到topic_ws/src目录
cd topic_ws/src
# 创建接口功能包
ros2 pkg create custom_srv_interfaces --build-type ament_cmake --dependencies rosidl_default_generators
2. 写自定义服务接口(.srv文件)

服务接口文件必须放在srv目录下,文件名就是接口名(比如MoveRobot.srv)。

  • 先创建srv目录:mkdir -p src/custom_srv_interfaces/srv
  • 创建并编辑MoveRobot.srvtouch src/custom_srv_interfaces/srv/MoveRobot.srv
    文件内容如下(请求是“移动距离”,响应是“移动后的位置”):
# 请求:机器人需要移动的距离(正数前进,负数后退)
float32 distance
---
# 响应:移动后的当前位置
float32 pose
3. 配置CMakeLists.txt:生成接口代码

修改src/custom_srv_interfaces/CMakeLists.txt,告诉编译器“要生成这个.srv文件的代码”:

# 找到依赖包
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)

# 声明要生成的接口文件(这里是MoveRobot.srv)
rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/MoveRobot.srv"  # 自定义的服务接口文件
)

# 标记功能包为接口包(让其他功能包能找到这个接口)
ament_package()
4. 配置package.xml:声明依赖和接口组

修改src/custom_srv_interfaces/package.xml,添加编译依赖和接口组声明(否则其他功能包找不到这个接口):

<!-- 编译依赖:告诉ROS2编译时需要这些包 -->
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rosidl_default_generators</depend>

<!-- 关键:声明这个包是接口包,让其他包能引用它的接口 -->
<member_of_group>rosidl_interface_packages</member_of_group>

<!-- 测试依赖(可选,新手暂时用不到) -->
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>
5. 编译接口包:生成可调用的代码

回到工作空间根目录,编译接口包:

colcon build --packages-select custom_srv_interfaces

编译完成后,ROS2会自动生成C++和Python的接口代码:

  • C++头文件路径:install/custom_srv_interfaces/include/custom_srv_interfaces/srv/move_robot.hpp(后续C++节点会引用这个头文件)
  • 不用手动管生成的代码,后续直接调用就行——我当年第一次自定义接口时,以为要自己写头文件,白忙活了半天。

三、用C++写服务节点:客户端发请求,服务端处理

有了自定义接口,接下来写服务端和客户端节点:服务端模拟机器人移动(接收“移动距离”请求,返回“最终位置”),客户端发送移动请求并接收响应。

1. 创建C++服务功能包

先创建依赖rclcpp和自定义接口的功能包:

cd topic_ws/src
# 创建C++功能包,依赖rclcpp和自定义接口custom_srv_interfaces
ros2 pkg create service_cpp --build-type ament_cmake --dependencies rclcpp custom_srv_interfaces
cd ..
2. 写服务端节点:处理“移动请求”

服务端的核心逻辑是:初始化节点→创建服务端→绑定请求处理回调(收到请求后模拟移动,返回响应)。
文件路径:src/service_cpp/src/service_server.cpp,代码如下:

#include "rclcpp/rclcpp.hpp"
// 引用自定义服务接口的头文件(编译后自动生成)
#include "custom_srv_interfaces/srv/move_robot.hpp"
#include <chrono>
#include <thread>

// 给自定义接口起个别名,后续写代码更简洁
using MoveRobot = custom_srv_interfaces::srv::MoveRobot;
// 时间别名,方便用500ms休眠
using namespace std::chrono_literals;

class ServiceServerNode : public rclcpp::Node
{
public:
  ServiceServerNode(std::string node_name) : Node(node_name), current_pose_(0.0)
  {
    RCLCPP_INFO(this->get_logger(), "%s节点已启动!", node_name.c_str());
    
    // 创建服务端:服务名"move_robot_srv",绑定请求处理回调
    service_ = this->create_service<MoveRobot>(
      "move_robot_srv",  // 服务名(客户端必须和这个一致)
      // 绑定回调函数:收到请求后调用handle_move_request
      std::bind(&ServiceServerNode::handle_move_request, this, 
                std::placeholders::_1, std::placeholders::_2)
    );
  }

private:
  // 请求处理回调函数:参数1是请求,参数2是响应
  void handle_move_request(
    const std::shared_ptr<MoveRobot::Request> request,
    const std::shared_ptr<MoveRobot::Response> response
  )
  {
    // 打印收到的请求:移动距离和当前位置
    RCLCPP_INFO(this->get_logger(), "收到请求:移动距离=%.2f,当前位置=%.2f", 
                request->distance, current_pose_);
    
    // 模拟机器人移动:每步移动剩余距离的10%,休眠500ms(模拟真实移动过程)
    float target_pose = current_pose_ + request->distance;  // 目标位置
    while (std::fabs(target_pose - current_pose_) > 0.01)  // 误差小于0.01时停止
    {
      float step = (target_pose - current_pose_) * 0.1;  // 每步移动距离
      current_pose_ += step;
      RCLCPP_INFO(this->get_logger(), "移动中:当前位置=%.2f", current_pose_);
      std::this_thread::sleep_for(500ms);  // 休眠500ms
    }
    
    // 设置响应:返回最终位置
    response->pose = current_pose_;
    RCLCPP_INFO(this->get_logger(), "响应请求:最终位置=%.2f", response->pose);
  }

  // 服务端指针(类型是自定义的MoveRobot服务)
  rclcpp::Service<MoveRobot>::SharedPtr service_;
  float current_pose_;  // 机器人当前位置(初始为0)
};

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<ServiceServerNode>("move_robot_server");
  rclcpp::spin(node);  // 循环运行,等待请求
  rclcpp::shutdown();
  return 0;
}
3. 写客户端节点:发送“移动请求”

客户端的核心逻辑是:初始化节点→创建客户端→等待服务端上线→发送请求→接收响应。
文件路径:src/service_cpp/src/service_client.cpp,代码如下:

#include "rclcpp/rclcpp.hpp"
#include "custom_srv_interfaces/srv/move_robot.hpp"

using MoveRobot = custom_srv_interfaces::srv::MoveRobot;
using namespace std::chrono_literals;

class ServiceClientNode : public rclcpp::Node
{
public:
  ServiceClientNode(std::string node_name) : Node(node_name)
  {
    RCLCPP_INFO(this->get_logger(), "%s节点已启动!", node_name.c_str());
    
    // 创建客户端:服务名"move_robot_srv"(必须和服务端一致)
    client_ = this->create_client<MoveRobot>("move_robot_srv");
    
    // 定时器:启动后1s发送请求(确保服务端有时间启动,避免请求发送失败)
    timer_ = this->create_wall_timer(
      1s,  // 1s后触发
      std::bind(&ServiceClientNode::send_move_request, this)
    );
  }

  // 发送请求的函数
  void send_move_request()
  {
    timer_->cancel();  // 取消定时器:只发送一次请求(避免重复发)
    
    // 等待服务端上线:超时1s,最多等10次(防止一直等下去)
    while (!client_->wait_for_service(1s))
    {
      if (!rclcpp::ok())  // 如果节点被中断(比如按Ctrl+C),就退出
      {
        RCLCPP_ERROR(this->get_logger(), "等待服务时节点被中断!");
        return;
      }
      RCLCPP_INFO(this->get_logger(), "等待服务端上线...");
    }
    
    // 构造请求数据:移动距离=5.0(前进5个单位)
    auto request = std::make_shared<MoveRobot::Request>();
    request->distance = 5.0;
    
    // 发送异步请求:绑定响应回调(收到响应后执行response_callback)
    auto result_future = client_->async_send_request(
      request, 
      std::bind(&ServiceClientNode::response_callback, this, std::placeholders::_1)
    );
  }

  // 响应回调函数:收到服务端响应后执行
  void response_callback(const rclcpp::Client<MoveRobot>::SharedFuture future)
  {
    auto response = future.get();  // 获取响应结果
    RCLCPP_INFO(this->get_logger(), "收到响应:移动后位置=%.2f", response->pose);
  }

private:
  rclcpp::Client<MoveRobot>::SharedPtr client_;  // 客户端指针
  rclcpp::TimerBase::SharedPtr timer_;  // 定时器指针(用于延迟发送请求)
};

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<ServiceClientNode>("move_robot_client");
  rclcpp::spin(node);  // 循环等待响应
  rclcpp::shutdown();
  return 0;
}
4. 配置CMakeLists.txt:编译服务节点

修改src/service_cpp/CMakeLists.txt,添加编译规则(和话题节点类似,只是依赖多了自定义接口):

# 找到依赖包:rclcpp和自定义的custom_srv_interfaces
find_package(rclcpp REQUIRED)
find_package(custom_srv_interfaces REQUIRED)

# 编译服务端节点
add_executable(move_robot_server src/service_server.cpp)
ament_target_dependencies(move_robot_server rclcpp custom_srv_interfaces)

# 编译客户端节点
add_executable(move_robot_client src/service_client.cpp)
ament_target_dependencies(move_robot_client rclcpp custom_srv_interfaces)

# 安装节点到install目录
install(TARGETS
  move_robot_server
  move_robot_client
  DESTINATION lib/${PROJECT_NAME}
)
5. 编译运行:验证服务通信
  • 编译:回到工作空间根目录,编译service_cpp功能包:
    colcon build --packages-select service_cpp
    
  • 运行服务端:终端1执行(必须先启动服务端,客户端才能发请求):
    source install/setup.bash && ros2 run service_cpp move_robot_server
    
    看到日志:move_robot_server节点已启动!,服务端开始等待请求。
  • 运行客户端:终端2执行:
    source install/setup.bash && ros2 run service_cpp move_robot_client
    
    客户端会先打印“等待服务端上线…”(如果服务端已启动,会直接发请求),然后服务端打印“移动中”的日志,最后客户端打印“收到响应:移动后位置=5.00”——服务通信成功!

看展示:
在这里插入图片描述

github源码地址:https://github.com/Timnf/github/tree/main/ros


网站公告

今日签到

点亮在社区的每一天
去签到