ROS2 jazzy + gazebo harmonic多传感器融合移动机器人系统仿真

前言

在当今的机器人研究与应用领域,移动机器人的自主导航能力是一项核心且富有挑战性的课题。从早期的轮式里程计到如今的多传感器融合方案,机器人感知与导航技术经历了数十年的演进。随着 ROS2 的发布和 Gazebo 仿真平台的持续升级,开发者们获得了更强大的工具链来构建和验证自主系统。

本文将详细介绍一个基于 ROS2 Jazzy Jalisco 和 Gazebo Harmonic 的多传感器融合移动机器人系统。该系统实现了从传感器数据获取、时间同步、位姿估计到路径规划与避障的完整自主导航链路,并集成了本地 CI 自动化流水线,实现一键编译、静态检查和仿真测试。

本项目覆盖了以下核心技术领域:RGBD+IMU 传感器时间同步、误差状态卡尔曼滤波(ESKF)、Gazebo 仿真环境搭建、SLAM 同步定位与建图、Nav2 导航栈集成、视觉障碍物检测以及 CI/CD 自动化。

源码地址:GitHub - HulinCal/ros2_multisensors_fusion: A Multi-Sensor Fusion Mobile Robot Simulation Platform Based on ROS2 Jazzy, Implementing RGBD+IMU Time Synchronization, ESKF Pose Fusion, Gazebo Simulation, SLAM Mapping, and Nav2 Visual Obstacle Avoidance Navigation · GitHub

1. 背景与技术选型

1.1 为什么选择 ROS2 Jazzy

ROS2 Jazzy 是 ROS2 的最新长期支持(LTS)版本,相较于之前的版本带来了多项重要改进。首先,Jazzy 默认使用 Python 3.12,对 Python 3.10 及更早版本提供了更好的兼容性支持。其次,Jazzy 与 Gazebo Harmonic 的深度集成使得仿真环境搭建变得更加顺畅,ros_gz_bridge 和 ros_gz_sim 工具包的稳定性也大幅提升。

在 Jazzy 中,nav2 导航栈进行了多项优化,包括改进的控制器接口和更灵活的代价地图配置。此外,Jazzy 对 ROS2 Control 框架进行了标准化,使得硬件接口定义(URDF 中的 ros2_control 标签)与 Gazebo 仿真的集成变得前所未有的简洁。这些特性为构建可靠的仿真系统奠定了坚实基础。

1.2 系统设计目标

本项目的设计目标可以概括为以下四点:

第一,构建一个完整的移动机器人仿真平台。该平台需要包含真实的传感器模型(RGBD 相机、IMU)、合理的运动学模型(差速驱动)和符合物理规律的仿真环境。

第二,实现高精度的位姿估计。通过融合 RGBD 里程计和 IMU 数据,利用误差状态卡尔曼滤波(ESKF)算法,为机器人提供平滑、低延迟的位姿估计,为导航决策提供可靠输入。

第三,集成完整的自主导航能力。包括基于 slam_toolbox 的实时建图、Nav2 的全局路径规划和局部轨迹跟踪,以及基于深度图的视觉避障功能。

第四,建立工程化的 CI/CD 流程。通过自动化脚本实现编译、静态代码检查和仿真测试的一键执行,确保代码质量和系统可靠性。

1.3 技术栈

本项目涉及的主要技术组件包括:

ROS2 Jazzy Jalisco 作为中间件框架,提供节点通信、参数管理、生命周期节点等基础能力;Gazebo Harmonic 作为物理仿真引擎,负责传感器数据生成和机器人运动模拟;Eigen3 作为线性代数库,支撑 ESKF 算法中的矩阵运算;message_filters 用于多传感器数据的近似时间同步;ros_gz_bridge 和 ros_gz_sim 实现 ROS2 与 Gazebo 之间的话题桥接和实体管理;ros_gz_ros2_control 将 ROS2 Control 框架集成到 Gazebo 仿真中;slam_toolbox 提供 SLAM 建图功能;Nav2 导航栈完成路径规划与轨迹跟踪。

2. 系统架构设计

2.1 总体架构

系统采用分层架构设计,从上到下依次为:感知层、融合层、决策层和执行层。各层之间通过 ROS2 话题进行松耦合通信,便于独立开发、测试和替换。

