news 2026/10/23 16:59:56

ROS动作通信详解:从Service阻塞到Action异步任务控制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ROS动作通信详解:从Service阻塞到Action异步任务控制

简介:面向ROS初学者与机器人开发者,这份动作服务器与客户端学习源码包以C++语言展示了动作通信机制的完整实现,帮助解决机器人任务中长耗时操作的发起、执行与监控问题。压缩包共5个文件,涵盖2个C++源文件(服务端与客户端主程序)、1个action消息定义、1个txt说明文档,以及CMakeLists.txt与package.xml构建配置,整体仅8KB,精简结构便于快速阅读与复用。源码中使用actionlib库的SimpleActionServer/SimpleActionClient,清晰演示了executeCB回调、发送Goal、等待Result等关键步骤,并配合ros_tutorials_action目录下的动作定义与include头文件,体现一个可编译的catkin工程组织方式。已有227人浏览学习,适合对照博客笔记进行实操验证,也可作为课堂实验模板,在此基础上扩展tf、geometry_msgs等模块,完成更复杂的机器人行为控制。总体而言,这份资源把理论讲解与可运行代码浓缩在一个小包里,对快速理解ROS动作机制很有帮助。

1. ROS 动作通信到底解决了什么:从“服务只能返回一次”到“进度可见、任务可取消”

写 ROS 两三年的人,大多卡过这个问题:想给机械臂发一个耗时数秒的运动指令,写 service 吧,服务端在算,客户端只能干等,中途想取消还没办法;换 topic 吧,又失去了“请求—响应”的语义,根本不知道任务到底结束没有。ROS action 就是为这个场景设计的通信原语。动作通信把一次任务拆成目标、反馈、结果三段,客户端能实时看到进度,新目标可以抢占旧目标,取消请求也能被服务端响应。这份源码笔记拆的正是基于 C++ 的 action 服务端与客户端完整实现,从消息定义到 CMake 配置再到编译运行,适合所有被 service 同步阻塞坑过、想上手异步任务控制的 ROS 开发者。

2. 三段式消息与状态机:动作通信的底层逻辑

2.1 .action 文件的语法和代码生成

源码包里最容易忽略的是action目录下的.action文件。这套笔记里的动作定义是MoveToGoal.action,内容不长,但三段式的结构很典型:

# 目标段(Goal) geometry_msgs/Pose target_pose --- # 结果段(Result) bool reached --- # 反馈段(Feedback) float32 distance_remaining

三段用---分隔,这就是 action 消息的全部语法。第一段定义客户端要提交的目标,比如机械臂末端的目标位姿;第二段定义任务结束后服务端返回的最终结果;第三段是服务端执行过程中随时吐出来的进度信息。需要记住的是,Feedback 可以发布任意多次,Result 只能发一次,且以最后一次为准。

把这文件放进action/目录后,CMake 里的配置也有一套固定写法:

find_package(catkin REQUIRED COMPONENTS roscpp actionlib actionlib_msgs message_generation ) add_action_files( DIRECTORY action FILES MoveToGoal.action ) generate_messages( DEPENDENCIES actionlib_msgs std_msgs geometry_msgs ) catkin_package( CATKIN_DEPENDS roscpp actionlib actionlib_msgs message_runtime )

这里有几个容易翻车的位置。add_action_files必须写在generate_messages之前;generate_messages的DEPENDENCIES漏掉actionlib_msgs,生成的头文件会缺 GoalStatus 相关定义。编译完成后到devel/include/learning_actions/下看一眼,能看到MoveToGoalAction.h、MoveToGoalActionGoal.h、MoveToGoalActionResult.h一整组头文件,C++ 代码里包含最外层的MoveToGoalAction.h就够用了。我一般会写一句#include <learning_actions/MoveToGoalAction.h>,剩下的交给编译系统处理。

2.2 服务器与客户端的状态机:从 PENDING 到 DONE 之间的那些状态

动作通信两边各维护一套状态机。客户端发目标后,这个目标先进入 PENDING,服务器接受后变 ACTIVE,执行完毕根据结局变成 SUCCEEDED、PREEMPTED、ABORTED 或 REJECTED。这些状态码和actionlib_msgs/GoalStatus里的定义一一对应,调试时直接看数字就能判断当前卡在哪一步。

