跳转至

构建系统与大型 C++ 项目组织

本章定位:把“能在本机编过”提升为“依赖边界清楚、目标可复用、安装可导出、CI 可重复、多人协作不互相破坏”的大型机器人 C++ 工程能力。 构建系统不是附属脚本,而是模块边界、ABI、依赖传播和交付形态的工程说明书。

前置自测

  1. CMake 中 PUBLICPRIVATEINTERFACE 的传播语义是什么?
  2. 为什么顶层全局 include_directories() 容易污染大型项目?
  3. colcon overlay workspace 的工作原理是什么?
  4. rosdep、vcpkg、conan 分别解决什么问题?
  5. 一个库暴露 Eigen 类型时,为什么 Eigen 依赖必须是 PUBLIC

本章目标

学完本章后,你应该能写出 target-centric 的现代 CMake。 你应该能设计机器人项目的包边界、头文件边界、安装导出和 CI 流程。 你还应该能解释 CMake、colcon、ament、rosdep、Docker 和工业 CI 在机器人项目中的分工。


7.1 现代 CMake 的核心:target 边界 ⭐

这一节解决的问题是:为什么大型 C++ 项目不能靠全局变量和全局 include 路径维持。

动机:构建系统表达的是依赖关系

一个机器人项目通常包含:

模块 依赖 是否应暴露
math_core Eigen 是,头文件可能用 Eigen 类型
qp_solver_bridge OSQP/ProxSuite 否,适配层实现细节
robot_model Pinocchio、URDF parser 部分暴露
controller math_core、robot_model 对下游暴露控制接口
ros2_node rclcpp、controller ROS2 可执行入口

如果把所有 include 路径和编译选项放到全局,模块之间会产生隐式依赖。 今天能编过,不代表明天别人拆出一个库还能编过。

CMake target 类似 C++ 类。 类的 public 成员是承诺,private 成员是实现细节。 target 的 PUBLIC 依赖会传给下游,PRIVATE 依赖不会。

正确写法

# 声明最低 CMake 版本和项目语言,避免隐式使用过旧策略。
cmake_minimum_required(VERSION 3.22)
project(robot_control LANGUAGES CXX)

# 查找外部依赖,后续只链接导入 target,不手写 include 路径。
find_package(Eigen3 REQUIRED)
find_package(proxsuite REQUIRED)

# 控制核心是一个普通 C++ 库,不直接依赖 ROS2 节点。
add_library(controller_core
    src/controller_core.cpp
)

# C++ 标准属于接口承诺,下游链接该库时也应满足同样标准。
target_compile_features(controller_core PUBLIC cxx_std_17)

target_include_directories(controller_core
    PUBLIC
        # 源码树内使用 include/,安装后使用安装前缀下的 include/。
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)

target_link_libraries(controller_core
    PUBLIC
        # Eigen 类型会出现在公开头文件中,因此必须传播给下游。
        Eigen3::Eigen
    PRIVATE
        # QP 求解器只在 .cpp 中使用,是实现细节。
        proxsuite::proxsuite
)

这里 Eigen 是 PUBLIC,因为头文件可能出现 Eigen::VectorXd。 ProxSuite 是 PRIVATE,因为求解器可以藏在 .cpp 或 pimpl 中。

反面失败:全局 include 泄漏

# 错误示例:所有目标都能看到这个 include 路径。
# 这会让未声明依赖的 target 也能“碰巧”编译通过。
include_directories(${CMAKE_SOURCE_DIR}/third_party/some_solver/include)
add_definitions(-DUSE_FAST_SOLVER)

问题包括:

  1. 某个目标可以不声明依赖却编过。
  2. 换求解器时影响全项目。
  3. 编译选项污染第三方库。
  4. 头文件边界失真,安装后下游找不到依赖。

PUBLIC/PRIVATE/INTERFACE 的判断

依赖出现在何处 CMake 关键字
只在 .cpp 使用 PRIVATE
出现在 public header 类型或函数签名 PUBLIC
当前 target 不编译源码,只向下游传递 INTERFACE
头文件模板中使用 PUBLIC
编译选项影响 ABI 通常 PUBLIC 或统一工具链

本质洞察:构建边界和代码边界必须一致。 如果头文件承诺了某个类型,CMake 就必须把该类型的依赖传播给下游。 否则项目只是在源码树内部“碰巧能编过”。

练习

  1. 判断一个暴露 Eigen::Ref<const Eigen::VectorXd> 的库应如何链接 Eigen。
  2. 把一个使用全局 include_directories 的 CMakeLists 改成 target 级写法。
  3. 设计 controller_corecontroller_ros 两个 target 的依赖关系,要求核心库不依赖 ROS2。

7.2 安装、导出与包边界 ⭐⭐

这一节解决的问题是:如何让你的库不只在源码树内能用,也能被其他项目正确 find_package。

动机:本地能编过不等于可复用

大型项目常需要:

  1. 单独测试核心库。
  2. 被 ROS2 节点链接。
  3. 被仿真工具链接。
  4. 被下游项目 find_package
  5. 被 CI 和 Docker 重新构建。

如果不安装导出 target,下游只能复制 include 路径和库路径。 这会迅速失控。

安装导出示例

include(GNUInstallDirs)

# 安装库 target,并把 target 元信息写入导出集合。
install(TARGETS controller_core
    EXPORT robot_controlTargets
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

# 安装公开头文件,保持 include/robot_control/... 的命名空间结构。
install(DIRECTORY include/
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)

# 导出命名空间 target,供下游 find_package 后链接。
install(EXPORT robot_controlTargets
    NAMESPACE robot_control::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/robot_control
)

下游使用:

# 下游项目只需要 find_package 和链接命名空间 target。
find_package(robot_control REQUIRED)

target_link_libraries(my_app
    PRIVATE
        # 不手写头文件路径,也不手写库文件绝对路径。
        robot_control::controller_core
)

头文件组织

# 目录示意:公开头文件、实现文件和测试文件分层放置。
include/
  robot_control/
    controller_core.hpp
    solver_interface.hpp
src/
  controller_core.cpp
  solver_osqp.cpp
test/
  controller_core_test.cpp

public header 只放稳定接口。 第三方求解器细节放在 src 或私有头文件。 这样换求解器不会迫使所有下游重新包含新头文件。

ABI 与编译选项

ABI 是二进制接口。 C++ 项目中下列因素可能影响 ABI:

因素 例子 风险
标准库 ABI libstdc++ dual ABI 链接后运行崩溃
编译器版本 GCC/Clang 混用 符号和异常边界
编译选项 SIMD、异常、RTTI 对齐和调用约定
public 类型 Eigen、std 容器 下游受影响

机器人项目通常通过统一 Docker、toolchain file 或 CI 镜像降低 ABI 风险。

练习

  1. 为一个 math_core 库写 install/export 规则,并在另一个最小项目中 find_package
  2. 把求解器第三方头文件从 public header 移到 .cpp,观察下游重新编译范围。
  3. 解释为什么 public header 中暴露 std::vector<Eigen::Vector4d> 需要关心 allocator 和 ABI。

7.3 FetchContent、ExternalProject 与包管理 ⭐⭐

这一节解决的问题是:第三方依赖应该如何进入项目。

三种来源

来源 方式 优势 风险
系统包 apt/rosdep 简单、与 ROS2 集成好 版本受发行版限制
C++ 包管理器 vcpkg/conan 跨平台、版本可控 ROS2 集成需额外设计
源码拉取 FetchContent/ExternalProject 精确控制源码 构建时间和维护成本

FetchContent

FetchContent 在配置阶段获取并纳入当前构建。 适合 header-only、小型 CMake 友好依赖。

include(FetchContent)

# 固定源码仓库和 tag,保证不同机器拉到同一份依赖代码。
FetchContent_Declare(
    small_library
    GIT_REPOSITORY https://example.com/small_library.git
    GIT_TAG v1.2.3
)

# 把依赖作为当前构建的一部分纳入,适合 CMake 友好的轻量依赖。
FetchContent_MakeAvailable(small_library)

ExternalProject

ExternalProject 在构建阶段独立构建。 适合大型依赖或构建系统不兼容的项目。 代价是 target 集成和 IDE 体验更复杂。

rosdep

ROS2 项目中,系统依赖优先写进 package.xml,由 rosdep 解析。

<!-- package.xml 声明 ROS2 包依赖,rosdep 会根据这些键安装系统依赖。 -->
<package format="3">
  <name>robot_controller</name>
  <version>0.1.0</version>
  <description>Controller package</description>
  <maintainer email="dev@example.com">Developer</maintainer>
  <license>Apache-2.0</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <depend>rclcpp</depend>
  <depend>Eigen3</depend>
  <test_depend>ament_cmake_gtest</test_depend>
</package>

反面失败:把第三方源码直接复制进项目

短期看最简单。 长期会出现:

  1. 不知道第三方版本。
  2. 无法及时更新安全修复。
  3. 本地修改和上游修改混在一起。
  4. 许可证边界不清。

练习

  1. 为一个纯 CMake 小依赖写 FetchContent 接入,并固定 tag。
  2. 在 ROS2 包中加入 package.xml 依赖,用 rosdep 检查。
  3. 比较同一依赖通过 apt 和 FetchContent 引入时的可重复性差异。

7.4 ROS2 构建生态:ament 与 colcon ⭐⭐

这一节解决的问题是:ROS2 为什么不用单个顶层 CMake 直接构建所有包。

colcon 的角色

colcon 是元构建工具。 它读取 workspace 中的多个包,按依赖顺序调用各自构建系统。 每个包仍然可以是 CMake、Python 或其他构建类型。

# 使用 colcon 构建当前 workspace,并把 CMake 构建类型传给每个 CMake 包。
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo

overlay workspace

overlay 允许在已安装 ROS2 或基础 workspace 之上叠加当前开发包。 这对机器人项目很重要:

  1. 底层使用稳定发布版本。
  2. 当前只修改少量包。
  3. 不必从源码重编整个 ROS2。
# 先加载底层 ROS2 环境,再构建当前 overlay。
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install
# 构建后加载当前 workspace,使本地包覆盖底层已安装包。
source install/setup.bash

ament_cmake

ament 提供 ROS2 包所需的导出和测试宏。

# ament_cmake 提供 ROS2 包导出、测试和安装约定。
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)

