3. ROS基础编程:Publisher与Subscriber实现、自定义消息、launch文件编写

好,咱们今天来聊聊ROS里最核心的通信方式——话题(Topic)。说白了,Publisher和Subscriber就是ROS世界里“说话”和“听话”的两个人。你想想看,一个机器人身上那么多传感器,激光雷达、摄像头、IMU,它们各自采集数据,然后通过话题发布出去;其他模块,比如导航、建图,就订阅这些话题来获取数据。这套机制,是ROS的基石。

我个人习惯,每开始一个新项目,第一件事就是先把话题通信的框架搭好。这就像盖房子先打地基,地基稳了,后面加什么功能都顺手。

3.1 编写一个简单的Publisher(发布者)

我们先写一个发布者节点。假设我们要发布一个“Hello ROS”的字符串消息。

创建一个包,名字就叫ros_basics,依赖roscppstd_msgs

cd ~/catkin_ws/src
catkin_create_pkg ros_basics roscpp std_msgs

然后在src文件夹里新建一个文件,叫talker.cpp

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

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

    // 创建一个Publisher,发布到"chatter"话题,队列大小1000
    ros::Publisher pub = nh.advertise<std_msgs::String>("chatter", 1000);

    // 设置循环频率为10Hz
    ros::Rate loop_rate(10);

    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();
        ++count;
    }

    return 0;
}

嗯,这里要注意一点:ros::spinOnce()不能少。它负责处理回调函数,虽然我们这个例子里没有订阅回调,但养成习惯总是好的。我曾经在一个项目里忘了加spinOnce(),结果程序跑起来后,订阅端死活收不到数据,排查了半天才发现是这里的问题。

3.2 编写一个简单的Subscriber(订阅者)

有发布者,就得有订阅者。新建一个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;

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

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

    return 0;
}

你看,订阅者比发布者更简单。它不需要自己控制循环频率,ros::spin()会一直阻塞,直到节点关闭。每次有消息到达,回调函数就会被触发。

我个人觉得,理解回调机制是ROS编程的一个门槛。你想想看,你的程序不是主动去“拿”数据,而是数据来了“通知”你。这种事件驱动的模式,在机器人系统里非常高效。

3.3 自定义消息类型

光用std_msgs::String肯定不够。实际项目中,我们经常需要传递复杂的数据结构。比如,我要发布一个“超声波传感器”的数据,包含距离、时间戳、传感器ID。这时候就需要自定义消息。

ros_basics包下新建一个msg文件夹,创建Sonar.msg

float32 distance
time stamp
int32 sensor_id

然后,修改CMakeLists.txt,添加以下内容:

find_package(catkin REQUIRED COMPONENTS
  roscpp
  std_msgs
  message_generation
)

add_message_files(
  FILES
  Sonar.msg
)

generate_messages(
  DEPENDENCIES
  std_msgs
)

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

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

编译一下:

cd ~/catkin_ws
catkin_make

现在,你就可以在代码里使用ros_basics::Sonar这个类型了。用法和标准消息一模一样。

避坑指南: 我曾经犯过一个低级错误——自定义消息里用了Header类型,但忘了在CMakeLists.txt里添加std_msgs依赖。结果编译报错,提示找不到Header。记住,Headerstd_msgs的一部分,必须显式依赖。

3.4 launch文件编写

手动一个个启动节点太麻烦了。launch文件就是用来批量启动节点的。它用XML格式,简单直观。

ros_basics包下新建launch文件夹,创建talker_listener.launch

<launch>
    <node name="talker" pkg="ros_basics" type="talker" output="screen"/>
    <node name="listener" pkg="ros_basics" type="listener" output="screen"/>
</launch>

启动方式:

roslaunch ros_basics talker_listener.launch

你看,一行命令,两个节点同时跑起来了。output="screen"表示把日志输出到终端,方便调试。

launch文件还有很多高级用法,比如:

  • 参数服务器:通过<param>标签设置参数
  • 命名空间:通过ns属性隔离不同机器人
  • 包含其他launch文件:通过<include>标签复用配置

举个例子,设置一个参数:

<launch>
    <param name="sonar_frequency" value="20.0"/>
    <node name="sonar_node" pkg="ros_basics" type="sonar_publisher" output="screen"/>
</launch>

在代码里,你可以通过nh.getParam("sonar_frequency", freq)来获取这个参数。

核心要点: launch文件不仅仅是启动节点,它还是整个系统的“配置中心”。我建议你把所有可调参数都放到launch文件里,而不是硬编码在代码中。这样,换一个场景,改一下launch文件就行,不用重新编译。

3.5 实战:一个完整的传感器数据采集示例

我们来整合一下。假设我们要采集一个虚拟的超声波传感器数据,并实时显示。

发布者(sonar_publisher.cpp):

#include <ros/ros.h>
#include <ros_basics/Sonar.h>

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

    ros::Publisher pub = nh.advertise<ros_basics::Sonar>("sonar_data", 10);
    ros::Rate loop_rate(5);

    int sensor_id = 1;
    while (ros::ok())
    {
        ros_basics::Sonar msg;
        msg.distance = 1.0 + (rand() % 100) / 100.0; // 模拟1.0~2.0米
        msg.stamp = ros::Time::now();
        msg.sensor_id = sensor_id;

        ROS_INFO("Sonar %d: %.2f m", msg.sensor_id, msg.distance);
        pub.publish(msg);

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

    return 0;
}

订阅者(sonar_subscriber.cpp):

#include <ros/ros.h>
#include <ros_basics/Sonar.h>

void sonarCallback(const ros_basics::Sonar::ConstPtr& msg)
{
    ROS_INFO("Received: Sensor %d, Distance: %.2f m, Time: %.2f",
             msg->sensor_id, msg->distance, msg->stamp.toSec());
}

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

    ros::Subscriber sub = nh.subscribe("sonar_data", 10, sonarCallback);
    ros::spin();

    return 0;
}

launch文件(sonar.launch):

<launch>
    <node name="sonar_pub" pkg="ros_basics" type="sonar_publisher" output="screen"/>
    <node name="sonar_sub" pkg="ros_basics" type="sonar_subscriber" output="screen"/>
</launch>

运行一下:

roslaunch ros_basics sonar.launch

你会看到发布者每秒发5次数据,订阅者实时打印出来。这就是一个最基础的传感器数据采集流程。

注意: 在实际项目中,传感器数据往往有噪声。你可以在订阅者里加一个简单的滤波,比如滑动平均。我做过一个项目,超声波数据抖动很厉害,加了5次滑动平均后,数据平滑多了。

好了,这一章的内容就到这里。Publisher和Subscriber是ROS通信的“左膀右臂”,自定义消息让你能灵活封装数据,launch文件则让系统启动变得优雅。下一章,我们会深入服务(Service)和动作(Action)通信,敬请期待。