☰
基于ROS 2的移动机器人SLAM建图与自主导航模块实践
2026/10/21 15:52:23 网站建设 项目流程

最近一直在折腾移动机器人底盘,手上攒了一套激光雷达加双轮差速的小车,系统跑的是 Ubuntu 加 ROS 2。以前建图导航都是现成 Demo 跑一跑,真正要自己搭一套模块化、能复用的地图系统时,才发现零散的功能包拼起来根本不够用。于是花了几个周末把方案重新梳理了一遍,做了个叫lingbot-map的地图与导航综合模块。这篇文章记录的就是这个项目从需求拆解、地图分层设计、传感器集成到最终实现自主建图与导航的完整过程,分享给正在做机器人定位导航、想搞定 SLAM 建图与路径规划的朋友。

lingbot-map解决的核心问题很简单:如何让一台普通差速底盘小车,在没有 GPS 的室内环境里,自主构建环境地图、实时定位并规划出可通行的路径。它面向的关键技术点包括激光 SLAM、AMCL 定位、栅格地图与代价地图管理,以及 ROS 2 环境下的多传感器融合与 tf 变换体系。无论你是刚接触机器人导航的入门者,还是想参考一套完整工程架构的开发者,这篇文章都会给你一条清晰的技术路线,以及我在实际调试中踩坑后的经验总结。

1. 项目整体设计与方案选型

1.1 核心需求与设计思路

开始动工之前,我先把项目要解决的问题列清楚。lingbot-map不是什么实验室里的花活,而是要满足三个硬性需求:

  • 可重复建图:同样的场地,多次建图结果要基本一致,不能因为雷达震动或者轮子打滑就飘走。
  • 可长期定位:建图完成后,重启系统或更换场景,能快速恢复到正确位姿。
  • 可安全导航:从 A 点到 B 点的路径规划,必须避开障碍物,还要根据机器人实际尺寸做膨胀处理。

围绕这三个需求,我规划了四个核心层级:传感器输入层、SLAM 建图与定位层、代价地图层、路径规划与运动控制层。每层之间通过 ROS 2 的话题和服务解耦,这样后续替换雷达、换底盘电机驱动,都不需要推翻重来。

设计时我特意把“地图”的概念拆开:栅格地图负责静态环境描述,代价地图负责动态障碍与安全边界描述,tf 变换树负责把雷达、底盘、电机编码器这几个坐标系串起来。三者配合,机器人才能既知道环境长什么样,又知道自己在哪里、周围有没有新出现的障碍物。

1.2 技术选型:为什么是激光雷达 + 差速底盘

关于传感器方案,我考虑过视觉 SLAM,比如纯相机加特征点。但对比下来,激光雷达在室内环境里有两个明显优势:一是测距精度高,几毫米级别的误差,远不是单目摄像头能比的;二是不依赖光照条件,关灯环境也能稳定建图。对于入门级机器人项目,单线激光雷达加轮式里程计是性价比最高、调试门槛最低的组合。

底盘选择差速驱动也很关键。双轮差速的运动学模型简单直观,线速度和角速度可以独立控制,非常适合和 ROS 2 的nav2导航框架对接。相比之下,全向轮底盘虽然后勤上更灵活,但运动学解算复杂,对编码器标定和里程计精度要求也高得多。对于项目初期,把差速底盘调稳比追求全向机动更划算。

1.3 软件框架与代码组织架构

软件框架上,我更倾向于把lingbot-map拆成三个独立功能包:

  • lingbot_sensor:负责激光雷达、IMU 和编码器数据采集与处理,输出标准的sensor_msgs/LaserScan和nav_msgs/Odometry。
  • lingbot_mapping:封装 SLAM 算法服务端,负责建图、地图保存和 AMCL 定位。
  • lingbot_nav:基于nav2的导航模块,加载代价地图、对接路径规划器和控制器。

这样划分的好处是:某个模块出问题时,可以直接单独重启或替换,其他模块不受影响。比如雷达串口松动导致数据断流,不需要重启整个机器人系统,只需要重启lingbot_sensor节点即可。我在实际测试中多次受益于这种解耦设计。

2. 核心模块拆解与技术实现

2.1 SLAM 建图:Gmapping 与 Cartographer 对比与选型

SLAM 建图是整个项目的基石。在 ROS 2 生态里,最常用的开源方案有两个:slam_toolbox(对应原 Gmapping 系列)和cartographer。我两个都实际跑过,下面是我整理的对比:

对比维度slam_toolboxcartographer
计算资源占用较低,树莓派级别也能跑较高,建议有独立 CPU 核心
回环检测能力较弱,纯 2D 激光匹配强,支持子图回环与后端优化
建图精度(典型场景)小房间、走廊较好的效果大面积、多回环场景更稳定
地图类型2D 栅格地图2D/3D 栅格地图
调试难度容易上手,参数少参数多,配置复杂

我的实际选择是:日常房间建图用slam_toolbox,速度快、参数简单;如果遇到大面积场地、或者小车走几圈后地图有轻微错位的情况,就切换到cartographer跑一次回环优化。两个算法封装在同一个建图节点里,通过配置文件切换算法类型,对上层导航逻辑完全透明。

在建图过程中,我发现有个细节特别影响最终地图质量——雷达安装高度和俯仰角。如果雷达稍微低头,扫描到地面的点会让地图边缘出现一圈模糊影子。我最后给雷达做了 3D 打印支架,依托水平泡校准安装平面,地图清晰度提升非常明显。

2.2 定位与里程计:多传感器数据融合

建图完成后,机器人再次启动时就需要回答一个问题:“我现在在哪里?”这依赖两个信息源:里程计和激光雷达匹配结果。

里程计数据来自电机编码器,它短时间内的相对位姿很准,但长时间运行必然产生漂移。激光雷达匹配则通过扫描当前环境与已有地图的相似性来推算位姿,精度高但没有历史记忆。AMCL 算法把二者结合:先用里程计预测机器人大致位置,再用雷达观测值修正粒子权重。这个原理可以类比成:你闭着眼睛往前走,每走几步睁眼看一下周围的标志物,修正一下自己的方向。

在lingbot-map里,我给坐标变换做了统一管理。整个机器人最重要的三组坐标系分别是:

  • base_link:机器人底盘中心,所有传感器都挂在它下面。
  • laser:激光雷达坐标系,直接随雷达安装位置变化。
  • odom:里程计坐标系,世界坐标系中的临时参考。

每次启动,tf 树必须正确广播odom -> base_link和base_link -> laser的变换,AMCL 节点才能正常监听。如果雷达的安装位置测量误差超过 3 厘米,地图精度影响还不大,但定位收敛速度会明显变慢,尤其在小房间里容易出现粒子漂移。

2.3 代价地图:静态地图之上的动态安全防护

有了静态栅格地图,机器人知道墙在哪,但还不能直接导航。因为地图上没有沙发、桌子腿等动态障碍物,也没考虑机器人本体的宽度。这就轮到代价地图上场了。

导航中使用的代价地图实际上分两层:

  • 静态层(static layer):来源于我们建好的栅格地图,表示环境中永久不变的障碍物。
  • 障碍物层(obstacle layer):实时订阅激光雷达话题,把检测到的点云转换到栅格中,标记为障碍。
  • 膨胀层(inflated layer):把障碍物向外扩张机器人半径的距离,确保规划出的路径不会让机器人擦到墙。

三层叠加之后,规划器就是在“安全区域”里寻路。膨胀半径我设置为 0.25 米,对应车体半径加 5 厘米安全余量。这个值设太大会导致窄通道无法通行,设太小则容易刮蹭。

2.4 路径规划与控制:全局规划 + 局部避障

导航过程的控制分成两级。全局规划使用 Dijkstra 算法,在整个代价地图上搜索一条从起点到目标点的最优路径。局部规划则负责实时避障,采用 DWA 算法,机器人走一小段后就会重新评估周围障碍物,动态调整速度指令。

实际测试时我发现一个细节:全局规划器要设置合理的“规划频率”,默认 1Hz 一般够了。如果设得太高,在复杂环境中路径会反复抖动,底盘电机跟着来回正反转,损耗很大。局部控制器的最大线速度我设置为 0.25 m/s,最大角速度 0.9 rad/s。这个参数下,机器人走起来既顺滑,又不会因为侧翻风险导致雷达数据抖动。

3. 实操过程与核心环节实现

3.1 硬件环境准备与系统配置

我的测试平台构成比较简单:一块 STM32 驱动板控制两个直流减速电机,激光雷达串口连接树莓派,树莓派上跑 Ubuntu 22.04 加 ROS 2 Humble。整套系统的接线和供电是很多新手容易忽略的环节,我一开始直接用充电宝给树莓派供电,结果雷达一扫描,瞬时电流造成电压跌落,树莓派频繁重启。

