本篇内容基于 【ROS2 Humble】ROS2 启蒙 - CLI 工具介绍的基本概念,如果没看过建议先跟着做一遍

1 创建 ROS2 工作空间

在学习和使用 ROS2 的时候,往往需要创建大量的 ROS2 的功能包。为了能够统一管理,需要创建一个文件夹专门存放这些包,这么一个文件夹就可以被称作工作空间。把工作空间加入到系统环境变量以后,经过 ROS2 提供的 colcon 工具的编译,就可以在终端中轻易地调用各种 ros2 的包了。

为每一个工作空间创建一个独立目录是一种经验做法(英文翻译为最佳实践) ,目录名称没有硬性要求,但最好能体现该工作空间的用途。选用 ros2_ws 作为目录名,在创建一个 src 目录,将工作空间内所有功能包都放在该目录下,这也是一种经验做法,不是硬性规定。执行下方指令就完成了工作空间的创建。

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src

2 创建 ROS2 功能包

功能包(package)是 ROS2 代码的组织单元。如果你希望代码可以被安装,或是分享给其他人使用,就需要把代码组织到功能包中。借助功能包,你可以发布自己的 ROS2 工程成果,让其他人能够方便地编译和使用。

ROS 2 的功能包创建使用 ament 作为构建系统,colcon 作为构建工具。官方支持使用 CMake 或 Python 两种方式创建功能包(也就是支持 C++ 和 Python)。也存在其他构建类型,这里不做研究。

创建一个功能包的指令如下:

# 创建一个基于 Python 的ROS2功能包
ros2 pkg create --build-type ament_python <package_name>

# 创建一个基于 C++ 的ROS2功能包
ros2 pkg create --build-type ament_cmake <package_name>

# 上面两个指令创建的功能包缺少主入口文件,加上一个 --node-name 顺便把节点入口文件一起创建了
ros2 pkg create --build-type ament_python --node-name <node_name> <package_name>
ros2 pkg create --build-type ament_cmake --node-name <node_name> <package_name>

执行指令,分别创建 C++ 和 Python 的功能包

ros2 pkg create --build-type ament_python --node-name my_node my_pkg_python
ros2 pkg create --build-type ament_cmake --node-name my_node my_pkg_cmake

创建功能包后,可以看到项目的目录结构如图:

2.1 Python 功能包

目录结构:

my_pkg_python/
├── my_pkg_python/          # 【重要】真正业务Python源码包(必须有__init__.py)
│   ├── __init__.py
│   └── my_node.py
├── resource/               # ament索引空文件,编译要用,初学不用管
│   └── my_pkg_python
├── test/                   # 单元测试,可选,正式项目建议保留,初学不用管
│   ├── __init__.py
│   └── test_my_node.py
├── package.xml             # 【重要】ROS2 ament元数据
├── setup.py                # 【重要】setuptools打包主文件
└── setup.cfg               # 【重要】修改脚本输出路径适配ros2 run
  • my_pkg_python(主要修改的地方)

    这个文件夹中包含了两个文件,分别是:my_node.py​和 __init__.py

    __init__.py​:Python​中关于包的初始化文件,在 import​这个包的时候会先调用这个 __init__.py 对包进行初始化,目前用不到,是空白文件。

    my_node.py​:--node-name 参数帮我们创建的对应名称的文件了,文件的内容也很简单。后续如果有代码就可以直接在这个文件进行编写了。

    def main():
        print('Hi from my_pkg_python.')
    
    
    if __name__ == '__main__':
        main()
    
  • setup.py(重要)

    这个文件的内容比较多,主要关注 entry_points,这个参数配置了程序入口点,例如当前由 ros2​自动生成的配置所描述的意思就是,执行 ros2 run my_pkg_python my_node​ 就会调用 my_node.py​里面的 main()

    install_requires 中配置了 setuptools,表示要使用 Python 的 setuptools 工具对这个 ros2 的包进行构建。

    from setuptools import find_packages, setup
    
    package_name = 'my_pkg_python'		# 定义当前ROS2功能包的包名,和文件夹名称保持一致
    
    setup(
        name=package_name,				# 发布包名称,上面变量赋值过来
        version='0.0.0',
        packages=find_packages(exclude=['test']),	# 自动查找项目下所有Python包,排除test测试目录不参与打包
        data_files=[
    		# 将resource/my_pkg_python文件安装到ament索引目录,让ros2能识别到这个包
            ('share/ament_index/resource_index/packages',
                ['resource/' + package_name]),
    		# 将package.xml安装到share/my_pkg_python下,ROS2工具读取包元信息
            ('share/' + package_name, ['package.xml']),
        ],
        install_requires=['setuptools'],		# 该包运行依赖的Python库,setuptools是基础打包依赖
        zip_safe=True,							# 是否允许zip压缩包运行,ROS2Python包统一设置True
        maintainer='ubuntu',					# 维护者名字
        maintainer_email='ubuntu@todo.todo',	# 维护者邮箱
        description='TODO: Package description',	# 功能包描述信息
        license='TODO: License declaration',	# 软件许可证声明
        extras_require={						# 额外可选依赖,执行单元测试时才需要安装的库
            'test': [
                'pytest',
            ],
        },
    
        entry_points={							# 程序入口点,注册可执行终端命令
            'console_scripts': [
    			# 格式:终端命令名 = 模块文件.类/模块名:入口main函数
                # 执行ros2 run my_pkg_python my_node 就会调用my_node.py里面的main()
                'my_node = my_pkg_python.my_node:main'
            ],
        },
    )
    
  • setup.cfg(看看就行)

    setup.cfg​ 是 setuptools 的配置文件(ini 格式) ,和 setup.py​ 配合使用。setup.py​ 是 Python 代码;setup.cfg 是静态配置文件,把打包参数写在配置文件里,在进行 ROS2 包构建的时候提供相应的配置。

    ; ---------------- setup.cfg ----------------
    ; [develop]:对应开发模式:pip install . --develop / colcon build 开发构建模式
    [develop]
    ; script_dir:开发模式下,生成的脚本存到 构建目录下 lib/my_pkg_python
    ; $base 代表构建输出的base根目录
    script_dir=$base/lib/my_pkg_python
    
    ; [install]:安装模式,pip install . 或者 colcon build --install 正式安装
    [install]
    ; install_scripts:正式安装后,可执行脚本放到 install/lib/my_pkg_python
    install_scripts=$base/lib/my_pkg_python
    

    setuptools 默认会将生成的可执行脚本放到 bin/​ 目录下,但是 ROS2 不希望节点脚本放在 bin 下,因此 ROS2 约定 Python 节点脚本统一放在 install/<pkg_name>/lib/<pkg_name>/​和 build/<pkg_name>/lib/<pkg_name>/​下,所以靠 setup.cfg​ 强制修改脚本输出路径,把 entry_points 生成的节点脚本输出到 lib/my_pkg_python​,而不是系统 bin 目录。 这样 ros2 run my_pkg_python my_node 才能正常找到你的 Python 节点。

  • package.xml(重要)

    这个文件主要和构建 ros2 包的构建有关,也就是告诉 colcon​工具的一些配置,colcon 编译将在下一节介绍。

    package.xml​ 是 ROS2ament 构建系统的元描述文件。colcon​ 编译工具、ros2 pkg​、rosdep​、ament 全部读取这个 xml。这个 xml 文件声明该 ROS2 功能包的全部依赖关系。

    这个文件目前只有 build_type​里面的 ament_python 配置需要关注,其他的看看就行。

    <?xml version="1.0"?>
    <!-- 指定xml校验schema,package_format3 对应ROS2格式3(Humble及以后标准) -->
    <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
    <!-- 根标签,format="3"ROS2包格式版本,ROS1是format2 -->
    <package format="3">
      <!-- 功能包名称,必须和setup.py的package_name完全一致 -->
      <name>my_pkg_python</name>
      <!-- 包版本号,和setup.py version保持一致 -->
      <version>0.0.0</version>
      <!-- 功能包描述文本 -->
      <description>TODO: Package description</description>
      <!-- 维护者,email属性填写邮箱 -->
      <maintainer email="1178902213@qq.com">WitcherCheung</maintainer>
      <!-- 软件许可证,Apache-2.0 / MIT等 -->
      <license>TODO: License declaration</license>
    
      <!-- 测试阶段依赖,仅colcon test的时候才需要,编译运行不需要 -->
      <test_depend>ament_copyright</test_depend>    <!-- 版权检查工具 -->
      <test_depend>ament_flake8</test_depend>       <!-- python代码flake8静态语法检查 -->
      <test_depend>ament_pep257</test_depend>       <!-- python文档字符串规范检查 -->
      <test_depend>python3-pytest</test_depend>     <!-- pytest单元测试框架 -->
    
      <!-- export:导出包的额外属性,给ament构建系统识别 -->
      <export>
        <!-- build_type告诉colcon:这个包是 ament_python 类型(Python包),不是ament_cmake(C++包)
             非常关键!写错会完全编译失败。C++包这里写 ament_cmake -->
        <build_type>ament_python</build_type>
      </export>
    </package>
    
  • resource 文件夹(看看就行)

    配合 setup.py​ 的 data_files​的 ('share/ament_index/resource_index/packages', ['resource/' + package_name])

    ament_index 索引机制,ROS2 靠这个空文件登记:存在这个功能包

    ros2 pkg list​、ros2 pkg find​ 就是扫描这个索引目录,文件本身内容是空的,不需要写任何东西

  • test 文件夹(看看就行)

    存放单元测试、集成测试代码。不重要。

2.2 C++ 功能包

目录结构:

