3 · 话题通信实战

好,咱们直接动手。前两章把概念和工具铺垫完了,这一章我带你写点真东西。你会亲手搓一个发布者、一个订阅者,然后用 launch 文件把它们撮合到一起。说白了,就是 ROS 里最核心的“发-收”模型,你搞懂了它,SLAM 里那些传感器数据流、里程计、地图话题,全是一个套路。

发布者节点 talker 订阅者节点 listener /chatter 话题 发布者 → 话题 → 订阅者(解耦通信)

3.1 编写一个简单的发布者节点

我习惯用 C++ 写节点,因为 SLAM 里对实时性要求高。不过 Python 版本也给你,你挑顺手的上手就行。先看 C++ 的发布者,我们叫它 talker。

// 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;
    ros::Publisher pub = nh.advertise<std_msgs::String>("chatter", 10);
    ros::Rate loop_rate(10);  // 10 Hz

    int count = 0;
    while (ros::ok())
    {
        std_msgs::String msg;
        std::stringstream ss;
        ss << "hello SLAM " << count;
        msg.data = ss.str();

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

        ros::spinOnce();
        loop_rate.sleep();
        ++count;
    }
    return 0;
}

嗯,这里要注意:advertise 的第二个参数是队列大小,10 表示最多缓存 10 条消息。我在项目里遇到过队列设太小导致丢消息,尤其是激光雷达数据,建议根据频率调大一点。

💡 个人小经验: 发布频率别太猛。SLAM 里 IMU 数据可能 200Hz,但如果你只是测试,10Hz 足够了。我曾经把测试节点设成 1000Hz,结果把 rviz 搞崩了……

3.2 编写一个简单的订阅者节点

有发就有收。订阅者节点 listener 会一直监听 /chatter 话题,收到消息就打印出来。

// listener.cpp
#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;
    ros::Subscriber sub = nh.subscribe("chatter", 10, chatterCallback);
    ros::spin();  // 阻塞等待回调
    return 0;
}

你看,订阅者比发布者还简单。核心就三行:初始化、订阅、spin。这里我特别想说一下 ros::spin() 和 ros::spinOnce() 的区别。你想想看,如果你的节点还要做其他计算(比如跑 SLAM 前端),就不能用 spin() 卡死,得用 spinOnce() 配合循环。我在做多传感器融合时,就因为这个踩过坑——用了 spin 导致其他传感器回调饿死。

🔧 避坑指南: 我曾经在订阅回调里直接做耗时处理(比如闭环检测),结果话题消息越积越多,最后节点崩了。记住:回调函数要轻量,复杂计算扔到单独线程里。

3.3 使用 launch 文件启动多节点

手动开两个终端,一个 rosrun talker,一个 rosrun listener,太原始了。生产环境里我们全用 launch 文件。下面这个 launch 把两个节点一起拉起来,还能设置参数。

<?xml version="1.0"?>
<launch>
    <node name="talker_node" pkg="beginner_tutorials" type="talker" output="screen"/>
    <node name="listener_node" pkg="beginner_tutorials" type="listener" output="screen"/>
</launch>

就这几行。你把它保存成 talker_listener.launch,然后 roslaunch beginner_tutorials talker_listener.launch,两个节点同时跑起来。output="screen" 表示把日志打印到终端,调试时很有用。

launch 属性作用我常用的值
name节点别名(覆盖节点名)用有意义的别名,如 lidar_driver
pkg功能包名与 CMakeLists 一致
type可执行文件名编译出的二进制名
output日志输出方式screen / log
args命令行参数比如波特率、端口号
⚠️ 注意: launch 文件里的 name 只是别名,真正的节点名由 ros::init 决定。但如果你在 launch 里重映射了名称,会覆盖代码里的名字。我建议统一用 launch 里的 name,方便管理。

3.4 把一切串起来:编译与运行

假设你已经在工作空间 catkin_ws/src/beginner_tutorials 里写好了代码。别忘了在 CMakeLists.txt 里添加:

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

然后 catkin_make。我个人习惯在编译前先 source devel/setup.bash,不然容易找不到包。运行 launch 后,你会看到 talker 每秒发 10 次 "hello SLAM X",listener 同步打印出来。嗯,这就是 ROS 最基础的通信闭环。

catkin_make roslaunch rqt_graph 编译 → 启动 → 可视化验证

最后,你可以用 rqt_graph 看看节点图,确认 talker 和 listener 通过 /chatter 连在一起。这一步我每次都会做,因为肉眼看到箭头连通,心里才踏实。