# ROS2 节点是边缘入口,核心算法仍应放在普通 C++ 库中。
add_executable(controller_node src/controller_node.cpp)
ament_target_dependencies(controller_node rclcpp)

install(TARGETS controller_node
    # ROS2 可执行文件按包名安装到 lib/<package>。
    DESTINATION lib/${PROJECT_NAME}
)

# 生成 ROS2 包元信息,通常放在 CMakeLists 末尾。
ament_package()

对纯 C++ 核心库,不要强迫其依赖 ament。 更好的结构是核心库用普通 CMake,ROS2 包作为薄封装。

练习

  1. 创建两个 ROS2 包:controller_corecontroller_ros,让 ROS 包依赖核心包。
  2. 用 overlay 修改一个包,说明 source 顺序为何重要。
  3. 解释 --symlink-install 对 Python launch 文件和配置文件调试有什么帮助。

7.5 CMake Presets、Docker 与 CI ⭐⭐

这一节解决的问题是:如何让构建配置可重复,而不是只存在某个人的终端历史里。

CMake Presets

CMakePresets.json 把常用配置写入版本控制。

// 教学注释版 CMakePresets;真实 JSON 文件不支持 // 注释,复制时可删除注释行。
{
  "version": 6,
  "configurePresets": [
    {
      // 预设名称成为命令行中的稳定入口。
      "name": "linux-relwithdebinfo",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/relwithdebinfo",
      "cacheVariables": {
        // RelWithDebInfo 适合调试和性能分析,比 Debug 更接近真实性能。
        "CMAKE_BUILD_TYPE": "RelWithDebInfo",
        "CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
      }
    }
  ],
  "buildPresets": [
    {
      "name": "build-linux-relwithdebinfo",
      "configurePreset": "linux-relwithdebinfo"
    }
  ]
}

Docker

Docker 用于固定系统依赖、编译器、ROS2 发行版和工具版本。 GPU 项目还需要 NVIDIA Container Toolkit。

# 固定 ROS2 发行版基础镜像,减少系统依赖漂移。
FROM ros:jazzy-ros-base