状态名称状态码含义
PENDING0目标已发送,等待服务器接收
ACTIVE1服务器已接收,正在执行
PREEMPTED2目标被新目标抢占或主动取消
SUCCEEDED3执行成功并返回结果
ABORTED4执行失败,无有效结果
REJECTED5目标被服务器直接拒绝
RECALLING6客户端请求撤销还没发出去的目标
LOST8目标丢失,通常因为连接异常

这组数字在代码里不会直接出现,但后面用rostopic echo /xxx/status查状态时全都会看到。理解状态机最有用的一个场景是排查“客户端为什么没拿到结果”:如果状态停在了 ACTIVE 不变,说明服务器的执行回调内部卡住了;如果变成了 PREEMPTED,说明中间有人调用了cancelGoal()或在执行回调里返回了抢占状态。

动作通信在底层其实展开成五个话题:goal、feedback、result、status、cancel。service 之所以做不到中途取消和进度反馈,就是因为它只有一对请求—响应话题。action 把一次任务的三个关键事件拆到不同话题上,客户端订阅result时并不会阻塞等待,这是异步通信能成立的根本原因。

3. 手写动作服务器:SimpleActionServer 与 execute 回调的执行边界

3.1 服务器构造函数与回调绑定:start() 的顺序不是玄学

源码包里服务器端用到了actionlib::SimpleActionServer,初始化代码不长,但要讲顺序:

#include <ros/ros.h> #include <actionlib/server/simple_action_server.h> #include <learning_actions/MoveToGoalAction.h> class MoveActionServer { protected: ros::NodeHandle nh_; actionlib::SimpleActionServer<learning_actions::MoveToGoalAction> as_; std::string action_name_; public: MoveActionServer(std::string name) : as_(nh_, name, boost::bind(&MoveActionServer::executeCB, this, _1), false), action_name_(name) { as_.start(); ROS_INFO("action server [%s] started", action_name_.c_str()); } void executeCB(const learning_actions::MoveToGoalGoalConstPtr &goal); };

构造函数的第三个参数是执行回调,用boost::bind把成员函数和this绑在一起,第四个参数false表示关闭自动启动。为什么要把 auto_start 设为 false?因为如果传 true,回调线程会在对象成员还没全部构造完成时就开始跑,一旦 executeCB 里访问了尚未初始化的成员,就会出现很难复现的段错误。写服务端时,我习惯先构造好所有成员、再在最末尾调用as_.start()。

3.2 executeCallback 主循环:反馈频率、抢占检查与退出条件

executeCB 是动作服务器真正干活的函数,下面的代码模拟了一个带距离反馈的移动任务:

void MoveActionServer::executeCB( const learning_actions::MoveToGoalGoalConstPtr &goal) { double distance = goal->target_pose.position.x; double step = 0.05; ros::Rate rate(10); // 10Hz,每 100ms 循环一次 learning_actions::MoveToGoalFeedback feedback; learning_actions::MoveToGoalResult result; ROS_INFO("receive goal, target_x = %.2f", distance); while (distance > 0.01) { // 抢占检查:客户端取消或新目标到达,必须在这个位置感知 if (as_.isPreemptRequested() || !ros::ok()) { ROS_INFO("goal preempted, distance = %.2f", distance); as_.setPreempted(); return; } distance -= step; feedback.distance_remaining = distance > 0.0 ? distance : 0.0; as_.publishFeedback(feedback); rate.sleep(); } result.reached = true; as_.setSucceeded(result); }

这段代码里有几个值得注意的设计点。as_.isPreemptRequested()必须在每个循环开头检查一次,这是服务器响应取消请求的唯一机会。publishFeedback是向 feedback 话题发布消息,客户端只要处于 ACTIVE 状态就会持续收到。rate.sleep()控制循环频率,10Hz 意味着客户端每秒能看到 10 条进度更新。

反馈发布的频率要结合业务场景选择,不是越快越好。对机械臂运动这类任务,10Hz 足够表达进度;对高速视觉跟踪这类场景,可以把频率提到 50Hz,但每一帧都要做一次序列化和话题传输,CPU 占用会明显上升。

3.3 三种结局的写法:setSucceeded、setAborted、setPreempted

executeCB 正常走到循环结束,用setSucceeded(result)发布最终结果,客户端会收到 SUCCEEDED 状态和 result 里的reached = true;如果任务中途发现路径规划失败、传感器数据异常这类问题,应该调用setAborted(result);如果客户端请求取消,调用as_.setPreempted()。三兄弟各自对应不同的状态码和 result 发布行为。一个容易搞混的点是,setSucceeded和setAborted都能带 result,但setPreempted也可以带 result,用来传递“被取消时执行到哪一步”的现场信息。

这套源码还在服务器里加了超时保护:如果任务超过预设时间,在 executeCB 外层包一个定时器,超时后强制setAborted。这是一个很实用的习惯,因为某个被调度的子进程或底层驱动一旦卡死,executeCB 的 while 循环可能永远不会退出,外层的定时器是最后的救命稻草。

4. 手写动作客户端:两种结果等待方式与超时处理

4.1 waitForServer:启动竞态和超时参数

客户端的核心代码集中在怎么和服务器通信上,初始化部分如下:

#include <ros/ros.h> #include <actionlib/client/simple_action_client.h> #include <learning_actions/MoveToGoalAction.h> int main(int argc, char** argv) { ros::init(argc, argv, "move_action_client"); // 第二个参数 true:让客户端自己拉一个线程处理回调 actionlib::SimpleActionClient<learning_actions::MoveToGoalAction> ac("move_to_goal", true); ROS_INFO("waiting for action server..."); // 等待服务器启动,最多等 10 秒 if (!ac.waitForServer(ros::Duration(10.0))) { ROS_ERROR("action server not found within 10s"); return -1; } ROS_INFO("action server connected");

SimpleActionClient构造函数的第一个参数是 action 名称,它和服务器端必须完全一致,大小写、命名空间都算。第二个参数true表示开启内部 spin 线程,客户端不需要在代码里写额外的ros::spin()就能持续处理服务器发来的状态反馈。waitForServer(ros::Duration(10.0))是带超时的等待,返回 false 说明 10 秒内没发现服务器。新手最容易在这里踩坑:服务器节点没启动,客户端就挂在这个函数上一直等,所以正式代码里必须传超时参数。

4.2 waitForResult 轮询模式:状态、结果与取消

服务器连接成功后,构造目标并发送:

learning_actions::MoveToGoalGoal goal; goal.target_pose.position.x = 2.5; goal.target_pose.position.y = 0.0; ROS_INFO("send goal, x = %.2f", goal.target_pose.position.x); ac.sendGoal(goal); // 等待结果返回,最多等 30 秒 bool finished = ac.waitForResult(ros::Duration(30.0)); if (finished) { actionlib::SimpleClientGoalState state = ac.getState(); const learning_actions::MoveToGoalResultConstPtr& result = ac.getResult(); ROS_INFO("state: %s, reached: %d", state.toString().c_str(), result->reached); } else { ROS_WARN("timeout, cancel goal"); ac.cancelGoal(); // 取消之后等一小段时间,让状态变成 PREEMPTED ac.waitForResult(ros::Duration(3.0)); } return 0; }

waitForResult会阻塞当前线程,直到结果话题收到消息、或超过设定的ros::Duration。getState()返回SimpleClientGoalState,用toString()可以打出 SUCCEEDED、PREEMPTED 这类可读状态。getResult()拿到最终结果,如果任务是 ABORTED 或 PREEMPTED,getResult()可能返回空指针,取值前要先判空。

超时分支里的cancelGoal()值得说两句。调用它之后,服务器要等到下一次isPreemptRequested()检查才会响应,响应完成后状态变成 PREEMPTED,客户端才会结束等待。所以取消后要再waitForResult(ros::Duration(3.0)),给状态转换留出时间窗口,否则立刻退出,状态机可能还停留在 RECALLING。

4.3 回调驱动模式:sendGoal 带三个回调的生产级写法

轮询模式适合简单 demo,但生产代码里更好用的是回调驱动。sendGoal的重载版本可以传三个回调函数:

void doneCb(actionlib::SimpleClientGoalState& state, const learning_actions::MoveToGoalResultConstPtr& result) { ROS_INFO("done, state=%s", state.toString().c_str()); if (result) { ROS_INFO("reached=%d", result->reached); } } void activeCb() { ROS_INFO("goal just went active"); } void feedbackCb( const learning_actions::MoveToGoalFeedbackConstPtr& feedback) { ROS_INFO("distance=%.2f", feedback->distance_remaining); } ac.sendGoal(goal, boost::bind(&doneCb, this, _1, _2), boost::bind(&activeCb, this), boost::bind(&feedbackCb, this, _1));

三个回调分别在目标完成、目标被服务器接收、收到反馈时触发。doneCb里传的是SimpleClientGoalState&和结果指针,反馈回调每收到一条 feedback 消息就会执行一次。这种模式的好处是客户端主线程完全不被阻塞,可以继续处理别的逻辑,适合把动作通信嵌进状态机或 UI 框架里。

这里要提一个实际生产中常见的补充:如果客户端同时处理多个任务,最好在 doneCb 里判断goal_id,因为服务器可能由于抢占等原因返回多个结果,只有匹配当前目标的那个才应该被采纳。

5. 动作实战避坑:四个能卡住新手一整天的典型问题

5.1 客户端一直等待服务器?先检查 start() 与 action 名称

现象:客户端卡在waitForServer超时,终端不停打印 “action server not found”。

原因:最常见的是服务器端没调用as_.start()。构造函数里把 auto_start 设为false后,如果忘记手动启动,服务器对象虽然创建了,但五个底层话题根本没有建立。其次是 action 名称不一致,服务器端叫move_to_goal,客户端写成moveto_goal或者带了某个命名空间前缀,两边永远配对不上。

解决:先执行rostopic list | grep move_to_goal,如果输出为空,说明服务器节点没把话题首跳出来,去查服务器代码的as_.start()是否被执行;如果话题存在但客户端仍然超时,把两边的 action 名统一成全局完整名称。

5.2 反馈风暴:10Hz 足够,不要每毫秒 publishFeedback

现象:rostopic hz /move_to_goal/feedback显示上千赫兹,CPU 占用飙升,其他节点响应变慢。

原因:executeCB 里的 while 循环没有rate.sleep(),或者循环很快但每轮都无条件publishFeedback。feedback 是 topic 消息,发布得越频繁,序列化和传输开销越大,积压的 feedback 还会撑大订阅端队列。

解决:给循环加ros::Rate,或者在循环里做节流:

int loop_count = 0; while (distance > 0.01) { loop_count++; if (as_.isPreemptRequested() || !ros::ok()) { as_.setPreempted(); return; } if (loop_count % 5 == 0) { feedback.distance_remaining = distance; as_.publishFeedback(feedback); } rate.sleep(); }

loop_count % 5 == 0意味着每五轮发布一次,配合 10Hz 的循环频率就是 2Hz 的反馈更新,对大多数控制类任务已经够用。反馈是给客户端“看进度”用的,不是控制系统的主反馈通道,别把它当成传感器数据一样狂发。

5.3 取消不生效:执行回调里不检查 isPreemptRequested 就是白搭

现象:客户端调用cancelGoal(),服务器端没有任何反应,任务照样执行到最后,最终返回 SUCCEEDED。

原因:executeCB 里是一个长时间运行的 while 循环,中间只有rate.sleep(),没有调用as_.isPreemptRequested()。SimpleActionServer 无法打断正在执行的 executeCB,取消请求到达后必须等当前回调运行结束才会被处理。如果循环要跑 30 秒,取消请求就等 30 秒。

解决:在每个循环周期开头检查抢占状态,发现抢占请求后立刻setPreempted()并 return。如果任务里包含多个阶段,每个阶段完成、进入下一个阶段前都要检查一次。更细粒度的做法是把任务切成多个子步骤,每个子步骤单独检查一次,这样取消的响应延迟可以控制在单个步骤的执行时长以内。

5.4 头文件生成失败:add_action_files 与 generate_messages 的顺序坑

现象:编译时报fatal error: learning_actions/MoveToGoalAction.h: No such file or directory。

原因:CMakeLists.txt 里add_action_files写在generate_messages之后,或者generate_messages的DEPENDENCIES里漏了actionlib_msgs。另一种可能是一次catkin_make的产物还没生成完整,直接再编客户端的代码也找不到头文件。

解决:把 CMake 配置按前面第 2 章的顺序重写一遍,然后在工作空间执行完整的catkin_make,不要只编译单个目标。编译结束后去devel/include/learning_actions/目录下ls确认头文件确实生成了,再回头编译客户端。这里需要注意的细节是,改了.action文件后,catkin_make有时不会自动重新生成消息头文件,习惯性地删掉devel/和build/里的对应缓存后再编译,能省掉很多莫名的半边红。

6. 调试动作通信最快的姿势:三个终端盯着 status 看状态跳变

动作通信调试,我最常用的方法不是看日志,而是开三个终端直接观察底层话题。先在服务器和客户端都跑起来之前,用一个终端执行rostopic list | grep move_to_goal,能看到五个标准话题:

/move_to_goal/cancel /move_to_goal/feedback /move_to_goal/goal /move_to_goal/result /move_to_goal/status

五个话题全部出现,说明服务器端启动正常。然后一个终端执行rostopic echo /move_to_goal/status,一个终端执行rostopic echo /move_to_goal/feedback。status 话题输出的status字段就是第 2 章表格里的状态码,发送目标后应该看到它从 1(ACTIVE)跳到 3(SUCCEEDED),中间夹杂着 0(PENDING)的短暂停留。如果状态卡在 1 不动,说明执行回调内部阻塞,优先检查服务器端的 while 循环和rate.sleep()。

feedback 话题同步验证进度更新的频率,用rostopic hz /move_to_goal/feedback可以直接测出反馈发布频率,如果显示的数字和代码里ros::Rate设置的不一致,多半是循环里的其他操作耗时太长,或者有多个节点同时向同名 feedback 话题发消息。

多机或同节点多机器人场景下,我会把 action 名称加上命名空间前缀,比如/robot1/move_to_goal、/robot2/move_to_goal,五个话题会自动带上对应前缀。这样rostopic list | grep robot1就能一眼看出哪台设备的状态,不用从一大堆同名话题里靠时间戳去猜数据属于谁。

从那以后,我每次调动作通信,都会强制自己先开这三个终端把 status、feedback、result 挂出来再干活,看到状态码和预想不一致,立即回头查代码,而不是盲目加日志重编。希望这篇文章能帮你把这套动作服务器与客户端的源码跑通,也能少走几个我当年绕过的弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 14:59:35

30V转5V降压芯片方案设计手册:小电流LDO到大电流同步整流全覆盖

输入 30V 转 12V / 5V降压方案设计手册 平芯微 降压/LDO 芯片选型 参考电路 BOM 清单。覆盖电动车电池系统、工业电源、AGV/机器人、LED 驱动辅助电源等场景&#xff0c;一页掌握 6 款方案。 PW2153 PW2815 PW2312B PW2458 PW8600 PW75XX 6款降压 / LDO 芯片方案 150VDC-DC 最…

作者头像 李华
网站建设 2026/10/22 21:40:05

【旁道:偏门真正的圈子】

一个项目&#xff0c;可以放飞 其实一个项目是非常简单的&#xff0c;但是偏门这东西真的就是不敢随便说 我这简单的靠自己的经验&#xff0c;实战带着大家做成了十年的偏门 每年我们都能非常好的非常精准的搞赢 今年也一样好了&#xff0c;大家自己看把

作者头像 李华
网站建设 2026/10/18 20:47:44

Unity开发者必知:VS快捷键导航与调试效率指南

说实话&#xff0c;刚转Unity开发那会儿&#xff0c;我总觉得Visual Studio就是个“稍微高级点的记事本”&#xff0c;写代码全靠鼠标点来点去&#xff0c;一个函数从A文件跳到B文件得来回滚轮&#xff0c;调试的时候更是手忙脚乱。直到有一次&#xff0c;隔壁工位的大哥用快捷…

作者头像 李华
网站建设 2026/10/22 17:26:41

数据走丢困局破解:iLabPower如何打通研发孤岛

周五晚上十点&#xff0c;A 项目的阶段性报告明天要交。林博士没走&#xff0c;他在实验室翻了三个小时的抽屉。他要找的&#xff0c;是一条两年前做过的偶联反应条件——当时活性数据很好&#xff0c;本该写进这次的报告。笔记本在&#xff0c;但那一页被咖啡渍晕开&#xff0…

作者头像 李华
网站建设 2026/10/21 1:01:07

医学图像配准经典方法回顾:从变换模型到优化策略的完整框架

1. 为什么还要回头看2014年以前的医学图像配准1.1 一个被快节奏淹没的领域基石搞医学图像处理的人&#xff0c;现在张口就是深度学习配准、Transformer配准、扩散模型配准。这没错&#xff0c;技术确实在往前跑。但我自己在实际项目里反复遇到一个情况&#xff1a;很多年轻工程…

作者头像 李华
网站建设 2026/10/23 14:16:40

单细胞组间比较进阶:虚拟敲除与Geneformer调控网络分析实战

1. 思路拆解&#xff1a;为什么单细胞组间比较&#xff0c;光看差异表达已经不够用了先聊一个痛点。做单细胞测序组间比较的朋友应该都有体会&#xff0c;常规流程跑完Seurat聚类、找marker、做差异表达&#xff0c;拿到一张火山图、一张热图&#xff0c;审稿人会觉得“你做完了…

作者头像 李华