my_pkg_cmake
├── CMakeLists.txt      # CMake构建脚本(核心编译配置)
├── include
│   └── my_pkg_cmake    # 头文件存放目录,惯例套一层包名文件夹,防止头文件名冲突
├── package.xml         # ROS2包元信息,和python包是同一种文件格式,但依赖/标签不一样
└── src
    └── my_node.cpp     # C++源代码实现文件
  • package.xml(重要)

    和 Python 的类似,功能也差不多,区别是一个 buildtool_depend 标签

    <buildtool_depend>​:构建工具依赖,C++ 包必须写 ament_cmake,Python 则是 ament_python

  • CMakeList(非常重要)

    # CMake最低版本,ROS2Humble要求至少3.8
    cmake_minimum_required(VERSION 3.8)
    
    # 定义项目名,必须和package.xml里<name>完全一致
    project(my_pkg_cmake)
    
    # 如果编译器是gcc/g++或者clang,开启严格编译警告
    if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
      add_compile_options(-Wall -Wextra -Wpedantic)
    endif()
    
    # ==================== 查找依赖部分 ====================
    # 导入ament_cmake构建框架,C++包必选
    find_package(ament_cmake REQUIRED)
    
    # 模板注释:如果需要额外ROS依赖,取消注释写find_package(xxx REQUIRED)
    # find_package(<dependency> REQUIRED)
    
    # 编译可执行程序:把src/my_node.cpp编译出名为my_node的可执行文件
    # 好像不同于Python可以写到文件内的某个函数,c++这个写法好像就是写到某个文件,文件中需要有main函数。
    add_executable(my_node src/my_node.cpp)
    
    # 设置头文件搜索路径
    # $<BUILD_INTERFACE>:编译阶段,源码目录下include
    # $<INSTALL_INTERFACE>:安装之后,install/include
    target_include_directories(my_node PUBLIC
      $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
      $<INSTALL_INTERFACE:include>)
    
    # 设置C/C++标准:C语言C99,C++语言C++17,ROS2默认C++17
    target_compile_features(my_node PUBLIC c_std_99 cxx_std_17)  # Require C99 and C++17
    
    # 安装规则:把编译出来的my_node二进制,安装到 install/my_pkg_cmake/lib/my_pkg_cmake/
    # ros2 run my_pkg_cmake my_node 就去这个路径找程序
    install(TARGETS my_node
      DESTINATION lib/${PROJECT_NAME})
    
    # ===================== 测试代码段 =====================
    # BUILD_TESTING:colcon build -DBUILD_TESTING=ON / colcon test 才会进入这个分支
    if(BUILD_TESTING)
      find_package(ament_lint_auto REQUIRED)
      # 跳过版权头文件检查;源码补上版权注释后,要把这行注释掉
      # the following line skips the linter which checks for copyrights
      # comment the line when a copyright and license is added to all source files
      set(ament_cmake_copyright_FOUND TRUE)
    
      # 跳过cpplint代码规范检查;git仓库+源码有版权头就注释掉这行
      # the following line skips cpplint (only works in a git repo)
      # comment the line when this package is in a git repo and when
      # a copyright and license is added to all source files
      set(ament_cmake_cpplint_FOUND TRUE)
    
      # 自动读取package.xml里test_depend,加载所有测试依赖
      ament_lint_auto_find_test_dependencies()
    endif()
    
    # ament包收尾宏,**必须放在整个CMakeLists的最后一行有效代码**
    # 作用:导出包信息、生成cmake配置、注册到ament索引,让colcon/ros2能识别本包
    ament_package()
    
    • project(my_pkg_cmake) :cmake 语法,工程名称。必须和 package.xml 里的项目名称一致

    • find_package:cmake 语法,查找库和包。C++ 代码里写了 #include "rclcpp/rclcpp.hpp"​,但 CMake 在编译时根本不知道这个头文件在哪里,也不知道对应的库文件 .so​或 .a 在哪里。

      • 作用:找到头文件路径、找到库文件路径、设置相关变量、导入 CMake 目标。
      • 运行逻辑:首先找 AMENT_PREFIX_PATH 环境变量,这是 ROS2 最核心的查找路径。当执行 source /opt/ros/humble/setup.bash​ 时,这个环境变量会被设置,里面包含了所有已安装 ROS 2 包的前缀路径,通常指向 ROS2 安装目录 /opt/ros/humble​还有工作空间中的包。其次找标准的 CMake 模块路径 <prefix>/share/<package_name>/cmake/​,比如 rclcpp 就是指向 /opt/ros/humble/share/rclcpp/cmake/rclcppConfig.cmake​。最后还可以自定义一个 prefix 路径,不过这个不在讨论范围之内。
      • ament_target_dependencies(本节没有出现,但很重要,先预警一下):ROS2 特有语法,通常跟在 find_package 后面,将找到的包传递给目标。但是创建的这个包中没有写 ament_target_dependencies,因为创建的包默认并没有使用到 ros2 的库,因此不需要用到 ament_target_dependencies
    • add_executable:cmake 语法,编译可执行程序。可以将 cpp 文件编译成指定名称的可执行文件,编译后可以通过 ros2 run <pkg_name> <可执行文件> 来运行。

    • install:cmake 语法,将编译可执行程序放到执行的目录下。同样决定了能不能使用 ros2 run <pkg_name> <可执行文件> 来运行程序。

  • my_node.cpp(程序入口)

    就是一个普通的 C++ 文件,在 CMakeLists.txt 中配置了 add_executable 就是编译这个文件成可执行文件。

    #include <cstdio>
    
    int main(int argc, char ** argv)
    {
      (void) argc;
      (void) argv;
    
      printf("hello world my_pkg_cmake package\n");
      return 0;
    }
    

3 编译 ROS2 功能包

3.1 使用 colcon build 构建

回到 ~/ros2_ws​目录下,使用 colcon build 命令进行构建

构建完成以后可以看到多出了 3 个文件夹:build​、install​以及 log

  • build(编译的中间文件缓存目录)

    每个功能包会单独生成一个子文件夹:build/my_pkg_cmake​、build/my_pkg_python

    my_pkg_cmake:

    • cmake 生成的缓存 CMakeCache.txt​、Makefile、.o 目标文件
    • cmake 配置、编译过程的临时产物

    my_pkg_python:

    • python setup.py 构建过程的临时构建缓存

    作用:

    1. colcon 增量编译依据:再次 build 时,如果源码没改动,直接复用 build 里缓存,跳过编译,加快速度。
    2. 保存编译过程中间文件。
    3. 如果源码修改不生效、CMakeLists/package.xml 修改后异常、依赖错乱,可以将 build、install 以及 log 目录清除,再进行重新编译。
  • install(最终安装输出目录,运行时真正读取)

    source install/setup.bash​就是加载这个目录的环境下的每个包的独立子文件夹:install/my_pkg_cmake​、install/my_pkg_python

    • ros2 run​、ros2 pkg list​、rosdep​、launch​文件,全部读取 install 目录,不去读 src 源码目录
    • 因此在 src 改完代码,必须重新 colcon build,才会把新产物复制到 install
    • source install/setup.bash 的作用:把 install 下面各个包路径注册到环境变量 AMENT_PREFIX_PATH
    • 如果修改了 CMakeLists.txt、setup.py、package.xml,只改 src,不重新 build,install 里还是旧文件,代码不会更新。不要手动往 install 里复制文件,全部交给 colcon build 生成。

    C++ ament_cmake 包 install 内部结构

    install/my_pkg_cmake/
    ├── lib/
    │   └── my_pkg_cmake/
    │       └── my_node      # 编译出来的二进制可执行程序,ros2 run从这里找节点
    ├── include/             # 导出的头文件,别的包find_package时读取
    └── share/
        └── my_pkg_cmake/
            └── package.xml  # 复制过来的包元文件
    

    Python ament_python 包 install 内部结构

    install/my_pkg_python/
    ├── lib/
    │   └── my_pkg_python/   # setup.cfg指定输出,console_scripts生成的python节点脚本
    └── share/
        ├── ament_index/     # resource索引文件,ros2 pkg list识别包
        └── my_pkg_python/
            └── package.xml
    
  • log(日志目录)

    编译产生的日志文件,有时候可以在这里查看编译出错的原因。

3.2 编译好的功能包如何使用?

  1. 把我们的工作空间加入到环境变量中,并重启终端

    echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc

  2. 运行 Python 功能包

    这个指令就是被配置在 setup.py 文件中,通过 ROS2run 即可运行

    ros2 run my_pkg_python my_node

  3. 运行 C++ 功能包

    ros2 run my_pkg_cmake my_node

通过 echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc​指令将系统的 ros2​的工作空间添加到环境变量 ~/.bashrc​中,此工作空间就属于是底层工作空间(underlay)

现在我们自己再创建一个工作空间,可以使用 echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc​将我们的工作空间也添加到环境变量里面,因为是后添加的,所以会晚于底层工作空间进行加载,我们的工作空间称为覆盖工作空间(overlay)底层工作空间同名功能包将被覆盖工作空间的功能包覆盖。 也就是说,假如系统功能包如果有个叫做 hello​的功能包,我们自己也创建了一个 hello​功能包,重名了以后会优先使用后加载的功能包,也就是我们的 hello 功能包。

4 Node 节点

4.1 Python 写法

创建好的包里面并没有导入任何的包,所以只能做个简单的 print 的工作。如果要使用 ros2 的功能包,需要我们引入 rclpy。rclpy 是使用 Python 编写的调用 ros 功能的重要功能包,绝大部分的功能都是从这个 rclpy 包中引入使用。

