
把一台ROS小车从“能走”变成“认路”中间最关键的一道坎就是建图。底盘能响应/cmd_vel、雷达在RViz里画出圈圈这只能说明你的传感器和控制链路是通的要让小车在真实环境里拥有自己的“认知地图”就得跑一次SLAM建图。这个系列做到第04期终于到了这一步在上位机上使用gmapping完成室内环境的2D栅格地图构建。这篇文章写给手里已经有一台能跑起来的ROS小车、雷达和底盘驱动都正常工作的朋友。我不会从一个空工程开始讲环境安装而是集中拆解“用gmapping建图”这条主线上所有值得注意的事为什么选gmapping而不是其他方案、建图前需要哪些基础话题和数据、launch文件怎么写、参数怎么调、推车走位的技巧、以及我实际踩过的各种坑。读完你不仅能复现一遍完整流程还能在出问题时自己排查而不是到处问人。1. 为什么这步选择了gmapping室内2D建图的“稳”字诀1.1 不吹不黑主流建图方案的选型对比很多刚接触SLAM的同学一上来就在gmapping、hector、cartographer之间纠结最近还老看到fast-lio、mid360这类3D方案。选型其实没那么玄核心就一句话根据你的传感器配置、硬件算力和应用场景来定而不是根据哪个看起来高级。这个项目是室内轮式导航小车雷达是单线2D激光雷达上位机是一块树莓派或类似性能的板卡目标是输出能被move_base用的2D栅格地图。在这个前提下我把各个方案过一遍方案核心思路是否依赖odom算力消耗典型场景适合当前项目吗gmapping粒子滤波 激光扫描匹配依赖低室内轮式机器人、低速环境非常适合hector_slam纯激光扫描匹配不依赖低无人机、无里程计平台勉强但雷达频率不高时容易飘cartographer图优化 子图匹配依赖可融合IMU高室内外大型场景、复杂环境不推荐树莓派上跑起来吃力fast-lio等3D方案激光IMU紧耦合里程计依赖IMU/3D雷达较高3D建图、特种机器人不匹配本项目没有IMU和3D雷达我当时选gmapping理由非常实际第一它不挑雷达5Hz到10Hz的单线雷达都能用对安装精度要求也相对宽松第二它依赖odom里程计而底盘驱动里本身就发布odom不需要额外买IMU第三粒子滤波的调参路径很直观particles、linearUpdate、angularUpdate这些参数改一下就能看到效果对新手非常友好。cartographer虽然地图效果好、还能做回环检测但配置项实在太多scan matching、子图插入、位姿推测、全局优化全都要调。在树莓派这类上位机上跑起来CPU占用常年七八十调试一次编译加重启小半天就没了。fast-lio那种方案就更不用说了传感器不匹配硬上只会浪费时间和钱。1.2 粒子滤波干的事一边猜自己在哪一边画地图gmapping的原理用大白话讲就是让机器人一边用激光雷达探测周围环境一边回答“我现在在哪里”然后把每一次测量到的障碍物轮廓画到一张地图上。它内部是用一群“粒子”来表示机器人位置的概率分布每个粒子就是一份对机器人位姿的猜测。每来一帧激光算法拿粒子的位置把激光数据和已有地图做匹配算一个匹配得分得分高的粒子说明“这个位置猜得对”就多保留一些后代得分低的粒子逐步淘汰。随着粒子不断重采样最后剩下的一堆粒子就会聚集在机器人真实位姿附近。这就是粒子滤波SLAM最核心的逻辑。这个过程里有两个关键输入预测靠里程计纠正靠激光。里程计告诉你“我大概朝哪个方向走了多远”激光告诉你“我看到的墙是不是跟地图对上”。两者缺一不可。所以你会发现gmapping建图效果好不好很大程度取决于odom准不准。这也是后面很多坑的根源——odom一旦飘了粒子给你拉回来一次可以每次都靠着雷达硬拽回来就不现实了。2. 建图前的三大件雷达、里程计与TF树2.1 底盘的odom从哪来、到哪里去在ROS导航小车里上位机和下位机的分工很明确下位机STM32/Arduino这类单片机负责采集编码器数据、控制电机上位机树莓派/Jetson/迷你主机跑ROS master和各种算法节点。odom话题是由上位机里的底盘驱动节点发布的数据来源于下位机通过串口发上来的编码器计数、角速度等信息。在继续往下之前先确认你的底盘驱动节点有没有正确发布odom。检查方式很简单在终端里跑rostopic echo /odom | head -20你会看到类似这样的输出header: seq: 123 stamp: secs: 1700000000 nsecs: 123456789 frame_id: odom child_frame_id: base_footprint pose: pose: position: x: 1.23 y: 0.45 z: 0.0 orientation: x: 0.0 y: 0.0 z: 0.56 w: 0.83 twist: twist: linear: x: 0.2 y: 0.0 z: 0.0 angular: z: 0.1重点看frame_id是odomchild_frame_id是base_footprint有些底盘是base_link都可以但要保持全链路一致。position和orientation表示机器人在odom坐标系下的位置和姿态linear.x以及angular.z是当前速度反馈。我遇到过一个非常典型的错误下位机里程计把单位搞错了编码器累积的数值按毫米发上来结果推着小车走1米odom里显示走了1000米。这种数据喂给gmapping地图直接就“起飞”了。所以上手第一件事推着小车走直线看odom里的x数值变化是否和实际距离对得上。2.2 激光雷达驱动从串口权限到/scan话题雷达这块我以最常见的rplidar系列和ydlidar系列为例。驱动包装好之后把雷达USB插到上位机上ls -l /dev/ttyUSB*如果显示的是crw-rw---- root dialout这种权限普通用户没法直接读需要加权限sudo usermod -aG dialout $USER或者图省事每次启动前执行sudo chmod 666 /dev/ttyUSB0建议直接写udev规则一劳永逸。在/etc/udev/rules.d/下新建rplidar.rules内容填KERNELttyUSB*, ATTRS{idVendor}10c4, ATTRS{idProduct}ea60, MODE:0666idVendor和idProduct用lsusb查自己设备的值。驱动起来之后用rostopic hz /scan确认雷达频率。rplidar A1默认扫描频率在5.5Hz到10Hz之间ydlidar X2/X4一般也是5到12Hz。如果频率忽高忽低、或者经常卡顿先怀疑USB供电问题换带屏蔽的USB线或者单独5V供电基本能解决。雷达数据发出来的是sensor_msgs/LaserScan包含距离和角度数组gmapping直接从/scan主题订阅。2.3 TF树关系一次梳理清楚后面少求人TF树是ROS里最让新手头疼、但又是建图绕不开的东西。gmapping要正常工作必须知道激光雷达在机器人身体的哪个位置、odom在哪、机器人在哪。它需要的坐标系变换关系在ROS里构成一棵树map → odom → base_footprint → base_link → laser这个链条里每个箭头的发布者不同map → odom由gmapping节点自己发布表示机器人在全局地图中的位姿估计。odom → base_footprint由底盘驱动节点发布也就是里程计累积的位置。base_footprint → base_link一般由robot_state_publisher根据URDF发布。如果只有底盘没机械臂等情况也可以让驱动节点直接发布。base_link → laser表示激光雷达相对于机器人本体的安装位置通常由雷达驱动包里的静态TF发布。检查TF树完整性用rqt_tf_tree或者命令行rosrun tf view_frames它会生成frames.pdf打开就能看到当前所有坐标系的连接关系。如果看到map和odom没连起来那基本是gmapping没启动或者没正常工作如果base_link和laser断了那就是雷达驱动里静态TF没发布。这里分享一个很容易被忽略的点坐标系名字必须统一大小写。比如雷达驱动里发射的frame是laser你的gmapping launch里写的是laser_frameTF树直接断。这种问题报错不算很明显日志里只是不断提示等待TF新手查半天都不一定反应得过来。3. 上线实操写一个能直接用的gmapping建图launch3.1 一个经过实测的launch模板建图launch不需要花里胡哨以下是我在自己小车上验证过的模板里面包含了gmapping必需的全部参数也兼顾了树莓派的性能launch !-- 底盘驱动具体节点名和包名以自己小车为准 -- node namebase_driver pkgmy_bot_bringup typebase_driver_node outputscreen/ !-- 激光雷达驱动以rplidar为例 -- node namerplidar_node pkgrplidar_ros typerplidarNode outputscreen param nameserial_port value/dev/ttyUSB0/ param nameframe_id valuelaser/ param nameserial_baudrate value115200/ param nameangle_compensate valuetrue/ /node !-- gmapping核心节点 -- node nameslam_gmapping pkggmapping typeslam_gmapping outputscreen param namebase_frame valuebase_footprint/ param nameodom_frame valueodom/ param namemap_frame valuemap/ !-- 运动触发条件 -- param namelinearUpdate value0.2/ param nameangularUpdate value0.15/ !-- 激光相关 -- param namemaxUrange value5.0/ param namemaxRange value6.0/ param namesigma value0.05/ param namekernelSize value1/ param namelstep value0.05/ param nameastep value0.05/ param nameiterations value5/ !-- 运动模型噪声 -- param namesrr value0.1/ param namesrt value0.2/ param namestr value0.1/ param namestt value0.2/ !-- 匹配打分阈值 -- param nameminimumScore value0.0/ !-- 地图更新 -- param namemap_update_interval value5.0/ param nameparticles value40/ param namexmin value-10.0/ param nameymin value-10.0/ param namexmax value10.0/ param nameymax value10.0/ /node /launch注意xmin/ymin/xmax/ymax这一组参数限制的是栅格地图的边界范围单位是米。如果你要在很大的环境里建图比如几百平米的厂房记得把它放宽否则超出边界的部分不会画进地图里。3.2 参数不是玄学每个关键项为什么这么设我一直觉得gmapping这类算法最大的优点就是参数直观你用“行为后果”去理解它比死记硬背容易得多。先说linearUpdate和angularUpdate。这两个参数表示机器人在平移多少米或者旋转多少弧度之后才触发一次新的地图扫描匹配。设小了算法频繁做优化CPU占用高设大了建图精度下降尤其在转弯多的地方容易出现错位。linearUpdate0.2的意思是每移动20厘米做一次优化angularUpdate0.15约等于每转8.6度做一次优化。这两个值是我在树莓派上权衡之后给出的经验值如果你的上位机性能好可以再把linearUpdate降到0.1精度会更好。maxUrange和maxRange是一对需要配合的参数。激光雷达标称测距范围比如12米但我们在室内建图时远处的点往往噪声很大还会混入杂散反射。maxUrange设5米意思是最远只信任5米内的数据参与map构建maxRange设6米稍微高于maxUrange用来判定“超过这个距离就是没有障碍物”。这样设置的目的是在不牺牲地图质量的前提下减少远处噪声的干扰。然后是particles。这个参数直接影响粒子滤波的粒子和分布理论上粒子越多位姿估计越稳但CPU开销也线性增长。树莓派3B上我建议最多40个性能更好的上位机可以放到60到80个。我实测过从40调到80地图精度的提升肉眼几乎不可见但CPU占用翻了一倍个人认为得不偿失。minimumScore值得单独拿出来说。它是激光扫描匹配得分的最低阈值低于这个分数的扫描直接算“匹配失败”。很多教程把这个值设成50甚至200我不推荐新手这么做。原因很简单在小车底盘状况一般、odom误差较大的情况下minimumScore设太高会让gmapping频繁判定失败地图一片片地糊掉。我的建议是刚开始设0先把图建出来之后再根据rostopic echo /slam_gmapping/scan_match_score看实际得分慢慢往上加。3.3 一键启动用launch嵌套整合驱动和建图调试过程中我喜欢分开跑方便定位问题。底盘的驱动一个终端、雷达一个终端、gmapping一个终端哪个节点挂了看得清清楚楚。但分步调试完正式建图时再打开三个终端就很烦了。一个更省事的做法是用launch文件嵌套把三个启动项合到同一个launch里。具体来说就是在顶层launch里用include引入底盘驱动和雷达驱动的launch再加上gmapping节点。这样每次建图只需要roslaunch my_bot_bringup slam_gmapping.launch但要注意启动顺序。gmapping节点启动之后需要等底盘和雷达的话题、TF都发布出来才能正常工作。里顺序上把底盘的节点放前面雷达驱动放中间gmapping放最末然后在gmapping节点前用roslaunch自带的group加一个sleep不太好弄更简单的办法是在节点内部做一个time延时。我以前用的是wait_for_param或者干脆在C驱动里用ros::Duration(3).sleep()做延时但如果你不想改代码还有一种很常见的做法分两个launch先roslaunch my_bot_bringup base_and_lidar.launch等话题都出来了再roslaunch my_bot_bringup mapping_only.launch。虽然多敲了一条命令但建图过程中重启gmapping也方便——地图数据出问题关掉gmapping节点再启动就行底盘和雷达不用跟着重启。4. 手推小车建图的完整流程从启动到保存4.1 建图前的准备动作清单在建图开始前我建议每次都花两分钟做一轮快速检查别嫌麻烦这一步能帮你省下后面好几轮试错执行rostopic list | grep -E scan|odom确认/scan和/odom都在。执行rostopic hz /scan和rostopic hz /odom确认数据频率稳定。打开rqt_tf_tree确认map、odom、base_footprint、base_link、laser之间的连接完整。打开RVizAdd一个Map显示Topic选/map再Add一个LaserScan显示Topic选/scan。把场地里的杂物、人清一清雷达高度尽量保持与墙体中部对齐。这些步骤看起来基础但每一项出问题都会让建图“莫名其妙”地失败。比如雷达频率只有2Hzgmapping匹配时数据点间隔太大地图就会出现锯齿状边缘这种问题靠调参数根本解决不了只能回去查雷达。4.2 推车走位先绕大圈再扫内部建图走位是有讲究的不会推车的同学推着推着就把地图搞得乱七八糟。我总结了一套比较好用的路线策略。第一步先让小车在原地缓慢转几圈注意是控制下位机让小车“原地旋转”不是拎起来转。这样做的目的是让雷达对周围环境建立初步的匹配基准同时让gmapping粒子尽快收敛。第二步沿房间的墙壁走一个大的外圈尽量保持离墙1到2米的距离把房间的大轮廓画出来。第三步回到起点附近再以“S形”或“回字形”路线扫描房间内部区域把桌子、椅子、隔断这些特点补全。走位过程中有几个铁律。速度一定要慢我一般把线速度控制在0.1到0.2m/s角速度控制在0.3rad/s以内。推车转向时尽量原地转不要走弧线否则轮子侧滑会让odom产生额外误差。遇到走廊这种窄通道贴着中间走两侧的墙都能扫到。如果地图在RViz里出现局部模糊停下来等一下map_update_interval触发更新再继续走。另一个建议是建图时尽量走“闭环”路线。也就是说从A点出发绕一圈之后最好能回到A点附近这样gmapping有机会通过激光匹配修正回到起点时累积的odom误差。如果环境太大、走廊太长每走一段就停下来让地图更新一次相当于给算法一点时间“消化”数据。4.3 保存地图map_saver与yaml文件的秘密建图效果检查完之后保存地图用map_server包rosrun map_server map_saver -f ~/catkin_ws/src/my_bot/maps/room1这条命令会在指定路径下生成room1.pgm和room1.yaml两个文件。pgm是图片格式的地图yaml是地图的元信息。打开yaml文件里面的字段含义大概是image: room1.pgm resolution: 0.05 origin: [-10.0, -10.0, 0.0] negate: 0 occupied_thresh: 0.65 free_thresh: 0.196resolution表示每个像素代表多少米0.05就是5厘米一个栅格。如果建图环境很大想省内存可以改成0.1但导航的精度会下降。origin是地图左下角或左上角取决于图像约定在地图坐标系下的坐标。occupied_thresh和free_thresh表示像素灰度值转换为栅格概率时多少阈值算占用、多少算空闲。这里提醒一下保存地图时要注意当前工作空间的环境变量。如果你开了多个ROS master或者用了多个工作空间map_saver有可能订阅不到/map话题导致保存出来的pgm是全黑的。我用rosrun map_server map_saver之前习惯先rospack find map_server确认一下包路径没问题。保存完地图用系统自带的图片查看器打开pgm确认一下墙体应该是黑色空白区域是白色或灰色。如果全是黑色或者全是白色说明订阅/map失败或者分辨率设置有问题重新检查一下再保存。跑不通的情况下可以在RViz里截个图作为备选但正式导航还是要用map_saver生成的文件。5. 翻车实录gmapping建图最常见的六个坑5.1 坑一TF缺失导致gmapping直接罢工gmapping启动后如果终端不断刷下面这类日志[ WARN] [....]: Could not get transform from base_footprint to laser这基本就是TF树断了。排查链路我建议这么走先rosrun tf view_frames生成TF树PDF看base_link和laser的连接在不在如果不在打开雷达驱动的launch检查frame_id参数的拼写接着检查base_footprint是否被正确发布。曾经有个朋友告诉我他的TF树完整但gmapping就是不出图我远程一看原来是map → odom那条边在他截图的时候还没建立因为gmapping在等所有TF都齐全之后才会开始发布map → odom而他的底盘发布的是odom → base_link但gmapping配置里写的是base_footprint两边不匹配。5.2 坑二地图墙体拖影、错位地图最典型的问题就是墙体拖影比如一面墙建出来变成了“双墙”或者一道门的位置在扫第二遍时偏了十几厘米。排查顺序从概率最高的开始先怀疑odom里程计。推着小车沿直线走一段看odom的x值和实际距离差多少再原地转90度看orientation的z和w算出的角度是否正确。如果odom本身就有累积偏差gmapping能修正一部分但修正不了全部。odom没问题再检查base_link到laser的静态TF。如果你的雷达安装位置不是正好在小车中心比如装在车头偏左10厘米、转了个小角度这个偏移量没有正确写进base_link → laser的静态TF建出来的地图就会一边墙厚一边墙薄。在RViz里把LaserScan显示和Map显示叠在一起看如果雷达扫出来的点云跟地图边界莫名错开多半就是静态TF没标对。5.3 坑三雷达串口权限与数据乱跳雷达出现的另一个经典症状是rostopic echo /scan里大量出现inf或nan或者点云在RViz里“抽风”一样乱闪。如果你之前没有配置udev规则每次重插USB雷达都要重新chmod 666否则驱动节点会报权限错误。数据乱跳我遇到得最多的是USB供电不足雷达电机转速不稳表现在 /scan 发布频率在5Hz和10Hz之间来回跳有时候直接几秒不发数据。解决的思路很直接不要用上位机的USB口直接给雷达供电用带供电的USB HUB或者给雷达单独一路5V电源。这个问题在小车上非常普遍因为树莓派这类板子的USB口供电能力本身就有限。5.4 坑四里程计单位/方向不对引发地图翻转地图整体镜像翻转或者推着小车往前走、地图上的轨迹往后走这通常不是gmapping的问题而是odom数据本身不对。常见情况有两种一种是下位机编码器线序接反导致linear.x的符号反了另一种是单位换算错了上位机收到的是脉冲计数直接当成米。排查起来也不难推着小车朝x正方向走半米看odom的x是不是正数如果是负数翻转线性速度的符号或者在STM32代码里改方向标志。单位换算我建议用一个已知长度的地面作为标定比如地板砖推车走3块砖看odom读数和实际距离的比值再调整换算系数。5.5 坑五minimumScore设置不当造成的匹配崩溃这个坑我在前面提过但因为它太典型必须再详细说一次。有人建图时看到地图突然出现一块“黑洞”或者局部乱码就怀疑gmapping算法不行。其实很多时候是minimumScore设太高了。gmapping的激光匹配过程会计算一个打分表示当前帧激光和已有地图的吻合程度。如果这个分数低于minimumScore算法认为这次匹配失败粒子滤波的重采样就会出现问题地图开始一点点烂掉。解决的路线是先设0建图跑完一段路之后用rostopic echo /slam_gmapping/scan_match_score查看实际分数比如你看到大多数时候在80到150之间偶尔有40的低谷那minimumScore可以设30到50。设太高反而会把正常的低分比如雷达扫到角度特别刁钻的角落时错杀。5.6 坑六扫过的路径地图突然整片漂移地图建到一半前面已经建好的部分突然整体平移了几十厘米这是所有建图者最崩溃的瞬间。这种现象的根源是粒子滤波丢失了正确位姿通常发生在急转弯、快速移动、或者雷达被遮挡的时间段里。从操作层面看推车速度放慢、转弯变缓是最直接的缓解手段。从参数层面看调高angularUpdate的触发频率比如从0.2降到0.1能让算法在转弯过程中多做几次匹配能有效减小转角处的漂移概率。如果你的小车有额外的IMU也可以考虑在后续升级中加进来给gmapping提供姿态参考能大幅改善这种情况。不过在纯里程计雷达的配置下靠操作习惯来避免急变量比事后调参更靠谱。6. 建图质量再往上走参数调优与扩展思路6.1 针对自家底盘特性调整噪声参数很多人的建图流程走到“能出一张图”就停了但我建议多花点时间针对自家底盘的“脾气”调一下运动模型噪声参数。gmapping里有4个参数和运动模型相关srr直线平移带来的位置误差、srt直线平移带来的角度误差、str旋转带来的位置误差、stt旋转带来的角度误差。什么意思呢如果你的底盘直线走得很稳、但原地转弯时容易打滑那就应该让str和stt稍微大一点意思是“我知道转弯时位置和角度都可能崩算法你多依赖激光匹配”。反过来如果你的底盘轮子抓地力好、转向准那str和stt可以设小让算法更信任里程计。我自己的实测经验大概这样底盘状态srrsrtstrstt直行稳定、转向良好0.050.10.050.1直行稳定、转向打滑常见差速轮0.050.10.10.2整体轮子打滑严重0.10.20.150.3注意这些参数的改变要一次改一组改完重新建图看效果不要一次动四五个否则出问题你根本不知道是哪个参数引入的。6.2 雷达安装位姿标定如果你的雷达装得有点歪或者不在小车的几何中心正常建图也能出图但地图的细节精度会受影响。我建图时遇到过走廊宽度一边宽一边窄的情况量了一下实际走廊是均匀的最后发现是雷达底座3D打印件本身有1度的安装误差。雷达安装标定有几个土办法。最简单的是找一个规则的矩形房间把墙体建出来之后在RViz里用Publish Point量取两侧墙到小车的距离如果左右不一致说明雷达有x轴或y轴方向的平移误差。如果是航向角误差绕z轴旋转更明显的症状是地图里墙角不再垂直或者本应笔直的墙变成了轻微弯曲的弧线。修改的方法是调整雷达驱动里的静态TF参数。比如你把laser相对base_link的yaw从0改成0.02看看地图变化方向再用二分法微调直到地图直线度满意为止。这个过程有点费时间但做一次就一劳永逸后面建图、导航都受益。6.3 建图完成之后下一步的思路当你保存好一张质量不错的地图这个项目最“硬”的一关就算过了。下一步自然就是导航也就是用move_base和amcl做自主定位和路径规划。地图文件、TF树、底盘驱动这些在导航阶段都要继续用所以建图这一步打下的基础越扎实后面调导航就越省心。如果你想在建图这个方向继续深入还可以试试保存多房间的地图、做地图拼接、或者给地图做后期编辑比如手动移除动态障碍物留下的痕迹。这些内容以后有机会再单独写一篇详细说。最后分享一点个人体会建图这件事看起来是“跑一个gmapping节点然后推车走一圈”但真正把它做好百分之八十的功夫花在传感器和底盘上。雷达装得正不正、odom准不准、推车走位顺不顺这些基础项决定了你最后能拿到什么样的地图。参数设置反而是最后一步。在建图过程中每改一个参数我建议只改一个变量做一次完整建图然后对比地图变化。别急着把所有参数一次拉到“最优组合”因为gmapping内部的粒子滤波本身带随机性连续跑两次地图也不可能一模一样只有在控制变量的前提下观察到的变化才真正来自参数调整。等你积累了几轮对比数据再回头看哪些参数值得调、哪些参数只是别人教程里的噱头就能形成自己的判断了。我自己第一次建图时地图歪歪扭扭墙都是双线后来一步步把odom校准、雷达静态TF标好、推车速度放慢再跑出来的地图就非常工整。那种成就感比小车第一次转起来还要强很多。希望这篇文章能帮你少走一些弯路。