感知层由 Gazebo 仿真环境中的 RGBD 相机和 IMU 传感器组成,通过 ros_gz_bridge 将仿真话题桥接到 ROS2 网络。融合层包含 sensor_sync 节点和 eskf_node 节点,分别完成时间同步和状态估计。决策层由 obstacle_detector 节点、slam_toolbox 和 Nav2 导航栈组成,分别负责障碍物检测、环境建图和运动规划。执行层由 diff_drive_base_controller 组成,将速度指令转换为轮子的角速度,驱动机器人运动。

2.2 数据流分析

数据流的起点是 Gazebo 中的传感器。RGBD 相机以 15Hz 的频率发布彩色图像、深度图像和相机内参,IMU 传感器以 100Hz 的频率发布加速度和角速度数据。这些数据通过 ros_gz_bridge 从 Gazebo 话题转换为 ROS2 话题。

sensor_sync 节点订阅相机彩色图、深度图和 IMU 数据,使用 message_filters 的 ApproximateTimeSynchronizer进行近似时间同步,将三路数据对齐到统一的时间戳后发布到 synced/ 前缀的话题。eskf_node 节点订阅同步后的 IMU 数据和 RGBD 里程计数据,通过 ESKF 算法融合得到高精度位姿,发布到 eskf/odom 和 eskf/pose 话题,并通过 TF2 广播 map→base_link 变换。

obstacle_detector_node 节点订阅深度图,在图像中心区域扫描障碍物,将检测结果发布到 obstacle_detections 话题。Nav2 的代价地图订阅深度图和障碍物检测结果,实时更新环境代价分布。slam_toolbox 订阅 RGBD 数据生成虚拟激光扫描,完成环境建图。Nav2 的全局规划器根据地图规划路径,局部规划器根据代价地图跟踪路径并避障,最终将 cmd_vel 指令发送给 diff_drive_base_controller。

2.3 节点与话题

系统的核心节点和话题关系如下:

主要发布话题:

sensor_sync_node → synced/camera/image, synced/camera/depth_image, synced/imu/data eskf_node → eskf/odom, eskf/pose, TF(map→base_link) obstacle_detector_node → obstacle_detections, closest_obstacle_point, min_obstacle_distance diff_drive_base_controller → /odom, TF(odom→base_footprint)

主要订阅话题:

sensor_sync_node ← /camera/image, /camera/depth_image, /imu/data eskf_node ← synced/imu/data, /odom obstacle_detector_node ← /camera/depth_image, /camera/camera_info, /camera/image diff_drive_base_controller ← /cmd_vel

3. 传感器时间同步

3.1 为什么需要时间同步

在多传感器融合系统中,时间同步是至关重要的第一步。不同传感器以不同的频率和时间戳发布数据:RGBD 相机通常以 15-30Hz 运行,而 IMU 可以达到 100-400Hz。当这些数据用于融合计算时,如果时间戳不同步,会导致状态估计出现系统性偏差。特别是在机器人快速运动时,即使是几毫秒的时间差也可能引入显著的误差。

以本项目为例,ESKF 滤波器需要在同一时刻获取 IMU 加速度数据和 RGBD 里程计位姿。如果直接使用最新的 IMU 数据和最新的里程计数据,它们之间可能存在数百毫秒的时间差,在机器人以 1m/s 的速度运动时,这意味着 10cm 以上的位置误差。

3.2 ApproximateTimeSynchronizer 原理

ROS2 的 message_filters 库提供了多种同步策略,其中 ApproximateTimeSynchronizer 是最常用的一种。它的基本思想是维护一个固定大小的消息队列,当所有订阅的话题都有数据时,选取时间戳最接近的一组数据进行回调。

ApproximateTimeSynchronizer 的核心参数包括:同步队列大小和最大时间差阈值。队列大小决定了在时间窗口内可以缓存多少条消息用于匹配,较大的队列可以提高匹配成功率但增加延迟。最大时间差阈值定义了可接受的时间戳差异,超过此阈值的消息组将被丢弃。

在本项目中,我们将队列大小设置为 10,最大时间差阈值设置为 0.01s。这意味着系统最多缓存 10 组数据用于匹配,并要求三路数据的时间戳差异不超过 10ms。这种配置在保证同步精度的同时,也确保了较高的匹配成功率。

3.3 实现细节与踩坑记录

在实现 sensor_sync_node 的过程中,我们遇到了一些值得记录的问题。