要改 3 个文件:

  1. 修改 my_pkg_python/my_node.py

    import rclpy
    from rclpy.node import Node
    import time
    
    def main(args=None):
    	rclpy.init(args=args)		# 先对rclpy工具包进行初始化
    	node = Node("HelloNode")	# 创建node节点,并提供node节点的名称,名称不能有空格
    	while rclpy.ok():			# 检查全局上下文rclpy状态,当按下Ctrl+C会返回false,就会正确执行剩余代码以后退出
    		node.get_logger().info("HelloWorld")
    		time.sleep(1)
    
    	node.destroy_node()		# 释放node节点
    	rclpy.shutdown			# 关闭rclpy
    
  2. 修改 setup.py,在 entry_points 的 console_scripts 中添加该节点的运行脚本

        entry_points={
            'console_scripts': [
                'hello = my_pkg_python.my_node:main'
            ],
    
  3. 修改 package.xml

    由于这个 Python 代码用到了 rclpy,因此需要引入一个 rclpy,这样 colcon 构建 Python 包的时候才找到这个包

    <exec_depend>rclpy</exec_depend>
    

  4. 使用 colcon build 构建 ros 包,并运行

4.2 C++ 写法

主要修改 3 个文件:

  1. 修改 rclcpp/rclcpp.cpp

    固定写法,就像最开始学 C 语言的 Hello World 那样,背下来就对了。

    #include <rclcpp/rclcpp.hpp>
    #include <time.h>	// 主要要用sleep这个库
    
    int main(int argc, char *argv[])
    {
        rclcpp::init(argc, argv);
        auto mynode = rclcpp::Node::make_shared("MyHello");		// 通过rclcpp创建一个node节点的智能指针
        while (rclcpp::ok())
        {
            RCLCPP_INFO(mynode->get_logger(), "Hello World.");	// C++要让node输出日志的写法
            sleep(1);
        }
        rclcpp::shutdown();
        return 0;
    }
    
  2. 修改 CMakeLists.txt

    ...
    find_package(ament_cmake REQUIRED)
    find_package(rclcpp REQUIRED)		// 工程添加rclcpp的依赖
    ...
    target_include_directories.......
    
    ament_target_dependencies(${PROJECT_NAME}_node rclcpp)		// 给这个可执行程序文件添加rclcpp的依赖
    
  3. 修改 package.xml

    如果只是进行 colcon build​,这个文件不做修改其实没有影响,但是会影响到使用 rosdep install​对应的包,和 Python 工程的 requirements.txt​类似。考虑到项目的完整性,还有如果后期需要打包(bloom)、发布二进制仓库或者在其他环境使用这个包的时候,会出现找不到包的情况,因此最好还是修改一下。在 buildtool_depend 下方添加 <depend>rclcpp</depend> 就行。

    ...
    <buildtool_depend>ament_cmake</buildtool_depend>
    <depend>rclcpp</depend>
    ...
    
  4. 编译并运行结果

    写这个文章的时候我的 pkg 包有点混乱,所以会有指令和这个项目不太一样的情况,不要太在意。

5 Topic 话题

5.1 Python 写法

创建话题

编写一个创建话题的节点 hello_publish_node,就叫做 hello_publish_node.py 文件

import rclpy
from rclpy.node import Node
import time
from std_msgs.msg import String		# topic话题要想输出字符串类型数据要用这个String对象

class HelloPublishNode(Node):
    def __init__(self, node_name):
        super().__init__(node_name)
        self.pub = self.create_publisher(String, 'HelloTopic', 10)
        self.timer = self.create_timer(1, self.timer_callback)

    def timer_callback(self):
        
        msg = String()		# 字符串必须要用这个String对象
        msg.data = time.asctime()	# 真的数据内容在这个String的data里面
        self.pub.publish(msg)
        self.get_logger().info(f'HelloTopic publishes message: {msg.data}')


def main(args=None):
	# main 函数这边创建节点都是固定写法了
    rclpy.init(args=args)
    node = HelloPublishNode("hello_publish_node")
    rclpy.spin(node)

    node.destroy_node()
    rclpy.shutdown()

订阅话题

编写一个订阅话题的节点 hello_subscribe_node​,就叫做 hello_subscribe_node.py 文件

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class HelloSubscribeNode(Node):
    def __init__(self, node_name):
        super().__init__(node_name)
        self.sub = self.create_subscription(String, 'HelloTopic', self.hello_topic_callback, 10)

    def hello_topic_callback(self, msg: String):
		# 接收的时候,把String对象里面的data拿出来
        self.get_logger().info(f'HelloTopic receives message: {msg.data}')


def main(args=None):
    rclpy.init(args=args)
    node = HelloSubscribeNode("hello_subscribe_node")
    rclpy.spin(node)

    node.destroy_node()
    rclpy.shutdown()

和 node 那边类似,也是要去 setup.py 中添加相关运行脚本

运行结果:

发布者

订阅者

使用 rqt_graph 显示话题订阅的关系图

5.2 C++ 写法

5.2.1 发布话题

创建文件:demo_pub.cpp

#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>	// ROS 2标准消息库中的字符串消息类型,用于在话题上发送/接收字符串数据
#include <time.h>					// C标准时间库,虽然本代码中未直接使用,但通常用于时间相关操作(如获取系统时间)

using namespace std::chrono_literals;	// C++标准库的时间字面量支持,允许使用500ms、1s等简洁的时间表示法

/**
 * @brief 演示发布者节点类
 * 继承自rclcpp::Node,是一个自定义的ROS 2节点
 */
class DemoNode : public rclcpp::Node{
public:
    /**
     * @brief 构造函数
     * 初始化节点名称、创建发布者和定时器
     */
    DemoNode() : Node("demo_pub_node"){		// C++语法,调用父类的构造函数
        // 使用RCLCPP_INFO宏打印INFO级别的日志信息,C++中要给node节点打印日志输出就得这样搞
        // this->get_logger()获取当前节点的日志记录器
        RCLCPP_INFO(this->get_logger(), "Pub node start successful.");
        
        // 创建发布者:
        // - 模板参数<std_msgs::msg::String>指定消息类型为字符串
        // - "/chatter"是话题名称
        // - 10是队列大小(缓存最近10条消息)
        // - this->create_publisher是Node类的成员函数
        pub_ = this->create_publisher<std_msgs::msg::String>("/chatter", 10);
        
        // 创建定时器:
        // - 500ms:触发周期,每隔500毫秒触发一次
        // - std::bind(&DemoNode::timer_cb, this):绑定成员函数timer_cb作为回调
        // - this->create_wall_timer创建基于系统时钟的定时器
        timer_ = this->create_wall_timer(500ms, std::bind(&DemoNode::timer_cb, this));
    }

private:
    /**
     * @brief 定时器回调函数
     * 每500ms被调用一次,创建消息并通过发布者发送
     */
    void timer_cb(){
        // 创建字符串消息对象(栈上分配)
        auto msg = std_msgs::msg::String();
        
        // 设置消息的数据内容为"Hello World."
        msg.data = "Hello World.";

        // 打印日志,显示正在发布的内容
        // c_str()将std::string转为C风格字符串
        RCLCPP_INFO(this->get_logger(), "publish %s", msg.data.c_str());
        
        // 通过发布者发布消息
        // ->是智能指针的箭头运算符,调用成员函数
        pub_->publish(msg);
    }
    
    // 发布者智能指针:
    // - SharedPtr表示共享指针,自动管理内存
    // - 模板参数指定消息类型为std_msgs::msg::String
    rclcpp::Publisher<std_msgs::msg::String>::SharedPtr pub_;
    
    // 定时器智能指针:
    // - TimerBase是定时器的基类
    // - SharedPtr管理定时器对象的生命周期
    rclcpp::TimerBase::SharedPtr timer_;
};

/**
 * @brief 程序入口函数
 * 初始化ROS 2、创建节点对象、进入事件循环、最后清理资源
 */
int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    auto mynode = std::make_shared<DemoNode>();

    // 进入事件循环:
    // - spin会阻塞当前线程,处理所有回调(定时器、订阅、服务等)
    // - 直到收到Ctrl+C或调用shutdown()才会退出
    rclcpp::spin(mynode);
    rclcpp::shutdown();
    return 0;
}

5.2.2 订阅话题

创建 demo_sub.cpp

#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
#include <time.h>

using namespace std::chrono_literals;

/**
 * @brief 演示订阅者节点类
 * 继承自rclcpp::Node,用于接收并处理话题消息
 */
class DemoNode : public rclcpp::Node{
public:
    /**
     * @brief 构造函数
     * 初始化节点名称、创建订阅者
     */
    DemoNode() : Node("demo_sub_node"){
        // 打印node节点启动成功日志
        RCLCPP_INFO(this->get_logger(), "Sub node start successful.");
        
        /**
         * @brief 创建订阅者
         * 
         * 语法:create_subscription<消息类型>(话题名, 队列大小, 回调函数)
         * 
         * 参数详解:
         * - "/chatter":要订阅的话题名称,必须与发布者的话题名称完全一致
         * - 10:队列大小(深度),当消息处理速度跟不上接收速度时,
         *       缓存最近10条消息,丢弃更早的消息
         * - std::bind(&DemoNode::sub_cb, this, std::placeholders::_1):
         *       绑定成员函数sub_cb作为回调,std::placeholders::_1表示回调函数占位符,
         *       用于接收传入的消息参数
         * 
         * 返回值:rclcpp::Subscription智能指针,存储在成员变量sub_中
         */
        sub_ = this->create_subscription<std_msgs::msg::String>(
            "/chatter", 
            10, 
            std::bind(&DemoNode::sub_cb, this, std::placeholders::_1)
        );
    }

private:
    /**
     * @brief 订阅者回调函数
     * 每当接收到话题消息时被自动调用
     * 
     * @param msg 接收到的消息,使用SharedPtr(共享指针)传递
     *            类型为std_msgs::msg::String的智能指针
     *            使用->操作符访问成员,如msg->data
     * 
     * 注意:回调函数在ROS 2的执行器(Executor)中被调用,
     *       应尽量快速返回,避免长时间阻塞
     */
    void sub_cb(const std_msgs::msg::String::SharedPtr msg){
        // 使用RCLCPP_INFO打印接收到的消息
        // msg->data:获取消息中的字符串数据
        // c_str():将std::string转换为C风格字符串用于格式化输出
        RCLCPP_INFO(this->get_logger(), "receive: %s", msg->data.c_str());
    }
    
    /**
     * @brief 订阅者智能指针
     * 
     * 类型:rclcpp::Subscription<消息类型>::SharedPtr
     * - SharedPtr是共享指针,自动管理内存,无需手动delete
     * - 使用模板参数指定消息类型为std_msgs::msg::String
     * - 在构造函数中通过create_subscription创建并赋值
     * - 成员变量会在对象销毁时自动释放
     */
    rclcpp::Subscription<std_msgs::msg::String>::SharedPtr sub_;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
	// 使用共享指针创建一个指向DemoNode节点的指针,这么做可以自动释放资源,避免内存泄露
    auto mynode = std::make_shared<DemoNode>();
    rclcpp::spin(mynode);
    rclcpp::shutdown();
    return 0;
}

5.2.3 CMakeLists.txt 修改

cmake_minimum_required(VERSION 3.8)
project(witcher_pkg_cpp)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# 将常用的依赖都写到一起,后面可以通过${COMMON_ROS_DEPS}调用
set(COMMON_ROS_DEPS
  rclcpp
  std_msgs
)

# 这个包用到的依赖
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

include_directories(
  ${PROJECT_SOURCE_DIR}/src
)

# 添加执行脚本的文件的地址,加了这个以后才能找到执行脚本所在位置
add_executable(${PROJECT_NAME}_node src/${PROJECT_NAME}_node.cpp)
add_executable(demo_sub src/demo_sub.cpp)
add_executable(demo_pub src/demo_pub.cpp)

target_include_directories(${PROJECT_NAME}_node PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include>)

# 给每个文件添加依赖关系,不然会include不到
ament_target_dependencies(${PROJECT_NAME}_node ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_pub ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_sub ${COMMON_ROS_DEPS})

# 得在instal这里把脚本启动命令写上,才能通过ros2 run找到相关的命令
install(TARGETS ${PROJECT_NAME}_node demo_pub demo_sub
  DESTINATION lib/${PROJECT_NAME}
)

ament_package()

5.2.4 编译运行

终端执行 ros2 run witcher_pkg_cpp demo_pub​和 ros2 run witcher_pkg_cpp demo_sub,可以看到节点话题发布和订阅的情况,可以看到,在 0.001s,也就是 1ms 左右。

但是实际上速度不应该这么慢。其实可以看到,服务端的代码是先 log 输出日志,再 publish 消息,如果调整一下顺序,可以看到时间间隔来到了 0.00031s,也就是 0.3ms,速度差的有点多。

5.2.5 C++ 的 std::bind 语法详解

初学 C++,对这个东西很不理解,回调函数还得这么写才行,感觉很抽象。

std::bind(&DemoNode::sub_cb, this, std::placeholders::_1)
参数 含义
&DemoNode::sub_cb 成员函数指针,取地址
this 当前对象的指针,指定在哪个对象上调用
std::placeholders::_1 占位符,表示回调函数的​第一个参数(即接收到的消息)

简单理解: 告诉 ROS 2:"当收到消息时,在我的 this​ 对象上调用 sub_cb 函数,并把收到的消息作为第一个参数传进去。"

6 Service 服务

对于 topic​来说,服务 service​是双向的,是双向通信的,C/S 模型,通常还是一方作为服务端,一方是客户端,客户端向服务端请求一些功能。

6.1 Python 写法

先是需要编写 service 部分的代码。这里有一个会卡住的地方,就是服务接收的参数的 Type,放后面再说,这里先放服务端的代码。

服务端代码

import rclpy
from rclpy.node import Node
from witcher_pkg_interfaces.srv import AddType		# 这里导入了一个自定义的参数类型,见后面的内容介绍


class ServerNode(Node):
    def __init__(self, node_name):
        super().__init__(node_name)
		# 创建一个service服务
        self.server = self.create_service(AddType, "add_server", self.adder_callback)
	
	# 这个callback也是特殊,服务会把接收到的参数的传给回调函数,然后进行计算并返回完整的response
    def adder_callback(self, request, response):
        response.sum = request.a + request.b
        return response


def main(args=None):
    rclpy.init(args=args)
    node = ServerNode("adder_server_node")
    rclpy.spin(node)		// 创建一个服务的节点并循环运行

    node.destroy_node()
    rclpy.shutdown()

客户端代码:客户端代码比较复杂

import sys
import rclpy
from rclpy.node import Node
from witcher_pkg_interfaces.srv import AddType		# 这里导入了一个自定义的参数类型,见后面的内容介绍


class ClientNode(Node):
    def __init__(self, node_name):
        super().__init__(node_name)
        self.client = self.create_client(AddType, "add_server")		# 创建一个客户端
        while not self.client.wait_for_service(0.5):				# 等待连接服务端,等待500ms一次
            self.get_logger().info("wait for service...")
        self.request = AddType.Request()			# 实例化一个传参类型的request的对象

    def send_request(self):
        self.request.a = int(sys.argv[1])			# 要想取出对象的参数,直接.就行了。
        self.request.b = int(sys.argv[2])			# 这两步是配置好参数
        self.future = self.client.call_async(self.request)	# 调用服务端进行计算,并将参数传入


