第4章 ROS通信机制:让机器人节点真正“聊起来”

好,咱们进入正题。前面几章我们把ROS环境搭好了,也跑通了几个基础例程。但说实话,那些都是“单机版”——一个节点自己玩自己的。真正的机器人系统,传感器、控制器、执行器各司其职,它们之间怎么交换数据?这就是ROS通信机制要解决的核心问题。

我个人习惯把ROS通信分成两大类:话题(Topic)和服务(Service)。打个比方,话题就像广播电台——发布者只管往外发,订阅者想听就听,双方不需要知道对方是谁。服务呢,更像打电话——你打过去,对方接,你问一句他答一句,有来有回。

核心认知:话题用于持续的数据流(传感器数据、状态信息),服务用于一次性的请求-响应(执行动作、查询参数)。选错了通信方式,系统会变得又慢又别扭。

ROS通信机制总览 话题通信(Topic) 发布者 → 话题 → 订阅者 一对多、异步、持续流 适用:传感器数据、状态 服务通信(Service) 客户端 → 服务端 → 响应 一对一、同步、请求-响应 适用:指令、查询、配置 自定义消息与服务定义(.msg / .srv) 结构化数据载体,让通信内容更丰富 选型原则:持续数据流 → 话题;一次交互 → 服务 复杂数据结构 → 自定义消息/服务

4.1 话题通信编程实践

先聊话题。你想想看,一个机器人身上有多少传感器?激光雷达、IMU、摄像头、编码器……每个传感器都在不断产生数据。如果用服务的方式,每来一帧数据就要请求一次,系统早就卡死了。

话题通信的核心模式是发布-订阅。发布者只管往话题里扔数据,订阅者只管从话题里取数据。双方完全解耦。我在做AGV小车项目时,激光雷达数据就是通过话题发布的,路径规划节点订阅它,同时避障节点也订阅它——一个数据源,多个消费者,完美。

4.1.1 编写发布者节点

咱们用C++写一个简单的发布者,每隔0.5秒发布一次“Hello ROS”字符串。

// talker.cpp
#include "ros/ros.h"
#include "std_msgs/String.h"
#include <sstream>

int main(int argc, char **argv)
{
  ros::init(argc, argv, "talker");
  ros::NodeHandle nh;
  
  // 创建发布者,话题名"chatter",队列大小1000
  ros::Publisher pub = nh.advertise<std_msgs::String>("chatter", 1000);
  
  ros::Rate loop_rate(2);  // 2Hz,每0.5秒一次
  
  int count = 0;
  while (ros::ok())
  {
    std_msgs::String msg;
    std::stringstream ss;
    ss << "Hello ROS, count = " << count;
    msg.data = ss.str();
    
    ROS_INFO("Publishing: %s", msg.data.c_str());
    pub.publish(msg);
    
    ros::spinOnce();
    loop_rate.sleep();
    ++count;
  }
  return 0;
}

队列大小1000是什么意思? 如果订阅者处理速度跟不上发布速度,消息会在队列里缓存。超过1000条时,最旧的消息会被丢弃。我建议根据实际数据频率调整——高频数据(如激光雷达10Hz以上)队列可以设大些,低频数据设小些。

4.1.2 编写订阅者节点

订阅者就简单了——等着收消息就行。

// listener.cpp
#include "ros/ros.h"
#include "std_msgs/String.h"

void chatterCallback(const std_msgs::String::ConstPtr& msg)
{
  ROS_INFO("I heard: [%s]", msg->data.c_str());
}

int main(int argc, char **argv)
{
  ros::init(argc, argv, "listener");
  ros::NodeHandle nh;
  
  ros::Subscriber sub = nh.subscribe("chatter", 1000, chatterCallback);
  
  ros::spin();  // 进入循环,等待回调
  return 0;
}

注意ros::spin()和ros::spinOnce()的区别。前者阻塞等待回调,后者只处理一次就返回。我一般在循环里用spinOnce(),在纯订阅节点里用spin()。

4.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})

然后:

cd ~/catkin_ws
catkin_make
roscore  # 先启动ROS Master
rosrun beginner_tutorials talker
rosrun beginner_tutorials listener

你会看到订阅者窗口不断打印“I heard: Hello ROS, count = x”。嗯,这里要注意:必须先启动roscore,否则节点之间找不到彼此。我曾经有一次调试了半天,发现是终端没开roscore……

4.2 服务通信编程实践

话题搞定了,再来看看服务。服务的场景很明确:你需要一个确定的回答。比如“给我一个加法结果”、“帮我设置一下参数”、“启动某个功能”。

服务的模式是客户端-服务端。客户端发送请求,服务端处理并返回响应。整个过程是同步的——客户端会阻塞等待结果。