第一个问题是时间戳的比较和运算。ROS2 的 header.stamp 字段类型是 builtin_interfaces::msg::Time,这是一个纯数据结构,不支持直接的比较运算符(如 >、<)和算术运算(如减法)。最初的代码直接对 stamp 进行比较和减法,导致编译错误。解决方案是将时间戳转换为 rclcpp::Time 对象,该类重载了比较和算术运算符。

// 错误写法 if (rgb_msg->header.stamp > latest_time) { ... } double diff = depth_msg->header.stamp - rgb_msg->header.stamp; // 正确写法 rclcpp::Time rgb_time(rgb_msg->header.stamp); rclcpp::Time depth_time(depth_msg->header.stamp); if (depth_time > rgb_time) { ... } double diff = (depth_time - rgb_time).seconds();

第二个问题是同步后时间戳的统一策略。我们选择使用最晚的时间戳作为同步后的统一时间,而不是最早的或平均时间。这是因为 ESKF 滤波器在预测步骤中使用当前时刻的 IMU 数据进行积分,使用最晚的时间戳可以确保所有传感器数据都已在该时刻之前发布,避免使用"未来"数据。

第三个问题是 QoS(服务质量)配置。传感器数据的可靠性要求高但延迟容忍度较大,因此使用 rmw_qos_profile_sensor_data 配置,即 best_effort 可靠性和 volatile 耐久性。这种配置适合高频传感器数据传输,允许丢包但保证低延迟。

4. 误差状态卡尔曼滤波器(ESKF)

4.1 ESKF 与 EKF 的区别

在惯性导航系统中,扩展卡尔曼滤波器(EKF)和误差状态卡尔曼滤波器(ESKF)是两种主流方法。EKF 在完整的非线性状态空间中进行线性化,直接估计状态量本身;而 ESKF 则在误差空间中进行滤波,估计的是状态的误差量。

ESKF 的优势主要体现在三个方面。第一,ESKF 的状态向量更小。典型的 ESKF 状态向量包含 15 个元素(位置 3 + 速度 3 + 姿态 3 + 加计偏置 3 + 陀螺偏置 3),而 EKF 通常需要更大的状态向量来完整描述机器人运动。第二,ESKF 的线性化精度更高。因为在误差空间中,误差量通常很小,一阶线性化的近似效果更好。第三,ESKF 的计算效率更高,更适合实时应用。

4.2 状态向量定义

本项目的 ESKF 状态向量定义为 15 维:

x = [p, v, q, b_a, b_g]
| | | | |
3 3 3 3 3 →
共 15 维

其中 p 为位置(3D),v 为速度(3D),q 为姿态四元数(使用误差表示,3 维),b_a 为加速度计零偏,b_g 为陀螺仪零偏。所有量都在世界坐标系下表示,姿态部分采用李代数 so(3) 的向量表示,即旋转矩阵的小角度近似。

4.3 预测步骤:IMU 积分

预测步骤以 IMU 数据为驱动,以 100Hz 的频率执行。每当收到新的 IMU 数据,滤波器就执行一次预测。预测过程分为以下几个步骤:

首先,对 IMU 测量值进行零偏补偿。加速度测量值减去加计偏置,角速度测量值减去陀螺偏置。然后,将补偿后的加速度从机体坐标系转换到世界坐标系:a_world = R * (a_body - b_a) + g,其中 R 为当前姿态的旋转矩阵,g 为重力向量([0, 0, -9.81])。

接着进行状态积分。速度由加速度积分得到:v_new = v_old + a_world * dt;位置由速度积分得到:p_new = p_old + v_new * dt;姿态由角速度积分得到:q_new = q_old ⊗ δq,其中 δq 是由角速度和时间步长构成的旋转增量。

然后计算误差状态转移矩阵 F 和控制矩阵 G。F 矩阵描述了误差状态在无激励下的自然传播,G 矩阵描述了过程噪声如何影响误差状态。F 矩阵通过对连续时间系统进行离散化获得:

F = I + [0 I 0 0 0; //位置误差受速度误差影响
0 0 -[a_world]× 0 0; // 速度误差受姿态误差影响
0 0 I 0 0; // 姿态误差传播
0 0 0 I 0; // 加计偏置不变
0 0 0 0 I] * dt // 陀螺偏置不变

其中 [a_world]× 是加速度向量的反对称矩阵,用于表示姿态误差对加速度变换的影响。最后进行协方差传播:P_new = F * P_old * F^T + G * Q * G^T,其中 Q 为过程噪声协方差矩阵,对角线上的元素分别对应加速度、角速度和零偏的噪声强度。

