不管你是刚从ROS1迁移过来,还是完全零基础直接上手ROS2,第一个拦路虎基本都是同一个——怎么写一个能跑起来的节点。网上关于rclpy的教程不少,但要么是官方文档的翻译腔,要么就是贴一段代码然后说“这样就跑起来了”,至于代码背后的执行逻辑、为什么这么写、踩了坑怎么排查,往往没人讲透。这篇文章就以“创建Python类型节点”为切入点,把rclpy的完整用法拆开揉碎,从环境准备、工程组织、代码编写到编译运行和排错,全部过一遍。适合刚入门ROS2的开发者,也适合那些写过节点但总觉得“哪里没搞懂”的朋友。
1. 内容整体设计与思路拆解
1.1 为什么选Python而不是C++
先回答一个很多人纠结的问题:ROS2节点用Python写还是C++写?
我的建议是:如果你不是对实时性有硬性要求,或者不是在搞底层驱动、硬件控制这类对性能敏感的东西,Python的rclpy足够应付绝大多数场景。ROS2的通信架构里,Python客户端库和C++客户端库走的都是同一套DDS中间件,话题、服务、动作这些通信机制没有本质区别。你用Python写一个发布器,C++的订阅器照样能收到消息,反之亦然。
区别主要体现在性能和开发效率的权衡上。rclpy在消息序列化、回调执行这些环节确实比rclcpp慢一些,但换来的是极其舒服的开发体验:不用写CMakeLists编译、不用处理头文件依赖、改完代码直接rerun就行。我在实际项目里做过粗略对比,一个每秒发布10次的控制指令节点,Python版CPU占用不到1%,这个开销在大多数场景下根本感知不到。
所以别纠结语言选型,先把节点逻辑跑通,后续真有性能瓶颈再拿C++重写关键路径也不迟。而且Python节点的代码结构、节点生命周期管理的方式和C++是高度对应的,学会一个,迁移到另一个成本很低。
1.2 ROS2节点的工作原理简析
在动手写代码之前,得先理解“节点”在ROS2里到底是什么。一句话概括:节点就是一个独立运行的进程,它通过DDS与系统中的其他节点通信。这和ROS1有本质区别——ROS1需要roscore作为中心调度,节点之间通过master间接通信;ROS2去掉了中心节点,每个节点直接通过DDS的发现机制互相找到对方,所以天生支持多机分布式部署。
rclpy就是ROS2提供给Python开发者的客户端库。它的作用可以理解成一个“翻译层”:你的Python代码通过rclpy的API创建节点、发布话题、订阅消息,底层由C库rcl和DDS实现来真正干活。这也是为什么你要import rclpy,而不是直接去操作DDS——大多数情况下你根本不需要关心底层细节。
理解这个分层之后,很多疑问就迎刃而解了。比如为什么节点要rclpy.init()之后才能创建?因为rclpy.init()做的事情是初始化整个客户端库的运行环境,包括通信层的全局上下文;为什么spin()会让程序一直阻塞?因为spin的本质是让节点持续处理传入的回调事件,没有spin,订阅消息来了也没人处理。
1.3 版本选择与环境准备
ROS2的发行版比较多,目前主流的编程环境基本都集中在两个:Ubuntu 22.04上装Humble,Ubuntu 24.04上装Jazzy。如果你用的是Windows或macOS,虽然官方支持,但坑比较多,这里不推荐拿来学习。我自己在Humble和Jazzy两个版本下都验证过本文的代码,语法层面没有差异。
装好ROS2之后,先确认环境变量有没有生效:
printenv | grep ROS_DISTRO如果输出是humble或jazzy,说明环境加载成功。还没source环境的话,记得手动加载:
source /opt/ros/humble/setup.bash然后检查Python端是否正常:
python3 -c "import rclpy; print(rclpy.__version__)"能打印出版本号就说明rclpy可用。这里有一个新手特别容易踩的坑:ROS2的Python库是绑定在系统Python环境里的,千万不要用pip install rclpy去覆盖,也不要用虚拟环境(venv)去跑ROS2的Python代码,否则会各种import失败、版本冲突。
2. 核心细节解析与实操要点
2.1 工作空间是什么以及怎么建
ROS2的工程组织单位是“工作空间(workspace)”,最经典的结构是src目录下放各个功能包。创建方式很简单:
mkdir -p ~/ros2_ws/src cd ~/ros2_ws/src很多教程让你直接用命令生成包,但我觉得有必要先说清楚目录结构的意义——这会直接影响你以后排查问题的能力。一个标准的Python功能包包含以下几个关键文件:
my_package/ ├── package.xml # 包的元信息和依赖声明 ├── setup.py # Python安装配置 ├── setup.cfg # 让ROS2能找到可执行脚本 ├── resource/ # 标记这是一个ROS2包 └── my_package/ # 实际的Python代码目录 └── __init__.py目录结构看懂之后,你会发现ROS2的包管理其实并没有想象中复杂:package.xml告诉构建系统这个包依赖谁,setup.py告诉安装系统Python代码怎么装,setup.cfg告诉ros2 run去哪里找可执行文件。这三者缺一不可。
2.2 用ros2 pkg create一键生成包
手动建目录当然可以,但更推荐用官方命令自动生成。这样能保证目录结构不出错,省去很多低级问题:
cd ~/ros2_ws/src ros2 pkg create --build-type ament_python learning_nodes--build-type ament_python指定了这是Python类型的包,生成成功后你会看到learning_nodes目录下已经有了package.xml、setup.py、setup.cfg这些文件。别忘了同步安装依赖工具:
sudo apt install python3-colcon-common-extensions2.3 package.xml、setup.py、setup.cfg逐个吃透
生成的包只是模板,里面的依赖和配置基本都是空的。我见过太多人在这三个文件上翻车,咱们逐个过一遍。
先看package.xml。对于Python包,核心依赖就两类。build_depend只在构建时需要,而exec_depend是运行时的硬依赖。这里要明确加上rclpy:
<buildtool_depend>ament_python</buildtool_depend> <exec_depend>rclpy</exec_depend>再看setup.py。除了包名、版本号、描述这些基本信息,最关键的入口是entry_points。它声明了“当用户执行ros2 run learning_nodes simple_node时,实际去运行哪个函数”:
entry_points={ 'console_scripts': [ 'simple_node = learning_nodes.simple_node:main', ], },注意这个格式:左侧是命令名,右侧是模块路径:函数名,中间用空格加等号分隔。写错任何一个字符,ros2 run都会报Package 'learning_nodes' not found或者executable not found。
最后是setup.cfg,生成模板基本不用改,但你要知道它存在的意义:
[develop] script_dir=$base/lib/learning_nodes [install] install_scripts=$base/lib/learning_nodes它告诉colcon:安装后把可执行脚本放到lib/learning_nodes目录下。如果你瞎改这个路径,即使包编译成功,ros2 run也找不到命令。
3. 实操过程与核心环节实现
3.1 第一个最小节点:从init到spin
现在进入正题。在learning_nodes/learning_nodes/目录下创建simple_node.py,这是你的第一个最小可运行节点:
import rclpy from rclpy.node import Node class SimpleNode(Node): def __init__(self): super().__init__('simple_node') self.get_logger().info('节点已启动') def main(): rclpy.init() node = SimpleNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()代码只有十几行,但每一行都有讲究。
rclpy.init()负责初始化客户端库。如果你忘了写,创建节点时会直接抛rclpy._rclpy_pybind11.RCLError: failed to initialize之类的异常。SimpleNode继承自Node,super().__init__('simple_node')里的字符串就是节点名。get_logger()返回一个日志对象,等价于ROS1里的ROS_INFO。
最不能省的是rclpy.spin(node)。spin的英文原意是“旋转”,你可以理解成进入一个死循环,不断处理节点收到的各种事件。没有spin,你的节点进程会直接跑完main函数退出。节点名为什么在这里要单独拎出来说?因为ROS2的节点名在分布式系统里相当于进程的身份证,不能重复,而且名字里不能有空格和特殊字符。
3.2 千万别让节点被垃圾回收
上面的代码里有一个暗坑:如果写成下面这样,节点会瞬间消失:
def main(): rclpy.init() rclpy.spin(SimpleNode()) rclpy.shutdown()SimpleNode()这个临时对象没有赋值给变量,Python的垃圾回收机制会在函数作用域里立刻回收它。节点被销毁,spin自然就退出了。你在终端里会看到节点名一闪而过,ros2 node list里什么都查不到。
正确的做法是让节点对象活着,直到spin结束:
node = SimpleNode() rclpy.spin(node)3.3 让节点动起来:添加定时器和计数器
一个只会打印日志的节点没有实际意义,接下来给它加一个定时器任务。比如每秒发布一个递增计数,模拟传感器周期性发数据的场景:
import rclpy from rclpy.node import Node from std_msgs.msg import Int32 class CounterNode(Node): def __init__(self): super().__init__('counter_node') self.publisher = self.create_publisher(Int32, 'counter', 10) self.count = 0 self.timer = self.create_timer(1.0, self.timer_callback) def timer_callback(self): msg = Int32() msg.data = self.count self.publisher.publish(msg) self.get_logger().info(f'发布计数: {self.count}') self.count += 1 def main(): rclpy.init() node = CounterNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown()create_publisher有三个参数:消息类型、话题名、队列深度。create_timer的第一个参数是时间间隔(秒),第二个参数是回调函数。这里有几个细节值得注意。
队列深度参数10代表消息队列能缓存10条消息。如果订阅端处理不过来,旧消息会被丢弃。这个值不是越大越好,内存占用和时效性需要平衡。
Int32().data必须显式赋值。很多人直接用msg = Int32(data=self.count)其实也行,但要知道std_msgs里的消息都是这样带data字段的简单结构。消息类型不匹配是话题通信最常见的错误,比如发布方用Int32,订阅方声明Float32,两端都不会报错,但消息永远收不到。
3.4 发布订阅:让两个节点对话
单节点自嗨没意思,我们再写一个订阅节点,让两个节点真正对话起来。创建subscriber_node.py:
import rclpy from rclpy.node import Node from std_msgs.msg import Int32 class SubscriberNode(Node): def __init__(self): super().__init__('subscriber_node') self.subscription = self.create_subscription( Int32, 'counter', self.listener_callback, 10) def listener_callback(self, msg): self.get_logger().info(f'收到计数: {msg.data}') def main(): rclpy.init() node = SubscriberNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown()注意create_subscription的参数顺序和create_publisher不一样:发布是(类型, 话题, 回调是create_publisher的第三个参数里的callback吗?回头看——create_publisher(Int32, 'counter', 10)第三个参数是队列深度;create_subscription(Int32, 'counter', self.listener_callback, 10)第三个参数是回调函数,队列深度在第四位。这个不对称让很多人写错过。
还有一个高频问题:订阅节点必须也在spin状态下才能收到消息。因为消息到来时,回调函数是在spin的循环里被调用的。你写好了回调,不调用spin,回调永远不会执行。
3.5 代码之外的配置:改三处才能编译运行
写完两个Python文件,要跑起来还需要改三个地方。去setup.py里把入口函数都声明上:
entry_points={ 'console_scripts': [ 'simple_node = learning_nodes.simple_node:main', 'counter_node = learning_nodes.counter_node:main', 'subscriber_node = learning_nodes.subscriber_node:main', ], },然后确认package.xml里已经写上<exec_depend>rclpy</exec_depend>和<exec_depend>std_msgs</exec_depend>。很多人漏掉std_msgs,结果编译没问题,运行时import报错。
接着编译。在~/ros2_ws目录下执行:
colcon build --packages-select learning_nodes--packages-select可以只编译指定的包,在大工作空间里能省大量时间。编译完成后必须source一下才能让系统找到新包:
source install/setup.bashsource这步太容易被忽略了。刚编译完直接ros2 run,大概率会提示找不到包,但你明明已经编译成功——先检查有没有source环境。这个操作在新终端里都要重新执行,不想每次手敲的可以写进~/.bashrc。
3.6 运行验证:从ros2 run到ros2 node list
终于到运行环节。开三个终端,分别执行:
ros2 run learning_nodes simple_node ros2 run learning_nodes counter_node ros2 run learning_nodes subscriber_nodecounter节点启动后,subscriber终端会开始刷“收到计数: x”,证明整个通信链路是通的。再用一组ROS2自带命令来确认结构:
ros2 node list ros2 node info /counter_node ros2 topic list ros2 topic echo /counterros2 node info输出里会看到该节点发布的/counter话题。这套命令配合日常调试非常顺手,比在代码里到处打日志高效得多。尤其是ros2 topic echo,可以直接在终端里查看话题上流动的实时数据,排查通信问题必备。
4. 常见问题与排查技巧实录
4.1 import rclpy失败的几种原因
这是新手报错频率最高的问题。第一种情况是根本没装ROS2,那不用说了,先装好环境。第二种情况是ROS2装了但环境没source,python3 -c "import rclpy"会报ModuleNotFoundError。第三种情况比较隐蔽——用了conda或venv虚拟环境。conda默认会切换到自己的Python路径,把你隔离在系统环境之外,ROS2的包自然是找不到的。如果你开了conda环境,先conda deactivate再跑。
还有一个很多人问的问题:能不能用pip install rclpy?不建议。ROS2的rclpy是绑着特定发行版走的,和系统里的rcl、rmw实现强相关。混装版本极易引发运行时崩溃,报错还特别难查。
4.2 colcon build相关的坑
编译阶段最常见的报错是:
Package 'learning_nodes' not found不是编译报错,是运行时报的,多半是忘了source。另一个是ros2 pkg create生成的模板里package.xml没有正确填写<description>和<maintainer>,colcon build会报错让你补全。把这两个字段写上,注意maintainer里必须有email属性。
还有的报错长这样:
ModuleNotFoundError: No module named 'learning_nodes'这通常意味着setup.py里的packages字段没有正确声明。生成的模板默认是这样:
packages=['learning_nodes']如果你把代码放在了子目录里没注册,import必然失败。保持生成的目录结构不要乱动,就不会有这个问题。
4.3 ros2 run找不到节点的排查
ros2 run learning_nodes counter_node报executable not found,基本就是setup.py的entry_points写错了。中文输入法经常把冒号打成全角,或者把=写成中文全角空格,这都够你排查半天。
我的排查习惯是三步走:先看setup.py里console_scripts的语法有没有问题;再去install/learning_nodes/lib/learning_nodes/目录下看有没有生成对应的可执行脚本;最后检查setup.cfg的script_dir路径有没有被改动。
4.4 节点启动秒退的深度排查
如果你运行节点后终端没有任何输出就退出了,先用最笨的办法排查:在main函数里加一行print('start'),看打印不打印。如果不打印,说明Python解释器都没跑到你的代码,优先检查import语句是不是挂了。如果打印了,说明卡在rclpy.init()或spin上。
另一个秒退原因是端口冲突或DDS发现失效。ROS2底层走的DDS默认使用UDP端口,在多机场景或某些网络环境下会有问题。这时候看环境变量有没有ROS_DOMAIN_ID——同一网段内如果不同设备的domain id不一致,节点之间就“老死不相往来”,表现就是单个节点启动正常,但话题收不到数据、node list互相看不到。简单说,所有互联的机器必须设置同一个ROS_DOMAIN_ID。排查命令就这么一行:
echo $ROS_DOMAIN_ID没设置默认都是0,两边一致才行。
4.5 话题通了但收不到数据的进阶排查
ros2 topic list能看到话题,但topic echo没有任何输出,这是最让人头大的问题。优先怀疑消息类型不匹配。发布方和订阅方用的消息类型必须完全一致,std_msgs/Int32和std_msgs/Float32看似差不多,其实在DDS层会被识别成两个完全不同的话题类型。
其次检查通信两端是不是在同一个网络域。前面说了ROS_DOMAIN_ID不一致会造成“老死不相往来”。最后检查是否使用了不同的ROS_DOMAIN_ID或者存在防火墙拦截UDP多播的情况,这个在实体机器人部署时经常遇到。
5. 从Demo到工程化:多文件与参数配置
5.1 多节点拆分的工程哲学
看完上面的例子,你可能想说:就这?发布订阅我在教程里看过无数次了。但如果只是停留在跑通Demo,技术深度远远不够。下面聊聊怎么把单节点Demo改造成真正的工程。
工程化的第一件事是拆分文件。一个节点的代码不应该都堆在main()里。比如你的机器人节点需要同时处理传感器数据、运动控制、状态上报,全塞一个Python文件,几百行起步,后期维护成本极高。合理的做法是拆成多个模块:
learning_nodes/ ├── __init__.py ├── simple_node.py ├── counter_node.py ├── subscriber_node.py └── config.py各个节点在各自文件里独立实现,config.py放共享常量。这在团队协作时特别有用——A负责传感器节点,B负责控制节点,互不干扰。
5.2 给节点加参数:不做“硬编码狂魔”
Demo里把话题名、发布频率直接写在代码里,这在真实项目中不行。ROS2提供了参数机制,让节点可以在启动时动态配置。在构造函数里声明参数:
self.declare_parameter('topic_name', 'counter') self.declare_parameter('publish_rate', 1.0) topic_name = self.get_parameter('topic_name').get_parameter_value().string_value rate = self.get_parameter('publish_rate').get_parameter_value().double_value启动时这样传参:
ros2 run learning_nodes counter_node --ros-args -p topic_name:=my_counter -p publish_rate:=2.0这比改代码重编译高效太多。尤其在调参阶段,不用每次改完代码重新编译source,参数机制的体验我认为是ROS2相比ROS1一个很大的进步。
5.3 从单节点走向多节点系统的一点经验
文章最后分享一条我在实际项目中摸爬滚打得到的经验:学会用ros2 launch管理多个节点。
三个终端各跑一个ros2 run只是入门方式,真实项目的节点数量往往在十几个以上,手动启动注定是一场灾难。launch文件可以把所有节点的启动配置固化在一个文件里:
from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node(package='learning_nodes', executable='counter_node', name='counter_node'), Node(package='learning_nodes', executable='subscriber_node', name='subscriber_node'), ])一个命令全部拉起:
ros2 launch learning_nodes demo.launch.pynode的name参数还可以覆盖代码里写的节点名,这在多机部署时特别有用。另外还有一个容易被忽视的小建议:养成用ros2 bag record -a记录话题数据的习惯。真实机器人调试时,同一段问题可能要反复回放数据才能定位,有了录制好的bag文件,你可以在自己的电脑上离线重现现场,分析效率翻倍。
回到最初的问题——创建Python节点这件事本身并没有多难,难的是把通信机制、生命周期、工程组织这些环节串起来。我见过太多人在第一步就被各种环境问题劝退,或者跑通一个Demo就觉得ROS2不过如此。其实上手ROS2最正确的姿势恰恰是先从最简单的Python节点开始,把发布订阅、生命周期、参数系统这些基础概念吃透,再往launch、tf、nav2这些上层框架走,整体学习曲线会比直接啃C++节点平滑得多。而且rclpy的API设计总体上相当一致,当你掌握了一个节点的完整生命周期,后面写十个节点也只是复制熟练而已。