4.2.1 定义服务文件

先创建一个.srv文件,定义请求和响应的结构:

# AddTwoInts.srv
int64 a
int64 b
---
int64 sum

横线上面是请求,下面是响应。然后在CMakeLists.txt里添加:

add_service_files(FILES AddTwoInts.srv)
generate_messages(DEPENDENCIES std_msgs)

4.2.2 编写服务端节点

// server.cpp
#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("request: a=%ld, b=%ld", (long)req.a, (long)req.b);
  ROS_INFO("sending back response: [%ld]", (long)res.sum);
  return true;
}

int main(int argc, char **argv)
{
  ros::init(argc, argv, "add_two_ints_server");
  ros::NodeHandle nh;
  
  ros::ServiceServer service = nh.advertiseService("add_two_ints", add);
  ROS_INFO("Ready to add two ints.");
  ros::spin();
  
  return 0;
}

4.2.3 编写客户端节点

// client.cpp
#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("usage: add_two_ints_client X Y");
    return 1;
  }
  
  ros::NodeHandle nh;
  ros::ServiceClient client = nh.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("Sum: %ld", (long)srv.response.sum);
  }
  else
  {
    ROS_ERROR("Failed to call service add_two_ints");
    return 1;
  }
  
  return 0;
}

服务调用超时问题: 如果服务端没启动,客户端调用client.call()会一直阻塞。我建议在实际项目里加上超时判断,或者先检查服务是否存在:ros::service::waitForService("add_two_ints", ros::Duration(5.0));

4.3 自定义消息与服务

标准消息类型(String、Int32、Float64等)能应付简单场景。但真实机器人系统里,数据往往是复合的——比如一个IMU数据包含加速度、角速度、姿态四元数。这时候就需要自定义消息了。

说白了,自定义消息就是你自己定义的数据结构。我当年做四足机器人时,每条腿的状态包含关节角度、力矩、触地信号,打包成一个自定义消息,订阅者拿到后直接解析,清爽多了。

4.3.1 创建自定义消息

在功能包下创建msg/ImuData.msg:

# ImuData.msg
float64 ax
float64 ay
float64 az
float64 gx
float64 gy
float64 gz
float64 qw
float64 qx
float64 qy
float64 qz

然后在CMakeLists.txt里:

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

别忘了在package.xml里添加:

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

4.3.2 使用自定义消息

发布者里这样用:

#include "beginner_tutorials/ImuData.h"
beginner_tutorials::ImuData imu_msg;
imu_msg.ax = 0.01;
imu_msg.ay = -0.02;
imu_msg.az = 9.81;
pub.publish(imu_msg);

订阅者回调里直接访问字段:

void imuCallback(const beginner_tutorials::ImuData::ConstPtr& msg)
{
  ROS_INFO("Accel: %f, %f, %f", msg->ax, msg->ay, msg->az);
}

4.3.3 自定义服务

同理,服务也可以自定义。比如一个“设置目标点”的服务:

# SetGoal.srv
float64 x
float64 y
float64 theta
---
bool success
string message

客户端发送坐标和朝向,服务端返回是否成功以及提示信息。这种结构在实际导航系统中非常常见。

最佳实践: 自定义消息的字段命名要清晰,类型要精确。能用float64就别用int32,避免精度丢失。另外,消息文件里可以嵌套其他消息类型,比如std_msgs/Header header——加上时间戳和坐标系,方便数据同步。

4.4 话题 vs 服务:选型对比

我整理了一个对比表,你写代码前可以对照着看:

维度 话题(Topic) 服务(Service)
通信模式 发布-订阅(异步) 请求-响应(同步)
数据流向 单向,发布者→订阅者 双向,客户端→服务端→客户端
节点关系 一对多,解耦 一对一,耦合
典型场景 传感器数据、状态广播 指令执行、参数查询
实时性 取决于发布频率和队列 取决于服务端处理速度
错误处理 订阅者收不到就丢数据 客户端可检测超时/失败

选型其实不难:持续流数据用话题,一次交互用服务。如果你发现某个功能既可以用话题也可以用服务,那就想想数据是“一直发”还是“问一次”。

一个小技巧: 调试阶段可以用rostopic echo /chatter查看话题数据,用rosservice call /add_two_ints "a: 3 b: 5"测试服务。命令行工具能帮你快速验证通信是否正常,不用每次都写订阅者代码。

好了,这一章的内容就到这里。话题通信让你能搭建数据流水线,服务通信让你能实现指令交互,自定义消息则让数据结构更贴近真实需求。这三种能力组合起来,你已经可以搭建一个多节点协作的机器人系统雏形了。