4.4 更新步骤:RGBD 里程计融合

当收到 RGBD 里程计数据时(15Hz),执行更新步骤。更新过程包括以下计算:

首先计算观测矩阵 H。H 矩阵将状态误差映射到测量空间,即测量值是状态的直接观测:

H = [I 0 0 0 0; //位置观测
0 0 I 0 0] // 姿态观测

这意味着我们直接观测位置和姿态,观测维度为 6(位置 3 + 姿态 3)。

然后计算卡尔曼增益:K = P * H^T * (H * P * H^T + R)^(-1)。注意 K 的维度是 15×6,即状态维度×观测维度。这里我们遇到过一个经典的 Eigen 维度错误——最初将 K 声明为 6×15,导致编译时矩阵乘法维度不匹配。修正后 K 为 15×6 才能正确进行矩阵乘法运算。

接着计算新息(innovation):innovation = measurement - prediction。位置部分是测量位置与预测位置的差,姿态部分采用四元数误差的向量表示:q_error = q_meas * q_pred^(-1),将四元数误差的向量部分(即旋转轴乘以角度)作为新息。需要注意的是四元数误差的标量部分必须为正,因此当 w < 0 时需要对四元数进行翻转。

最后进行状态修正:dx = K * innovation,然后将 dx 分配到各状态分量进行修正。姿态修正采用左乘方式:q_new = q_old ⊗ [1, 0.5*dθ],其中 dθ 是姿态误差的 3 维向量表示。协方差更新:P = (I - K * H) * P。

4.5 参数调优与稳定性

ESKF 的性能高度依赖于过程噪声矩阵 Q 和观测噪声矩阵 R 的设置。Q 矩阵的对角元素越大,滤波器越信任 IMU 预测;R 矩阵的对角元素越大,滤波器越信任观测。在本项目中,我们将 Q 的加速度噪声设置为 0.001,角速度噪声为 0.0001,零偏噪声为 0.00001;R 的位置观测噪声设置为 0.01,姿态观测噪声为 0.001。

初始协方差 P 设为 0.01 乘以单位矩阵,表示初始状态的不确定性。经过多次迭代后,P 会收敛到一个稳定值,反映滤波器对当前状态估计的信心。如果发现滤波器发散(表现为姿态或速度估计剧烈振荡),通常需要检查 Q/R 的比值是否合理,以及初始化过程是否正确。

5. Gazebo 仿真平台构建

5.1 机器人 URDF 建模

机器人模型采用模块化的 Xacro 结构,分为四个文件:mobile_robot_core.urdf.xacro 定义底盘、IMU 和相机框架;mobile_robot_wheels.urdf.xacro 定义驱动轮和万向轮;mobile_robot_sensors.urdf.xacro 定义 RGBD 相机和 IMU 的 Gazebo 传感器插件;mobile_robot_full.urdf.xacro 作为主入口,包含前三个文件并添加 ros2_control 和 gazebo 插件。

机器人的运动学配置为差速驱动:左右两个驱动轮半径 0.05m,轮距 0.32m,前后各有一个万向轮支撑。底盘尺寸为 0.5m × 0.3m × 0.15m,质量 2kg。相机安装在底盘前方,距地面高度约 0.1m。IMU 安装在底盘中心附近。

在建模过程中我们遇到了几个常见问题。第一个是材质未定义错误。URDF 中引用的材质必须在使用前定义,否则 Gazebo 会报 "material undefined" 错误。我们在主 Xacro 文件开头定义了 blue、dark_grey、light_grey、white 四种材质。第二个是轮子 rpy 属性的格式问题。rpy 属性需要三个值(roll、pitch、yaw),最初错误地只写了一个值 "1.57079632679",导致解析失败。修正为 "0 1.57079632679 0" 后解决。第三个是万向轮的惯性参数缺失。Gazebo 要求所有 link 都必须有 inertial 标签,即使是被动万向轮也不例外。

5.2 ros2_control 集成

Jazzy 版本中,ros2_control 与 Gazebo 的集成更加紧密。我们使用 gz_ros2_control 包提供的GazeboSimSystem 插件作为硬件接口。在 URDF 中定义 ros2_control 标签:

<ros2_control name="GazeboSimSystem" type="system">
<hardware>
<plugin>gz_ros2_control/GazeboSimSystem</plugin>
</hardware>
<joint name="left_wheel_joint">
<command_interface name="velocity">
<param name="min">-1.0</param>
<param name="max">1.0</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
</joint>
<!--
右轮类似 -->
</ros2_control>

