先说个真实感受:很多刚接触ROS2的朋友,上来就把话题通信玩得飞起,但一碰到“客户端发个请求、服务端回个结果”这种一问一答的需求,就开始发懵。我在帮几个项目做技术评审时,见过不少人用话题硬模拟请求响应,绕来绕去,最后自己都被绕晕了。ROS2里明明有现成的服务通信机制,就是没被用起来。这篇就用C++完整过一遍服务通信的写法,把原理、代码、避坑全讲透。
1. 为什么需要服务通信:从一次“查询电量”需求说起
先说一个我实际遇到的场景。当时在做一台室内巡检机器人,上位机需要随时查询底盘当前电量,频率不高,但每次查询都要拿到一个实时的、确定性的数值。当时团队里有位同事提议:用话题发一个“查询请求”,底盘收到后再发一个“电量响应”话题回来。听完我就摇头——这方案不是不能跑,但至少有三个问题。
第一,话题是单向广播,没有“回答”的概念。发布者把消息丢到总线上就完事了,它根本不知道有没有人收到、收到的人是否处理成功。你要是用两个话题模拟请求和响应,那请求方得额外维护“我发出去了”“该等哪个响应”“响应是不是我这笔请求的结果”这一堆状态,代码复杂不说,出错率还高。
第二,话题没有超时和失败反馈。服务调用可以设置超时,服务端没起来或者处理超时,客户端能明确感知到。用话题模拟,只能靠自己在代码里倒计时,然后猜测“可能没收到吧”,这体验太糟糕了。
第三,话题的数据是“最新值覆盖旧值”。如果底盘电量的变化频率比查询频率还低,你发10次查询请求,话题上可能只来了1次响应,剩下的9次请求全部落空。这根本不是一个适合“查询-响应”模型的数据流。
而ROS2的服务通信(Service)天生就是为“一问一答”设计的。它采用的是客户端-服务器模型,客户端发一个请求(Request),服务端处理完返回一个响应(Response)。你调用一个服务,就像打个电话,拨出去有人接,你问一句,对方答一句,整个过程是同步的、确定的。
放在ROS2的整体架构里看,话题和服务是配合使用的,话题负责高频、持续的数据流转,比如传感器数据、状态发布;服务负责低频、确定性的操作调用,比如“开启导航”“查询参数”“保存地图”。你要是把服务用成话题,或者把话题用成服务,都会很别扭。判断标准很简单:这个操作是一次性的、需要拿到结果、失败要能感知,就选服务。
顺带说一句,ROS2里还有一个动作(Action)机制,适用于耗时长、可以被取消、需要进度反馈的任务。服务和动作的区别以后有机会再展开,这篇先把服务通信吃透。
2. 环境准备:先把“地基”打牢
写代码之前,环境得先捋顺。我这里用的发行版是Humble,对应Ubuntu 22.04。如果你用的是新版Ubuntu 24.04,对应的是Jazzy,安装方式和API基本一致,个别包名可能略有区别,后面遇到再说。
2.1 安装ROS2并创建功能包
ROS2的安装网上教程很多,这里不打算重复太多。核心就三步:设置软件源、安装核心包、初始化环境。装完以后,ros2命令行工具能正常输出版本信息,就说明基础环境OK。
然后我们需要创建一个工作空间。我习惯把代码放在~/dev_ws下面,这个路径可以随意,但建议不要用中文和空格。
mkdir -p ~/dev_ws/src cd ~/dev_ws/src ros2 pkg create service_demo --build-type ament_cmake --dependencies rclcpp example_interfaces这个命令会生成一个名为service_demo的功能包,类型是ament_cmake,也就是C++包,同时声明依赖rclcpp(ROS2的C++客户端库)和example_interfaces(包含AddTwoInts等示例接口)。后面所有源码都放在src/目录下。
2.2 验证example_interfaces是否可用
example_interfaces是ROS2官方提供的一组示例消息和服务接口,其中就包括AddTwoInts.srv,非常适合用来做第一次服务通信的练习。有些精简安装可能没带这个包,先确认一下:
ros2 interface show example_interfaces/srv/AddTwoInts正常会输出三行:
int64 a int64 b --- int64 sum第一段是请求(Request),包含两个int64的加数;第二段是响应(Response),包含一个int64的和。这个接口简单得不能再简单,但足够把服务通信的完整链路跑通。
如果上面命令报错找不到包,先安装:
sudo apt install ros-humble-example-interfacesJazzy的替换成ros-jazzy-example-interfaces,自己对应一下。这一步经常有人忽略,结果编译时找不到头文件,先确认接口包齐全能省很多折腾。
3. 服务端实现:逐行拆解一个加法服务
服务端是服务通信的“提供方”,它负责注册服务、监听请求、处理请求、返回响应。下面先写一个最精简但完整的服务端节点。
3.1 服务端完整代码
在service_demo/src/目录下新建add_two_ints_server.cpp,内容如下:
#include "rclcpp/rclcpp.hpp" #include "example_interfaces/srv/add_two_ints.hpp" #include <memory> // 服务回调函数,处理客户端发来的请求 void handle_add_two_ints( const std::shared_ptr<example_interfaces::srv::AddTwoInts::Request> request, std::shared_ptr<example_interfaces::srv::AddTwoInts::Response> response) { response->sum = request->a + request->b; RCLCPP_INFO( rclcpp::get_logger("rclcpp"), "收到请求: a = %ld, b = %ld", request->a, request->b); RCLCPP_INFO( rclcpp::get_logger("rclcpp"), "返回响应: sum = %ld", response->sum); } int main(int argc, char ** argv) { rclcpp::init(argc, argv); // 创建节点,名字叫 add_two_ints_server auto node = std::make_shared<rclcpp::Node>("add_two_ints_server"); // 创建服务,服务名 add_two_ints,类型 AddTwoInts auto service = node->create_service<example_interfaces::srv::AddTwoInts>( "add_two_ints", &handle_add_two_ints); RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "服务已启动,等待请求..."); // 保持节点运行,处理回调 rclcpp::spin(node); rclcpp::shutdown(); return 0; }3.2 代码里藏着哪些关键点
这段代码虽然短,但里面有几个初次接触容易忽略的细节。
回调函数的参数类型不能写错。请求参数是一个const std::shared_ptr<...Request>,响应参数是一个std::shared_ptr<...Response>。注意请求是const的,响应不是。这不是随便定的,而是ROS2接口生成的智能指针类型,你在回调里只能读请求、写响应。有人图省事把请求也写成非const,编译会直接报错。
响应参数虽然是shared_ptr,不需要手动new。服务框架在调用回调函数之前已经帮你把响应对象创建好了,你要做的只是往里面填字段。这一点和话题回调的消息类似,都是框架管理生命周期,用完即自动释放。
rclcpp::spin(node)这行很关键。它的作用是让节点进入阻塞循环,监听服务请求并触发回调。如果你的程序跑到这里就退出了,那服务也就跟着没了。实际项目中,如果线程紧张,也可以用rclcpp::spin配合多线程执行器,但在入门阶段,单线程spin就够用了。
还有一个容易踩的坑:多个服务回调的处理。这里的spin是单线程模型,意味着同一时刻只能处理一个请求。如果请求的耗时较长(比如要计算一个复杂的规划路径),后到的请求就排队等待。对于需要并发处理的场景,后面我会讲到如何使用MultiThreadedExecutor。
4. 客户端实现:调用服务的完整流程
服务端写好了,客户端就是那个“打电话的人”。客户端需要做四件事:创建节点、创建客户端对象、等待服务上线、发送请求并等待响应。
4.1 客户端完整代码
在src/下新建add_two_ints_client.cpp:
#include "rclcpp/rclcpp.hpp" #include "example_interfaces/srv/add_two_ints.hpp" #include <chrono> #include <cstdlib> #include <memory> using namespace std::chrono_literals; int main(int argc, char ** argv) { rclcpp::init(argc, argv); // 启动时通过命令行传入两个参数作为加数 if (argc != 3) { RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "用法: add_two_ints_client X Y"); return 1; } auto node = std::make_shared<rclcpp::Node>("add_two_ints_client"); auto client = node->create_client<example_interfaces::srv::AddTwoInts>("add_two_ints"); auto request = std::make_shared<example_interfaces::srv::AddTwoInts::Request>(); request->a = atoll(argv[1]); request->b = atoll(argv[2]); // 等待服务上线,最多等若干次 while (!client->wait_for_service(1s)) { if (!rclcpp::ok()) { RCLCPP_ERROR(rclcpp::get_logger("rclcpp"), "等待服务被中断,退出"); return 0; } RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "服务未上线,继续等待..."); } // 异步发送请求 auto future = client->async_send_request(request); // 阻塞等待响应,直到有结果或超时 if (rclcpp::spin_until_future_complete(node, future) == rclcpp::FutureReturnCode::SUCCESS) { RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "服务调用结果: %ld", future.get()->sum); } else { RCLCPP_ERROR(rclcpp::get_logger("rclcpp"), "服务调用失败"); } rclcpp::shutdown(); return 0; }4.2 为什么需要wait_for_service和spin_until_future_complete
第一次写客户端的人最容易犯的错误,就是服务端还没启动就急着发请求,结果请求直接丢到黑洞里。wait_for_service的意义就在这里:它先探测服务是否已经注册到了ROS2网络中,如果没上线,就每隔1秒重试一次。
这里还有个细节值得注意:为什么等待循环里要判断rclcpp::ok()?因为如果用户在等待期间按了Ctrl+C,ROS2上下文会被关闭,wait_for_service会一直返回false,如果没有这个判断,程序就永远不会退出。加了这个判断,用户中断时就能正常退出。这是个很实用的小技巧,处理长时间阻塞时养成这个习惯。
发送请求有同步和异步两种方式。async_send_request是异步发送,它会立刻返回一个std::future对象,然后你可以用spin_until_future_complete来阻塞等待。之所以不用“直接send然后立即get”,是因为ROS2的回调必须被spin处理。如果你在客户端里不调用任何spin相关的函数,响应回调永远不会被处理,future永远无法完成。这是一个暗坑,很多从ROS1转过来的朋友会踩——ROS1里call是同步的,ROS2里如果只调async_send_request之后while死等future.get(),等于让节点停止处理网络事件,从而死锁。
spin_until_future_complete这个名字很直白:它一边spin处理ROS2内部事件,一边等future完成,两者不冲突。返回值有三种:SUCCESS、TIMEOUT、INTERRUPTED。入门阶段只要知道SUCCESS就说明请求被服务端处理并返回了。
4.3 同步与异步的选择
上面用async_send_request加spin_until_future_complete,其实是“异步API,同步等待”的用法。真正的异步用法是不调用spin_until_future_complete,而是在future上挂回调、或者在自己的业务循环里轮询future.wait_for(0),这样客户端可以同时干别的事。
如果你确定整个程序就是等这个服务调用的结果,那当前这个写法最干净。如果客户端还要同时处理话题数据、要响应其他服务,那就得把spin_until_future_complete放到专门的线程里,别阻塞主循环。我一般习惯把服务调用封装成一个带超时的函数,这样上层业务不用关心底层是同步还是异步。
5. 编译配置:CMakeLists和package.xml缺一不可
代码写完后,还有最容易被新手忽略的一步——配置文件。如果CMakeLists.txt没写对,代码写得再对也编译不过。
5.1 更新CMakeLists.txt
打开service_demo/CMakeLists.txt,在find_package部分确认有这几行:
find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(example_interfaces REQUIRED)然后添加两个可执行目标的编译规则:
add_executable(add_two_ints_server src/add_two_ints_server.cpp) ament_target_dependencies(add_two_ints_server rclcpp example_interfaces) add_executable(add_two_ints_client src/add_two_ints_client.cpp) ament_target_dependencies(add_two_ints_client rclcpp example_interfaces) install(TARGETS add_two_ints_server add_two_ints_client DESTINATION lib/${PROJECT_NAME} )很多人会漏掉ament_target_dependencies这行,结果编译时死活找不到rclcpp的头文件,或者链接时一堆未定义的符号。这行代码的作用是把ROS2包的include目录和链接库传给编译器,是ROS2 C++包和普通CMake项目的核心区别。
5.2 声明package.xml依赖
再打开package.xml,确认<depend>标签里有这两行:
<depend>rclcpp</depend> <depend>example_interfaces</depend>package.xml和CMakeLists.txt的依赖必须一致,否则在别的机器上构建时会报缺少依赖。在ROS2里,rosdep工具就是靠读package.xml来自动安装依赖包的,如果你漏写了一项,别人clone你的代码后可能编译失败。
配置完成后,回到工作空间根目录编译:
cd ~/dev_ws colcon build --packages-select service_demo source install/setup.bash编译没有报错,两个可执行文件就算生成好了。
6. 运行与调试:从启动节点到观察日志
编译通过只是第一步,真正的乐趣在运行和调试。打开两个终端,一个跑服务端,一个跑客户端。
6.1 完整运行流程
终端1:
source ~/dev_ws/install/setup.bash ros2 run service_demo add_two_ints_server你会看到:
[INFO] [rclcpp]: 服务已启动,等待请求...终端2:
source ~/dev_ws/install/setup.bash ros2 run service_demo add_two_ints_client 2 3客户端输出:
[INFO] [rclcpp]: 服务调用结果: 5同时服务端终端也会打印收到请求和返回响应的日志。看到这里,你的第一次服务通信就算完整跑通了。
6.2 ros2命令行工具排查问题
实际开发中,服务通信不像例子这么顺利。我整理了几个高频问题以及对应的排查命令。
问题1:客户端一直打印“服务未上线,继续等待”。
先确认服务端确实启动了,再到另一个终端执行:
ros2 service list如果列表里没有/add_two_ints,说明服务没注册成功。检查服务端节点的create_service有没有执行到,或者节点是不是被spin之前的代码卡住了。还有一个可能:DDS发现机制的问题,两台机器之间通信时尤其常见,先绕过单机。
问题2:客户端报“Failed to call service add_two_ints”。
用这个命令查看服务类型是否匹配:
ros2 service type /add_two_ints如果输出是example_interfaces/srv/AddTwoInts,说明类型没问题。如果输出unknown,优先查客户端节点的create_client类型是否和服务端一致。
问题3:服务名不一致。
服务名必须完全一致才能通信,包括命名空间层级。有一个可以辅助观察的命令:
ros2 service find example_interfaces/srv/AddTwoInts这个命令会列出“当前网络里所有类型为AddTwoInts的服务”。如果客户端用的名字和服务端不一致,你在这个输出里能看到差异,比如一个是/add_two_ints,另一个是/my_add_two_ints。多写个斜杠、多一层命名空间,都会导致匹配不上。
6.3 用turtlesim的/spawn服务做练习
如果你想把服务通信的应用场景再扩展一下,ROS2自带的turtlesim就是一个绝佳的观察对象。启动小乌龟后,执行:
ros2 service list你会看到/spawn、/kill、/clear等一堆服务。尝试调用一下:
ros2 service call /spawn turtlesim/srv/Spawn "{x: 5, y: 5, theta: 0.0, name: 'turtle2'}"新乌龟就会出现在指定坐标。可以把turtlesim想象成服务端,你的命令就是客户端,这个例子直观展示了“服务调用会产生一个明确行为并返回结果”的特性。自己去敲一敲,比光看文章有用得多。
7. 自定义服务接口:从AddTwoInts到业务接口
用example_interfaces能把原理搞懂,但真实项目里几乎没有直接复用别人接口的。你需要定义自己的.srv文件,比如“获取机器人位姿”“保存地图”“执行路径规划”。
7.1 定义srv文件
在service_demo/下创建srv/目录,添加一个RobotPose.srv:
string robot_name --- float32 x float32 y float32 theta四个字段的含义:请求部分只有一个robot_name,告诉服务端要查谁;响应部分是机器人的坐标和朝向。.srv文件格式很简单,---上面是请求,下面是响应。
7.2 在CMakeLists.txt中生成接口
自定义接口需要让ROS2的消息生成工具帮忙生成C++代码。在CMakeLists.txt里添加:
rosidl_generate_interfaces(${PROJECT_NAME} "srv/RobotPose.srv" )注意,rosidl_generate_interfaces必须在find_package之后调用,并且之后的可执行目标要通过ament_target_dependencies依赖${PROJECT_NAME}。
还有个小细节:如果你的包名不是service_demo而是别的,比如robot_msgs,通常建议把自定义接口放在单独的接口包里,让多个功能包共享。在实际机器人项目里,常见做法是建一个xxx_msgs包专门放接口,业务代码引用它。这样可以避免出现循环依赖。
7.3 头文件包含路径的变化
接口生成之后,代码里的头文件就不是example_interfaces/srv/robot_pose.hpp了,而是service_demo/srv/robot_pose.hpp。类型名也对应变成service_demo::srv::RobotPose。引用方式:
#include "service_demo/srv/robot_pose.hpp" auto service = node->create_service<service_demo::srv::RobotPose>( "get_robot_pose", &handle_get_robot_pose);这个头文件是构建过程中自动生成的,第一次编译时会在install和build目录里出现。所以代码写得再谨慎,第一次编译时可能出现“找不到头文件”的报错。别慌,先确认rosidl_generate_interfaces有没有写对、有没有在ament_target_dependencies里加上自定义接口包,基本都是配置问题。
8. 进阶:多线程执行器与超时控制
入门阶段用单线程spin就够了,但到了实际项目中,一个机器人节点往往同时订阅话题、提供服务、调用其他节点,你需要考虑并发和超时的问题。
8.1 MultiThreadedExecutor的作用
默认的rclcpp::spin(node)是单线程的,服务回调一个接一个执行。如果某个服务回调需要计算3秒,另一个服务请求就只能排队。对于交互性强的系统,这往往不可接受。
改用多线程执行器很简单:
auto executor = std::make_shared<rclcpp::executors::MultiThreadedExecutor>(); executor->add_node(node); executor->spin();或者:
rclcpp::spin(node, rclcpp::executors::MultiThreadedExecutor::make_unique());这里最好显式指定线程池大小:
rclcpp::executors::MultiThreadedExecutor executor( rclcpp::executor::ExecutorArgs(), 4);意思是4个线程并发处理回调。但是要注意线程安全问题。多线程执行器会让多个回调同时进入你的代码,如果多个回调访问同一个成员变量,需要加锁。这个坑我在项目里踩过:两个服务回调同时修改一份共享地图,数据直接错乱。加个std::mutex就解决了,代价是略微牺牲并发性。
8.2 给服务调用加超时
spin_until_future_complete的默认行为是无限等待。如果服务端挂掉了,客户端会一直卡在那里,实际产品里这是不可接受的。
你可以给spin_until_future_complete传入超时时间:
auto status = rclcpp::spin_until_future_complete( node, future, 3s); if (status == rclcpp::FutureReturnCode::SUCCESS) { // 正常拿到响应 } else if (status == rclcpp::FutureReturnCode::TIMEOUT) { // 超时处理 RCLCPP_ERROR(rclcpp::get_logger("rclcpp"), "服务调用超时"); } else { // 被中断或异常 }这里有个逻辑要理清:wait_for_service负责服务是否存在,spin_until_future_complete的超时只管“请求发出去后多久没拿到响应”。两者是两个层面的超时,建议都做。
还有一个进阶方案:给服务端回调加超时阈值,如果处理时间超过阈值就提前返回失败响应。虽然ROS2服务端本身没有内置“回调超时中断”机制,但可以在回调里自己用std::async+future.wait_for(ms)来实现,把超过阈值的请求直接标记为失败。这个手段在控制类服务里尤其有用,避免某个服务长时间占用执行器线程,拖垮整个节点。
8.3 服务质量策略对服务影响
ROS2的服务通信也受QoS策略影响。服务端和客户端之间,请求和响应的QoS必须兼容。遇到莫名其妙的“服务调用超时”,如果网络没问题,可以考虑是不是QoS不匹配导致的。默认情况下不用改,但如果你的服务端设置了自定义的QoS(比如可靠性从RELIABLE改成BEST_EFFORT),客户端也需要对应设置:
rclcpp::QoS qos(rclcpp::QoSInitialization::from_rmw(rclcpp::KeepLast(10))); qos.reliability(RMW_QOS_POLICY_RELIABILITY_BEST_EFFORT); auto client = node->create_client<service_demo::srv::RobotPose>( "get_robot_pose", qos);关于QoS的细节很深,这里只提醒一句:服务通信的QoS策略不像话题那样“宽松”,请求和响应两端的策略必须匹配,否则通信会静默失败。遇到服务调用超时又查不出原因时,优先看看两端的QoS配置。
9. 几个实战中总结的教训
说了这么多,最后分享几条多年调服务通信总结出来的零散经验,都是踩坑踩出来的,希望能帮大家少走弯路。
不要用服务传大数据。服务通信本质上是实时的请求-响应,不适合承载大体积数据。比如有人想把整张地图或者完整点云塞进服务响应里,结果就是网络开销巨大、双方被阻塞很久。大数据应该用话题传输,服务只负责下发“开始传输”的指令。记住这个边界,能省很多麻烦。
服务名一定要有清晰的命名空间规划。多个节点、多台机器人、多个模块共处一个ROS2网络时,如果服务名都是/get_pose、/save_map这种裸名字,到时候根本分不清是谁的服务。建议用模块名做前缀,比如/robot_arm/get_pose、/navigation/save_map。ROS2的节点命名空间机制就是为了这个,别偷懒。
日志里把请求和响应的内容打出来。服务通信的调试比话题要直观得多,因为问答是成对的。但还是建议在服务端回调里把请求参数打全,在客户端收到响应后把结果打全。这里有两套日志,出现问题时对照两个终端的时间戳,很快能定位是服务端没收到请求、还是响应没有回到客户端。
发布者-订阅者模式的代码结构,直接迁移到服务端时要小心RAII。服务端节点常常是生命周期比较长的对象,如果把服务对象创建在某个局部函数里,函数结束服务就销毁了,请求自然就没人处理。确保服务对象和节点对象的生命周期保持一致,最稳妥的做法是作为节点成员或者和节点同作用域。
使用launch文件启动服务端和客户端。手动开两个终端没问题,但项目复杂以后,用launch文件统一启动是正路。一个简单的launch文件可以同时拉起服务端、客户端和可视化工具,省去大量重复的手工操作。这个知识点值得单独开一篇,这里先留个印象。
遇到问题时,ros2 doctor可以帮你看看整体环境。这个命令会检查ROS2环境变量、网络配置、DDS发现等,尤其是当你在多台机器上部署服务通信、发现对方的服务列表看不到时,ros2 doctor的输出往往能直接指出问题所在。很多“服务对端找不到”的疑难杂症,最后都归结到DDS域ID不一致、防火墙拦截、网络接口选择这几个原因上。
服务通信在ROS2里属于看似简单、实际细节不少的部分。把AddTwoInts跑通只是第一步,真正有价值的是理解它的同步模型、回调机制、超时处理和QoS约束。这些理解了,后面无论写导航服务的封装、还是给机械臂加控制接口,你会发现自己再也不需要反复查文档了。