ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ROS2 rclpy开发实战:从节点机制到Python节点创建与排错

ROS2 rclpy开发实战:从节点机制到Python节点创建与排错 搜 ros2 rclpy 的时候大多数人不是来学理论的而是已经装好了ROS2打开了编辑器想赶紧用Python写一个自己的节点出来。如果你也是这个状态那这篇就是给你写的。我会从为什么选rclpy开始把init、Node、spin这几个绕不开的机制讲透然后带着你从空目录一路写到能发布话题、能订阅话题、能配置参数的Python节点最后把编译、运行、排错过程中最常踩的坑一起列出来。目标很直接看完你能独立创建节点并且知道它为什么能跑起来。1. 为什么用Python写节点rclpy不是低配版很多新人有个默认想法正经的机器人项目应该用CPython只是用来写写脚本。这个观念在ROS2里真的该改一改了。rclpy是官方提供的一等客户端库它和rclcpp的地位是平等的只是底层绑定不同。想用好它先得搞清楚它在整个ROS2体系里的真实位置。1.1 rclpy和rclcpp同一套规范的两个客户端ROS2的通信核心是DDS不管你用哪种语言最终都要经过rcl中间层和DDS打交道。rclcpp是C客户端库rclpy是Python绑定。两者都实现了ROS2的基础规范包括话题、服务、动作、参数、生命周期节点、回调组等等。所以在功能上rclpy并没有矮人一截。我见过不少初学者明明Python写得很熟练却硬撑着用C写节点结果被编译、内存管理、头文件依赖搞到崩溃。等终于跑起来改一个参数又要重新编译一次。后来换了rclpy同样的逻辑可能只需要一半的时间。不是说C不好而是说如果你的任务不需要极致的实时性Python的开发效率优势是实打实的。1.2 什么场景适合用rclpy按我的经验下面这些场景直接用rclpy非常合适算法验证和原型设计。比如先验证一个路径规划思路代码写得快改得快。数据采集与转发节点。从传感器话题接收数据处理后发布到另一个话题这种IO密集型的任务Python完全扛得住。状态机与业务逻辑。比如导航任务调度、多步骤动作控制这类代码的核心是逻辑判断而不是计算。参数配置与调试工具。给已有系统写一个命令行参数调节工具、数据记录工具rclpy比C方便太多。如果你要做海量点云的实时处理、高频运动学解算、底层电机控制这类延迟和吞吐要求极高的模块那确实应该考虑C。但这不是非此即彼的选择。复杂的系统里两种节点完全可以共存重计算模块用C实现调度和业务逻辑用Python实现节点之间通过话题或服务通信各自发挥各自的长处。1.3 GIL到底对rclpy有多少影响Python的GIL经常被当成性能差的原罪一提到rclpy就有人说线程没法并行不如C。但实际跑机器人应用的时候你很快会发现大部分节点不是CPU密集型的而是IO密集型的——等消息、收消息、发消息、日志输出、参数读取。什么概念呢就像你站在快递传送带旁边大部分时间是在等下一个包裹过来而不是在拼命搬运。GIL只在真正并行执行Python字节码的时候才会有明显影响而收发消息本身ROS2底层是用C扩展和DDS实现的消息反序列化之后才进入Python回调。所以对于常见的话题通信场景GIL不是主要瓶颈。真正需要小心的是回调里的计算。如果你在一个回调里跑一个巨大的循环Python执行期间会持有GIL这时候其他Python线程会被卡住。这一点不管用单线程还是多线程executor都存在所以重点不是纠结GIL而是避免在回调里做重计算。这也正好引出后面要讲的executor和CallbackGroup话题。2. 先弄懂rclpy的三个关键机制init、Node、spin直接抄代码当然能跑但一旦出问题你根本不知道从哪里查起。我最开始写rclpy节点的时候就经历过照着示例抄完一运行好像没反应的尴尬。原因就是对背后机制没概念。rclpy里最核心的机制有三个rclpy.init()、Node对象、spin与executor。2.1 rclpy.init()给整个程序接通总电源rclpy.init()做的事情是初始化rclpy的运行时上下文。它会加载rcl库、准备DDS参与者、初始化日志系统等。你可以理解成init是给整个程序接上ROS2环境的总电源而后面创建的Node只是插在这条电路上的一个电器。这段代码必须在创建任何节点之前调用。如果你没调用init就直接去Node()rclpy会直接抛出异常提示运行时上下文还没准备好。init只需要调用一次就行即使你在同一个Python进程里要创建多个节点也只需要一次init。init还有一个容易被忽略的细节它可以接收参数列表。最常见的写法是rclpy.init(argsargs)这里的args通常来自sys.argv。这样ROS2自己的命令行参数比如--ros-args就能被正确解析同时不会影响你脚本里的自定义参数。如果你的程序完全不需要解析命令行也可以给空列表。等你见多了别人的代码就会明白为什么main函数都喜欢写成def main(argsNone)。2.2 Node对象节点的身份和资源容器Node是ROS2计算图里的基本单位。它有自己的名字和命名空间可以通过ros2 node list看到。你在节点里发布的每一个话题、创建的每一个服务、注册的每一个定时器本质上都挂在某个Node下面。所以rclpy里几乎所有操作都是围绕node进行的node.create_publisher()、node.create_subscription()、node.create_service()、node.create_timer()、node.get_logger()甚至参数系统也是node.declare_parameter()。我的代码习惯是把一个节点的所有资源都封装在自定义Node子类里在__init__里全部创建好这样节点结构清晰后续扩展也不会乱。名字方面有一点要注意在同一命名空间里节点名不能重复否则会出现同名节点冲突。所以给节点起名的时候我建议带上功能标识比如lane_detector、map_saver、battery_monitor这样用ros2 node list排查的时候一眼就能看出谁是谁。2.3 spin与executor为什么回调老是不执行新手最容易遇到的怪问题是明明订阅了话题消息也发了回调函数就是不执行。原因往往是没调用spin。rclpy的机制是这样的DDS收到消息后不会立刻自动调用你的Python回调而是把回调请求放到一个等待队列里。真正负责取出事件并调用回调的是executor。你调用rclpy.spin(node)相当于创建了一个executor并让它循环运行不断处理订阅、定时器、服务等事件触发对应的回调。可以这么理解spin就是节点的事件循环引擎。没有它节点只是在那边发呆哪怕话题消息爆炸它也一无所知。定时器也是同理你创建了create_timer(1.0, callback)但不spin定时器永远不会触发。所以一个标准rclpy节点的生命周期就是固定套路rclpy.init() node Node(my_node) # 创建各种发布者、订阅者、定时器 rclpy.spin(node) # 这才是真正开始跑起来 node.destroy_node() rclpy.shutdown()我猜很多人看到这里已经明白了之前代码不执行的真正原因。后面所有复杂节点本质上都是在这个骨架之上堆积木。3. 手写最小Python节点从空目录到能跑的talker理论说完了直接上手是最痛快的。下面我带你从零写一个最小可运行的Python节点它能周期发布一条字符串消息。3.1 包结构ament_python包的骨架在ROS2里Python节点通常放在ament_python类型的包里。手动建目录也行但我推荐用现成命令cd ~/ros2_ws/src ros2 pkg create --build-type ament_python my_pkg --node-name my_talker这条命令会生成一个标准包结构。如果你习惯了也可以手写结构大概是my_pkg/ ├── package.xml ├── setup.py ├── setup.cfg ├── resource/ │ └── my_pkg └── my_pkg/ ├── __init__.py ├── my_talker.py └── ...这里有两个文件要特别留意。一个是package.xml它声明了包的依赖我们的节点用到rclpy和std_msgs所以要在里面写exec_dependrclpy/exec_depend exec_dependstd_msgs/exec_depend另一个是setup.py里面最关键的是这个entry_points{ console_scripts: [ my_talker my_pkg.my_talker:main, ], },那行字符串表达的意思是ros2 run my_pkg my_talker时执行的是my_pkg.my_talker模块里的main函数。如果你以后给一个包写多个节点就在这里多配几个entry point。3.2 一个最小节点的六段式结构把my_talker.py打开写入#!/usr/bin/env python3 import time import rclpy from rclpy.node import Node from std_msgs.msg import String class MyTalker(Node): def __init__(self): super().__init__(my_talker) self.publisher self.create_publisher(String, chatter, 10) self.timer self.create_timer(1.0, self.timer_callback) def timer_callback(self): msg String() msg.data fhello from rclpy at {time.time():.3f} self.publisher.publish(msg) self.get_logger().info(fpublish: {msg.data}) def main(argsNone): rclpy.init(argsargs) node MyTalker() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ __main__: main()代码看起来不复杂但内部有几个关键点值得逐一说明。首先是要不要用class。你可以不用class直接把发布者和定时器挂在node属性上写函数但在稍微复杂一点的节点里状态变量一多没有class基本就乱套了。我习惯用class封装节点因为后续加参数、加订阅、加服务都方便所有成员都通过self统一管理。super().__init__(my_talker)是调用Node类的构造函数参数就是节点名。create_publisher(String, chatter, 10)里的10是队列深度意思是当订阅者处理不过来时话题最多缓存10条消息再新的就把最老的挤掉。这个数值不是随便填的发布频率高、订阅端处理慢的场景就得适当加大否则消息会无声无息地丢。create_timer(1.0, self.timer_callback)表示每1.0秒调用一次回调。定时器的单位是秒可以是浮点数比如0.05就是20Hz。这里的回调就是timer_callback函数。发布消息前需要构造String()消息对象给msg.data赋值再publish出去。很多人第一次写总是忘了先构造消息对象或者忘了给data赋值结果发布了一堆空消息还纳闷为什么对端没反应。main函数里的写法是我建议的标准收尾。先init再创建节点spin阻塞在那里服务直到收到CtrlC程序退出。当然如果你想优雅退出可以在循环里自己处理KeyboardInterrupt但基本结构不会有变化。3.3 编译和运行colcon build的正确打开方式代码写完之后回到工作区根目录编译cd ~/ros2_ws colcon build --packages-select my_pkg --symlink-install source install/setup.bash--packages-select指定只编译当前包免得整个工作区一起编译浪费时间。--symlink-install必须强烈建议养成习惯。它会让install目录里的Python代码以软链接方式指向源码目录这样你改了.py文件后不需要重新build就能生效只需重新source一下环境。不用这个参数的话每次改代码都要重新build烦到怀疑人生。编译之后一定要source否则新终端根本不知道你的包在哪。然后运行ros2 run my_pkg my_talker另外开一个终端用ros2 topic echo /chatter就能看到节点不断发布出来的消息。如果看到数据在刷说明你的第一个rclpy节点已经成功跑通了。这个时刻值得记一下后面所有复杂的节点都从这里延伸出来。4. 从hello到干活参数、日志、定时器让节点真正可配置一个只会发hello的节点没什么太大价值。真实项目里节点应该能通过参数调整行为用规范的日志输出状态用定时器管理周期任务。这一节的内容才是让节点从玩具变成工具的分水岭。4.1 参数系统不要把所有值都写死在代码里我见过太多人把话题名、发布频率、阈值这些直接写在代码里然后每次改需求都要改源码重新编译。ROS2的参数系统就是用来解决这个问题的。声明参数的方式很简单self.declare_parameter(publish_period, 1.0) self.declare_parameter(topic_name, chatter)在节点的__init__里声明之后运行期间可以通过命令行动态覆盖ros2 run my_pkg my_talker --ros-args -p publish_period:0.2也可以用launch文件传参数from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( packagemy_pkg, executablemy_talker, namemy_talker, parameters[{publish_period: 0.2}] ) ])读取参数时注意要用.value来拿真正的值否则你会拿回一个Parameter对象然后陷入为什么比较结果永远不对的困惑period self.get_parameter(publish_period).value我的经验是只要这个值以后可能被调整就不要写死在代码里。哪怕是当前看起来放死也没问题的阈值也值得声明成参数。因为在机器人调试阶段参数在线调整的价值远大于写死的简单。你甚至可以在回调里反复get_parameter这样即使节点不重启参数改动也能被读到。4.2 用get_logger分级日志代替裸printPython写多了习惯性用print但在ROS2节点里print会让日志变得混乱。你自己看还行一旦到了系统集成阶段几百个节点同时输出你根本不知道某行日志是哪个节点、什么级别、什么时候打的。rclpy提供了标准的logger。用起来很简单logger self.get_logger() logger.info(node started) logger.warn(something strange happened) logger.error(this is an error)和print最直观的区别是logger输出会带上节点名、日志级别和时间信息。更重要的是你可以控制日志级别。默认情况下debug级别的日志不会显示只有显式设置之后才看得到logger.set_level(rclpy.logging.LoggingSeverity.DEBUG)从命令行也能控制比如运行节点时临时把整个系统的日志级别调高或调低ros2 run my_pkg my_talker --ros-args --log-level debug进阶一点可以设置环境变量RCUTILS_LOGGING_LEVEL批量控制这对排查大系统中的诡异问题时特别有用。总之logger不是为了替代print而替代它是让日志可过滤、可追踪、可定位问题的标准做法。4.3 create_timer让非消息驱动的任务也变成周期前面talker里的定时器是周期发布消息这是它的常见用法。但定时器能做的事远不止发消息。比如状态机节点的周期性状态检查、传感器节点的掉线监测、心跳包的周期性发送这些任务不是由话题触发而是需要按时间节奏主动执行。最合适的做法就是create_timer。需要注意timer回调同样受executor调度如果回调本身耗时很长会拖累整个节点的其他回调。我见过一个极端案例有人在timer回调里写了一个阻塞式的串口读操作结果节点里的订阅回调全都饿死了消息能收但完全不处理。排查到后面才发现是timer占用了一切。如果你的周期任务比较重正确做法是把重活交给专门的工作线程timer回调只负责通知和投递比如往一个queue里塞任务再由后台线程取出来慢慢处理。这样既保持了定时触发的节奏又不会堵塞ROS2的回调执行流程。5. 多个回调与多个节点executor和CallbackGroup怎么配合真实节点往往不只有一个话题、一个定时器。你会发现多个回调同时存在时程序的执行方式和单回调完全不同。这一节我讲讲自己踩过的一个坑以及对应的解决思路。5.1 一个节点订阅多个话题回调会排队吗先做一个危险实验。给节点加一个订阅订阅chatter话题回调里故意sleep 3秒。同时再创建一个1秒周期的定时器。看起来定时器应该每秒跑一次但实际运行结果会让你傻眼定时器几乎不触发或者触发频率变得极不规律。原因是默认情况下rclpy.spin(node)使用单线程executor所有回调都在同一个线程里执行。订阅回调sleep了3秒executor整个被卡住定时器回调根本没有机会执行。这不是ROS2的bug而是机制本身就如此。多个回调在同一线程里就是排队一个阻塞全军覆没。5.2 多线程executor多回调可以并发执行解决思路之一是换用多线程executor。你可以手动创建from rclpy.executors import MultiThreadedExecutor executor MultiThreadedExecutor(num_threads4) executor.add_node(node) executor.spin()这样executor会用多个线程来跑回调不同回调之间有机会并行。这也是为什么我推荐在稍复杂的节点里不要直接rclpy.spin(node)而是自己创建executor来管理。多节点场景下你甚至可以把多个节点塞进同一个MultiThreadedExecutorexecutor.add_node(node_a) executor.add_node(node_b)要注意的是Python回调虽然跑在多线程里但GIL仍然存在所以并行的计算部分提升有限。不过对于等待IO、收发消息这类操作多线程executor的效果还是立竿见影的。5.3 CallbackGroup细粒度控制并发有时候你不想让所有回调都并发只想让某几个关键回调别再被其他耗时回调卡住。这时候就要用到CallbackGroup。rclpy里主要有两种回调组。默认的是MutuallyExclusiveGroup意思是一组内所有回调在同一个线程里串行执行。另一种是ReentrantCallbackGroup允许同一组里的回调在多个线程里同时执行。你可以在创建订阅或定时器时指定回调组from rclpy.callback_groups import ReentrantCallbackGroup group ReentrantCallbackGroup() self.subscription self.create_subscription( String, chatter, self.msg_callback, 10, callback_groupgroup )我的典型做法是耗时但不需要实时的回调放进一个组需要快速响应的回调放进另一个组再配合多线程executor就能做到互不拖累。这里有个容易混的点ReentrantCallbackGroup承诺的是这个组里的回调可以并发执行但如果你用的还是单线程executor它依旧不会并发。所以CallbackGroup和多线程executor是配合使用的。6. 实战排错rclpy节点最常见的问题排查清单写了这么多rclpy节点我把最容易让新手卡住的几个问题集中列一下。这些基本都是我亲身踩过、或者帮别人排查时见过的高频问题。6.1 ModuleNotFoundError: No module named xxx这个报错最常见的原因有三个。一是你的Python环境和ROS2环境不一致。比如你在conda的虚拟环境里运行节点而ROS2是基于系统Python安装的import rclpy自然失败。解决方法是确认当前which python指向的版本与ROS2使用的版本一致或干脆不激活虚拟环境运行。二是自定义消息的包没有build或没有source。如果你写的是from my_interfaces.msg import MyMsg但找不到模块先检查接口包是否真的编译了然后确认是否执行了source install/setup.bash。三是包名和模块名混淆。ROS2里Python模块路径是包名.文件名所以from my_pkg.my_talker import main没问题但如果你把包名写错或者文件里少了__init__.py就会import失败。6.2 Package xxx not found 和 ros2 run找不到可执行文件如果你已经build过但还是报找不到包第一反应应该是检查环境是否有问题。最直接的办法是新开一个终端source工作区后再试一次。很多时候就是忘记source或者source的是另一个工作区的setup.bash。还要检查包名拼写是否和目录名一致。ros2 pkg list | grep my_pkg能快速确认系统是否认识这个包。如果列出来了但ros2 run还是不行多半是setup.py里console_scripts没有配置好。记住entry point是在build时生成的改了entry_points后必须重新build仅仅是--symlink-install的软链接对这部分不生效。6.3 改了代码却不生效或者生效的是旧代码这个问题还经常以另一种形式出现明明改的是A工作区的代码运行后却是B工作区的老包。排查方法是运行ros2 pkg prefix my_pkg它会告诉你当前环境用的是哪个路径下的包。如果不匹配多半是环境变量里source了不止一个工作区后source的覆盖了前面的。我一般建议工作区越简单越好开发时就source当前这个工作区别把一堆乱七八糟的路径堆在.bashrc里。6.4 自定义接口怎么都import不了自定义msg/srv算是一个独立的大坑。首先接口包通常用ament_cmake类型创建ros2 pkg create --build-type ament_cmake my_interfaces然后往CMakeLists.txt里加rosidl_generate_interfaces把.msg和.srv文件列进去。build之后Python代码里的导入格式是from my_interfaces.msg import MyMsg from my_interfaces.srv import MyService很多人会写成import my_interfaces然后取里面的msg属性结果报错。其实ROS2生成的是独立模块必须按包名.msg.消息类型的路径来import。另外如果你同时build接口包和节点包记得先保证接口包编译成功并source再编译依赖它的节点包否则节点包在build时找不到生成后的Python模块直接报错。6.5 调试习惯先看图表再看日志最后怀疑代码当节点间通信不正常时我建议按照由外到内的顺序排查。启动节点后先ros2 node list确认节点已经注册再用ros2 node info /节点名看它发布了哪些话题、订阅了哪些话题然后用ros2 topic list和ros2 topic echo确认话题本身有数据在流动。这一套下来是通信问题还是代码逻辑问题基本就能分清楚了。确定话题没有数据时再去检查回调有没有触发。如果回调里加了不少日志还是没输出回头看看spin到底跑起来没有。很多时候不是消息没到而是节点的executor没有运转。按这个顺序排查比对着代码一行行冥想快得多。我在实际开发中养成的习惯是每个回调函数第一行先输出一条debug日志记录触发的关键信息。这样出问题时日志足以还原整个事件的时间线而不需要靠猜。别觉得日志啰嗦在分布式系统里看得见的数据才是安全感。
返回列表