第1章:ROS通信机制深入

各位同学,咱们今天来聊聊ROS的通信机制。说实话,这是整个ROS的命脉所在。我刚开始学ROS那会儿,总觉得通信嘛,不就是发发消息、调调服务?后来做项目才发现,这里面的门道可不少。

1.1 话题通信(Publisher/Subscriber)编程实战

话题通信,说白了就是发布-订阅模式。一个节点发消息,其他节点收消息。这种模式在机器人里用得最多——比如激光雷达数据、摄像头图像、里程计信息,都是通过话题来传递的。

核心要点:话题通信是异步的、一对多的。发布者只管发,订阅者只管收,两者互不等待。

1.1.1 编写一个发布者节点

我习惯用C++写ROS节点,因为效率高。咱们先写一个简单的发布者,每隔1秒发布一个字符串消息。

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

int main(int argc, char **argv)
{
    ros::init(argc, argv, "talker");
    ros::NodeHandle nh;
    
    ros::Publisher pub = nh.advertise<std_msgs::String>("chatter", 10);
    
    ros::Rate loop_rate(1);  // 1Hz
    int count = 0;
    
    while (ros::ok())
    {
        std_msgs::String msg;
        msg.data = "Hello ROS! count = " + std::to_string(count++);
        
        ROS_INFO("Publishing: %s", msg.data.c_str());
        pub.publish(msg);
        
        ros::spinOnce();
        loop_rate.sleep();
    }
    
    return 0;
}

嗯,这里要注意几个地方。第一,advertise的第二个参数是队列大小,我建议设成10以上,防止消息丢失。第二,ros::spinOnce()一定要加,不然回调函数不会执行。

个人经验:我在做AGV小车项目时,曾经因为忘记加spinOnce(),导致订阅者收不到消息。排查了半天才发现这个低级错误。所以,养成好习惯很重要。

1.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", 10, chatterCallback);
    
    ros::spin();  // 进入循环,等待回调
    
    return 0;
}

看到没?订阅者比发布者还简单。一个回调函数,一个ros::spin()就搞定了。为什么用spin()而不是spinOnce()?因为订阅者需要一直等待消息,用spin()更合适。

1.2 服务通信(Server/Client)编程实战

话题通信是单向的,服务通信是双向的。客户端发请求,服务端回响应。这就像你打电话问客服——你问一句,对方答一句。

应用场景:服务通信适合需要即时响应的操作,比如控制机器人运动、查询传感器状态、执行任务切换等。

1.2.1 编写一个服务端节点

咱们写一个简单的加法服务。客户端传入两个整数,服务端返回它们的和。

// 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 int)req.a, (long int)req.b);
    ROS_INFO("sending back response: [%ld]", (long int)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;
}

这里有个坑,我当年踩过。服务回调函数必须返回bool值,表示是否成功处理。如果你返回false,客户端会收到一个错误响应。

1.2.2 编写一个客户端节点

// 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");
    
    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 int)srv.response.sum);
    }
    else
    {
        ROS_ERROR("Failed to call service add_two_ints");
        return 1;
    }
    
    return 0;
}

避坑指南:我曾经在调用服务前没有检查服务是否可用,结果程序直接崩溃。建议用ros::service::waitForService()等待服务就绪,或者用client.waitForExistence()

1.3 参数服务器操作

参数服务器,说白了就是一个全局的键值对存储。你可以在任何节点里读写参数,非常方便。

操作 API 说明
设置参数 nh.setParam("key", value) 设置一个参数
获取参数 nh.getParam("key", value) 获取参数值
删除参数 nh.deleteParam("key") 删除指定参数
检查参数 nh.hasParam("key") 检查参数是否存在

举个例子,咱们在启动文件中设置机器人的轮子半径:

<launch>
    <param name="wheel_radius" value="0.1" />
    <node name="robot_node" pkg="my_robot" type="robot_node" output="screen"/>
</launch>

然后在节点里读取:

double wheel_radius;
if (nh.getParam("wheel_radius", wheel_radius))
{
    ROS_INFO("Wheel radius: %f", wheel_radius);
}
else
{
    ROS_WARN("Parameter wheel_radius not set, using default 0.1");
    wheel_radius = 0.1;
}

我的习惯:我一般把机器人的物理参数(轮距、轴距、最大速度等)都放在参数服务器里。这样换硬件时,改个配置文件就行,不用重新编译代码。

1.4 自定义消息与服务

ROS自带的类型(String、Int32等)够用吗?说实话,大部分时候不够。你想想看,一个激光雷达数据包含距离、角度、时间戳,用基本类型拼凑多麻烦?这时候就需要自定义消息了。

1.4.1 自定义消息

在包目录下创建msg文件夹,新建MyMessage.msg

# MyMessage.msg
string name
int32 age
float32 score
time stamp

然后在CMakeLists.txt里添加:

add_message_files(
  FILES
  MyMessage.msg
)

generate_messages(
  DEPENDENCIES
  std_msgs
)

别忘了在package.xml里添加依赖:

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

1.4.2 自定义服务

类似地,创建srv文件夹,新建MyService.srv

# MyService.srv
int32 input
---
string output

横线上面是请求,下面是响应。这个格式很直观,对吧?

关键点:自定义消息和服务编译后,ROS会自动生成对应的头文件和类。你只需要在代码里#include就能用了。

小结

这一章咱们把ROS的三大通信机制都过了一遍。话题通信适合持续的数据流,服务通信适合即时的请求-响应,参数服务器适合全局配置。我个人觉得,理解这三者的区别和适用场景,比记住API更重要。

下节课咱们聊ROS的坐标变换系统——tf。那可是机器人定位的基石,到时候我会分享一些实际项目中的坑和技巧。

课后练习:写一个话题发布者,发布自定义消息(包含机器人位置x、y和朝向theta)。再写一个订阅者,打印接收到的位置信息。试试看,不难的。