4. ROS2话题通信实战:发布者与订阅者模型、自定义消息接口、实现车辆速度话题发布与订阅

好,咱们今天来聊点实在的。话题通信,说白了就是ROS2里最核心的“传话”机制。一个节点负责喊话(发布者),另一个节点负责听(订阅者),中间通过一个叫“话题”的通道来传递消息。我刚开始接触ROS2时,总觉得这玩意儿跟ROS1差不多,但实际用起来,细节上差别还真不小。

4.1 发布者与订阅者模型:谁在说话,谁在听?

先看个最简单的模型。假设你有一辆车,车上有个速度传感器,它每隔0.1秒就往外喊一声“当前速度是30km/h”。另一边,仪表盘上的显示器一直在听,听到后就刷新显示。这就是发布者-订阅者模型。

发布者只管发,不管有没有人听。订阅者只管收,不管是谁发的。这种解耦设计,我在实际项目中深有体会——有一次我们要给自动驾驶系统加一个紧急制动模块,只需要订阅已有的速度话题就行,完全不用改传感器那边的代码。

核心要点:

  • 发布者(Publisher):负责发送消息,可以有多个
  • 订阅者(Subscriber):负责接收消息,也可以有多个
  • 话题(Topic):消息传递的通道,名称唯一
  • 消息(Message):具体的数据结构,比如速度值、时间戳

这里有个坑,我踩过。ROS2的话题通信是异步的,发布者发完消息就继续干别的事,不会等订阅者处理完。如果你的订阅者处理速度跟不上发布者的频率,消息就会堆积。嗯,这个问题后面我们会聊到怎么处理。

4.2 自定义消息接口:别用别人的鞋,不合脚

ROS2自带了很多标准消息类型,比如std_msgs/Float64、geometry_msgs/Twist。但实际项目中,这些往往不够用。我记得有一次做多传感器融合,需要同时传递速度、加速度、时间戳和置信度,标准消息根本装不下。

这时候就需要自定义消息接口。说白了,就是自己定义一个结构体,想装什么就装什么。

4.2.1 创建自定义消息文件

在ROS2工作空间里,消息定义文件放在.msg文件中。比如我们定义一个车辆速度消息:

# VehicleSpeed.msg
float32 speed          # 速度值,单位 m/s
float32 acceleration   # 加速度,单位 m/s²
uint64 timestamp       # 时间戳,微秒
string source          # 数据来源,比如 "wheel_encoder" 或 "gps"
float32 confidence     # 置信度,0.0 ~ 1.0

你看,这样就把所有需要的信息打包在一起了。我习惯把消息定义放在单独的msg包里,方便其他包引用。

4.2.2 配置CMakeLists.txt和package.xml

定义好消息文件后,还需要告诉编译系统怎么处理它。在CMakeLists.txt里加上:

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/VehicleSpeed.msg"
)

在package.xml里加上依赖:

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

这里有个小技巧:消息字段的类型一定要选对。比如时间戳用uint64而不是float64,因为微秒级的时间戳用整数更精确,不会出现浮点误差。

4.3 实现车辆速度话题发布与订阅

好,理论说完了,咱们直接上代码。我会用C++实现一个发布者节点和一个订阅者节点。

4.3.1 发布者节点:模拟速度传感器

// speed_publisher.cpp
#include "rclcpp/rclcpp.hpp"
#include "vehicle_msgs/msg/vehicle_speed.hpp"

class SpeedPublisher : public rclcpp::Node
{
public:
  SpeedPublisher() : Node("speed_publisher")
  {
    publisher_ = this->create_publisher<vehicle_msgs::msg::VehicleSpeed>("vehicle_speed", 10);
    timer_ = this->create_wall_timer(
      std::chrono::milliseconds(100),
      std::bind(&SpeedPublisher::timer_callback, this));
    RCLCPP_INFO(this->get_logger(), "速度发布者已启动");
  }

private:
  void timer_callback()
  {
    auto msg = vehicle_msgs::msg::VehicleSpeed();
    msg.speed = 10.0 + (rand() % 50) / 10.0;  // 模拟速度变化
    msg.acceleration = 0.5;
    msg.timestamp = this->now().nanoseconds() / 1000;  // 微秒
    msg.source = "wheel_encoder";
    msg.confidence = 0.95;

    publisher_->publish(msg);
    RCLCPP_INFO(this->get_logger(), "发布速度: %.2f m/s", msg.speed);
  }

