新建   展示   相册  列表  网摘
 

第2章 ROS 2 基础入门— 学习笔记

第2章 ROS 2 基础入门——从第一个节点开始

通过第1章的学习,你已经掌握了如何安装 ROS 2 以及如何使用命令行来启动一个ROS 2 的程序。

不过,仅满足于此显然是不够的,因为掌握 ROS 2 的安装和启动程序只算入门了一半,是时候再学习一些新的东西了。

今天我们来学习在哪里编写ROS 2 的程序,以及如何编译代码生成可执行文件。

 


 

2.1 编写你的第一个节点

在第 1 章中,我们运行的海龟模拟器和键盘控制器都会生成一个相应的 ROS 2 节点,模拟器节点会订阅来自键盘控制节点的话题,实现控制指令的传递。

所以,你可能会认为 ROS2 的节点就是可以进行话题订阅或发布的可执行的程序。

聪明如你,的确如此,但你还是低估了节点的作用,节点除了可以订阅和发布话题外,还可以使用服务、配置参数和执行动作等。

俗话说得好:“千学不如一看,千看不如一练。”接下来我们将分别使用 Python 和 C++来编写第一个 ROS 2 节点。

2.1.1 Python示例

ROS 2 提供了丰富的 Python 版本的客户端接口库,让你通过简单的调用即可完成节点的创建。 在主目录下创建chapt2/文件夹,并用VS Code打开该文件夹,接着创建ros2_pythonnode.py 文件,在文件中编写代码清单 2-1 的内容。

代码清单 2-1 一个最简单的 Python 节点

import rclpy from rclpy.node

import Node

def main():

  rclpy.init () node = Node ("python_node")

  rclpy.spin (node)

  rclpy.shutdown ()

if _name_=="_main main ()

 

如代码清单 2-1所示,首先导入ROS 2 提供的Python版本客户端库rclpy,从rclpy库的node 模块中导入 Node 类。

然后定义了一个 main 函数,在函数里调用 relpy 的 init 方法为接下来的通信分配资源,接着创建一个名为python_node的Node类实例,有了node实例,就可以通过它来订阅或者发布话题了,通信并不是本节的重点,所以这里没有进行任何操作。

创建完节点后,使用spin方法启动该节点,spin方法恰如其名,它会不断地循环检查被其运行的节点是否收到新的话题数据等事件,直到该节点被关闭为止。之后,rclpy.shutdown()方法用于清理分配的资源并确认节点是否被关闭。

在了解了每一行代码的作用后,运行代码,按 Ctrl+Shift+~键可以快速地在 VS Code 内打开集成终端,输入代码清单 2-2 中的命令运行代码。

代码清单 2-2 使用 Python 执行节点 $ python3 ros2_python_node.py 运行后会发现终端并没有任何输出和提示,此时不要怀疑代码有问题,节点其实已经运行起来了。

按 Ctrl+Shift+5 键可以在 VS Code原有的终端旁添加一个新的终端,在终端中输入代码清单 2-3 所示的命令。

代码清单 2-3使用命令行查询节点列表 $ ros2 node list /python_node 代码清单 2-3 中的 ros2 node list 是 ROS 2 命令行工具的节点模块下的命令之一,用于查看当前的节点列表,看到/python_node就代表我们的第一个ROS 2节点启动成功了。

但启动后没有一点提示显然不太友好,所以修改代码清单 2-1 中的 main 函数,加一句输出,如代码清单 2-4 所示。

