ROS2动作通信C++实战:从接口定义到机械臂拾取放置完整实现
1. 项目概述从话题到实战的跨越最近在机器人开发者社区里一个高频出现的话题是“如何让ROS2的动作Action真正用起来”。很多朋友尤其是从ROS1迁移过来或者刚入门的开发者在理解了话题Topic和服务Service之后面对动作这个概念总觉得隔着一层纱——知道它用于长时间运行、可抢占、有反馈的任务但一到用C实现就卡在了客户端与服务端的交互逻辑、目标状态管理这些细节上。这其实非常正常动作是ROS2通信机制中相对复杂的一环它融合了话题和服务的特性设计初衷就是为了解决那些“一言难尽”的耗时任务比如让机器人移动到某个点位、执行一段复杂的抓取序列或者完成一个SLAM建图过程。如果你正在为机器人开发一个需要持续数十秒甚至几分钟并且你希望随时能知道进度、能中途取消、能知道最终是成功还是失败的功能那么动作就是你不可或缺的工具。它比单纯用话题发布指令再订阅状态更结构化比用服务处理长任务更灵活可靠。本文的目的就是抛开那些笼统的概念直接深入到C代码层面手把手带你构建一个完整的、可运行的ROS2动作示例。我们会从一个具体的场景出发控制一个虚拟的机械臂执行一段“拾取-放置”任务。通过这个例子你将彻底掌握动作服务器Action Server和动作客户端Action Client的C实现脉络理解目标Goal、反馈Feedback、结果Result这三个核心消息是如何在两者之间流转的并学会处理取消、抢占等现实开发中必然会遇到的边界情况。无论你是正在学习ROS2的学生还是需要为产品添加复杂任务模块的工程师这篇内容都能提供即插即用的代码框架和避坑指南。2. 动作通信模型深度解析不仅仅是“带反馈的服务”在开始写代码之前我们必须把动作的通信模型吃透。很多教程把它简单描述为“一个服务加两个话题”这虽然形象但容易让人忽略其内在的强状态机和并发管理机制。理解这个模型是写出健壮动作代码的基础。2.1 三层通信结构与状态机一个ROS2动作本质上由三对发布/订阅关系构成它们共同维护了一个清晰的任务状态机目标话题Goal Topic客户端向服务器发送任务目标。这类似于服务的请求但它是单向的、异步的。服务器订阅此话题以接收新目标。取消话题Cancel Topic客户端向服务器发送取消请求。服务器订阅此话题以响应取消操作。状态话题Status Topic服务器向客户端广播当前所有被跟踪任务Goal的状态例如“正在执行”、“已取消”、“已成功”。客户端订阅此话题以了解任务的整体生命周期。反馈话题Feedback Topic服务器在执行任务的过程中定期向客户端发布进度信息。这是一个单向的、从服务器到客户端的流。结果服务Result Service当任务最终完成无论成功、失败或被取消时客户端会通过一个服务调用来获取最终的结果。这是一个同步的请求-响应确保客户端能可靠地拿到任务结局。这五条通道共同作用背后对应着一个严格的状态机。对于服务器接收到的每一个目标Goal其生命周期会经历ACCEPTED-EXECUTING- 最终状态SUCCEEDED,CANCELED,ABORTED等。客户端和服务器端的API设计正是为了让你方便地在这个状态机框架下工作而不是自己去维护这些状态和通道。2.2 与话题、服务的核心差异为什么不用话题或服务替代动作关键在于动作解决的痛点vs 话题Topic如果你用话题发送指令再用另一个话题订阅状态你需要自己设计消息协议来关联指令和状态自己处理“哪个状态对应哪个指令”的匹配问题自己实现取消逻辑。动作将这些标准化、内置化了。vs 服务Service服务是同步的、阻塞的调用。一个长达一分钟的移动任务会阻塞客户端一分钟期间无法接收任何其他信息也无法取消。动作的异步特性让客户端在发送目标后即可解放通过回调函数来处理反馈和结果。所以当你面临的任务具有“命令-执行-持续反馈-最终结果”这个模式时动作就是最自然的选择。接下来我们就以“机械臂拾取放置”为蓝本开始C实战。3. 开发环境准备与接口定义工欲善其事必先利其器。一个清晰的接口定义是后续所有工作的基石。3.1 创建ROS2工作空间与功能包假设你已经安装了ROS2 Humble或Iron版本安装过程不是本文重点网上教程很多。我们首先创建一个专门的工作空间和功能包。# 创建并进入工作空间 mkdir -p ~/ros2_action_ws/src cd ~/ros2_action_ws/src # 使用CMake创建C功能包依赖rclcpp和action接口 ros2 pkg create cpp_action_tutorial --build-type ament_cmake --dependencies rclcpp rclcpp_action rclcpp_components example_interfaces这里我们额外依赖了example_interfaces只是为了方便使用其中预定义的Fibonacci.action作为示例接口。在实际项目中你需要定义自己的.action文件。3.2 设计自定义动作接口.action文件在功能包的action目录下创建我们自己的动作接口文件PickAndPlace.action。cd cpp_action_tutorial mkdir action touch action/PickAndPlace.action编辑PickAndPlace.action文件定义任务的目标、反馈和结果# PickAndPlace.action # 定义目标消息拾取和放置的坐标 geometry_msgs/Point pick_position geometry_msgs/Point place_position string object_id --- # 定义结果消息最终是否成功以及可能的信息 bool success string message --- # 定义反馈消息当前阶段和完成百分比 string current_phase # 例如“MOVING_TO_PICK”, “GRASPING”, “MOVING_TO_PLACE”, “RELEASING” float32 percent_complete这个文件定义了一个完整的契约目标Goal告诉机械臂去哪里拾取pick_position放到哪里place_position以及操作哪个物体object_id。结果Result任务结束后告诉客户端是否成功success和附加信息message。反馈Feedback任务执行过程中定期告诉客户端现在在哪个阶段current_phase和总体进度percent_complete。3.3 配置构建系统为了让CMake能识别并编译我们自定义的.action文件需要修改CMakeLists.txt和package.xml。在CMakeLists.txt中找到find_package部分确保包含rosidl_default_generators并添加以下内容find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(rclcpp_action REQUIRED) find_package(rosidl_default_generators REQUIRED) # 声明要生成消息的.action文件 rosidl_generate_interfaces(${PROJECT_NAME} action/PickAndPlace.action ) ... # 在 ament_package() 之前添加依赖导出 ament_export_dependencies(rosidl_default_runtime) ament_export_include_directories(include)在package.xml中添加构建依赖和运行时依赖buildtool_dependament_cmake/buildtool_depend dependrclcpp/depend dependrclcpp_action/depend dependgeometry_msgs/depend dependrosidl_default_generators/depend member_of_grouprosidl_interface_packages/member_of_group现在你可以编译工作空间来生成对应的C头文件了cd ~/ros2_action_ws colcon build --packages-select cpp_action_tutorial source install/setup.bash编译成功后在install/cpp_action_tutorial/include/下就能找到自动生成的cpp_action_tutorial/action/pick_and_place.hpp等头文件我们将在代码中包含它们。4. 动作服务器Action Server的C实现动作服务器是任务的执行者。它的核心职责是接收目标、执行任务、发布反馈、返回结果并优雅地处理取消请求。4.1 服务器节点框架搭建我们在src目录下创建服务器节点文件pick_and_place_server.cpp。// pick_and_place_server.cpp #include rclcpp/rclcpp.hpp #include rclcpp_action/rclcpp_action.hpp #include cpp_action_tutorial/action/pick_and_place.hpp #include thread #include chrono using namespace std::chrono_literals; using PickAndPlace cpp_action_tutorial::action::PickAndPlace; using GoalHandlePickAndPlace rclcpp_action::ServerGoalHandlePickAndPlace; class PickAndPlaceActionServer : public rclcpp::Node { public: explicit PickAndPlaceActionServer(const rclcpp::NodeOptions options rclcpp::NodeOptions()) : Node(pick_and_place_action_server) { // 使用rclcpp_action创建动作服务器 this-action_server_ rclcpp_action::create_serverPickAndPlace( this, // 所属节点 pick_and_place, // 动作名称客户端将通过此名称连接 std::bind(PickAndPlaceActionServer::handle_goal, this, std::placeholders::_1, std::placeholders::_2), std::bind(PickAndPlaceActionServer::handle_cancel, this, std::placeholders::_1), std::bind(PickAndPlaceActionServer::handle_accepted, this, std::placeholders::_1) ); RCLCPP_INFO(this-get_logger(), Pick and Place Action Server 已启动等待目标...); } private: rclcpp_action::ServerPickAndPlace::SharedPtr action_server_; // 1. 处理目标请求决定是否接受新目标 rclcpp_action::GoalResponse handle_goal( const rclcpp_action::GoalUUID uuid, std::shared_ptrconst PickAndPlace::Goal goal) { RCLCPP_INFO(this-get_logger(), 收到新目标请求物体ID: %s, goal-object_id.c_str()); // 此处可添加目标验证逻辑例如检查坐标是否在可达范围内 (void)uuid; // 防止未使用变量警告 return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE; // 接受并立即执行 } // 2. 处理取消请求 rclcpp_action::CancelResponse handle_cancel( const std::shared_ptrGoalHandlePickAndPlace goal_handle) { RCLCPP_INFO(this-get_logger(), 收到取消请求); // 此处应设置一个标志位通知执行函数需要退出 // 为了示例我们假设取消总是被允许 return rclcpp_action::CancelResponse::ACCEPT; } // 3. 目标被接受后在新线程中执行任务 void handle_accepted(const std::shared_ptrGoalHandlePickAndPlace goal_handle) { // 使用std::thread避免阻塞主线程执行器 std::thread{std::bind(PickAndPlaceActionServer::execute, this, std::placeholders::_1), goal_handle}.detach(); } // 核心执行函数 void execute(const std::shared_ptrGoalHandlePickAndPlace goal_handle) { RCLCPP_INFO(this-get_logger(), 开始执行拾取放置任务); const auto goal goal_handle-get_goal(); auto feedback std::make_sharedPickAndPlace::Feedback(); auto result std::make_sharedPickAndPlace::Result(); // 模拟一个长时间运行的任务分为4个阶段 std::vectorstd::string phases {MOVING_TO_PICK, GRASPING, MOVING_TO_PLACE, RELEASING}; for (size_t i 0; i phases.size(); i) { // 检查任务是否被取消 if (goal_handle-is_canceling()) { result-success false; result-message 任务被用户取消; goal_handle-canceled(result); RCLCPP_INFO(this-get_logger(), 任务取消); return; } // 更新反馈 feedback-current_phase phases[i]; feedback-percent_complete (i 1) * 25.0; // 模拟25%50%75%100% goal_handle-publish_feedback(feedback); RCLCPP_INFO(this-get_logger(), 阶段: %s, 进度: %.0f%%, feedback-current_phase.c_str(), feedback-percent_complete); // 模拟该阶段耗时工作 std::this_thread::sleep_for(2s); } // 任务成功完成 result-success true; result-message 拾取放置任务成功完成; goal_handle-succeed(result); RCLCPP_INFO(this-get_logger(), 任务成功完成: %s, result-message.c_str()); } }; int main(int argc, char ** argv) { rclcpp::init(argc, argv); auto node std::make_sharedPickAndPlaceActionServer(); rclcpp::spin(node); rclcpp::shutdown(); return 0; }4.2 服务器实现关键点剖析三回调机制服务器的核心是三个回调函数handle_goal,handle_cancel,handle_accepted。它们分别处理目标到达、取消请求和目标被接受后的动作。这种设计将状态判断与任务执行解耦非常清晰。异步执行在handle_accepted中我们使用std::thread来在新线程中运行execute函数。这是至关重要的一步。如果你在回调函数中直接执行耗时任务会阻塞ROS2的执行器executor导致整个节点无法响应其他消息包括取消请求。分离线程是标准做法。状态检查与响应在execute函数的循环中每次迭代都通过goal_handle-is_canceling()检查是否收到了取消请求。如果收到则调用goal_handle-canceled(result)来通知客户端任务已取消并设置相应的结果。任务成功完成后则调用goal_handle-succeed(result)。反馈发布通过goal_handle-publish_feedback(feedback)定期向客户端发送进度信息。反馈的频率和内容需要根据实际任务调整既要让客户端感知进度又不能过于频繁造成网络负担。注意在实际的机器人控制中execute函数内部应该是与机器人控制器如MoveIt!、底层驱动节点交互的代码而不是sleep_for。这里的睡眠仅用于模拟耗时过程。5. 动作客户端Action Client的C实现客户端是任务的发起者。它负责发送目标、监听反馈、处理结果并可能发送取消指令。5.1 客户端节点框架搭建在src目录下创建客户端节点文件pick_and_place_client.cpp。// pick_and_place_client.cpp #include rclcpp/rclcpp.hpp #include rclcpp_action/rclcpp_action.hpp #include cpp_action_tutorial/action/pick_and_place.hpp #include geometry_msgs/msg/point.hpp #include thread using namespace std::chrono_literals; using PickAndPlace cpp_action_tutorial::action::PickAndPlace; using GoalHandlePickAndPlace rclcpp_action::ClientGoalHandlePickAndPlace; class PickAndPlaceActionClient : public rclcpp::Node { public: explicit PickAndPlaceActionClient(const rclcpp::NodeOptions node_options rclcpp::NodeOptions()) : Node(pick_and_place_action_client) { this-client_ rclcpp_action::create_clientPickAndPlace( this-get_node_base_interface(), this-get_node_graph_interface(), this-get_node_logging_interface(), this-get_node_waitables_interface(), pick_and_place // 动作名称必须与服务器一致 ); this-timer_ this-create_wall_timer( std::chrono::milliseconds(500), std::bind(PickAndPlaceActionClient::send_goal, this)); } void send_goal() { // 只发送一次目标 this-timer_-cancel(); // 等待动作服务器上线 if (!this-client_-wait_for_action_server(10s)) { RCLCPP_ERROR(this-get_logger(), 动作服务器未在10秒内响应); rclcpp::shutdown(); return; } // 构造目标消息 auto goal_msg PickAndPlace::Goal(); goal_msg.object_id red_box; goal_msg.pick_position.x 0.5; goal_msg.pick_position.y 0.2; goal_msg.pick_position.z 0.1; goal_msg.place_position.x -0.3; goal_msg.place_position.y 0.4; goal_msg.place_position.z 0.1; RCLCPP_INFO(this-get_logger(), 发送拾取放置目标...); // 设置发送目标的选项 auto send_goal_options rclcpp_action::ClientPickAndPlace::SendGoalOptions(); send_goal_options.goal_response_callback std::bind(PickAndPlaceActionClient::goal_response_callback, this, std::placeholders::_1); send_goal_options.feedback_callback std::bind(PickAndPlaceActionClient::feedback_callback, this, std::placeholders::_1, std::placeholders::_2); send_goal_options.result_callback std::bind(PickAndPlaceActionClient::result_callback, this, std::placeholders::_1); // 异步发送目标并立即返回 this-client_-async_send_goal(goal_msg, send_goal_options); } private: rclcpp_action::ClientPickAndPlace::SharedPtr client_; rclcpp::TimerBase::SharedPtr timer_; // 目标响应回调服务器是否接受了我们的目标 void goal_response_callback(std::shared_futureGoalHandlePickAndPlace::SharedPtr future) { auto goal_handle future.get(); if (!goal_handle) { RCLCPP_ERROR(this-get_logger(), 目标被服务器拒绝); } else { RCLCPP_INFO(this-get_logger(), 目标已被服务器接受正在执行); // 这里可以保存goal_handle用于后续可能的取消操作 // this-goal_handle_ goal_handle; } } // 反馈回调定期接收来自服务器的进度更新 void feedback_callback( GoalHandlePickAndPlace::SharedPtr, const std::shared_ptrconst PickAndPlace::Feedback feedback) { RCLCPP_INFO(this-get_logger(), 收到反馈: 阶段[%s], 进度 %.0f%%, feedback-current_phase.c_str(), feedback-percent_complete); } // 结果回调任务最终完成时调用 void result_callback(const GoalHandlePickAndPlace::WrappedResult result) { switch (result.code) { case rclcpp_action::ResultCode::SUCCEEDED: RCLCPP_INFO(this-get_logger(), 任务成功! 结果: %s, result.result-message.c_str()); break; case rclcpp_action::ResultCode::ABORTED: RCLCPP_ERROR(this-get_logger(), 任务被中止); break; case rclcpp_action::ResultCode::CANCELED: RCLCPP_WARN(this-get_logger(), 任务被取消); break; default: RCLCPP_ERROR(this-get_logger(), 未知结果代码); break; } // 结果收到可以关闭节点或发送新目标 rclcpp::shutdown(); } }; int main(int argc, char ** argv) { rclcpp::init(argc, argv); auto node std::make_sharedPickAndPlaceActionClient(); rclcpp::spin(node); rclcpp::shutdown(); return 0; }5.2 客户端实现关键点剖析等待服务器在发送目标前必须使用client_-wait_for_action_server()等待服务器上线。这是一个好习惯可以避免发送目标到不存在的服务器。异步发送与回调async_send_goal是异步调用它立即返回不会阻塞。你需要通过SendGoalOptions设置三个关键回调函数goal_response_callback通知你服务器是接受了还是拒绝了目标。feedback_callback在任务执行过程中每当服务器发布反馈时被调用。result_callback当任务最终完成成功、失败、取消时被调用并携带最终的结果。结果处理result_callback的参数WrappedResult包含了结果码ResultCode和具体的结果消息result。根据结果码进行不同的处理是客户端逻辑的核心。取消操作示例中没有展示取消但实际操作很简单。如果你保存了goal_handle在goal_response_callback中获得可以调用client_-async_cancel_goal(goal_handle)来发送取消请求。6. 编译、运行与调试实战现在让我们把代码跑起来看看动作通信的完整流程。6.1 修改CMakeLists.txt以编译节点在CMakeLists.txt的ament_package()之前添加可执行目标的构建指令add_executable(action_server src/pick_and_place_server.cpp) ament_target_dependencies(action_server rclcpp rclcpp_action geometry_msgs) rosidl_target_interfaces(action_server ${PROJECT_NAME} rosidl_typesupport_cpp) add_executable(action_client src/pick_and_place_client.cpp) ament_target_dependencies(action_client rclcpp rclcpp_action geometry_msgs) rosidl_target_interfaces(action_client ${PROJECT_NAME} rosidl_typesupport_cpp) install(TARGETS action_server action_client DESTINATION lib/${PROJECT_NAME} )6.2 运行与观察编译cd ~/ros2_action_ws colcon build --packages-select cpp_action_tutorial source install/setup.bash启动动作服务器终端1ros2 run cpp_action_tutorial action_server你应该看到输出Pick and Place Action Server 已启动等待目标...启动动作客户端终端2ros2 run cpp_action_tutorial action_client观察两个终端的输出。客户端会发送目标服务器会开始执行并发布反馈最终客户端会收到成功的结果。使用命令行工具监控终端3# 查看动作列表 ros2 action list # 你应该能看到 /pick_and_place # 查看动作信息 ros2 action info /pick_and_place # 这会显示该动作的服务器和客户端数量 # 手动发送目标可选 ros2 action send_goal /pick_and_place cpp_action_tutorial/action/PickAndPlace {pick_position: {x: 0.1, y: 0.2, z: 0.3}, place_position: {x: 0.4, y: 0.5, z: 0.6}, object_id: test} --feedback # --feedback 参数会让你实时看到反馈信息6.3 测试取消功能为了测试取消我们可以修改客户端在发送目标后稍等片刻就发起取消。在send_goal函数中async_send_goal之后添加// 在 async_send_goal 后启动一个定时器3秒后取消任务 this-cancel_timer_ this-create_wall_timer( 3s, [this, goal_handle_future]() { // 注意这里需要一种方式获取到goal_handle实际代码需调整 RCLCPP_INFO(this-get_logger(), 尝试取消任务...); // this-client_-async_cancel_goal(goal_handle); // 由于goal_handle在回调中实际实现可能需要一个类成员变量来保存它 });更健壮的做法是在goal_response_callback中将返回的goal_handle保存为类的成员变量例如this-goal_handle_然后在定时器回调中调用this-client_-async_cancel_goal(this-goal_handle_)。运行修改后的客户端你会看到服务器在MOVING_TO_PICK或GRASPING阶段接收到取消请求并终止任务客户端在result_callback中收到CANCELED的结果码。7. 进阶话题与生产环境注意事项掌握了基础实现后要将其用于实际机器人项目还需要考虑更多。7.1 动作服务器的并发与抢占处理我们的示例服务器一次只处理一个目标。但在现实中新的目标可能在旧目标未完成时到达。handle_goal回调的返回值决定了服务器的行为ACCEPT_AND_EXECUTE接受新目标并立即执行。如果已有任务在执行你需要决定是让新目标排队还是抢占旧目标。抢占需要你在execute函数中检查是否有新目标被接受并安全地终止当前任务。ACCEPT_AND_DEFER接受新目标但暂不执行。你可以将其放入队列等当前任务完成后再执行。REJECT直接拒绝新目标。实现一个健壮的、支持任务队列或优先级抢占的动作服务器是复杂的需要仔细设计任务调度逻辑和状态管理。7.2 反馈频率与网络负载反馈消息虽然有用但发送过于频繁例如在高频控制循环中会占用大量网络带宽和CPU资源。一个常见的优化策略是节流Throttling在服务器端不要每次循环都发布反馈而是累积一定时间或达到某个进度阈值后再发布。例如auto now this-now(); if ((now - last_feedback_time_).seconds() 0.1) { // 每100ms发布一次反馈 goal_handle-publish_feedback(feedback); last_feedback_time_ now; }7.3 超时与错误处理客户端超时客户端发送目标后可能希望在一定时间内得到结果否则视为失败。这可以通过在发送目标后启动一个定时器来实现在result_callback或定时器回调中处理超时逻辑。服务器端错误在execute函数中如果发生不可恢复的错误如机械臂失联、坐标越界应调用goal_handle-abort(result)来中止任务并将错误信息通过result传回客户端。连接丢失网络可能中断。客户端和服务器都需要考虑对端可能意外消失的情况。rclcpp_action库在一定程度上处理了连接问题但你的应用层逻辑可能需要重置状态或重试。7.4 与现有框架集成如MoveIt 2在真实的机械臂应用中你的动作服务器execute函数内部很可能是调用 MoveIt 2 的MoveGroupInterface来执行运动规划。例如// 伪代码 moveit::planning_interface::MoveGroupInterface move_group(node, arm_group); move_group.setPoseTarget(pick_pose); moveit::planning_interface::MoveGroupInterface::Plan my_plan; bool success (move_group.plan(my_plan) moveit::core::MoveItErrorCode::SUCCESS); if(success) { move_group.execute(my_plan); // 发布“移动到拾取点完成”的反馈 } // ... 后续进行抓取、移动、放置等操作这时你的动作服务器就成了一个高级任务编排器将复杂的“拾取-放置”序列分解为一系列 MoveIt 2 可以执行的基本动作并通过动作接口向外部提供一个统一、可监控的服务。从理解动作的三层通信模型到亲手实现一个完整的、带取消和反馈的C动作服务器与客户端我们走完了ROS2动作开发的核心路径。动作是ROS2中构建可靠、复杂机器人任务的基石。记住关键不在于记住API而在于理解其背后的状态机思想和异步编程模式。当你下次需要让机器人执行一个“需要时间、需要知道进度、可能被打断”的任务时动作应该是你脑海中的首选方案。在实际项目中多考虑边界情况并发、取消、错误恢复你的机器人系统将会更加稳健。