第4章 ROS通信机制:让机器人节点真正“聊起来”
好,咱们进入正题。前面几章我们把ROS环境搭好了,也跑通了几个基础例程。但说实话,那些都是“单机版”——一个节点自己玩自己的。真正的机器人系统,传感器、控制器、执行器各司其职,它们之间怎么交换数据?这就是ROS通信机制要解决的核心问题。
我个人习惯把ROS通信分成两大类:话题(Topic)和服务(Service)。打个比方,话题就像广播电台——发布者只管往外发,订阅者想听就听,双方不需要知道对方是谁。服务呢,更像打电话——你打过去,对方接,你问一句他答一句,有来有回。
核心认知:话题用于持续的数据流(传感器数据、状态信息),服务用于一次性的请求-响应(执行动作、查询参数)。选错了通信方式,系统会变得又慢又别扭。
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"测试服务。命令行工具能帮你快速验证通信是否正常,不用每次都写订阅者代码。
好了,这一章的内容就到这里。话题通信让你能搭建数据流水线,服务通信让你能实现指令交互,自定义消息则让数据结构更贴近真实需求。这三种能力组合起来,你已经可以搭建一个多节点协作的机器人系统雏形了。