后来改成了 12V 锂电池组,通过 DC-DC 降压模块分出三路独立供电:一路给驱动板电机供电,一路给树莓派,一路给激光雷达。三个子系统都做了地线共接,避免串扰。这是一个非常值得提醒的坑:供电系统不干净,SLAM 建图结果几乎一定不干净。

软件环境配置方面,除了安装 ROS 2 Humble 本身,还需要安装导航相关依赖包:

sudo apt install ros-humble-navigation2 ros-humble-nav2-bringup ros-humble-slam-toolbox ros-humble-cartographer ros-humble-amcl ros-humble-gazebo-ros-pkgs

这里我建议直接安装nav2-bringup,它已经把导航所需的启动文件打包好,不需要再从零手写。安装完成后用ros2 pkg list | grep nav2确认包是否齐全。

3.2 激光雷达驱动的接入与话题测试

激光雷达驱动移植到 ROS 2 是很常规的工作,我们用的雷达厂商提供了 ROS 2 驱动包,安装后直接运行节点即可。不过接入后第一件事不是建图,而是检查数据话题:

ros2 topic echo /scan --once

重点看两个指标:angle_min和angle_max是否覆盖完整平面,通常应为 -π 到 π;ranges数组是否有大量 inf 或 NaN 值。inf 通常表示激光束打到很远的物体上超过量程,但如果你站在雷达旁边,发现近距离某个方向总是 inf,大概率是雷达存在盲区或者安装偏斜。

还有一个非查不可的参数是雷达扫描帧率。如果雷达帧率只有 5Hz,在建图时小车稍微转快点,相邻帧之间的角度差会很大,SLAM 匹配容易失败。我的经验是激光帧率至少 10Hz,15Hz 以上更稳。

3.3 里程计发布与底盘标定流程

里程计质量直接影响建图与定位的稳定性。差速底盘通过编码器计算移动距离与角度的公式是:

左轮位移:Δd_left = (编码器计数差 / 编码器分辨率) × 轮周长

机器人中心位移:Δd = (Δd_left + Δd_right) / 2

航向角变化量:Δθ = (Δd_right - Δd_left) / 轮距

这里的三个关键参数是:编码器分辨率、驱动轮直径、轮距。这三项里面,驱动轮直径是最容易被低估影响的因素。原厂模具出来的轮子虽然标称 6.5cm,但实测外径往往带小数点偏差。我通过让小车直线行驶,实测 10 米实际位移与里程计报告的位移差值,反推出修正系数:

修正系数 = 实际位移 / 里程计位移

把修正系数乘到每个周期的位移增量上,里程计的短期精度从 5% 偏差降到了 1% 以内。这里我没有采用理论轮径,而是标定后的等效轮径,后续所有建图、导航都会踩在这个校准值之上。

3.4 slam_toolbox 建图启动与地图保存

启动建图流程前,我会先把激光雷达和里程计节点跑起来,用ros2 run tf2_echo tf2_echo base_link laser确认坐标变换正常,然后启动 slam_toolbox 节点,再打开 RViz2 查看地图构建过程。

lingbot_mapping包里我封装了一个建图启动脚本,核心内容是加载参数文件:

slam_toolbox: ros__parameters: odom_frame: odom map_frame: map base_frame: base_link scan_topic: /scan mode: mapping

启动后,我用手持遥控器控制小车在房间里慢慢移动。这里我总结出三条建图口诀,对新手特别适用:

  • 速度要慢,直线段控制在 0.15 m/s 以内,转弯时原地旋转,边转边等地图更新。
  • 路径要闭合,从起点出发绕一圈回到起点附近,让算法完成回环。
  • 场景要静止,建图过程中不要移动房间里的椅子、箱子,否则地图上会出现重影。

建图结束,调用地图保存节点,生成map.pgm和map.yaml两个文件。map.yaml里的resolution字段表示每像素对应多少米,常用 0.05,即 5 厘米一个栅格。这个分辨率下,墙面轮廓和室内通道都能清楚表达。

3.5 AMCL 定位初始化与参数调优

有了静态地图,就进入定位阶段。启动 AMCL 定位节点后,我发现最容易踩的坑是初始位姿不准确。AMCL 的粒子滤波器需要知道机器人启动时的近似位置和朝向,如果初始位姿偏差超过 1 米,粒子就很难收敛。

