ROS教程11:从 Service 到 Action——同步请求响应、长任务、Feedback、Cancel 与 actionlib 状态机
摘要:沿 roscpp Service 与 actionlib 源码主线,解释请求响应、CallbackQueue、Action 五个 Topic、Feedback、Result、Cancel 与 Preempt 的真实执行边界。
@[toc]
第 10 章已经把 Topic Node 的运行机制转成了可重复验证证据:纯 C++ 逻辑使用 gtest,ROS Node 组合行为使用 rostest,rqt 用于观察,rosbag 用于固定输入。到这里,Topic 的控制面、TCPROS 数据面、CallbackQueue、Spinner 和测试闭环已经串起来。
阶段 11 不进入 CAN/UART Driver,而是先补齐 ROS1 中另外两种常用通信语义:Service 与 Action。
1 | Topic |
本章继续使用:
1 | ros_ws/src/ros1_comm_lab |
新增:
1 | ros1_comm_lab/ |
阶段 10 已有的 MessageProcessor 保持不变,并被 Service Server 直接复用。这样 Service 实验不会重新复制“乘法、加法、范围检查”逻辑,而是只负责 ROS 接口层。
当前交付环境没有实际连接用户的
ros1-devNoetic Container,因此本文不会伪造 build/test 日志。所有运行命令都给出明确验收点,实际结果应在现有 Docker 环境中执行后回填。
0. 先把 .msg、.srv、.action 放到同一张接口地图里
第 03 章已经通过 ros1_hello/msg/HelloStatus.msg 实际创建过自定义 Topic 消息。阶段 11 再引入 .srv 和 .action 时,最重要的不是再记两个文件后缀,而是把下面两层概念彻底分开:
1 | 接口描述 |
0.1 .msg、.srv、.action 都可以看成 ROS 的 IDL 式接口描述
三者都承担类似 IDL 的职责:使用语言无关的文本声明跨 Node 交换的数据结构,再由 ROS 的生成工具产生 C++、Python 等语言绑定。
| 文件 | 基本结构 | 主要生成内容 | 上层通信语义 |
|---|---|---|---|
.msg |
一组字段 | Message 类型 | Topic 的一条 payload |
.srv |
Request --- Response |
Request / Response / Service 类型 | 一次请求、一次响应 |
.action |
Goal --- Result --- Feedback |
Goal / Result / Feedback 以及 Action 包装类型 | 可反馈、可取消的长任务 |
因此 .srv 很像 RPC 系统里的 IDL,但这个类比只到“描述请求/响应数据契约并生成代码”这一层。它并不意味着 .srv 文件本身定义了一个 TCP 端口,也不意味着 Client 把 .srv 导入后就直接执行一个普通本地函数。
阶段 11 的 TransformValue.srv:
1 | uint32 input |
可以先用函数形式帮助建立直觉:
1 | TransformValue(request) -> response |
但更准确的数据结构是:
1 | Request: |
--- 分隔线上方定义 Request,下方定义 Response。可以把它们暂时类比成“输入/输出”,但 ROS Service 更准确的术语是 Request/Response。这里定义的仍然只是接口数据。
0.2 .srv 不负责端口;真正把接口发布成 Service 的是 advertiseService()
Server 代码中的:
1 | service_ = nh_.advertiseService( |
才把 callback 注册成一个 ROS Service。本项目默认的 Service 名称是:
1 | /comm_lab/transform_value |
这是 ROS graph 中的 name,不是应用手工指定的:
1 | TCP port 12345 |
应用层通常不需要给每个 Service 固定分配端口。可以把调用过程先理解成:
1 | TransformValue.srv |
client.call(service) 从调用形式上很像远程函数调用,但运行时仍然要经过 ROS 的发现、连接和序列化传输:
1 | Client 填充 Request |
后面的 ServiceManager 源码分析会继续把这条简化链拆成 Master 注册/查询、ROSRPC 连接和 CallbackQueue。
0.3 ros1_comm_lab::TransformValue 为什么在源码树里找不到
Client 中有:
1 |
|
这里的 TransformValue 不是本项目手写的 C++ class,所以在 src/ 或 include/ 下搜索不到定义是正常现象。它来自:
1 | srv/TransformValue.srv |
经过 catkin/message generation 后产生的 C++ 代码。
构建之前,源码树里只有:
1 | ros1_comm_lab/ |
执行:
1 | catkin build ros1_comm_lab |
并 source 工作空间后,可以查找生成头文件:
1 | find /workspace/ros_ws/devel -path '*/ros1_comm_lab/TransformValue.h' -print |
在当前 merge-devel 配置下通常位于:
1 | /workspace/ros_ws/devel/include/ros1_comm_lab/TransformValue.h |
如果使用 isolated devel,也可能出现在类似:
1 | devel/.private/ros1_comm_lab/include/ros1_comm_lab/TransformValue.h |
生成代码的具体模板和辅助类型很多,但从使用者角度可以先把它理解成:
1 | TransformValueRequest |
上面只是帮助理解的结构化伪代码,不是生成头文件的逐字源码。于是:
1 | ros1_comm_lab::TransformValue service; |
就可以先理解成一个 Service 调用容器:
1 | service |
所以 Client 才能写:
1 | service.request.input = input; |
第 03 章的:
1 | HelloStatus.msg -> ros1_hello/HelloStatus.h |
和这里的:
1 | TransformValue.srv -> ros1_comm_lab/TransformValue.h |
本质上是同一套“接口描述 → 代码生成”思路,只是生成对象和上层通信语义不同。
0.4 Topic 和 Service 的根本差别不是“有没有 IDL”
容易产生的误解是:
1 | Topic 要自己处理消息 |
实际上 Topic 一样有 .msg,Service 才有 .srv:
1 | Topic -> .msg |
三者都能把接口描述转换成语言类型,并让 ROS 基础设施处理相应的序列化/反序列化与类型协商。真正的区别在通信语义。
Topic 更接近异步数据流或事件模型:
1 | Publisher |
它表达的是:
1 | “这里产生了一条数据或事件,订阅者按需接收。” |
因此很适合:
1 | /camera/image |
从嵌入式视角看,ADC sample、CAN RX frame、IMU sample、encoder position 也更接近 Topic 数据流。
Service 则是一次明确的请求/响应:
1 | Client Server |
它表达的是:
1 | “执行一次操作,并返回这次操作的结果。” |
因此更适合:
1 | /reset |
所以 .srv 的价值并不是“比订阅发布少写传输代码”。.msg 同样让 Topic 用户不必自己维护一套 socket payload 编解码。二者首先是在表达不同的软件交互模型:
1 | Topic = asynchronous stream / event model |
0.5 .srv 是两段,.action 是三段,但 Action 还不止多一个 Feedback
.srv 的语法核心是:
1 | Request |
当前文件:
1 | uint32 input |
.action 的语法核心是:
1 | Goal |
本章 Count.action:
1 | uint32 target |
因此可以先得到:
1 | Service |
但如果只记成“Action = Service + Feedback”,仍然会漏掉 Action 最重要的一部分:
1 | Cancel / Preempt |
Service 的典型调用是:
1 | Request --------------------> |
Action 则允许:
1 | Goal -------------------------------> |
也可以正常完成:
1 | Goal -------------------------------> |
所以更准确的关系是:
1 | Action |
后文会进一步看到:ROS1 actionlib 甚至不是一种独立于 Topic 的底层传输协议,而是在 Topic 机制之上建立的一套长任务协议与状态机。
0.6 先用一个工程判断树建立选择直觉
可以先用下面的顺序判断:
1 | 数据/事件是否持续产生? |
以电机/机械臂接口为例:
1 | 电机当前位置 |
move_to 可能持续数秒甚至更久,因此任务过程中需要:
1 | Goal: |
到这里先建立接口层心智模型。后续章节再分别深入 Service 的 ROSRPC/CallbackQueue,以及 Action 的五类 Topic 与 actionlib 状态机。
1. 先定义 Service:为什么业务失败不应该和传输失败混成一个 bool
文件:
1 | srv/TransformValue.srv |
内容:
1 | uint32 input |
ROS .srv 使用:
1 | Request |
分隔。
当前 Service 的请求只有:
1 | input |
响应明确区分:
1 | success |
这个设计是故意的。
roscpp Service callback 本身也返回一个 bool:
1 | bool callback(Request& req, Response& res) |
但这一层 bool 更接近“这次 Service 请求是否正常生成了一份 Response”。如果业务输入 101 超过 max_input=100,当前实验没有让 callback 返回 false,而是:
1 | response.success = false; |
这样 Client 可以区分:
1 | client.call(...) == false |
以后真正的 Driver Service,例如:
1 | reset_device |
也经常需要区分:
1 | RPC/Transport 失败 |
和:
1 | 设备明确返回“拒绝执行” |
这两个错误层不要混成一个状态。
2. .srv 和 .action 为什么会改变 CMake/package 依赖
阶段 03 已经在 ros1_hello 中通过 HelloStatus.msg 生成过自定义 Topic 消息。阶段 09、10 的 ros1_comm_lab 仍只使用现成的:
1 | std_msgs/UInt32 |
阶段 11 则第一次在 ros1_comm_lab 自己生成 Service 与 Action 接口代码。
CMake 需要加入:
1 | add_service_files( |
同时 find_package(catkin REQUIRED COMPONENTS ...) 增加:
1 | actionlib |
而 catkin_package(CATKIN_DEPENDS ...) 需要把生成接口的运行/导出依赖暴露出去:
1 | actionlib |
对于 package format 2,package.xml 中的生成接口依赖至少要对应为:
1 | <build_depend>message_generation</build_depend> |
其中 message_generation 只在构建时生成代码;message_runtime 既要能被依赖本 package 接口的下游 package 在构建导出阶段看到,也要在运行环境中存在。
这条构建链应该理解成:
1 | TransformValue.srv |
第 0.3 节已经用 TransformValue.h 展示了“接口描述 → 生成 C++ 类型”的结果。这里关注构建系统必须保证的先后关系:使用生成头文件的 C++ target 不能抢在 message generation 之前编译。
所以凡是 include 自己生成接口的 executable,都应该显式依赖:
1 | ${${PROJECT_NAME}_EXPORTED_TARGETS} |
否则并行构建中可能出现:
1 | C++ target 已经开始编译 |
当前项目已经把上述生成接口依赖直接合并到真实 CMakeLists.txt 与 package.xml,并保留阶段 09/10 现有 target。新增 Service/Action executable 和阶段 11 rostest 都显式依赖 ${${PROJECT_NAME}_EXPORTED_TARGETS} 与 ${catkin_EXPORTED_TARGETS},避免并行构建时生成 header 尚未完成。
3. Service Server:API 层只有 advertiseService(),背后仍然进入 ServiceManager
Server 的主入口:
1 | service_ = nh_.advertiseService( |
业务 callback:
1 | bool handleRequest( |
当前 Node 最终仍然是:
1 | ros::spin(); |
所以第一条关键结论是:
Service callback 不是一个脱离 CallbackQueue/Spinner 的特殊执行世界。
AdvertiseServiceOptions 本身具有:
1 | callback_queue |
没有指定时使用 global CallbackQueue。
因此当前 Server 的用户 callback 仍然需要:
1 | 请求到达 |
阶段 09 学过的“Spinner worker 数量”和“callback 阻塞”仍然成立。
4. ServiceManager::advertiseService():Master 中登记的不是 Node XML-RPC URI
沿 Noetic roscpp 源码继续:
1 | NodeHandle::advertiseService() |
源码:
1 | ros_comm/clients/roscpp/src/libros/service_manager.cpp |
ServiceManager::advertiseService() 创建 ServicePublication 后,会构造:
1 | rosrpc://<host>:<ConnectionManager TCP port> |
然后调用 Master:
1 | registerService |
注册参数中同时存在:
1 | service name |
这和 Topic 注册有一个重要区别。
Topic 注册主要让 Master 记录:
1 | 某个 Node XML-RPC URI 发布了某个 Topic |
Subscriber 后续还要:
1 | Publisher Node XML-RPC requestTopic |
而 Service Server 在 registerService 时就直接把:
1 | rosrpc://host:port |
登记给 Master。
因此 Service Client 后续 lookupService 拿到的就是 Service 数据连接入口,而不是先拿 Node XML-RPC URI、再执行一次 requestTopic。
可以压缩成:
1 | flowchart LR |
Master 仍然只负责控制面发现,不转发 Service Request/Response payload。
5. Service Client:ServiceClient::call() 为什么天然是阻塞接口
客户端:
1 | ros::ServiceClient client = |
真正发起请求:
1 | if (!client.call(service)) |
Noetic ServiceClient API 明确把 call() 定义为同步调用。
源码继续进入:
1 | ServiceClient::call() |
如果当前没有可复用连接:
1 | ServiceManager::lookupService() |
这里的命名第一次看会比较反直觉:
1 | Client 进程:ServiceServerLink |
它和第 08 章:
1 | Subscriber 进程中的 TransportPublisherLink |
是同一种命名思路:Link 名称描述远端角色。
ServiceServerLink::call() 会等待当前请求完成。即使已有另一个线程正在同一条 persistent Service 连接上 call,它也会排队并继续阻塞等待自己的结果。
所以:
1 | Service Client 当前线程 |
这不是 Topic Publisher 的:
1 | publish() |
6. waitForExistence() 为什么不是多余代码
阶段 11 Client 在调用前:
1 | if (!client.waitForExistence(ros::Duration(2.0))) |
这和阶段 10 的集成测试“先等 Topic 连接再发送”是同一类工程问题:不要把发现和网络建立过程假设成瞬时完成。
测试/启动刚开始时可能出现:
1 | Client 已经启动 |
此时立刻 call 会把:
1 | 启动竞态 |
和:
1 | 业务调用失败 |
混在一起。
waitForExistence() 会检查 Service 是否已经 advertise 且实际可联系。
它适合依赖某个 Service 才能继续初始化的场景,但不要无限等待而完全没有 shutdown/timeout 设计。当前实验固定 2 s,只是测试与学习环境中的明确上限,不应被直接解释成未来 Driver 产品超时契约。
7. Service callback 阻塞时到底会拖住谁
service_action_lab.launch 提供:
1 | response_delay |
默认:
1 | 0.0 |
可以实验:
1 | roslaunch ros1_comm_lab service_action_lab.launch \ |
再:
1 | rosservice call /comm_lab/transform_value "input: 7" |
Server callback 内:
1 | ros::WallDuration(response_delay_).sleep(); |
当前 Server 使用:
1 | ros::spin(); |
因此这两秒占住的是当前 global CallbackQueue 的唯一用户 callback worker。
如果同一个 Node 未来还增加:
1 | Subscriber |
而它们也使用同一个 global CallbackQueue,就可能被这个 Service callback 推迟。
所以阶段 09 的结论可以直接迁移到 Service:
1 | “Service 是请求响应” |
需要并发时,应明确选择:
1 | MultiThreadedSpinner |
而不是把一个长阻塞硬件操作直接塞进单线程 Service callback 后,再假设其它 ROS callback 不受影响。
8. 先运行 Service 实验
当前项目已完成 CMake/package 合并,构建时执行:
1 | cd /workspace/ros_ws |
启动:
1 | roslaunch ros1_comm_lab service_action_lab.launch |
查看:
1 | rosservice list |
应出现:
1 | /comm_lab/transform_value |
查看类型:
1 | rosservice type /comm_lab/transform_value |
预期:
1 | ros1_comm_lab/TransformValue |
查看 .srv:
1 | rossrv show ros1_comm_lab/TransformValue |
直接调用合法输入:
1 | rosservice call /comm_lab/transform_value "input: 7" |
预期业务结果:
1 | success: true |
越界:
1 | rosservice call /comm_lab/transform_value "input: 101" |
预期:
1 | success: false |
再运行 C++ Client:
1 | rosrun ros1_comm_lab transform_service_client 7 |
这里才真正走到:
1 | ServiceClient::call() |
后续使用 GDB 可以从这一行继续追 ServiceManager 和 ServiceServerLink。
9. Action 为什么存在:Service 不适合表达“运行 20 秒还能汇报进度和取消”
如果一个操作是:
1 | 设备复位 |
Service 很自然。
但如果任务是:
1 | 机械臂执行轨迹 20 s |
只用 Service 会遇到三个问题:
1 | Client 长时间阻塞 |
Action 就是为这种“有生命周期的任务”设计的。
本章 Action 只做计数,故意不引入设备逻辑:
1 | Goal: |
文件:
1 | action/Count.action |
内容:
1 | uint32 target |
三个区段依次是:
1 | Goal |
10. Action 不是第四种 ROS 传输协议:它实际上展开成五类 Topic
Service 的核心实现仍在已经准备好的 ros_comm 源码树中;Action 的实现位于独立的 ros/actionlib 仓库。为了继续使用前面相同的 F12/源码阅读工作流,可以把 Noetic 源码放到 ros_debug_ws/src:
1 | cd /workspace/ros_debug_ws/src |
CATKIN_IGNORE 用于明确告诉当前 debug workspace:这份 checkout 先只承担源码阅读,不参与后续 catkin build 的 package 发现。这样不会因为阶段 11 只是想 F12 阅读 actionlib,就无意中改变现有 Debug overlay 的构建集合。后续如果确实要自己编译 actionlib,再有意识地删除该文件并重新定义 overlay 验证范围。
Clone 后重点源码位于:
1 | ros_debug_ws/src/actionlib/actionlib/include/actionlib/server/simple_action_server.h |
在当前 VS Code 配置已经递归索引 ros_debug_ws/src/** 的前提下,这些文件可以继续作为 F12 和源码阅读目标。
这是阶段 11 最重要的结论之一。
Action 的上层 API 看起来是:
1 | sendGoal() |
但 actionlib 最终使用的是 ROS Topic。
一个 Action 名称:
1 | /comm_lab/count |
会形成典型接口:
1 | /comm_lab/count/goal |
关系可以先看成:
1 | flowchart LR |
所以 Action 数据最终仍然走前面已经掌握的:
1 | Topic 注册发现 |
actionlib 的价值不是“发明新 Transport”,而是在多个 Topic 之上建立:
1 | Goal ID |
等一致语义。
11. SimpleActionServer:为什么长时间 execute callback 不会直接占住 ros::spin() 那条线程
Server:
1 | actionlib::SimpleActionServer<CountAction> server_; |
构造:
1 | server_( |
注意最后:
1 | auto_start = false |
随后构造完成后再:
1 | server_.start(); |
Noetic SimpleActionServer 官方 API 也明确建议 auto_start=false,避免构造过程中的启动竞态。
更关键的是,当使用 ExecuteCallback 形式时,SimpleActionServer 内部会创建:
1 | execute_thread_ |
它的 executeLoop() 在独立线程调用阻塞式 execute callback。
因此当前 Node 存在两个不同执行面:
1 | main thread |
这解释了为什么示例中的:
1 | ros::WallDuration(step_period_).sleep(); |
可以放在 execute callback 里,而不会等同于直接在 main thread 的 ros::spin() callback 中 sleep。
但不要把它泛化成:
1 | “所有 Action callback 都自动在独立线程” |
这里讨论的是 SimpleActionServer 的 execute-callback 模式。底层 ActionServer 的 Goal/Cancel Topic callback 仍然依赖 ROS callback 处理。
因此 main() 仍然需要:
1 | ros::spin(); |
如果完全不 spin,Action Server 并不会因为“有 execute_thread_”就自动收到 Goal/Cancel。
12. SimpleActionClient(..., true):Client 为什么自己还有一条 spin thread
客户端:
1 | actionlib::SimpleActionClient<ros1_comm_lab::CountAction> |
第二个参数:
1 | spin_thread = true |
Noetic C++ SimpleActionClient 在这个模式下会建立自己的:
1 | CallbackQueue |
并创建:
1 | spin_thread_ |
线程循环执行:
1 | callback_queue.callAvailable(...); |
这使一个“只想同步等待 Action 结果”的 CLI Client 可以写成:
1 | client.sendGoal(...); |
而不需要 main thread 另外执行:
1 | ros::spin(); |
因为 Goal 的 status/feedback/result 对应订阅 callback 已经由 SimpleActionClient 自己的 spin thread 消费。
如果构造时传:
1 | false |
则用户必须自己保证 ROS callback 被执行。
这一点和阶段 09 的核心原则完全一致:
消息到达进程不等于 callback 自动执行,最终仍然要有某个线程消费 CallbackQueue。
13. Goal 从 Client 到 Server 后发生什么
正常执行:
1 | rosrun ros1_comm_lab count_action_client 5 |
Client:
1 | CountGoal goal; |
Server 的 execute thread 最终进入:
1 | void execute(const CountGoalConstPtr& goal) |
循环:
1 | count = 1 |
最后:
1 | server_.setSucceeded(result); |
Client 的状态机进入终态:
1 | SUCCEEDED |
同时:
1 | client.getResult() |
得到:
1 | completed = true |
这里 Feedback 和 Result 语义必须分开:
1 | Feedback |
不要用高频 Feedback 替代本来应该是普通连续 Topic 的传感器数据。Action Feedback 应该服务于“当前任务进度/阶段”,不是把 Action 当成通用流式数据通道。
14. Cancel 为什么最终表现为 Preempt
阶段 11 Client 支持第二个参数:
1 | cancel_after_sec |
例如:
1 | rosrun ros1_comm_lab count_action_client 100 0.5 |
表示:
1 | 发送 target=100 Goal |
cancelGoal() 不会跨进程直接调用 Server 的一个 C++ 函数。
它通过 Action 的:
1 | /cancel |
Topic 发送 GoalID 取消请求。
Server 的 actionlib 层收到后,把当前 Goal 标记为:
1 | preempt requested |
当前 execute callback 在每个工作步骤前检查一次,并在模拟阻塞步骤返回后再次检查:
1 | if (finishIfPreempted(count)) |
第二次检查不是重复代码。它覆盖“Cancel 恰好在阻塞步骤执行期间到达”的窗口,避免最后一个工作步骤完成后错误地继续进入 setSucceeded()。
这里还有一个状态边界:如果取消请求在 Goal 进入 ACTIVE 之前就生效,Client 可能观察到 RECALLED;如果当前 Goal 已经 ACTIVE,本实验的正常取消路径应进入 PREEMPTED。命令行 Client 因此把两种“取消已生效”的终态都视为取消演示成功,而自动测试会先等待 ACTIVE 再取消,从而只断言 PREEMPTED。
所以实际链路是:
1 | flowchart TD |
这说明 Cancel 不是“杀掉 Server 线程”。
正确的长任务必须具备合作式取消点:
1 | 执行一个小步骤 |
如果 execute callback 进入一个无法中断的:
1 | 30 s blocking device ioctl/read |
即使 Cancel Topic 已经到达,任务也可能很久以后才有机会调用:
1 | isPreemptRequested() |
所以 Action 提供的是取消协议和状态机,不会魔法般把任意阻塞 I/O 变成可即时取消操作。
这对未来 Driver 设计非常重要。
15. Action 新 Goal 与 Preempt 的关系
SimpleActionServer 实现的是 single-goal policy:同一时间只把一个 Goal 视为 active。
当新的、更晚 Goal 被接受时,旧 active Goal 会进入 preempt 语义。
因此 Action 更像:
1 | 一个有身份的任务状态机 |
而不是:
1 | 随便开 N 条后台线程处理 N 个请求 |
如果工程需求本身允许多个独立任务同时执行,就不能只因为 actionlib 有 Goal 就默认 SimpleActionServer 满足需求,需要重新选择任务模型或使用更底层的 ActionServer 设计。
16. 正常运行 Action 实验时观察什么
启动 Server:
1 | roslaunch ros1_comm_lab service_action_lab.launch \ |
查看 Action 展开的 Topic:
1 | rostopic list | grep '^/comm_lab/count' |
应该能观察到:
1 | /comm_lab/count/cancel |
然后:
1 | rosrun ros1_comm_lab count_action_client 5 |
客户端应依次打印 Feedback,并最终得到:
1 | state=SUCCEEDED |
取消实验:
1 | rosrun ros1_comm_lab count_action_client 100 0.5 |
预期最终状态:
1 | PREEMPTED |
并且:
1 | final_count < 100 |
这两组运行现象分别验证:
1 | Goal -> Feedback -> Result |
与:
1 | Goal -> Cancel -> Preempted Result |
17. 阶段 11 的 rostest 为什么同时覆盖 Service 和 Action
阶段 10 已经建立:
1 | rostest + gtest |
阶段 11 继续复用,不重新引入测试框架。
.test 同时启动:
1 | transform_service_server |
测试 executable:
1 | test_service_action |
覆盖四个行为。
Service 正常输入
1 | input = 7 |
断言:
1 | call() == true |
Service 业务拒绝
1 | input = 101 |
断言:
1 | call() == true |
这里专门保护前面定义的“Transport/RPC 成功”和“业务拒绝”边界。
Action 正常完成 + Feedback
1 | target = 5 |
断言:
1 | terminal state == SUCCEEDED |
Action Cancel
发送:
1 | target = 1000 |
测试先等 Client 观察到:
1 | ACTIVE |
再:
1 | cancelGoal(); |
最后断言:
1 | terminal state == PREEMPTED |
取消测试没有把“睡固定 500 ms 后一定已经 ACTIVE”写成核心前提,而是先轮询 Goal 状态进入 ACTIVE,再发 cancel,减少测试把调度时序偶然性当成业务契约的问题。
18. 在当前 Docker 环境中完成阶段 11 的真实验证
当前项目已经完成源码、接口定义以及 CMakeLists.txt / package.xml 集成;在现有 Noetic Container 中执行:
1 | cd /workspace/ros_ws |
然后:
1 | source devel/setup.bash |
先确认生成接口:
1 | rossrv show ros1_comm_lab/TransformValue |
以及:
1 | rosmsg show ros1_comm_lab/CountGoal |
再运行测试:
1 | catkin run_tests ros1_comm_lab |
测试目录应新增阶段 11 对应的 rostest/gtest XML。
由于当前交付环境没有运行用户的 Noetic Container,本稿的证据状态严格保持为:
1 | 阶段 11 源码文件 已生成 |
完成用户环境实测后,再把真实日志和截图回填到公开稿,不能把“代码看起来正确”改写成“已经验证通过”。
19. Topic、Service、Action 到底怎样选
到这里可以确认:三者的差异不是“哪一种更高级”或“哪一种少写代码”,而是它们表达的业务语义不同。
| 需求 | Topic | Service | Action |
|---|---|---|---|
| 持续传感器流 | 适合 | 不适合 | 通常不适合 |
| 一次命令 + 一次响应 | 可以自建协议,但冗余 | 适合 | 通常过重 |
| 长任务 | 需要自己设计任务状态协议 | Client 长时间阻塞且缺少标准进度/取消 | 适合 |
| 中间进度 | 天然可流式,但没有 Goal 绑定 | 无标准 Feedback | 内建 Feedback |
| 取消当前任务 | 需要自己定义 | 需要另一个接口/协议 | 内建 Cancel/Preempt |
| 多消费者 | 天然适合 | 请求响应语义不是广播 | Action 状态围绕 Goal 生命周期组织 |
工程上可以先按下面的判断链选择:
1 | 这是持续数据/事件吗? |
典型 Driver 接口因此可以拆成:
1 | Topic |
这套分类同时解释了为什么不能把 .srv 只理解成“自动生成传输代码的 IDL”,也不能把 Action 只理解成“Service 多了 Feedback”。
1 | .msg / .srv / .action |
两层概念需要同时成立,才能在后面的 Driver API 设计里选对抽象。
20. 阶段 06~11 现在形成了怎样的完整模型
阶段 06:
1 | 进程装载 |
阶段 07:
1 | Master |
阶段 08:
1 | TCPROS |
阶段 09:
1 | SubscriptionQueue |
阶段 10:
1 | gtest |
阶段 11:
1 | Service |
所以阶段 11 结束以后,ROS1 通信机制的基础主线基本闭合。
下一阶段再进入 Driver 工程时,就可以从“ROS API 怎么写”转向更重要的问题:
1 | ROS Interface |
并开始处理:
1 | 线程模型 |
而不是一边写真实 CAN/UART Driver,一边再补 Topic/Service/Action 的基础概念。
参考源码与官方资料
- roscpp
service_manager.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/service_manager.cpp - roscpp
service_client.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/service_client.cpp - roscpp
service_server_link.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/service_server_link.cpp - roscpp
service_client_link.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/service_client_link.cpp - roscpp
service_publication.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/service_publication.cpp - roscpp Noetic API
ServiceClient:https://docs.ros.org/en/noetic/api/roscpp/html/classros_1_1ServiceClient.html - actionlib Noetic
SimpleActionServer:https://docs.ros.org/en/noetic/api/actionlib/html/classactionlib_1_1SimpleActionServer.html - actionlib Noetic source:https://github.com/ros/actionlib/tree/noetic-devel/actionlib
- ROS Wiki CreatingMsgAndSrv:https://wiki.ros.org/ROS/Tutorials/CreatingMsgAndSrv
- ROS Wiki DefiningCustomServices:https://wiki.ros.org/ROS/Tutorials/DefiningCustomServices
- ROS Wiki Services:https://wiki.ros.org/Services
- ROS Wiki actionlib:https://wiki.ros.org/actionlib
ROS1 Noetic 已于 2025-05-31 EOL,ros/ros_comm 等上游仓库已归档。本系列固定阅读 Noetic 最终实现,不把第三方 fork 行为混入主线。