def main(args=None):
    rclpy.init(args=args)
    node = ClientNode("add_client_node")	# 创建客户端节点
    node.send_request()						# 发送请求
    while rclpy.ok():						# 查询 ROS 上下文是否还活着
        rclpy.spin_once(node)				# 处理一轮事件,等待节点有请求发送回来
        if node.future.done():
            try:
                response = node.future.result()		# 这里会拿到服务端返回的结果
            except Exception as e:
                node.get_logger().error(e)
            else:
                node.get_logger().info(
                    f"{sys.argv[1]} + {sys.argv[2]} = {response.sum}"
                )

    node.destroy_node()
    rclpy.shutdown()

6.2 C++ 写法

和 topic 话题类似,服务也需要用到一个内置的服务的数据类型 std_srvs/srv/SetBool,这个数据类型,将在通信接口中详细介绍,这里还是只要先用就行。

6.2.1 创建服务端 Server

src/demo_srv_server.cpp

#include <rclcpp/rclcpp.hpp>

// ROS 2标准服务库中的SetBool服务类型
// 服务定义:请求(bool data),响应(bool success, string message)
#include <std_srvs/srv/set_bool.hpp>

// 类型别名:简化服务类型的书写
// 以后使用SetBool就等同于std_srvs::srv::SetBool
using SetBool = std_srvs::srv::SetBool;

/**
 * @brief 演示服务端节点类
 * 继承自rclcpp::Node,提供一个设置布尔值的服务
 */
class DemoSrvServer : public rclcpp::Node
{
public:
    /**
     * @brief 构造函数
     * 初始化节点名称、创建服务端
     */
    DemoSrvServer() : Node("demo_srv_server")
    {
        /**
         * @brief 创建服务端
         * 
         * 语法:create_service<服务类型>(服务名, 回调函数)
         * 
         * 参数详解:
         * - "/demo_set_bool":服务名称,客户端通过此名称调用服务
         * - std::bind(&DemoSrvServer::service_cb, this, 
         *            std::placeholders::_1, std::placeholders::_2):
         *   绑定成员函数service_cb作为回调
         *   - _1:占位符,表示第一个参数(请求对象 Request)
         *   - _2:占位符,表示第二个参数(响应对象 Response)
         * 
         * 返回值:rclcpp::Service智能指针,存储在成员变量server_中
         */
        server_ = this->create_service<SetBool>(
            "/demo_set_bool",
            std::bind(&DemoSrvServer::service_cb, this, 
                      std::placeholders::_1, std::placeholders::_2)
        );
        
        // 打印服务启动成功日志
        RCLCPP_INFO(this->get_logger(), "Service Server ready");
    }

private:
    /**
     * @brief 服务回调函数
     * 当客户端请求到达时自动被调用
     * 
     * @param req 请求对象(SharedPtr共享指针)
     *            类型:SetBool::Request::SharedPtr
     *            包含成员:req->data(bool类型,客户端传入的布尔值)
     * 
     * @param res 响应对象(SharedPtr共享指针)
     *            类型:SetBool::Response::SharedPtr
     *            包含成员:
     *            - res->success(bool类型,表示操作是否成功)
     *            - res->message(string类型,返回给客户端的信息)
     * 
     * 注意:此函数在ROS 2执行器中调用,应快速返回避免阻塞
     */
    void service_cb(const SetBool::Request::SharedPtr req, 
                    SetBool::Response::SharedPtr res)
    {
        // 打印接收到的请求数据(将bool转为整数%d显示:1为true,0为false)
        RCLCPP_INFO(this->get_logger(), "recv request: %d", req->data);
        
        // 设置响应:操作成功
        res->success = true;
        
        // 设置响应:返回提示信息
        res->message = "ok done";
    }
    
    /**
     * @brief 服务端智能指针
     * 
     * 类型:rclcpp::Service<服务类型>::SharedPtr
     * - SharedPtr是共享指针,自动管理内存
     * - 使用模板参数指定服务类型为SetBool
     * - 在构造函数中通过create_service创建并赋值
     */
    rclcpp::Service<SetBool>::SharedPtr server_;
};

/**
 * @brief 程序入口函数
 */
int main(int argc, char** argv)
{
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<DemoSrvServer>());
    rclcpp::shutdown();
    
    return 0;
}

6.2.2 创建客户端 Client

src/demo_srv_client.cpp

#include <rclcpp/rclcpp.hpp>
#include <std_srvs/srv/set_bool.hpp>
using SetBool = std_srvs::srv::SetBool;
using namespace std::chrono_literals;

class DemoSrvClient : public rclcpp::Node {
public:
    DemoSrvClient(const std::string &node_name) : Node(node_name) {
        // 创建服务客户端:连接到名为 "/demo_set_bool" 的服务
        // this->create_client<服务类型>("服务名")
        client_ = this->create_client<SetBool>("/demo_set_bool");
        RCLCPP_INFO(this->get_logger(), "Client node created.");
        
        // 调用私有方法,实际发送请求(注意:这里在构造函数中同步执行,会阻塞构造完成)
        this->send_req();
    }

private:
    void send_req() {
        // ----- 步骤1:等待服务上线 -----
        // client_->wait_for_service(超时时间) 会阻塞等待,超时返回 false
        while (!client_->wait_for_service(1s)) {
            RCLCPP_WARN(this->get_logger(), "waiting service ...");
        }
        RCLCPP_INFO(this->get_logger(), "Client node connected.");

        // ----- 步骤2:构造请求 -----
        auto req = std::make_shared<SetBool::Request>();
        req->data = true;   // 设置请求数据为 true

        // ----- 步骤3:异步发送请求 -----
        auto result = client_->async_send_request(req);

        // ----- 步骤4:等待响应完成(同步阻塞) -----
        // rclcpp::spin_until_future_complete 会在等待 future 完成的同时,处理 ROS 2 回调
        if (rclcpp::spin_until_future_complete(this->get_node_base_interface(), result) == rclcpp::FutureReturnCode::SUCCESS) {
            
            // ----- 步骤5:获取响应并打印 -----
            // result.future.get() 只能调用一次!获取响应对象(Response 的共享指针)
            auto res = result.future.get();
            
            // 打印响应信息:message 字符串和 success 布尔值(%d 显示 0 或 1)
            RCLCPP_INFO(this->get_logger(), "res: msg=%s", res->message.c_str());
            RCLCPP_INFO(this->get_logger(), "res: success=%d", res->success);
        } else {
            // 如果超时或中断,打印错误信息(可选)
            RCLCPP_ERROR(this->get_logger(), "Request failed or timed out.");
        }
    }

    /**
     * @brief 客户端智能指针
     * 类型:rclcpp::Client<SetBool>::SharedPtr
     * 用于调用服务,由 create_client 创建,自动管理生命周期
     */
    rclcpp::Client<SetBool>::SharedPtr client_;
};

/**
 * @brief 主函数
 * 
 * 流程:
 * 1. 初始化 ROS 2
 * 2. 创建客户端节点(构造时自动发送请求并等待响应)
 * 3. 由于请求在构造函数中同步完成,无需调用 spin,直接关闭
 * 4. 关闭 ROS 2
 */
int main(int argc, char *argv[]) {
    rclcpp::init(argc, argv);
    auto node = std::make_shared<DemoSrvClient>("ClientNode");
    
    // 注意:这里没有调用 rclcpp::spin(node),
    // 因为请求已经同步完成,在send_req的rclcpp::spin_until_future_complete中已经阻塞,并等待回调了
    
    rclcpp::shutdown();
    return 0;
}

6.2.3 修改 CMakeLists.txt

cmake_minimum_required(VERSION 3.8)
project(witcher_pkg_cpp)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
find_package(std_srvs REQUIRED)

set(COMMON_ROS_DEPS
  rclcpp
  std_msgs
  std_srvs
)

include_directories(
  ${PROJECT_SOURCE_DIR}/src
)

add_executable(${PROJECT_NAME}_node src/${PROJECT_NAME}_node.cpp)
add_executable(demo_sub src/demo_sub.cpp)
add_executable(demo_pub src/demo_pub.cpp)
add_executable(demo_server src/demo_server.cpp)
add_executable(demo_client src/demo_client.cpp)

target_include_directories(${PROJECT_NAME}_node PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include>)

ament_target_dependencies(${PROJECT_NAME}_node ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_sub ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_pub ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_server ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_client ${COMMON_ROS_DEPS})

install(TARGETS ${PROJECT_NAME}_node demo_sub demo_pub demo_client demo_server
  DESTINATION lib/${PROJECT_NAME}
)

ament_package()

6.2.4 运行报错处理

运行的时候遇到了两个比较难受的问题,调试了很久,AI 都一派胡言的,不过最后还是在 AI 大量错误的回复中大浪淘沙找到正确的答案。

  1. terminate called after throwing an instance of 'std::runtime_error' what(): Node '/ClientNode' has already been added to an executor.

    在 main 函数中使用了 rclcpp::spin(node)​,由于构造的时候已经去执行 rclcpp::spin_until_future_complete​,将 node 加入到执行器里了,再 rclcpp::spin(node) 就会冲突,解决办法就是改造代码,只保留其中一个。

  2. terminate called after throwing an instance of 'std::future_error' what(): std::future_error: No associated state

    因为 result.future.get()​只能 get 一次,再次 gei 就会出现 No associated state 的报错,所以要用一个变量保存下来。

6.2.5 正确运行结果

服务正确启动,客户端启动发送请求后会受到服务端发出的结果

7 自定义通信接口

学 topic 和 service 的时候,用到的 String 和 SetBool 就是官方提供的通信接口,在 ROS2 中,一个有 3 种通信接口,其对应的文件类型如下

  1. .msg:主要由 topic 话题使用,单向传输

    int32 msg		# 这种由于没有分隔符,所以直接通过实例化对象的属性名来访问变量
    
  2. .srv​:主要是 service 服务使用,前面也提到了,作为输入量和输出量使用,用 --- 分隔请求还是应答数据

    int32 a			# Request
    int32 b
    ---
    int32 sum		# Response
    
  3. .action​:主要是 action 动作使用(后面会提到),提供了一些函数调用,用 --- 分隔目标、结果和反馈

    bool enable		# Goal
    ---
    bool finish		# Result
    ---
    int32 state		# Feedback
    

7.1 ROS2 官方提供的接口

/opt/ros/humble/share​里面有很多 ROS2 的功能包,里面 std_msg​、std_srv 都包含了大量的相关接口。

例如我们用到的 srd_msgs 中的 String.msg 文件,就是直接定义一个 string data,在实际使用的时候也是通过 String msg​以及 msg.data 来使用的

# 所在目录:/opt/ros/humble/share/std_msgs/msg/String.msg

# This was originally provided as an example message.
# It is deprecated as of Foxy
# It is recommended to create your own semantically meaningful message.
# However if you would like to continue using this please use the equivalent in example_msgs.

string data

同样的还有还有 /opt/ros/humble/share/std_srvs/srv/SetBool.srv,对于这个消息类型,我们是通过 request.data 来配置请求数据,通过 response.success 和 response.message 来解析接收到的数据

bool data # e.g. for hardware enabling / disabling
---
bool success   # indicate successful run of triggered service
string message # informational, e.g. for error messages

7.2 接口相关 CLI 指令

ros2 interface list					// 查看全部已定义的接口
ros2 interface show [接口名称]		// 查看接口的完整定义
ros2 interface package [包名]		// 查看某个功能包定义的所有通信接口

例如要查看 srd_msgs 的 String,可以使用指令 ros2 interface show std_msgs/msg/String,输出的结果和直接查看对应的 String.msg 文件一致

7.3 自定义接口

7.3.1 为 Python 项目创建接口

