第2章:ROS通信机制详解

各位同学,欢迎来到第二章。上一章我们搭好了ROS环境,今天来聊聊ROS最核心的东西——通信机制。

ROS本质上是一个分布式框架。节点之间怎么说话?靠的就是通信机制。我个人习惯把ROS通信分成两大类:话题通信服务通信。前者是“发布-订阅”模式,后者是“请求-响应”模式。搞懂这两个,ROS就算入门了。

2.1 话题通信:Publisher/Subscriber

话题通信,说白了就是“广播”。一个节点发消息,其他节点谁想听谁听。发布者只管发,订阅者只管收,双方不需要知道对方的存在。

我在项目中遇到过这样一个场景:机器人上有激光雷达,需要把扫描数据发给导航模块。用话题通信就非常合适——雷达节点持续发布数据,导航节点订阅即可,哪怕同时有多个节点订阅也没问题。

2.1.1 编写一个Publisher节点

我们以C++为例,写一个发布“Hello ROS”字符串的节点。

#include <ros/ros.h>
#include <std_msgs/String.h>

int main(int argc, char **argv)
{
    // 初始化节点,节点名称为"talker"
    ros::init(argc, argv, "talker");
    ros::NodeHandle nh;

    // 创建发布者,话题名为"chatter",队列长度为1000
    ros::Publisher pub = nh.advertise<std_msgs::String>("chatter", 1000);

    ros::Rate loop_rate(10);  // 10Hz

    while (ros::ok())
    {
        std_msgs::String msg;
        msg.data = "Hello ROS!";

        pub.publish(msg);
        ROS_INFO("发布消息: %s", msg.data.c_str());

        ros::spinOnce();
        loop_rate.sleep();
    }

    return 0;
}
小提示:队列长度1000是什么意思?如果订阅者处理速度跟不上,消息会积压在队列里。队列满了,最旧的消息会被丢弃。我一般设100~1000,具体看你的实时性要求。

2.1.2 编写一个Subscriber节点

#include <ros/ros.h>
#include <std_msgs/String.h>

void chatterCallback(const std_msgs::String::ConstPtr& msg)
{
    ROS_INFO("收到消息: %s", msg->data.c_str());
}

int main(int argc, char **argv)
{
    ros::init(argc, argv, "listener");
    ros::NodeHandle nh;

    // 订阅"chatter"话题,回调函数为chatterCallback
    ros::Subscriber sub = nh.subscribe("chatter", 1000, chatterCallback);

    ros::spin();  // 进入循环,等待回调

    return 0;
}

嗯,这里要注意:ros::spin()会阻塞主线程,只处理回调。如果你的节点还要做其他事,用ros::spinOnce()配合循环。

2.1.3 编译与运行

在CMakeLists.txt中添加:

add_executable(talker src/talker.cpp)
add_executable(listener src/listener.cpp)
target_link_libraries(talker ${catkin_LIBRARIES})
target_link_libraries(listener ${catkin_LIBRARIES})

然后:

catkin_make
roscore
rosrun your_package talker
rosrun your_package listener

你会看到talker在发,listener在收。这就是最基础的话题通信。

2.2 服务通信:Server/Client

话题通信是“单向广播”,服务通信是“一问一答”。客户端发送请求,服务端处理并返回响应。适合需要即时结果的场景。

举个例子:机器人要抓取一个物体,需要知道当前机械臂的位置。这时候发一个请求,服务端返回位置数据,比用话题轮询高效得多。

2.2.1 编写一个Server节点

我们实现一个加法服务:客户端发两个整数,服务端返回它们的和。

#include "ros/ros.h"
#include "beginner_tutorials/AddTwoInts.h"

bool add(beginner_tutorials::AddTwoInts::Request  &req,
         beginner_tutorials::AddTwoInts::Response &res)
{
    res.sum = req.a + req.b;
    ROS_INFO("请求: a=%ld, b=%ld", (long int)req.a, (long int)req.b);
    ROS_INFO("响应: sum=%ld", (long int)res.sum);
    return true;
}

int main(int argc, char **argv)
{
    ros::init(argc, argv, "add_two_ints_server");
    ros::NodeHandle n;

    ros::ServiceServer service = n.advertiseService("add_two_ints", add);
    ROS_INFO("服务已就绪,等待请求...");
    ros::spin();

    return 0;
}

2.2.2 编写一个Client节点

#include "ros/ros.h"
#include "beginner_tutorials/AddTwoInts.h"
#include <cstdlib>

int main(int argc, char **argv)
{
    ros::init(argc, argv, "add_two_ints_client");
    if (argc != 3)
    {
        ROS_INFO("用法: add_two_ints_client X Y");
        return 1;
    }

    ros::NodeHandle n;
    ros::ServiceClient client = n.serviceClient<beginner_tutorials::AddTwoInts>("add_two_ints");

    beginner_tutorials::AddTwoInts srv;
    srv.request.a = atoll(argv[1]);
    srv.request.b = atoll(argv[2]);

    if (client.call(srv))
    {
        ROS_INFO("结果: %ld", (long int)srv.response.sum);
    }
    else
    {
        ROS_ERROR("调用服务失败");
        return 1;
    }

    return 0;
}
避坑指南:我曾经犯过一个低级错误——客户端调用服务时,服务端还没启动。结果客户端一直卡在call()那里。记住:调用前最好用ros::service::waitForService()等待服务就绪。

2.3 自定义消息与服务接口

ROS内置的消息类型(如String、Int32)够用吗?说实话,在真实项目中远远不够。你需要定义自己的数据结构。

比如,我要发布一个“机器人状态”消息,包含位置、速度、电池电量。这时候就得自定义消息。

2.3.1 自定义消息(.msg)

在功能包下创建msg/RobotStatus.msg

float32 x
float32 y
float32 theta
float32 linear_velocity
float32 angular_velocity
float32 battery_percent

然后在CMakeLists.txt中添加:

add_message_files(FILES RobotStatus.msg)
generate_messages(DEPENDENCIES std_msgs)

在package.xml中添加:

<build_depend>message_generation</build_depend>
<exec_depend>message_runtime</exec_depend>

编译后,你就可以像使用标准消息一样使用your_package::RobotStatus了。

2.3.2 自定义服务(.srv)

创建srv/GetRobotPose.srv

# 请求:机器人ID
string robot_id
---
# 响应:位置和朝向
float32 x
float32 y
float32 theta

CMakeLists.txt中同样要添加:

add_service_files(FILES GetRobotPose.srv)

这样,你的服务接口就有了自定义的数据结构。

经验之谈:我建议把常用的消息类型放在一个单独的msg包里,比如“robot_msgs”。这样多个功能包都能复用,避免重复定义。你想想看,如果每个包都定义一套“Pose”消息,后期维护起来多头疼。

2.4 话题 vs 服务:怎么选?

特性 话题通信 服务通信
通信模式 发布/订阅(一对多) 请求/响应(一对一)
实时性 高(持续流数据) 中等(调用时才有)
适用场景 传感器数据、状态广播 触发式操作、参数查询
是否阻塞 非阻塞 客户端会阻塞等待

说白了,如果你要持续发数据(比如激光雷达、摄像头),用话题。如果你要“问一个问题,等一个答案”(比如“给我算个路径”),用服务。

好了,这一章的内容就到这里。下一章我们会聊ROS的另一个重要机制——参数服务器和动作通信。到时候见。