简介:面向ROS初学者与移动机器人入门开发者的四轮小车模型包,围绕带摄像头和激光雷达的底盘,提供以URDF描述车体、车轮、关节及传感器安装关系的完整示例,适合用来理解机器人模型搭建、传感器数据接入与仿真环境准备。压缩包共6个文件,整体约22KB,以urdf模型文件、launch启动文件、package.xml与CMakeLists等工程配置为主,配合txt说明、pdf文档和gv结构图,能够帮助读者快速把握功能包的组织方式、模型加载入口与节点启动关系。已有2765人学习下载。读者可从中获得可直接放入ROS工作空间编译的模型包结构,参考车轮链接与关节、质量惯量等物理属性,以及摄像头和激光雷达在URDF中的声明方法;模型按功能包形式拆分模型、启动与配置内容,pdf和gv结构图还能辅助核对车体与传感器的连接关系。对于需要自建移动底盘的读者,这份小型示例可作为对照模板,帮助梳理差速四轮底盘与激光雷达、相机联合建模时的坐标关系、关节类型选择及配置拆分思路,为后续路径规划、避障与建图实验提供可复用的建模基础。
1. 一台四轮小车卡在建图前夜:ROS 底盘、摄像头、激光雷达的最小闭环
一台四轮小车,底盘能跑、摄像头能出图、激光雷达在 rviz 里也能把墙画出来,可建图节点一拉起来,地图就开始糊——排查半天,问题出在轮距参数填成了半径,里程计每米多算了一截。这是 ROS 上带摄像头、激光雷达的四轮小车项目里最典型的卡点:硬件全会动,真正拦住人的是坐标系、时间戳和标定参数。
这类项目本质上是把四个东西串成一个闭环:底盘给出可信的/odom,激光雷达给出带正确frame_id的/scan,摄像头给出标定过的/image_raw,上层建图与导航节点才会买账。适合手上已有一台差速四轮底盘、想在 ROS 里补上感知与自主导航能力的开发者,也适合把它当成移动机器人入门的第一个完整工程来拆。
2. 工程骨架与分层:从轮子到 /cmd_vel 的四层结构、话题契约和 TF 树
拿到这类小车,先别急着看建图参数。真正决定后面顺不顺的,是整条链路的分层是否清楚。分层不清楚,出了问题只能靠猜,而 ROS 里靠猜的成本极高——一个frame_id写错,表现可能是导航不收敛,也可能是雷达点云原地不动,现象完全不像同一个原因。
2.1 四层结构:轮子转一圈要经过四次翻译
从电机轴到/cmd_vel,中间隔着四层,每层的职责、运行频率和失败表现都不一样,认清这四层,排障时能直接定位到层。
第一层是机械与电气层。四个直流减速电机,左右各两个并联成一组,靠编码器反馈转速。这一层决定的是轮径、轮距、减速比这三个物理参数,它们后面会反复出现在里程计和差速解算里,量错一个,上层全部白搭。
第二层是下位机固件层。主控 MCU 以 50Hz 左右读编码器计数、跑单轮速度闭环、接收上位机下发的目标速度。这一层跑的是硬实时,不该被上层调度影响,所以 PID 放在这里比放在 ROS 节点里稳。
第三层是串口桥接层,也就是 ROS 侧最值得自己写的一个节点。它干两件事:把/cmd_vel的线速度角速度翻译成左右轮目标速度帧;把下位机回传的编码器计数翻译成/odom和odom -> base_footprint的 TF。这一层是纯粹的翻译官,不掺业务逻辑,出了错最容易定位。
第四层是应用层:激光雷达驱动、摄像头驱动、建图、定位、路径规划。这层基本都是现成包,配置大于编码。
四层的失败表现差别很大。电气层坏了表现为「有一个轮子不转」;固件层坏了表现为「速度发下去车不动或用肉眼可见的速度抖动」;桥接层坏了表现为「车在动但 rviz 里小车不动」;应用层坏了才是「地图糊」。分清这四种现象,能省掉大量瞎试的时间。
2.2 话题契约与 TF 树:先把名字定死,再写代码
多人协作或者自己隔两周再回来看,最容易翻车的是话题名和坐标系名随手改。我的习惯是先定一张契约表,把话题名、消息类型、发布者、频率写死,后续所有节点都照这张表来。
| 话题 | 类型 | 发布者 | 建议频率 |
|---|---|---|---|
/cmd_vel | geometry_msgs/Twist | 遥控 / 导航 | 10~20 Hz |
/odom | nav_msgs/Odometry | 串口桥接节点 | 30~50 Hz |
/scan | sensor_msgs/LaserScan | 雷达驱动 | 10 Hz |
/image_raw | sensor_msgs/Image | 摄像头驱动 | 30 Hz |
/camera_info | sensor_msgs/CameraInfo | 摄像头驱动 | 与图像同频 |
/tf | tf2_msgs/TFMessage | 桥接节点 / 静态发布 | 随更新 |
TF 树是另一张必须提前画好的图。这类小车的标准形状是:map -> odom -> base_footprint -> base_link -> laser_link / camera_link。其中map -> odom由定位节点(AMCL)发布,odom -> base_footprint由底盘桥接节点发布,base_link以下的固定安装关系写在 URDF 里,由robot_state_publisher统一发布。
提示:
base_footprint不是可有可无的装饰。它把车体投影到地面上,让代价地图和路径规划都按二维平面处理。省掉它,后面调move_base时会遇到一堆「机器人嵌入地面」的怪现象。
2.3 工作空间与目录约定
功能包按职责拆开,别把所有东西塞进一个包里。一个能长期维护的布局大概是这样:
catkin_ws/src/ ├── car_description/ # URDF/xacro 模型、mesh、rviz 配置 ├── car_base_driver/ # 串口桥接节点、PID、里程计 ├── car_sensors/ # 雷达与摄像头的 launch、标定文件 ├── car_nav/ # gmapping、amcl、move_base 参数 └── car_bringup/ # 顶层 launch,一键拉起所有节点依赖用 apt 装现成的,编译只编译自己的包:
# 常见做法是在 Ubuntu 20.04 + ROS Noetic 这套组合上做,包名对得上 sudo apt install ros-noetic-usb-cam ros-noetic-gmapping \ ros-noetic-amcl ros-noetic-move-base \ ros-noetic-teleop-twist-keyboard cd ~/catkin_ws catkin_make -DCATKIN_WHITELIST_PACKAGES="car_base_driver;car_description" source devel/setup.bashCATKIN_WHITELIST_PACKAGES这个参数值得记住。工作空间里包一多,全量catkin_make会慢到让人放弃改代码,白名单只编译自己改的包,配合source devel/setup.bash就能快速迭代。忘记 source 是新手最常见的「改了没生效」原因。
2.4 URDF 与 launch:静态变换写进模型,别散在命令行
base_link到laser_link的偏移,有两种做法:命令行发static_transform_publisher,或者写进 URDF。我强烈建议写进 URDF。散在命令行的静态变换,一旦和 URDF 里的重复发布,TF 树就会出现两个父节点,tf_echo直接报错,而这种错误在 rviz 里只表现为「东西乱飘」,非常难联想。
<!-- car.urdf.xacro 片段:底盘与雷达的固定安装关系 --> <link name="base_footprint"/> <link name="base_link"> <visual> <origin xyz="0 0 0.045"/> <geometry><box size="0.26 0.20 0.09"/></geometry> </visual> </link> <joint name="base_joint" type="fixed"> <parent link="base_footprint"/> <child link="base_link"/> <origin xyz="0 0 0.045"/> <!-- 车体中心离地高度,按实际量 --> </joint> <joint name="laser_joint" type="fixed"> <parent link="base_link"/> <child link="laser_link"/> <origin xyz="0.10 0 0.12" rpy="0 0 0"/> <!-- 雷达相对车体中心的安装偏移 --> </joint>origin里的xyz必须按实车量,单位是米。测量误差超过 3 厘米,建图时墙面就会出现系统性重影。rpy只有在雷达装歪时才需要填,填了之后要记得同步改驱动里的frame_id,两边不一致同样会导致点云旋转错位。
顶层 launch 用一个文件把所有东西拉起来,方便一键复现:
<launch> <arg name="model" default="$(find car_description)/urdf/car.urdf.xacro"/> <param name="robot_description" command="$(find xacro)/xacro $(arg model)"/> <node pkg="robot_state_publisher" type="robot_state_publisher" name="robot_state_publisher"/> <node pkg="car_base_driver" type="car_base_node" name="car_base_node" output="screen"> <param name="port" value="/dev/car_base"/> <param name="wheel_separation" value="0.18"/> <param name="wheel_diameter" value="0.065"/> </node> <include file="$(find car_sensors)/launch/lidar.launch"/> <include file="$(find car_sensors)/launch/camera.launch"/> <node pkg="rviz" type="rviz" name="rviz" args="-d $(find car_description)/rviz/car.rviz"/> </launch>wheel_separation和wheel_diameter这两个参数是整篇里最该反复核对的数字。它们同时被里程计积分和差速解算使用,错一个,表现是「车走直线但地图越走越歪」或者「转弯角度始终偏小」。用卷尺量轮距时量的是左右轮接地点的中心距,不是车体宽度。
3. 底盘驱动拆解:串口帧格式、差速解算与里程计积分
底盘驱动是这类项目里唯一必须自己写的核心模块,也是最容易写出玄学的地方。现成的包要么绑死特定硬件,要么参数藏得很深,与其改别人的,不如自己写一个两百行的节点,出了问题每一行都能查。
3.1 串口帧格式:先约定,再写代码
上位机和下位机之间是字节流,没有边界就没有解析。最省事的约定是「帧头 + 长度 + 负载 + 校验和」,两端照同一份协议实现,出错了还能靠校验和立刻发现。
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 固定0xAA 0x55,用于同步 |
| 长度 | 1 字节 | 负载字节数 |
| 命令字 | 1 字节 | 0x01下发速度,0x02回传编码器 |
| 负载 | N 字节 | 速度帧:左右轮 mm/s 各 int16;回传帧:左右计数各 int32 |
| 校验 | 1 字节 | 从长度到负载末尾的累加和取低 8 位 |
解析端最容易犯的错是「以为一次 read 就是完整一帧」。串口是流式的,一次读到的可能是半帧,也可能是两帧。正确写法是维护一个缓冲区,每次读到的字节追加进去,循环查找帧头,凑够长度才解析。
# 串口接收解析:维持缓冲区,按帧头切分,凑不齐就等下一次 def feed(self, data): self.buf += data while True: start = self.buf.find(b'\xaa\x55') if start < 0: self.buf = b'' return if len(self.buf) < start + 3: self.buf = self.buf[start:] return length = self.buf[start + 2] frame_len = 3 + length + 1 # 头部3 + 负载 + 校验1 if len(self.buf) < start + frame_len: self.buf = self.buf[start:] # 半帧,留着等后续字节 return frame = self.buf[start:start + frame_len] self.buf = self.buf[start + frame_len:] if sum(frame[2:-1]) & 0xFF == frame[-1]: self.on_frame(frame) # 校验通过才分发feed的入参是本次read拿到的原始字节,返回值不用管,解析结果通过on_frame回调分发。find找不到帧头时直接清空缓冲区,是为了避免在噪声数据上无限累积。校验失败时丢弃整帧而不是丢一个字节,能防止在错位的数据里反复误判帧头。
3.2 差速解算:把 /cmd_vel 翻译成左右轮速
/cmd_vel给的是车体线速度v(m/s)和角速度w(rad/s),差速底盘要的是左右轮各自的线速度。公式很朴素:
v_left = v - w * L / 2 v_right = v + w * L / 2其中L是轮距。反过来,从编码器读数算车体速度用:
v = (v_right + v_left) / 2 w = (v_right - v_left) / L# cmd_vel 回调:线速度角速度 -> 左右轮目标速度(mm/s),并发给下位机 def on_cmd_vel(self, msg): v = msg.linear.x w = msg.angular.z half = self.wheel_sep / 2.0 v_left = v - w * half v_right = v + w * half # 限幅:超过底盘物理极限的值直接截断,比让下位机去饱和更可控 v_left = max(-self.max_speed, min(self.max_speed, v_left)) v_right = max(-self.max_speed, min(self.max_speed, v_right)) self.send_speed_frame(int(v_left * 1000), int(v_right * 1000))max_speed建议设成实测能稳定跑到的值,而不是电机标称值。设太大,下位机 PID 长期饱和,表现为「发 0.5 实际只跑 0.35」,里程计就会持续高估位移。设太小,导航规划出的路径会因为速度受限而频繁重规划。
3.3 里程计积分:为什么你的 /odom 越走越偏
里程计是从编码器计数积分出来的位姿,误差会累积,这是物理事实,改不掉。能改的是「别把误差放大」。常见的两种写法,一种是用上一周期的航向直接投影,另一种是用本周期航向增量的一半做中点投影。后者在走弧线时明显更准。
def update_odom(self, d_left, d_right, dt): """d_left / d_right:本周期左右轮实际走过的距离,单位米""" d = (d_left + d_right) / 2.0 # 车体中心位移 dth = (d_right - d_left) / self.wheel_sep # 航向增量 mid = self.th + dth / 2.0 # 中点法:用半程航向投影 self.x += d * math.cos(mid) self.y += d * math.sin(mid) self.th += dth # 发布 /odom 与 odom -> base_footprint 的 TF,两者时间戳必须一致 stamp = rospy.Time.now() self.publish_odom(self.x, self.y, self.th, stamp) self.publish_tf(self.x, self.y, self.th, stamp)d_left和d_right的来源是编码器计数差乘以单脉冲位移:单脉冲位移 = π * 轮径 / (编码器线数 * 减速比 * 4),最后的 4 是四倍频。这个系数一旦算错,整个里程计会等比例缩放,表现是「让车走 1 米,/odom显示走了 1.2 米」。校准方法很简单:地上量出 2 米直线,手动推车走完,看/odom的 x 值,按比例修正轮径即可。
注意:
/odom里的位姿和odom -> base_footprint的 TF 必须用同一个时间戳发布。分两次取rospy.Time.now(),两者差几毫秒,rviz 里就会出现轻微抖动,tf还会偶尔报 extrapolation 错误。
3.4 PID 调速与限幅:放在下位机还是上位机
单轮速度闭环我一般放在下位机,50Hz 以上跑,不受上位机调度抖动影响。上位机只管下发目标速度,不参与闭环。
// 单轮增量式 PID,10ms 周期调用一次 float pid_step(PID *p, float target, float actual, float dt) { float err = target - actual; p->integral += err * dt; // 积分限幅:防止长时间堵转后积分饱和,松手瞬间猛冲 if (p->integral > p->i_max) p->integral = p->i_max; if (p->integral < -p->i_max) p->integral = -p->i_max; float deriv = (err - p->last_err) / dt; p->last_err = err; float out = p->kp * err + p->ki * p->integral + p->kd * deriv; // 输出限幅到 PWM 占空比范围 if (out > 1.0f) out = 1.0f; if (out < -1.0f) out = -1.0f; return out; }kp从 0.3 左右起调,ki给很小的值(比如 0.05),kd这类底盘基本可以给 0。调参顺序是先只给kp,加到轮子能快速跟速且不持续振荡;再加ki消掉稳态误差;kd只在有明显过冲时加一点点。i_max不设,最常见的翻车是「顶着墙推两秒,松手车自己窜出去」。
3.5 验证:三条命令确认底盘是否可信
上层节点一个都别开,先单独验证底盘。三条命令足够:
# 1. 让车以 0.1 m/s 直行,观察实际速度与 /odom 是否一致 rostopic pub -r 10 /cmd_vel geometry_msgs/Twist \ '{linear: {x: 0.1, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}' # 2. 看里程计输出的位姿,推车 2 米看 x 是否接近 2.0 rostopic echo /odom/pose/pose/position # 3. 确认 TF 链是通的,应该能打印出平移和旋转 rosrun tf tf_echo odom base_footprint三步都过了,底盘才算「可信」。很多建图糊掉的问题,在这一步就能发现,根本不用等到上层。
4. 传感器接入:摄像头标定、激光雷达驱动与建图导航的最小参数集
传感器这一层的核心不是「能出数据」,而是「出的数据带正确的坐标系和时间戳」。驱动装好只是第一步,标定和 TF 对齐才是让上层算法能用的前提。
4.1 摄像头:设备号、标定与 cv_bridge
USB 摄像头接上后先确认设备节点,再决定用哪个驱动。设备号不固定是常见坑,插拔一次就可能从video0变成video2。稳妥做法是在 udev 里按序列号或端口绑定固定名字。
# 先看设备是否被识别,确认支持的格式和分辨率 v4l2-ctl --list-devices v4l2-ctl -d /dev/video0 --list-formats-ext # 用 usb_cam 拉起图像,参数按实际支持的分辨率填 rosrun usb_cam usb_cam_node _video_device:=/dev/video0 \ _image_width:=640 _image_height:=480 _pixel_format:=yuyv \ _camera_frame_id:=camera_linkpixel_format填错的表现是图像花屏或者节点直接退出。camera_frame_id必须和 URDF 里的相机 link 名完全一致,否则image_view能看图,但做视觉投影时找不到变换。
标定用棋盘格,标定结果落在/camera_info里,后续做测距、颜色阈值、AprilTag 识别都依赖它:
rosrun camera_calibration cameracalibrator.py \ --size 8x6 --square 0.025 \ image:=/usb_cam/image_raw camera:=/usb_cam--size是内角点数量,不是格子数;--square是格子边长,单位米,用尺子量,别估。标定过程中要缓慢平移和倾斜,覆盖画面四角和远近,进度条走满后点 CALIBRATE 再 SAVE,结果会写进~/.ros/camera_info/。
在节点里取图用cv_bridge:
from cv_bridge import CvBridge self.bridge = CvBridge() def on_image(self, msg): # 一定要指定编码,不指定会跟随消息的 encoding,容易出现 3 通道 4 通道不一致 frame = self.bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8') hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (0, 120, 70), (10, 255, 255)) # 红色阈值示例 self.pub_mask.publish(self.bridge.cv2_to_imgmsg(mask, encoding='mono8'))desired_encoding显式写bgr8,比让它自适应更省事。转回 ROS 消息时编码要和单通道/三通道匹配,mask是单通道就写mono8,写错会直接抛异常。
4.2 激光雷达:串口权限、扫描参数与 frame_id
串口雷达接上后第一步是解决权限,第二步是解决设备名漂移,第三步才是调参数。前两步不做,后面每次重启都要sudo chmod 777,非常难受。
# udev 规则:按 VID/PID 绑定固定符号链接并放开权限 # 写入 /etc/udev/rules.d/99-car-serial.rules KERNEL=="ttyUSB*", ATTRS{idVendor}=="<你的VID>", \ ATTRS{idProduct}=="<你的PID>", MODE:="0666", SYMLINK+="car_lidar" # 重新加载规则并触发,之后插拔都能看到 /dev/car_lidar sudo udevadm control --reload-rules && sudo udevadm triggerVID 和 PID 用lsusb查。一个底盘、一个雷达都走 USB 转串口时,两个设备的节点名会随插入顺序互换,用 udev 绑定是唯一省心的解。
雷达驱动的参数重点看三个:串口、frame_id、扫描频率。
<launch> <node pkg="lidar_driver" type="lidar_node" name="lidar_node" output="screen"> <param name="port" value="/dev/car_lidar"/> <param name="baudrate" value="115200"/> <param name="frame_id" value="laser_link"/> <!-- 必须与 URDF 一致 --> <param name="scan_frequency" value="10.0"/> <param name="angle_min" value="-3.14159"/> <param name="angle_max" value="3.14159"/> <param name="range_min" value="0.10"/> <param name="range_max" value="8.00"/> </node> </launch>range_max别照抄雷达标称值。室内环境下超过 6~8 米的回波噪声很大,gmapping的maxUrange也要跟着设成同一个量级,两边不一致时,建图会出现「远处的墙时有时无」。
frame_id写错的现象很有辨识度:rviz 里雷达点云固定贴在车头不动,车转了它也不跟着转。这不是雷达坏了,是 TF 查不到,rviz 直接把点云丢在原点。
4.3 静态变换只发一次:重复发布的坑
base_link到laser_link的变换,URDF 里写过就不要再命令行发一次。两边都发,TF 树里laser_link会有两个父节点,tf_echo报TF_OLD_DATA或直接冲突。
如果模型里确实没写,临时补一条也来得及:
# 参数依次是 x y z yaw pitch roll 父坐标系 子坐标系 周期(ms) rosrun tf static_transform_publisher 0.10 0 0.12 0 0 0 base_link laser_link 50这条命令的100或50是发布周期,静态变换用 50ms 足够。反过来,如果 URDF 已经写了,就用rosrun tf view_frames生成一张 PDF 看一眼树,确认没有重复的父子关系,比在 rviz 里猜快得多。
4.4 建图与导航的最小参数集
建图先用gmapping,它对里程计的要求比cartographer低,参数少,能跑通就说明底层链路是对的。
<node pkg="gmapping" type="slam_gmapping" name="slam_gmapping" output="screen"> <param name="base_frame" value="base_footprint"/> <param name="odom_frame" value="odom"/> <param name="map_frame" value="map"/> <param name="delta" value="0.05"/> <!-- 地图分辨率,5cm 够用 --> <param name="maxUrange" value="8.0"/> <!-- 与雷达 range_max 对齐 --> <param name="particles" value="30"/> <!-- 粒子数,室内小场景 30 足够 --> <param name="linearUpdate" value="0.2"/> <!-- 走 20cm 更新一次 --> <param name="angularUpdate" value="0.2"/> <!-- 转 0.2rad 更新一次 --> <param name="temporalUpdate" value="1.0"/> </node>建图之后切导航,move_base的两层代价地图是关键。局部用滚动窗口,全局用静态地图:
# local_costmap 关键参数 local_costmap: global_frame: odom robot_base_frame: base_footprint rolling_window: true # 必须开,否则局部地图不会跟着车走 width: 3.0 height: 3.0 resolution: 0.05 update_frequency: 5.0 obstacle_layer: observation_sources: scan scan: {topic: /scan, data_type: LaserScan, clearing: true, marking: true} inflation_layer: inflation_radius: 0.25 # 略大于车体半径,太小会擦墙 cost_scaling_factor: 5.0几个参数的经验区间:inflation_radius取车体外接圆半径加 5~10cm;cost_scaling_factor越小,机器人越倾向于远离障碍;resolution保持 0.05 和地图一致,改小了 CPU 直接吃满。全局代价地图的global_frame是map,局部的是odom,这两个写反,导航会启动失败或者原地不动。
5. 避坑与排查:五个把小车调成「原地打转」和「地图糊图」的典型坑
这一章的五条都是从「现象」倒推「原因」的记录。这类项目的难点不在写代码,而在现象和原因之间的距离太远,一条现象可能对应五六个原因。按现象归类,能快速缩小排查范围。
5.1 现象:一给 cmd_vel 小车就原地打转
原因:左右轮的目标速度算出来是反的,或者其中一个电机的接线极性反了。差速解算公式没错,但v_left和v_right下发时顺序颠倒,直行指令变成了原地旋转。另一种情况是编码器极性反了,闭环把正反馈当成负反馈,越纠越偏。
解决:先断开闭环,开环给一个固定的左右轮相同速度,观察两个轮子转向是否一致。不一致就调电机线序或者软件里的符号位。确认开环正常后再开闭环,用rostopic echo /odom看直行时angular.z是否接近 0,有明显偏值说明左右轮的实际有效轮径不一致,需要在参数里做单轮补偿系数。
5.2 现象:rviz 里雷达点云贴在车头不跟着转
原因:/scan的header.frame_id写的是一个 TF 树里不存在的坐标系,或者写成了base_link但 URDF 里雷达的 link 名是laser_link。rviz 查不到变换,就把点云按原始坐标丢出来,看起来像固定在车上。
解决:用rosrun tf tf_echo base_link laser_link确认变换存在,再rostopic echo /scan/header看frame_id到底是什么,两边对齐。顺带检查是不是用了两个雷达驱动同时在发/scan,后启动的会覆盖前一个的frame_id,表现就是「有时候对有时候不对」。
5.3 现象:建图越走越歪,绕一圈回到起点对不上
原因:里程计标度错了。轮径、轮距、编码器线数、减速比四个参数里任意一个偏 5%,走 20 米就会累积出 1 米的误差。gmapping靠里程计做运动预测,预测偏太多,粒子撒不到正确位置,地图自然撕裂。
解决:做两次标定。直线标定:地上量 3 米,直行后看/odom的 x,按比例修正轮径。旋转标定:原地转 10 圈,看最终yaw是否回到 0,偏了就修正轮距。两次标定要分开做,一起调永远调不准。标定完再重新建图,通常立竿见影。
5.4 现象:全局路径规划出来了,一走就撞墙
原因:局部代价地图的rolling_window没开,或者robot_base_frame写错,导致局部地图不跟着车移动。另一种是inflation_radius小于车体半径,规划出的路径贴着墙走,实际车宽过不去。
解决:先在 rviz 里把/local_costmap/costmap打开,车往前走时看局部地图是否跟着滚动。不滚动就是rolling_window或global_frame的问题。再量一下车体最宽处,inflation_radius至少取一半再加 5cm。还有一个隐蔽原因是雷达安装高度超过 30cm,扫不到矮障碍物,这种情况只能加超声波或者降低雷达高度。
5.5 现象:跑十几分钟后串口掉线,或者 CPU 突然打满
原因:串口掉线多半是 USB 供电不足或者线材接触不良,尤其是底盘电机启动瞬间的电流冲击会拉低 USB 电压。CPU 打满常见于建图时particles设太大,或者某个节点以过高的频率发布大尺寸图像。
解决:串口用带屏蔽的短线,底盘和主控分开供电,或者加个 USB hub 带独立电源。CPU 方面先rosrun rqt_top rqt_top看是哪个节点吃的,图像话题把分辨率从 1280x720 降到 640x480,建图时把particles从 80 降到 30,delta从 0.025 放到 0.05,通常能降一半负载。另外记得把 rviz 的Decay Time调小,点云累积过多也会吃 CPU。
6. 二次开发:加一路视觉、换导航栈时该按什么顺序改
这套底盘跑通之后,往上加东西的诱惑很大,但顺序错了会陷进去。我一般的改造顺序是:先加感知、再加决策、最后换框架,每一步都要能单独验证,不验证不往下走。
加一路视觉,最容易的做法不是直接上视觉 SLAM,而是先做「视觉触发」。用cv_bridge拿图,做颜色或模板匹配,把结果作为一个Bool或自定义消息发出来,让导航层决定要不要停。这样视觉模块完全独立,出问题拔掉也不影响底盘跑。验证方法是让车按固定路径跑十圈,统计误触发次数,超过两次就先调阈值,别急着换算法。
换导航栈时,move_base到较新的导航框架的迁移,坑主要在三个地方:一是参数从 yaml 挪到了别的组织方式,二是行为树取代了原来的恢复行为配置,三是插件接口改名。稳妥做法是新旧并行跑一段时间,同一个/scan、/odom喂给两套,对比规划出的路径是否接近,接近了再切。
一个具体的技巧是给所有可调参数留一层薄封装。比如把轮径、轮距、雷达安装偏移集中写进一个car_params.yaml,驱动和 URDF 都从这一份读,改一处全生效。以前我把轮距在三个地方各写一遍,改完忘了同步,找了一下午才发现是 URDF 里的旧值在起作用。
| 改造目标 | 先动哪里 | 验证信号 |
|---|---|---|
| 加视觉识别 | 独立节点,只发结果话题 | 误触发率 < 5% |
| 换定位方案 | 保持/scan、/odom不变 | tf_echo map base_footprint稳定 |
| 换导航栈 | 新旧并行对比路径 | 路径重合度肉眼可接受 |
| 加远程遥测 | 只订阅,不发布控制量 | 不影响/cmd_vel频率 |
从那以后我每次动底盘参数,都强制走一遍「直行 3 米 + 原地转 10 圈」的标定验证,不管改动多小。参数这种东西,改的时候觉得无关紧要,出问题的时候它永远在嫌疑名单第一页。这套小车本身不复杂,复杂的是链路里每一环都得对上,按层验证、按现象排查,基本不会卡太久。希望帮到你。
本文还有配套的精品资源,点击获取