如果是只有 python 的工程,由于没有 CMakeLists,没办法直接在本项目的目录下创建相关的 Type 文件,需要额外再创建一个 ros2 的包。通常好像是 ros2 都是建议专门给自定义的类型都创建一个 pkg 包。

ros2 pkg create --build_type ament_cmake [pkg包名] --dependencies rclpy rosidl_default_generators​

比较重要的就是需要这个 rosidl_default_generators​,ROS 2 中一个核心的构建工具依赖包。它的主要作用是让你创建的这个包,具备从自定义接口定义文件(如 .msg​, .srv​, .action)生成不同编程语言(如 C++ 和 Python)代码的能力

执行好后会创建一个相关 pkg 包,打开创建好的这个 pkg 包,我是命名为 witcher_pkg_interfaces​。创建一个 srv​目录,在里面创建一个 AddType.srv 文件:

.
├── CMakeLists.txt
├── include
│   └── witcher_pkg_interfaces
├── package.xml
├── src
└── srv
    └── AddType.srv

AddType.srv​文件内容如下:比较反常的地方就在这了,这个就是简单定义一下参数类型和名称,其中 request​参数和 response​参数需要用 ---​隔开。在这里,上面的是 request​参数,下面的是 response 参数。

int32 a
int32 b
---
int32 sum

完事在 CMakeLists.txt 中添加这些内容:find_package(rosidl_default_generators REQUIRED)​还有 rosidl_generate_interfaces 这堆东西。

cmake_minimum_required(VERSION 3.8)
project(witcher_pkg_interfaces)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# find dependencies
find_package(ament_cmake REQUIRED)
# uncomment the following section in order to fill in
# further dependencies manually.
# find_package(<dependency> REQUIRED)
find_package(rosidl_default_generators REQUIRED)

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  # the following line skips the linter which checks for copyrights
  # comment the line when a copyright and license is added to all source files
  set(ament_cmake_copyright_FOUND TRUE)
  # the following line skips cpplint (only works in a git repo)
  # comment the line when this package is in a git repo and when
  # a copyright and license is added to all source files
  set(ament_cmake_cpplint_FOUND TRUE)
  ament_lint_auto_find_test_dependencies()
endif()

rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/AddType.srv"
)

ament_package()

最后在 package.xml 文件中添加相关依赖项:

  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <depend>rosidl_default_runtime</depend>
  <member_of_group>rosidl_interface_packages</member_of_group>

那这样就行了,这样以后使用 colcon build​编译以后,在 Python 中就可以通过使用 from pkg包名/srv import AddType 来调用这个参数接口了。

7.3.2 为 C++ 项目创建接口

Python 项目中由于没有 CMakeLists.txt,因此需要单开一个 interface 的包来创建接口。但是 C++ 项目是有的,所以可以直接在 C 项目中创建一个接口(推荐新开一个 interface 的包专门用于存放接口,本节只是做个样子,后续章节统一放在专门的新的 interface 包里面使用)。

创建文件

在 C++ 的项目 witcher_pkg_cpp​下,创建一个 msg/Book.msg

cd ~/ros2_ws/src/witcher_pkg_cpp
mkdir -p msg
touch msg/Book.msg

输入一下内容:

string name
string author
int32 price

内容很容易看懂,就不解释了

接着修改一下 CMakeLists.txt 文件,添加以下内容

find_package(rosidl_default_generators REQUIRED)
...
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Book.msg"
)

然后修改 packages.xml 文件。是的,和上一小节的操作一样

  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <depend>rosidl_default_runtime</depend>
  <member_of_group>rosidl_interface_packages</member_of_group>

这时候进入 ~/ros2_ws​使用 colcon build​构建项目,使用 colcon build --packages-select witcher_pkg_cpp​可以单独只编译这个包。完成后使用指令查看,可以看到 Messages 类型中多了一个我们自定义的 witcher_pkg_cpp/msg/Book 类型。

基于原有的 demo_pub.cpp 和 demo_sub.cpp 文件进行修改:

demo_pub.cpp

#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
#include <witcher_pkg_cpp/msg/book.hpp>
#include <time.h>

using namespace std::chrono_literals;

class DemoNode : public rclcpp::Node{
public:
    DemoNode() : Node("demo_pub_node"){
        RCLCPP_INFO(this->get_logger(), "Pub node start successful.");
        pub_ = this->create_publisher<witcher_pkg_cpp::msg::Book>("/chatter", 10);
        timer_ = this->create_wall_timer(500ms, std::bind(&DemoNode::timer_cb, this));
        book.name = "C++ Primer";
        book.author = "Stanley B. Lippman";
        book.price = 109;
    }

private:
    void timer_cb(){
        pub_->publish(book);
        RCLCPP_INFO(this->get_logger(), "publish time: %d", count++);
    }
    int count = 0;
    witcher_pkg_cpp::msg::Book book = witcher_pkg_cpp::msg::Book();
    rclcpp::Publisher<witcher_pkg_cpp::msg::Book>::SharedPtr pub_;
    rclcpp::TimerBase::SharedPtr timer_;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    auto mynode = std::make_shared<DemoNode>();
    rclcpp::spin(mynode);
    rclcpp::shutdown();
    return 0;
}

demo_sub.cpp

#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>
#include <witcher_pkg_cpp/msg/book.hpp>
#include <time.h>

using namespace std::chrono_literals;

class DemoNode : public rclcpp::Node{
public:
    DemoNode() : Node("demo_sub_node"){
        RCLCPP_INFO(this->get_logger(), "Sub node start successful.");
        sub_ = this->create_subscription<witcher_pkg_cpp::msg::Book>("/chatter", 10, std::bind(&DemoNode::sub_cb, this, std::placeholders::_1));
    }

private:
    void sub_cb(const witcher_pkg_cpp::msg::Book::SharedPtr book){
        RCLCPP_INFO(this->get_logger(), "receive book, name: %s, author: %s, price: %d.", book->name, book->author, book->price);
    }
    rclcpp::Subscription<witcher_pkg_cpp::msg::Book>::SharedPtr sub_;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    auto mynode = std::make_shared<DemoNode>();
    rclcpp::spin(mynode);
    rclcpp::shutdown();
    return 0;
}

编译运行

奇怪了,为什么会输出乱码?噢噢噢没有调用 .c_str(),改一下。

8 Action 动作

我们最初用到了小海龟来实现对小海龟的前进、转向的控制,因为我们可以直接从图形化界面上看到小海龟的前进方向和所在位置,所以我们根本没有考虑过能否收到小海龟的反馈。但如果把场景换成操控另一个房间的小车呢?我们肯定希望能够实时收到小车的反馈,比如有没有遇到障碍物?走了多远?现在的速度是多少?前面我们已经学到了话题和服务,我们可以创建若干个话题来订阅小车的运行情况,创建多个服务来控制小车的移动。这个过程是比较繁琐的,因此 ROS2 用 Action 来直接满足对应的需求。Action 是能够实时反馈的。

8.1 Python 写法

假设有一个要让机器人转圈的动作:当任务目标 goal 发出启动 enable 后,上位机能够实时收到机器人的反馈 feedback 当前的状态 state,当机器人转完一圈以后输出结果 result 是否完成 finish

8.1.1 创建 Action 的接口

创建在前面的 srv 的同级目录下的 action 文件夹下,名字叫 MoveCircle.action

bool enable		# goal/Goal(),这个应该就是固定写法,最上面这行固定就是goal,下面也是固定已经定义好的名字
---
bool finish		# result/Result()
---
int32 state		# feedback/Feedback()

8.1.2 创建 Server

在这个控制小车的例子中,谁应该是 Server,谁是 Client?Server 是那个被请求的,根据请求的情况去调用自己的回调函数并做出响应,Client 不需要有什么回调函数,在需要的时候发出请求就好了,这么看来,Server 应该由小车来做,Client 应该就是上位机了。

编写 learn_action_client.py

import rclpy
from rclpy.node import Node
from rclpy.action import ActionServer
from witcher_pkg_interfaces.action import MoveCircle		# 自己创建的action类型
import time
from random import randrange


class ActionServerNode(Node):
    def __init__(self, node_name):
        super().__init__(node_name)
        self._action_server = ActionServer(
            self, MoveCircle, "move_circle", self.move_circle_callback
        )
        self.get_logger().info("action server init succeed.")	# 显示一下action服务初始化的情况
	
    def move_circle_callback(self, goal_handle):
        self.get_logger().info("moving circle")
        feedback_msg = MoveCircle.Feedback()

        for i in range(0, 360, 5):
            if i % 30 == 0:
                feedback_msg.state = i
                goal_handle.publish_feedback(feedback_msg)
            time.sleep(randrange(2, 8, 1) / 50)

        goal_handle.succeed()
        result = MoveCircle.Result()
        result.finish = True
        return result


def main(args=None):
    rclpy.init(args=args)
    node = ActionServerNode("action_server_node")
    rclpy.spin(node)

    node.destroy_node()
    rclpy.shutdown()

8.1.3 创建 Client

编写 learn_action_server.py

import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient

from witcher_pkg_interfaces.action import MoveCircle


