资讯动态

ROS基础功能源码实操:从节点通信、tf到URDF建模全解析

发布时间:2026/10/17 11:36:58 来源:尧图企业网站定制
简介一份面向机器人操作系统ROS学习者的《ROS机器人开发实践》随书源码与功能包合集适合正在入门或进阶机器人开发的工程师、学生对照书籍完成实操。内容覆盖ROS基础操作、话题与服务、参数服务器、导航与SLAM、多机器人协作以及ROS2独立功能包等模块并包含mrobot、marm、感知和机器学习相关功能包。压缩包共839个文件约146.12MB以222个Python脚本、87个launch启动文件、38个XML配置、32个YAML参数以及cpp/h源文件、URDF/Xacro模型、RViz可视化配置等为主要类型目录按基础、进阶、感知、学习、ROS2等维度分隔便于检索与迁移。其中感知与机器学习相关包可继续作为传感器数据处理、目标检测等实验起点。已有1137人学习下载。借助这些源码能快速获得书中示例对应的可运行代码、依赖描述与模型配置省去重复搭建环境的时间也可直接复用消息定义、服务调用和URDF建模片段开展二次开发。1. 当仿真里的机器人突然不动了一套能直接跑的ROS基础功能源码开发过机器人的朋友大多经历过这样的场景照着《ROS机器人开发实践》把发布者、订阅者、服务端的代码一行行敲进去catkin_make一路绿灯结果roslaunch一启动机器人模型在rviz里纹丝不动终端里刷出一屏又一屏红色报错。问题通常不在算法层面而是ROS基础功能——话题、服务、参数、tf、URDF——某个环节没配对。这套源码就是为这个场景准备的把书里涉及的基础功能代码整理成可直接编译运行的功能包覆盖节点通信、坐标变换、机器人建模和launch启动这几个日常机器人开发绕不开的模块。刚入门的读者可以照着跑通每个demo理解机制有经验的开发者也能拿它当标准实现来对照。2. 整体结构拆解工作空间、功能包与编译配置拿到源码先别急着编译先把目录结构看明白。ROS项目和普通软件项目最大的区别在于它的编译单位不是整个仓库而是功能包。一次catkin_make会遍历src下所有包但某个包编译失败并不会影响其他包继续编译——这个设计的好处是模块隔离坏处是新手很容易在满屏的红色错误输出里找不到真正的问题出在哪个包。2.1 工作空间里的功能包划分和阅读顺序这套源码按功能模块拆分成多个包每个包聚焦一种ROS基础机制目录结构大致是这样catkin_ws/ ├── build/ # 编译中间文件由catkin自动生成 ├── devel/ # 环境变量与生成的可执行文件 └── src/ ├── learning_topic/ # 话题通信发布者与订阅者 ├── learning_service/ # 服务通信服务端与客户端 ├── learning_action/ # action通信长时间任务的请求应答 ├── learning_parameter/ # 参数服务器参数的读写与动态配置 ├── learning_tf/ # 坐标变换tf广播与监听 ├── learning_urdf/ # URDF机器人建模从连杆到关节 └── learning_launch/ # launch文件多节点启动编排每个包的定位很清楚这是书里刻意做的分层。第一遍看源码时重点看learning_topic和learning_service它们是ROS通信机制的骨架learning_tf和learning_urdf可以放到第二步因为它们依赖前面的基本概念learning_launch放在最后看launch文件的本质是多个节点的组合启动节点都没搞明白就先看launch很容易一头雾水。这个阅读顺序和编译顺序是一个道理。我的工程习惯是第一次catkin_make之前先把src下不影响大局的包临时移出只保留learning_topic和learning_service等这两个包编译通过、demo跑通之后再逐步把其余包放回src重新编译。这样如果某个包有问题你能精确定位到是哪个包的哪一行引入的错误而不是在一堆“找不到头文件”“链接失败”的红色输出里大海捞针。2.2 package.xml与CMakeLists.txt一个管依赖一个管编译每个功能包的根目录下有两个文件决定这个包能不能编译、能不能运行一个是package.xml一个是CMakeLists.txt。很多新手在编译报错时盯着代码看半天实际问题却出在这两个配置文件里。先看learning_topic的package.xml?xml version1.0? package format2 namelearning_topic/name version0.0.1/version descriptionROS topic communication demo package/description maintainer emaildevexample.comdeveloper/maintainer licenseMIT/license buildtool_dependcatkin/buildtool_depend dependroscpp/depend dependrospy/depend dependstd_msgs/depend /packagepackage.xml是功能包的“身份证”。name字段必须和目录名一致否则catkin会提示找不到包。depend字段声明依赖的ROS库roscpp和rospy分别对应C和Python接口std_msgs提供标准消息类型比如String、Int32、Float64。这里有一个很容易被忽视的细节如果写的是Python节点但depend里只写了roscpp没写rospy编译不会报错因为Python脚本不需要编译一旦运行节点import rospy直接抛异常。所以检查package.xml时要养成习惯别只盯着代码逻辑看。再看learning_topic的CMakeLists.txt关键段落cmake_minimum_required(VERSION 3.0.2) project(learning_topic) find_package(catkin REQUIRED COMPONENTS roscpp rospy std_msgs ) catkin_package() include_directories(${catkin_INCLUDE_DIRS}) add_executable(talker src/talker.cpp) add_dependencies(talker ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS}) target_link_libraries(talker ${catkin_LIBRARIES}) add_executable(listener src/listener.cpp) add_dependencies(listener ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS}) target_link_libraries(listener ${catkin_LIBRARIES})这段CMakeLists对应C版本的发布者和订阅者。add_executable把src/talker.cpp编译成名为talker的可执行文件add_dependencies是catkin的特殊要求它会先编译消息头文件再编译节点如果你的节点用了自定义消息却漏了这一行会报“找不到头文件”target_link_libraries把ROS库链入可执行文件。三条语句缺一不可。如果功能包里只有Python脚本则不需要add_executable因为Python是解释执行的。但必须给脚本加可执行权限否则rosrun会提示Permission denied这个坑后面专门讲。这套源码里同一个功能同时给了Python和C两套实现。我的建议是读代码时以Python为主重点是理解通信机制写正式工程时优先C。C节点部署时不依赖解释环境性能瓶颈更容易定位。反过来Python有个不可替代的优势调试单点功能时改代码不用重新编译直接重新运行脚本就行。所以我的工作流是先用Python脚本快速验证逻辑确认无误后再用C重写成正式版本。还有一个编译上的经验如果你改了某个包里的自定义消息定义只重新编译那个包往往不够所有依赖它的包也需要重新编译。catkin_make增量编译时通常能处理好但如果你发现改了消息定义后其他包仍在使用旧定义试试catkin_make --force-cmake强制重新配置。这条命令算是我遇到ROS编译玄学问题时的第一招。3. 话题通信核心链路从发布者到订阅者的完整流程话题是ROS里最基础的通信方式特点是异步、单向、多对多。发布者只管往话题上发数据不关心谁在订阅订阅者只接收数据不用知道数据是谁发的。learning_topic这个包就是围绕这个机制组织的下面拆开看发布者和订阅者的代码。3.1 发布者节点talker的节点初始化与消息发布#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy from std_msgs.msg import String def talker(): # 节点初始化节点名在ROS网络中全局唯一 rospy.init_node(talker, anonymousTrue) # 创建发布者话题名为chatter消息类型String队列深度10 pub rospy.Publisher(chatter, String, queue_size10) # 发布频率10Hz每秒发布10次 rate rospy.Rate(10) while not rospy.is_shutdown(): msg String() msg.data hello ROS %s % rospy.get_time() pub.publish(msg) rate.sleep() if __name__ __main__: try: talker() except rospy.ROSInterruptException: pass发布者的逻辑链路是init_node注册节点到ROS网络Publisher声明话题名和消息类型Rate控制循环节奏publish发出消息sleep按频率挂起。整个while循环持续执行直到收到CtrlC或节点被关闭。这里有两个参数值得在改动时特别注意。第一个是queue_size消息队列容量。发布速率10Hz如果订阅者处理不过来消息会在队列里堆积queue_size10表示最多缓存10条超出后最旧的消息被丢弃。这个值不是越大越好——下游处理速度跟不上时队列再大也只是延迟丢包时间系统整体感知仍然卡顿。第二个是anonymousTrue它允许同名节点共存。加了anonymous后节点名会自动拼接随机后缀同时跑两个talker也不冲突。这在多节点调试时很有用但在正式系统里我一般不启用因为节点名不稳定会让日志和话题监控变得混乱。再看C版本的发布者这是实际工程里更常用的写法#include ros/ros.h #include std_msgs/String.h #include sstream int main(int argc, char **argv) { ros::init(argc, argv, talker); ros::NodeHandle n; ros::Publisher pub n.advertisestd_msgs::String(chatter, 10); ros::Rate loop_rate(10); while (ros::ok()) { std_msgs::String msg; std::stringstream ss; ss hello ROS ros::Time::now(); msg.data ss.str(); pub.publish(msg); ros::spinOnce(); loop_rate.sleep(); } return 0; }C版本的逻辑和Python版本一致区别主要在两点advertise的模板参数指定了消息类型编译期就确定数据类型主循环里必须显式调用ros::spinOnce()来处理回调。在这个没有订阅者的例子里spinOnce看起来多余但一旦节点同时存在订阅者忘写spinOnce会导致回调函数永远不执行。这个知识点在下一个节点里会再次遇到。advertise的第二个参数10就是队列深度含义和Python版本相同。3.2 订阅者节点listener的回调机制与spin#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy from std_msgs.msg import String def callback(data): # 每收到一条消息这个函数被自动调用一次 rospy.loginfo(I heard %s, data.data) def listener(): rospy.init_node(listener, anonymousTrue) # 订阅chatter话题消息到达时回调callback rospy.Subscriber(chatter, String, callback) # 阻塞等待保持节点不退出 rospy.spin() if __name__ __main__: listener()订阅者节点的核心是回调机制。Subscriber注册之后消息到达时自动触发callback函数。这里有一个重要前提ROS默认的回调是单线程的。如果回调函数里有耗时操作——图像处理、文件写入、复杂计算——新消息会在队列里堆积节点看起来就像卡住了一样。所以工程上的约定是回调里尽量只做轻量操作耗时任务丢给独立线程或者交给后续的处理节点。rospy.spin()的作用是阻塞住主线程让回调有机会被触发。很多新手写的订阅者节点没有spin或者用while循环加sleep代替结果回调一次都不执行。原因是ROS的消息回调依赖内部事件循环需要主线程进入spin状态循环才会持续分发消息。有人尝试while not rospy.is_shutdown()加sleep这会让CPU空转回调依然不执行因为事件循环根本没转起来。C版本对应#include ros/ros.h #include std_msgs/String.h void chatterCallback(const std_msgs::String::ConstPtr msg) { ROS_INFO(I heard: [%s], msg-data.c_str()); } int main(int argc, char **argv) { ros::init(argc, argv, listener); ros::NodeHandle n; ros::Subscriber sub n.subscribe(chatter, 10, chatterCallback); ros::spin(); return 0; }C版本用ros::spin()进入事件循环。区分spin和spinOnce是理解这套代码的关键spin会一直在循环里不返回适合纯粹的订阅者节点spinOnce只处理当前堆积的回调然后立即返回适合发布者节点在循环里周期性地做发送操作。把spin用在发布者主循环里会导致后续代码永远执行不到把spinOnce用在订阅者节点里则会让回调处理变得断断续续。搞混这两个函数是ROS新手最常见的报错来源之一。如果想在话题上发送的不只是字符串而是坐标、速度、位姿这类结构化数据做法是在功能包的msg目录下自定义消息文件。定义格式和前面的srv文件类似编译后会自动生成对应语言的类。这是从“通信基础”走向“机器人控制”的第一步导航、机械臂控制、多机协同这些场景里几乎所有数据都是通过自定义消息在话题上流动的。4. 服务通信与参数服务器同步请求应答的实现方式话题通信是异步的发布者发完就完事不关心对方有没有收到。但有些场景需要同步的请求应答——比如查询机器人的当前位姿你得发出请求然后等结果回来。ROS里的服务就是干这个的它是典型的同步通信模式learning_service这个包演示了完整的服务端和客户端实现。4.1 服务端与客户端srv文件如何驱动代码生成服务通信的第一步不是写代码而是定义srv文件。它描述请求和响应的数据格式语法非常直观int64 a int64 b --- int64 sum三行定义了一个名为AddTwoInts的服务前两行是请求字段---分隔线下面是响应字段。记住这个分隔线上面是请求下面是响应。文件保存在srv/AddTwoInts.srv然后在CMakeLists.txt里加上编译规则add_service_files(FILES AddTwoInts.srv)同时修改find_packagefind_package(catkin REQUIRED COMPONENTS roscpp rospy std_msgs message_generation )srv文件的价值在于让通信双方的数据格式有约束。不用srv文件也可以靠传字符串实现请求应答但那样做服务端和客户端之间没有契约字段名改一个就要两头找bug。定义成srv文件后编译期就能发现问题生成的代码会自动做字段类型校验。服务端代码#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy from learning_service.srv import AddTwoInts, AddTwoIntsResponse def handle_add_two_ints(req): # 从请求对象里取两个加数并求和 result req.a req.b rospy.loginfo(Returning [%s %s %s], req.a, req.b, result) # 返回值必须是srv里定义的响应类型 return AddTwoIntsResponse(result) def server(): rospy.init_node(add_two_ints_server) # 注册服务客户端通过add_two_ints这个名字调用 rospy.Service(add_two_ints, AddTwoInts, handle_add_two_ints) rospy.spin() if __name__ __main__: server()注意import那一行from learning_service.srv这一段的learning_service是功能包名srv是编译后生成的消息模块目录。这行import能工作前提是前面CMakeLists里的add_service_files已经执行过。如果改了srv文件但没有重新编译import会失败因为生成的代码并不存在于当前包的安装目录里。这是service例子里最常出现的问题。客户端代码#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy from learning_service.srv import AddTwoInts def client(): rospy.init_node(add_two_ints_client) # 等待服务上线最多等5秒 rospy.wait_for_service(add_two_ints, timeout5) try: # 建立服务连接返回一个可调用的代理对象 call rospy.ServiceProxy(add_two_ints, AddTwoInts) # 同步调用发出请求阻塞等待响应 resp call(3, 5) rospy.loginfo(Result: %s, resp.sum) except rospy.ServiceException as e: rospy.logerr(Service call failed: %s, e) if __name__ __main__: client()客户端代码有两个关键点。第一是wait_for_service它在调用前阻塞等待服务端上线如果服务端没启动就等到超时而不是立即报错。这在实际项目里很实用省去了手写重试逻辑的麻烦。第二是ServiceProxy和Service是两个不同的类Service是服务端接收请求的入口ServiceProxy是客户端发起调用的出口两者通过服务名关联。如果服务名拼写不一致客户端会一直等到超时错误信息还不容易看出来。什么时候用服务什么时候用话题这个选择会影响整个系统结构。服务是同步的适合查询类、控制类等需要确认结果的短任务话题是异步的适合高频数据流。如果你发一条运动控制指令用服务比较合适因为你需要知道指令是否被成功执行如果你做的是传感器数据流用话题因为传感器高频发布数据不可能等每个消费者应答完再发下一条。见过不少项目里新手用服务推高频传感器数据结果服务端阻塞、客户端超时、整个节点卡死。高频数据请用话题请求应答请用服务这个判断标准应该从一开始就建立。4.2 参数服务器读写参数与launch传参参数服务器是ROS里一个独立的全局字典节点可以往里面写参数也可以读取其他节点写入的参数。learning_parameter这个包演示了最基本的读写操作#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy def param_demo(): rospy.init_node(param_demo) # 设置参数实际键名为 /param_demo/publish_rate rospy.set_param(~publish_rate, 10) # 读取参数第二个参数是默认值 rate rospy.get_param(~publish_rate, 30) rospy.loginfo(publish_rate %s, rate) if __name__ __main__: param_demo()参数名的第一个~是私有命名空间的简写~publish_rate会被展开为/param_demo/publish_rate相当于把参数挂到节点自己的命名空间下。get_param带默认值读取时如果参数不存在就返回默认值不会抛异常。这个设计很贴心但也是坑如果代码里不小心把参数名写错了默认值会静默兜底你不会立即发现问题直到某天参数行为不对才一点一点追溯发现是参数名拼写错了。在launch文件里传参是更常用的做法下面是learning_launch包里的片段launch node nameparam_demo pkglearning_parameter typeparam_demo.py outputscreen param namepublish_rate value20 / param namelog_enabled valuetrue / /node /launch这段launch启动param_demo节点通过param标签传入两个参数。注意这里的publish_rate没有~前缀和代码里set_param的~publish_rate是不同的键前者是全局参数/publish_rate后者是私有参数/param_demo/publish_rate。如果代码里用get_param(~publish_rate)读取私有参数launch里param namepublish_rate传的却是全局参数两边的值互不相通。这个命名空间匹配问题在ROS开发里非常常见尤其在多人协作的项目里每个人对参数命名习惯不同参数对不上是家常便饭。5. 避坑指南跑这套源码最容易踩的5个坑源码本身是能跑的但运行环境、权限、命名空间这些环节很容易出问题。这里把跑这套源码时最高频的5个报错记录下来每条按现象、原因、解决三步展开。5.1 现象catkin_make编译通过rosrun却提示找不到功能包编译明明成功了执行rosrun learning_topic talker.py终端提示package not found。原因几乎都是环境变量没有生效。编译生成的可执行文件和环境变量都放在devel目录下不执行sourceROS根本不知道这个包存在。解决方法是source devel/setup.bash但这条命令只对当前终端有效新开一个终端又要重新执行。我通常直接把这一行追加到~/.bashrc末尾这样每次打开终端自动生效。另一个隐蔽问题是多个ROS工作空间并存时~/.bashrc里只能保留一个工作空间的source否则后source的会把之前的环境变量覆盖掉出现“明明source了还是找不到包”的诡异现象。这个排查顺序要记住先看终端里ROS_PACKAGE_PATH变量指向哪里再看~/.bashrc的source行有没有冲突。5.2 现象Python脚本报Permission deniedrosrun执行Python脚本时提示Permission denied但脚本代码看起来完全正常。原因是rosrun要求脚本具备可执行权限而从压缩包解压出来的文件默认权限是644没有执行位。解决方法是chmod x scripts/*.py这个坑我几乎每次拿到新源码包都会踩一遍。后来养成习惯任何ROS源码包到手第一件事就是find src -name *.py -exec chmod x {} ;把权限问题一次性扫干净。否则每运行一个节点就报一次Permission denied纯属浪费时间。5.3 现象发布者和订阅者都启动了rostopic echo却收不到数据终端里分别rosrun了talker和listenerlistener没有报错但rostopic echo /chatter就是没有输出。这种情况八成是话题名或命名空间不匹配。先用rostopic list查看当前活跃话题确认话题实际叫什么名字。新手很容易把话题名写错一个字母或者在launch文件里给节点加了namespace导致实际话题名变成/namespace/chatter而代码里订阅的是/chatter。解决方法是让发布者和订阅者的话题名严格一致如果涉及namespace在launch文件里用remap标签做话题重映射remap fromchatter to/robot1/chatter /rostopic list是排查这类问题的第一工具不要凭直觉猜直接看ROS网络里真实存在的话题名。5.4 现象tf坐标变换报错Lookup would require extrapolation在learning_tf的例子里打开rviz控制台报错Lookup would require extrapolation into the past机器人模型在rviz里抖动。这个报错的本质是时间同步问题tf的lookupTransform需要指定查询时间戳如果目标时间戳比tf buffer里已有的数据早就会触发这个错误。常见原因是两个节点之间的tf广播频率不一致或者查询时使用了固定时间戳但系统时间发生偏移。解决方法是查询时用ros::Time(0)获取最新变换同时检查广播端的循环rate是否设置得太低。记住tf不是一次性静态数据它是带时间戳的缓存查询时间点必须在缓存时间范围之内。5.5 现象URDF模型在rviz里加载后关节错位learning_urdf里加载模型某个关节的零件飞到了奇怪的位置或者两个连杆互相穿插。原因几乎都是joint的origin坐标定义错误。URDF里每个joint都有一个origin描述子连杆相对父连杆的位置和姿态xyz是平移量rpy是旋转量。很多新手只设置了type和parent/child漏掉origin导致所有连杆堆在原点。排查顺序先看单位再看origin最后才看质量属性。URDF的单位是米从其他建模软件导出的模型可能是毫米或英寸数值直接抄过来会把模型放大好几倍。一个典型的joint定义长这样joint namejoint2 typecontinuous parent linklink1/ child linklink2/ origin xyz0 0.1 0.2 rpy0 0 0/ axis xyz0 1 0/ /joint这段joint描述的是link2相对link1的位置。xyz0 0.1 0.2表示link2的坐标系原点在link1坐标系下沿y方向偏移0.1米、沿z方向偏移0.2米axis指定关节的旋转轴是y轴。如果模型错位优先检查数值的符号和单位这两个问题占URDF姿势问题的八成以上。6. 二次开发思路把基础功能源码改造成自己的第一个机器人应用跑通全部demo只是起点更关键的是知道怎么在基础源码上改出自己的东西。这里给两条实际可操作的改造路径一条针对多机器人场景一条针对仿真到实机的过渡。6.1 把单机话题改造成多机器人命名空间做多移动机器人项目时每台机器人都在跑同一套talker和listener节点如果不做隔离所有机器人的话题会互相窜。解决办法是在launch文件里给每台机器人分配独立的namespacelaunch group nsrobot1 node nametalker pkglearning_topic typetalker.py outputscreen/ /group group nsrobot2 node nametalker pkglearning_topic typetalker.py outputscreen/ /group /launch这样robot1的话题自动变成/robot1/chatterrobot2的是/robot2/chatter互不干扰。订阅端只要指定话题名/robot1/chatter就能精确获取某台机器人的数据。这个改造没有改动一行业务逻辑纯粹依靠命名空间做了解耦是基础源码上性价比最高的改动之一。6.2 从仿真到实机驱动层替换时的接口设计用这套源码里的URDF模型在仿真里跑通之后最常见的下一步是把仿真中的里程计替换成真实传感器数据。仿真里的里程计通常来自物理引擎的ground truth数据是理想值实机里程计来自轮式编码器或IMU有噪声也有漂移。替换时不要直接改上层逻辑而是写一个里程计发布节点把实机传感器原始数据换算成和仿真相同的Odometry消息格式发布到同名话题。这样上层节点完全感知不到底层变化仿真里调好的导航和控制代码可以直接复用到实机上。这个接口适配层的设计思路是从这套基础源码走向真正机器人开发时最值得借鉴的一点。不过这里有一个普遍的坑编码器输出的增量数据换算成线速度时需要知道轮距和轮径这两个参数只要标定误差超过5%机器人直线运行轨迹就会明显偏移。我曾经在一台小车上踩过这个坑走的路径总往一侧偏检查了半天发现轮径参数直接用了图纸上的标称值没有按实际轮子做标定。后来做每台新机器人第一件事就是先跑一段直线记录实际位移和理论位移的比值把轮径参数离线修正一遍再让其他模块跑起来。从那以后每次替换传感器驱动我都强制先做一轮单位换算和参数标定的验证再谈上层功能的适配。希望这篇源码拆解能帮到你这套基础功能代码值得你多花些时间在上面做实验。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