第4章:话题通信实战:编写发布者与订阅者节点
好,咱们今天来点真家伙。
前面几章我们把ROS2的底层概念、环境搭建都捋了一遍。说实话,光看理论容易犯困。我当年学ROS2的时候,最烦的就是看一堆文档,然后不知道代码怎么写。所以这一章,咱们直接上手写代码。
目标很明确:用C++和Python各写一对发布者(Publisher)和订阅者(Subscriber)节点。先传字符串,再传自定义消息。嗯,这是ROS2通信的基石,也是你后续所有机器人开发的基础。
4.1 先聊聊话题通信的本质
话题通信,说白了就是发布-订阅模式。一个节点发消息,一个或多个节点收消息。发的人不管谁在收,收的人也不管谁在发。这种解耦设计,在嵌入式系统里特别实用。
我做过一个多传感器融合的项目,激光雷达、摄像头、IMU各跑各的节点。如果都用服务通信,那代码耦合度会高到让人崩溃。话题通信让每个传感器节点只管往外扔数据,融合节点自己挑着收就行。
为什么会这样设计?因为机器人系统里,数据流往往是单向的、持续的。比如里程计数据,每秒几十次,你不可能每次都去请求一下。话题通信的异步特性,正好解决这个问题。
核心要点:
- 发布者:只管发布,不关心谁在订阅
- 订阅者:只管接收,不关心谁在发布
- 话题名:双方通过同一个字符串名称匹配
- 消息类型:双方必须使用完全相同的消息定义
4.2 准备工作:创建功能包
动手之前,先把工作空间搭好。我个人习惯用colcon构建,干净利落。
# 创建工作空间
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
# 创建功能包,同时支持C++和Python
ros2 pkg create --build-type ament_cmake chapter4_talker_listener \
--dependencies rclcpp std_msgs
# 再创建一个纯Python包,方便对比
ros2 pkg create --build-type ament_python chapter4_py_talker_listener \
--dependencies rclpy std_msgs
注意这里我加了--dependencies参数。很多新手会忘记加,然后编译报错才回头补。我建议你一开始就把依赖写清楚,省得后面折腾。
4.3 C++实现:字符串发布者
先来C++版本。为什么先写C++?因为ROS2底层就是C++写的,性能更好。在嵌入式场景下,我通常用C++写核心节点。
在chapter4_talker_listener/src目录下创建talker.cpp:
#include <chrono>
#include <memory>
#include <string>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
using namespace std::chrono_literals;
class TalkerNode : public rclcpp::Node
{
public:
TalkerNode() : Node("talker_node")
{
publisher_ = this->create_publisher<std_msgs::msg::String>("chatter", 10);
timer_ = this->create_wall_timer(
500ms, std::bind(&TalkerNode::timer_callback, this));
RCLCPP_INFO(this->get_logger(), "发布者节点已启动");
}
private:
void timer_callback()
{
auto message = std_msgs::msg::String();
message.data = "Hello, ROS2! 第 " + std::to_string(count_++) + " 条消息";
publisher_->publish(message);
RCLCPP_INFO(this->get_logger(), "发布: '%s'", message.data.c_str());
}
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
rclcpp::TimerBase::SharedPtr timer_;
size_t count_ = 0;
};
int main(int argc, char *argv[])
{
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<TalkerNode>());
rclcpp::shutdown();
return 0;
}
这段代码里,我用了create_wall_timer定时触发。为什么不用死循环加sleep?因为ROS2的spin机制会阻塞主线程,用定时器更优雅。我曾经见过有人用while(1)加sleep,结果节点无法正常退出,Ctrl+C都杀不死。嗯,定时器才是正道。
4.4 C++实现:字符串订阅者
同样在src目录下创建listener.cpp:
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
class ListenerNode : public rclcpp::Node
{
public:
ListenerNode() : Node("listener_node")
{
subscription_ = this->create_subscription<std_msgs::msg::String>(
"chatter", 10, std::bind(&ListenerNode::topic_callback, this, std::placeholders::_1));
RCLCPP_INFO(this->get_logger(), "订阅者节点已启动");
}
private:
void topic_callback(const std_msgs::msg::String &msg) const
{
RCLCPP_INFO(this->get_logger(), "收到: '%s'", msg.data.c_str());
}
rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};
int main(int argc, char *argv[])
{
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<ListenerNode>());
rclcpp::shutdown();
return 0;
}
注意看回调函数的参数类型:const std_msgs::msg::String &msg。这里用引用传递,避免拷贝。在嵌入式设备上,消息体可能很大(比如图像),用引用能省不少内存。
小技巧:回调函数里不要做耗时操作。如果你需要处理数据,请把数据放到队列里,另起线程处理。否则会阻塞消息接收,导致丢包。我踩过这个坑,当时IMU数据一直丢,查了两天才发现是回调里做了FFT计算。
4.5 配置CMakeLists.txt
别忘了把这两个源文件加到编译列表里。打开CMakeLists.txt,在末尾添加:
add_executable(talker src/talker.cpp)
ament_target_dependencies(talker rclcpp std_msgs)
add_executable(listener src/listener.cpp)
ament_target_dependencies(listener rclcpp std_msgs)
install(TARGETS
talker
listener
DESTINATION lib/${PROJECT_NAME}
)
这里有个细节:install部分很多人会漏掉。不写的话,编译出来的可执行文件不会安装到install目录,运行时会找不到。我刚开始学的时候,每次都要手动拷贝,后来才发现是install没写。
4.6 Python实现:发布者与订阅者
Python版本更适合快速原型验证。在嵌入式开发中,我经常先用Python写个demo,确认逻辑没问题,再转成C++。
在chapter4_py_talker_listener/chapter4_py_talker_listener目录下创建talker.py:
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class TalkerNode(Node):
def __init__(self):
super().__init__('talker_node')
self.publisher_ = self.create_publisher(String, 'chatter', 10)
timer_period = 0.5 # 500ms
self.timer = self.create_timer(timer_period, self.timer_callback)
self.count = 0
self.get_logger().info('发布者节点已启动')
def timer_callback(self):
msg = String()
msg.data = f'Hello, ROS2! 第 {self.count} 条消息'
self.publisher_.publish(msg)
self.get_logger().info(f'发布: "{msg.data}"')
self.count += 1
def main(args=None):
rclpy.init(args=args)
node = TalkerNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
再创建listener.py:
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class ListenerNode(Node):
def __init__(self):
super().__init__('listener_node')
self.subscription = self.create_subscription(
String,
'chatter',
self.listener_callback,
10)
self.subscription # 防止被垃圾回收
self.get_logger().info('订阅者节点已启动')
def listener_callback(self, msg):
self.get_logger().info(f'收到: "{msg.data}"')
def main(args=None):
rclpy.init(args=args)
node = ListenerNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Python代码看起来更简洁。但注意那个self.subscription赋值,很多人会问为什么还要再写一遍?因为Python的垃圾回收机制,如果不用变量保存,订阅对象会被回收,导致收不到消息。我刚开始写Python ROS2时就被这个坑过,调试了半天才发现是引用丢了。
4.7 自定义消息:定义.msg文件
字符串消息太简单了,实际项目中我们更需要自定义消息。比如传一个包含温度、湿度、时间戳的传感器数据。
在chapter4_talker_listener包下创建msg目录,新建SensorData.msg:
# 传感器数据结构
float32 temperature # 温度,单位:摄氏度
float32 humidity # 湿度,单位:百分比
int64 timestamp # 时间戳,单位:微秒
string device_id # 设备ID
然后修改CMakeLists.txt,添加消息生成支持:
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/SensorData.msg"
)
ament_export_dependencies(rosidl_default_runtime)
别忘了在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>
4.8 使用自定义消息的发布者
修改talker.cpp,使用自定义消息:
#include "chapter4_talker_listener/msg/sensor_data.hpp"
// 在回调函数中
auto message = chapter4_talker_listener::msg::SensorData();
message.temperature = 25.5 + (rand() % 100) / 10.0;
message.humidity = 60.0 + (rand() % 100) / 10.0;
message.timestamp = this->now().nanoseconds() / 1000;
message.device_id = "sensor_01";
publisher_->publish(message);
注意:自定义消息的头文件路径是包名/msg/消息名.hpp。很多人会写成msg/sensor_data.hpp,编译会报找不到头文件。这是ROS2的命名空间规则,习惯了就好。
4.9 编译与运行测试
回到工作空间根目录:
cd ~/ros2_ws
colcon build --packages-select chapter4_talker_listener chapter4_py_talker_listener
source install/setup.bash
开三个终端,分别运行:
# 终端1:C++发布者
ros2 run chapter4_talker_listener talker
# 终端2:C++订阅者
ros2 run chapter4_talker_listener listener
# 终端3:Python订阅者(可以同时订阅)
ros2 run chapter4_py_talker_listener listener
你会看到C++和Python的订阅者都能收到消息。这就是ROS2跨语言通信的魅力。我当年第一次看到C++发的消息被Python节点收到时,觉得这玩意儿真神奇。其实底层用的是DDS,语言只是封装。
4.10 避坑指南与调试技巧
写到这里,我总结几个实战中容易踩的坑:
- 话题名拼写错误:发布者和订阅者的话题名必须完全一致,大小写敏感。我曾经把
chatter写成chater,找了半小时bug。 - QoS设置不匹配:默认的QoS配置是
10(队列深度)。如果发布者用10,订阅者用1,可能会丢消息。建议双方保持一致。 - 忘记source setup.bash:每次新开终端都要source,或者加到
.bashrc里。我习惯用echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc一劳永逸。 - 自定义消息未编译:修改了
.msg文件后,必须重新编译整个包。只编译依赖包是不够的。
调试利器:用ros2 topic list查看当前所有话题,用ros2 topic echo /chatter直接看消息内容。这两个命令能帮你快速定位通信问题。
好了,这一章的内容就到这里。你想想看,从字符串到自定义消息,从C++到Python,你已经掌握了ROS2话题通信的核心。下一章我们会聊服务通信,那是另一种模式——请求-响应。到时候你会发现,话题和服务各有各的用武之地。