解决方式有两种:一种是手动在 RViz2 里用“2D Pose Estimate”按钮指定大概位置;另一种是在启动时通过参数或服务直接写入初始位姿:

amcl: ros__parameters: initial_pose: x: 1.0 y: 2.0 theta: 1.57 initial_cov_xx: 0.5 initial_cov_yy: 0.5

我建议先手动初始化一次,观察粒子收敛情况,再把这个位姿固化到启动参数里,做成“一键定位”。AMCL 里几个关键参数的经验值我放在下面:

参数名推荐值范围说明
max_particles2000 ~ 4000粒子越多,定位越稳但 CPU 占用越高
min_particles500 ~ 1000粒子太少容易丢失定位
update_min_d0.1 ~ 0.2机器人移动多少米才更新粒子,太勤会抖动
update_min_a0.1 ~ 0.2机器人转多少弧度才更新粒子

实测下来,在 20 平米左右的空间中,2500 个粒子、0.15 米的更新距离表现最好,CPU 占用大约稳定在 25%。

3.6 nav2 导航与动态目标点派发

导航阶段启动nav2_bringup后,可以通过 RViz2 的“Nav2 Goal”按钮直接点击目标点发布导航任务。不过我在项目里更常用代码方式派发目标点,方便自动化测试:

from geometry_msgs.msg import PoseStamped import rclpy from rclpy.node import Node class GoalPublisher(Node): def __init__(self): super().__init__('goal_publisher') self.publisher = self.create_publisher(PoseStamped, '/goal_pose', 10) self.timer = self.create_timer(1.0, self.publish_goal) def publish_goal(self): goal = PoseStamped() goal.header.frame_id = 'map' goal.header.stamp = self.get_clock().now().to_msg() goal.pose.position.x = 2.5 goal.pose.position.y = 1.0 goal.pose.position.z = 0.0 goal.pose.orientation.w = 1.0 self.publisher.publish(goal) self.get_logger().info('目标点已发布') rclpy.init() node = GoalPublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown()

nav2 收到目标点后,会依次执行全局路径规划和局部路径跟踪。如果局部规划器发现自己走的方向被临时出现的人挡住,会原地重新搜索局部路径,而不是退回全局起始点。我用一盆绿植挡在机器人前方测试过,机器人在距离绿植 30 厘米左右平滑绕开,验证了动态避障的有效性。

3.7 地图的保存与复用闭环

整个项目最重要的闭环是“建图 → 保存 → 复用定位 → 导航”。为了让这个过程真正可用,我在代码里做了一个小功能:每次建图结束后自动统计地图的可通行面积和总栅格数,如果可通行面积比例低于 25%,会打印提示“当前环境开阔度不够,建议清理障碍后重新建图”。

这个逻辑帮我省了不少事。有一次我让小车在一个堆满杂物的储物间建图,地图建得快,但后期导航时机器人总在窄通道里规划失败。原因就是可通行区域太少,膨胀层把几乎所有通道都堵上了。后来我养成了习惯:每次建图后都看一眼地图栅格统计信息,对可通行情况做到心里有数。

4. 常见问题与排查技巧实录

4.1 建图过程中地图漂移与错位

地图漂移是最常见也最头疼的问题。现象是墙壁出现重影,或者走廊拐角错开。我排查的顺序固定如下:

  1. 先看里程计是否准确:让小车直线走 2 米,对比实测位置与odom话题输出。误差超过 5 厘米就需要重新标定轮径修正系数。
  2. 再查雷达数据是否有缺失:用ros2 topic hz /scan查看频率,低于 8Hz 多半是驱动或者串口传输丢包。
  3. 最后检查底盘控制是否有空程:差速底盘在高速启动或急停时,电机惯性容易让编码器走多几个脉冲,对应在 SLAM 里就是瞬时抖动。

排查后,我们项目里的地图漂移大多集中在 1 和 3。解决办法是给里程计话题加了一个滑动平均滤波,把异常突变值平滑掉。

4.2 AMCL 定位丢失与粒子发散

定位丢失的典型特征是 RViz2 里粒子突然散开成一团乱麻,或者机器人位置和实际位置明显不一致。我第一次遇到时以为是雷达坏掉了,后来追踪日志才发现是base_link -> laser的平移坐标写错了。

统一规则是:所有传感器坐标都用“从 base_link 中心到传感器中心的偏移量”。我最初把激光雷达的安装高度 0.35m 对应到 z 轴,但写成了 0.5m。雷达高度误差 15 厘米,看起来影响不大,但在走廊狭窄处,激光扫描到天花板边缘,粒子自然发散。