同时在 <gazebo> 标签中声明控制器插件:

<gazebo>
<plugin filename="gz_ros2_control-system" name="gz_ros2_control::GazeboSimROS2ControlPlugin">
<parameters>$(find gazebo_sim)/config/ros2_control.yaml</parameters>
</plugin>
</gazebo>

这种方式的工作流程是:Gazebo 启动后,gz_ros2_control 插件自动加载 controller_manager,然后通过 spawner 节点加载 joint_state_broadcaster 和 diff_drive_base_controller。整个过程无需手动启动 ros2_control_node,比传统方式更加简洁。

5.3 ros_gz_bridge 话题桥接

ros_gz_bridge 负责在 ROS2 和 Gazebo 之间转发话题。桥接配置文件 bridge.yaml 定义了 8 条桥接规则:时钟同步(GZ→ROS)、IMU 数据(GZ→ROS)、彩色图像(GZ→ROS)、深度图像(GZ→ROS)、相机内参(GZ→ROS)、深度相机内参(GZ→ROS)、速度指令(ROS→GZ)、里程计(GZ→ROS)。

在 Jazzy 版本中,bridge.yaml 的格式为每个桥接规则使用列表项,包含 ros_topic_name、gz_topic_name、ros_type_name、gz_type_name 和 direction 五个字段。其中 direction 字段明确指定了数据流向:GZ_TO_ROS 表示从 Gazebo 到 ROS2,ROS_TO_GZ 表示从 ROS2 到 Gazebo。

5.4 控制器配置与启动

ros2_control.yaml 文件定义了 controller_manager 和 diff_drive_base_controller 的参数。在 Jazzy 中,配置文件必须使用 /**/ 前缀作为命名空间标识符。diff_drive_base_controller 的关键参数包括:左右轮关节名称、轮距、轮半径、发布频率、odom 坐标系、速度和加速度限制等。

启动顺序至关重要:首先启动 gazebo 仿真,然后通过 ros_gz_sim/create 节点生成机器人实体,等待机器人加载完成后(OnProcessExit 事件),依次启动 joint_state_broadcaster_spawner 和diff_drive_base_controller_spawner。这种串行启动确保了 controller_manager 在加载控制器时已经就绪。

6. SLAM 建图与 Nav2 导航

6.1 slam_toolbox 配置

slam_toolbox 是目前最先进的开源 SLAM 实现之一,支持 2D 和 3D 建图,具备回环检测和全局优化能力。本项目中,slam_toolbox 配置为 mapping 模式,使用 RGBD 相机的深度图投影生成虚拟激光扫描来建图。

关键配置参数包括:地图分辨率 0.05m,最大激光范围 20m,最小运动距离 0.05m,最小航向变化 0.02rad。回环检测每 3 个节点触发一次,使用 ICP 匹配算法,最大迭代 10 次,变换精度 1e-10。这些参数在保证建图质量的同时也考虑了实时性需求。

6.2 Nav2 导航栈架构

Nav2 是 ROS2 的导航框架,采用行为树(Behavior Tree)驱动的模块化架构。本项目中使用了 Nav2 的以下组件:bt_navigator(行为树导航器)、controller_server(控制器服务器)、planner_server(规划器服务器)、recoveries_server(恢复行为服务器)、map_server(地图服务器)、amcl(自适应蒙特卡洛定位)和 lifecycle_manager_navigation(生命周期管理)。

全局规划使用 NavfnPlanner,采用 Dijkstra 或 A* 算法在栅格地图上计算最短路径。局部规划使用 DWBLocalPlanner(动态窗口法),在速度空间中搜索最优的线速度和角速度组合,在满足运动学约束的前提下尽可能快速地跟踪全局路径。

6.3 视觉避障的实现

传统的避障方案多使用 2D 激光雷达数据构建代价地图,但本项目创新性地采用深度图数据,通过 voxel_layer 构建 3D 体素代价地图。voxel_layer 直接订阅深度图话题 /camera/depth_image,将深度数据投影到 3D 空间并构建体素占据栅格。这种方案的优势在于:能够检测高于激光平面的障碍物(如桌子、门框),提供更丰富的环境表示,与 RGBD 相机完美兼容。

