ROS通信核心:roscpp实现Topic与Service的C++编程实战
1. 项目概述:从零到一掌握ROS通信核心
搞机器人开发,ROS是绕不开的一环。很多朋友在学完基础概念后,面对第一个实际要写的C++节点时,常常会卡壳:消息(Topic)和服务(Service)的代码到底怎么写?roscpp这个库的接口怎么用起来才顺手?今天,我就结合自己踩过的坑,把roscpp实现Topic和Service的完整流程、核心细节和避坑指南,掰开揉碎了讲清楚。无论你是刚接触ROS的在校学生,还是需要快速上手的工程师,这篇内容都能让你在半小时内,写出一个结构清晰、运行稳定的通信节点,彻底搞明白roscpp的编程范式。
简单来说,roscpp是ROS为C++开发者提供的一套客户端库,它封装了节点、话题、服务、参数等核心概念的底层通信细节。实现Topic就是学会如何发布(Publish)和订阅(Subscribe)数据;实现Service则是掌握如何提供(Advertise)和调用(Call)一个远程过程。听起来简单,但里面的门道不少,比如消息类型的生成、回调函数的设计、线程模型的理解,每一个环节都可能让你调试半天。接下来,我们就直接进入实战。
2. 核心概念与工程准备
在动手写代码之前,我们必须把几个关键概念和准备工作做到位。ROS的编程有它特定的“规矩”,事先了解能避免很多低级错误。
2.1 ROS消息与服务类型剖析
ROS通信的本质是序列化的数据交换。无论是Topic里流动的Message,还是Service中请求与应答的Request和Response,它们都不是普通的C++结构体,而是由.msg或.srv文件定义,并通过ROS构建系统自动生成的强类型类。
消息(.msg文件): 用于Topic通信,是一种单向的、异步的数据流。比如一个激光雷达的扫描数据LaserScan.msg,里面定义了header、angle_min、ranges等字段。在C++中,它会生成一个同名的类,例如sensor_msgs::LaserScan,你可以像操作普通类一样对其成员赋值。
服务(.srv文件): 用于Service通信,是一种双向的、同步的请求-应答模式。一个.srv文件分为上下两部分,用---分隔,上半部分是请求(Request)的数据结构,下半部分是应答(Response)的数据结构。例如一个简单的加法服务AddTwoInts.srv,请求是两个int64数字,应答是一个int64的和。在C++中,它会生成一个AddTwoInts类,里面嵌套定义了Request和Response两个子类。
注意: 自定义消息或服务后,必须在
package.xml中声明对message_generation的构建依赖(<build_depend>),在CMakeLists.txt中添加相应的生成规则,并确保在catkin_make后source devel/setup.bash,生成的C++头文件才能被正确找到。这是新手最容易忽略的一步,会导致编译时找不到头文件。
2.2 Catkin工作空间与包创建实操
ROS代码必须生活在Catkin工作空间中。假设我们的工作空间叫catkin_ws,我们来创建一个专门练习通信的包。
# 1. 创建并初始化工作空间(如果已有则跳过) mkdir -p ~/catkin_ws/src cd ~/catkin_ws/ catkin_make source devel/setup.bash # 这一步非常重要,每次新开终端都需要执行 # 2. 进入src目录,创建我们的功能包 # 包名:communication_pkg, 依赖:roscpp std_msgs message_generation cd ~/catkin_ws/src catkin_create_pkg communication_pkg roscpp std_msgs message_generation # 3. 创建必要的目录来存放我们自定义的消息和服务定义 cd communication_pkg mkdir msg mkdir srv接下来,我们在msg目录下创建一个Person.msg文件,内容如下:
string name uint8 age uint8 sex uint8 unknown = 0 uint8 male = 1 uint8 female = 2这个消息描述了一个人的基本信息。注意,我们这里使用了常量定义,这在.msg文件中是允许的,可以增加代码可读性。
然后,在srv目录下创建一个Greeting.srv文件,内容如下:
string name uint8 age --- string feedback这个服务接收一个人的姓名和年龄,返回一句问候语。
定义好之后,我们需要修改CMakeLists.txt和package.xml来让ROS知道如何编译它们。这是核心步骤,务必仔细。
修改package.xml: 确保包含以下行(catkin_create_pkg通常已添加,检查即可):
<build_depend>message_generation</build_depend> <exec_depend>message_runtime</exec_depend>修改CMakeLists.txt: 这是关键,找到相应部分修改或添加:
# 1. 修改find_package,确保包含message_generation find_package(catkin REQUIRED COMPONENTS roscpp std_msgs message_generation # 添加这一行 ) # 2. 添加我们自定义的msg和srv文件 add_message_files( FILES Person.msg ) add_service_files( FILES Greeting.srv ) # 3. 生成消息和服务代码,并指定依赖 generate_messages( DEPENDENCIES std_msgs ) # 4. 在catkin_package中声明运行时依赖 catkin_package( CATKIN_DEPENDS roscpp std_msgs message_runtime )最后,回到工作空间根目录编译:
cd ~/catkin_ws catkin_make编译成功后,你可以在~/catkin_ws/devel/include/communication_pkg/目录下找到自动生成的C++头文件,如Person.h和Greeting.h。我们的C++代码将包含这些头文件来使用自定义类型。
3. Topic通信的C++实现详解
Topic是ROS中最常用的异步通信机制,采用发布/订阅模型。发布者和订阅者互相不知道对方的存在,通过话题名称进行耦合,非常适合传感器数据流等场景。
3.1 发布者(Publisher)节点编写指南
发布者的核心任务是创建一个ros::Publisher对象,并周期性地向指定话题发布消息。我们创建一个talker.cpp文件,发布我们自定义的Person消息。
// talker.cpp #include "ros/ros.h" // 包含自动生成的消息头文件 #include "communication_pkg/Person.h" int main(int argc, char **argv) { // 初始化ROS节点,节点名称必须唯一 ros::init(argc, argv, "person_talker_node"); // 创建节点句柄,它是与ROS系统通信的主要入口点 ros::NodeHandle nh; // 创建一个Publisher,发布到“person_info”话题 // 消息类型是communication_pkg::Person // 第二个参数100是队列大小,如果发布速度超过订阅接收速度,最多缓存100条消息 ros::Publisher person_pub = nh.advertise<communication_pkg::Person>("person_info", 100); // 设置发布频率,这里是1Hz(每秒1次) ros::Rate loop_rate(1); int count = 0; while (ros::ok()) { // ros::ok()在节点正常运行时返回true // 创建并填充一个消息对象 communication_pkg::Person person_msg; person_msg.name = "Alice"; person_msg.age = 20 + (count % 5); // 让年龄有点变化 person_msg.sex = communication_pkg::Person::female; // 使用msg中定义的常量 // 发布消息 person_pub.publish(person_msg); // 使用ROS_INFO打印日志,类似于printf,但会被ROS日志系统记录 ROS_INFO("Publish Person Info: name:%s, age:%d, sex:%d", person_msg.name.c_str(), person_msg.age, person_msg.sex); // 处理一次回调(虽然这里没有订阅,但良好习惯是保留) ros::spinOnce(); // 按照设定的频率休眠,控制循环速度 loop_rate.sleep(); ++count; } return 0; }关键点解析:
ros::init: 必须第一个被调用,它解析传入的参数(如__name:=重命名节点),并初始化节点。ros::NodeHandle: 节点句柄是资源管理的核心。发布者、订阅者、服务端、客户端都通过它来创建。它的作用域决定了这些资源的生命周期。当句柄被析构时,它所创建的所有发布者、订阅者等都会被自动关闭。advertise<>(): 该方法模板化,需要指定要发布的消息类型。返回一个ros::Publisher对象。队列大小是一个重要的参数,设置太小可能导致高速发布时丢失消息,设置太大会占用更多内存。需要根据实际数据流量权衡。ros::ok(): 循环条件,当收到Ctrl+C、ros::shutdown()被调用、或同名的另一个节点启动时,会变为false。ros::spinOnce(): 处理所有挂起的回调函数(例如订阅消息的回调、服务请求的回调)。即使当前节点没有订阅,也建议保留,这是一个好习惯。ros::Rate与sleep(): 这是控制循环周期的经典方式,比单纯用sleep函数更精确,它会补偿循环执行本身消耗的时间,使平均频率更接近设定值。
3.2 订阅者(Subscriber)节点编写指南
订阅者的核心是创建一个ros::Subscriber对象,并为其绑定一个回调函数。当有新消息到达时,ROS会异步调用这个回调函数。我们创建listener.cpp。
// listener.cpp #include "ros/ros.h" #include "communication_pkg/Person.h" // 消息回调函数,当收到新消息时自动调用 // 参数是消息的常量引用,避免拷贝开销 void personInfoCallback(const communication_pkg::Person::ConstPtr& msg) { // 处理接收到的消息 std::string sex_str; switch(msg->sex) { case communication_pkg::Person::male: sex_str = "Male"; break; case communication_pkg::Person::female: sex_str = "Female"; break; default: sex_str = "Unknown"; break; } ROS_INFO("I heard: [Name: %s, Age: %d, Sex: %s]", msg->name.c_str(), msg->age, sex_str.c_str()); } int main(int argc, char **argv) { ros::init(argc, argv, "person_listener_node"); ros::NodeHandle nh; // 创建一个Subscriber,订阅“person_info”话题 // 消息类型是communication_pkg::Person // 当有新消息时,ROS会调用personInfoCallback函数 // 第二个参数100是队列大小,表示最多可以缓存100条未处理的消息 ros::Subscriber person_sub = nh.subscribe("person_info", 100, personInfoCallback); // ros::spin()会进入循环,不断地调用消息队列里的回调函数 // 当有消息到达时,personInfoCallback会被触发 // 它会一直阻塞,直到ros::ok()返回false(如Ctrl+C) ros::spin(); return 0; }关键点解析:
- 回调函数签名: 必须符合
void callback(const MsgType::ConstPtr&)的形式。ConstPtr是ROS消息的智能指针常量类型(通常是boost::shared_ptr<const MsgType>),使用它既安全又高效。 subscribe(): 绑定话题名、队列大小和回调函数。队列大小在这里指回调队列,如果回调函数处理太慢,新消息会在这个队列里排队等待,超过数量则丢弃最旧的消息。ros::spin()与ros::spinOnce():ros::spin(): 一个死循环,持续处理回调,直到节点关闭。适用于这种纯订阅者节点。ros::spinOnce(): 处理当前时刻所有挂起的回调一次,然后返回。通常用在有循环的节点里(比如上面的发布者),在循环中调用,既能处理回调,又不影响主循环。
- 线程安全: 默认情况下,所有回调都在
ros::spin()或ros::spinOnce()被调用的线程中执行。这意味着如果你的回调函数执行时间很长,它会阻塞其他回调(包括定时器、服务回调等)的处理。这是roscpp单线程回调队列的典型特点。
3.3 Topic通信的配置、编译与运行测试
写好代码后,我们需要修改CMakeLists.txt来编译它们。
在CMakeLists.txt中找到add_executable和target_link_libraries部分,添加:
# 创建可执行文件 talker, 源文件是src/talker.cpp add_executable(talker src/talker.cpp) # 将talker链接到catkin库和相关的依赖库 target_link_libraries(talker ${catkin_LIBRARIES}) # 添加对自动生成的消息的头文件的依赖,确保先编译消息再编译节点 add_dependencies(talker ${PROJECT_NAME}_generate_messages_cpp) add_executable(listener src/listener.cpp) target_link_libraries(listener ${catkin_LIBRARIES}) add_dependencies(listener ${PROJECT_NAME}_generate_messages_cpp)回到工作空间根目录编译:
cd ~/catkin_ws catkin_make source devel/setup.bash运行测试:
- 启动ROS核心(Master):
roscore - 新终端,运行发布者:
rosrun communication_pkg talker - 新终端,运行订阅者:
rosrun communication_pkg listener
你应该能在订阅者终端看到每秒打印一次的消息。同时,可以用rostopic echo /person_info命令查看原始消息流,用rqt_graph命令可视化节点和话题的连接关系。
实操心得: 在开发中,我强烈建议将节点的初始化、句柄创建、发布者/订阅者创建放在类的构造函数中,而将主循环或
ros::spin()放在一个单独的run()方法里。这样结构更清晰,也便于资源管理。另外,话题名称通常用全局名称(如/person_info)或相对名称,在代码中直接写字符串容易出错,我习惯在类中定义static const std::string常量来存储话题名。
4. Service通信的C++实现详解
Service是ROS中的同步远程过程调用(RPC)机制。客户端发送一个请求(Request),并阻塞等待直到服务端处理完毕并返回一个应答(Response)。适用于需要确认执行结果的命令,如开关控制、计算服务等。
4.1 服务端(Server)节点编写指南
服务端负责提供(Advertise)一个服务,并绑定一个处理回调函数。当客户端调用时,回调函数被触发,处理请求并填充应答。我们创建greeting_server.cpp。
// greeting_server.cpp #include "ros/ros.h" #include "communication_pkg/Greeting.h" // 包含自动生成的服务头文件 // 服务回调函数 // 参数:请求(req), 应答(res) // 返回bool类型,通常返回true表示成功处理。返回false可用于指示无效请求。 bool handleGreetingRequest(communication_pkg::Greeting::Request &req, communication_pkg::Greeting::Response &res) { // 处理请求数据 ROS_INFO("Received service request: name=%s, age=%d", req.name.c_str(), req.age); // 构建应答数据 std::stringstream ss; ss << "Hello " << req.name << "! You are " << req.age << " years old."; res.feedback = ss.str(); ROS_INFO("Sending back response: [%s]", res.feedback.c_str()); return true; // 处理成功 } int main(int argc, char **argv) { ros::init(argc, argv, "greeting_server_node"); ros::NodeHandle nh; // 创建一个Service Server,提供服务名为“greeting_service” // 当有请求到达时,调用handleGreetingRequest函数 ros::ServiceServer service = nh.advertiseService("greeting_service", handleGreetingRequest); ROS_INFO("Greeting Service Server is ready."); // 进入循环,等待服务请求 // 当有客户端调用时,ROS会自动在新线程(如果配置了多线程)或本线程中调用回调函数 ros::spin(); return 0; }关键点解析:
- 回调函数签名:
bool function_name(MsgReqType &, MsgResType &)。第一个参数是请求的引用,第二个是应答的引用。你需要在函数内读取req,写入res。返回值表示服务处理是否成功,客户端可以通过call()方法的返回值(也是bool)获知。 advertiseService(): 与服务名和回调函数绑定。服务名在ROS网络中必须是唯一的。- 阻塞与线程: 默认的单线程
spin下,服务回调函数执行时,会阻塞其他回调(包括其他服务、话题订阅等)。如果服务处理耗时很长,需要考虑使用多线程回调队列(通过ros::MultiThreadedSpinner或ros::AsyncSpinner)。
4.2 客户端(Client)节点编写指南
客户端创建ros::ServiceClient对象,然后填充一个请求对象,调用call()方法。call()会阻塞,直到收到服务端的应答或超时。我们创建greeting_client.cpp。
// greeting_client.cpp #include "ros/ros.h" #include "communication_pkg/Greeting.h" #include <cstdlib> // 用于atoi int main(int argc, char **argv) { ros::init(argc, argv, "greeting_client_node"); ros::NodeHandle nh; // 创建一个Service Client,指定要调用的服务名“greeting_service” ros::ServiceClient client = nh.serviceClient<communication_pkg::Greeting>("greeting_service"); // 准备服务请求数据 communication_pkg::Greeting srv; // 可以从命令行参数读取名字和年龄,这里简单示例 if (argc == 3) { srv.request.name = argv[1]; srv.request.age = atoi(argv[2]); } else { // 如果没有提供参数,使用默认值 srv.request.name = "DefaultUser"; srv.request.age = 30; ROS_WARN("Usage: rosrun communication_pkg greeting_client <name> <age>"); ROS_WARN("Using default values: name=%s, age=%d", srv.request.name.c_str(), srv.request.age); } ROS_INFO("Calling service 'greeting_service' with name:%s, age:%d", srv.request.name.c_str(), srv.request.age); // 关键:调用服务 // call()方法是阻塞的,会一直等待直到收到响应或发生错误 if (client.call(srv)) { // 如果call返回true,通常表示通信成功且服务端返回true ROS_INFO("Service call succeeded. Response: %s", srv.response.feedback.c_str()); } else { // 如果call返回false,可能是服务不可用、通信失败或服务端处理失败(返回false) ROS_ERROR("Failed to call service 'greeting_service'"); return 1; } return 0; }关键点解析:
serviceClient<>(): 模板参数是服务类型。创建客户端时,服务可能还未启动,这没关系。call()方法: 这是同步阻塞调用。它会一直等待,直到:a) 收到服务端的应答;b) 发生通信错误(如服务端断开);c) ROS系统关闭。它的返回值是一个bool,这个bool并不直接代表服务端业务逻辑的成功,而是代表“是否成功收到了一个应答且服务端回调函数返回了true”。如果网络超时或服务端回调返回false,call()也会返回false。- 等待服务可用: 在调用
call()之前,服务可能还没启动。一个健壮的做法是使用client.waitForExistence()或ros::service::waitForService(“service_name”)来等待服务可用,或者用client.isValid()检查客户端是否已正确创建。
4.3 Service通信的配置、编译与运行测试
同样,在CMakeLists.txt中添加编译规则:
add_executable(greeting_server src/greeting_server.cpp) target_link_libraries(greeting_server ${catkin_LIBRARIES}) add_dependencies(greeting_server ${PROJECT_NAME}_generate_messages_cpp) add_executable(greeting_client src/greeting_client.cpp) target_link_libraries(greeting_client ${catkin_LIBRARIES}) add_dependencies(greeting_client ${PROJECT_NAME}_generate_messages_cpp)编译并运行:
- 确保
roscore在运行。 - 启动服务端:
rosrun communication_pkg greeting_server - 启动客户端(带参数):
rosrun communication_pkg greeting_client Bob 25 - 你应该在服务端看到收到请求的日志,在客户端看到服务返回的问候语。
注意事项: Service是同步的,意味着客户端会一直阻塞。因此,绝对不要在负责控制循环或实时响应的主线程中调用可能耗时的服务,这会导致整个节点“卡住”。对于这类情况,应该考虑使用异步服务调用(
ros::ServiceClient配合多线程),或者改用Actionlib(ROS中更复杂的异步任务管理机制)。
5. 高级话题与核心避坑指南
掌握了基础实现后,我们来看看如何写出更健壮、高效的代码,以及那些官方文档里不常提,但实践中一定会踩的坑。
5.1 消息与服务的自定义与最佳实践
自定义消息和服务是ROS开发的常态。除了前面提到的编译依赖,还有几个关键点:
- 字段命名与兼容性: 消息/服务字段一旦发布,应尽量避免修改名称或类型,因为这会破坏已有节点的兼容性。如果需要增加字段,尽量加在末尾。ROS的消息版本管理比较弱,变更需谨慎。
- 使用常量: 如前例,在
.msg文件中定义常量(如uint8 MALE=1)可以让代码更清晰、更安全,避免魔法数字。 - 复杂数据类型: ROS内置了丰富的基本类型数组(
int32[],float64[]等)和标准消息类型(如std_msgs/Header,geometry_msgs/Pose)。优先使用这些标准类型来组合成你的自定义消息,而不是重新发明轮子。例如,你的自定义消息里第一个字段通常是std_msgs/Header header,用于添加时间戳和坐标系信息。 - 命名空间: 自定义消息/服务生成的C++类位于你的包名命名空间下(如
communication_pkg::Person)。在代码中要正确包含和引用。
5.2 回调函数、线程模型与spin机制深度解析
这是roscpp编程中最容易混淆和出问题的地方。
默认的单线程模型: 当你调用ros::spin()或ros::spinOnce()时,ROS会在当前线程中依次执行回调队列(Callback Queue)里的回调函数。这意味着:
- 如果你的订阅者回调函数
callbackA执行需要5秒,那么在这5秒内,即使有新的消息到达,或者其他定时器回调到期,它们都不会被处理,必须等callbackA执行完。 - 对于服务服务器,如果其回调函数执行很慢,客户端调用就会长时间阻塞。
解决方案:多线程回调队列roscpp提供了多种方式来并行处理回调。
#include <ros/callback_queue.h> #include <ros/spinner.h> // 方法1:使用MultiThreadedSpinner // 创建固定数量线程的Spinner ros::MultiThreadedSpinner spinner(4); // 使用4个线程 spinner.spin(); // 替代ros::spin() // 方法2:使用AsyncSpinner(更灵活,推荐) // 先启动Spinner,它会在后台运行 ros::AsyncSpinner async_spinner(2); // 使用2个线程 async_spinner.start(); // ... 这里你的主线程可以继续做其他事情,比如控制循环 ros::waitForShutdown(); // 等待关闭信号 async_spinner.stop(); // 方法3:自定义回调队列 // 为某个特定的订阅者或服务器指定一个独立的队列和线程处理 ros::CallbackQueue my_queue; ros::AsyncSpinner specific_spinner(1, &my_queue); specific_spinner.start(); ros::NodeHandle nh2; nh2.setCallbackQueue(&my_queue); // 用这个nh2创建的订阅者,其回调将在my_queue对应的线程中被处理 ros::Subscriber sub2 = nh2.subscribe("topic2", 10, callback2);实操心得: 对于大多数应用,我推荐使用
ros::AsyncSpinner。在主函数初始化节点和所有发布者/订阅者/服务后,启动一个AsyncSpinner,然后主线程用ros::waitForShutdown()等待结束。这样所有回调都能被及时处理,不会阻塞主线程。线程数通常设置为CPU核心数即可。
5.3 参数服务器与动态配置入门
除了Topic和Service,ROS还有一个重要的通信机制——参数服务器(Parameter Server)。它是一个全局的键值对存储,节点可以读取(getParam)或设置(setParam)参数,常用于配置。
// 在节点中获取参数 std::string default_name; // 从参数服务器获取参数“/person_name”,如果不存在,使用默认值“Unknown” nh.param<std::string>("/person_name", default_name, "Unknown"); int publish_rate; // 另一种方式,先检查是否存在 if (nh.getParam("publish_rate", publish_rate)) { // 成功获取到参数 loop_rate = ros::Rate(publish_rate); } else { ROS_WARN("Parameter 'publish_rate' not set, using default 1 Hz"); }更高级的用法是结合dynamic_reconfigure包,实现运行时的动态参数调整,这对于算法调参非常有用,但配置稍复杂,需要额外的.cfg配置文件。
5.4 常见编译与运行时问题排查
编译错误:找不到头文件(
fatal error: xxx/xxx.h: No such file or directory)- 原因: 自定义消息/服务头文件未生成或路径未包含。
- 解决:
- 检查
CMakeLists.txt中的add_message_files,add_service_files,generate_messages,add_dependencies是否正确配置。 - 确保执行了
catkin_make且编译成功。 - 确保在运行节点的终端里
source devel/setup.bash。最好将这一行添加到你的~/.bashrc中。
- 检查
运行时警告:
Could not process inbound connection- 原因: 消息类型不匹配。比如发布者发布的是
std_msgs/String,但订阅者订阅的是std_msgs/Int32。 - 解决: 使用
rostopic type /topic_name和rosservice type /service_name检查话题/服务的实际类型,确保发布/订阅、服务端/客户端使用的类型完全一致(包括包名)。
- 原因: 消息类型不匹配。比如发布者发布的是
服务调用失败,但服务端已启动
- 原因A: 服务名不一致。可能有命名空间问题。客户端调用
/node1/greeting,服务端提供的是/greeting。 - 解决: 使用
rosservice list查看确切的服务名。在代码中,可以使用ros::NodeHandle的命名空间,或者使用全局名称(以/开头)。 - 原因B: 服务端回调函数返回了
false。 - 解决: 检查服务端回调函数的逻辑,确保在成功处理时返回
true。
- 原因A: 服务名不一致。可能有命名空间问题。客户端调用
节点启动后立即退出
- 原因: 主函数执行到
return 0。对于订阅者或服务端,必须在最后有ros::spin()或类似的循环来保持节点活跃。如果用了ros::spinOnce(),要确保它在一个while(ros::ok())循环里。
- 原因: 主函数执行到
回调函数处理速度跟不上消息发布速度
- 现象: 订阅者似乎丢消息,或者处理的消息不是最新的。
- 原因: 发布队列或订阅回调队列满了。
- 解决:
- 增大
advertise()和subscribe()的队列大小参数(但治标不治本)。 - 优化回调函数的处理逻辑,减少耗时。
- 使用多线程
AsyncSpinner,避免一个慢回调阻塞其他消息。 - 考虑使用消息过滤器(
message_filters)来同步或丢弃某些消息,或者使用Publisher的getNumSubscribers()方法,在没有订阅者时不发布,节省资源。
- 增大
把这些原理和技巧吃透,你就能写出既正确又高效的ROS C++节点了。记住,多写多调试,遇到问题先查日志(rosout),再用rqt_graph,rostopic,rosservice等命令行工具辅助诊断,效率会高很多。
