1. 从一台扫地机说起:为什么我要啃SLAM与Nav2这条全链路
去年年底我把家里那台老扫地机拆了,原因很简单——它在我家客厅和餐厅之间的那道门槛上反复卡死,地图上那块区域永远是一团糊。拆开一看,里面是一颗低成本激光雷达加一个单线陀螺仪,主控跑的是某家定制固件,建图和导航耦合得死死的,想调参数都没入口。那一刻我就动了念头:干脆用ROS2自己搭一台,把SLAM建图和Nav2导航这条链路从头到尾走一遍,搞清楚"从点云到地图"中间到底发生了什么。
这篇东西就是那次折腾的完整记录。核心关键词是SLAM、Nav2、ROS2、LiDAR、Gazebo,我会从仿真环境搭起,一路讲到真机上跑通自主导航。适合谁看?如果你已经装好ROS2、能跑通turtlebot3的基础demo,但一到自己搭车、自己调参就懵,那这篇就是写给你的。如果你连ROS2都还没装,也别急着关,我会在环境部分把踩过的坑都标出来,照着抄作业能省你至少两个周末。
先说清楚一件事:SLAM和Nav2不是一回事,很多人混着讲。SLAM解决的是"我在哪、周围长什么样",输出的是地图和位姿;Nav2解决的是"我知道在哪了,怎么走过去",输出的是速度指令。中间靠一张地图和一套坐标变换(TF)串起来。这条链路里任何一环出问题,表现都是"机器人乱走"或者"原地打转",但根因可能天差地别。所以我会把每一环拆开讲,让你出问题时知道往哪查。
整条链路我分成五块:仿真环境与传感器建模、SLAM建图、地图后处理与八叉树转换、Nav2导航栈配置、真机迁移与调参。下面逐块展开。
2. 仿真环境搭建:Gazebo里先把车造出来
2.1 为什么先用Gazebo而不是直接上真机
我一开始也想直接上真机,省事。但真机调试SLAM有个致命问题:你没法复现。今天建图糊了一块,明天同样的路线可能又好了,因为光照、地面反光、行人走动全在变。Gazebo的价值在于把变量锁死——同样的世界、同样的传感器噪声模型、同样的初始位姿,你改一个参数就能看到确定性的变化。等你把Gazebo里调通了,再迁到真机,剩下的只是传感器标定和实际噪声的差异,工作量能砍掉一大半。
Gazebo选版本要注意,ROS2 Humble对应的是Gazebo Classic 11,如果你装的是Gazebo Sim(新版,也叫Ignition),插件接口完全不一样,网上大部分教程对不上。我踩过这个坑,装了Gazebo Sim结果gazebo_ros插件加载不了,折腾半天才发现是版本错配。所以下面统一按ROS2 Humble + Gazebo Classic 11来讲。
2.2 机器人模型:URDF还是SDF
Gazebo原生吃SDF,但ROS2生态里大家用URDF,因为RViz2、robot_state_publisher、Nav2全都认URDF。我的做法是URDF为主,Gazebo插件通过URDF的<gazebo>标签注入。这样一份模型文件,RViz2能可视化,Gazebo能仿真,不用维护两套。
一个能跑SLAM和Nav2的车,URDF里必须有这几块:
- base_link:车体中心,所有坐标系的根
- 左右驱动轮:带
<joint type="continuous">,配libgazebo_ros_diff_drive.so插件 - 万向轮或支撑轮:纯物理支撑,不用驱动
- 激光雷达link:配
libgazebo_ros_ray_sensor.so插件,输出sensor_msgs/LaserScan - IMU link:配
libgazebo_ros_imu_sensor.so,输出sensor_msgs/Imu - TF树:base_link → laser_link、base_link → imu_link,这些由robot_state_publisher自动发布
差速驱动插件的关键参数我列一下,这几个值直接决定后面Nav2能不能调通:
<plugin name="diff_drive" filename="libgazebo_ros_diff_drive.so"> <ros> <namespace>/</namespace> </ros> <update_rate>50</update_rate> <left_joint>left_wheel_joint</left_joint> <right_joint>right_wheel_joint</right_joint> <wheel_separation>0.35</wheel_separation> <wheel_diameter>0.1</wheel_diameter> <max_wheel_torque>20</max_wheel_torque> <max_wheel_acceleration>1.0</max_wheel_acceleration> <command_topic>cmd_vel</command_topic> <odometry_topic>odom</odometry_topic> <odometry_frame>odom</odometry_frame> <robot_base_frame>base_footprint</robot_base_frame> <publish_odom>true</publish_odom> <publish_odom_tf>true</publish_odom_tf> <publish_wheel_tf>true</publish_wheel_tf> </plugin>wheel_separation和wheel_diameter必须和URDF里几何尺寸严格一致,否则里程计会系统性偏差。我见过有人URDF里轮距写0.35,插件里写0.3,结果机器人转90度实际转了105度,SLAM建出来的地图全是歪的。这种错误排查起来极其痛苦,因为现象是"地图慢慢歪",你会先怀疑雷达标定,最后才发现是轮距。
2.3 激光雷达插件与噪声模型
雷达插件是SLAM质量的决定性因素。Gazebo里用libgazebo_ros_ray_sensor.so,关键参数:
<sensor name="lidar" type="ray"> <update_rate>10</update_rate> <ray> <scan> <horizontal> <samples>360</samples> <min_angle>-3.14159</min_angle> <max_angle>3.14159</max_angle> </horizontal> </scan> <range> <min>0.12</min> <max>8.0</max> <resolution>0.01</resolution> </range> <noise> <type>gaussian</type> <mean>0.0</mean> <stddev>0.01</stddev> </noise> </ray> </sensor>samples是每圈点数,360点对应1度分辨率,这是低成本雷达的典型值。stddev是高斯噪声标准差,0.01米比较接近真实雷达。别把噪声设成0,否则你在仿真里调好的SLAM参数一到真机就崩,因为真实雷达永远有噪声。我建议仿真噪声略大于真机实测值,这样调出来的参数有裕量。
min距离设0.12是因为大多数雷达近处有盲区,设太小会让Gazebo算出穿模的假点。max设8米是室内场景的合理值,设太大计算量飙升还容易引入远处噪声。
2.4 世界文件与场景设计
Gazebo默认的空世界对SLAM没意义,你得搭一个有特征的环境。我的做法是拿Gazebo自带的worlds/empty.world改,往里塞几个不同形状的障碍物:方盒子、圆柱、斜放的板子。为什么要斜放?因为纯正交的墙会让SLAM的scan matching退化——所有方向的特征都一样,算法分不清自己在走廊哪一段。斜墙能提供方向性约束。
场景里还要注意地面纹理。Gazebo默认地面是纯色,视觉SLAM会直接失效,激光SLAM虽然不依赖纹理,但如果你后面想加视觉融合,地面得有特征。我一般给地面贴一张带噪点的纹理图。
启动命令:
ros2 launch my_robot_description gazebo.launch.pylaunch文件里要依次拉起:gazebo(加载world)、robot_state_publisher(发布TF)、spawn_entity(把URDF塞进Gazebo)。顺序不能乱,spawn必须在gazebo起来之后,否则找不到服务。
3. SLAM建图:从点云到栅格地图的完整过程
3.1 SLAM算法选型:slam_toolbox还是Cartographer
ROS2 Humble里主流两个选择:slam_toolbox和Cartographer。我两个都跑过,结论是:室内小场景、2D激光、要快速出结果,选slam_toolbox;大场景、要闭环优化、能接受调参成本,选Cartographer。
slam_toolbox的优势是开箱即用,参数少,和Nav2的map_server无缝对接。它的核心是基于图优化的位姿图,用scan matching做前端,闭环检测做后端。缺点是回环检测对参数敏感,走廊这种长直场景容易漏闭环。
Cartographer的强项是子图(submap)机制,每个子图内部做scan matching,子图之间做闭环。它对大场景更稳,但配置复杂,需要调num_range_data、submap_size等一堆参数,而且ROS2版本的Cartographer维护不如slam_toolbox活跃。
我最终选slam_toolbox,因为我的场景是单层住宅,面积不到100平,slam_toolbox完全够用,而且调参快。下面按slam_toolbox讲。
3.2 slam_toolbox配置详解
配置文件mapper_params_online_async.yaml里几个关键参数:
slam_toolbox: ros__parameters: solver_plugin: solver_plugins::CeresSolver ceres_linear_solver: SPARSE_NORMAL_CHOLESKY ceres_preconditioner: SCHUR_JACOBI ceres_trust_strategy: LEVENBERG_MARQUARDT ceres_dogleg_type: TRADITIONAL_DOGLEG ceres_loss_function: None odom_frame: odom map_frame: map base_frame: base_footprint scan_topic: /scan mode: mapping resolution: 0.05 max_laser_range: 8.0 minimum_time_interval: 0.5 transform_timeout: 0.2 tf_buffer_duration: 30.0 stack_size_to_use: 40000000 enable_interactive_mode: true use_scan_matching: true use_scan_barycenter: true minimum_travel_distance: 0.5 minimum_travel_heading: 0.5 scan_buffer_size: 10 scan_buffer_maximum_scan_distance: 10.0 link_match_minimum_response_fine: 0.1 link_scan_maximum_distance: 1.5 loop_search_maximum_distance: 3.0 do_loop_closing: true loop_match_minimum_chain_size: 10 loop_match_maximum_variance_coarse: 3.0 loop_match_minimum_response_coarse: 0.35 loop_match_minimum_response_fine: 0.45 correlation_search_space_dimension: 0.5 correlation_search_space_resolution: 0.01 correlation_search_space_smear_deviation: 0.1 distance_variance_penalty: 0.5 angle_variance_penalty: 1.0 fine_search_angle_offset: 0.00349 coarse_search_angle_offset: 0.349 coarse_angle_resolution: 0.0349 minimum_angle_penalty: 0.9 minimum_distance_penalty: 0.5 use_response_expansion: trueresolution: 0.05是栅格地图分辨率,5厘米一格。这个值越小地图越精细,但内存和计算量越大。室内导航5厘米足够,再小没必要。
minimum_travel_distance: 0.5和minimum_travel_heading: 0.5是触发一次scan matching的最小移动量。设太小会让计算量爆炸,设太大建图会漏细节。0.5米/0.5弧度是我实测比较平衡的值。
do_loop_closing: true必须开,否则走一圈回来地图会错位。loop_search_maximum_distance: 3.0是闭环搜索半径,如果你的场景回环距离超过3米,要调大。
3.3 建图实操:控制机器人走一圈
启动SLAM:
ros2 launch slam_toolbox online_async_launch.py params_file:=./mapper_params_online_async.yaml然后开键盘遥控:
ros2 run teleop_twist_keyboard teleop_twist_keyboard建图时的走法有讲究。别走太快,slam_toolbox的minimum_time_interval: 0.5意味着它每0.5秒才处理一帧,走太快两帧之间位移过大,scan matching会失败。我一般控制在0.2米/秒以内。
走法要"扫"不要"冲"。每个房间进去后,沿着墙慢慢绕一圈,让雷达扫到所有角落。走廊要来回走两遍,给闭环检测提供数据。转弯时慢一点,角速度别超过0.5弧度/秒,否则里程计和雷达的时间戳对不齐,位姿会飘。
建图过程中在RViz2里盯着看,如果发现地图某块突然"糊"了或者墙变厚了,说明那一段scan matching失败了。这时候别继续走,退回去重走那一段。我踩过的坑是硬着头皮走完,结果整张地图后半段全歪,只能重来。
3.4 地图保存与格式转换
建完图保存:
ros2 run nav2_map_server map_saver_cli -f ./my_map会生成my_map.pgm(图像)和my_map.yaml(元数据)。yaml里记录分辨率、原点、阈值:
image: my_map.pgm resolution: 0.05 origin: [-10.0, -10.0, 0.0] negate: 0 occupied_thresh: 0.65 free_thresh: 0.196origin是地图左下角在map坐标系里的位置,这个值必须和建图时的初始位姿对应,否则Nav2里机器人初始位置会偏。
如果你要用八叉树地图(比如做3D导航或避障),需要把2D栅格转成octomap。这一步用octomap_server,订阅点云或激光,输出octomap_msgs/Octomap。注意八叉树地图内存占用比2D栅格大一个数量级,100平的房子2D地图几MB,八叉树能到几百MB,嵌入式平台要慎重。
4. Nav2导航栈:从地图到速度指令
4.1 Nav2架构与插件选型
Nav2不是单个节点,是一堆节点的集合,核心几个:
- map_server:加载地图,发布
/map - amcl:蒙特卡洛定位,订阅激光和地图,输出
map→odom的TF - planner_server:全局路径规划,默认用NavFn或Smac
- controller_server:局部路径跟踪,默认用DWB或RPP
- behavior_server:处理恢复行为(卡住了怎么办)
- bt_navigator:行为树调度,把上面这些串起来
插件选型上,全局规划我用Smac Planner,比NavFn强在支持运动学约束,规划出的路径机器人真能走。局部控制我用Regulated Pure Pursuit(RPP),比DWB好调,参数少,对差速车友好。
4.2 AMCL定位参数调优
AMCL是Nav2里最容易出问题的一环。核心参数:
amcl: ros__parameters: alpha1: 0.2 alpha2: 0.2 alpha3: 0.2 alpha4: 0.2 alpha5: 0.2 base_frame_id: base_footprint beam_skip_distance: 0.5 beam_skip_error_threshold: 0.9 beam_skip_threshold: 0.3 do_beamskip: false global_frame_id: map lambda_short: 0.1 laser_likelihood_max_dist: 2.0 laser_max_range: 8.0 laser_min_range: 0.12 laser_model_type: likelihood_field max_beams: 60 max_particles: 2000 min_particles: 500 odom_frame_id: odom pf_err: 0.05 pf_z: 0.99 recovery_alpha_fast: 0.0 recovery_alpha_slow: 0.0 resample_interval: 1 robot_model_type: nav2_amcl::DifferentialMotionModel save_pose_rate: 0.5 sigma_hit: 0.2 tf_broadcast: true transform_tolerance: 1.0 update_min_a: 0.2 update_min_d: 0.25 z_hit: 0.5 z_max: 0.05 z_rand: 0.5 z_short: 0.05laser_model_type选likelihood_field比beam稳,因为它对雷达噪声不敏感。max_beams: 60是每帧用60个点做匹配,360点全用计算量太大,60个足够。
alpha1到alpha4是里程计噪声模型,如果你的里程计准,这几个值调小;里程计飘,调大。我实测差速车在瓷砖地面上alpha1=0.2比较合适。
update_min_d: 0.25和update_min_a: 0.2是触发定位更新的最小位移和转角。设太小计算量大,设太大定位会滞后。
4.3 代价地图配置
代价地图分全局和局部两层。全局代价地图用于规划,局部用于避障。
全局代价地图关键参数:
global_costmap: global_costmap: ros__parameters: update_frequency: 1.0 publish_frequency: 1.0 global_frame: map robot_base_frame: base_footprint use_sim_time: true robot_radius: 0.22 resolution: 0.05 track_unknown_space: true plugins: ["static_layer", "obstacle_layer", "inflation_layer"] obstacle_layer: plugin: "nav2_costmap_2d::ObstacleLayer" enabled: true observation_sources: scan scan: topic: /scan max_obstacle_height: 2.0 clearing: true marking: true data_type: "LaserScan" inflation_layer: plugin: "nav2_costmap_2d::InflationLayer" cost_scaling_factor: 3.0 inflation_radius: 0.55 static_layer: plugin: "nav2_costmap_2d::StaticLayer" map_subscribe_transient_local: truerobot_radius: 0.22是机器人外接圆半径,这个值必须准确。设小了会撞,设大了过不去窄门。我的车宽0.3米,外接圆半径0.22米(含雷达凸起)。
inflation_radius: 0.55是膨胀半径,意思是障碍物周围0.55米内都算高代价。这个值一般设成robot_radius + 0.3左右,给控制留裕量。cost_scaling_factor: 3.0控制代价衰减速度,值越大衰减越快,路径越贴近障碍物。
局部代价地图类似,但global_frame是odom,rolling_window: true,尺寸一般设3×3米。
4.4 行为树与恢复行为
Nav2默认行为树navigate_to_pose_w_replanning_and_recovery.xml已经够用,但恢复行为要配好。默认三个:clear_costmap(清代价地图)、spin(原地转)、backup(后退)。
spin的spin_dist: 1.57是转90度,我一般调成3.14转180度,因为90度经常转不出困境。backup的backup_dist: 0.15太小,调成0.3。
恢复行为触发条件是路径被堵死或者机器人卡住。我实测最常见的是动态障碍物(比如人走过)导致局部代价地图被占满,这时候clear_costmap能解决。如果是真卡住了,backup加spin组合能脱困。
5. 真机迁移:从仿真到现实的鸿沟
5.1 传感器标定:LiDAR与IMU
仿真里雷达和IMU是完美对齐的,真机上必须标定。LiDAR和IMU的外参标定我用lidar_imu_calib这个包,原理是手眼标定,让机器人做几个特定动作(转圈、前后移动),采集数据后优化出外参。
标定前要确认两件事:时间同步和坐标系定义。时间同步用message_filters做近似时间同步,因为雷达和IMU的频率不一样(雷达10Hz,IMU 100Hz)。坐标系定义要统一,雷达的frame_id和URDF里的link名必须一致,否则TF树断。
标定结果是一个旋转矩阵和平移向量,写进URDF的<origin>标签。我踩过的坑是标定完忘了改URDF,结果TF树里雷达位置还是默认值,SLAM建图一直歪。
5.2 里程计标定
真机里程计误差主要来自轮径和轮距不准。标定方法:让机器人直线走10米,测量实际距离,算出比例因子;再原地转10圈,测量实际转角,算出轮距修正。
# 直线标定 ros2 run teleop_twist_keyboard teleop_twist_keyboard # 记录odom数据 ros2 bag record /odom走完后对比odom累计位移和实际测量值,比例因子 = 实际值 / odom值。这个因子写进差速驱动插件的wheel_diameter或直接在odom发布前乘上。
5.3 参数迁移与微调
仿真参数不能直接搬到真机,主要差异在噪声和延迟。真机雷达噪声比仿真大,AMCL的sigma_hit要调大;真机控制延迟比仿真大,controller_frequency要调低。
我的迁移流程是:先搬仿真参数,跑一圈看现象;如果定位飘,调AMCL;如果路径跟踪抖,调RPP;如果避障太保守,调膨胀半径。每次只改一个参数,改完跑同样的路线对比。
6. 常见问题与排查技巧实录
6.1 建图相关
问题:地图重影,墙变厚
排查顺序:先看TF树是否完整(ros2 run tf2_tools view_frames),再看雷达时间戳是否和odom对齐(ros2 topic hz /scan和/odom),最后看minimum_travel_distance是否太小导致重复匹配。
问题:闭环失败,走一圈回来地图错位
调大loop_search_maximum_distance,调小loop_match_minimum_response_coarse。如果还不行,检查场景是否有足够特征,纯走廊场景闭环本来就难。
6.2 导航相关
问题:机器人原地打转
先看AMCL是否收敛(RViz2里粒子云是否聚集),再看全局路径是否合理(有没有穿过障碍物),最后看局部控制器参数(lookahead_dist是否太大)。
问题:机器人撞障碍物
检查robot_radius和inflation_radius,再看局部代价地图是否更新及时(update_frequency)。我遇到过局部代价地图更新频率1Hz,机器人走快了障碍物还没标上,调成5Hz解决。
问题:导航到目标点附近停不下来
调小xy_goal_tolerance和yaw_goal_tolerance,默认0.25米和0.25弧度,我调成0.1和0.1。但别调太小,否则机器人会在目标点附近反复微调。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 地图重影 | TF不同步/雷达噪声大 | view_frames查TF | 调时间同步/调sigma_hit |
| 定位飘 | AMCL粒子少/里程计差 | RViz看粒子云 | 调max_particles/标定里程计 |
| 路径穿墙 | 代价地图未更新 | RViz看costmap | 调update_frequency |
| 原地打转 | 局部控制器参数错 | 看cmd_vel | 调lookahead_dist |
| 撞障碍 | 膨胀半径小 | 看inflation_radius | 调大至robot_radius+0.3 |
| 目标点停不下 | 容差大 | 看goal_tolerance | 调小至0.1 |
6.4 独家避坑技巧
技巧一:用ros2 bag录数据回放调试。真机调参最痛苦的是每次都要重新跑,用bag录下雷达、odom、TF,回放时SLAM和Nav2照常运行,相当于把真机"冻结"了反复调。这个技巧帮我省了无数时间。
技巧二:RViz2里同时看TF和costmap。很多人只看地图,不看TF,结果TF断了还在那调SLAM参数。养成习惯:出问题先看TF树。
技巧三:参数改动用版本管理。Nav2参数几十个,改着改着就忘了原来是什么。我用git管理yaml文件,每次改完commit,出问题能回滚。
技巧四:仿真噪声略大于真机。这样调出来的参数有裕量,迁到真机不会因为噪声稍大就崩。
技巧五:先调定位再调导航。AMCL没收敛,后面调什么都是白搭。判断AMCL收敛的标准是粒子云聚集且机器人位姿和实际吻合。
7. 这条链路还能怎么扩展
跑通基础链路后,我做了几个扩展,效果不错。一是加八叉树地图做3D避障,用octomap_server订阅雷达点云,输出3D栅格,Nav2的obstacle_layer可以订阅octomap做3D避障,比2D激光强在能识别悬空障碍(比如桌腿之间的空间)。二是加多目标点巡航,用nav2_simple_commander的Python API写脚本,让机器人按顺序走多个点。三是加动态重规划,订阅一个外部话题,收到新目标点就取消当前导航重新规划。
真机迁移那块,我最后悔的是没早点做传感器标定。仿真里雷达和IMU完美对齐,真机上差几度,SLAM建图就歪得没法看。标定花了我一个周末,但之后所有调试都顺了。如果你也在做类似的项目,我的建议是:仿真里把链路跑通,真机上第一件事就是标定,别急着调SLAM参数。