从零搭建ROS小车:SLAM与导航完整实战复盘
这台车我从零开始搭起来从一堆散件到能在室内绕开障碍物自动开到指定点前后花了一个多月。项目标题写得很简单——“做一台slam与navigation的ROS小车”但真正动手后你会发现把跑起来和跑稳定完全是两回事。整车架构很常规差速轮式底盘、单线激光雷达、一块跑Linux的ARM板卡、一张可复用地图SLAM部分跑通gmapping和cartographer两条路线navigation走AMCL定位加move_base规划。我用这篇文章把整个链路完整复盘一遍重点放在网上教程很少写清楚的选型逻辑、坐标系关系和调参细节上。这个项目适合两类人看一类是学过ROS基础但还没碰过实物的学生另一类是已经有小车但一直困在“launch能启动、Rviz能看到点云、导航却乱跑”阶段的爱好者。我尽量把每一步为什么这样做讲透而不是只贴命令。毕竟一台实体小车出问题时往往不是某个包不会用而是你对系统每一层的数据链路不够敏感。1. 硬件层怎么配底盘、雷达和主控的取舍逻辑先说硬件因为我在这个项目里最大的体会是硬件选错了后面的软件怎么调都别扭。很多人喜欢一上来就堆配置结果一半时间都花在跟硬件较劲上。1.1 底盘选差速而不是麦轮省下的是调试时间底盘类型决定了你的运动模型、里程计计算方式以及导航调试的难度。市面上常见的DIY底盘无非三种差速轮式、麦克纳姆轮式、阿克曼式。我最终用的是差速双轮加万向辅助轮的方案理由很直接——差速底盘的里程计模型最简单。差速底盘的左右轮速度独立转弯时内外轮差驱动转向位移可以由轮式编码器直接累计这个“从编码器计数到x/y坐标变化”的过程只需要知道轮径和轮距。麦轮底盘可以平移看起来很酷但是轮子与地面接触时产生的侧向滑移很难建模里程计误差比差速轮大一个量级SPL-SLAM这类对odom精度敏感的场景会非常吃亏。阿克曼底盘需要前轮转角反馈底盘的机械精度直接影响控制效果对DIY场景来说成本和调试复杂度都划不来。提示如果你只是为了学习SLAM和navigation不要被“全方位移动”吸引。先把差速底盘做稳之后想换运动模型ROS里的桩子和驱动框架都可以复用。1.2 单线激光雷达是SLAM传感器的性价比之选首台SLAM小车建议把激光雷达作为主传感器视觉方案可以后面再加。原因很简单单线激光雷达输出的是极坐标距离数据数据格式稳定不受光照影响SLAM算法成熟一个普通房间的建图精度就能满足导航需求。我用的是一颗国产单线雷达测距范围8米左右扫描频率10Hz角度分辨率大约每度一采样满足室内场景绰绰有余。选雷达时有三点容易忽略。第一是供电稳定性雷达对5V电源质量敏感如果和电机共用一路电源电机启动瞬间的电压跌落会让雷达直接重启。第二是安装位置雷达要装在小车中轴线的最高点避免车体结构遮挡扫描平面但也不要伸得超出车体太多否则碰撞容易损毁雷达。第三是线材固定雷达的旋转机构会把连接线拧来拧去接头没紧固很容易在旋转中脱落我一开始就栽在这上面症状是跑着跑着激光数据整段消失。摄像头可以后续再加用它跑视觉SLAM或做目标识别都可以但对首次搭建而言它不是核心传感器。IMU同理属于“锦上添花”没有它轮式里程计也能干活只是地面打滑时会比较痛苦后面第5章我会单独说这个问题。1.3 主控选型和供电计算预算的隐形门槛主控板卡是整个小车的“大脑”但它不是越贵越好关键看你的算法要吃什么资源。gmapping这种粒子滤波SLAM算法在四核ARM Linux板卡上跑得动一连上导航栈的costmap更新就会吃力。cartographer会明显吃CPU和内存如果预算允许选择带GPU加速的嵌入式板卡会更从容。我建议至少满足几个门槛四核处理器、2GB内存、能跑Ubuntu对应版本、有USB和串口资源。至于为什么不用单片机直接当主控是因为ROS的生态和调试工具都建立在Linux之上单片机更适合做底层电机控制。我当时的方案是STM32负责电机PID闭环和编码器采集ARM板负责SLAM、导航、地图处理和UI交互两边通过串口通信。供电是DIY小车最容易被低估的环节。电机启动电流往往是额定电流的好几倍如果主控、雷达、电机共用一路电源系统会频繁重启。我的做法是电池出来分成两路一路经过大电流电机驱动模块给电机供电另一路经过独立的降压模块给板卡和雷达供电两边只在总电源处共地。这个处理做完后雷达离线、板卡重启这类“玄学故障”基本绝迹。2. 从电机到里程计底层驱动和坐标系的第一次打通硬件装好后软件层面的第一道关卡不是SLAM而是让底盘驱动可靠地跑起来并把里程计数据以正确的形式送进ROS。这个环节决定了后面SLAM和导航的“地基”。2.1 单片机控制协议cmd_vel进、odom出整个控制链是这样的ROS侧发布cmd_vel话题话题里是线速度米/秒和角速度弧度/秒主控通过串口把这两个速度值发给STM32STM32根据差速模型分解出左右轮目标速度通过PID闭环输出PWM给电机驱动编码器测量实际轮速后STM32再把这些信息封装成里程计数据发回主控由主控发布odom话题。这里有一个核心原则电机控制必须是闭环的不能用开环PWM。开环控制下左右轮的负载差异、地面阻力、电池电压变化都会导致两侧实际速度不一致小车直线跑不正闭环PID可以自动纠正。PID参数不需要一上来就调得很复杂我先只调比例项让直行稳定再慢慢加积分消除静差。实测下来只要直行段稳得住转弯误差在后期靠AMCL也能纠回来。# 简化的差速模型线速度 角速度 - 左右轮速度 # v: 线速度omega: 角速度L: 轮距 left_speed v - omega * L / 2 right_speed v omega * L / 22.2 串口数据帧的设计与超时保护串口协议是整个系统的“神经”。我设计的帧格式很简单帧头、功能码、数据长度、数据段、校验和、帧尾。数据段里包含线速度、角速度我习惯把浮点数放大为整数传输——比如速度先乘以1000变成整数再拆成高8位低8位避免直接传浮点时的字节序问题。发送频率我控制在50Hz。很多人喜欢拉到100Hz但单片机的串口中断处理和PID计算未必扛得住数据堆积反而会产生旧数据延迟执行的问题。反过来频率太低也不行低于30Hz时底盘控制会有肉眼可见的顿挫感。串口回传的里程计数据我特意加上了左右轮编码器计数值、实际速度和电池电压后面调PID和排查问题时全靠这些字段。提示校验和或者CRC是必须的。我遇到过一次线缆松动导致波特率异常单片机收到一堆乱码直接当成速度指令小车原地画圈。加校验后非法帧直接丢弃这个问题立刻变成“数据丢失”而不再是“乱执行”。2.3 TF树结构odom、base_link、laser之间的关系很多新手在这里开始懵了——什么是TF简单说TF就是告诉系统“雷达坐标系相对车体坐标系在哪儿、车体相对世界坐标在哪儿”的变换关系。SLAM和导航的运行基础不是地图本身而是这张变换网。我这台车的TF树长这样map - odom - base_link - laser。map是全局地图坐标系odom是里程计坐标系base_link是车体中心坐标系laser是雷达坐标系。其中map - odom的变换由AMCL在导航时维护负责纠正长期漂移odom - base_link的变换来自底盘驱动发布的里程计base_link - laser是静态变换因为雷达相对车体的位置和朝向不变。一个很常见的错误是把里程计直接发布成map - odom中间缺少odom这一层。这样做的后果是SLAM和导航里的两个定位来源里程计和激光匹配打架小车会走走停停甚至原地转圈。正确的做法是底盘驱动只发布odom - base_link把map - odom留给SLAM或AMCL去维护。另外所有坐标系字符串必须严格一致base_link和base_footprint是两回事大小写和下划线都不能马虎。3. SLAM建图实测从gmapping起步到cartographer进阶SLAM是这台车最核心的卖点。我在建图上采用了两步策略先跑通gmapping把整个流程打通再尝试cartographer对比建图质量的差异。这么做不是因为我不会直接配cartographer而是因为gmapping足够简单可以让前面硬件、驱动、坐标系的调试成本先“兑现”。3.1 为什么先选gmapping而不是一上来就上cartographergmapping是基于粒子滤波的2D激光SLAM算法它的核心思路是用一组粒子代表机器人可能的位置每来一帧激光就用雷达数据和里程计数据给粒子打分保留最可能的那些同时更新地图。这个方案对轮式底盘特别友好因为它非常依赖odom数据来缩小搜索范围。gmapping的配置在launch文件里几行就够了相关的参数类型也不多对第一次建图的用户来说出问题时排查面很窄。面积不大的房间、走廊这类结构简单的室内环境gmapping完全够用。它的主要缺点是没有闭环优化当小车重新回到之前经过的区域时系统不会主动调整历史轨迹累计漂移如果较大会出现“地图重影”。cartographer的定位是“我能做好回环”它有前端扫描匹配和后端位姿图优化两个阶段前端负责实时估计后端在检测到闭环后调整整体轨迹所以在大场景和重访区域更多的环境下地图质量会有明显提升。我自己的实测感受是在十来平的小房间两种方案差别不大但一旦车从房间逛到走廊再绕回来gmapping的地图就明显有了错位cartographer能自动修正。3.2 手动遥控建图的操作节奏和地图质量判断第一次建图不要急着写自动导航脚本用键盘遥控手动来就好。建图前先做全套检查激光话题有数据、odom有输出、TF树完整、Rviz里能看到雷达点云贴合在墙上。任何一条不满足都先解决完再开跑。launch node pkggmapping typeslam_gmapping nameslam_gmapping outputscreen param namebase_frame valuebase_link/ param nameodom_frame valueodom/ param namemap_frame valuemap/ param namemap_update_interval value2.0/ param namelinearUpdate value0.5/ param nameangularUpdate value0.3/ /node /launch建图操作有讲究。启动后先让小车原地缓慢转一圈让雷达把四周环境扫一遍然后从起始位置开始慢慢往前开速度控制在0.2米/秒以内。路径方面不要走直线然后复杂转弯而是用蛇形覆盖整个区域沿墙边至少来回走两遍每到一个拐角先停稳再慢转最后回到起点完成闭环。整个过程如果某个区域墙线越来越淡或者出现重影那就是odom漂移了要停下来处理不要容忍继续。建完之后用map_server保存地图保存前在Rviz里关闭scan显示只保留栅格地图看一下整体轮廓是不是直角清晰、墙体边缘有没有毛刺和偏移。地图质量差的话导航阶段会连环爆雷。3.3 换用cartographer后看到的差异和回环处理cartographer的配置比gmapping多不少但核心思路恰恰能印证前面说的“为什么要先跑通gmapping”。因为它需要你明确指定机器人的运动模型配置、激光帧与里程计的关系如果你连odom - base_link都没调通cartographer的报错会让人一头雾水。我迁移时用了纯2D模式也就是不提供IMU数据。需要注意的有几点雷达频率低于配置预期会触发告警初始位姿要给得合理否则前端扫描匹配容易跑飞纯2D模式下对odom质量更加敏感所以跑cartographer前先把底盘PID和里程计校准做好很关键。实测同一段路线的两次建图cartographer在回环后的地图整体更“闭合”墙壁更平直这是它后端优化带来的直观收益。4. Navigation导航调试AMCL定位、代价地图和move_base导航这部分真正让小车“自主”起来。实现导航有一个前提地图已经建好并且保存成了文件。这一步开始系统不再“边建图边定位”而是拿着已有地图在小车上确定自己在哪里然后规划路径走过去。4.1 加载地图后系统怎么“找自己”AMCL粒子滤波原理直说启动导航时系统不知道该小车在地图的哪个位置需要你手动给出一个初始位姿。AMCL的做法是在给定位姿周围撒一堆粒子每个粒子代表一个可能的机器人位置。小车运动后粒子跟着运动模型更新每来一帧激光就把雷达数据拿到每个粒子的位置上跟地图做匹配匹配度高的粒子权重高匹配度低的粒子被淘汰。经过几次迭代粒子会收敛到正确位置附近最终聚成一团。关于粒子数量的设置有个肉眼可见的权衡。粒子太少初始收敛慢且容易陷入错误位置粒子太多CPU占用高机器人在原地不动时粒子还在反复抖动。我的经验是从默认值开始先让粒子在小范围快速收敛再去拉大“打散范围”。给初始位姿时有个技巧不要把小车放在空旷区域的中央放在墙角、门框这类特征明显的位姿激光扫描到的环境结构更独特粒子收敛更快。如果初始位姿给得偏差太大粒子可能在错误的走廊里“饿死”表现为所有粒子都得不到高分AMCL会不断重新撒粒子但仍然分散。这时候与其手动微调位姿不如调用全局定位服务从头来一次把小车的起始位置精确摆到地图边缘的一个已知点再给一次估计。4.2 代价地图参数膨胀半径和机器人尺寸的匹配代价地图是导航栈的“安全边际”又分全局代价地图和局部代价地图。全局地图用来做全局路径规划局部地图用在行进中实时避障。这两层地图都在原始感知数据上做膨胀区别只是更新范围和频率不同。影响最大也最容易调飞的一个参数是inflation_radius膨胀半径。它的含义是障碍物四周一定范围内被标记为“危险”越靠近障碍物代价值越高路径规划器会尽量避开。这个小车本身有一个外接圆半径robot_radius膨胀半径通常要大于机器人半径因为导航不能贴着障碍物走。设小了路径规划会贴着墙甚至插进障碍物的缝隙设大了门缝、过道这类窄通道会被判定为不可通行。我一开始用默认膨胀半径比较大小车就出现过一个典型症状明明房间门足够宽但是规划器总是绕着门缝往外拐因为门两侧墙壁膨胀之后的代价区域连成一片了。测量车体实际尺寸后把这些参数改成跟真实几何匹配问题立刻消失。robot_radius: 0.18 inflation_radius: 0.25 observation_sources: laser_scan_sensor laser_scan_sensor: sensor_frame: laser data_type: LaserScan topic: /scan marking: true clearing: true4.3 move_base调参顺序先能走再走好move_base是navigation的核心节点负责全局路径规划和局部路径跟踪。调它的顺序特别重要我的经验是先调底层运动限制再调规划器最后才动代价地图。很多人一上来就调inflation_radius或者尝试更换全局规划器结果越调越乱。第一步是速度限制。max_vel_x、max_vel_trans、max_vel_theta以及对应的加速度值先给一组偏保守的参数比如最大线速度0.25米/秒、最大角速度0.5弧度/秒加速度不要给太大让小车有温和的启停。第二步往前走一个点目标观察Rviz里全局路径是否平滑、小车是否频繁转向。如果路径贴障碍或者绕远路回到代价地图调整膨胀参数。第三步才去调局部规划器里的轨迹评分参数比如是否优先直行、对转向的惩罚权重。实测中最容易让人误判的是小车明明有全局路径但走着走着突然停下来转了个大弯。这多半不是规划问题而是局部代价地图实时更新时算法认为前方近处的某个区域代价过高。这时候先看局部地图的扩散范围再看膨胀参数是不是跟车体尺寸不匹配。调导航永远是先验证“数据是否正确进了系统”再谈“策略参数是否合理”。5. 实测中踩过的问题与通用排查法把整台车跑顺之后回头看绝大多数时间不是花在“跑起来”而是花在“排查为什么跑偏、乱跑、不走”。这一章把我踩过的几个代表性问题和排查思路完整列出来。5.1 坐标系错乱Rviz里雷达点云“原地乱飞”的定位方法有一次我启动导航后Rviz里的地图位置是对的但雷达点云不在小车模型上而是一直在小车周围“高速旋转”。第一反应是雷达坏了但单独echo/scan数据显示正常说明硬件没问题。进一步排查TF发现laser - base_link的静态变换没有发布那么雷达坐标系相对车体坐标的变换缺失系统就沿用了一个默认的、不断变化的变换于是点云看起来在飞。这个问题说明了调试SLAM项目的一个通用思路打开Rviz前先在终端里把TF树查清楚。rosrun tf tf_echo base_link laser可以直接打出一个6自由度变换如果这个变换在静止时都不稳定说明TF发布端的问题而不是算法问题。另外注意launch文件里robot_state_publisher发布的坐标系名称是否和传感器驱动里的frame_id一致一个字母都不允许错。5.2 瓷砖地面打滑:里程计漂移导致建图扭曲建图时最难受的问题不是地图没建出来而是建出来的地图墙线弯曲、门框变形。我遇到过一次排查半天算法配置都没问题最后发现是地板太滑小车起步和转弯时轮子打滑编码器累计出来的路程比实际路程短地图被压缩变形。处理办法分三路并进。第一路物理层面降低加速度让电机不要瞬间发力给底盘增加配重提高轮子摩擦。第二路软件层面用robot_localization把轮式里程计和IMU融合滤波后odom明显更稳。第三路是标定问题让小车在直线上走1米比对实际位移和odom估计修正轮径参数再让它原地转360度比对实际角度修正轮距参数。做完这一圈同一块地面上的建图质量好了很多。5.3 硬件、软件、参数三类问题的排查表把所有排查经验汇总成一张表可以帮你节省大量时间。现象优先怀疑的方向排查手段常见处理激光话题无数据供电或线材检查雷达指示灯、万用表测电压独立供电、紧固接头雷达数据有但TF报错坐标系名称不匹配tf_echo各坐标系变换统一frame_id建图时墙体重影odom漂移直行和转角标定标定轮径/轮距、加IMU导航规划不出路径膨胀半径过大查看全局代价地图匹配robot_radius小车走着突然乱转向局部代价地图更新异常观察局部costmap减小膨胀半径/限速串口偶尔丢数据波特率/校验缺失加打印看帧结构加入CRC校验最后再分享一个小经验。做实体机器人项目最好按“三个里程碑”推进第一个里程碑是底盘驱动稳定能够通过键盘遥控实现直行、转弯并且odom在Rviz里随动第二个里程碑是SLAM建图成功保存的地图轮廓清晰第三个里程碑才是navigation导航。每完成一个里程碑就备份一份完整的工作空间因为后面调试经常会改坏东西有一份能回退的干净环境我能少掉不少头发。这台车做完后我最大的收获不是那几张地图而是搞明白了系统每一层的数据从哪里来、谁依赖谁。换成新的底盘、新的传感器时你依然能快速迁移这些经验。