遇到粒子发散,我自己的快速恢复技巧是:把机器人停在原地旋转 360 度,让雷达扫一遍周围环境,AMCL 通常能在一圈内重新收敛。如果还不行,就直接重新初始化位姿,并让小车慢速往已知方向挪动。

4.3 代价地图上的“幽灵障碍物”

导航过程中,机器人偶尔会在空无一物的地方突然停车,RViz2 代价地图上显示一个临时障碍点。这种“幽灵障碍物”通常来自激光雷达打到透明玻璃或镜面材料上的返回异常。

物体表面太光滑时,部分激光束会被反射到别处,雷达测量距离突然变近或变远。代价地图障碍层收到这些异常点后,会临时标成障碍,导致规划器“无路可走”。

我的处理办法分两步。先在雷达驱动里启用“最小距离过滤”,把 0.05m 以内的点直接剔除,因为正常情况下雷达不可能测出这么近的距离。再在障碍物层的 raytrace 参数中开启“无代价空间清理”,让被误标的栅格在下一帧中没有重复命中的情况下快速清除。这样处理之后,玻璃门附近再没有出现误停车问题。

5. 实测数据与效果对比

等所有模块都调通后,我做了一组规范化测试来评估整个lingbot-map系统的综合表现。测试场景是一个 8m × 10m 的开放办公区,里面有工位隔断、几把椅子和两个落地绿植。

测试项测试结果
单圈建图耗时约 6 分钟(手动控制,平均速度 0.15 m/s)
地图分辨率0.05 m/pixel
重复建图一致性两次建图边界最大偏差 0.08 m,平均偏差 0.04 m
AMCL 定位收敛时间初始位姿偏差 0.8m 时,收敛约 3 秒
导航路径成功率随机发布 30 个目标点,27 次成功到达,成功率 90%
最大直线导航速度0.25 m/s
动态避障响应时间障碍物突然出现到机器人开始绕行约 0.5 m

失败的三次导航全部发生在同一个场景:机器人需要穿过一个宽度仅 0.55m 的通道,而膨胀半径加车体半径后,安全通过宽度几乎贴到极限,规划器反复计算都找不到足够安全的路径。后来我把膨胀半径从 0.25m 微调到 0.22m,才解决了这个极限工况。这也说明,代价地图参数与环境尺寸强相关,没有一个值能通吃所有场景。

对比单用视觉方案和单用里程计的方案,激光 SLAM 加 AMCL 在室内稳定性和可重复性上都明显胜出。视觉方案的主要问题是在光照变化和遮挡时特征点数量骤降,而单用里程计方案长时间运行后误差累积非常大,导航到远距离目标时车会越走越歪。三者对比下来,激光雷达加轮式里程计融合是目前入门级室内机器人最靠谱、也最容易复现的技术组合。

6. 一些未经整理的个人体会与扩展建议

如果你打算照着lingbot-map复现一套,我最想强调的并不是算法参数怎么调,而是“把基础做扎实再谈优化”。我见过不少开发者一上来就追求跑通 cartographer 回环,结果一周之后还困在坐标变换和雷达数据频率上。先把 Gmapping 或 slam_toolbox 跑稳定,再逐步迁移到更复杂的方案,这条路径会顺畅得多。

硬件层面,建议新手直接购买带编码器的减速电机,不要为了省钱买普通直流电机。编码器反馈让里程计精度有了质的飞跃,没有它,一切 SLAM 算法效果都会打折。另外,雷达安装位置尽量在底盘正中心上方,偏移越小,运动模型越简单。

软件层面,养成用ros2 bag record -a录制数据的习惯会非常有帮助。调试 SLAM 参数时,回放一包真实数据比反复遥控小车在房间里转圈效率高得多。我录了几段不同房间的数据包,后来调参数都是离线回放,几分钟就能完成一次调参循环。

后面我还打算给lingbot-map增加两项能力。一是接入 IMU 惯性测量单元,和轮式里程计做更紧密的数据融合,用来对付地毯和瓷砖上轮子打滑的情况。二是把地图格式升级成通用标准格式,方便迁移到不同仿真平台中去验证。就目前的表现来看,这套模块已经可以稳定支撑日常室内参观、巡检类场景的应用需求,每次看到小车准确穿过窄门、绕开突然出现的障碍物,还是能想起最初它连直线都走不直的样子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询