class ActionClientNode(Node):
    def __init__(self, node_name):
        super().__init__(node_name)
        self._action_client = ActionClient(self, MoveCircle, "move_circle")
        self.get_logger().info("action client init succeed.")

    def send_move_circle(self, enable):
        goal_msg = MoveCircle.Goal()
        goal_msg.enable = enable
        self._action_client.wait_for_server()

        self.future = self._action_client.send_goal_async(
            goal_msg, feedback_callback=self.move_circle_feedback_callback
        )

        self.future.add_done_callback(self.move_circle_done_callback)

    def move_circle_feedback_callback(self, feedback_msg):
        feedback = feedback_msg.feedback
        self.get_logger().info(f"receive feedback: state = {feedback.state}")

    def get_result_callback(self, future):
        result = future.result().result
        self.get_logger().info(f"result: {result.finish}")

    def move_circle_done_callback(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().info("Goal rejected")
            return

        self.get_logger().info("Goal accepted")

        self.reuslt_future = goal_handle.get_result_async()
        self.reuslt_future.add_done_callback(self.get_result_callback)


def main(args=None):
    rclpy.init(args=args)
    node = ActionClientNode("action_node")
    node.send_move_circle(True)

    rclpy.spin(node)

    node.destroy_node()
    rclpy.shutdown()

8.1.4 运行结果

8.2 C++ 写法

那 C++ 的部分就负责把 Python 部分实现,接口在 Python 部分已经创建好了,所以这里就不创建了,直接创建 Server 跟 Client

但是 Action 在 C++ 的写法十分抽象。。官方给的文档甚至更抽象,无奈只好先用 DeepSeek 生成一个样例来改改了。

实现的逻辑还是和 Python 部分的代码一样,只是换成了 C++ 的实现

8.2.1 创建 server

代码很复杂,指针来指针去的。看上去好像逻辑很清晰,实际自己上手敲一遍,会当场晕过去。。。

demo_action_server.cpp

#include <rclcpp/rclcpp.hpp>

// ROS 2 Action 库头文件,提供 Action 服务端/客户端的创建和操作
#include <rclcpp_action/rclcpp_action.hpp>

// 自定义 Action 接口:MoveCircle(定义在 witcher_pkg_interfaces 包中)
#include <witcher_pkg_interfaces/action/move_circle.hpp>

// C 标准线程库(此处的 threads.h 实际应为 thread,但代码中用 sleep 函数,依赖 time.h)
// 注意:实际编译时需要 #include <thread> 和 #include <chrono>,但此处保留了原样
#include <threads.h>
#include <time.h>

// 类型别名:简化书写,之后使用 MoveCircle 代替完整类型名
using MoveCircle = witcher_pkg_interfaces::action::MoveCircle;

/**
 * @brief Action 服务端节点类
 * 
 * 功能:提供 MoveCircle Action 服务,模拟执行画圆动作(共 360 步)
 * - 支持目标接受/拒绝
 * - 支持中途取消
 * - 每 3 步发送一次进度反馈
 */
class ActionServerNode : public rclcpp::Node {
public:
    /**
     * @brief 构造函数
     * @param node_name 节点名称(外部传入)
     * 
     * 创建 Action 服务端,并绑定三个核心回调函数:
     * 1. handle_goal   - 处理目标请求(决定接受或拒绝)
     * 2. handle_cancel - 处理取消请求
     * 3. handle_accept - 目标接受后启动实际执行线程
     */
    ActionServerNode(std::string node_name) : Node(node_name) {
        // 创建 Action 服务端
        // 参数:this(节点指针), 动作名称, 三个回调函数绑定,这三个回调函数是固定顺序,而且必须要配置
        server_ = rclcpp_action::create_server<MoveCircle>(
            this,
            "move_circle_action",		// Action 服务名称
            std::bind(&ActionServerNode::handle_goal, this, std::placeholders::_1, std::placeholders::_2),	// 目标处理回调
            std::bind(&ActionServerNode::handle_cancle, this, std::placeholders::_1),						// 取消处理回调
            std::bind(&ActionServerNode::handle_accept, this, std::placeholders::_1)                        // 接受后执行回调
        );
        RCLCPP_INFO(this->get_logger(), "Action server created successful.");
    }

private:
    /**
     * @brief ① 目标处理回调
     * 
     * 当客户端发送目标时被调用,用于决定是否接受该目标
     * 
     * @param uuid 目标的唯一标识符(此处未使用,用 (void)uuid 消除编译警告)
     * @param goal 客户端发送的目标对象(包含 enable 字段)
     * @return GoalResponse 接受/拒绝该目标
     * 
     * 逻辑:如果 goal->enable 为 true,则接受并立即准备执行;
     *       否则拒绝该目标
     */
    rclcpp_action::GoalResponse handle_goal(
        const rclcpp_action::GoalUUID &uuid,		// 只读的,使用&引用可以减少一次拷贝的开销,这是这个回调函数必须配置的。
        std::shared_ptr<const MoveCircle::Goal> goal
    ) {
        (void)uuid;  // 显式忽略未使用的参数,避免编译警告

        // 根据 enable 字段决定是否接受目标
        if (goal->enable) {
            return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;  // 接受并执行
        } else {
            return rclcpp_action::GoalResponse::REJECT;              // 拒绝目标
        }
    }

    /**
     * @brief ② 取消处理回调
     * 
     * 当客户端请求取消正在执行的目标时被调用
     * 
     * @param goal_handle 当前正在执行的目标句柄
     * @return CancelResponse 是否接受取消请求
     * 
     * 逻辑:始终接受取消请求(实际取消操作在 execute 函数中处理)
     */
    rclcpp_action::CancelResponse handle_cancle(
        const std::shared_ptr<rclcpp_action::ServerGoalHandle<MoveCircle>> goal_handle	// 也使用了const,表示只读的意思
    ) {
        RCLCPP_INFO(this->get_logger(), "Cancel request received.");
        return rclcpp_action::CancelResponse::ACCEPT;  // 接受取消请求
    }

    /**
     * @brief ③ 接受目标后的执行启动回调
     * 
     * 当 handle_goal 返回 ACCEPT_AND_EXECUTE 后被调用
     * 此函数不能阻塞,因此需要创建新线程来执行实际任务
     * 
     * @param goal_handle 已接受的目标句柄
     * 
     * 实现:使用 std::thread 启动独立线程执行 execute 函数
     * .detach() 使线程与主线程分离,独立运行
     */
    void handle_accept(
        const std::shared_ptr<rclcpp_action::ServerGoalHandle<MoveCircle>> goal_handle
    ) {
        // 创建新线程执行 execute 函数,并将 goal_handle 传入
        // detach() 让线程在后台独立运行,不阻塞主线程
        std::thread{std::bind(&ActionServerNode::execute, this, std::placeholders::_1), goal_handle}.detach();
    }

    /**
     * @brief ④ 实际动作执行函数(在新线程中运行)
     * 
     * 模拟画圆动作:从 1° 到 360°,每步耗时 1 秒
     * - 每 3° 发送一次进度反馈(state = 当前角度)
     * - 每步检查是否收到取消请求,若收到则提前终止
     * - 完成后返回结果(finish = true)
     * 
     * @param goal_handle 当前执行的目标句柄
     * 
     * 流程:
     * 1. 获取目标数据(goal->enable)
     * 2. 循环 1~360 度
     * 3. 检查取消标志,若取消则设置 finish=false 并返回
     * 4. 每 3 步发送一次反馈(feedback->state = i)
     * 5. 循环结束后设置 finish=true 并标记成功
     */
    void execute(
        const std::shared_ptr<rclcpp_action::ServerGoalHandle<MoveCircle>> goal_handle
    ) {
        RCLCPP_INFO(this->get_logger(), "Executing action...");

        // 获取客户端传入的目标参数(goal 包含 enable 字段)
        auto goal = goal_handle->get_goal();

        // 创建反馈对象和结果对象(共享指针)
        auto feedback = std::make_shared<MoveCircle::Feedback>();
        auto result = std::make_shared<MoveCircle::Result>();

        // 模拟画圆:从 1° 到 360° 循环
        for (int i = 1; i <= 360; i++) {
            RCLCPP_INFO(this->get_logger(), "enter for %d...", i);

            // 【取消检查】每次循环检查是否收到取消请求
            if (goal_handle->is_canceling()) {
                RCLCPP_WARN(this->get_logger(), "Received cancle command.");
                result->finish = false;           // 设置结果为未完成
                goal_handle->canceled(result);    // 标记目标为已取消
                return;                           // 立即退出函数
            }

            // 【反馈发送】每 3 步发送一次进度反馈
            if (i % 3 == 0) {
                RCLCPP_INFO(this->get_logger(), "feedback %d", i);
                feedback->state = i;              // 反馈字段:当前角度
                goal_handle->publish_feedback(feedback);  // 发布反馈给客户端
            }

            sleep(1);  // 模拟每步耗时 1 秒(实际控制周期)
        }

        // 循环正常结束,表示动作完成
        result->finish = true;           // 设置结果为成功
        goal_handle->succeed(result);    // 标记目标为成功完成
    }

    /**
     * @brief Action 服务端智能指针
     * 
     * 类型:rclcpp_action::Server<MoveCircle>::SharedPtr
     * - 管理 Action 服务端的生命周期
     * - 在构造函数中通过 create_server 创建并赋值
     */
    rclcpp_action::Server<MoveCircle>::SharedPtr server_;
};

/**
 * @brief 程序入口函数
 * 
 * 流程:
 * 1. 初始化 ROS 2
 * 2. 创建 Action 服务端节点
 * 3. 进入事件循环(阻塞,处理回调请求)
 * 4. 收到 Ctrl+C 后退出循环,关闭 ROS 2
 */
int main(int argc, char* argv[]) {
    rclcpp::init(argc, argv);
    auto node = std::make_shared<ActionServerNode>("move_circle_action_server");
    rclcpp::spin(node);   // 阻塞等待,直到收到关闭信号

    rclcpp::shutdown();
    return 0;
}

详解:

  • #include <rclcpp_action/rclcpp_action.hpp>:C++ 中,action 相关的库函数并没有放在 rclcpp 中,而是在 rclcpp_action 中,因此需要单独再引入。
  • rclcpp_action::create_server<MoveCircle>​:创建 action 的 server,必须实现 3 个回调函数。里面的 std::bind(&A::func, this, _1, _2); 的写法也已经在前面见过了,就是传入一个回调函数。bind 实际生成一个匿名的函数对象,该函数不是类中的成员函数,它是一个临时仿函数(栈上的匿名 struct 对象),不属于任何类。传入的第一个参数需要是对象指针 this,第二个开始是形参的占位符了。
  • std::thread{std::bind(&ActionServerNode::execute, this, std::placeholders::_1), goal_handle}.detach();​:这里是开一个线程,用 {}​其实和 std::thread(...)​的效果一样,但 {} 的好处是:1. 禁止隐式窄化转换,更安全。2. 风格统一,看起来像“初始化一个线程对象”。

8.2.2 创建 client

#include <rclcpp/rclcpp.hpp>
#include <rclcpp_action/rclcpp_action.hpp>
#include <witcher_pkg_interfaces/action/move_circle.hpp>
using MoveCircle = witcher_pkg_interfaces::action::MoveCircle;
using namespace std::chrono_literals;

/**
 * @brief Action 客户端节点类
 * 
 * 功能:连接到 Action 服务端,发送画圆任务目标
 * - 等待服务端上线
 * - 发送目标(enable = true)
 * - 接收服务端反馈(当前角度 state)
 * - 接收最终结果(finish 是否成功)
 * - 结果返回后自动关闭节点
 */
class ActionClientNode : public rclcpp::Node {
public:
    ActionClientNode(std::string node_name) : Node(node_name) {
        // 创建 Action 客户端
        // 参数:this(节点指针), Action 服务名称
        client_ = rclcpp_action::create_client<MoveCircle>(
            this,
            "move_circle_action"
        );
        RCLCPP_INFO(this->get_logger(), "Action client created.");
        
        // 直接发送目标(内部会等待服务端上线)
        send_goal();
    }

private:
    /**
     * @brief 发送目标函数
     * 
     * 流程:
     * 1. 等待 Action 服务端上线(每 500ms 检查一次)
     * 2. 构造目标对象(设置 enable = true)
     * 3. 配置发送选项(绑定三个回调函数)
     * 4. 异步发送目标(不阻塞)
     */
    void send_goal() {
        // 【等待服务上线】
        // 循环等待,直到服务端可用(wait_for_action_server 超时返回 false)
        while (!client_->wait_for_action_server(500ms)) {
            RCLCPP_WARN(this->get_logger(), "Waiting for server...");
        }
        RCLCPP_INFO(this->get_logger(), "Server connected.");

        // 【构造目标】
        auto goal = MoveCircle::Goal();
        goal.enable = true;   // 启用画圆动作

        // 【配置发送选项】
        // 绑定三个回调函数:目标响应、反馈、结果
        auto goal_options = rclcpp_action::Client<MoveCircle>::SendGoalOptions();
        goal_options.goal_response_callback = 
            std::bind(&ActionClientNode::goal_response_callback, this, std::placeholders::_1);
        goal_options.feedback_callback = 
            std::bind(&ActionClientNode::feedback_callback, this, std::placeholders::_1, std::placeholders::_2);
        goal_options.result_callback = 
            std::bind(&ActionClientNode::result_callback, this, std::placeholders::_1);

        // 【异步发送目标】
        // 立即返回,不会阻塞,后续通过回调处理响应
        client_->async_send_goal(goal, goal_options);
    }

    /**
     * @brief ① 目标响应回调
     * 
     * 当服务端处理目标请求后被调用,告知目标是否被接受
     * 
     * @param goal_handle 目标句柄(若被拒绝则为 nullptr)
     * 
     * 逻辑:
     * - 若 goal_handle 为空 → 目标被拒绝,打印错误并关闭节点
     * - 否则打印成功信息,并保存句柄供后续使用
     */
    void goal_response_callback(
        const rclcpp_action::ClientGoalHandle<MoveCircle>::SharedPtr &goal_handle
    ) {
        if (!goal_handle) {
            // 目标被拒绝
            RCLCPP_ERROR(this->get_logger(), "Goal was rejected.");
            rclcpp::shutdown();   // 关闭节点
            return;
        }

        RCLCPP_INFO(this->get_logger(), "Goal accepted");
        goal_handle_ = goal_handle;   // 保存句柄(可用于取消等操作)
    }

    /**
     * @brief ② 反馈回调
     * 
     * 当服务端发布反馈时被调用(在 execute 循环中每 3 度触发)
     * 
     * @param goal_handle 目标句柄(此处未使用,形参留空)
     * @param feedback 反馈对象(包含 state 字段,即当前角度)
     * 
     * 逻辑:打印当前进度(角度值)
     */
    void feedback_callback(
        rclcpp_action::ClientGoalHandle<MoveCircle>::SharedPtr,      // 未使用,可省略变量名
        const std::shared_ptr<const MoveCircle::Feedback> feedback
    ) {
        RCLCPP_INFO(this->get_logger(), "Received feedback, status: %d", feedback->state);
    }

    /**
     * @brief ③ 结果回调
     * 
     * 当动作执行完成(成功、取消或异常)时被调用
     * 
     * @param result WrappedResult 包含结果数据和状态码
     * 
     * 逻辑:
     * - 根据 result->result->finish 判断是否成功完成
     * - 打印相应信息
     * - 无论成功失败,都关闭节点(程序退出)
     */
    void result_callback(
        const rclcpp_action::ClientGoalHandle<MoveCircle>::WrappedResult &result
    ) {
        // 判断最终结果
        if (result.result->finish) {
            RCLCPP_INFO(this->get_logger(), "Goal run result is successful.");
        } else {
            RCLCPP_ERROR(this->get_logger(), "Goal run result is failure.");
        }
        rclcpp::shutdown();   // 收到结果后关闭节点
    }

    /**
     * @brief 客户端智能指针
     * 
     * 类型:rclcpp_action::Client<MoveCircle>::SharedPtr
     * 用于与 Action 服务端通信,由 create_client 创建
     */
    rclcpp_action::Client<MoveCircle>::SharedPtr client_;

    /**
     * @brief 目标句柄智能指针
     * 
     * 类型:rclcpp_action::ClientGoalHandle<MoveCircle>::SharedPtr
     * 用于标识当前正在执行的目标,可用于取消操作
     * 在 goal_response_callback 中赋值
     */
    rclcpp_action::ClientGoalHandle<MoveCircle>::SharedPtr goal_handle_;
};

/**
 * @brief 程序入口函数
 * 
 * 流程:
 * 1. 初始化 ROS 2
 * 2. 创建 Action 客户端节点(构造时自动发送目标)
 * 3. 进入事件循环(等待回调完成)
 * 4. 收到结果后,节点内部调用 shutdown(),退出循环
 * 5. 关闭 ROS 2
 */
int main(int argc, char* argv[]) {
    rclcpp::init(argc, argv);
    auto node = std::make_shared<ActionClientNode>("move_circle_client_node");
    rclcpp::spin(node);   // 阻塞等待,直到节点内部调用 rclcpp::shutdown()

    rclcpp::shutdown();
    return 0;
}

8.2.3 CMakeLists.txt

为了能够使用 rclcpp_action 包,并且添加编译出 demo_action_client/server 的可执行文件,需要进行一些修改:

cmake_minimum_required(VERSION 3.8)
project(witcher_pkg_cpp)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()


# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclcpp_action REQUIRED)
find_package(std_msgs REQUIRED)
find_package(std_srvs REQUIRED)
find_package(witcher_pkg_cpp REQUIRED)
find_package(witcher_pkg_interfaces REQUIRED)
find_package(rosidl_default_generators REQUIRED)

set(COMMON_ROS_DEPS
  rclcpp
  rclcpp_action
  std_msgs
  std_srvs
  witcher_pkg_cpp
  witcher_pkg_interfaces
)

include_directories(
  ${PROJECT_SOURCE_DIR}/src
)

add_executable(${PROJECT_NAME}_node src/${PROJECT_NAME}_node.cpp)
add_executable(demo_sub src/demo_sub.cpp)
add_executable(demo_pub src/demo_pub.cpp)
add_executable(demo_server src/demo_server.cpp)
add_executable(demo_client src/demo_client.cpp)
add_executable(demo_pub_book src/demo_pub_book.cpp)
add_executable(demo_sub_book src/demo_sub_book.cpp)
add_executable(demo_action_server src/demo_action_server.cpp)
add_executable(demo_action_client src/demo_action_client.cpp)

target_include_directories(${PROJECT_NAME}_node PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include>)

ament_target_dependencies(${PROJECT_NAME}_node ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_sub ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_pub ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_server ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_client ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_sub_book ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_pub_book ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_action_server ${COMMON_ROS_DEPS})
ament_target_dependencies(demo_action_client ${COMMON_ROS_DEPS})

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Book.msg"
)

