ROS2 Action机制详解:C++实现可取消的长时间任务通信 1. 项目概述为什么我们需要ROS2 Action在机器人开发中通信是系统的血脉。我们熟悉的话题Topic和服务Service解决了大部分问题话题用于持续的数据流服务用于一次性的请求-响应。但有一种场景它们处理起来很别扭一个需要长时间运行、可以中途取消、并且能实时反馈进度的任务。比如让机械臂移动到某个目标位置。用服务调用后会一直阻塞直到机械臂到达或超时期间你无法知道它走到哪了也无法取消。用话题你需要自己设计一套复杂的“开始”、“反馈”、“完成”的发布/订阅协议代码会变得难以维护。这就是ROS2动作Action机制登场的时候。它本质上是一个建立在服务和话题之上的高级通信接口专门为这类“可取消的长时间运行任务”而设计。一个典型的Action通信包含三个角色客户端Action Client发起目标、服务端Action Server执行任务、以及一个反馈通道。客户端发送目标后服务端开始执行并周期性地通过反馈话题发布进度客户端可以随时监听这个反馈。最重要的是客户端可以发送取消请求服务端也能妥善处理取消逻辑并返回最终结果可能是成功、取消或失败。这次我们就用C来彻底拆解一个ROS2 Action的完整实现。我会带你从接口定义开始一步步构建一个模拟的“机械臂移动”动作服务器和客户端并深入每个环节的细节和避坑点。无论你是刚从ROS1迁移过来还是初次接触ROS2的动作机制这篇都能让你获得可以直接复用到真实项目中的代码和经验。2. 动作通信的核心设计与接口定义2.1 动作接口文件.action的解剖一切始于接口定义。ROS2 Action使用.action文件来定义通信的“契约”它规定了目标Goal、反馈Feedback和结果Result的数据结构。这个文件需要放在功能包的action/目录下。我们创建一个MoveRobot.action文件内容如下# 目标定义客户端希望机械臂到达的目标位置 float32 target_x float32 target_y float32 target_z --- # 结果定义动作执行完毕后返回的最终结果 bool success string message --- # 反馈定义动作执行过程中周期性发送的进度信息 float32 current_x float32 current_y float32 current_z float32 completion_percentage格式解析与设计考量文件被---分隔成三个部分顺序固定为Goal、Result、Feedback。Goal部分定义了客户端请求的内容。这里我们模拟三维空间中的一个目标点。在实际项目中这里可能是关节角度、末端位姿包含姿态的四元数等更复杂的数据类型。Result部分定义了动作最终结束时返回的信息。success布尔值标志成败message字符串可以携带更详细的描述例如失败原因“碰撞检测触发”。Feedback部分定义了执行过程中周期性发送的数据。除了当前位置我们还添加了一个completion_percentage完成百分比这是一个非常实用的设计让客户端可以轻松地更新进度条。注意.action文件中使用的数据类型必须是ROS2 IDL接口定义语言支持的类型例如基本类型bool,int32,float64,string或其他.msg文件中定义的消息类型。复杂结构建议先定义为.msg再在这里引用。2.2 CMakeLists.txt 与 package.xml 的配置要点定义好接口文件后我们需要在CMakeLists.txt和package.xml中声明以便ROS2的构建系统能生成对应的C头文件。在package.xml中确保添加了rclcpp、rclcpp_action和rosidl_default_generators的依赖。exec_dependrclcpp/exec_depend exec_dependrclcpp_action/exec_depend buildtool_dependrosidl_default_generators/buildtool_depend在CMakeLists.txt中关键步骤是找到相关包并将action文件添加到rosidl_generate_interfaces调用中。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/MoveRobot.action ) # 链接生成的头文件所需的目标 ament_target_dependencies(${PROJECT_NAME}_node rclcpp rclcpp_action ${PROJECT_NAME} # 链接本包生成的消息接口 ) ament_package()编译与生成执行colcon build --packages-select your_package_name后你会在install/your_package_name/include/下找到生成的头文件例如your_package_name/action/move_robot.hpp。这个头文件包含了我们将要使用的所有C类如MoveRobot::Goal、MoveRobot::Result、MoveRobot::Feedback以及最重要的MoveRobot动作类型本身。3. 动作服务器Action Server的详细实现动作服务器是执行任务的核心。它需要处理目标请求、在后台执行任务、发布反馈、处理取消请求并在完成后返回结果。3.1 服务器类的基本框架我们创建一个RobotActionServer类它继承自rclcpp::Node。#include “rclcpp/rclcpp.hpp” #include “rclcpp_action/rclcpp_action.hpp” #include “your_package_name/action/move_robot.hpp” using MoveRobot your_package_name::action::MoveRobot; using GoalHandleMoveRobot rclcpp_action::ServerGoalHandleMoveRobot; class RobotActionServer : public rclcpp::Node { public: RobotActionServer() : Node(“robot_action_server”) { // 构造函数内初始化动作服务器 } private: rclcpp_action::ServerMoveRobot::SharedPtr action_server_; };3.2 核心回调函数处理目标、取消与执行动作服务器的核心是三个回调函数handle_goal处理新目标、handle_cancel处理取消请求、execute执行任务。我们通过rclcpp_action::create_server来创建服务器并绑定这些回调。1. 创建服务器与目标处理回调RobotActionServer() : Node(“robot_action_server”) { using namespace std::placeholders; this-action_server_ rclcpp_action::create_serverMoveRobot( this, “move_robot”, // 动作名称客户端通过此名称连接 std::bind(RobotActionServer::handle_goal, this, _1, _2), std::bind(RobotActionServer::handle_cancel, this, _1), std::bind(RobotActionServer::handle_accepted, this, _1) ); RCLCPP_INFO(this-get_logger(), “动作服务器已启动等待目标...”); }2.handle_goal回调当客户端发送一个新目标时此函数被调用。你可以在这里进行目标验证例如检查目标点是否在可达工作空间内。rclcpp_action::GoalResponse handle_goal( const rclcpp_action::GoalUUID uuid, std::shared_ptrconst MoveRobot::Goal goal) { // 忽略未使用的uuid参数它唯一标识此目标 (void)uuid; // 目标验证逻辑 float x goal-target_x, y goal-target_y, z goal-target_z; float max_range 10.0; if (std::sqrt(x*x y*y z*z) max_range) { RCLCPP_WARN(this-get_logger(), “目标点 (%.2f, %.2f, %.2f) 超出工作空间拒绝执行。”, x, y, z); return rclcpp_action::GoalResponse::REJECT; } RCLCPP_INFO(this-get_logger(), “收到新目标移动到 (%.2f, %.2f, %.2f)” x, y, z); return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE; // 接受并立即执行 }实操心得返回ACCEPT_AND_EXECUTE意味着服务器接受目标后会自动调用execute。你也可以返回ACCEPT然后手动在handle_accepted中启动执行线程这提供了更灵活的控制。3.handle_cancel回调当客户端请求取消时此函数被调用。它接收一个shared_ptrGoalHandleMoveRobot你需要通知正在执行的任务线程应该终止。rclcpp_action::CancelResponse handle_cancel( const std::shared_ptrGoalHandleMoveRobot goal_handle) { RCLCPP_INFO(this-get_logger(), “收到取消请求。”); // 在实际应用中这里应设置一个标志位通知执行线程优雅退出。 // 我们假设有一个成员变量 std::atomicbool cancel_requested_。 cancel_requested_ true; return rclcpp_action::CancelResponse::ACCEPT; // 接受取消 }4.handle_accepted与execute回调handle_accepted在目标被接受后调用通常在这里启动执行线程。execute函数是任务执行的主体。void handle_accepted(const std::shared_ptrGoalHandleMoveRobot goal_handle) { // 使用线程执行避免阻塞ROS2的executor std::thread{std::bind(RobotActionServer::execute, this, _1), goal_handle}.detach(); } void execute(const std::shared_ptrGoalHandleMoveRobot goal_handle) { RCLCPP_INFO(this-get_logger(), “开始执行移动任务...”); rclcpp::Rate loop_rate(1); // 设置反馈频率例如1Hz const auto goal goal_handle-get_goal(); auto feedback std::make_sharedMoveRobot::Feedback(); auto result std::make_sharedMoveRobot::Result(); // 模拟从原点(0,0,0)移动到目标点 float current_x 0.0, current_y 0.0, current_z 0.0; float step_x goal-target_x / 10.0; float step_y goal-target_y / 10.0; float step_z goal-target_z / 10.0; for (int i 1; i 10; i) { // 检查是否被取消 if (goal_handle-is_canceling()) { result-success false; result-message “任务被用户取消”; goal_handle-canceled(result); RCLCPP_INFO(this-get_logger(), “任务已取消。”); return; } // 更新当前位置和进度 current_x step_x; current_y step_y; current_z step_z; feedback-current_x current_x; feedback-current_y current_y; feedback-current_z current_z; feedback-completion_percentage i * 10; // 发布反馈 goal_handle-publish_feedback(feedback); RCLCPP_INFO(this-get_logger(), “发布反馈进度 %d%% 位置 (%.2f, %.2f, %.2f)” feedback-completion_percentage, current_x, current_y, current_z); loop_rate.sleep(); } // 任务完成 result-success true; result-message “成功到达目标点”; goal_handle-succeed(result); RCLCPP_INFO(this-get_logger(), “移动任务完成”); }关键点解析线程分离execute在独立线程中运行这是为了不阻塞主线程即ROS2的Executor使其能继续处理其他回调如新的目标或取消请求。取消检查在循环中必须检查goal_handle-is_canceling()。如果为真需要用canceled(result)通知客户端任务已取消并设置适当的结果信息。反馈发布使用goal_handle-publish_feedback(feedback)发布反馈。客户端会异步接收到这些信息。终止状态任务结束时必须调用succeed(result),canceled(result), 或abort(result)来设置最终状态并传递结果。这是告诉客户端任务最终结局的唯一方式。4. 动作客户端Action Client的详细实现客户端负责发起目标、监听反馈、接收结果以及请求取消。4.1 客户端类的基本框架#include “rclcpp/rclcpp.hpp” #include “rclcpp_action/rclcpp_action.hpp” #include “your_package_name/action/move_robot.hpp” #include chrono #include functional #include future using MoveRobot your_package_name::action::MoveRobot; using GoalHandleMoveRobot rclcpp_action::ClientGoalHandleMoveRobot; class RobotActionClient : public rclcpp::Node { public: RobotActionClient() : Node(“robot_action_client”) { this-client_ rclcpp_action::create_clientMoveRobot(this, “move_robot”); } void send_goal(float x, float y, float z); // 发送目标函数 private: rclcpp_action::ClientMoveRobot::SharedPtr client_; // 回调函数声明 void goal_response_callback(std::shared_futureGoalHandleMoveRobot::SharedPtr future); void feedback_callback(GoalHandleMoveRobot::SharedPtr, const std::shared_ptrconst MoveRobot::Feedback feedback); void result_callback(const GoalHandleMoveRobot::WrappedResult result); };4.2 发送目标与处理异步回调动作客户端的API是异步的大量使用std::future和回调函数。1. 组装并发送目标void RobotActionClient::send_goal(float x, float y, float z) { // 等待动作服务器上线 if (!this-client_-wait_for_action_server(std::chrono::seconds(5))) { RCLCPP_ERROR(this-get_logger(), “动作服务器未在5秒内响应。”); return; } auto goal_msg MoveRobot::Goal(); goal_msg.target_x x; goal_msg.target_y y; goal_msg.target_z z; RCLCPP_INFO(this-get_logger(), “发送目标 (%.2f, %.2f, %.2f)”, x, y, z); // 设置发送选项 auto send_goal_options rclcpp_action::ClientMoveRobot::SendGoalOptions(); send_goal_options.goal_response_callback std::bind(RobotActionClient::goal_response_callback, this, std::placeholders::_1); send_goal_options.feedback_callback std::bind(RobotActionClient::feedback_callback, this, std::placeholders::_1, std::placeholders::_2); send_goal_options.result_callback std::bind(RobotActionClient::result_callback, this, std::placeholders::_1); // 异步发送目标并立即返回 this-client_-async_send_goal(goal_msg, send_goal_options); }2. 目标响应回调 (goal_response_callback)这个回调在服务器接受或拒绝目标后触发。参数是一个std::shared_future我们需要从中获取GoalHandle。void RobotActionClient::goal_response_callback(std::shared_futureGoalHandleMoveRobot::SharedPtr future) { auto goal_handle future.get(); if (!goal_handle) { RCLCPP_ERROR(this-get_logger(), “目标被服务器拒绝。”); } else { RCLCPP_INFO(this-get_logger(), “目标已被服务器接受正在执行。”); // 这里可以保存goal_handle用于后续可能的取消操作 // stored_goal_handle_ goal_handle; } }3. 反馈回调 (feedback_callback)在服务器执行任务并发布反馈时此回调被触发。void RobotActionClient::feedback_callback( GoalHandleMoveRobot::SharedPtr, const std::shared_ptrconst MoveRobot::Feedback feedback) { RCLCPP_INFO(this-get_logger(), “收到反馈进度 %.0f%% 当前位置 (%.2f, %.2f, %.2f)”, feedback-completion_percentage, feedback-current_x, feedback-current_y, feedback-current_z); // 在实际UI中这里可以更新进度条或显示当前位置 }4. 结果回调 (result_callback)当任务完成成功、取消或中止时此回调被触发。void RobotActionClient::result_callback(const GoalHandleMoveRobot::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::CANCELED: RCLCPP_WARN(this-get_logger(), “任务被取消。结果信息%s” result.result-message.c_str()); break; case rclcpp_action::ResultCode::ABORTED: RCLCPP_ERROR(this-get_logger(), “任务中止。结果信息%s” result.result-message.c_str()); break; default: RCLCPP_ERROR(this-get_logger(), “未知结果代码。”); break; } }4.3 实现取消功能客户端可以在任何时候取消一个正在执行的目标前提是它保存了该目标对应的GoalHandle。void cancel_goal() { if (stored_goal_handle_) { RCLCPP_INFO(this-get_logger(), “发送取消请求...”); // 异步发送取消请求 auto future_cancel client_-async_cancel_goal(stored_goal_handle_); // 通常不需要等待取消结果结果会在 result_callback 中收到 CANCELED 状态。 } else { RCLCPP_WARN(this-get_logger(), “没有可取消的活动目标。”); } }5. 编译、运行与调试实战5.1 编译配置与节点启动确保你的CMakeLists.txt正确编译两个节点。通常你需要定义两个可执行目标add_executable(robot_action_server src/robot_action_server.cpp) ament_target_dependencies(robot_action_server rclcpp rclcpp_action ${PROJECT_NAME}) add_executable(robot_action_client src/robot_action_client.cpp) ament_target_dependencies(robot_action_client rclcpp rclcpp_action ${PROJECT_NAME}) install(TARGETS robot_action_server robot_action_client DESTINATION lib/${PROJECT_NAME})使用colcon build编译后在两个不同的终端中分别运行服务器和客户端# 终端1启动服务器 source install/setup.bash ros2 run your_package_name robot_action_server # 终端2启动客户端并发送目标 source install/setup.bash ros2 run your_package_name robot_action_client # 注意客户端代码中需要调用 send_goal 函数。通常我们会将 send_goal 调用放在一个定时器或命令行参数触发中。一个更实用的客户端是在构造函数中启动一个定时器一段时间后自动发送目标或者通过ROS2参数/服务来触发。5.2 使用命令行工具监控动作ROS2提供了强大的命令行工具来直观地监控动作通信这对于调试至关重要。查看动作列表ros2 action list查看动作信息ros2 action info /move_robot这会显示与该动作关联的服务器、客户端以及用于Goal、Result、Feedback、Cancel的话题。手动发送目标ros2 action send_goal /move_robot your_package_name/action/MoveRobot “{target_x: 2.0, target_y: 3.0, target_z: 1.0}” --feedback--feedback参数会实时打印出服务器发布的反馈信息非常有用。监听反馈话题动作的反馈、状态、结果都通过隐藏的话题发布。你可以直接用ros2 topic echo来监听例如ros2 topic echo /move_robot/_action/feedback。5.3 典型问题排查与解决技巧问题1编译时报错 “找不到 action/MoveRobot.hpp”原因最可能的原因是CMakeLists.txt中rosidl_generate_interfaces没有正确包含你的.action文件或者编译顺序有问题。解决确保.action文件路径正确并执行colcon build后在install目录下检查头文件是否生成。有时需要清理构建目录重新编译rm -rf build install log然后colcon build。问题2客户端报错 “动作服务器未响应”原因服务器节点未启动或者动作名称不匹配。解决首先用ros2 node list和ros2 action list确认服务器节点和动作是否存在。检查客户端代码中创建Client时使用的动作名称如“move_robot”是否与服务器端create_server时使用的名称完全一致包括命名空间。问题3服务器接受了目标但客户端收不到反馈或结果原因1客户端的回调函数没有被正确绑定或触发。排查在客户端的各个回调函数开头添加日志确认它们是否被调用。检查SendGoalOptions中的回调绑定语法。原因2服务器的execute函数没有正确发布反馈或设置最终状态succeed/canceled/abort。排查在服务器的publish_feedback和succeed调用前后添加日志。确保execute函数在独立线程中运行没有因为异常而提前退出。问题4取消请求无效原因服务器端的handle_cancel回调虽然被调用但execute函数中的goal_handle-is_canceling()检查可能不生效或者cancel_requested_标志位没有正确传递到执行线程。解决确保cancel_requested_这类标志位是std::atomicbool类型以保证线程安全。在execute循环的每次迭代开始处检查goal_handle-is_canceling()。问题5多个目标同时处理混乱原因服务器使用detach分离线程如果快速连续发送多个目标会创建多个线程它们可能共享或竞争某些资源如日志、标志位。解决为每个目标句柄 (goal_handle) 创建一个独立的任务执行器例如一个包含所有执行状态和取消标志的类实例确保执行上下文隔离。更稳健的做法是使用线程池来管理并发任务。6. 进阶应用与设计模式6.1 在复杂节点中集成动作服务器在实际机器人系统中动作服务器很少是独立存在的。它可能是一个大节点如Navigation2中的NavigateToPose动作服务器的一部分。集成时需要注意资源管理确保动作服务器的执行线程不会耗尽系统资源。考虑使用固定大小的线程池。状态同步动作执行状态需要与节点的其他模块如传感器数据处理、全局规划器同步。通常使用共享的、线程安全的状态机。与生命周期节点结合如果使用rclcpp_lifecycle动作服务器应在节点activate时创建在deactivate时销毁并妥善处理激活期间未完成的目标。6.2 动作的取消与抢占策略“取消”语义可以细化为多种策略温和取消服务器收到取消请求后停止当前任务回到安全状态例如机械臂停止运动并保持当前位置。强制中止立即停止所有执行单元可能涉及急停指令。目标抢占新目标到达时自动取消旧目标。这需要在handle_goal回调中实现检查是否有正在执行的目标如果有先取消它再接受新目标。NavigateToPose动作通常采用这种策略。实现抢占示例rclcpp_action::GoalResponse handle_goal(...) { if (current_goal_handle_ current_goal_handle_-is_executing()) { // 取消当前正在执行的目标 auto cancel_result current_goal_handle_-async_cancel(); // 可以选择等待取消完成或直接接受新目标 RCLCPP_INFO(get_logger(), “抢占取消旧目标接受新目标。”); } // ... 验证新目标 ... return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE; }6.3 动作超时与客户端重试机制服务器端执行可能挂起客户端需要设置超时。ROS2 Action Client 本身不提供发送目标后的整体超时但我们可以利用std::future和async_get_result来实现。auto future_result client_-async_get_result(goal_handle); if (future_result.wait_for(std::chrono::seconds(30)) std::future_status::timeout) { RCLCPP_WARN(this-get_logger(), “获取结果超时尝试取消目标。”); client_-async_cancel_goal(goal_handle); }更完善的客户端还应实现重试逻辑例如在服务器拒绝或任务失败后根据策略如指数退避重新发送目标。6.4 动作与行为树Behavior Tree的配合在复杂的机器人任务编排中动作常作为行为树BT的叶子节点Action Node。例如NavigateToPose是一个动作PickObject是另一个动作。行为树库如BehaviorTree.CPP通常提供了与ROS2 Action集成的节点类型。其核心是将动作的发送、反馈监听、结果处理封装成一个符合行为树执行语义运行中、成功、失败的节点。理解动作的状态机PENDING, EXECUTING, CANCELING, SUCCEEDED, CANCELED, ABORTED是设计好行为树动作节点的关键。7. 性能考量与最佳实践总结1. 反馈频率要适度反馈话题也是ROS2话题高频反馈如100Hz会给网络和序列化/反序列化带来压力。根据实际需求选择频率1-10Hz对于进度更新通常足够。2. 结果消息应简洁结果消息用于传递最终状态不应包含大量数据。大数据应通过话题或服务另行传递。3. 妥善处理资源execute函数中如果打开了设备句柄、分配了内存必须在所有退出路径成功、取消、异常中确保资源被正确释放。4. 使用智能指针管理生命周期在回调函数中尤其涉及多线程时使用std::shared_ptr来管理目标和结果数据避免悬空指针。5. 日志分级在execute循环内避免使用RCLCPP_INFO等高频率日志改用RCLCPP_DEBUG并通过运行时日志级别控制其输出避免日志洪水。6. 测试策略单元测试应覆盖动作的所有状态转换正常成功、执行中取消、服务器拒绝目标、执行失败中止等。可以使用rclcpp的测试工具模拟客户端行为。从我自己的项目经验来看把ROS2 Action用好的一个标志是你的机器人核心功能模块导航、机械臂控制、语音交互都以清晰定义的动作接口对外提供服务。这极大地提升了系统的模块化程度和可维护性。当你想让机器人“去A点拿B物体再放到C点”时你只需要在高层编排器中串联几个动作客户端而不需要关心底层是轮子怎么转、机械爪怎么开合。这种抽象能力正是ROS2动作通信机制带给我们的最大价值。