  rclcpp::Publisher<vehicle_msgs::msg::VehicleSpeed>::SharedPtr publisher_;
  rclcpp::TimerBase::SharedPtr timer_;
};

int main(int argc, char *argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<SpeedPublisher>());
  rclcpp::shutdown();
  return 0;
}

这段代码里,我用了定时器每100ms发布一次消息。实际项目中,你可能需要从硬件读取真实数据,但模拟数据用来测试逻辑完全够用。

个人经验:发布者的队列大小(上面代码中的10)很关键。如果订阅者处理慢,消息会积压。我一般设成10~50,太大容易爆内存,太小容易丢消息。

4.3.2 订阅者节点:模拟仪表盘

// speed_subscriber.cpp
#include "rclcpp/rclcpp.hpp"
#include "vehicle_msgs/msg/vehicle_speed.hpp"

class SpeedSubscriber : public rclcpp::Node
{
public:
  SpeedSubscriber() : Node("speed_subscriber")
  {
    subscription_ = this->create_subscription<vehicle_msgs::msg::VehicleSpeed>(
      "vehicle_speed", 10,
      std::bind(&SpeedSubscriber::topic_callback, this, std::placeholders::_1));
    RCLCPP_INFO(this->get_logger(), "速度订阅者已启动");
  }

private:
  void topic_callback(const vehicle_msgs::msg::VehicleSpeed::SharedPtr msg)
  {
    RCLCPP_INFO(this->get_logger(), "收到速度: %.2f m/s, 来源: %s, 置信度: %.2f",
                msg->speed, msg->source.c_str(), msg->confidence);

    // 这里可以做速度校验
    if (msg->confidence < 0.5) {
      RCLCPP_WARN(this->get_logger(), "速度数据置信度偏低,请检查传感器");
    }
  }

  rclcpp::Subscription<vehicle_msgs::msg::VehicleSpeed>::SharedPtr subscription_;
};

int main(int argc, char *argv[])
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<SpeedSubscriber>());
  rclcpp::shutdown();
  return 0;
}

订阅者的回调函数里,我加了一个置信度检查。这是我在实际项目中养成的习惯——永远不要相信传感器数据。曾经有一次,轮速编码器被泥巴糊住了,速度数据直接飘到200km/h,要不是有置信度校验,系统差点就失控了。

4.3.3 运行测试

编译好之后,开两个终端:

# 终端1:启动发布者
ros2 run vehicle_system speed_publisher

# 终端2:启动订阅者
ros2 run vehicle_system speed_subscriber

你会看到订阅者终端不断打印收到的速度信息。如果发布者停了,订阅者会一直等,不会报错——这就是话题通信的优雅之处。

避坑指南:我曾经遇到过一个问题——发布者和订阅者都启动了,但订阅者就是收不到消息。排查了半天,发现是话题名称写错了,一个叫"vehicle_speed",另一个叫"vehicle_speed_data"。ROS2的话题名称是大小写敏感的,而且不能有拼写错误。所以,命名一定要统一,最好在配置文件里定义好。

4.4 本章知识体系

下面这张图展示了本章的核心逻辑,我画成了流程图,方便你理解整个通信链路:

发布者节点 speed_publisher 订阅者节点 speed_subscriber 话题: vehicle_speed 消息内容 (VehicleSpeed.msg) speed: 15.2 m/s acceleration: 0.5 m/s² timestamp: 1234567890 source: "wheel_encoder" confidence: 0.95 发布者 → 话题 → 订阅者:异步、解耦、一对多

从这张图可以看得很清楚:发布者把消息扔到话题通道里,订阅者从通道里取。两者不需要知道对方的存在,这就是ROS2话题通信的精髓。

4.5 总结与避坑

好了,这一章的内容就这些。我最后再唠叨几句:

  • 消息类型要选对:能用整数别用浮点,能定长别变长,性能差很多
  • 话题名称要统一:最好在常量或配置文件中定义,别手写字符串
  • 队列大小要合理:根据你的发布频率和订阅处理速度来调
  • 别忘了加日志:调试时能救命,我吃过这个亏

下一章我们会聊更高级的话题——服务通信,那是另一种“一问一答”的模式。不过今天先到这里,把话题通信练熟了再说。

课后练习:试着修改发布者代码,让速度值按照正弦曲线变化,模拟车辆加速减速的过程。然后在订阅者里计算平均速度,每10秒打印一次。

可可精品资料库