install(TARGETS ${PROJECT_NAME}_node demo_sub demo_pub demo_client demo_server demo_sub_book demo_pub_book demo_action_server demo_action_client
  DESTINATION lib/${PROJECT_NAME}
)

ament_package()

8.2.4 运行结果

9 Param 参数

param 是全局参数,可以给 node 节点设置一些全局参数,作为全局的字典存在,都是一些键值对,就是 <key, value> 的类似。

9.1 CLI 指令

ros2 param list		// 查看所有参数
ros2 param describe [节点名] [参数名]		// 查看参数详细描
ros2 param set [节点名] [参数名] [目标值]	// 修改参数名
ros2 param get [节点名] [参数名]			// 获取参数名
ros2 param dump [节点名]					// 输出节点的全部参数名称
ros2 param load [节点名]	[参数文件]		// 加载参数文件到对应节点

9.2 Python 写法

创建这种全局可以被修改、查询的参数,只需要通过对 node 节点调用相关的函数即可,因为比较简单就不做实验了,拿个官方的样例来看看:

import rclpy
import rclpy.node

class MinimalParam(rclpy.node.Node):
    def __init__(self):
        super().__init__('minimal_param_node')

		# node节点使用declare_parameter声明一个参数与参数值,每需要一个参数就要声明一次
		# 这里声明一个名叫my_parameter的参数,值为world
        self.declare_parameter('my_parameter', 'world')

        self.timer = self.create_timer(1, self.timer_callback)

    def timer_callback(self):
		# 读取param参数,也是通过node节点的get_parameter来获取,记住写法就行
        my_param = self.get_parameter('my_parameter').get_parameter_value().string_value

        self.get_logger().info('Hello %s!' % my_param)
		
		# 这是要修改参数值,可以看得出是创建了一个新的参数值,然后下方通过set_parameters设置这个值
        my_new_param = rclpy.parameter.Parameter(
            'my_parameter',
            rclpy.Parameter.Type.STRING,
            'world'
        )
        all_new_parameters = [my_new_param]	# set_parameters应该是可以一次设置很多值,用列表包一下
        self.set_parameters(all_new_parameters)

def main():
    rclpy.init()
    node = MinimalParam()
    rclpy.spin(node)

if __name__ == '__main__':
    main()

param 参数的创建:node.declare_parameter{'参数1', '参数2', ......}

param 参数的获取:param = node.get_parameter('参数1').get_parameter_value().string_value

param 参数值的修改:node.set_parameters([rclpy.parameter.Parameter('参数名', 参数类型如rclpy.Prameter.Type.STRING, '目标值')])​,最后调用 set_parameters 对参数值进行更新

9.3 C++ 写法

C++ 这里也是难得少见的简单,调用的函数名称和 Python 的一样,那同样也是用官方给的案例来做做样子

#include <chrono>
#include <functional>
#include <string>

#include <rclcpp/rclcpp.hpp>

using namespace std::chrono_literals;

class MinimalParam : public rclcpp::Node
{
public:
  MinimalParam()
  : Node("minimal_param_node")
  {
	// 这里声明了一个可以被修改的参数
    this->declare_parameter("my_parameter", "world");

    timer_ = this->create_wall_timer(
      1000ms, std::bind(&MinimalParam::timer_callback, this));
  }

  void timer_callback()
  {
	// 这里获取参数的值
    std::string my_param = this->get_parameter("my_parameter").as_string();

    RCLCPP_INFO(this->get_logger(), "Hello %s!", my_param.c_str());

	// 如果要设置值,就新创建一个parameter,然后包成一个vector以后传入给set_parameters函数
    std::vector<rclcpp::Parameter> all_new_parameters{rclcpp::Parameter("my_parameter", "world")};
    this->set_parameters(all_new_parameters);
  }

private:
  rclcpp::TimerBase::SharedPtr timer_;
};

int main(int argc, char ** argv)
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<MinimalParam>());
  rclcpp::shutdown();
  return 0;
}

10 Plugins 插件(拓展阅读)

官方文档原文翻译:pluginlib 是一个 C++ 库,用于在 ROS 软件包内部加载和卸载插件。插件是可动态加载的类,从运行时库(即共享对象、动态链接库)中载入。使用 pluginlib 时,应用程序无需显式链接包含这些类的库;pluginlib 可以在任意时刻打开包含导出类的库,而应用程序事先不需要知道该库,也不需要包含对应类定义的头文件。插件适用于无需应用源码,即可扩展、修改应用行为的场景。

看着看难理解,问了一下 DeepSeek:ROS 2 中的插件是一种能在运行时动态加载和卸载功能模块的机制 它允许你像“搭乐高”一样,将一个复杂的机器人系统拆分成多个独立、可插拔的“积木块”。插件机制是构建大型、可维护、可扩展的 ROS 2 系统的核心支柱。

10.1 基本概念

10.1.1 插件有什么用?

插件机制的核心价值在于解决机器人软件开发中的“牵一发而动全身”问题。它的主要用途包括:

  • 在不修改、不重新编译主程序的情况下,动态地扩展或修改系统功能。
  • 实现功能模块的独立开发、测试和部署,不同团队可以并行开发各自的插件。
  • 构建可插拔的软件架构,主程序如导航框架 Nav2、可视化工具 RViz 只提供“插槽”,具体算法由插件实现。
  • 实现“热插拔”,在不重启整个系统的情况下加载新功能,非常适合需要频繁迭代或支持多种硬件的机器人系统。

10.1.2 使用插件的优势

  • 高度解耦,提升可维护性​:核心框架只依赖稳定的接口(基类),不依赖任何具体的算法实现。这使得代码结构清晰,降低了维护成本。
  • 动态扩展,增强灵活性​:可以在系统运行时决定加载哪个功能模块。例如,导航系统可以在运行时切换不同的路径规划算法。
  • 促进代码复用​:一个插件可以在多个项目中被直接使用,无需复制代码。
  • 便于构建生态系统:第三方开发者可以在不修改 ROS 2 源码的情况下,为系统贡献新的功能插件。

10.1.3 ROS 2 插件技术栈 pluginlib

  • pluginlib:一个 C++ 库,专门用于从 ROS 包中加载和卸载插件。
  • 比直接使用 dlopen 更好:pluginlib 提供了类型安全的 C++ 接口,能确保加载的插件符合基类规范,避免了直接操作动态链接库(dlopen/dlsym)可能出现的类型错误。

10.2 C++ 实现一个插件

开发和使用一个 ROS 2 插件通常遵循以下四个步骤:

  1. 定义接口:创建一个包含纯虚函数的基类(抽象类)。
  2. 实现插件​:创建新的类,继承自基类并实现其所有纯虚函数。
  3. 注册插件:在插件类的源文件末尾,使用 pluginlib​提供的,如 PLUGINLIB_EXPORT_CLASS 将插件类注册到系统中。
  4. 加载插件:主程序通过 pluginlib​提供的 class_loader​,在运行时根据配置动态加载所需的插件库(.so 文件)。

