调试一台差速轮机器人时,里程计数据和IMU数据总是对不上——里程计显示走了1米,IMU却认为只移动了0.8米。翻看日志,两个话题的时间戳差了整整300毫秒。不是传感器坏了,而是两个数据处理节点各自为政,没有同步机制。你需要的不是更好的传感器,而是一个让节点之间有序沟通的框架。
上面这张图暴露了问题根源:里程计节点和IMU节点是独立进程,各自按照自己的节奏发布数据。如果没有统一的同步机制,融合节点拿到两个话题的数据时,时间戳可能已经错位。ROS2的解决方案是把每个功能模块封装成一个节点(Node),通过中间件让节点之间有序通信。
节点在ROS2里就是一个可执行进程,完成一项特定的工作——比如读取激光雷达数据、控制电机、发布里程计等。每个节点拥有唯一的名称,在系统里是全局可识别的。
| 通信方式 | 模型 | 方向性 | 同步/异步 | 典型场景 |
|---|---|---|---|---|
| 话题(Topic) | 发布/订阅 | 单向流 | 异步 | 传感器数据流、状态广播 |
| 服务(Service) | 请求/响应 | 双向 | 同步 | 查询参数、触发动作 |
| 动作(Action) | 目标/结果+反馈 | 双向+持续 | 混合 | 导航、机械臂运动 |
ROS2提供了三种通信原语,分别解决不同场景的需求。上面这张表是你在项目里做技术选型时的速查手册。本章重点讲话题,因为它是使用频率最高、最基础的一种——机器人里90%的数据交换都靠话题完成。
话题通信的核心是发布/订阅(Pub/Sub)模型:
/odom、/imu/data、/cmd_vel。这种解耦设计的最大好处是:新增一个订阅者不需要修改发布者的代码。你在调试时临时加一个 ros2 topic echo /odom 命令,就能看到数据流,而发布者节点根本感知不到你的存在。
std_msgs/msg/String 只包含一个字符串字段,而 sensor_msgs/msg/LaserScan 包含角度范围、距离数组等复杂结构。发布者和订阅者必须使用相同的消息类型才能通信。
这里有一个容易被忽略的细节:话题名 + 消息类型必须完全匹配,订阅者才能收到数据。很多教程不会告诉你的是,如果消息类型不匹配,ROS2不会报错,只会静默地不传输数据。我曾经排查了一整个下午,才发现订阅者用的消息类型是 std_msgs::msg::String,而发布者发的是 std_msgs::msg::UInt8——两个类型都对,但就是收不到数据。
我们用C++写一个最简单的发布者节点,每隔1秒发布一条 "Hello ROS2" 消息。这里使用ROS2 Humble版本,rclcpp库。
// talker_node.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
int main(int argc, char **argv) {
rclcpp::init(argc, argv); // 1. 初始化ROS2
auto node = std::make_shared<rclcpp::Node>("talker"); // 2. 创建节点
auto publisher = node->create_publisher<std_msgs::msg::String>("chatter", 10);
// 3. 创建发布者
rclcpp::spin(node); // 4. 保持节点运行
rclcpp::shutdown();
return 0;
}
对应的订阅者节点:
// listener_node.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
void topic_callback(const std_msgs::msg::String::SharedPtr msg) {
RCLCPP_INFO(rclcpp::get_logger("listener"), "收到: '%s'", msg->data.c_str());
}
int main(int argc, char **argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("listener");
auto subscription = node->create_subscription<std_msgs::msg::String>(
"chatter", 10, topic_callback);
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}
| 代码要点 | 说明 |
|---|---|
rclcpp::init() | 初始化ROS2客户端库,必须在所有API调用之前执行 |
create_publisher<T>(topic, qos) | 创建发布者,T是消息类型,topic是话题名,qos是队列深度 |
create_subscription<T>(topic, qos, callback) | 创建订阅者,注册回调函数,有消息到达时自动调用 |
rclcpp::spin() | 阻塞当前线程,不断处理回调,相当于让节点"活"起来 |
上面这段代码里,spin()是最容易被误解的一行。很多入门教程说它"让节点持续运行",但实际它做的事情是:不断检查是否有新消息到达、是否有定时器触发、是否有服务请求,然后调用对应的回调函数。如果你在 spin() 之后写了代码,那些代码永远不会被执行——因为 spin() 不返回。
把两个节点文件放到ROS2包的 src/ 目录下,修改 CMakeLists.txt 添加编译规则,然后:
# 编译
colcon build --packages-select 你的包名
# 运行发布者
ros2 run 你的包名 talker
# 新终端运行订阅者
ros2 run 你的包名 listener
如果一切正常,订阅者终端会每秒打印一条 "收到: 'Hello ROS2'"。
ros2 topic list 检查话题是否存在——看到了 /chatter,说明发布者在工作。ros2 topic echo /chatter 直接查看话题数据——能看到数据,说明网络没问题。std_msgs::msg::UInt8,而发布者用的是 std_msgs::msg::String。类型不匹配,导致订阅者收不到数据。ros2 topic list 里有话题但 ros2 topic echo 没输出,大概率是权限问题或DDS配置不对——检查防火墙和网络接口。
talker),后启动的节点会强制关闭前一个节点,导致原节点消失。这在launch文件中特别容易踩坑——不小心写了两个同名节点,结果只有一个在工作。
还有一点:话题名称的层级结构。ROS2的话题名支持分层命名,比如 /sensor/imu/data、/sensor/lidar/scan。这种命名方式不仅是组织代码的习惯,还能利用命名空间实现节点分组和隔离——这在多机器人系统中非常有用。
ROS_DOMAIN_ID,确保所有节点使用相同的domain ID。实际项目里,我建议显式设置ROS_DOMAIN_ID,而不是用默认值0——否则同一网络里不同项目的ROS2节点会互相干扰。
很多教程不会告诉你的是,ROS_DOMAIN_ID 的取值范围是0到101(包括101),超出这个范围DDS会拒绝启动。我曾经设置了 ROS_DOMAIN_ID=200,节点启动时直接报 Domain ID out of range 错误,排查了半天才找到原因。
chatter 话题上发布 std_msgs::msg::Int32 类型的数据,数值递增。ros2 topic info /chatter 查看话题详情,确认类型是否正确。判断题:
rclcpp::spin() 后,节点会持续处理回调,不会自动退出。 (对 / 错)ROS_DOMAIN_ID 可以隔离不同项目的ROS2节点,避免干扰。