代码清单2-4 添加输出的节点代码 def main(): rclpy.init () node = Node ("python_node") rclpy.shutdown () node.get_logger().info(·你好 Python 节点!') rclpy.spin (node) 这里我们加了一句输出指令,但并没有使用你所熟悉的 print 函数,而是先通过node 实 例调用 get_logger()获取日志记录器,接着调用日志记录器的 info 方法输出了一句话。

接着来运行测试一下,在刚刚运行节点的终端里,按 Ctrl+C 键,该命令可以打断当前终端运行的程序,输入代码清单2-5所示的命令并运行。

 

代码清单2-5 运行带日志输出的Python节点 $ python3 ros2_python_node.py [INFO] [1699126891.009349500] [python_node]: 你好 Python 节点! 可以看到这里不仅输出了我们想要的内容,还输出了日志的级别、时间和节点信息。

如果你想查看更多日志信息,可以通过环境变量 RCUTILS_CONSOLE_OUTPUT-FORMAT修改输出的日志格式,使用如代码清单 2-6 所示的设置就可以在输出消息的同时输出代码所在的函数和行号。

代码清单 2-6 使用环境变量输出更多的信息 $ export RCUTILS_CONSOLE_OUTPUT_FORMAT= [{function_name}:{line_number}] : {message) $ python3 ros2_python_node.py [main:7] :你好 Python 节点! 使用()包含特定单词就可以表示对应的消息,除了上面代码使用的三个外,还有表示日志级别的severity、表示日志记录器名的 name、表示文件名字的file_name、表示时间戳的time以及表示纳秒时间戳的time_asnanoseconds。

第一个Python节点到这里就算写完了,但在写代码的时候好像没有提示,这是因为没有安装 Python 插件。

如图 2-1 所示,打开 VS Code 的扩展,搜索 Python,安装第一个插件即可。 a 1 Python Extension M 45 图 2-1 安装插件 再次编辑代码时你会发现如图 2-2 所示的提示。把鼠标指针长时间悬停在某个函数上,函数注释就会随之跳出,如图2-3所示。 456789 扩展:商店 Python Python Ф105.4M IntelliSense (Pylance), Linting ◆Microsoft 安装 Python Indent 0 7.3M Correct Python indentation Kevin Rose 安装 def main(): rclpy.init() node Node("python node" prt (method) def info( message: Any, **kwargs: Any node.get_logger().1 rclis enabled for init nar init subclass rcl;@ info ) -> bool tho Log a message with INFO severity via :py:classmethod:RcutilsLogger.log:. if ().info('你好 Python 节点!'); 图 2-2 代码提示 图 2-3函数注释

 

2.1.2 C++ 示例 都说“人生苦短,我用Python”,但Python作为解释型语言,因为运行效率问题,在实际的机器人产品开发中并不占优势。所以在学习如何使用 Python 编写节点的同时,还可以学习一下 C++ 的实现方式,互相取长补短。 用VS Code打开chapt2/文件夹,新建ros2_cpp_node.cpp文件,在文件中编写如代码清单 2-7 所示的代码。

代码清单 2-7 一个最简单的 C++节点 #include "rclcpp/rclcpp.hpp" int main(int argc, char **argv) rclcpp: :init (arge, argv); auto node = std: :make_shared<rclcpp::Node>("cpp_node"); RCLCPP_INFO (node->get_logger(),"你好 C++ 节点!"); rclcpp: : spin (node); rclcpp::shutdown (); return 0; 在代码清单 2-7 中,首先包含了relepp下的relepp.hpp 这两个头文件。

然后在主函数里调用 init 函数进行初始化并分配资源,为接下来的通信做好准备。接着调用 std::make_shared传入节点名来构造一个名为rclepp::Node的对象,并返回该对象的智能指针。

其中auto是类型推导,会根据返回值推导 node的类型。之后通过宏定义RCLCPP_INFO调用节点的日志记录器输出日志,再使用spin函数启动节点并不断循环检测处理事件。

最后在结束时调用rclepp::shutdown() 清理资源。 如果你有 C 语言基础,看懂代码清单 2-7 中的内容并不困难,可能会让你产生困惑的是创建节点对象所用的智能指针,因为它是 C++ 11 中的一个新特性,这里你只需要简单了解即可,后续介绍 ROS 2 基础时会再次学习这部分内容。

另外,“:”符号是作用域解析运算符,用于访问命名空间或类中的元素,比如init、spin和shutdown函数都在relepp这个命名空间下,所以需要使用rclepp::加函数名进行调用。

和普通C++程序一样,需要对代码进行编译才能生成可执行文件。对于复杂的代码,我们使用CMake工具来构建,仿照1.4.4 节的CMakeLists.txt,在chapt2/下编写同名文件,内容如代码清单 2-8 所示。

代码清单 2-8 chapt2/CMakeLists.txt cmake_minimum_required (VERSION 3.8) project (ros2_cpp) add_executable (ros2_cpp_node ros2_cpp_node.cpp) 依然使用CMake构建编译,打开终端,进入chapt2/目录,接着输入如代码清单2-9 所示的命令。

代码清单2-9 生成可执行文件 $ cmake The c compiler identification is GNU 11.4.0 Build files have been written to: /home/fishros/chapt2 $ make /home/fishros/chapt2/ros2_cpp_node.cpp:1:10: fatal error: rclcpp/rclcpp.hpp: 没有那个文件或目录 1| #include "rclcpp/rclcpp.hpp" compilation terminated. make [2]: [CMakeFiles/ros2_cpp_node.dir/build.make:76: CMakeFiles/ros2_cpp_ node.dir/ros2_cpp_node.cpp.o] 错误 1 make[1]: *** [CMakeFiles/Makefile2:83: CMakeFiles/ros2_cpp_node.dir/all] i 2 make: *** [Makefile:91: all] 错误2 很抱歉,使用make命令进行编译时你将看到如代码清单 2-9 所示的报错信息,这是因为代码中包含了relepp.hpp头文件,但是这个头文件并不在系统默认头文件目录,而是在ROS 2的安装目录下,可以通过CMake指令来为ros2_cpp_node查找并添加依赖,在chapt2/CMakeLists.txt 末尾追加代码清单 2-10 中的指令。

代码清单 2-10 查找并添加依赖 # 查找 rclcpp头文件和库文件的路径 find_package (rclcpp REQUIRED) # 给可执行文件包含头文件 target_include_directories(ros2_cpp_node PUBLIC ${rclcpp_INCLUDE_DIRs)) # 给可执行文件链接库文件 target_link_libraries (ros2_cpp_node ${rclcpp_LIBRARIES)) 代码清单 2-10 中的三行指令完成了依赖库查找、头文件路径添加和动态库链接三个步骤,其中find_package指令会从更多的目录查找依赖,在查找到rclepp 的头文件和库文件后,还会嵌套查找 relepp 所需的依赖。

添加后再次执行 make操作,就可以看到可执行文件 ros2_cpp_node 已经生成,命令及结果如代码清单 2-11 所示。 代码清单 2-11 运行节点 $ ./ros2_cpp_node 再打开另外一个终端,依次输入代码清单 2-12 中的两条命令。 [INFO] [1698472590.755824396] [cpp_node] : 你好 C++ 节点! 代码清单 2-12 查看节点列表及信息 $ ros2 node list

/cpp_node $ ros2 node info /cpp_node Subscribers: /parameter_events: rcl_interfaces/msg/ParameterEvent Publishers: /parameter_events: rcl interfaces/msg/ParameterEvent /rosout: rcl_interfaces/msg/Log Service Servers: /cpp_node/describe_parameters: rcl_interfaces/srv/DescribeParameters /cpp_node/get_parameter_types: rcl_interfaces/srv/GetParameterTypes /cpp_node/get_parameters: rcl_interfaces/srv/GetParameters /cpp_node/list_parameters: rcl_interfaces/srv/ListParameters /cpp_node/set_parameters: rcl_interfaces/srv/SetParameters /cpp_node/set_parameters_atomically: rcl_interfaces/srv/ SetParametersAtomically Service Clients: Action Servers: Action Clients: 可以看到终端第一条节点列表命令返回了/cpp_node,而第二条指令 ros2 node info 是ROS 2 命令行工具节点模块下的另一个命令,用于查看指定节点信息,运行之后可以看到其返回了节点的订阅者、发布者和服务等相关信息。

到这里你的第一个 C++ 版的 ROS 2 节点就完成了,但上面的操作其实为接下来的学习留下了小陷阱,因为CMakeLists.txt 文件会被接下来要学习的ROS 2 构建工具搜索和误用,所以为了接下来的学习能够顺利进行,请你将chapt2/CMakeLists.txt文件删除或者换个名字。除此之外,你在写代码的过程中有没有发现居然没有代码提示?答案应该是没有,原因是我们没有给 VS Code安装并配置C++插件。

打开VS Code扩展,搜索C++ Extension,安装如图 2-4 所示的插件。 扩展:商店 C++ Extension C/C++ Extension Pack Popular extensions for C++ developn Microsoft 图2-4 安装 C/C++ Extension Pack 安装完成后,回到代码文件。如图2-5所示,此时可以看到包含头文件那一行出现了红色的波浪线,单击该行,VS Code 会出现一个快速修复灯泡,单击“编辑‘includePath’设置”,跳转到设置界面。

 

C ros2_cpp_node.cpp 2 × #include "rclcpp/rclcpp.hpp" C ros2_cpp_node.cpp>... 3 **argv) ke share 包含路径 include路径是包括源文件中随附的头文件(如#include "myHeaderFile.h")的文件夹。指定IntelliSense引擎在搜索包含的头文件时要使用的列表路径。对这些路径进行的搜索不是递归搜索。指定··可指示递归搜索。

例如,$(workspaceFolder)/*·将搜索所有子目录,而${workspaceFolder)则不会。如果在安装了Visual Studio的Windows上,或者在 compilerPath 设置中指定了编译器,则无需在此列表中列出系统 include 路径。

每行一个包含路径。 S(workspaceFolder)/ /opt/ros/${ROS_DISTRO}/include/* 56 快速修复… 编辑"includePath"设置 argv); 启用所有错误波形曲线 图 2-5 编辑头文件路径 在设置界面添加ROS 2头文件所在的目录/opt/ros/S {ROS_DISTRO}/include/**,如图2-6 所示,然后单击输入框外的空白处即可保存。

图 2-6 包含 ROS 2 安装路径 再打开刚刚报错的代码,你会发现,头文件报错已经消失,如图 2-7 所示的代码提示和如图 2-8 所示的函数注释都可以正常使用了。 rclcpp::init(argc, argv); auto node = std::make_shared<rclcpp::Node>("cpp_node"); RCLCPP_INFO(node->get_l rclcpp::spín(node): get_logger rclcpp::Logger rclcpp: :Node: rclcpp::shutdown(); @get_node_logging_interface return θ; @get_clock 图 2-7 检查代码提示 pc, cha rclcpp::Logger rclcpp::Node::get_logger() const (argc, 返回: std::m The logger of the node node->get_logger(), "你好C++节点! "); 图 2-8 查看函数注释 配置头文件目录后,会在 VS Code 当前的工作目录生成一个.vscode/c_cpp_properties.json 文件,头文件配置会被写在这个文件里的 includePath项下,所以直接编辑这个文件也可以添加头文件配置。需要注意的是,因为该文件只存在于当前目录,所以改变工作目录需要重新配置。

 


 

2.2 使用功能包组织 Python节点

  通过上一节的学习,你已经成功地编写了你的第一个 Python节点和第一个C++ 节点,ROS 2 为我们提供了更好的组织节点的方式。

功能包(Package)是ROS 2 中用于组织和管理节点的工具,在功能包内编写代码后,只需要配置几句指令,就可以使用 ROS 2 提供的构建指令对节点进行编译和安装,更加方便我们的开发。

除了方便开发外,还可以将功能相关的节点放置于同一个功能包下,方便分享和使用,比如 1.3 节中的海龟模拟器和键盘控制节点就是属于同一功能包下的不同节点。 因为不同编程语言的构建方式不同,对于不同的开发语言,ROS 2 提供了不同构建类型的功能包,我们先来学习如何将Python节点放入功能包中。

2.2.1 在功能包中编写Python节点

首先用 VS Code 打开主目录下的 chapt2 文件,然后打开集成终端,输入如代码清单 2-13所示的命令。

代码清单2-13 创建Python功能包 $ ros2 pkg create demo_python_pkg --build-type ament_python --license Apache-2.0 going to create a new package package name: demo_python_pkg destination directory: /home/fishros/chapt2 package format: 3 version: 0.0.0 description: TODO: Package description maintainer: ['fishros <fishros@todo.todo>'] licenses: ['Apache-2.0'] build type: ament_python dependencies: [] 代码清单 2-13 中的 ros2 pkg create 是 ROS 2 命令行工具 pkg 模块下用于创建功能包的命令, demo_python_pkg是功能包的名字,后面的--build-type ament_python表示指定功能包的构建类型为ament_python,最后的--license Apache-2.0用于声明功能包的开源协议。

从日志不难看出,该命令运行后在当前终端目录下创建了 demo_python_pkg 文件夹,并在其下创建了一些默认的文件和文件夹,在 VS Code 左侧的资源管理器中展开该文件夹,可以看到如图2-9所示的内容。

demo_python_pkg > demo_python_pkg > resource >test LICENSE 关于这个功能包中的文件结构分析,下一小节再学习。

我们先来学习如何在功能包里编写节点,在demo_python_pkg/demo_python_pkg 目录下新建 python_node.py,在文件中输入如代码清单 2-14 所示的代码。

package.xml setup.cfg setup.py 图 2-9 demo_python_pkg 功能包结构

代码清单 2-14 最简单的 Python 节点 import rclpy from rclpy.node import Node def main(): node = Node ("python_node") node.get_logger().info('你好 Python 节点!') rclpy.spin (node) rclpy.shutdown () rclpy.init () 这段代码来自 2.1.1 节中我们创建 Python 节点时所用的代码,不过细心些你会发现,这里只定义了 main 函数,去掉了调用部分的代码,那怎么运行 main 函数呢?

答案就是告诉功能包main函数的位置,打开demo_python_pkg/setup.py,添加'python_node-demo_python_pkg.python_node:main',到 console_scripts 下,添加的代码及位置如代码清单 2-15 所示。

代码清单 2-15 添加配置注册 Python 节点 tests_require= ['pytest'], entry_points={ 'console_scripts': [ 'python_node = demo_python_pkg.python_node:main', ] setup.py 是 Python 开发中常用的构建配置文件,添加的这句指令的含义是,当执行python_node 时就相当于执行 demo_python_pkg 目录下 python_node 文件中的 main 函数。添加完成后打开demo_python_pkg/package.xml,添加依赖信息,添加的内容和位置如代码清单 2-16所示。

代码清单 2-16 在 demo_python_pkg/package.xml 中添加 rclpy 依赖声明 <?xml version="1.0"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <package format="3"> <license>Apache-2.0</license> <depend>rclpy</depend> <test_depend>ament_copyright</test_depend> </package> package.xml 是 ROS 2 功能包的清单文件,下一小节会详细介绍。

这里添加 <depend>rclpy</depend>的原因是我们在代码里用到了relpy库,所以需要通过清单文件进行声明。 接着就可以在chapt2目录下,使用如代码清单2-17所示的命令来构建功能包。

 

代码清单 2-17 在 chapt2 目录下使用 colcon 构建功能包 $ colcon build Starting >>> demo_python_pkg Finished <<< demo_python_pkg [0.58s] Summary: 1 package finished [0.69s] 代码清单 2-17 中的 colcon 是 ROS 2 中用于构建功能包的工具,这里使用 colcon build 就可以构建当前及子目录下所有的功能包。

构建完成后,你会发现在chapt2文件夹中会多出 build、install 和log 三个文件夹。其中build 里面是构建过程中产生的中间文件; install 是放置构建结果的文件夹,打开install文件夹可以看到demo_python_pkg功能包, python_node等可执行文件就放在目录的特定文件夹下;而 log 里面则放置着构建过程中所产生的各种日志信息。构建完成后该如何运行呢?

类比运行海龟时的指令,用如代码清单2-18所示的命令即可。 代码清单 2-18 运行 python_node 节点 $ ros2 run demo_python_pkg python_node Package 'demo_python_pkg' not found 运行代码清单 2-18 中的命令后,会提示找不到 demo_python_pkg包,根据1.4.5 节的介绍,ros2 run 通过 AMENT_PREFIX_PATH环境变量的值来查找功能包,该值默认是 ROS 2系统的安装目录,demo_python_pkg 的安装目录在 install 目录下,我们需要修改 AMENTPREFIX_PATH 来帮助 ros2 run 找到该功能包,在 install 目录下有一个 setup.bash 的脚本,直接运行它就可以自动修改AMENT-PREFIX-PATH环境变量,命令如代码清单2-19所示。 代码清单 2-19 通过 source 设置环境变量 $ source install/setup.bash $ echo $AMENT_PREFIX_PATH /home/fishros/chapt2/install/demo_python_pkg:/opt/ros/humble 从代码清单 2-19 可以看到,执行完 source 后,在 AMENT_PREFIX_PATH 环境变量中就出现了刚刚构建的功能包路径,此时再次运行节点,就可以正常执行了,命令及结果如代码清单2-20所示。

代码清单 2-20 再次运行 python_node 节点 $ ros2 run demo_python_pkg python_node [INFO] [1680635227.584828594] [python_node] : 你好 Python 节点! 需要注意的是,因为环境变量只对当前的终端上下文有效,所以打开新的终端后就要重新执行source,才能找到节点。

至此你成功地创建了 Python 功能包,并在其中编写、构建和运行了一个 Python 节点,接下来我们一起分析一下 Python 功能包。

 

2.2.2 功能包结构分析

  在 VS Code 中打开 demo_python_pkg 文件夹,可以看到如图 2-10所示的目录结构。 demo_python_pkg demo_python_pkg 第一眼看上去文件很多,你肯定会觉得有点头晕。

不要担心,下面就对图2-10的内容进行讲解,之后你再看就不会感到那么吃力了。 e_init_.py python_node.py resource demo_python_pkg demo_python_pkg test 这个目录的名字默认和功能包名字保持一致,它是放置节点代码的目录,也是你以后开发 Python 节点的主战场。该文件夹下的init_.py 是 Python 包的标识文件,换句话说,如果一个文件夹下包含了该文件,则表示该文件夹是一个 Python 的包,该文件默认为空。 test_copyright.py test_flake8.py test_pep257.py R LICENSE package.xml resource osetup.cfg setup.py 该目录可以放置一些资源,而在功能包中,该目录和其下的demo_python_pkg 文件比较特殊,主要为了提供功能包标识,我们无须关心,也不用手动修改该文件。

图 2-10 Python 功能包结构 test 测试代码文件夹,用于放置代码单元测试文件,结合colcon构建工具中的测试相关指令,可以完成对代码的单元测试并形成报告。

单元测试在大型项目开发中较为重要,目前用处不大,了解即可。 LICENSE 该文件是功能包的许可证。你应该记得创建该功能包时所使用的 --license Apache-2.0 参数,这个文件内容就是 Apache-2.0 的协议内容。当我们将功能包开源或者分享给他人时,许可证可以帮助保护知识产权。

许可证类型很多,Apache-2.0是其中一种灵活的开源许可证,对商业支持较友好。 package.xml 该文件是功能包的清单文件,每个 ROS 2 的功能包都会包含这个文件。

在该文件内声明了功能包的名称、版本编号、功能包管理者、构建类型、许可证和依赖等信息。上一小节中构建代码前,我们在该文件中添加了relpy 的依赖,实际上不添加也可以正常构建和运行,但我更鼓励你在清单中声明代码所用到的依赖包,因为当我们分享或移植功能包时,通过该文件可以快速了解该功能包需要哪些依赖。

同时,在构建时 ROS 2 也可以帮助你管理依赖关系,简化构建的过程。 setup.cfg 该文件是一个普通的文本文件,用于放置构建 Python 包时的配置选项,这些配置在构建

时会被读取和处理。 setup.py 该文件是 Python 包的构建脚本文件,其中包含一个 setup() 函数,用于指定如何构建和安装Python包。在添加节点时,就需要在该文件中声明可执行文件名称及对应函数,上一小节我们已经添加过一次了,这里不再赘述。 除了上面的这些文件和文件夹外,在实际开发中也可以根据自己的需求添加文件或文件夹。

 


2.3 使用功能包组织 C++ 节点

  成功把 Python 节点装进功能包后,我们再来把 C++ 节点装进功能包中。

2.3.1 在功能包中编写 C++ 节点

  打开 VS Code 的集成终端,进入 chapt2 目录下,输入如代码清单 2-21 所示的命令。

代码清单2-21 创建C++功能包 $ ros2 pkg create demo_cpp_pkg --build-type ament_cmake --license Apache-2.0 going to create a new package package name: demo cpp pkg destination directory: /home/fishros/chapt2 package format: 3 version: 0.0.0 description: TODO: Package description maintainer: ['fishros <fishros@todo.todo>'] licenses: ['Apache-2.0'] build type: ament_cmake dependencies: [1 creating folder ./demo_cpp_pkg creating ./demo_cpp_pkg/package.xml creating source and include folder creating folder ./demo_cpp_pkg/src creating folder ./demo_cpp_pkg/include/demo_cpp_pkg creating ./demo_cpp_pkg/CMakeLists.txt ros2 pkg create 是用于创建功能包的命令,其中 demo_cpp_pkg 是功能包的名字,后 面的--build-type ament_cmake表示指定功能包的构建类型为ament_cmake,最后的 --license Apache-2.0 用于声明功能包的开源协议。

从日志不难看出,该命令在当前文件夹下创建了demo_cpp_pkg 文件夹,并在其下创建了一些默认的文件和文件 demo_cpp_pkg > include > src 夹,在 VS Code 左侧的资源管理器中展开该文件夹,其目录结 M CMakeLists.txt LICENSE 构如图 2-11 所示。 package.xml 下一小节我们将对该功能包结构进行分析,这里你只需知 图 2-11 C++ 功能包目录结构

 

道在sre下编写节点即可。在demo_cpp_pkg/src下添加 cpp_node.cpp,在文件中输入如代码清单 2-22 所示的代码。 代码清单 2-22 一个简单的 C++节点 #include "rclcpp/rclcpp.hpp" int main(int argc, char **argv) # uncomment the following section in order to fill in # further dependencies manually. #1.查找rclcpp头文件和库 find package (rclcpp REQUIRED) 一 relcpp::init (arge, argv); auto node = std::make shared<rclcpp: :Node>("cpp node") ; RCLCPP_INFO (node->get_logger()," 你好 C++ 节点!"); rclcpp: :spin (node); rclcpp: :shutdown (); return 0; 你没看错,代码清单 2-22 就是 2.1.2 节的代码,直接用即可,无须再添加多余代码。编写完后,还需要注册节点以及添加依赖,编辑 CMakeLists.txt,最终添加的内容及位置如代码清单 2-23 所示。 代码清单 2-23 chapt2/demo_cpp_pkg/CMakeLists.txt cmake_minimum_required (VERSION 3.8) find_package (ament_cmake REQUIRED) 2.添加可执行文件cpp node add_executable(cpp_node src/cpp_node.cpp) # 3. 为 cpp_node 添加依赖 ament_target dependencies (cpp node rclcpp) #4.将cpp node复制到 install 目录 install (TARGETS cpp_node DESTINATION lib/${PROJECT_NAME} ament_package ( 在代码清单2-23中,首先添加了find_package和add_executable用于查找依赖以及添加可执行文件,然后采用 ament_cmake 提供的 ament_target_dependencies 指令来添加依赖,最后添加的是install指令,该指令将编译好的可执行文件复制到install/demo_cpp_pkg/libdemo_cpp_pkg 目录下,这样使用 ros2 run 才能找到该节点。 在创建 C++ 功能包时,选定的构建类型是 ament_cmake, ament_cmake 其实是CMake的超集。ament_cmake 在 CMake的指令集之上,又添加了一些更加方便的指令。在代码清单 2-23中,可以看到find_package (ament_cmake REQUIRED)指令,该句指令是创建ament_cmake 功

 

能包时自动添加的,这样就可以在后面使用ament相关的指令,如ament_target_dependencies和 ament_package 等指令。 代码清单 2-23 的最后一行是 ament_package(),该指令会从 CMakeLists.txt 收集信息,生成索引和进行相关配置,所以该指令需要在每个ament_cmake类型的功能包的CMakeLists.txt 的最后一行进行调用。 构建功能包前还需要在清单文件packages.xml中添加对relepp的依赖声明,完整声明如代码清单 2-24 所示。 代码清单 2-24 chapt2/demo_cpp_pkg/packages.xml <?xml version="1.0"?> <license>Apache-2.0</license> <depend>rclcpp</depend> <test_depend>ament_copyright</test_depend> </package> <depend>rclepp</depend> 用于声明当前功能包依赖 relepp 库。完成这些后,我们就可以使用如代码清单2-25所示的命令来构建功能包。 代码清单 2-25 构建功能包 $ colcon build Starting >>> demo_cpp_pkg Starting >>> demo_python_pkg Finished <<< demo_cpp_pkg [0.41s] Finished <<< demo_python_pkg [0.73s] Summary: 2 packages finished [0.85s] 代码清单 2-25 中的 colcon 是 ROS 2 中用于构建功能包的工具,这里使用 colcon build 可以构建当前及子目录下所有的功能包。

若构建时当前目录下不存在 build、installl 和 log 这三个目录,则会自动创建,并将构建中间文件、结果和日志放入对应目录中。构建完成后,再查看chapt2/install/demo_cpp_pkg/lib/demo_cpp_pkg/目录就可以看到cpp_node可执行文件了。接下来就可以运行该文件,依次输入代码清单2-26中的两条命令。 代码清单 2-26 运行节点 $ source install/setup.bash $ ros2 run demo_cpp_pkg cpp_node [INFO] [1680684100.228612032] [cpp_node]: 你好 C++ 节点! 在代码清单2-26中, source指令的作用与2.2.1节中的Python示例相同,即让ROS 2能够找到demo_cpp_pkg和其下的节点。

运行完指令后,可以看到节点已经成功启动了。至此,我们完成了在C++功能包中编写节点,但你需要知道的是, colcon build其实也是调用cmake和 make 完成对代码的编译的。

 

2.3.2 功能包结构分析

  在VS Code中的资源管理器中打开demo_cpp_pkg文件夹,并将其子文件夹完全展开,可以看到如图 2-12 所示的目录结构。 该目录非常简洁,包含 2 个文件夹、3 个文件,下面将逐一介绍。 demo_cpp_pkg v include/demo_cpp_pkg √ src include C cpp_node.cpp 该目录用于存放C++的头文件,如果要编写头文件,一般都放置在这个目录下。 M CMakeLists.txt LICENSE package.xml src 图2-12 C++功能包目录结构 代码资源目录,可以放置节点或其他相关代码。 CMakeLists.txt 该文件是C/C++ 构建系统CMake的配置文件,在该文件中添加指令,即可完成依赖查找、可执行文件添加、安装等工作。 LICENSE 该文件是功能包的许可证。创建该功能包时使用了--license Apache-2.0参数,这个文件内容就是 Apache-2.0 的协议内容,在 2.2.2 节中有关于这个协议的简单介绍。

package.xml 该文件是功能包的清单文件,每个 ROS 2 的功能包都会包含这个文件,和 2.2.2 节中Python 功能包中的 package.xml 功能相同。它的更多用法会在下一小节进行讲解。 当然,除了上面这些文件和文件夹,在实际开发中还可以添加其他目录和文件,比如用于放置地图的 map 目录、用于放置参数的 config 目录等。

 


2.4多功能包的最佳实践Workspace

经过前面几节的学习,你成功创建了 ROS 2 的功能包,掌握了在功能包中编写并运行节点的方法。

不过你应该会发现一些小问题,比如当前目录下有多个功能包时,明明只需要构建一个功能包,使用构建指令时却会将所有功能包都进行构建。

再比如,功能包和编译产生的临时文件都在同一个目录,一旦功能包数量增加,就会变得混杂。该如何解决这些问题呢?本节我们就来学习多功能包组合的最佳实践。

一个完整的机器人往往由多个不同的功能模块组成,所以就需要对多个功能包进行组合。ROS 2 开发者约定了 Workspace(工作空间)这一概念。用 VS Code 打开 chapt2/,打开集成终端进入 chapt2 目录,输入代码清单 2-27 中的命令。 代码清单2-27 创建工作空间 $ mkdir -p chapt2_ws/src

 

在上面的命令中,-p 参数表示递归创建,创建完 chapt2_ws 后并在该目录下创建 src 文件夹,这样就得到了一个工作空间。你可能会问,这不就是一个普通的文件夹吗,怎么就成工作空间了?

原因是工作空间本身就是一个概念和约定。在开发过程中,我们会将所有的功能包放到src目录下,并在src同级目录运行colcon进行构建,此时构建出的build, install和log 等目录则保持和 sre同级。

接着在终端中进入 chapt2 目录下,依次输入代码清单 2-28中的命令。

代码清单 2-28 移动功能包到 chapt2_ws/src/ $ mv demo_cpp_pkg/ chapt2_ws/src/ $ mv demo_python_pkg/ chapt2_ws/src/ $ rm -rf build/ install/ log/ 代码清单 2-28 中的前两句用于将已有的功能包直接移动到 chapt2_ws/src/ 下,然后将前面编译产生的目录删掉。接着在终端中进入chapt2/chapt2_ws/目录下,输入代码清单2-29中的命令来构建功能包。

代码清单 2-29 构建功能包 $ colcon build Starting >>> demo_cpp_pkg Starting >>> demo_python_pkg Finished <<< demo_python_pkg [0.94s] Finished <<< demo_cpp_pkg [4.19s] Summary: 2 packages finished [4.34s] colcon build 命令不负众望,扫描并构建了当前工作空间下的所有功能包。

但如果想要构建指定的功能包,比如 demo_cpp_pkg,只需要使用--packages-select 命令加上功能包的名字,测试命令如代码清单 2-30 所示。

代码清单 2-30 选择一个功能包进行构建 $ colcon build --packages-select demo_cpp_pkg Finished <<< demo_cpp_pkg [0.19s] Starting >>> demo_cpp_pkg Summary: 1 package finished [0.29s] 使用 colcon build 构建时,在 CPU 允许的情况下,所有的功能包会同时开始构建,从代码清单 2-29所示的构建日志中就可以看出,这里两个功能包就是同时开始构建的, demopython_pkg 功能包先完成,之后是 demo_cpp_pkg。

但有时在同一个工作空间下,不同功能包之间会出现依赖情况,比如demo_python_pkg依赖demo_cpp_pkg的构建结果,此时就需要先构建demo_cpp_pkg,完成后再构建demo_python_pkg。要实现这个功能非常简单,只需要在功能包的清单文件中声明依赖关系即可,

 

打开demo_python_pkg/package.xml,添加对 demo_cpp_pkg的依赖,完成后的内容如代码清单 2-31 所示。

代码清单 2-31 添加工作空间下的功能包依赖 <?xml version="1.0"?> <depend>rclpy</depend> <depend>demo_cpp_pkg</depend> <test_depend>ament_copyright</test_depend> </package> 保存后再次输入如代码清单2-32所示的构建命令。 代码清单 2-32 通过依赖控制构建顺序 $ colcon build Starting >>> demo_cpp_pkg Finished <<< demo cpp pkg [0.18s] Starting >>> demo_python_pkg Finished <<< demo_python_pkg [0.56s] Summary: 2 packages finished [0.85s] 可以看到,运行命令后先完成了对 demo_cpp_pkg的构建,然后demo_python_pkg才开始构建。

关于 ROS 2 功能包和工作空间的学习,到这里就算告一段落了,接下来的重点会放在代码上。所以稍事休息,一起来学习些ROS 2编程要用到的基础知识吧。

 


2.5 ROS 2 基础之编程

相比 ROS 1,ROS 2 在开发上采用了更新的版本和更现代化的特性,比如我们当前使用的 Humble 版本是 ROS 2,采用的就是 Python 3.10 版本,针对 C++ 则使用了 11、14 和 17 等版本的新特性。

“万丈高楼平地起”,在学习新特性之前,我们先来学习现代高级语言最重要的一大特性:面向对象编程。

 

2.5.1 面向对象编程

  关于面向对象编程,你在很多教程中都可以看到标准化的定义,但这些标准化的定义更多的是一些概念,只有本来就懂的人才能看懂,之前不了解的人很难理解。

所以我打算用通俗易懂的语言和实际的程序来向你展示什么是面向对象编程。 和C语言这种面向过程的语言不同,面向对象的语言都可以创建类,所谓的类就是对事物的一种封装。

人、手机、机器人等任何事物都可以封装成一个类。类中可以拥有自己的属

性和方法,比如说人类都有身高、年龄,手机都有品牌和内存大小,这些都属于属性,不难发现,属性通常是类的描述。

方法则表示类的行为,比如人都会吃和睡,手机都可以开机和关机等。 通过这种类的封装,在需要使用时便可以实例化一个类的对象,比如当需要吃东西时,可以创建一个名字叫“张三”的人,调用其所拥有的方法完成吃这个动作。

除了将类实例化成一个具体的对象进行调用外,类还能被继承,这点稍后再讲,现在我们尝试创建一个类。

1. Python 示例 用 VS Code 打开 chapt2_ws/,接着在 src/demo_python_pkg/demo_python_pkg 目录下创建人类节点person_node.py,接着添加如代码清单2-23所示的代码。 代码清单 2-33 定义一个最简单的 Python 类 class PersonNode: def_init -> None: pass (self) 这是一个空的类声明,我们在 Python 中使用 class 关键字声明了一个类,并为其添加了一个空的_init_方法,_init_方法是 Python 类中的一个特殊方法,该方法在创建该类的对象时会被调用。

当需要给该类添加属性时,一般都会通过_init_方法的参数来传递初始值。现在我们给这个类添加一些属性和方法,如代码清单 2-34 所示。 代码清单 2-34 为 Python 类添加属性和方法 class PersonNode: def init (self, name:str, age:int ) -> None: print('PersonNode 的 _init_ 方法被调用了') self.age = age self.name = name def eat (self, food_name: str): print(f'我叫 {self.name},今年{self.age}岁,我现在正在吃{food_name}') 我们在-init-方法中创建了age和name这两个属性,并要求传入字符串类型的名字和整型的年龄,同时加了一个输出语句,用于判断该方法是否被调用。接着又定义了一个吃饭方法 eat(),调用该方法时要求提供字符串类型的食物名称 food_name 作为参数。需要注意的是,所有 Python 类内部的方法第一个参数默认都是 self,代表其本身。 PersonNode 节点类定义好了,接下来我们实例化这个类,并实现对它的调用。在person_node.py 文件的最后添加节点实例代码,如代码清单 2-35 所示。

代码清单 2-35 定义main函数实例化PersonNode class PersonNode: def main(): node.eat('鱼香肉丝') node = PersonNode('法外狂徒张三',18)

这里传入了姓名和年龄两个参数给 PersonNode,实例化了一个 PersonNode 类的对象node,node 也可以称为 PersonNode类的一个实例。

实例化完成后调用它的 eat()方法。 让我们尝试编译和运行代码,首先在 setup.py 中对当前节点进行注册,完成后编写setup.py 中 entry_points 的内容,如代码清单 2-36 所示。

代码清单 2-36 setup.py entry_points={ 'console_scripts': [ 'python_node = demo_python_pkg.python_node:main', 'person_node = demo_python_pkg.person_node:main' ], }, 然后开始尝试构建,打开终端,输入如代码清单 2-37 所示的指令。 代码清单 2-37 构建单个功能包 $ colcon build --packages-select demo_python_pkg Starting >>> demo python pkg Finished <<< demo_python_pkg [0.60s] Summary: 1 package finished [0.70s] 最后运行 person_node节点,如代码清单 2-38 所示。 代码清单 2-38 运行 person_node 节点 $ source install/setup.bash $ ros2 run demo_python_pkg person_node PersonNode 的 init 方法被调用了 我叫法外狂徒张三,今年18岁,我现在正在吃鱼香肉丝 结果符合我们的预期。学习完如何将属性和方法封装成一个类,我们再来学习面向对象编程的另一个重要特性——继承。

假设要定义一个作家类节点WriterNode,作家都有自己的书,所以我们可以给WriterNode 类添加一个 book 属性。但作家也是人,也有姓名和年龄,也要吃饭。如果再给作家添加age和name属性以及eat方法,会多做很多无意义的工作。

相比之下,如果能让WriterNode直接继承PersonNode的属性和方法,那岂不省事?当然可以这样做,接下来编写代码,在 src/demo_python_pkg/demo_python_pkg 目录下创建作家节点 writer_node.py,然后添加如代码清单2-39所示的代码。 代码清单 2-39 创建作家节点WriterNode,并继承PersonNode from demo_python_pkg.person_node import PersonNode class WriterNode (PersonNode): def init (self. book:str) -> None: print('WriterNode 的 _init_ 方法被调用了')

 self.book = book def main(): node = WriterNode('论快速入狱')node.eat('鱼香肉丝') 在代码清单2-39中,我们先从person_node文件中导入了PersonNode类,定义了WriteNode类,并在类名后添加括号,在括号中写入 PersonNode,表示其继承自 PersonNode。然后在init_方法中添加book这一属性。

接着实例化了一个WriterNode的对象node,给其年龄、姓名和作品进行赋值,最后对eat方法进行调用。下面请你自行在setup.py中添加writer_node节点,并重新构建,你应该会看到如代码清单 2-40 所示的结果。

代码清单2-40 writer_node的运行结果 $ ros2 run demo_python_pkg writer_node WriterNode的-init-方法被调用了 Traceback (most recent call last): File "/home/fishros/chapt2/chapt2_ws/install/demo_python_pkg/lib/demo_python_ pkg/writer_node", line 33, in <module> sys.exit(load_entry_point('demo-python-pkg==0.0.0', 'console_scripts', 'writer_node')()) File "/home/fishros/chapt2/chapt2_ws/install/demo_python_pkg/lib/python3.10/ site-packages/demo_python_pkg/writer_node.py", line 10, in main AttributeError: 'WriterNode' object has no attribute 'age' node.eat('鱼香肉丝') File "/home/fishros/chapt2/chapt2_ws/install/demo_python_pkg/lib/python3.10/sitepackages/demo python pkg/person node.py", line 12, in eat print (f'年龄 {self.age),名字叫 {self.name}的人此时正在吃 {food_name}') [ros2run]: Process exited with failure 1 出错了,不用着急,我将教你如何面对代码报错。这里的错误提示信息,从上到下,首先是错误的代码调用过程,我们调用了 node.eat 方法,eat 方法内调用了 print 才出的错,错误原因是 AttributeError: 'WriterNode' object has no attribute 'age',明明我们已经让 WriterNode继承了 PersonNode了,为什么 WriterNode 会没有 age 属性?结合第一句输出信息你应该发现了,PersonNode 的_init_方法并没有被调用,我们可以通过super()来调用父类的init_方法。修改writer_node.py,如代码清单 2-41所示。 代码清单 2-41 添加对父类_init_ 方法的调用 class WriterNode(PersonNode): def _init_(self, name: str, age: int, book: str) -> None: super ()._init_(name, age) print ('WriterNode 的 _init 方法被调用了') self.book = book 保存后,需要再次编译才能将代码复制到 install 目录下,重新编译运行并查看结果。再次运行 writer_node,结果如代码清单 2-42 所示。 代码清单 2-42 运行 writer_node $ ros2 run demo python_pkg writer_node PersonNode 的 init方法被调用了 方法被调用了 WriterNode的 init 我叫法外狂徒张三,今年 18 岁,我现在正在吃鱼香肉丝 从代码清单 2-42 的运行结果可以看出, WriterNode已经成功地继承了PersonNode 的属性和方法。 学习完 Python 的类和继承机制,我们尝试把 PersonNode 和 WriterNode 变成真正的 ROS2 节点。你应该还记得在编写第一个 Python 节点时,我们将 Node 类进行了实例化,如果让PersonNode 继承 Node,那么 PersonNode 就可以拥有 Node 类所有的属性和方法,从而成为一个真正的 ROS 2 节点。下面一起来试试,修改 person_node.py,如代码清单 2-43 所示。 代码清单 2-43 继承 ROS 2 Node 的 PersonNode import rclpy from rclpy.node import Node class PersonNode (Node): def _init_(self, node_name: str, name: str, age: int) -> None: super ()._init_(node_name) self.age = age self.name = name def eat (self, food_name: str): self.get_logger().info(£'我 叫 {self.name},今年 {self.age} 岁,我 现 在正 在吃 {food_name}') def main(): rclpy.init () node = PersonNode('person_node',法外狂徒张三·,'18')d node.eat('鱼香肉丝·) rclpy.spin (node) rclpy.shutdown () 这里首先导入了rclpy库和Node类,然后让PersonNode继承Node,并在一init-方法中要求传入节点名称以在调用父类的_init_方法时使用。最后将 eat方法的输出方式从print修改为ROS 2的logger。再次构建和运行,结果如代码清单2-44所示。 代码清单 2-44 运行改造为节点后的 writer_node $ ros2 run demo_python_pkg writer_node [INFO] [1680891408.833994560] [person-node]: 我叫法外狂徒张三,今年18岁,我现在正在吃 鱼香肉丝 在代码清单 2-43 中使用了属于 Node类才有的 self.get_logger方法,并成功输出,从这一点可以看到PersonNode成功继承了 ROS 2 的 Node类。

 WriterNode 继承自 PersonNode,此时应该也拥有了 Node 类所拥有的属性和方法。请你尝试将它也改造成一个ROS 2的节点并运行起来,当作你的课后作业。

一 一下子学习了这么多面向对象的概念,肯定让你感到有些头大,甚至觉得这样写有些烦琐。对于面向对象编程,需要在今后的学习和工作中不断实践才能体会到它的魅力。休息一下,下面节将学习C++中面向对象的编程实现方法。 2. C++ 示例 在上一节学习了面向对象基本概念,并使用 Python 代码实现之后,再学习本节 C++ 面向对象编程你会觉得轻松一些。因为 C++ 作为和 Python一样的高级语言,面向对象的特性是相同的,只是语法上有所不同。接下来还是使用上一节中的概念来创建一个 C++ 版本的PersonNode 节点。 在 chapt2_ws/src/demo_cpp_pkg/src下新建 person_node.cpp,在该文件中编写如代码清单 2-45 所示的代码。

代码清单 2-45 用 C++ 编写继承 Node 的 PersonNode #include <string> #include "rclcpp/rclcpp.hpp" class PersonNode: public rclcpp::Node private: std::string name_; int age_; public: PersonNode (const std::string &node_name, const int &age): Node (node_name) const std::string &name, this->name = name; this->age_= age; }; void eat (const std::string &food_name) RCLCPP_INFO(this->get_logger(),"我是 8s,今年 8d 岁,我现在正在吃8s" name_.c_str(), age_, food_name.c_str()); }; }; int main(int argc, char **argv) rclcpp: :init (argc, argv); auto node = std::make_shared<PersonNode>("cpp_node", " 法外狂徒张三", 18) ;node->eat("鱼香 ROS"); rclcpp: :spin (node); rclcpp: :shutdown ();

return 0; 从上往下看,代码中首先包含了string和relepp/rclepp.hpp两个头文件,包含string的原因是节点名称和姓名要用字符串表示。 接着使用class关键字定义了PersonNode类,使其继承rclepp::Node,在类的内部定义了private 部分,即姓名和年龄两个属性。 在public部分定义了构造函数PersonNode,并传入节点名称、姓名和年龄作为参数。需要注意的是,这里的参数传递采用的都是静态引用方式,在 std::string 后添加 & 表示传递引用,引用与指针类似,传递引用避免了不必要的数据复制,可以提高代码效率。std::string前的 const 限制变量为只读,即不能修改,这可以避免它在方法内被意外修改,提高代码的安全性。在构造函数后添加:Node(node_name)用于调用父类的构造函数,传递节点名称参数,这一点和 Python 一致,但语法不同。 在构造函数内,通过 this(即指向自己的指针)对姓名和年龄进行赋值。 接下来是 eat 方法的实现,传递了食物名称的静态引用,方法体内调用了 ROS 2 的日志模块来输出数据,因为 RCLCPP_INFO 采用的是 C 风格的格式化输出,所以 name_和 foodname 需要调用 c_str()将字符串类型转换成 C 风格类型的字符串。 在main函数里调用std::make_shared,传入节点名、姓名和年龄,构造一个PersonNode的对象,并返回该对象的智能指针赋值给 node。然后调用 node 的 eat 方法和 ROS 2 的相关方法。 接下来修改 CMakeLists.txt,添加 person_node 节点,添加完成后完整的 CMakeLists.txt如代码清单 2-46 所示。 代码清单 2-46 添加 person_node 节点 # find dependencies find_package (ament_cmake REQUIRED) add_executable(person_node src/person_node.cpp) ament_target_dependencies (person_node rclcpp) install (TARGETS cpp_node person_node DESTINATION lib/${PROJECT_NAME) ament_package () 输入如代码清单2-47所示的命令来构建和运行person_node节点。 代码清单 2-47 编译和运行 person_node 节点 s colcon build --packages-select demo_cpp_pkg $ source install/setup.bash

$ ros2 run demo_cpp_pkg person_node [INFO] [1680904724.328635258] [cpp_node]:我是法外狂徒张三,今年18岁,我现在正在吃鱼香ROS 到这里,你应该会觉得很开心吧,因为你已经成功地使用面向对象的方式编写了一个C++节点类。这些年来, C++语言的新特性在不断丰富,并在ROS 2中大量使用,比如智能指针中的 make_shared。稍作休息,下面一起来学习 ROS 2 开发中能用到的 C++ 新特性吧。

 

2.5.2 用得到的 C++新特性

  从1998年发布C++98开始, C++标准经历了多次更新和修改,每次修改都会给C++带来新的语法、语义和标准库的新特性。在 2000 年后,C++ 标准经历了几次重要修改,主要包括2011年发布的C++11 (这次修改引入了智能指针和Lambda表达式等新特性)、 2014年发布的 C++14、2017年发布的C++17以及2020年发布的C++20。 ROS 2 与 ROS 1 相比更加符合现代机器人开发的要求,从采用的 C++ 语言标准就可以看出。ROS 2的源码中使用C++11及更高版本,同时很多ROS 2开源库及框架也都是采用C++ 作为主要开发语言,所以在正式深入学习 ROS 2 之前,我们来学习几个用得到的 C++新特性。 在前面几个小节中,实例化ROS 2节点时的 auto node = std::make_shared<rclepp::Node>("cpp_node"),这句代码就用到了两个C++ 新特性:自动类型推导和智能指针。 1.自动类型推导 自动类型推导体现在代码中就是 auto 关键字。我们在实例化 ROS 2 节点的对象时使用的就是auto,它可以在给变量赋值时根据等号右边的类型自动推导变量的类型。下面编写代码来测试一下,在chapt2_ws/src/demo_cpp_pkg/src/下新建 learn_auto.cpp,在该文件中编写如代码清单 2-48 所示的代码。 代码清单 2-48 自动类型推导测试 learn_auto.cpp #include <iostream> int main() 一 auto x = 5; auto y = 3.14; auto z = 'a'; std::cout << x << std::endl; std::cout << y << std::endl; std::cout << z << std::endl; return 0;

代码清单 2-48中定义了三个变量,分别赋值整型、浮点型和字符型三种类型的值,变量类型统一用 auto修饰,再把变量逐一输出。在CMakeLists.txt 中添加learn_auto节点配置,构建后运行,结果如代码清单 2-49 所示。 代码清单 2-49 运行测试命令及结果 $ ros2 run demo_cpp_pkg learn_auto 3.14 a 从运行结果可以看出,使用auto定义的变量和正常定义的没有区别,但在一些情况下可以大大简化代码。 2. 智能指针 智能指针是 C++11 引入的一种智能化指针,它可以管理动态分配的内存,避免内存泄漏和空指针等问题。C++11 提供了三种类型的智能指针:std::unique_ptr、std::shared_ptr 和std::weak_ptr。在代码中使用 std::make_shared 就是创建一个std::shared_ptr 智能共享指针,下面我们重点来学习 std::shared_ptr。 在C 语言中,指针用于存储其他变量的地址,智能指针也是如此。当指针指向的动态内存不再使用时,需要手动地调用free进行释放,忘记或提前释放就会造成内存泄漏和空指针调用问题。智能共享指针的智能之处就在这里,该指针会记录指向同一个资源的指针数量,当数量为 0 时会自动释放内存,这样一来就不会出现提前释放或者忘记释放的情况。下面编写代码来测试一下,在 chapt2_ws/src/demo_cpp_pkg/src/ 下新建 learn_shared_ptr.cpp,在该文件中编写如代码清单2-50所示的代码。 代码清单 2-50智能指针测试 #include <iostream> #include <memory> int main() auto pl = std::make_shared<std::string>("This is a str."); std::cout << "p1 的引用计数为:" << p1.use_count() << ",指向内存的地址为:" << p1.get () << std::endl; auto p2 = pl; std::cout <<"p1 的引用计数为:" << p1.use_count() << ",指向内存的地址为:"<< pl.get () << std::endl; std::cout << "p2 的引用计数为:" << p2.use_count() << ",指向内存的地址为:" << p2.get () << std::endl; p1.reset (); std::cout << "p1 的引用计数为:"<< p1.use_count()<<",指向内存的地址为:"<< p1.get () << std::endl;

智能指针是在头文件 <memory> 的 std 命名空间中定义的,所以代码里首先包含了<memory>。然后在主函数里使用std::make_shared创建了一个指向std::string类型的智能指针p1,再输出p1所指向资源的被引用次数pl.use_count()和内存地址pl.get(),此时p1所指向的资源只被p1所引用,引用次数应该为1。 接着我们将 pl 指向的资源分享给 p2,此时资源被 pl 和 p2同时引用,资源的引用次数应该为 2,资源地址不变。 最后调用了 pl.reset() 方法,将 pl 重置,此时 pl 不再指向原有的资源,p2 继续指向资源,此时 p1 的引用次数变为了 0,p2 指向资源的引用次数变为了 1,输出的 p2 指向的资源内容依然不变。 我们在 CMakeLists.txt 添加 learn_shared_ptr 节点配置,编译后运行,结果如代码清单 2-51所示。 } std::cout <<"p2 的引用计数为:"<< p2.use_count()<<",指向内存的地址为:" <<std::cout << "p2 指向资源的内容为:"<< p2->c_str()<<std::endl;return 0; p2.get() << std::endl; 代码清单2-51 智能指针测试结果 $ ros2 run demo_cpp_pkg learn_shared_ptr p1的引用计数为: 1,指向内存的地址为0x5621fcb6cec0 p1 的引用计数为: 2,指向内存的地址为0x5621fcb6ceco p2的引用计数为: 2,指向内存的地址为0x5621fcb6ceco p1 的引用计数为:0,指向内存的地址为 p2的引用计数为: 1,指向内存的地址为0x5621fcb6cec0 p2 指向资源的内容为 This is a str. 从代码清单 2-51 中的指令运行结果可以看出,虽然重置了 p1,但因为 p2 依然持有资源,所以资源并不会被释放,最后依然可以正常输出其值。试想一下,如果我们在同一个程序中将某个资源使用智能共享指针进行管理,那么该数据无论在多少个函数内进行传递,都不会发生资源的复制,运行效率会大大提高。当所有的程序使用完毕后,还会自动回收,不会造成内存泄漏。以上就是 ROS 2 中大量采用智能指针的原因。 3. Lambda 表达式 你可能听说过匿名函数,Lambda 表达式是 C++11 引入的一种匿名函数,没有名字,但是也可以像正常函数一样调用。Lambda 表达式有一套自己的语法,格式如代码清单 2-52 所示。 代码清单 2-52 Lambda 表达式的格式 [capture list] (parameters) -> return_type { function body ) 其中, capture list表示捕获列表,可以用于捕获外部变量; parameters表示参数列表;return_type 表示返回值类型;function body 表示函数体。

在chapt2_ws/src/demo_cpp_pkg/src/下新建learn_lambda.cpp,在该文件中编写如代码清单 2-53 所示的代码。 代码清单 2-53 使用 Lambda 函数计算两数之和并输出 #include <iostream> #include <algorithm> int main() auto add = [] (int a, int b) -> int { return a + b; }; int sum = add(3, 5); auto print_sum = [sum] ()->void { std::cout << "3 + 5 = " << sum << std: :endl; }; print_sum (); return 0; 在代码清单 2-53 中,首先定义了一个两数相加的函数,捕获列表为空,int a,int b 是其参数,返回值类型是 int,函数体是return a+b;接着调用add计算了3+5 并存储在sum中;然后又定义了一个函数 print_sum,此时捕获列表中传入了 sum;最后在函数体中输出 sum 的值。在 CMakeLists.txt 添加 learn_lambda 节点配置,编译后运行,结果如代码清单 2-54 所示。 代码清单2-54 运行learn_lambda可执行文件 $ ros2 run demo_cpp_pkg learn_lambda 8运行结果符合预期。你现在可能还体会不到Lambda带来的好处,没关系,在后续的学习中你将会发现它的优势。 4.函数包装器 std::function std::function 是 C++11 引入的一种通用函数包装器,它可以存储任意可调用对象(函数、函数指针、Lambda表达式等)并提供统一的调用接口。听概念可能你会很懵,下面直接带你编写代码,然后结合代码来学习。在chapt2_ws/src/demo_cpp_pkg/src/下新建learn_function.cpp,在该文件中编写如代码清单2-55所示的代码。 代码清单2-55 使用不同类型的函数创建函数包装器 #include <iostream> #include <functional> void save_with_free_fun(const std::string &file_name)_ std::cout << "调用了自由函数,保存:" << file_name << std::endl; class FileSave

public: void save_with_member_fun (const std::string &file_name) FileSave file_save; 一 std::cout <<"调用了成员方法,保存:" << file_name << std::endl; }; }; int main() auto save_with_lambda_fun = [] (const std::string &file_name) -> void std::cout <<"调用了 Lambda 函数,保存:" << file_name << std::endl; }; //将自由函数放进function对象中 std::function<void(const std::string &)> savel = save_with_free_fun; // 将 Lambda 函数放入 function 对象中 std::function<void(const std::string &)> save2 = save_with_lambda_fun; // 将成员方法放入 function 对象中 std: :function<void(const std::string &)> save3 = std: :bind (&Filesave::save_ with member fun, &file save, std::placeholders:: 1); // 无论哪种函数都可以使用统一的调用方式 savel ("file.txt"); save2 ("file.txt"); save3 ("file.txt"); return 0; 代码有点长,我们从上依次往下看,<functional>是函数包装器所在的头文件,所以要包含。接着在外部定义了一个名为save 的函数,这种直接在外部定义的函数称为自由函数,其参数是文件名称。然后定义了一个文件保存类FileSave,为其添加了一个成员方法savewith_member_fun,参数同样是文件的名字。 在main里实例化了FileSave对象file_save,创建了Lambda函数save_with_lambda_fun。接着通过自由函数直接赋值、Lambda表达式赋值和std::bind绑定赋值三种方式,创建了三个std::function<void(const std::string &)>对象,然后分别调用封装后的三个保存函数。 下面重点讲解 std::bind,它可以将一个成员函数变成一个std::function 的对象,正常调用成员函数的方法是使用对象加函数的形式,如file_save.save_with_member_fun,这里用std::bind 将成员函数 FileSave::save_with_member_fun 与对象 file_save 绑定在一起,并使用std::placeholders::_1占位符预留一个位置传递函数的参数。 在 CMakeLists.txt 添加 learn_function 节点配置,编译后运行,结果如代码清单 2-56 所示。 代码清单2-56运行learn_function $ ros2 run demo_cpp_pkg learn_function 调用了自由函数,保存:file.txt

调用了 Lambda 函数,保存 :file.txt调用了成员方法,保存:file.txt 到这里你已经学习了很多 C++ 的新特性,相信有了本节的铺垫,下面再学习基于 C++的ROS 2机器人开发,你一定会轻松不少。接下来我们学习后续开发要用到的最后一个知识点——多线程与回调函数。

 

2.5.3 多线程与回调函数

如果你习惯了面向过程的开发方式,多线程对你来说一定很酷,多线程的魅力在于可以让程序并行运行。比如可以利用多线程同时下载多本小说,而不需要下载一个后再等待下一个。此外,本节的另外一个重点是回调函数。 1. Python 示例 我们将函数A作为参数传递给函数B,并在函数B通过该参数调用函数A,从而实现对执行结果的处理,比如下载完成后调用回调函数用于反馈下载结果。下面我们用Python来实现多线程下载小说。在src/demo_python_pkg/demo_python_pkg 目录下中创建 learn_thread.py,在该文件中添加如代码清单 2-57所示的代码。 代码清单2-57 下载小说到本地 import threading import requests class Download: def download(self, url, callback): print(f'线程:{threading.get_ident()}开始下载:{url}') response = requests.get (url) response.encoding = 'utf-8' callback (url, response.text) def start_download(self, url, callback): thread = threading.Thread(target=self.download, args=(url, callback)) thread.start () def download_finish_callback(url, result): print(f'{url}下载完成,共:{len(result)}字,内容为:{result[:5]}...') def main(): d = Download () d.start_download('http://localhost:8000/novel1.txt', download_finish_ callback) d.start_download('http://localhost:8000/novel2.txt', download_finish_ callback) d.start_download('http://localhost:8000/novel3.txt', download_finish_ callback) 在代码清单 2-57 中,首先导入了Python 线程库 threading,接着导入了HTTP 请求库

requests。若无法导入或导入时提示导入失败,则使用 apt 安装 Python3-requests 即可。然后定义了一个Download类,在该类中添加了download和 start_download 两个方法。 download 方法接收下载的 url 和回调函数 callback 作为参数,然后输出当前的线程 id 和下载的url,接着调用requests请求数据,最后将url和数据文本通过回调函数传递出去。 start_download 函数同样接收 url 和回调函数两个参数,然后在函数里新建一个线程thread来运行目标函数download,并将url和callback作为参数传递,最后调用thread.start()启动线程。 在代码清单 2-57 中创建完 Download 类后,又定义了一个函数 download_finish_callback作为回调函数使用,用于处理下载完成的数据。最后是main函数,在该函数中先实例化了一个Download类的对象d,分别调用下载小说的三个部分,并将download_finish_callback作为回调函数使用。 在编写测试代码前,先来准备要下载的小说和下载服务器。打开终端,进入 chapt2_ws 目录,使用如代码清单 2-58 所示的命令来创建三个章节的小说并启动一个本地的 HTTP 服务器。 代码清单2-58 创建三个小说文件并运行服务 $ echo"第一章 少年踏上修仙路,因诛仙力量被驱逐。">novel1.txt $ echo"第二章 学习修仙,结交朋友,明白责任。"> novel2.txt $ echo "第三章 张家杰回村庄,抵抗邪恶,成为守护者。" > novel3.txt $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 在 setup.py 中添加 learn_thread 节点,编译后运行,结果如代码清单 2-59 所示。 代码清单 2-59 多线程下载小说 $ ros2 run demo_python_pkg learn_thread 线程 :140385711027776 开始下载:http://localhost:8000/novel1.txt线程 :140385702635072 开始下载:http://localhost:8000/novel2.txt线程 :140385694242368 开始T载:http://localhost:8000/novel3.txthttp://localhost:8000/novel2.txt 下载完成,共: 20 字,内容为:第二章 学...http://localhost:8000/novel1.txt 下载完成,共:22字,内容为:第一章 少...http://localhost:8000/novel3.txt 下载完成,共: 23 字,内容为:第三章张 ... 从结果可以看出,一共启动了三个线程进行下载,首先下载完成的是字数最少的novel2.txt,最后下载完成的是 novel3.txt。当然这个顺序也不是一定的,因为文件都比较小,很快就能下载完成。多线程有好处也有坏处,线程数量过多会影响系统调度,所以在ROS 2 中,默认只在一个线程里进行数据处理和调度。 2. C++ 示例 和Python多线程一样,C++多线程也可以实现并行运行程序,只不过调用接口和语法有所不同。接下来我们直接通过代码来学习。在正式编写代码前,先下载一个C++的HTTP请求库cpp-httplib,该库只需要引入头文件即可使用。打开集成终端,进入chapt2_ws/src/

demo_cpp_pkg/include目录,输入如代码清单2-60所示的命令,使用Git下载。 代码清单 2-60 使用 Git 下载开源库 s git clone https://gitee.com/ohhuo/cpp-httplib.git 正克隆到'cpp-httplib'... remote: Enumerating objects: 4527, done. remote: Counting objects: 100% (4527/4527), done. remote: Compressing objects: 100% (1422/1422), done. remote: Total 4527 (delta 3057), reused 4527 (delta 3057), pack-reused 0 接收对象中: 100% (4527/4527), 2.27 MB | 805.00 KB/s,完成.处理 delta 中:100% (3057/3057),完成。 下载完成后,还需要在 CMakeLists.txt 中添加 include_directories(include) 指令,指定 include文件夹为头文件目录。最终 CMakeLists.txt 的内容如代码清单 2-61 所示。 代码清单 2-61 使用 include_directories 添加依赖 find_package (rclcpp REQUIRED) include_directories (include) ament_package () 完成上面的操作后,在chapt2_ws/src/demo_cpp_pkg/src下新建learn_thread.cpp,在该文件中编写如代码清单 2-62 所示的代码。 代码清单 2-62 chapt2_ws/src/demo_cpp_pkg/src/learn_thread.cpp #include <iostream> #include <thread> #include <chrono> #include <functional> #include <cpp-httplib/httplib.h> class Download public: void download(const std::string &host, const std::string &path, const std::function<void(const std::string &, const std::string &)> &callback) std::cout << "线程 ID: " << std::this_thread: :get_id() << std::endl; httplib::Client client (host); auto response = client.Get (path); if (response && response->status == 200) callback (path, response->body); void start_download(const std::string &host, const std::string &path, const std::function<void(const std::string &, const std::string &)> &callback)

一 auto download_fun = std::bind(&Download::download, this, std::placeholders::_1, std::placeholders::_2, std: :placeholders::_3); std::thread download_thread(download_fun, host, path, callback); download_thread.detach(); }; int main () Download download; auto download_finish_callback = [] (const std::string &path, const std::string &result) -> void std::cout <<"下载完成:" << path <<" 共:" << result.length() <<"字,内容为: " << result.substr(0, 16) << std::endl; }; download.start_download("http://localhost:8000", "/novell.txt", download_ finish_callback); download.start_download("http://localhost:8000", "/novel2.txt", download_ finish_callback); download.start_download("http://localhost:8000", "/novel3.txt", download_ finish_callback); std::this_thread: :sleep_for (std::chrono: :milliseconds (1000 * 10)); return 0; 在上述代码中,首先包含了线程相关头文件thread、时间相关头文件chrono、函数包装器头文件 functional和用于下载的cpp-httplib/httplib.h 头文件。然后声明了Download类,并在其中添加了download函数和start_download函数。 download 函数有三个参数:第一个是主机地址 host;第二个是路径,其实是把完整的网址拆成前后两部分;第三个是回调函数,当请求成功后调用,传递请求结果。 start_download 函数的参数和 download函数相同,在函数体内,首先将成员函数 download变成一个std::function的对象,接着创建一个thread对象,传入函数、主机地址、路径和回调函数。C++的线程和Python不同,创建完成后就会直接运行。最后的download_thread.detach 的作用是将线程与当前进程分离,使得线程可以在后台运行。 最后,在main函数中创建了Download类的一个实例,通过Lambda创建了回调函数,并三次调用start_download方法来下载文件。然后使用std::this_thread::sleep_for将当前线程延迟了10s,防止程序直接退出,以便所有下载都有足够的时间完成。 在CMakeLists.txt中添加lean_thread节点相关配置,然后构建功能包,使用如代码清单2-63所示的终端命令运行代码。 代码清单 2-63 运行 learn_thread 节点 $ ros2 run demo_cpp_pkg learn_thread

线程 ID: 140551882073664 线程 ID: 140551873680960 线程 ID: 140551798126144 下载完成: /novel3.txt 共: 65字,内容为:第三章 张家... 下载完成: /novel1.txt共: 62字,内容为:第一章少年... 下载完成: /nove12.txt 共: 56字,内容为:第二章学习... 从结果可以看出,程序分别启动了三个线程完成文件下载,但因为C++和Python的字符长度统计方式不同,这里显示的字数有所不同。 好了,关于用得到的 ROS 2 基础编程知识大概就这些了,只要你掌握了本节的知识点,后续就不用担心看不懂代码了。

 


2.6 小结与点评

  本章给出了很多代码,通过本章的学习,我们首先掌握了使用 Python 和 C++ 编写 ROS2 的节点,然后又学习了使用功能包和工作空间组织节点,在本章的最后,又学习了大量的编程知识,为接下来的学习打下了坚实的基础。毫不夸张地说,你现在已经成功入门了ROS2 开发。 不过,你的ROS 2 机器人开发之旅才刚刚开始,我们还要继续努力,给自己安排一段休息时间,调整到最好的状态,继续踏上 ROS 2 的学习旅程吧!

 

posted @ 2026-01-12 11:25  前沿风暴  阅读(5)  评论(0)    收藏  举报

校内网 © 2004-2026

京公网安备 33010602011771号 京ICP备2021040463号-3