10.2.1 定义接口

ros2 pkg create --build-type ament_cmake --license Apache-2.0 --dependencies pluginlib --node-name area_node polygon_base

include/polygon_base/​添加一个 regular_polygon.hpp 文件,并复制以下内容:

#ifndef POLYGON_BASE_REGULAR_POLYGON_HPP
#define POLYGON_BASE_REGULAR_POLYGON_HPP

namespace polygon_base
{
  class RegularPolygon
  {
    public:
      virtual void initialize(double side_length) = 0;
      virtual double area() = 0;
      virtual ~RegularPolygon(){}

    protected:
      RegularPolygon(){}
  };
}  // namespace polygon_base

#endif  // POLYGON_BASE_REGULAR_POLYGON_HPP

上述代码创建了一个名为 RegularPolygon​(正多边形)的抽象类。需要注意的是代码中存在 initialize​初始化方法。使用 pluginlib 时,​要求类必须有无参构造函数​,因此如果类需要传入参数,我们就通过 initialize 方法将参数传递给对象。

我们需要将该头文件导出为接口库,以供其他类使用。为此需要修改 CMakeLists.txt

find_package(pluginlib REQUIRED) 后添加

# 库 (这将被用于插件的基类)
add_library(${PROJECT_NAME} INTERFACE)
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS ${PROJECT_NAME})
target_compile_features(${PROJECT_NAME} INTERFACE c_std_99 cxx_std_17)
target_include_directories(${PROJECT_NAME} INTERFACE
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include/${PROJECT_NAME}>
)
target_link_libraries(${PROJECT_NAME} INTERFACE ${pluginlib_TARGETS})

# 将这个头文件安装到ROS2的include中,可以被任意文件引用
install(DIRECTORY include/
  DESTINATION include/${PROJECT_NAME}
)

# 安装库并导出到目标
install(TARGETS ${PROJECT_NAME}
  EXPORT export_${PROJECT_NAME}
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin
)
install(EXPORT export_${PROJECT_NAME}
  NAMESPACE ${PROJECT_NAME}::
  DESTINATION share/${PROJECT_NAME}/cmake
)

ament_package 前添加

# Export old-style CMake variables
ament_export_include_directories(
  include
)

# Export modern CMake targets
ament_export_targets(
  export_${PROJECT_NAME}
)

10.2.2 实现并注册插件

ros2 pkg create --build-type ament_cmake --license Apache-2.0 --dependencies polygon_base pluginlib --library-name polygon_plugins polygon_plugins

修改 polygon_plugins.cpp 内容:

#include <polygon_base/regular_polygon.hpp>
#include <cmath>

namespace polygon_plugins
{
  class Square : public polygon_base::RegularPolygon
  {
    public:
      void initialize(double side_length) override
      {
        side_length_ = side_length;
      }

      double area() override
      {
        return side_length_ * side_length_;
      }

    protected:
      double side_length_;
  };

  class Triangle : public polygon_base::RegularPolygon
  {
    public:
      void initialize(double side_length) override
      {
        side_length_ = side_length;
      }

      double area() override
      {
        return 0.5 * side_length_ * getHeight();
      }

      double getHeight()
      {
        return sqrt((side_length_ * side_length_) - ((side_length_ / 2) * (side_length_ / 2)));
      }

    protected:
      double side_length_;
  };
}

// 注册插件:引入pluginlib库
#include <pluginlib/class_list_macros.hpp>
// 注册插件:通过PLUGINLIB_EXPORT_CLASS宏将正方形和三角形的插件编入系统插件库中
PLUGINLIB_EXPORT_CLASS(polygon_plugins::Square, polygon_base::RegularPolygon)
PLUGINLIB_EXPORT_CLASS(polygon_plugins::Triangle, polygon_base::RegularPolygon)

Square​(正方形)类与 Triangle​(三角形)类的实现相当简单:保存边长,并利用边长计算面积。只有最后三行是 pluginlib​专属代码,这里调用了一些特殊宏,用于将这些类注册为可用插件。我们来看一下 PLUGINLIB_EXPORT_CLASS 宏的各个参数:

  1. 插件类的​完整限定类型名​,本例中为 polygon_plugins::Square
  2. 基类的完整限定类型名,本例中为 polygon_base::RegularPolygon

声明一个 XML 文件:

完成上述步骤后,当插件所在库被加载时就能够创建插件实例,但插件加载器仍然需要一种方式找到该库,并且知晓库内部要引用哪些内容。为此,我们还需要创建一个 XML 文件,再配合功能包清单里一条专用导出语句,让 ROS 工具链能够获取插件的全部必要信息。

在根目录下创建文件 plugins.xml

<library path="polygon_plugins">
  <class type="polygon_plugins::Square" base_class_type="polygon_base::RegularPolygon">
    <description>This is a square plugin.</description>
  </class>
  <class type="polygon_plugins::Triangle" base_class_type="polygon_base::RegularPolygon" name="awesome_triangle">
    <description>This is a triangle plugin.</description>
  </class>
</library>

几点注意事项:

  • library​标签给出包含待导出插件的库的相对路径。​在 ROS2 中,这里直接填写库名即可​。而 ROS1 中需要带上 lib​前缀,有时写法为 lib/libpolygon_plugins,ROS2 此处写法更加简洁。

  • class 标签用于声明我们要从库中导出的插件。下面说明它的各个参数:

    • type​:插件的完整限定类型。本例为 polygon_plugins::Square
    • base_class​:插件对应的基类完整限定类型。本例为 polygon_base::RegularPolygon
    • description:插件及其功能的描述信息。
    • name(可选):类加载器用来查找插件的别名(可理解为自定义标识名)。

最后一步,打开 CMakeLists.txt​,在 find_package(pluginlib REQUIRED) 后面添加:

pluginlib_export_plugin_description_file(polygon_base plugins.xml)

这表明,需要用到基础类 polygon_base​,插件声明 XML 文件的相对路径 plugins.xml

10.2.3 加载插件

回到上一个创建的包 polygon_base 中,修改 area_node.cpp 文件

#include <pluginlib/class_loader.hpp>
#include <polygon_base/regular_polygon.hpp>

int main(int argc, char** argv)
{
  // To avoid unused parameter warnings
  (void) argc;
  (void) argv;

  pluginlib::ClassLoader<polygon_base::RegularPolygon> poly_loader("polygon_base", "polygon_base::RegularPolygon");

  try
  {
    std::shared_ptr<polygon_base::RegularPolygon> triangle = poly_loader.createSharedInstance("awesome_triangle");
    triangle->initialize(10.0);

    std::shared_ptr<polygon_base::RegularPolygon> square = poly_loader.createSharedInstance("polygon_plugins::Square");
    square->initialize(10.0);

    printf("Triangle area: %.2f\n", triangle->area());
    printf("Square area: %.2f\n", square->area());
  }
  catch(pluginlib::PluginlibException& ex)
  {
    printf("The plugin failed to load for some reason. Error: %s\n", ex.what());
  }

  return 0;
}

ClassLoader​ 是需要重点理解的核心类,定义在头文件 class_loader.hpp 中:

该类以基类作为模板参数,示例中基类为 polygon_base::RegularPolygon

构造函数第一个参数是字符串,代表基类所在的功能包名,本例为 polygon_base​。 第二个参数是字符串,传入插件基类的完整限定类型,本例为 polygon_base::RegularPolygon

实例化插件类对象有多种方式。本示例使用共享指针。只需调用 createSharedInstance​ 并传入插件的检索标识即可:该标识既可以是插件类的完整限定类型(即插件声明 XML 文件中 type​属性的值,例如 polygon_plugins::Square​),也可以是可选的自定义别名(XML 文件中 name​属性的值,例如 awesome_triangle)。

重要提示:该节点所在的 polygon_base​ 功能包并不依赖 polygon_plugins 功能包。插件是动态加载的,不需要在编译脚本中声明依赖关系。此外,示例中是硬编码插件名称完成实例化,你也可以通过参数等方式动态指定插件。

编译运行:

执行指令编译:

colcon build --packages-select polygon_base polygon_plugins

执行指令查看插件:

ros2 plugin list | grep polygon

测试使用插件的代码:

ros2 run polygon_base area_node

// 结果
Triangle area: 43.30
Square area: 100.00

10.3 插件(Plugin)和组件(Component)的区别

  • 组件(Component)是一种特殊的插件​:它本身就是一个​ROS 2 节点(Node)
  • 插件(Plugin)是更通用的概念:它可以是一个独立的算法类,不必是一个完整的 ROS 2 节点。

一个实用的建议是:在能满足需求的前提下,优先考虑使用组件(Component) ,因为它能更好地融入 ROS 2 的节点生命周期管理。

10.4 进一步思考

  • 疑问 1:如果只是合作开发的话,定义好 interface,各自开发不同的包不就行了吗?没有非要用到插件的必要吧?那个插件最大最大的优点是不是就是能够热插拔,那除非说是整个系统已经非常非常庞大啊,就重新编译重新启动可能会耗时十几到几十分钟的情况下,那这时候使用插件才是一个有必要的选择,不然插件可能确实挺鸡肋的,是吗?

    • :插件最大的、不可替代的核心优势,其实不是“避免重启”,而是“进程内(Intra-process)通信带来的极致性能”和“零侵入式的框架扩展”。举 2 个极端的例子。

      1. 性能差距:假设你在做高速激光雷达 SLAM 或机械臂的力控,频率是 1000Hz(即每毫秒发一帧数据)。在高速数据流场景下,“独立包”因为跨进程通信,性能上限远低于“插件”。这不是重启慢不慢的问题,而是能不能跑起来的问题。

        • 用包(独立节点) :节点 A(驱动)通过 Topic 发数据 → 数据要被序列化成二进制流 → 通过 DDS(底层网络/共享内存)传输 → ​节点 B(算法)反序列化​。这一来一回,即便使用共享内存,也有几毫秒的拷贝和序列化开销。在 1000Hz 下,CPU 直接飙满,甚至丢帧。
        • 用插件(Plugin) :主进程直接加载你的算法插件。发布者和订阅者可以开启“进程内通信”。这时候,数据不需要序列化,直接传递智能指针(shared_ptr)。发布者把数据放到内存里,订阅者直接拿到指针读取,零拷贝(Zero-copy)。
      2. 框架的扩展性:让第三方无缝接入(Nav2 和 RViz 的玩法)。如果你的系统是一个大型框架(比如导航框架 Nav2),你作为框架的作者,你根本不知道未来用户会写什么名字的包,你没法在代码里硬编码去启动别人的节点。

        • 用包(独立节点) :用户写了一个新的全局路径规划器包。他必须修改你的框架 Launch 文件,或者你必须提供一个巨大的配置文件,写上 node_name: "my_planner_node",然后框架通过 System 调用启动子进程。这样做很重,且框架对子进程的控制权很弱(没法直接调用类内部函数)。
        • 用插件(Plugin) :框架作者只定义接口基类(虚函数),不关心实现。用户把算法类导出为插件,放到系统里。框架启动时,通过 pluginlib 自动扫描发现这个插件,直接把它加载进自己的进程内存里。此时,框架直接持有这个类的对象指针,可以毫秒级地调用插件内部的 API,甚至直接读取插件的成员变量。

参考文献

  1. Beginner: Client libraries — ROS 2 Documentation: Humble documentation
  2. 11.动作:完整行为的流程管理_哔哩哔哩_bilibili