代价地图配置中,local_costmap 使用 3m×3m 的滚动窗口,global_costmap 使用静态地图+深度观测的组合。两个代价地图都使用 voxel_layer 作为观测层,inflation_layer 作为膨胀层,static_layer 用于 global_costmap。膨胀半径设为 0.55m,代价衰减因子为 3.0,确保机器人在障碍物附近有足够的安全距离。

此外,我们还实现了独立的 obstacle_detector_node,提供额外的障碍物检测能力。该节点在图像中心的 100×100 像素窗口内检测最近障碍物,当距离小于警告阈值(0.5m)时,发布检测结果到 obstacle_detections 话题。这个话题可以被 Nav2 的 Costmap2D 或其他安全模块订阅,作为对 voxel_layer 的补充,提供更直接的碰撞预警。

7. 视觉障碍物检测

7.1 深度图处理算法

obstacle_detector_node 的核心功能是从深度图中提取障碍物信息。算法流程如下:

第一,获取相机内参。节点订阅 /camera/camera_info 话题,提取焦距 fx、fy 和主点 cx、cy,用于后续的像素到世界坐标转换。

第二,设置检测区域。在图像中心取 100×100 像素的窗口作为检测区域。这个区域大小可以通过参数 detection_height_pixels 和 detection_width_pixels 调整。较小的区域可以减少计算量,较大的区域可以检测更广泛的障碍物。

第三,遍历检测区域内的每个像素。对于 16UC1 编码的深度图,将原始 16 位值乘以 0.001 转换为米;对于 32FC1 编码的深度图,直接使用浮点值。跳过无效深度(NaN 或超出阈值范围)。记录有效像素中最小的深度值及其像素坐标。

第四,反投影到 3D 空间。使用相机内参将最近障碍物的像素坐标转换为世界坐标:X = (u - cx) * d / fx,Y = (v - cy) * d / fy,Z = d,其中 d 为深度值。

第五,发布检测结果。当检测到障碍物且距离小于警告阈值时,发布三类消息:min_obstacle_distance(浮点数,表示最近障碍物距离)、closest_obstacle_point(3D 点,表示最近障碍物位置)和 obstacle_detections(Detection2DArray,包含障碍物的类别、置信度和边界框)。

7.2 检测参数与性能

节点支持以下可配置参数:检测频率 detection_rate(默认 5Hz)、危险距离 danger_distance(默认 0.3m)、警告距离 warning_distance(默认 0.5m)、检测窗口尺寸 detection_height_pixels 和 detection_width_pixels(默认 100)。

当障碍物距离小于 danger_distance 时,障碍物被分类为 "danger";当距离在 danger 和 warning 之间时,分类为 "warning"。置信度分数根据距离动态计算:score = 1.0 - (min_distance / warning_distance),距离越近置信度越高。

性能方面,由于只在图像中心的 100×100 窗口内进行计算,即使遍历所有像素也只需处理 10000 个数据点,在现代 CPU 上耗时远低于 1ms,完全可以在 5Hz 甚至更高的频率下稳定运行。

8. CI 自动化流水线

8.1 设计理念

CI(持续集成)是保证代码质量和系统可靠性的关键手段。本项目设计了一套完整的本地 CI 流水线,包含三个主要阶段:编译(build.sh)、静态代码检查(lint.sh)和自动化测试(test.sh)。通过 run_all.sh 脚本一键执行整个流水线。

设计理念是:第一,简单易用。所有脚本都可以独立运行,也可以通过 run_all.sh 一键执行。第二,输出清晰。每个步骤都有明确的通过/失败标识和耗时统计。第三,结果持久化。编译日志、lint 结果和测试报告都保存到对应目录,便于追溯和分析。

8.2 编译阶段

build.sh 脚本的主要步骤包括:首先设置 PATH 环境变量优先级,确保使用系统 Python 而非 Conda 环境(这是 ROS2 开发中常见的陷阱)。然后 source ROS2 的 setup.bash。接着尝试使用 rosdep 安装依赖,如果失败则回退到 apt install。最后使用 colcon build 编译所有包,指定 Release 构建类型和系统 Python 解释器路径。

系统 Python 的强制指定是本项目 CI 的一个关键细节。许多 ROS2 开发者在使用 Conda 环境时会遇到 ModuleNotFoundError: No module named catkin_pkg 的错误,这是因为 Conda 的 Python 缺少 ROS2 所需的 catkin_pkg 模块。通过在 PATH 中将 /usr/bin 放在最前面,并在 colcon build 中显式指定 -DPython3_EXECUTABLE=/usr/bin/python3,可以彻底解决这个问题。

