ROS2 C++ 小车控制完整实战(四):自定义 Action 通信保姆级教程

目录

摘要

功能包完整目录:

[一、为什么还需要 Action 通信?](#一、为什么还需要 Action 通信?)

[1.1 前面三种通信方式分别解决了什么?](#1.1 前面三种通信方式分别解决了什么?)

[(1)Topic 通信](#(1)Topic 通信)

[(2)自定义 msg 通信](#(2)自定义 msg 通信)

[(3)自定义 srv 通信](#(3)自定义 srv 通信)

[1.2 Topic、Service、Action 到底有什么区别?](#1.2 Topic、Service、Action 到底有什么区别?)

(1)Topic:一直持续,但是没有反馈

(2)Service:一次请求,一次处理,一次返回结果

[(3)Service 是否一定立马返回?](#(3)Service 是否一定立马返回?)

(4)Action:发送一个目标,过程中持续反馈,最后返回结果

(5)更严谨地理解三种通信方式

[1.3 Service 与 Action](#1.3 Service 与 Action)

[(1)Service 的局限性](#(1)Service 的局限性)

[(2)Action 适合什么场景?](#(2)Action 适合什么场景?)

[二、自定义 Action 接口文件](#二、自定义 Action 接口文件)

[2.1 Action 文件放在哪里?](#2.1 Action 文件放在哪里?)

[2.2 LimoAction.action 文件内容](#2.2 LimoAction.action 文件内容)

(1)Goal:目标部分

(2)Result:结果部分

(3)Feedback:反馈部分

[三、修改 limo_msgs 接口包配置](#三、修改 limo_msgs 接口包配置)

[3.1 修改 CMakeLists.txt](#3.1 修改 CMakeLists.txt)

[3.2 修改 package.xml](#3.2 修改 package.xml)

[3.3 编译接口包](#3.3 编译接口包)

[四、Action 服务端代码](#四、Action 服务端代码)

[4.1 Action 服务端的作用](#4.1 Action 服务端的作用)

[4.2 新建服务端文件](#4.2 新建服务端文件)

[五、Action 客户端代码](#五、Action 客户端代码)

[5.1 Action 客户端的作用](#5.1 Action 客户端的作用)

[5.2 新建客户端文件](#5.2 新建客户端文件)

[六、修改 limo_learning 功能包](#六、修改 limo_learning 功能包)

[6.1 正常功能包目录:](#6.1 正常功能包目录:)

[6.2 修改 limo_learning 的 CMakeLists.txt](#6.2 修改 limo_learning 的 CMakeLists.txt)

[(1)添加 action 依赖](#(1)添加 action 依赖)

(2)添加可执行文件

(3)添加安装配置

[6.3 修改 package.xml](#6.3 修改 package.xml)

七、编译与运行

[7.1 编译功能包](#7.1 编译功能包)

[7.2 先启动 Action 服务端](#7.2 先启动 Action 服务端)

[7.3 再启动 Action 客户端](#7.3 再启动 Action 客户端)

[7.4 查看 Action 列表](#7.4 查看 Action 列表)

[7.5 查看 Action 信息](#7.5 查看 Action 信息)

[7.6 使用命令行发送 Action 目标](#7.6 使用命令行发送 Action 目标)

[八、Topic、Service、Action 对比总结](#八、Topic、Service、Action 对比总结)

[8.1 Topic 通信](#8.1 Topic 通信)

[8.2 Service 通信](#8.2 Service 通信)

[8.3 Action 通信](#8.3 Action 通信)

[8.4 三者对比表](#8.4 三者对比表)

九、本篇总结


摘要

前面几篇文章已经分别详细讲解了 ROS2 C++ 小车控制中的几种常见通信方式:

第一篇主要讲解:Topic + 官方标准 msg:

ROS2 C++ 小车控制完整实战(一):一文搞懂 Topic 发布订阅、Twist 消息与 /cmd_vel 速度控制-CSDN博客https://blog.csdn.net/m0_58954356/article/details/161829804?spm=1001.2014.3001.5502也就是使用ROS2 官方已经定义好的消息类型,例如:

geometry_msgs/msg/Twist

通过 /cmd_vel 话题发布速度指令,实现对小车前进、后退、转向等基础运动控制。

第二篇主要讲解:Topic + 自定义 msg:

ROS2 C++ 小车控制完整实战(二):自定义 msg 消息发布与订阅保姆级教程-CSDN博客https://blog.csdn.net/m0_58954356/article/details/161834874?spm=1001.2014.3001.5502我们自己定义了 LimoStatus.msg,用来发布小车状态信息,例如:

电池电压、车辆状态、控制模式、运动模式、错误码等。

第三篇主要讲解:Service + 自定义 srv:

ROS2 C++ 小车控制完整实战(三):自定义 srv 服务通信保姆级教程-CSDN博客https://blog.csdn.net/m0_58954356/article/details/161834909?spm=1001.2014.3001.5502也就是客户端发送一次请求,服务端处理后返回一次响应。例如客户端发送 x、y、z 速度参数,服务端收到后控制小车运动,并返回 success 表示是否执行成功。

但是 Service 更适合"短时间、一次性、有明确返回结果"的任务。

如果任务执行时间比较长,比如:

  1. 让小车持续运动一段时间
  2. 让小车执行导航任务
  3. 让小车执行巡检任务
  4. 让小车执行一个可以反馈进度的控制任务

这时候就更适合使用 Action 通信。

本篇文章就继续在前面 Topic、msg、srv 的基础上,讲解 ROS2 中更适合长时间任务的通信方式:

自定义 Action 通信。


需要说明的是:

本篇重点先放在 Action 通信的整体流程和工程配置上,让大家先知道 Action 是什么、Action 文件怎么写、功能包怎么配置、代码怎么编译运行、终端命令怎么测试。

至于 Action 服务端和客户端代码中每一行到底是什么意思,例如 create_server、create_client、GoalHandle、handle_goal、handle_cancel、handle_accepted、execute、feedback_callback、result_callback 等核心内容,会放到下一篇文章中单独进行详细拆解。


功能包完整目录:

bash 复制代码
ros2_ws/
└── src
    ├── limo_msgs
    │   ├── msg
    │   │   └── LimoStatus.msg
    │   ├── srv
    │   │   └── LimoSrv.srv
    │   ├── action
    │   │   └── LimoAction.action
    │   ├── CMakeLists.txt
    │   └── package.xml
    │
    └── limo_learning
        ├── src
        │   ├── limo_topic_cmd.cpp
        │   ├── limo_topic_sub.cpp
        │   ├── limo_srv_server.cpp
        │   ├── limo_srv_client.cpp
        │   ├── limo_action_server.cpp
        │   └── limo_action_client.cpp
        ├── CMakeLists.txt
        └── package.xml

这里有两个包:

功能包 作用
limo_msgs 定义自定义接口:msg、srv、action
limo_learning 编写 C++ 节点代码:Topic、Service、Action 服务端和客户端

一、为什么还需要 Action 通信?

1.1 前面三种通信方式分别解决了什么?

在正式写 Action 代码之前,我们先回顾一下前面几篇文章中已经使用过的 ROS2 通信方式。

前面主要讲了三部分内容:

  1. Topic 通信
  2. 自定义 msg 通信
  3. 自定义 srv 通信

这三种通信方式各自解决的问题并不一样。

(1)Topic 通信

Topic 通信适合持续不断地发布数据。

例如:

小车速度控制:

复制代码
/cmd_vel

小车状态发布:

复制代码
/limo_status

雷达数据发布:

复制代码
/scan

Topic 的特点是:

  1. 发布者只管发布,订阅者只管接收。
  2. 发布者不关心订阅者有没有收到,也不等待返回结果。

所以 Topic 适合:

  • 持续发布速度
  • 持续发布状态
  • 持续发布传感器数据

Topic 就像广播。


(2)自定义 msg 通信

自定义 msg 的作用是:

ROS2 官方提供的消息类型不够用时,我们可以自己定义消息结构。

例如前面我们定义过:

复制代码
LimoStatus.msg

里面包含:

复制代码
std_msgs/Header header
uint8 vehicle_state
uint8 control_mode
uint8 motion_mode
float64 battery_voltage
uint16 error_code

这样就可以把小车状态封装成一个完整的自定义消息,然后通过 Topic 发布出去。

所以:

msg 解决的是"传什么数据"的问题。


(3)自定义 srv 通信

Service 通信适合一次请求、一次响应。

例如前面我们定义过:

复制代码
LimoSrv.srv

内容类似:

复制代码
float32 x
float32 y
float32 z
---
bool success

客户端发送请求:

复制代码
x = 0.2
y = 0.0
z = 0.0

服务端收到请求后,转换成 Twist 速度指令发布到 /cmd_vel,然后返回:

复制代码
success = true

所以 Service 适合:

  1. 客户端请求一次
  2. 服务端处理一次
  3. 服务端返回一次结果

Service 就像"问一句,答一句"。


1.2 Topic、Service、Action 到底有什么区别?

在正式讲 Action 之前,一定要先把 ROS2 中最常见的三种通信方式区分清楚:

  1. Topic
  2. Service
  3. Action

三种通信方式都可以让 ROS2 节点之间进行数据交互,但是它们适合的场景完全不一样。

可以先用一张表简单对比:

通信方式 是否持续通信 是否有反馈 / 结果 适合场景
Topic 持续发布 / 持续接收 没有返回结果 速度、雷达、状态、里程计
Service 一次请求 / 一次响应 有最终响应 查询、开关、短时间任务
Action 一个目标 / 持续反馈 / 最终结果 有过程反馈,也有最终结果 导航、巡检、长时间任务

简单来说:

  1. Topic 适合持续数据流。
  2. Service 适合一次性请求。
  3. Action 适合长时间任务。

(1)Topic:一直持续,但是没有反馈

  1. Topic 通信就像广播。
  2. 发布者 一直向某个话题发布数据,订阅者只要订阅了这个话题,就可以持续接收数据。

例如:

复制代码
发布者 → /cmd_vel → 订阅者

发布者只管发布,订阅者只管接收。

Topic 的特点是:

  1. 持续通信
  2. 异步通信
  3. 没有返回结果

比如在小车控制中:

复制代码
/cmd_vel        持续发送速度指令
/scan           持续发送雷达数据
/odom           持续发送里程计数据
/limo_status    持续发送小车状态

这些数据都适合用 Topic。

因为它们不是问答关系,而是持续不断地发布和接收。

一句话理解:

Topic 就是一直发、一直收,但是不返回结果。


(2)Service:一次请求,一次处理,一次返回结果

  1. Service 通信更像去窗口办业务。
  2. 客户端先提出一个请求,服务端收到请求后进行处理,处理完成后再返回响应结果。

整体流程是:

复制代码
客户端发送请求
        ↓
服务端收到请求
        ↓
服务端处理请求
        ↓
服务端返回响应结果
        ↓
客户端拿到结果

所以 Service 不是一直通信,而是:

请求一次,处理一次,返回一次。

例如前面自定义 srv 通信中,客户端发送:

复制代码
x = 0.2
y = 0.0
z = 0.0

服务端收到请求后,把它转换成 Twist 速度指令,然后发布到:

复制代码
/cmd_vel

最后返回:

复制代码
success = true

这就是典型的 Service 通信。

一句话理解:

Service 就是客户端问一次,服务端处理一次,然后返回一次结果。


(3)Service 是否一定立马返回?

这里是很多初学者最容易混淆的地方。

Service 不是一定"立马返回",而是:

服务端处理完这一次请求后,才会返回结果。

也就是说,Service 返回快还是慢,取决于服务端回调函数里面到底做了多少事情。

比如客户端请求:

让小车前进 1 秒。

如果服务端代码里面这样写:

复制代码
收到请求
发布速度
等待 1 秒
停止小车
返回 success = true

那么客户端就需要等服务端这一整套流程执行完,才能拿到返回结果。

但是如果服务端代码这样写:

复制代码
收到请求
发布一次速度
立刻返回 success = true

那么客户端就会很快收到响应结果。

所以关键不在于 Service 本身,而在于:

服务端回调函数里面到底执行了多长时间。

可以这样理解:

Service 返回的时机 = 服务端处理完这一次请求并设置 response 的时机

  1. 因此,Service 更适合短时间任务。
  2. 如果任务执行时间很长,客户端一直等待响应就不太合适了。

(4)Action:发送一个目标,过程中持续反馈,最后返回结果

Action 通信适合长时间任务。

它不是简单的"一问一答",而是:

复制代码
客户端发送目标 Goal
        ↓
服务端开始执行任务
        ↓
执行过程中不断发送 Feedback
        ↓
任务完成后返回 Result

比如导航任务:

复制代码
目标:去 A 点
反馈:当前完成 20%
反馈:当前完成 50%
反馈:当前完成 80%
结果:到达成功

Action 和 Service 最大的区别。

  1. Service 只有最终响应。
  2. Action 不仅有最终结果,还可以在执行过程中持续反馈状态。

Action 的特点是:

  1. 可以执行长时间任务
  2. 中途可以持续反馈
  3. 最后有最终结果
  4. 任务中途还可以取消

Action 就是发送一个目标,服务端慢慢执行,执行过程中不断反馈,最后返回最终结果。


(5)更严谨地理解三种通信方式

  1. Topic 是异步持续通信,发布者和订阅者互不等待,也没有返回结果。
  2. Service 通常用于一次性请求-响应通信,客户端发送请求后,等待服务端处理完成并返回响应结果,适合短时间任务。
  3. Action 是异步的目标任务通信,客户端发送目标后,服务端可以在执行过程中持续返回反馈,任务完成后再返回最终结果,适合长时间任务。

结合小车控制来理解:

复制代码
Topic:持续发送 /cmd_vel 速度控制小车
Service:请求小车执行一次简单动作,然后返回是否成功
Action:请求小车执行一个长时间任务,过程中反馈状态,完成后返回最终结果

到这里就可以自然引出本篇文章的重点:

当我们希望小车执行一个持续一段时间的任务,并且希望在执行过程中不断收到状态反馈时,就应该使用 Action 通信。


1.3 Service 与 Action

(1)Service 的局限性

Service 虽然很适合一次性请求,但是它不太适合长时间任务。

比如现在有一个需求:

让小车向前运动 10 秒,并且执行过程中不断反馈当前执行状态。

如果使用 Service,就会出现一个问题:

客户端发送请求后,只能等待服务端最终返回结果。

在任务执行过程中,客户端很难持续知道:

  1. 当前执行到哪一步了
  2. 小车是否还在运动
  3. 任务有没有中途失败
  4. 任务进度是多少

也就是说,Service 只适合短任务,不适合长任务。


(2)Action 适合什么场景?

Action 通信就是为长时间任务设计的。

Action 比 Service 多了一个非常重要的能力:

可以在任务执行过程中持续反馈状态。

Action 通信一般包含三部分:

  1. 目标 Goal
  2. 反馈 Feedback
  3. 结果 Result

可以理解为:

  1. 客户端先发送一个任务目标
  2. 服务端开始执行任务
  3. 执行过程中不断返回反馈信息
  4. 任务结束后返回最终结果

例如:

客户端发送目标:

让小车以 x=0.2 的速度前进

服务端执行过程中持续反馈:

  1. 当前状态 status = 1
  2. 当前状态 status = 2
  3. 当前状态 status = 3

任务执行完成后返回结果:

success = true

所以 Action 更适合:

  1. 导航任务
  2. 巡检任务
  3. 机械臂执行任务
  4. 小车长时间运动控制
  5. 需要中途反馈进度的任务

一句话总结:

Action 适合"执行时间较长,并且需要反馈过程状态"的任务。


二、自定义 Action 接口文件

2.1 Action 文件放在哪里?

前面我们已经创建过接口功能包:

复制代码
limo_msgs

这个功能包中可以放:

复制代码
msg
srv
action

对应三种自定义接口:

cpp 复制代码
limo_msgs/
├── msg
│   └── LimoStatus.msg
├── srv
│   └── LimoSrv.srv
├── action
│   └── LimoAction.action
├── CMakeLists.txt
└── package.xml

本篇要新增的就是:

复制代码
action/LimoAction.action

2.2 LimoAction.action 文件内容

创建文件:

复制代码
limo_msgs/action/LimoAction.action

写入内容:

复制代码
float32 x
float32 y
float32 z
---
bool success
---
uint32 status

这个 action 文件中有三个部分,中间用两个 --- 分隔。


(1)Goal:目标部分

第一部分是 Goal,也就是客户端发送给服务端的目标。

复制代码
float32 x
float32 y
float32 z

这里的含义是:

x:小车线速度 x 方向

y:小车线速度 y 方向

z:小车角速度 z 方向

后面服务端会把它们转换成:

复制代码
geometry_msgs::msg::Twist

对应关系是:

复制代码
twist.linear.x = goal->x;
twist.linear.y = goal->y;
twist.angular.z = goal->z;

也就是说:

客户端通过 Action 发送目标速度,服务端根据目标速度控制小车运动。


(2)Result:结果部分

第二部分是 Result,也就是任务执行完成后,服务端返回给客户端的最终结果。

复制代码
bool success

这里表示任务是否执行成功。

如果任务正常完成:

复制代码
success = true

如果任务执行失败:

复制代码
success = false

(3)Feedback:反馈部分

第三部分是 Feedback,也就是任务执行过程中,服务端持续反馈给客户端的状态。

复制代码
uint32 status

这里可以表示当前执行状态。

例如:

复制代码
status = 1
status = 2
status = 3
status = 4
status = 5

表示任务正在一步步执行。

当然,在真实项目中,status 也可以设计成更具体的含义,例如:

  • 0:空闲
  • 1:正在执行
  • 2:任务暂停
  • 3:任务完成
  • 4:任务失败

本篇为了方便理解,先用 status 表示执行进度。


三、修改 limo_msgs 接口包配置

3.1 修改 CMakeLists.txt

前面自定义 msg 和 srv 时,已经配置过 rosidl_generate_interfaces。

现在需要把 action 文件也加入进去。

打开:

复制代码
limo_msgs/CMakeLists.txt

找到:

cpp 复制代码
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/LimoStatus.msg"
  "srv/LimoSrv.srv"
  DEPENDENCIES std_msgs
)

修改为:

cpp 复制代码
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/LimoStatus.msg"
  "srv/LimoSrv.srv"
  "action/LimoAction.action"
  DEPENDENCIES std_msgs
)

这样编译时,ROS2 才会根据 LimoAction.action 自动生成 C++ 代码。


3.2 修改 package.xml

打开:

复制代码
limo_msgs/package.xml

确认里面有下面这些依赖:

XML 复制代码
<buildtool_depend>ament_cmake</buildtool_depend>

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>

<member_of_group>rosidl_interface_packages</member_of_group>

如果前面自定义 msg 和 srv 已经配置过,那么这里一般不用重复修改。


3.3 编译接口包

回到工作空间根目录:

复制代码
cd ~/agilex_open_class_ws

编译:

复制代码
colcon build --packages-select limo_msgs

编译完成后刷新环境变量:

复制代码
source install/setup.bash

查看 action 接口是否生成成功:

复制代码
ros2 interface show limo_msgs/action/LimoAction

正常情况下会看到:

复制代码
float32 x
float32 y
float32 z
---
bool success
---
uint32 status

说明自定义 Action 接口已经生成成功。


四、Action 服务端代码

4.1 Action 服务端的作用

Action 服务端负责:

  1. 接收客户端发送的目标
  2. 根据目标控制小车运动
  3. 执行过程中持续发布反馈
  4. 任务结束后返回最终结果

在本篇案例中,Action 服务端的核心功能是:

  1. 客户端发送 x、y、z 速度目标,服务端把目标转换成 Twist
  2. 服务端向 /cmd_vel 发布速度
  3. 服务端循环反馈 status
  4. 最后返回 success

也就是说:

Action 服务端是真正执行任务的一方。


4.2 新建服务端文件

在 limo_learning 功能包下创建:

复制代码
limo_learning/src/limo_action_server.cpp

写入代码:

cpp 复制代码
// 引入智能指针相关头文件
// std::shared_ptr、std::make_shared 会用到
#include <memory>

// 引入线程相关头文件
// std::thread 会用到
#include <thread>

// 引入 ROS2 C++ 客户端库
#include "rclcpp/rclcpp.hpp"

// 引入 ROS2 Action 通信相关头文件
#include "rclcpp_action/rclcpp_action.hpp"

// 引入 Twist 速度消息类型
// 用于向 /cmd_vel 发布速度指令
#include "geometry_msgs/msg/twist.hpp"

// 引入自定义 Action 接口
// 对应 limo_msgs/action/LimoAction.action
#include "limo_msgs/action/limo_action.hpp"

// 自定义 Action 服务端节点类
// 继承 rclcpp::Node,说明它是一个 ROS2 节点
class LimoActionServer : public rclcpp::Node
{
public:
    // 给自定义 Action 类型起一个别名,方便后面书写
    // limo_msgs::action::LimoAction 对应 LimoAction.action 生成的 C++ 类型
    using LimoAction = limo_msgs::action::LimoAction;

    // 给 Action 目标句柄类型起一个别名
    // GoalHandle 用来表示客户端发送过来的一个目标任务
    using GoalHandleLimoAction = rclcpp_action::ServerGoalHandle<LimoAction>;

    // 构造函数
    // Node("limo_action_server") 表示当前节点名称为 limo_action_server
    LimoActionServer() : Node("limo_action_server")
    {
        // 创建 /cmd_vel 速度发布者
        // 发布 geometry_msgs::msg::Twist 类型消息
        // 队列长度为 10
        cmd_pub_ = this->create_publisher<geometry_msgs::msg::Twist>(
            "/cmd_vel",
            10
        );

        // 创建 Action 服务端
        // Action 类型:LimoAction
        // Action 名称:/limo_action
        // handle_goal:收到目标请求时调用
        // handle_cancel:收到取消请求时调用
        // handle_accepted:目标被接受后调用
        action_server_ = rclcpp_action::create_server<LimoAction>(
            this,
            "/limo_action",
            std::bind(
                &LimoActionServer::handle_goal,
                this,
                std::placeholders::_1,
                std::placeholders::_2
            ),
            std::bind(
                &LimoActionServer::handle_cancel,
                this,
                std::placeholders::_1
            ),
            std::bind(
                &LimoActionServer::handle_accepted,
                this,
                std::placeholders::_1
            )
        );

        // 打印节点启动日志
        RCLCPP_INFO(this->get_logger(), "limo_action_server node has started.");
    }

private:
    // /cmd_vel 速度发布者对象
    // 用于控制小车运动
    rclcpp::Publisher<geometry_msgs::msg::Twist>::SharedPtr cmd_pub_;

    // Action 服务端对象
    // 用于提供 /limo_action 动作服务
    rclcpp_action::Server<LimoAction>::SharedPtr action_server_;

    // 处理客户端发送过来的 Goal 目标请求
    // 当客户端发送目标时,会先进入这个函数
    rclcpp_action::GoalResponse handle_goal(
        const rclcpp_action::GoalUUID & uuid,
        std::shared_ptr<const LimoAction::Goal> goal)
    {
        // 当前代码中没有使用 uuid
        // 写 (void)uuid 是为了避免编译器提示"变量未使用"
        (void)uuid;

        // 打印客户端发送过来的目标参数
        // goal->x 表示目标中的 x 方向速度
        // goal->y 表示目标中的 y 方向速度
        // goal->z 表示目标中的 z 方向角速度
        RCLCPP_INFO(
            this->get_logger(),
            "Received goal: x=%.2f, y=%.2f, z=%.2f",
            goal->x,
            goal->y,
            goal->z
        );

        // 接受并立即执行该目标
        // ACCEPT_AND_EXECUTE 表示服务端同意执行客户端发来的任务
        return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
    }

    // 处理客户端发送的取消请求
    // 当客户端想取消正在执行的 Action 任务时,会进入这个函数
    rclcpp_action::CancelResponse handle_cancel(
        const std::shared_ptr<GoalHandleLimoAction> goal_handle)
    {
        // 当前代码中没有直接使用 goal_handle
        // 写 (void)goal_handle 是为了避免编译器提示"变量未使用"
        (void)goal_handle;

        // 打印收到取消请求的日志
        RCLCPP_INFO(this->get_logger(), "Received request to cancel goal.");

        // 接受客户端的取消请求
        return rclcpp_action::CancelResponse::ACCEPT;
    }

    // 当目标被接受后,会进入这个函数
    // 这里不直接执行任务,而是开启一个新线程执行 execute()
    void handle_accepted(
        const std::shared_ptr<GoalHandleLimoAction> goal_handle)
    {
        // 创建一个新线程执行 execute 函数
        // 这样做的目的:
        // 避免长时间任务阻塞 ROS2 主线程
        //
        // detach() 表示线程独立运行
        // 主线程不会等待这个线程结束
        std::thread{
            std::bind(
                &LimoActionServer::execute,
                this,
                std::placeholders::_1
            ),
            goal_handle
        }.detach();
    }

    // 真正执行 Action 任务的函数
    // 目标被接受后,会在新线程中执行这个函数
    void execute(
        const std::shared_ptr<GoalHandleLimoAction> goal_handle)
    {
        // 打印开始执行任务的日志
        RCLCPP_INFO(this->get_logger(), "Executing goal...");

        // 获取客户端发送过来的目标数据
        const auto goal = goal_handle->get_goal();

        // 创建反馈对象
        // Feedback 对应 LimoAction.action 中第二个 --- 后面的反馈部分
        auto feedback = std::make_shared<LimoAction::Feedback>();

        // 创建结果对象
        // Result 对应 LimoAction.action 中第一个 --- 后面的结果部分
        auto result = std::make_shared<LimoAction::Result>();

        // 设置循环频率为 1Hz
        // 也就是每秒执行一次循环
        rclcpp::Rate loop_rate(1);

        // 创建 Twist 速度消息对象
        geometry_msgs::msg::Twist cmd_vel;

        // 将 Action 目标中的 x、y、z 转换成 Twist 速度指令
        // goal->x 对应 linear.x,表示前后方向速度
        // goal->y 对应 linear.y,表示左右方向速度
        // goal->z 对应 angular.z,表示绕 z 轴旋转角速度
        cmd_vel.linear.x = goal->x;
        cmd_vel.linear.y = goal->y;
        cmd_vel.angular.z = goal->z;

        // 循环执行 5 次
        // 因为 loop_rate 是 1Hz,所以这里大约执行 5 秒
        for (uint32_t i = 1; i <= 5; i++)
        {
            // 判断当前目标是否正在被取消
            // 如果客户端发送了取消请求,并且服务端接受取消,就会进入这里
            if (goal_handle->is_canceling())
            {
                // 创建停止速度指令
                // Twist 默认值都是 0,所以表示停止小车
                geometry_msgs::msg::Twist stop_cmd;

                // 发布停止指令到 /cmd_vel
                cmd_pub_->publish(stop_cmd);

                // 设置最终结果为失败
                result->success = false;

                // 通知客户端:目标已经取消
                goal_handle->canceled(result);

                // 打印取消日志
                RCLCPP_INFO(this->get_logger(), "Goal canceled.");

                // 结束 execute 函数
                return;
            }

            // 发布速度指令,让小车按照目标速度运动
            cmd_pub_->publish(cmd_vel);

            // 设置反馈状态
            // 这里用 i 表示当前执行进度
            // 第 1 秒 status=1,第 2 秒 status=2,以此类推
            feedback->status = i;

            // 向客户端发布反馈信息
            // 客户端可以在任务执行过程中不断收到 feedback
            goal_handle->publish_feedback(feedback);

            // 打印当前反馈状态
            RCLCPP_INFO(
                this->get_logger(),
                "Publishing feedback: status=%d",
                feedback->status
            );

            // 按照 1Hz 频率休眠
            // 也就是每次循环间隔约 1 秒
            loop_rate.sleep();
        }

        // 任务执行完成后,发布停止指令
        // 防止小车继续运动
        geometry_msgs::msg::Twist stop_cmd;
        cmd_pub_->publish(stop_cmd);

        // 设置最终结果为成功
        result->success = true;

        // 通知客户端:目标执行成功
        goal_handle->succeed(result);

        // 打印任务成功日志
        RCLCPP_INFO(this->get_logger(), "Goal succeeded.");
    }
};

// main 函数,程序入口
int main(int argc, char ** argv)
{
    // 初始化 ROS2
    rclcpp::init(argc, argv);

    // 创建 LimoActionServer 节点对象
    auto node = std::make_shared<LimoActionServer>();

    // 运行节点
    // spin 会让节点持续运行,等待 Action 请求、取消请求等回调触发
    rclcpp::spin(node);

    // 关闭 ROS2
    rclcpp::shutdown();

    return 0;
}
```

五、Action 客户端代码

5.1 Action 客户端的作用

Action 客户端负责:

  1. 等待 Action 服务端启动
  2. 发送目标给服务端
  3. 接收服务端反馈
  4. 接收最终执行结果

在本篇案例中,客户端发送:

复制代码
x = 0.2
y = 0.0
z = 0.0

表示让小车向前运动。

服务端执行过程中,客户端会不断收到 feedback。

最后收到 result,判断任务是否执行成功。


5.2 新建客户端文件

在 limo_learning 功能包下创建:

复制代码
limo_learning/src/limo_action_client.cpp

写入代码:

cpp 复制代码
// 引入智能指针相关头文件
// std::shared_ptr、std::make_shared 会用到
#include <memory>

// ROS2 C++ 客户端库头文件
#include "rclcpp/rclcpp.hpp"

// ROS2 Action 通信相关头文件
#include "rclcpp_action/rclcpp_action.hpp"

// 引入自定义 Action 接口头文件
// 对应 limo_msgs/action/LimoAction.action
#include "limo_msgs/action/limo_action.hpp"

// 自定义 Action 客户端节点类
class LimoActionClient : public rclcpp::Node
{
public:
    // 给自定义 Action 类型起一个别名,方便后面使用
    using LimoAction = limo_msgs::action::LimoAction;

    // 给 Action 客户端目标句柄类型起一个别名
    // ClientGoalHandle<LimoAction> 表示客户端这边用于管理目标任务的句柄
    using GoalHandleLimoAction = rclcpp_action::ClientGoalHandle<LimoAction>;

    // 构造函数
    // 创建节点时,节点名称为 limo_action_client
    LimoActionClient() : Node("limo_action_client")
    {
        // 创建 Action 客户端对象
        // LimoAction 表示使用的 Action 接口类型
        // "/limo_action" 表示要连接的 Action 服务名称
        action_client_ = rclcpp_action::create_client<LimoAction>(
            this,
            "/limo_action"
        );

        // 打印日志,提示 Action 客户端节点已经启动
        RCLCPP_INFO(this->get_logger(), "limo_action_client node has started.");
    }

    // 发送 Action 目标任务的函数
    void send_goal()
    {
        // 等待 Action 服务端启动
        // 最多等待 5 秒
        // 如果 5 秒内没有发现 Action 服务端,则直接返回
        if (!action_client_->wait_for_action_server(std::chrono::seconds(5)))
        {
            RCLCPP_ERROR(this->get_logger(), "Action server not available.");
            return;
        }

        // 创建一个 Action 目标消息
        // LimoAction::Goal 对应 .action 文件中第一段 Goal 部分
        auto goal_msg = LimoAction::Goal();

        // 设置目标参数
        // 这里表示希望小车按照 x、y、z 指定的速度执行运动
        goal_msg.x = 0.2;
        goal_msg.y = 0.0;
        goal_msg.z = 0.0;

        // 打印日志,提示正在发送目标
        RCLCPP_INFO(this->get_logger(), "Sending goal...");

        // 创建发送目标时的选项对象
        // 里面可以绑定三个回调函数:
        // 1. goal_response_callback:目标是否被服务端接受
        // 2. feedback_callback:执行过程中接收反馈
        // 3. result_callback:执行结束后接收最终结果
        auto send_goal_options = rclcpp_action::Client<LimoAction>::SendGoalOptions();

        // 绑定目标响应回调函数
        // 当服务端接受或拒绝目标时,会自动调用 goal_response_callback
        send_goal_options.goal_response_callback =
            std::bind(
                &LimoActionClient::goal_response_callback,
                this,
                std::placeholders::_1
            );

        // 绑定反馈回调函数
        // 当服务端执行过程中发布 feedback 时,会自动调用 feedback_callback
        send_goal_options.feedback_callback =
            std::bind(
                &LimoActionClient::feedback_callback,
                this,
                std::placeholders::_1,
                std::placeholders::_2
            );

        // 绑定结果回调函数
        // 当 Action 任务执行完成、取消或失败时,会自动调用 result_callback
        send_goal_options.result_callback =
            std::bind(
                &LimoActionClient::result_callback,
                this,
                std::placeholders::_1
            );

        // 异步发送目标任务
        // goal_msg 是目标内容
        // send_goal_options 中包含三个回调函数
        action_client_->async_send_goal(goal_msg, send_goal_options);
    }

private:
    // Action 客户端对象
    // 用于连接 /limo_action,并向 Action 服务端发送目标任务
    rclcpp_action::Client<LimoAction>::SharedPtr action_client_;

    // 目标响应回调函数
    // 当客户端发送目标后,服务端会决定是否接受该目标
    void goal_response_callback(
        const GoalHandleLimoAction::SharedPtr & goal_handle)
    {
        // 如果 goal_handle 为空,说明服务端拒绝了目标
        if (!goal_handle)
        {
            RCLCPP_ERROR(this->get_logger(), "Goal was rejected by server.");
        }
        // 如果 goal_handle 不为空,说明服务端接受了目标
        else
        {
            RCLCPP_INFO(this->get_logger(), "Goal accepted by server.");
        }
    }

    // 反馈回调函数
    // 服务端执行任务过程中,可以不断向客户端发送 feedback
    void feedback_callback(
        GoalHandleLimoAction::SharedPtr,
        const std::shared_ptr<const LimoAction::Feedback> feedback)
    {
        // 打印服务端返回的反馈状态
        // feedback->status 对应 .action 文件中 Feedback 部分的 status 字段
        RCLCPP_INFO(
            this->get_logger(),
            "Received feedback: status=%d",
            feedback->status
        );
    }

    // 最终结果回调函数
    // 当 Action 任务结束后,会进入这个函数
    void result_callback(
        const GoalHandleLimoAction::WrappedResult & result)
    {
        // 根据 result.code 判断任务最终状态
        switch (result.code)
        {
            // 任务成功完成
            case rclcpp_action::ResultCode::SUCCEEDED:
                RCLCPP_INFO(
                    this->get_logger(),
                    "Goal succeeded, success=%d",
                    result.result->success
                );
                break;

            // 任务执行失败,被服务端中止
            case rclcpp_action::ResultCode::ABORTED:
                RCLCPP_ERROR(this->get_logger(), "Goal was aborted.");
                break;

            // 任务被取消
            case rclcpp_action::ResultCode::CANCELED:
                RCLCPP_WARN(this->get_logger(), "Goal was canceled.");
                break;

            // 其他未知状态
            default:
                RCLCPP_ERROR(this->get_logger(), "Unknown result code.");
                break;
        }

        // 收到最终结果后关闭 ROS2 节点
        rclcpp::shutdown();
    }
};

int main(int argc, char ** argv)
{
    // 初始化 ROS2
    rclcpp::init(argc, argv);

    // 创建 Action 客户端节点对象
    auto node = std::make_shared<LimoActionClient>();

    // 调用 send_goal(),向 Action 服务端发送目标任务
    node->send_goal();

    // 进入 ROS2 事件循环
    // 用于等待并处理 Action 回调函数
    // 包括目标响应、过程反馈、最终结果
    rclcpp::spin(node);

    // 关闭 ROS2
    rclcpp::shutdown();

    return 0;
}

六、修改 limo_learning 功能包

6.1 正常功能包目录:

bash 复制代码
ros2_ws/
└── src
    ├── limo_msgs
    │   ├── msg
    │   │   └── LimoStatus.msg
    │   ├── srv
    │   │   └── LimoSrv.srv
    │   ├── action
    │   │   └── LimoAction.action
    │   ├── CMakeLists.txt
    │   └── package.xml
    │
    └── limo_learning
        ├── src
        │   ├── limo_topic_cmd.cpp
        │   ├── limo_topic_sub.cpp
        │   ├── limo_srv_server.cpp
        │   ├── limo_srv_client.cpp
        │   ├── limo_action_server.cpp
        │   └── limo_action_client.cpp
        ├── CMakeLists.txt
        └── package.xml

6.2 修改 limo_learning 的 CMakeLists.txt

(1)添加 action 依赖

因为 Action 代码中用到了:

复制代码
rclcpp_action

所以需要在 CMakeLists.txt 中增加依赖。

打开:

复制代码
limo_learning/CMakeLists.txt

添加:

cpp 复制代码
find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED)
find_package(geometry_msgs REQUIRED)
find_package(limo_msgs REQUIRED)

如果前面已经有 rclcpp、geometry_msgs、limo_msgs,就只需要补充:

复制代码
find_package(rclcpp_action REQUIRED)

(2)添加可执行文件

继续添加:

cpp 复制代码
add_executable(limo_action_server src/limo_action_server.cpp)
ament_target_dependencies(
  limo_action_server
  rclcpp
  rclcpp_action
  geometry_msgs
  limo_msgs
)

add_executable(limo_action_client src/limo_action_client.cpp)
ament_target_dependencies(
  limo_action_client
  rclcpp
  rclcpp_action
  limo_msgs
)

(3)添加安装配置

在 install 中加入:

cpp 复制代码
install(TARGETS
  limo_action_server
  limo_action_client
  DESTINATION lib/${PROJECT_NAME}
)

如果前面已经有其他可执行文件,可以统一写成:

cpp 复制代码
install(TARGETS
  limo_topic_cmd
  limo_topic_sub
  limo_status_pub
  limo_status_sub
  limo_srv_server
  limo_srv_client
  limo_action_server
  limo_action_client
  DESTINATION lib/${PROJECT_NAME}
)

6.3 修改 package.xml

打开:

复制代码
limo_learning/package.xml

添加依赖:

XML 复制代码
<depend>rclcpp</depend>
<depend>rclcpp_action</depend>
<depend>geometry_msgs</depend>
<depend>limo_msgs</depend>

其中:

XML 复制代码
<depend>rclcpp_action</depend>

是本篇 Action 通信需要新增的重点依赖。


七、编译与运行

7.1 编译功能包

回到工作空间根目录:

复制代码
cd ~/agilex_open_class_ws

编译:

复制代码
colcon build

如果只想编译相关功能包,也可以:

复制代码
colcon build --packages-select limo_msgs limo_learning

编译完成后刷新环境变量:

复制代码
source install/setup.bash

7.2 先启动 Action 服务端

打开第一个终端:

复制代码
cd ~/agilex_open_class_ws
source install/setup.bash
ros2 run limo_learning limo_action_server

正常输出类似:

复制代码
[INFO] [limo_action_server]: limo_action_server node has started.

说明 Action 服务端已经启动。


7.3 再启动 Action 客户端

打开第二个终端:

复制代码
cd ~/agilex_open_class_ws
source install/setup.bash
ros2 run limo_learning limo_action_client

客户端输出类似:

复制代码
[INFO] [limo_action_client]: limo_action_client node has started.
[INFO] [limo_action_client]: Sending goal...
[INFO] [limo_action_client]: Goal accepted by server.
[INFO] [limo_action_client]: Received feedback: status=1
[INFO] [limo_action_client]: Received feedback: status=2
[INFO] [limo_action_client]: Received feedback: status=3
[INFO] [limo_action_client]: Received feedback: status=4
[INFO] [limo_action_client]: Received feedback: status=5
[INFO] [limo_action_client]: Goal succeeded, success=1

服务端输出类似:

复制代码
[INFO] [limo_action_server]: Received goal: x=0.20, y=0.00, z=0.00
[INFO] [limo_action_server]: Executing goal...
[INFO] [limo_action_server]: Publishing feedback: status=1
[INFO] [limo_action_server]: Publishing feedback: status=2
[INFO] [limo_action_server]: Publishing feedback: status=3
[INFO] [limo_action_server]: Publishing feedback: status=4
[INFO] [limo_action_server]: Publishing feedback: status=5
[INFO] [limo_action_server]: Goal succeeded.

7.4 查看 Action 列表

可以使用命令查看当前系统中的 Action:

复制代码
ros2 action list

正常情况下可以看到:

复制代码
/limo_action

7.5 查看 Action 信息

复制代码
ros2 action info /limo_action

可以看到 Action 的服务端和客户端数量。


7.6 使用命令行发送 Action 目标

除了写客户端代码,也可以直接用终端命令测试 Action。

复制代码
ros2 action send_goal /limo_action limo_msgs/action/LimoAction "{x: 0.2, y: 0.0, z: 0.0}"

如果想看到反馈信息,可以加上:

复制代码
--feedback

完整命令:

复制代码
ros2 action send_goal /limo_action limo_msgs/action/LimoAction "{x: 0.2, y: 0.0, z: 0.0}" --feedback

这样终端就会显示服务端不断返回的 feedback。

  1. ros2 action send_goal 命令可以直接在终端中向 Action 服务端发送目标任务,不需要额外编写客户端代码。
  2. 其中 /limo_action 表示 Action 名称,limo_msgs/action/LimoAction 表示自定义 Action 接口类型,"{x: 0.2, y: 0.0, z: 0.0}" 表示发送给服务端的 goal 目标内容。
  3. 如果在命令末尾加上 --feedback,终端会实时显示服务端执行过程中返回的 feedback 信息,方便调试 Action 通信流程。

八、Topic、Service、Action 对比总结

8.1 Topic 通信

Topic 适合持续发送数据。

例如:

  1. 速度控制
  2. 状态发布
  3. 雷达数据
  4. IMU 数据
  5. 里程计数据

特点是:

  1. 发布者不断发布
  2. 订阅者不断接收
  3. 没有请求和响应
  4. 没有最终结果返回

可以理解为:

Topic 是广播型通信。


8.2 Service 通信

Service 适合一次请求、一次响应。

例如:

  1. 打开某个功能
  2. 切换某种模式
  3. 发送一次控制请求
  4. 查询一次状态

特点是:

  1. 客户端发送请求
  2. 服务端处理请求
  3. 服务端返回结果
  4. 过程一般比较短

可以理解为:

Service 是问答型通信。


8.3 Action 通信

Action 适合长时间任务。

例如:

  1. 导航到目标点
  2. 执行巡检任务
  3. 执行机械臂动作
  4. 小车持续运动一段时间
  5. 需要反馈执行进度的任务

特点是:

  1. 客户端发送目标
  2. 服务端执行任务
  3. 执行过程中持续反馈
  4. 结束后返回最终结果
  5. 任务中途还可以取消

可以理解为:

Action 是任务型通信。


8.4 三者对比表

通信方式 核心特点 是否持续通信 是否有返回结果 是否有过程反馈 适合场景
Topic 发布/订阅 速度、状态、传感器数据
Service 请求/响应 短时间一次性任务
Action 目标/反馈/结果 长时间任务、导航、巡检

九、本篇总结

本篇文章在前面 Topic、msg、srv 的基础上,继续讲解了 ROS2 中非常重要的 Action 通信机制。

前面我们已经知道:

  1. Topic 可以持续发布速度和状态
  2. 自定义 msg 可以封装自己的数据结构
  3. Service 可以实现一次请求和一次响应

但是当任务执行时间比较长,并且需要在执行过程中持续反馈状态时,Service 就不太适合了

这时候就需要 Action。

本篇通过自定义:

复制代码
LimoAction.action

实现了一个小车 Action 控制案例。

其中:

  1. Goal 负责接收客户端发送的目标速度
  2. Feedback 负责在执行过程中反馈当前状态
  3. Result 负责在任务结束后返回最终结果
  • 服务端收到目标后,将 x、y、z 转换成 Twist 速度指令,并发布到 /cmd_vel 控制小车运动。
  • 客户端发送目标后,可以持续接收服务端反馈,并在任务结束后获取 success 结果。

同时,本篇还完成了 limo_msgs 接口功能包和 limo_learning 代码功能包的配置。

其中:

  1. limo_msgs 负责定义自定义接口,包括 msg、srv、action。
  2. limo_learning 负责存放 C++ 节点代码,包括 Topic、Service、Action 的服务端和客户端。

至此,ROS2 中最核心的三种通信方式已经串起来了:

  1. Topic:持续发布和订阅数据
  2. Service:一次请求和一次响应
  3. Action:长时间任务、过程反馈、最终结果

下一篇文章会专门进入 Action 服务端和客户端代码解析,重点讲清楚:

  1. create_server 是如何创建 Action 服务端的。
  2. create_client 是如何创建 Action 客户端的。
  3. GoalHandle 到底是什么。
  4. handle_goal、handle_cancel、handle_accepted 分别什么时候执行。
  5. execute 函数为什么要单独开线程。
  6. publish_feedback、succeed、canceled、abort 分别有什么作用。
  7. 客户端的 goal_response_callback、feedback_callback、result_callback 分别负责什么。

本篇先把 Action 通信完整跑通。
下一篇再把 Action 服务端和客户端代码彻底讲明白。

掌握这三种通信方式后,再去看 ROS2 机器人项目中的导航、巡检、底盘控制、机械臂控制等代码,就会清晰很多。