# 安装构建工具和静态分析工具,清理 apt 缓存减小镜像体积。
RUN apt-get update && apt-get install -y \
    build-essential \
    cmake \
    ninja-build \
    clang-tidy \
    && rm -rf /var/lib/apt/lists/*

# 所有 CI 和本地容器构建都从统一工作目录开始。
WORKDIR /ws

Docker 不应掩盖构建系统问题。 如果只能在某个容器里靠手工环境变量编过,说明依赖声明仍不完整。

CI 最小流水线

name: build-and-test

# push 和 pull_request 都触发同一套构建测试。
on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure
        # 使用版本化的 CMake preset,避免 CI 复制本地命令历史。
        run: cmake --preset linux-relwithdebinfo
      - name: Build
        # 构建 preset 复用 configure preset 的输出目录。
        run: cmake --build --preset build-linux-relwithdebinfo
      - name: Test
        # 失败时打印测试输出,便于定位具体断言。
        run: ctest --test-dir build/relwithdebinfo --output-on-failure

机器人 ROS2 项目可使用 industrial_ci 或自定义 Docker CI。 核心目标是让每次提交都能重现编译、测试、格式和静态分析。

练习

  1. 为本地项目添加 CMake preset,要求生成 compile_commands.json
  2. 写一个 Dockerfile 固定 ROS2 发行版和构建工具。
  3. 设计 CI 阶段:格式、编译、单元测试、集成测试、sanitizer,说明每阶段失败意味着什么。

7.6 项目组织与架构边界 ⭐⭐⭐

这一节解决的问题是:大型机器人 C++ 项目如何避免变成互相 include 的一团。

推荐目录结构

# 推荐工作区示意:核心库、ROS2 封装、工具和构建基础设施分层。
robot_stack/
  modules/
    math_core/
    optimization_bridge/
    robot_model/
    controller_core/
  ros2_ws/
    src/
      controller_ros/
      perception_ros/
  tools/
    log_convert/
    bag_eval/
  docker/
  cmake/
  docs/

核心库应尽量不依赖 ROS2。 ROS2 包负责消息转换、参数、节点生命周期和 launch。 这样核心算法可以在 gtest、benchmark、仿真和非 ROS 环境中复用。

头文件依赖最小化

技术 目的
前向声明 减少 include
pimpl 隐藏重依赖和 ABI
interface header 暴露稳定抽象
include-what-you-use 检查多余依赖

反面失败:控制核心直接依赖 rclcpp

如果 ControllerCore 直接使用 ROS2 logger、参数和消息类型,那么:

  1. 单元测试必须初始化 ROS2。
  2. 控制核心无法嵌入非 ROS 仿真。
  3. 实时边界和回调边界混在一起。
  4. 下游想复用控制算法必须引入 ROS2。

正确做法是把 ROS2 消息转换成核心库结构体。

// 轻量视图只借用外部数组,不拥有内存,适合实时控制路径。
struct JointStateView {
    const double* q;
    const double* dq;
    int n;
};

class ControllerCore {
public:
    // 控制核心只接收普通 C++ 数据结构,不依赖 ROS2 消息类型。
    bool update(const JointStateView& state, double dt);
};

练习

  1. 画出你的机器人项目依赖图,要求箭头不能从核心库指向 ROS2 包。
  2. 找一个 public header 中不必要的 include,用前向声明替换。
  3. 设计一个 ControllerCore 的纯 C++ 测试,不初始化 ROS2。

7.7 大型 C++ 项目的分层地图 ⭐⭐⭐

这一节解决的问题是:一个机器人仓库里几十个库、节点和工具应该如何分层,才不会在半年后变成所有模块互相依赖。

动机:项目组织先于代码组织

很多初学者把“项目组织”理解为目录摆放。 目录当然重要,但目录只是表象。 真正的组织是依赖方向、数据边界、实时边界和交付边界。

一个控制项目可以把源文件放得很整齐,却仍然架构混乱。 典型表现是 controller_core include 了 rclcpprobot_model 直接读取 YAML 参数。 qp_solver_bridge 在头文件里暴露第三方求解器类型。 simulation_node 调用了硬件驱动的私有函数。

这些问题不是缩进和命名能解决的。 它们说明项目没有回答一个更根本的问题: 哪个模块是稳定核心,哪个模块是边缘适配,哪个模块只在某种运行环境存在。

四层结构

大型机器人 C++ 项目可以按四层理解:

层级 典型内容 依赖方向 稳定性要求
基础层 math_core、geometry、time、logging interface 不依赖业务模块 最高
领域层 robot_model、estimator_core、controller_core、planner_core 依赖基础层
适配层 ros2_node、sim_bridge、hardware_bridge、solver_bridge 依赖领域层
应用层 launch、配置、实验工具、离线评估脚本 依赖适配层 变化最快

依赖只能从上层指向下层。 基础层不应该知道 ROS2。 领域层不应该知道具体传感器驱动。 适配层可以知道 ROS2、仿真器、硬件协议和求解器。 应用层把这些目标组合成可运行系统。

这个结构类似控制系统里的分层控制。 底层电流环必须稳定、快速、接口窄。 上层任务规划变化快、场景多、假设也多。 如果上层细节渗入底层,底层就失去可复用性。 如果底层接口过宽,上层每改一次都会牵动大量重编译。

依赖方向示意

# 分层依赖示意:箭头只允许向下,禁止核心层回头依赖边缘层。
application_tools
  └── ros2_nodes
        ├── controller_core
        │     ├── robot_model
        │     └── math_core
        └── hardware_bridge
              └── controller_core

在这个图中,controller_core 不知道它最终由 ROS2 调用、仿真器调用还是离线测试调用。 它只知道输入状态、参考轨迹和输出控制命令。 这让同一份控制代码可以在单元测试、benchmark、仿真和真实机器人上复用。

反面失败:让核心层读取参数服务器

如果 ControllerCore 构造函数直接接收 rclcpp::Node&,看起来写参数很方便。 但代价非常大:

方便之处 隐含代价
直接 declare_parameter 核心库无法脱离 ROS2 测试
直接用 ROS2 logger benchmark 和仿真工具必须初始化 ROS2
直接用 ROS2 message public header 传播 ROS2 依赖
直接读取参数服务器 配置来源无法替换为文件或内存结构

更好的做法是定义普通 C++ 配置结构。 ROS2 节点负责把参数服务器中的值转换成该结构。 核心库只校验结构体是否合法。

#include <string>
#include <vector>

struct ControllerConfig {
    double control_period_s = 0.002;       // 控制周期,单位为秒。
    double max_torque_nm = 23.0;           // 关节力矩限幅,单位为牛米。
    std::vector<double> kp;                // 位置环比例增益,长度应等于关节数。
    std::vector<double> kd;                // 速度环微分增益,长度应等于关节数。
    std::string model_name;                // 机器人模型名称,用于选择动力学参数。
};

class ControllerCore {
public:
    explicit ControllerCore(ControllerConfig config);

    bool isConfigValid() const;            // 检查配置长度、周期和限幅是否合理。

private:
    ControllerConfig config_;              // 核心库持有纯 C++ 配置,不持有 ROS2 节点。
};

分层判断问题

当你不确定某个类型应该放在哪里,可以问四个问题:

  1. 它是否需要在没有 ROS2 的环境中测试?
  2. 它是否需要在仿真和真机之间复用?
  3. 它是否暴露在 public header 中?
  4. 它的变化频率是否明显高于核心算法?

如果答案是“需要复用、需要测试、暴露接口、变化不快”,它更可能属于基础层或领域层。 如果答案是“依赖某个运行环境、协议或工具”,它更可能属于适配层。

类型 推荐归属 原因
Eigen::VectorXd 基础层可暴露 数学类型稳定且广泛使用
sensor_msgs::msg::JointState ROS2 适配层 消息类型绑定通信中间件
pinocchio::Model robot_model 内部或受控暴露 依赖重,ABI 和编译时间需控制
OsqpEigen::Solver solver_bridge 内部 求解器可替换,不应泄漏
HardwarePacket hardware_bridge 协议与设备绑定

练习

  1. 画出一个四足项目的四层依赖图,要求核心控制库不依赖 ROS2 和硬件驱动。
  2. 把一个直接读取 ROS2 参数的核心类改成接收普通 C++ 配置结构。
  3. 判断 pinocchio::Data 是否应该出现在 public header 中,并说明理由。

7.8 Target 设计模式:库、接口库、对象库与可执行入口 ⭐⭐⭐

这一节解决的问题是:现代 CMake 中不只是“加一个库”,还要选择正确的 target 形态。

为什么 target 形态重要

CMake target 是构建系统里的最小可组合单元。 它携带源文件、头文件路径、编译特性、编译定义、链接依赖和安装导出信息。 如果 target 设计粗糙,CMakeLists 会迅速膨胀成全局变量脚本。

一个机器人项目通常同时需要:

  1. 纯头文件数学工具。
  2. 有源码的核心算法库。
  3. 可替换的求解器适配库。
  4. 用于多个可执行文件共享源码的对象库。
  5. 很薄的 ROS2 节点入口。
  6. 测试和基准测试 target。

这些 target 的职责不同,CMake 写法也应不同。

target 类型对比

target 类型 是否编译源码 是否产出库文件 典型用途
STATIC .a 核心算法库、离线工具
SHARED .so 插件、运行期共享库
INTERFACE header-only、统一编译选项
OBJECT .o 集合 多个库共享同一批对象文件
ALIAS 命名空间别名
EXECUTABLE 可执行文件 节点、工具、测试入口

选择 target 类型时,先问“这个单元是否有自己的二进制边界”。 如果只是传播头文件和编译选项,用 INTERFACE。 如果是可链接的核心能力,用 STATICSHARED。 如果只是把同一批源码复用到多个最终产物,可考虑 OBJECT

header-only 接口库

# math_traits 只有头文件,不需要编译源码。
add_library(math_traits INTERFACE)

# 头文件路径通过 INTERFACE 传播给下游。
target_include_directories(math_traits
    INTERFACE
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)

# Eigen 类型出现在头文件模板中,因此作为 INTERFACE 依赖传播。
target_link_libraries(math_traits
    INTERFACE
        Eigen3::Eigen
)

接口库不是“空库”。 它是一个依赖传播载体。 很多工程规范、编译开关和头文件模板都可以通过接口库表达。

统一编译警告接口

项目中常见需求是给自有代码开启严格警告,但不要污染第三方代码。 这时可以定义一个内部接口 target。

# project_warnings 只传播编译选项,不编译任何源码。
add_library(project_warnings INTERFACE)

target_compile_options(project_warnings
    INTERFACE
        -Wall
        -Wextra
        -Wpedantic
        -Wconversion
)

# 只把严格警告链接到自有 target。
target_link_libraries(controller_core
    PRIVATE
        project_warnings
)

这里使用 PRIVATE 是因为下游不一定愿意继承你的警告策略。 编译警告通常是项目内部质量策略,不是库接口的一部分。 例外是 ABI 相关编译定义,例如是否启用异常或 RTTI,这类选项必须统一。

对象库的适用边界

对象库适合共享同一批源码给多个最终 target。 例如你想同时产出静态库和用于插件的共享库。

# 对象库编译源码,但不直接产出最终库文件。
add_library(controller_objects OBJECT
    src/controller_core.cpp
    src/controller_state.cpp
)

target_compile_features(controller_objects PUBLIC cxx_std_17)

# 静态库复用对象文件,供离线工具链接。
add_library(controller_core_static STATIC
    $<TARGET_OBJECTS:controller_objects>
)

# 共享库复用对象文件,供插件或动态加载场景使用。
add_library(controller_core_shared SHARED
    $<TARGET_OBJECTS:controller_objects>
)

对象库不要滥用。 如果只是普通库复用,直接 target_link_libraries 更清楚。 对象库会让安装、导出和 IDE 展示更复杂。 它适合确实需要复用编译产物的场景。

可执行入口应尽量薄

可执行文件 target 应该像 main 函数一样薄。 它负责解析参数、加载配置、创建对象、启动循环。 算法逻辑应在库 target 中。

#include "robot_control/controller_core.hpp"

int main(int argc, char** argv) {
    (void)argc;                            // 示例中不解析命令行参数。
    (void)argv;                            // 真实项目可在这里读取配置文件路径。

    ControllerConfig config;
    config.control_period_s = 0.002;       // 设置控制周期。
    config.max_torque_nm = 23.0;           // 设置力矩限幅。

    ControllerCore controller(config);      // main 只装配对象,不写控制算法。
    return controller.isConfigValid() ? 0 : 1;
}

薄入口带来的直接收益是测试方便。 你可以在 gtest 中直接链接 controller_core。 不用运行整个节点,也不用模拟命令行和进程环境。

练习

  1. 把一个只包含模板函数的 math_utils 改成 INTERFACE target。
  2. 为自有 target 添加 project_warnings 接口库,说明为什么不传播给下游。
  3. 把一个包含大量业务逻辑的 main.cpp 拆成库 target 和薄可执行入口。

7.9 包导出的完整闭环:从 build tree 到 install tree ⭐⭐⭐

这一节解决的问题是:为什么写了 install(EXPORT ...) 还不等于真正可被下游可靠使用。

find_package 的实际流程

下游调用 find_package(robot_control REQUIRED) 时,CMake 不是凭空知道你的库在哪里。 它会在若干前缀中寻找配置文件。 对现代 CMake 包,最关键的是:

  1. robot_controlConfig.cmake
  2. robot_controlConfigVersion.cmake
  3. robot_controlTargets.cmake

Targets.cmake 描述导出的 target。 Config.cmake 负责加载 target 并查找传播依赖。 ConfigVersion.cmake 负责版本兼容判断。

如果只导出 targets,而没有配置文件,下游常常还要手写路径。 这说明包导出没有闭环。

配置文件模板

# robot_controlConfig.cmake.in:安装时由 configure_package_config_file 生成。
@PACKAGE_INIT@

include(CMakeFindDependencyMacro)

# 公开接口依赖 Eigen,因此下游 find_package 时也要找到 Eigen。
find_dependency(Eigen3 REQUIRED)

# 加载由 install(EXPORT ...) 生成的命名空间 target。
include("${CMAKE_CURRENT_LIST_DIR}/robot_controlTargets.cmake")

# 检查所有必需组件是否已找到。
check_required_components(robot_control)

这个文件的关键是 find_dependency。 如果你的公开头文件暴露了 Eigen 类型,下游必须也能找到 Eigen。 否则导出的 target 虽然存在,编译下游时仍然会报找不到头文件或 target。

生成 Config 和 Version 文件

include(CMakePackageConfigHelpers)

# 生成可重定位的 Config.cmake,不把构建机绝对路径写死到安装包中。
configure_package_config_file(
    cmake/robot_controlConfig.cmake.in
    ${CMAKE_CURRENT_BINARY_DIR}/robot_controlConfig.cmake
    INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/robot_control
)

# 生成版本兼容文件,SameMajorVersion 表示主版本相同即可兼容。
write_basic_package_version_file(
    ${CMAKE_CURRENT_BINARY_DIR}/robot_controlConfigVersion.cmake
    VERSION ${PROJECT_VERSION}
    COMPATIBILITY SameMajorVersion
)

# 安装配置文件和版本文件。
install(FILES
    ${CMAKE_CURRENT_BINARY_DIR}/robot_controlConfig.cmake
    ${CMAKE_CURRENT_BINARY_DIR}/robot_controlConfigVersion.cmake
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/robot_control
)

可重定位是包导出的核心要求。 安装包不应记录 /home/someone/project/build/... 这样的绝对路径。 否则包搬到 Docker 镜像、CI 缓存或另一台机器后就会失效。

构建树导出与安装树导出

使用场景 机制 适合阶段
build tree export(EXPORT ...) 本地开发、超级构建
install tree install(EXPORT ...) 发布、CI、下游复用

初学者常只测试 build tree。 这很危险。 源码树里有很多隐式路径,能掩盖安装规则错误。 真正的可复用性必须通过 install tree 测试。

安装树冒烟测试

# 安装当前项目到临时前缀。
cmake --install build/relwithdebinfo --prefix /tmp/robot_control_install

# 新建一个完全独立的下游构建目录。
cmake -S examples/consumer -B /tmp/robot_control_consumer_build \
  -DCMAKE_PREFIX_PATH=/tmp/robot_control_install

# 构建下游项目,验证 find_package 和导出 target 是否完整。
cmake --build /tmp/robot_control_consumer_build

这个测试非常便宜,却能发现大量问题:

  1. 忘记安装头文件。
  2. 忘记导出 target。
  3. public 依赖没有 find_dependency
  4. 安装路径中残留构建目录绝对路径。
  5. 命名空间 target 名称与文档不一致。

版本策略

包导出还牵涉版本承诺。 机器人项目内部库也应遵守简单的语义化版本约定。

改动类型 示例 版本动作
兼容新增 增加新函数、新 target 增加次版本
行为修复 修复边界条件,不改接口 增加补丁版本
破坏接口 删除函数、改变 public 类型 增加主版本
ABI 破坏 改变 public class 数据成员 至少增加主版本

内部项目不一定要正式发布到系统包仓库。 但版本思维仍然重要。 它帮助团队判断“这个改动会影响谁”。

练习

  1. controller_core 补齐 Config.cmake.in、版本文件生成和安装规则。
  2. 写一个独立 consumer 项目,只用 find_package 和命名空间 target 链接库。
  3. 故意删除 find_dependency(Eigen3 REQUIRED),观察下游报错位置并解释原因。

7.10 依赖边界与头文件卫生 ⭐⭐⭐

这一节解决的问题是:为什么大型 C++ 项目的编译时间和耦合度常常来自头文件。

头文件是传播边界

C++ 的 #include 是文本包含。 一个 public header include 了什么,下游编译单元就会看到什么。 这和 Python 的 import 或 Java 的包机制不同。 C++ 头文件的传播成本更重。

如果一个控制库的公开头文件 include 了 Pinocchio、rclcpp、OSQP 和 YAML,那么任何下游文件只要 include 控制库接口,就会被迫解析这些重依赖。 编译慢只是表层问题。 更大的问题是依赖边界失真。

include 决策表

public header 中的需求 推荐做法 理由
按值持有类型 include 完整定义 编译器需要知道大小和析构
只声明指针或引用 前向声明 降低头文件传播
模板中使用成员 include 完整定义 模板实例化需要实现可见
函数签名暴露第三方类型 谨慎 include 并声明 PUBLIC 依赖 这是接口承诺
只在 .cpp 使用 放到 .cpp include 不污染下游

pimpl 隐藏重依赖

当一个类需要使用重依赖,但不想把重依赖暴露给下游时,可以使用 pimpl。

#pragma once

#include <memory>

class SolverAdapter {
public:
    SolverAdapter();
    ~SolverAdapter();                      // 析构函数放到 .cpp 中定义,确保 Impl 完整。

    SolverAdapter(SolverAdapter&&) noexcept;
    SolverAdapter& operator=(SolverAdapter&&) noexcept;

    bool solve();                          // 对外只暴露稳定行为,不暴露具体求解器类型。

private:
    struct Impl;                           // 前向声明实现结构体,隐藏第三方依赖。
    std::unique_ptr<Impl> impl_;           // public header 只需要知道指针大小。
};

对应实现文件:

#include "robot_control/solver_adapter.hpp"

#include <memory>

struct SolverAdapter::Impl {
    int max_iteration = 100;               // 示例字段,真实项目中可放第三方求解器对象。
    double tolerance = 1e-6;               // 求解器容差只属于实现细节。
};

SolverAdapter::SolverAdapter()
    : impl_(std::make_unique<Impl>()) {}

SolverAdapter::~SolverAdapter() = default;

SolverAdapter::SolverAdapter(SolverAdapter&&) noexcept = default;

SolverAdapter& SolverAdapter::operator=(SolverAdapter&&) noexcept = default;

bool SolverAdapter::solve() {
    return impl_->max_iteration > 0;        // 示例逻辑:真实项目在这里调用求解器。
}

pimpl 的代价是一次间接访问和额外分配。 在实时热路径中要谨慎。 如果对象每个控制周期都创建,pimpl 可能引入抖动。 但对于求解器适配、模型加载、参数管理这类低频对象,pimpl 很常见。

依赖边界检查

可以把依赖边界写成自动检查。 例如核心库禁止 include ROS2 头文件。

# 检查核心库头文件中是否误包含 ROS2 头文件。
rg -n '#include <rclcpp|#include "rclcpp' modules/controller_core/include

# 检查核心库实现中是否误包含硬件驱动私有头文件。
rg -n '#include .*hardware_driver' modules/controller_core/src

这些检查不是替代编译,而是把架构规则变成可执行规则。 越早发现依赖反向,修复成本越低。

练习

  1. 找一个 public header 中的重 include,用前向声明或 pimpl 降低传播。
  2. 写一个 CI 检查,禁止 controller_core/include 中出现 rclcpp
  3. 比较 pimpl 前后 ninja -d stats 的编译单元数量和总耗时。

7.11 工作区组织:monorepo、overlay 与 vendor 包 ⭐⭐

这一节解决的问题是:多个包、多个依赖源和多个机器人型号如何在同一工作区中协作。

monorepo 与多仓库

机器人团队常在 monorepo 和多仓库之间摇摆。 两者都不是绝对正确,关键在于依赖节奏。

组织方式 优势 风险 适合场景
monorepo 原子修改、统一 CI、跨包重构方便 仓库变大、权限边界粗 核心栈紧耦合
多仓库 发布边界清晰、权限独立 跨仓修改麻烦、版本组合复杂 SDK、驱动、共享库
混合式 核心 monorepo,外部依赖独立 需要明确同步规则 中大型团队

对教学项目而言,可以先用 monorepo。 原因是学习重点在依赖边界和构建规则,而不是仓库治理。 当某个模块有独立发布、独立权限或独立生命周期时,再拆仓更自然。

ROS2 workspace 的分层

实际项目常用三层:

# 工作区分层示意:系统层稳定,平台层复用,任务层变化快。
/opt/ros/jazzy                  # 系统 underlay:发行版包
/ws/platform/install            # 平台 underlay:机器人通用库
/ws/task/install                # 任务 overlay:当前实验或产品包

加载顺序必须从底到顶:

# 加载系统 ROS2 环境。
source /opt/ros/jazzy/setup.bash

# 加载平台基础包。
source /ws/platform/install/setup.bash

# 加载当前任务包,使本地修改覆盖下层同名包。
source /ws/task/install/setup.bash

如果顺序反了,你可能运行到旧版本节点。 这类问题很隐蔽,因为编译成功、启动成功,但行为不是你刚改的代码。

colcon 包选择

colcon 支持选择部分包构建。 这对于大型 workspace 很重要。

# 只构建 controller_ros 及其依赖。
colcon build --packages-up-to controller_ros --symlink-install

# 从某个包开始重建它和后续依赖包。
colcon build --packages-above controller_core --symlink-install

# 跳过暂时不需要的仿真包,缩短本地构建时间。
colcon build --packages-skip heavy_simulation --symlink-install

局部构建不是偷懒。 它是大型工作区的常规开发方式。 但 CI 仍然应该定期全量构建,防止被跳过的包长期失效。

vendor 包

当某个第三方依赖没有合适的系统包,ROS2 中常用 vendor 包管理。 vendor 包本质上是把第三方依赖包装成可由 colcon 构建和导出的包。

# 示例:vendor 包内部下载或查找第三方库,并向 ROS2 工作区导出。
cmake_minimum_required(VERSION 3.22)
project(example_solver_vendor)

find_package(ament_cmake REQUIRED)

# 真实项目可在这里调用 ExternalProject 或优先查找系统安装。
add_library(example_solver_vendor INTERFACE)

target_include_directories(example_solver_vendor
    INTERFACE
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)

# 导出 target,供其他 ROS2 包通过 ament 使用。
install(TARGETS example_solver_vendor
    EXPORT export_example_solver_vendor
)

ament_export_targets(export_example_solver_vendor HAS_LIBRARY_TARGET)
ament_package()

vendor 包的价值是统一入口。 其他包不需要知道依赖来自 apt、源码还是预编译二进制。 它们只依赖一个稳定的 CMake/ament target。

练习

  1. 设计一个三层 workspace,说明每层放哪些包、加载顺序是什么。
  2. --packages-up-to 构建某个 ROS2 节点,并解释 colcon 如何找到依赖闭包。
  3. 为一个没有系统包的第三方库设计 vendor 包接口,不把下载细节暴露给下游。

7.12 CI 不是“跑命令”,而是工程契约 ⭐⭐⭐

这一节解决的问题是:CI 应该如何覆盖构建矩阵、安装导出、依赖边界和工作区组合。

CI 的四类问题

CI 不只是跑 cmake --build。 它应该回答四类问题:

问题 典型检查
能否从干净环境构建 Docker、apt/rosdep、CMake configure
能否按接口复用 install tree consumer test
是否破坏依赖边界 禁止核心库 include ROS2、clang-tidy
是否保持运行质量 ctest、sanitizer、benchmark、回放测试

本地机器上成功构建的可信度有限。 本地环境可能残留旧安装、手工环境变量、系统包和未提交文件。 CI 的价值在于从干净环境重建事实。

CMake 项目的 CI 矩阵

name: cmake-quality

on:
  push:
  pull_request:

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        build_type: [Debug, RelWithDebInfo]
        compiler: [gcc, clang]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install tools
        # 安装 Ninja 和编译器;真实项目可改为固定 Docker 镜像。
        run: sudo apt-get update && sudo apt-get install -y ninja-build clang

      - name: Configure
        # 使用矩阵变量构建不同编译器和构建类型组合。
        run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}

      - name: Build
        # 并行构建全部默认 target。
        run: cmake --build build --parallel

      - name: Test
        # 输出失败详情,避免只看到一个失败编号。
        run: ctest --test-dir build --output-on-failure

矩阵不是越多越好。 每增加一个维度,CI 时间和维护成本都会上升。 常见策略是每次提交跑少量关键组合,每日或发布前跑完整组合。

ROS2 工作区 CI

name: ros2-workspace

on:
  push:
  pull_request:

jobs:
  colcon:
    runs-on: ubuntu-latest
    container:
      image: ros:jazzy-ros-base
    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        # rosdep 根据 package.xml 安装系统依赖。
        run: |
          apt-get update
          rosdep update
          rosdep install --from-paths src --ignore-src -y

      - name: Build
        # 加载 ROS2 环境后构建整个 workspace。
        run: |
          . /opt/ros/jazzy/setup.sh
          colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo

      - name: Test
        # colcon test 汇总每个包的测试结果。
        run: |
          . /opt/ros/jazzy/setup.sh
          . install/setup.sh
          colcon test --event-handlers console_direct+
          colcon test-result --verbose

这里使用容器是为了固定 ROS2 发行版。 如果依赖 GPU、仿真器或私有 apt 源,CI 还需要额外镜像管理。 但原则不变:依赖来源要版本化,构建命令要可重复。

安装导出 CI

# 从源码构建并安装到临时前缀。
cmake -S . -B build -DCMAKE_INSTALL_PREFIX=/tmp/install
cmake --build build --parallel
cmake --install build

# 在独立 consumer 中验证 find_package。
cmake -S examples/consumer -B /tmp/consumer_build \
  -DCMAKE_PREFIX_PATH=/tmp/install
cmake --build /tmp/consumer_build --parallel

这个阶段应该进入 CI。 因为它能发现源码树内测试发现不了的问题。 尤其是忘记安装头文件、忘记导出依赖、写死构建路径。

依赖边界 CI

# 禁止核心库公开头文件包含 ROS2。
if rg -n '#include <rclcpp|#include "rclcpp' modules/controller_core/include; then
  echo "controller_core public headers must not include ROS2 headers"
  exit 1
fi

# 禁止求解器第三方类型出现在核心公开接口中。
if rg -n 'Osqp|proxsuite|qpOASES' modules/controller_core/include; then
  echo "solver implementation types must stay behind solver_bridge"
  exit 1
fi

这种检查很朴素,但非常有效。 架构规则如果只存在口头约定里,迟早会被赶进度时打破。 把规则放进 CI,相当于把项目边界写成了可执行文档。

练习

  1. 给一个普通 CMake 库增加 install tree consumer CI 阶段。
  2. 为 ROS2 workspace 设计“每次提交”和“每日全量”两套 CI 检查。
  3. 写一个依赖边界脚本,禁止 modules/math_core 依赖任何 ROS2 包。

7.13 大型工程的演进策略 ⭐⭐

这一节解决的问题是:项目从小到大时,如何逐步引入构建规范,而不是一次性推倒重来。

不要一开始就追求最复杂形态

一个教学项目或原型项目可以从很简单的结构开始:

  1. 一个核心库。
  2. 一个可执行文件。
  3. 一组 gtest。
  4. 一个 CMake preset。
  5. 一个 CI job。

当出现真实痛点后,再加入更强的结构。 例如下游要复用时补安装导出。 编译变慢时治理头文件。 求解器要替换时引入适配层。 多机器人型号并存时引入配置包和平台层。

工程规范的目标是降低复杂度,而不是展示复杂度。 如果一个规则无法减少错误、减少耦合或提高可重复性,它就应该暂缓。

演进路线

阶段 主要目标 关键动作
原型 快速验证算法 单库、单可执行、少量测试
可复用 让下游链接核心能力 target 化、安装导出、consumer 测试
多包 拆分 ROS2 节点和核心库 colcon、ament、overlay
工业化 稳定交付和多人协作 Docker、CI、sanitizer、边界检查
平台化 多机器人和多任务共享 平台 underlay、vendor 包、版本策略

这个路线不是线性的。 有些项目很早就需要 Docker。 有些内部研究代码永远不需要正式发布包。 关键是让每个新增机制服务于明确问题。

改造旧式 CMake 的顺序

# 改造顺序示意:先建立 target,再处理安装导出,最后治理工作区。
1. 把全局 include_directories 改为 target_include_directories
2. 把全局 add_definitions 改为 target_compile_definitions
3. 把库依赖写进 target_link_libraries
4. 区分 PUBLIC、PRIVATE、INTERFACE
5. 为核心库补 install/export
6. 添加 find_package consumer 示例
7. 加入 CI 和依赖边界检查

这个顺序的好处是每一步都能单独验证。 不要第一步就重排所有目录。 目录变化会制造大量无关 diff,掩盖真正的构建逻辑变化。

最小验收清单

项目 通过标准
本地构建 cmake --preset ...colcon build 一条命令成功
测试 ctestcolcon test 能在干净构建目录运行
安装 cmake --install 后存在头文件、库和 Config 文件
下游复用 独立 consumer 只用 find_package 成功链接
依赖边界 核心库不 include ROS2、硬件驱动和求解器实现类型
CI 至少覆盖配置、编译、测试和安装导出
文档 README 或章节说明如何构建、测试和使用

综合练习

  1. 从零设计一个 robot_cpp_workspace,包含 math_corerobot_modelcontroller_corecontroller_ros 四个包。
  2. 要求 math_core 是普通 CMake 库,controller_ros 是 ROS2 包。
  3. 要求 controller_core 能被 gtest、benchmark 和 ROS2 节点同时链接。
  4. controller_core 写 install/export 和 consumer 示例。
  5. 为 CI 设计四个阶段:普通构建、安装导出、依赖边界、ROS2 workspace 构建。

7.14 colcon 与 CMake 的关系深入解析 ⭐⭐

这一节解决的问题是:colcon 和 CMake 不是替代关系,而是不同层级的工具,理解它们的分工是大型 ROS2 项目的基础。

colcon 不构建代码

这一点必须首先明确。colcon 本身不调用编译器、不处理头文件、不链接库。它做的事情是:

  1. 扫描 workspace 中的包。
  2. 解析 package.xml 得到包之间的依赖关系。
  3. 按拓扑排序确定构建顺序。
  4. 依次调用每个包的构建系统(CMake、setuptools 等)。
  5. 把每个包的安装产物放到统一的 install 目录。

可以把 colcon 类比为建筑工地的总调度。它不砌砖(编译)、不焊钢筋(链接),而是决定"先建地基再建墙再装门窗"的顺序。每栋建筑(包)由自己的施工队(CMake)完成实际工作。

colcon 视角:
  包 A (无依赖) → 先构建
  包 B (依赖 A) → A 完成后构建
  包 C (依赖 A, B) → A 和 B 都完成后构建

CMake 视角(包 B 内部):
  find_package(A)  → 找到包 A 的导出 target
  add_library(B_lib ...)
  target_link_libraries(B_lib A::core)
  ...

colcon 传递给 CMake 的信息

colcon 通过以下机制影响 CMake 的行为。

机制 作用 示例
CMAKE_PREFIX_PATH 告诉 CMake 去哪里找已安装的包 包 A 安装后,包 B 能 find_package(A)
--cmake-args 传递 CMake 变量 -DCMAKE_BUILD_TYPE=RelWithDebInfo
--cmake-clean-cache 清除 CMake 缓存 依赖变更后强制重新配置
install 目录结构 统一安装前缀 所有包的头文件和库在同一层级

反事实地看,如果没有 colcon,你需要手动为每个包运行 CMake,并且手动设置 CMAKE_PREFIX_PATH 使后续包能找到前序包。对于 5 个包这还可以,对于 50 个包就完全不可行了。colcon 自动化了这个拓扑排序和路径传递过程。

ament_cmake vs ament_python vs 纯 CMake

ROS2 包可以使用三种构建类型。

构建类型 适合 特征 注意
ament_cmake C++ ROS2 包 提供 ROS2 导出宏、测试宏 最常见的 ROS2 C++ 包类型
ament_python 纯 Python ROS2 包 使用 setuptools 不适合 C++ 混合
纯 CMake 核心 C++ 库 不依赖 ament 可在非 ROS2 环境复用

本质洞察:ament_cmake 和纯 CMake 不是对立的选择,而是分层使用的工具。 核心算法库用纯 CMake 编写,使其可以在 gtest、benchmark 和非 ROS2 仿真中独立使用。 ROS2 节点用 ament_cmake 编写,作为核心库的薄封装。 colcon 负责协调两者的构建顺序。

ament_target_dependencies 是 ament_cmake 提供的便利宏。它会自动添加 find_package 找到的所有 include 路径和链接库。

# ament 便利宏:一行搞定 include 和链接。
ament_target_dependencies(my_node rclcpp std_msgs)

# 等价的纯 CMake 写法(更显式,推荐核心库使用)。
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
target_link_libraries(my_node
    PRIVATE
        rclcpp::rclcpp
        ${std_msgs_TARGETS}
)

对于核心库,推荐使用显式的 target_link_libraries,因为它更清楚地表达 PUBLIC/PRIVATE 边界。对于 ROS2 节点入口(通常所有依赖都是 PRIVATE),ament_target_dependencies 更简洁。

⚠️ 常见陷阱

类型 错误做法 现象 根本原因 正确做法
概念 认为 colcon 就是 CMake 的替代品 试图在 colcon 命令中写编译逻辑 混淆元构建和实际构建 colcon 只管调度,CMake 管编译
编程 纯 CMake 库的 CMakeLists 中写 ament_package() 非 ROS2 环境无法构建 核心库不应依赖 ament 核心库用纯 CMake
工程 colcon build 后不 source install/setup.bash 后续包找不到前序包 环境未更新 每次构建后 source
思维 不写 package.xml 直接用 CMake 组织 ROS2 包 colcon 无法解析依赖顺序 colcon 依赖 package.xml 始终维护 package.xml

练习

  1. 创建两个包:纯 CMake 核心库和 ament_cmake ROS2 节点,用 colcon 构建它们,观察构建顺序。
  2. 故意删除包 B 的 package.xml 中对包 A 的依赖声明,观察 colcon 构建顺序的变化。
  3. 解释为什么 colcon build --cmake-args -DCMAKE_BUILD_TYPE=Debug 能影响所有 CMake 包的构建类型。

7.15 交叉编译:Jetson/ARM 完整流程 ⭐⭐⭐

这一节解决的问题是:如何在 x86 开发机上编译能在 Jetson 或 ARM 板上运行的 ROS2 项目。

为什么需要交叉编译

机器人的计算平台常常不是 x86 桌面机。Jetson(ARM64)、树莓派(ARM64)、工业嵌入式板卡(ARM/RISC-V)都是常见选择。在这些平台上直接编译大型 C++ 项目有两个问题:编译速度慢(嵌入式处理器计算力有限)、编译环境受限(内存和磁盘空间可能不足)。

交叉编译是在强力 x86 机器上生成目标平台可执行文件的标准方法。可以类比为在大工厂(开发机)制造零件,然后把成品运到工地(嵌入式板卡)安装。

CMake Toolchain File

交叉编译的核心是 CMake toolchain file。它告诉 CMake 使用哪个编译器、链接器、系统根路径和目标架构。

# aarch64_toolchain.cmake
# 交叉编译工具链文件:在 x86 主机上编译 ARM64 二进制。

set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)

# 交叉编译器路径,通常由 NVIDIA JetPack 或 apt 安装。
set(CMAKE_C_COMPILER /usr/bin/aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/aarch64-linux-gnu-g++)

# sysroot 包含目标平台的头文件和库。
# 可以从 Jetson 上拷贝,或使用 NVIDIA 提供的 SDK。
set(CMAKE_SYSROOT /opt/jetson_sysroot)

# 查找库和头文件时只在 sysroot 中搜索。
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)

使用 toolchain file 构建:

cmake -S . -B build_jetson \
  -DCMAKE_TOOLCHAIN_FILE=cmake/aarch64_toolchain.cmake \
  -DCMAKE_BUILD_TYPE=RelWithDebInfo

cmake --build build_jetson --parallel

交叉编译 ROS2 项目

交叉编译 ROS2 项目比纯 CMake 项目更复杂,因为需要处理 ROS2 依赖。

步骤 内容 工具
1. 准备 sysroot 目标平台的系统库和 ROS2 安装 rsync/Docker
2. 配置 toolchain 编译器和 sysroot 路径 CMake toolchain file
3. 构建依赖 在目标架构上构建或获取 ROS2 依赖 cross-compile colcon 或 QEMU
4. 构建项目 使用 colcon + toolchain 构建 colcon + --cmake-args
5. 部署 把安装目录拷贝到目标设备 rsync/scp
6. 测试 在目标设备上运行 ssh + 运行测试

Docker + QEMU 方案

另一种常见方案是使用 Docker 多架构构建。Docker 配合 QEMU 用户态模拟,可以在 x86 机器上运行 ARM64 容器。

# 使用 ARM64 基础镜像,Docker + QEMU 会自动模拟。
FROM arm64v8/ros:jazzy-ros-base

# 在模拟的 ARM64 环境中安装依赖和构建。
# 速度比原生 ARM 编译慢,但比交叉编译的环境准备简单。
RUN apt-get update && apt-get install -y \
    build-essential \
    cmake \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /ws
COPY . /ws/src/my_package
RUN . /opt/ros/jazzy/setup.sh && \
    colcon build --symlink-install
# 在 x86 主机上启用 QEMU 多架构支持。
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

# 构建 ARM64 镜像。
docker buildx build --platform linux/arm64 -t my_robot:arm64 .

Jetson 部署的特殊考虑

Jetson 平台有 CUDA 和 TensorRT 的特殊需求。

特殊项 说明 处理方式
CUDA 版本 Jetson 的 CUDA 版本与桌面 GPU 不同 使用 JetPack 对应版本
TensorRT engine engine 与 GPU 架构绑定 在 Jetson 上构建 engine
GPU 架构 Jetson 使用集成 GPU(如 Orin) 设置正确的 -arch=sm_87
共享内存 Jetson 的 CPU 和 GPU 共享物理内存 统一内存的行为不同于独立 GPU

⚠️ 常见陷阱

类型 错误做法 现象 根本原因 正确做法
编程 sysroot 中库版本与目标设备不一致 运行时链接错误 sysroot 过期 定期同步 sysroot
编程 交叉编译时使用 find_program 找主机工具 找到 x86 工具 默认搜索主机路径 在 toolchain 中设置 CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER
概念 认为交叉编译只是换个编译器 依赖找不到 sysroot 和 prefix path 缺失 完整配置 sysroot 和依赖路径
工程 桌面 GPU 构建的 TensorRT engine 部署到 Jetson 加载失败 engine 与 GPU 架构绑定 在目标设备构建 engine

练习

  1. 为一个纯 CMake 库编写 ARM64 toolchain file,在 x86 主机上交叉编译。
  2. 使用 Docker + QEMU 构建一个 ROS2 包的 ARM64 镜像。
  3. 列出从 x86 开发到 Jetson 部署的完整流程,包括编译、打包、传输和验证步骤。

7.16 Docker 容器化部署 ⭐⭐

这一节解决的问题是:如何用 Docker 让机器人软件的部署可重复、可回滚、不受宿主环境影响。

Docker 在机器人中的价值

机器人软件部署面临的核心问题是环境一致性。开发机上的 Ubuntu 版本、ROS2 版本、CUDA 版本、Python 包版本可能与机器人上板不同。Docker 把应用和其所有依赖打包成一个独立的运行环境。

传统部署问题 Docker 解决方式
"在我机器上能跑" 容器包含完整环境
系统升级破坏依赖 容器与宿主隔离
多版本 ROS2 共存 不同容器用不同 ROS2
回滚到上一个版本 切换镜像 tag
新机器人上板部署 拉取镜像即可运行

多阶段构建

生产 Docker 镜像应使用多阶段构建,把编译工具和运行依赖分离。

# === 阶段 1:构建阶段 ===
FROM ros:jazzy-ros-base AS builder

# 安装构建依赖。
RUN apt-get update && apt-get install -y \
    build-essential \
    cmake \
    ninja-build \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /ws
COPY . /ws/src/my_robot

# 安装 ROS2 包依赖。
RUN . /opt/ros/jazzy/setup.sh && \
    apt-get update && \
    rosdep install --from-paths src --ignore-src -y && \
    rm -rf /var/lib/apt/lists/*

# 构建。
RUN . /opt/ros/jazzy/setup.sh && \
    colcon build --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo

# === 阶段 2:运行阶段 ===
FROM ros:jazzy-ros-base AS runtime

# 只安装运行时依赖,不安装编译器。
RUN apt-get update && apt-get install -y \
    ros-jazzy-ros2launch \
    && rm -rf /var/lib/apt/lists/*

# 从构建阶段复制安装产物。
COPY --from=builder /ws/install /ws/install

# 设置入口点。
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

入口点脚本:

#!/bin/bash
# entrypoint.sh:加载 ROS2 环境并执行传入的命令。
set -e
source /opt/ros/jazzy/setup.bash
source /ws/install/setup.bash
exec "$@"

GPU 容器

需要 GPU 的容器使用 NVIDIA Container Toolkit。

# 运行带 GPU 支持的 ROS2 容器。
docker run --gpus all \
    --runtime=nvidia \
    -e NVIDIA_VISIBLE_DEVICES=all \
    -e NVIDIA_DRIVER_CAPABILITIES=compute,utility \
    my_robot:latest \
    ros2 launch my_robot bringup.launch.py

Docker Compose 编排多容器

一个机器人系统可能需要多个容器:感知、控制、可视化、日志。

# docker-compose.yml
version: "3.8"
services:
  perception:
    image: my_robot:perception
    runtime: nvidia
    network_mode: host
    environment:
      - RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
    command: ros2 launch perception bringup.launch.py

  control:
    image: my_robot:control
    network_mode: host
    privileged: true  # 实时线程可能需要特权
    environment:
      - RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
    command: ros2 launch control bringup.launch.py

  visualization:
    image: my_robot:viz
    network_mode: host
    environment:
      - DISPLAY=${DISPLAY}
    volumes:
      - /tmp/.X11-unix:/tmp/.X11-unix
    command: ros2 launch viz rviz.launch.py

⚠️ 常见陷阱

类型 错误做法 现象 根本原因 正确做法
编程 构建和运行镜像不分离 镜像体积 5GB+ 编译工具留在运行镜像 多阶段构建
工程 容器内 RMW 和宿主 RMW 不一致 节点无法通信 RMW 必须匹配 统一 RMW 配置
概念 Docker 解决所有部署问题 硬件访问和实时性受限 容器增加了一层抽象 评估实时和硬件需求
思维 每次修改都重新构建完整镜像 迭代慢 未利用 Docker 层缓存 合理分层,把频繁变化的层放在最后

练习

  1. 为一个 ROS2 包编写多阶段 Dockerfile,确保运行镜像不包含编译工具。
  2. 使用 Docker Compose 编排两个 ROS2 节点容器,验证它们能通过 topic 通信。
  3. 比较 Docker 容器内和宿主环境直接运行的 ROS2 节点延迟差异。

7.17 vcpkg/conan 包管理器集成 ⭐⭐⭐

这一节解决的问题是:当 apt 和 rosdep 无法提供需要的库版本时,如何用 C++ 包管理器精确控制依赖。

C++ 包管理器解决什么问题

ROS2 项目的依赖来源通常有三类。

来源 适合 局限
apt/rosdep ROS2 生态包、系统库 版本受发行版限制
FetchContent 小型 CMake 库 大型依赖编译时间长
vcpkg/conan 跨平台、版本精确控制 需要额外学习和集成

当你需要特定版本的 OSQP、ProxSuite、fmt、spdlog、nlohmann_json、或任何不在 ROS2 生态中的库时,包管理器是最可靠的选择。

vcpkg 集成

vcpkg 是 Microsoft 维护的 C++ 包管理器。它与 CMake 集成紧密。

# 安装 vcpkg。
git clone https://github.com/microsoft/vcpkg.git
cd vcpkg && ./bootstrap-vcpkg.sh

# 安装依赖。
./vcpkg install fmt spdlog nlohmann-json osqp-eigen

CMake 集成:

# 通过 toolchain 文件集成 vcpkg。
# 在 CMakePresets.json 或命令行中指定。
cmake -S . -B build \
  -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake

# 之后正常使用 find_package,vcpkg 会自动提供。
find_package(fmt REQUIRED)
find_package(spdlog REQUIRED)
find_package(OsqpEigen REQUIRED)

vcpkg.json 清单文件

{
  "name": "robot-controller",
  "version": "0.1.0",
  "dependencies": [
    "fmt",
    "spdlog",
    {
      "name": "osqp-eigen",
      "version>=": "0.8.0"
    },
    "nlohmann-json"
  ]
}

清单文件的价值是把依赖版本写入版本控制。团队成员和 CI 都能获得相同版本的依赖。

conan 集成

conan 是另一个流行的 C++ 包管理器,在工业项目中使用广泛。

# conanfile.txt
[requires]
fmt/10.2.1
spdlog/1.13.0
nlohmann_json/3.11.3

[generators]
CMakeDeps
CMakeToolchain
# 安装依赖到本地缓存。
conan install . --output-folder=build --build=missing

# 使用 conan 生成的工具链文件构建。
cmake -S . -B build \
  -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake

与 ROS2 workspace 的共存

vcpkg/conan 和 colcon 可以共存,但需要注意优先级。

CMake 搜索顺序(由 CMAKE_PREFIX_PATH 决定):
  1. colcon 安装的 ROS2 包 → source install/setup.bash 设置
  2. vcpkg/conan 安装的包 → toolchain file 设置
  3. 系统 apt 安装的包 → /usr/lib, /usr/include

冲突最常发生在两个来源都提供同一个库但版本不同时。例如 apt 安装了旧版 fmt,vcpkg 安装了新版 fmt。CMake 可能找到错误的版本。解决方法是显式指定搜索路径或在 CMakeLists 中用 find_packagePATHS 参数。

⚠️ 常见陷阱

类型 错误做法 现象 根本原因 正确做法
编程 vcpkg 和 apt 同时提供同一库 版本冲突 搜索路径优先级不明 统一一个来源,或显式指定路径
工程 不锁定 vcpkg 版本 CI 上依赖版本漂移 vcpkg 仓库持续更新 使用 vcpkg.json + baseline 锁定
概念 认为包管理器替代构建系统 混淆职责 vcpkg/conan 只管依赖获取 CMake 仍然管编译和链接
思维 每个依赖都用包管理器 不必要的复杂度 apt 能满足的就不需要 apt 优先,不足时用包管理器

练习

  1. 用 vcpkg 安装 fmt 和 spdlog,在一个纯 CMake 项目中使用 find_package 链接它们。
  2. 写一个 vcpkg.json 清单文件,锁定至少三个依赖的版本。
  3. 在一个同时使用 colcon 和 vcpkg 的项目中,解释 CMAKE_PREFIX_PATH 的搜索顺序。

7.18 CI/CD 流水线设计 ⭐⭐⭐

这一节解决的问题是:如何设计一条覆盖构建、测试、安全和部署的完整 CI/CD 流水线。

CI 不是"能编过就行"

前面章节已经介绍了基本的 CI 检查。这一节从工程实践角度设计一条完整的流水线。

CI/CD 流水线阶段:
  ┌─────────┐    ┌──────────┐    ┌───────────┐    ┌──────────┐
  │ 格式检查  │ →  │ 编译构建  │ →  │ 单元测试  │ →  │ 静态分析  │
  └─────────┘    └──────────┘    └───────────┘    └──────────┘
       ↓                                                ↓
  ┌──────────┐    ┌──────────┐    ┌───────────┐    ┌──────────┐
  │ 安装导出  │ →  │ 集成测试  │ →  │ Sanitizer │ →  │ 构建镜像  │
  └──────────┘    └──────────┘    └───────────┘    └──────────┘

每个阶段的职责

阶段 检查内容 失败含义 工具
格式检查 代码风格一致 提交前未格式化 clang-format, cmake-format
编译构建 能否在干净环境编译 依赖缺失或语法错误 CMake + Ninja/Make
单元测试 功能正确性 逻辑错误 gtest, pytest
静态分析 潜在 bug 和代码质量 未初始化、越界、类型问题 clang-tidy, cppcheck
安装导出 下游能否 find_package 导出规则不完整 consumer 项目测试
集成测试 模块间交互 接口不兼容 launch_testing, ros2 test
Sanitizer 内存安全和线程安全 UB、数据竞争 ASan, TSan, UBSan
构建镜像 部署镜像可用 Docker 配置错误 Docker buildx

GitHub Actions 完整示例

name: robot-ci

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check C++ format
        run: |
          find src include -name '*.cpp' -o -name '*.hpp' | \
            xargs clang-format --dry-run --Werror

  build-and-test:
    needs: format
    runs-on: ubuntu-latest
    container:
      image: ros:jazzy-ros-base
    strategy:
      matrix:
        build_type: [Debug, RelWithDebInfo]
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: |
          apt-get update
          rosdep update
          rosdep install --from-paths src --ignore-src -y
      - name: Build
        run: |
          . /opt/ros/jazzy/setup.sh
          colcon build --cmake-args \
            -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \
            -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - name: Test
        run: |
          . /opt/ros/jazzy/setup.sh
          . install/setup.sh
          colcon test --event-handlers console_direct+
          colcon test-result --verbose

  sanitizer:
    needs: format
    runs-on: ubuntu-latest
    container:
      image: ros:jazzy-ros-base
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: |
          apt-get update
          rosdep update
          rosdep install --from-paths src --ignore-src -y
      - name: Build with ASan
        run: |
          . /opt/ros/jazzy/setup.sh
          colcon build --cmake-args \
            -DCMAKE_BUILD_TYPE=Debug \
            -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
            -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
      - name: Test with ASan
        run: |
          . /opt/ros/jazzy/setup.sh
          . install/setup.sh
          colcon test --event-handlers console_direct+

  install-export:
    needs: build-and-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build and install
        run: |
          cmake -S modules/controller_core -B build \
            -DCMAKE_INSTALL_PREFIX=/tmp/install
          cmake --build build --parallel
          cmake --install build
      - name: Consumer test
        run: |
          cmake -S examples/consumer -B /tmp/consumer_build \
            -DCMAKE_PREFIX_PATH=/tmp/install
          cmake --build /tmp/consumer_build

GitLab CI 对比

GitLab CI 使用 .gitlab-ci.yml,语法不同但原则相同。

stages:
  - format
  - build
  - test
  - deploy

format:
  stage: format
  image: ubuntu:24.04
  script:
    - apt-get update && apt-get install -y clang-format
    - find src include -name '*.cpp' -o -name '*.hpp' |
        xargs clang-format --dry-run --Werror

build:
  stage: build
  image: ros:jazzy-ros-base
  script:
    - . /opt/ros/jazzy/setup.sh
    - colcon build --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo
  artifacts:
    paths:
      - install/

CI 策略:每次提交 vs 每日全量

检查类型 每次提交 每日全量 发布前
格式
编译(默认配置)
单元测试
编译(矩阵)
Sanitizer
安装导出
集成测试
性能基准

⚠️ 常见陷阱

类型 错误做法 现象 根本原因 正确做法
工程 CI 只跑编译不跑测试 bug 进入主分支 编译通过不等于正确 至少跑单元测试
工程 所有检查放一个 job CI 反馈慢 串行执行所有步骤 并行独立阶段
概念 CI 通过就能部署 部署环境不同 CI 和生产环境差异 用与部署一致的镜像跑 CI
思维 CI 配置写了就不维护 CI 逐渐变慢或失效 依赖更新和环境变化 定期审查 CI 配置

练习

  1. 为一个 ROS2 项目设计完整的 GitHub Actions CI,包括格式、编译、测试和 sanitizer 四个阶段。
  2. 配置 ASan(AddressSanitizer)构建并运行测试,故意引入一个越界访问验证 ASan 能否捕获。
  3. 设计"每次提交"和"每日全量"两套 CI 检查,解释为什么 sanitizer 不放在每次提交中。

7.19 工程案例:多包工作区组织最佳实践 ⭐⭐⭐

这一节解决的问题是:一个完整的机器人控制项目如何从零组织包结构、依赖边界和构建流程。

案例场景

一个四足机器人项目需要包含以下功能模块。

模块列表:
  - 数学工具(线性代数、旋转表示)
  - 机器人模型(URDF 加载、运动学、动力学)
  - QP 求解器适配(OSQP、ProxSuite 可切换)
  - 控制核心(MPC、WBC)
  - 状态估计(IMU 融合、接触检测)
  - ROS2 节点(消息转换、参数管理、launch)
  - 仿真桥(连接 MuJoCo 或 Isaac Sim)
  - 工具(离线轨迹评估、日志转换)

推荐包结构

quadruped_ws/
  modules/                          # 纯 CMake 库(不依赖 ROS2)
    math_core/
      include/math_core/            # 公开头文件
      src/                          # 实现文件
      test/                         # gtest 测试
      CMakeLists.txt                # 纯 CMake
    robot_model/
      include/robot_model/
      src/
      test/
      CMakeLists.txt
    qp_solver_bridge/
      include/qp_solver_bridge/     # 只暴露抽象接口
      src/                          # OSQP/ProxSuite 适配
      CMakeLists.txt
    controller_core/
      include/controller_core/
      src/
      test/
      CMakeLists.txt
    estimator_core/
      include/estimator_core/
      src/
      test/
      CMakeLists.txt

  ros2_ws/src/                      # ROS2 包(薄封装)
    controller_ros/
      src/controller_node.cpp       # 消息转换 + 节点生命周期
      launch/
      config/
      package.xml
      CMakeLists.txt                # ament_cmake
    estimator_ros/
      ...
    bringup/
      launch/                       # 总启动文件
      config/                       # 机器人型号参数
      package.xml
      CMakeLists.txt

  sim_bridge/                       # 仿真器适配
    mujoco_bridge/
    isaac_bridge/

  tools/                            # 离线工具
    trajectory_eval/
    bag_converter/

  docker/                           # Docker 镜像定义
    Dockerfile.dev
    Dockerfile.deploy
    docker-compose.yml

  cmake/                            # 共享 CMake 模块
    aarch64_toolchain.cmake
    project_warnings.cmake

  ci/                               # CI 配置
    .github/workflows/ci.yml

依赖关系图

math_core ← robot_model ← controller_core ← controller_ros
                ↑               ↑
            estimator_core ── estimator_ros
        qp_solver_bridge

箭头方向:被依赖 ← 依赖者

注意以下不允许出现的依赖方向: - math_core 不能依赖 robot_model(基础层不依赖领域层) - controller_core 不能依赖 rclcpp(核心层不依赖适配层) - qp_solver_bridge 的 public header 不能暴露 OSQP 类型(适配层隐藏实现)

构建流程

# 第一步:构建纯 CMake 模块(不需要 ROS2 环境)
cmake -S modules -B modules/build \
  --preset linux-relwithdebinfo
cmake --build modules/build --parallel
cmake --install modules/build --prefix /ws/modules_install

# 第二步:构建 ROS2 包(加载 ROS2 环境和纯 CMake 模块)
source /opt/ros/jazzy/setup.bash
export CMAKE_PREFIX_PATH=/ws/modules_install:$CMAKE_PREFIX_PATH
cd ros2_ws
colcon build --symlink-install \
  --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo
source install/setup.bash

测试策略

测试层级 范围 工具 频率
纯 C++ 单元测试 math_core, controller_core gtest 每次提交
ROS2 节点测试 controller_ros launch_testing 每次提交
集成测试 仿真环境中完整系统 MuJoCo + pytest 每日
Sanitizer 内存和线程安全 ASan, TSan 每日
安装导出测试 下游复用 consumer 项目 每日
性能基准 控制频率和延迟 benchmark 发布前

本案例中各章节知识的综合运用

本章知识 在案例中的体现
target-centric CMake(7.1) 每个模块的 PUBLIC/PRIVATE 依赖
安装导出(7.2, 7.9) 纯 CMake 模块可被 ROS2 包 find_package
包管理(7.3) QP 求解器通过 vcpkg 获取
colcon/ament(7.4, 7.14) ROS2 包用 ament_cmake
Docker/CI(7.5, 7.16, 7.18) 多阶段构建 + 完整 CI
架构边界(7.6, 7.7) 四层分离
Target 模式(7.8) 接口库、对象库、薄可执行
依赖边界(7.10) pimpl 隐藏求解器
workspace 组织(7.11) 三层 overlay
交叉编译(7.15) Jetson 部署用 toolchain

⚠️ 常见陷阱

类型 错误做法 现象 根本原因 正确做法
工程 所有代码放一个 ROS2 包 核心算法无法独立测试 没有分层 核心逻辑分离为纯 CMake 库
编程 controller_core include rclcpp 非 ROS2 环境无法编译 核心层依赖了适配层 消息转换放在 ROS2 节点中
概念 求解器类型暴露在 public header 换求解器影响所有下游 依赖边界泄漏 pimpl 或接口类隔离
思维 先写代码再想包结构 重构成本高 依赖方向在代码中固化 先设计依赖图再写代码

练习

  1. (跨章综合题)从零设计一个机械臂控制项目的包结构,包含运动学库、轨迹规划库、MoveIt2 桥接和 ROS2 节点。要求画出四层依赖图,并为核心库写 install/export 规则。
  2. 为案例中的 qp_solver_bridge 实现 pimpl 隔离,使 public header 不暴露 OSQP 或 ProxSuite 类型。
  3. 编写 CI 配置,覆盖纯 CMake 模块构建、ROS2 workspace 构建、安装导出测试和依赖边界检查四个阶段。

本章小结

知识点 关键结论 工程动作
target-centric CMake target 表达真实依赖 避免全局 include 和 flags
安装导出 本地能编不等于可复用 install/export targets
依赖管理 来源决定可重复性 rosdep、FetchContent、包管理器分工
colcon/ament ROS2 多包构建 overlay 和薄 ROS 封装
Presets/Docker/CI 构建配置要版本化 自动化编译测试
架构边界 核心库不应依赖 ROS2 消息转换放在边缘

累积项目:机器人 C++ 工程骨架

本章新增模块是 robot_cpp_workspace

阶段 1:建立 math_coreoptimization_bridgecontroller_core 三个普通 CMake 库。 阶段 2:为每个库写 target 级依赖、安装和导出规则。 阶段 3:建立 controller_ros ROS2 包,只做消息转换和节点生命周期。 阶段 4:添加 CMake Presets、Dockerfile 和 CI。 阶段 5:加入 include-what-you-use 或 clang-tidy,保持头文件依赖最小。

延伸阅读

资料 难度 阅读目的
Modern CMake 文档 ⭐⭐ 学习 target-centric 思维
Professional CMake ⭐⭐⭐ 深入安装导出和工具链
ROS2 ament_cmake 文档 ⭐⭐ 学习 ROS2 包构建
colcon 文档 理解 workspace 和 overlay
industrial_ci 文档 ⭐⭐ 快速搭建 ROS2 CI

故障排查手册

症状 可能原因 排查步骤 处理
本地能编,安装后下游不能编 依赖未 PUBLIC 传播 查看 public header 类型 修正 target_link_libraries
改一个头文件重编全项目 include 过重 用 include graph 或 iwyu 前向声明和 pimpl
colcon 找到旧包 overlay source 顺序错误 打印 AMENT_PREFIX_PATH 重新 source 正确 workspace
CI 编译失败本地通过 本地隐式依赖 清理 build、用容器复现 补 package.xml/CMake 依赖
SIMD 相关崩溃 编译选项不一致 检查 target flags 统一工具链或目标属性
ROS2 核心算法难测试 核心依赖 rclcpp 查看 include 和构造依赖 拆出纯 C++ 核心库