8.3 静态检查阶段

lint.sh 脚本集成了四种静态检查工具:ament_cpplint 检查 C++ 代码风格,包括命名规范、缩进、行宽等;ament_lint_cmake 检查 CMakeLists.txt 的规范性,确保构建配置的质量;ament_xmllint 检查 package.xml 的 XML 格式合法性,保证包描述文件的正确性;ament_clang_format 检查代码格式化一致性,确保团队协作中的代码风格统一。

这些工具的结果分别保存到 lint_results/ 目录下的 cpplint.txt、cmake_lint.txt、package_lint.txt和 clang_format.txt 文件中。在 CI 流程中,lint 阶段即使发现问题也不会中断流水线(使用 || true),因为格式化问题不应阻塞测试和构建。开发者可以在闲暇时统一修复格式问题。

8.4 自动化测试阶段

test.sh 脚本实现了多层次的自动化测试:第一层是文件存在性检查,验证所有可执行节点、launch 文件、URDF 文件和配置文件都已正确安装到 install 目录。第二层是格式正确性检查,验证 bridge.yaml 的配置格式符合 Jazzy 版本的要求。第三层是功能正确性检查,通过 xacro 命令验证机器人模型可以被正确解析。第四层是集成测试,启动 Gazebo 仿真并验证所有控制器是否成功加载。

Gazebo 仿真测试是最关键的测试环节。脚本通过 timeout 命令限制仿真运行时间为 25 秒,在这期间等待控制器加载完成。测试通过 grep 命令检查日志中是否出现 "Configured and activated diff_drive_base_controller"的关键信息,以此判断仿真启动是否成功。这种基于日志关键字的验证方式简单但有效,已被证明在 CI 环境中稳定可靠。

9. 踩坑记录与最佳实践

9.1 Python 环境冲突

这是最常见也最隐蔽的问题。当系统同时安装了 Conda 时,默认的 python 命令可能指向 Conda 的 Python,而它缺少 ROS2 所需的 catkin_pkg、empy 等模块。症状表现为 colcon build 报错,但错误信息可能不直接指向根因。解决方案是在所有 ROS2 命令前设置 PATH:export PATH="/usr/bin:/usr/local/bin:$PATH",确保系统 Python 优先被使用。如果仍然有问题,可以通过 which python3 检查当前使用的 Python 路径。

9.2 ros2_control 配置格式

Jazzy 版本的 ros2_control.yaml 引入了 /**/ 前缀作为命名空间标识符,这是与之前版本的重要区别。如果配置文件缺少此前缀,控制器管理器会无法识别控制器类型参数,导致 "type param was not defined" 错误。正确的格式是在控制器名称前添加 /**/ 注释标记:

/**/diff_drive_base_controller:
ros__parameters:
type: diff_drive_controller/DiffDriveController

此外,spawner 节点需要通过 --param-file 参数显式传递配置文件路径,否则控制器也无法获取正确的参数。

9.3 URDF 常见问题

URDF/Xacro 建模中有三个高频问题。第一是材质未定义:所有在 visual 或 collision 中使用的材质必须在文件开头(或被 include 的文件中)先定义。第二是 rpy 属性格式:rpy 需要三个浮点数,分别对应 roll、pitch、yaw,不能省略任何一个。第三是惯性参数:每个 link 都必须包含 inertial 标签,即使是无质量的装饰性 link 也不例外,否则 Gazebo 会输出警告甚至拒绝加载。

9.4 时间戳操作

ROS2 中的时间处理有一个容易混淆的地方:builtin_interfaces::msg::Time 和 rclcpp::Time 是两种不同的类型。前者是纯消息类型,不支持运算符重载;后者是 ROS2 的时间封装类型,支持比较和算术运算。在需要对时间戳进行比较或计算时间差时,必须先将 builtin_interfaces::msg::Time 转换为 rclcpp::Time 对象。

9.5 矩阵维度

在使用 Eigen 进行矩阵运算时,维度不匹配是常见的编译错误来源。特别是在 ESKF 这类涉及大量矩阵运算的算法中,必须时刻关注矩阵的行列数。一个实用的技巧是:在关键矩阵运算前,使用 Eigen 的 static_assert 检查维度:

static_assert(K.rows() == 15 && K.cols() == 6,
"K matrix dimensions must be 15x6");