4. ROS2通信机制:话题通信实战、服务通信实战、自定义消息与服务接口
通信机制,说白了就是机器人系统里各个模块之间怎么“说话”。你想想看,一个机器人身上有激光雷达、摄像头、电机驱动、路径规划……这些节点如果各干各的,那跟一堆散沙没区别。ROS2 提供了两种最核心的通信方式:话题(Topic)和服务(Service)。这一章,咱们就动手把它们跑通,再聊聊怎么定义自己的消息类型。
核心要点:话题是“发布-订阅”模式,适合持续的数据流;服务是“请求-响应”模式,适合一次性的任务调用。两者没有谁优谁劣,看场景选就行。
4.1 话题通信实战:让数据流起来
话题通信是 ROS2 里用得最多的模式。一个节点发布消息,零个或多个节点订阅接收。发布者和订阅者之间不需要知道对方的存在,完全解耦。
我个人习惯把话题想象成一个“广播电台”。发布者就是电台主持人,一直在说话;订阅者就是听众,想听就打开收音机。主持人不知道谁在听,听众也不知道主持人长什么样——但双方都知道“频率”(话题名)和“语言”(消息类型)。
4.1.1 编写发布者节点
咱们先写一个简单的发布者,每隔 0.5 秒发布一次“Hello ROS2”字符串消息。
// publisher.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
class SimplePublisher : public rclcpp::Node
{
public:
SimplePublisher() : Node("simple_publisher")
{
publisher_ = this->create_publisher<std_msgs::msg::String>("chatter", 10);
timer_ = this->create_wall_timer(
std::chrono::milliseconds(500),
std::bind(&SimplePublisher::timer_callback, this));
}
private:
void timer_callback()
{
auto message = std_msgs::msg::String();
message.data = "Hello ROS2";
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_;
};
int main(int argc, char *argv[])
{
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<SimplePublisher>());
rclcpp::shutdown();
return 0;
}
小提示:队列大小参数(这里的 10)很关键。如果订阅者处理速度跟不上发布速度,消息会在队列里排队。队列满了之后,最旧的消息会被丢弃。我在项目中遇到过因为队列设得太小导致丢数据的坑,后来改成 100 才稳定。
4.1.2 编写订阅者节点
订阅者这边更简单。只需要声明一个回调函数,消息来了自动触发。
// subscriber.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
class SimpleSubscriber : public rclcpp::Node
{
public:
SimpleSubscriber() : Node("simple_subscriber")
{
subscription_ = this->create_subscription<std_msgs::msg::String>(
"chatter", 10,
std::bind(&SimpleSubscriber::topic_callback, this, std::placeholders::_1));
}
private:
void topic_callback(const std_msgs::msg::String::SharedPtr msg)
{
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<SimpleSubscriber>());
rclcpp::shutdown();
return 0;
}
编译运行后,你会看到发布者每秒打印两次“发布: Hello ROS2”,订阅者同步打印“收到: Hello ROS2”。这就是最基础的话题通信。
4.2 服务通信实战:一问一答
话题适合持续的数据流,但有些场景需要“你问我答”。比如让机器人抓取一个物体,你需要发送“抓取目标坐标”,然后等待机器人回复“成功/失败”。这时候就该服务上场了。
服务通信是同步的:客户端发送请求后阻塞等待,服务端处理完返回响应。ROS2 的服务基于相同的底层 DDS 实现,但语义上更接近 RPC(远程过程调用)。
4.2.1 编写服务端节点
咱们用 ROS2 内置的 std_srvs/srv/SetBool 服务类型,实现一个简单的开关控制。
// service_server.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_srvs/srv/set_bool.hpp"
class SimpleServiceServer : public rclcpp::Node
{
public:
SimpleServiceServer() : Node("simple_service_server")
{
service_ = this->create_service<std_srvs::srv::SetBool>(
"toggle_switch",
std::bind(&SimpleServiceServer::handle_service, this,
std::placeholders::_1, std::placeholders::_2));
}
private:
void handle_service(
const std::shared_ptr<std_srvs::srv::SetBool::Request> request,
std::shared_ptr<std_srvs::srv::SetBool::Response> response)
{
if (request->data) {
response->success = true;
response->message = "开关已打开";
} else {
response->success = true;
response->message = "开关已关闭";
}
RCLCPP_INFO(this->get_logger(), "收到请求: %s, 回复: %s",
request->data ? "true" : "false",
response->message.c_str());
}
rclcpp::Service<std_srvs::srv::SetBool>::SharedPtr service_;
};
4.2.2 编写客户端节点
客户端这边需要等待服务可用,然后发送请求。
// service_client.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_srvs/srv/set_bool.hpp"
class SimpleServiceClient : public rclcpp::Node
{
public:
SimpleServiceClient() : Node("simple_service_client")
{
client_ = this->create_client<std_srvs::srv::SetBool>("toggle_switch");
}
void send_request(bool value)
{
while (!client_->wait_for_service(std::chrono::seconds(1))) {
RCLCPP_WARN(this->get_logger(), "等待服务上线...");
}
auto request = std::make_shared<std_srvs::srv::SetBool::Request>();
request->data = value;
auto future = client_->async_send_request(request);
// 这里可以用 future.get() 阻塞等待,或者绑定回调
if (rclcpp::spin_until_future_complete(this->get_node_base_interface(), future) ==
rclcpp::FutureReturnCode::SUCCESS)
{
auto response = future.get();
RCLCPP_INFO(this->get_logger(), "服务响应: success=%d, message='%s'",
response->success, response->message.c_str());
}
}
rclcpp::Client<std_srvs::srv::SetBool>::SharedPtr client_;
};
注意:服务通信是同步阻塞的。如果服务端处理时间很长,客户端会一直等。我曾经在项目里因为服务端死锁导致整个机器人卡死,后来加了超时机制才解决。建议生产环境中给服务调用加上超时判断。
4.3 自定义消息与服务接口
内置的消息类型(String、Int32、SetBool 等)只能应付简单场景。实际项目中,你需要传输激光雷达点云、图像、机器人状态等复杂数据。这时候就得自己定义接口了。
ROS2 的接口定义文件有三种:.msg(消息)、.srv(服务)、.action(动作)。咱们先看前两种。
4.3.1 定义自定义消息
假设我们要传输一个“机器人状态”消息,包含位置、速度和电池电量。在功能包下创建 msg/RobotStatus.msg:
# RobotStatus.msg
float32 x
float32 y
float32 theta
float32 linear_velocity
float32 angular_velocity
float32 battery_percentage
然后在 CMakeLists.txt 中添加:
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/RobotStatus.msg"
"srv/GetStatus.srv"
)
编译后,你就可以像使用标准消息一样使用 your_package::msg::RobotStatus 了。
4.3.2 定义自定义服务
服务接口包含请求和响应两部分。创建 srv/GetStatus.srv:
# GetStatus.srv
# 请求:空(不需要输入)
---
# 响应
your_package_msgs/msg/RobotStatus status
bool success
string message
注意 --- 分隔了请求和响应。请求部分可以为空,但响应部分必须至少有一个字段。
4.3.3 接口定义的最佳实践
| 原则 | 说明 | 我踩过的坑 |
|---|---|---|
| 字段类型明确 | 尽量使用 ROS2 原生类型(float32、int32、string),避免嵌套过深 | 有一次我用了嵌套 4 层的消息,编译没问题,但序列化性能惨不忍睹 |
| 版本兼容 | 接口一旦发布,不要删除已有字段,只能新增可选字段 | 团队里有人改了接口字段名,导致其他模块全部编译失败,折腾了一下午 |
| 命名规范 | 包名用下划线,消息/服务名用驼峰 | 这个其实 ROS2 官方有推荐,但很多人不遵守,导致代码可读性差 |
| 默认值 | 给数值类型设置合理的默认值,避免未初始化数据 | 电池电量默认 0.0,结果日志里全是 0%,排查了半天才发现是忘记赋值 |
4.4 本章小结
话题和服务是 ROS2 通信的两大基石。话题适合传感器数据、状态信息等持续流;服务适合触发式、需要确认的操作。自定义接口让你能定义自己的数据结构,把机器人的“语言”规范化。
嗯,这里要注意一点:不要一上来就用自定义接口。先用标准消息把功能跑通,确认逻辑没问题后再抽象出通用接口。我见过太多人一开始就设计复杂的接口体系,结果改需求时全得重来。
一句话总结:话题是“广播”,服务是“打电话”,自定义接口是“制定方言”。三者配合,你的机器人系统才能高效沟通。