构建系统与大型 C++ 项目组织¶
本章定位:把“能在本机编过”提升为“依赖边界清楚、目标可复用、安装可导出、CI 可重复、多人协作不互相破坏”的大型机器人 C++ 工程能力。 构建系统不是附属脚本,而是模块边界、ABI、依赖传播和交付形态的工程说明书。
前置自测¶
- CMake 中
PUBLIC、PRIVATE、INTERFACE的传播语义是什么? - 为什么顶层全局
include_directories()容易污染大型项目? - colcon overlay workspace 的工作原理是什么?
rosdep、vcpkg、conan 分别解决什么问题?- 一个库暴露 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)
问题包括:
- 某个目标可以不声明依赖却编过。
- 换求解器时影响全项目。
- 编译选项污染第三方库。
- 头文件边界失真,安装后下游找不到依赖。
PUBLIC/PRIVATE/INTERFACE 的判断¶
| 依赖出现在何处 | CMake 关键字 |
|---|---|
只在 .cpp 使用 |
PRIVATE |
| 出现在 public header 类型或函数签名 | PUBLIC |
| 当前 target 不编译源码,只向下游传递 | INTERFACE |
| 头文件模板中使用 | PUBLIC |
| 编译选项影响 ABI | 通常 PUBLIC 或统一工具链 |
本质洞察:构建边界和代码边界必须一致。 如果头文件承诺了某个类型,CMake 就必须把该类型的依赖传播给下游。 否则项目只是在源码树内部“碰巧能编过”。
练习¶
- 判断一个暴露
Eigen::Ref<const Eigen::VectorXd>的库应如何链接 Eigen。 - 把一个使用全局
include_directories的 CMakeLists 改成 target 级写法。 - 设计
controller_core与controller_ros两个 target 的依赖关系,要求核心库不依赖 ROS2。
7.2 安装、导出与包边界 ⭐⭐¶
这一节解决的问题是:如何让你的库不只在源码树内能用,也能被其他项目正确 find_package。
动机:本地能编过不等于可复用¶
大型项目常需要:
- 单独测试核心库。
- 被 ROS2 节点链接。
- 被仿真工具链接。
- 被下游项目
find_package。 - 被 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 风险。
练习¶
- 为一个
math_core库写 install/export 规则,并在另一个最小项目中find_package。 - 把求解器第三方头文件从 public header 移到
.cpp,观察下游重新编译范围。 - 解释为什么 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>
反面失败:把第三方源码直接复制进项目¶
短期看最简单。 长期会出现:
- 不知道第三方版本。
- 无法及时更新安全修复。
- 本地修改和上游修改混在一起。
- 许可证边界不清。
练习¶
- 为一个纯 CMake 小依赖写 FetchContent 接入,并固定 tag。
- 在 ROS2 包中加入
package.xml依赖,用 rosdep 检查。 - 比较同一依赖通过 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 之上叠加当前开发包。 这对机器人项目很重要:
- 底层使用稳定发布版本。
- 当前只修改少量包。
- 不必从源码重编整个 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 包作为薄封装。
练习¶
- 创建两个 ROS2 包:
controller_core和controller_ros,让 ROS 包依赖核心包。 - 用 overlay 修改一个包,说明
source顺序为何重要。 - 解释
--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。 核心目标是让每次提交都能重现编译、测试、格式和静态分析。
练习¶
- 为本地项目添加 CMake preset,要求生成
compile_commands.json。 - 写一个 Dockerfile 固定 ROS2 发行版和构建工具。
- 设计 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、参数和消息类型,那么:
- 单元测试必须初始化 ROS2。
- 控制核心无法嵌入非 ROS 仿真。
- 实时边界和回调边界混在一起。
- 下游想复用控制算法必须引入 ROS2。
正确做法是把 ROS2 消息转换成核心库结构体。
// 轻量视图只借用外部数组,不拥有内存,适合实时控制路径。
struct JointStateView {
const double* q;
const double* dq;
int n;
};
class ControllerCore {
public:
// 控制核心只接收普通 C++ 数据结构,不依赖 ROS2 消息类型。
bool update(const JointStateView& state, double dt);
};
练习¶
- 画出你的机器人项目依赖图,要求箭头不能从核心库指向 ROS2 包。
- 找一个 public header 中不必要的 include,用前向声明替换。
- 设计一个
ControllerCore的纯 C++ 测试,不初始化 ROS2。
7.7 大型 C++ 项目的分层地图 ⭐⭐⭐¶
这一节解决的问题是:一个机器人仓库里几十个库、节点和工具应该如何分层,才不会在半年后变成所有模块互相依赖。
动机:项目组织先于代码组织¶
很多初学者把“项目组织”理解为目录摆放。 目录当然重要,但目录只是表象。 真正的组织是依赖方向、数据边界、实时边界和交付边界。
一个控制项目可以把源文件放得很整齐,却仍然架构混乱。
典型表现是 controller_core include 了 rclcpp。
robot_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 节点。
};
分层判断问题¶
当你不确定某个类型应该放在哪里,可以问四个问题:
- 它是否需要在没有 ROS2 的环境中测试?
- 它是否需要在仿真和真机之间复用?
- 它是否暴露在 public header 中?
- 它的变化频率是否明显高于核心算法?
如果答案是“需要复用、需要测试、暴露接口、变化不快”,它更可能属于基础层或领域层。 如果答案是“依赖某个运行环境、协议或工具”,它更可能属于适配层。
| 类型 | 推荐归属 | 原因 |
|---|---|---|
Eigen::VectorXd |
基础层可暴露 | 数学类型稳定且广泛使用 |
sensor_msgs::msg::JointState |
ROS2 适配层 | 消息类型绑定通信中间件 |
pinocchio::Model |
robot_model 内部或受控暴露 | 依赖重,ABI 和编译时间需控制 |
OsqpEigen::Solver |
solver_bridge 内部 | 求解器可替换,不应泄漏 |
HardwarePacket |
hardware_bridge | 协议与设备绑定 |
练习¶
- 画出一个四足项目的四层依赖图,要求核心控制库不依赖 ROS2 和硬件驱动。
- 把一个直接读取 ROS2 参数的核心类改成接收普通 C++ 配置结构。
- 判断
pinocchio::Data是否应该出现在 public header 中,并说明理由。
7.8 Target 设计模式:库、接口库、对象库与可执行入口 ⭐⭐⭐¶
这一节解决的问题是:现代 CMake 中不只是“加一个库”,还要选择正确的 target 形态。
为什么 target 形态重要¶
CMake target 是构建系统里的最小可组合单元。 它携带源文件、头文件路径、编译特性、编译定义、链接依赖和安装导出信息。 如果 target 设计粗糙,CMakeLists 会迅速膨胀成全局变量脚本。
一个机器人项目通常同时需要:
- 纯头文件数学工具。
- 有源码的核心算法库。
- 可替换的求解器适配库。
- 用于多个可执行文件共享源码的对象库。
- 很薄的 ROS2 节点入口。
- 测试和基准测试 target。
这些 target 的职责不同,CMake 写法也应不同。
target 类型对比¶
| target 类型 | 是否编译源码 | 是否产出库文件 | 典型用途 |
|---|---|---|---|
STATIC |
是 | .a |
核心算法库、离线工具 |
SHARED |
是 | .so |
插件、运行期共享库 |
INTERFACE |
否 | 否 | header-only、统一编译选项 |
OBJECT |
是 | .o 集合 |
多个库共享同一批对象文件 |
ALIAS |
否 | 否 | 命名空间别名 |
EXECUTABLE |
是 | 可执行文件 | 节点、工具、测试入口 |
选择 target 类型时,先问“这个单元是否有自己的二进制边界”。
如果只是传播头文件和编译选项,用 INTERFACE。
如果是可链接的核心能力,用 STATIC 或 SHARED。
如果只是把同一批源码复用到多个最终产物,可考虑 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。
不用运行整个节点,也不用模拟命令行和进程环境。
练习¶
- 把一个只包含模板函数的
math_utils改成INTERFACEtarget。 - 为自有 target 添加
project_warnings接口库,说明为什么不传播给下游。 - 把一个包含大量业务逻辑的
main.cpp拆成库 target 和薄可执行入口。
7.9 包导出的完整闭环:从 build tree 到 install tree ⭐⭐⭐¶
这一节解决的问题是:为什么写了 install(EXPORT ...) 还不等于真正可被下游可靠使用。
find_package 的实际流程¶
下游调用 find_package(robot_control REQUIRED) 时,CMake 不是凭空知道你的库在哪里。
它会在若干前缀中寻找配置文件。
对现代 CMake 包,最关键的是:
robot_controlConfig.cmakerobot_controlConfigVersion.cmakerobot_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
这个测试非常便宜,却能发现大量问题:
- 忘记安装头文件。
- 忘记导出 target。
- public 依赖没有
find_dependency。 - 安装路径中残留构建目录绝对路径。
- 命名空间 target 名称与文档不一致。
版本策略¶
包导出还牵涉版本承诺。 机器人项目内部库也应遵守简单的语义化版本约定。
| 改动类型 | 示例 | 版本动作 |
|---|---|---|
| 兼容新增 | 增加新函数、新 target | 增加次版本 |
| 行为修复 | 修复边界条件,不改接口 | 增加补丁版本 |
| 破坏接口 | 删除函数、改变 public 类型 | 增加主版本 |
| ABI 破坏 | 改变 public class 数据成员 | 至少增加主版本 |
内部项目不一定要正式发布到系统包仓库。 但版本思维仍然重要。 它帮助团队判断“这个改动会影响谁”。
练习¶
- 为
controller_core补齐Config.cmake.in、版本文件生成和安装规则。 - 写一个独立 consumer 项目,只用
find_package和命名空间 target 链接库。 - 故意删除
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
这些检查不是替代编译,而是把架构规则变成可执行规则。 越早发现依赖反向,修复成本越低。
练习¶
- 找一个 public header 中的重 include,用前向声明或 pimpl 降低传播。
- 写一个 CI 检查,禁止
controller_core/include中出现rclcpp。 - 比较 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。
练习¶
- 设计一个三层 workspace,说明每层放哪些包、加载顺序是什么。
- 用
--packages-up-to构建某个 ROS2 节点,并解释 colcon 如何找到依赖闭包。 - 为一个没有系统包的第三方库设计 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,相当于把项目边界写成了可执行文档。
练习¶
- 给一个普通 CMake 库增加 install tree consumer CI 阶段。
- 为 ROS2 workspace 设计“每次提交”和“每日全量”两套 CI 检查。
- 写一个依赖边界脚本,禁止
modules/math_core依赖任何 ROS2 包。
7.13 大型工程的演进策略 ⭐⭐¶
这一节解决的问题是:项目从小到大时,如何逐步引入构建规范,而不是一次性推倒重来。
不要一开始就追求最复杂形态¶
一个教学项目或原型项目可以从很简单的结构开始:
- 一个核心库。
- 一个可执行文件。
- 一组 gtest。
- 一个 CMake preset。
- 一个 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 一条命令成功 |
| 测试 | ctest 或 colcon test 能在干净构建目录运行 |
| 安装 | cmake --install 后存在头文件、库和 Config 文件 |
| 下游复用 | 独立 consumer 只用 find_package 成功链接 |
| 依赖边界 | 核心库不 include ROS2、硬件驱动和求解器实现类型 |
| CI | 至少覆盖配置、编译、测试和安装导出 |
| 文档 | README 或章节说明如何构建、测试和使用 |
综合练习¶
- 从零设计一个
robot_cpp_workspace,包含math_core、robot_model、controller_core、controller_ros四个包。 - 要求
math_core是普通 CMake 库,controller_ros是 ROS2 包。 - 要求
controller_core能被 gtest、benchmark 和 ROS2 节点同时链接。 - 为
controller_core写 install/export 和 consumer 示例。 - 为 CI 设计四个阶段:普通构建、安装导出、依赖边界、ROS2 workspace 构建。
7.14 colcon 与 CMake 的关系深入解析 ⭐⭐¶
这一节解决的问题是:colcon 和 CMake 不是替代关系,而是不同层级的工具,理解它们的分工是大型 ROS2 项目的基础。
colcon 不构建代码¶
这一点必须首先明确。colcon 本身不调用编译器、不处理头文件、不链接库。它做的事情是:
- 扫描 workspace 中的包。
- 解析
package.xml得到包之间的依赖关系。 - 按拓扑排序确定构建顺序。
- 依次调用每个包的构建系统(CMake、setuptools 等)。
- 把每个包的安装产物放到统一的 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 vs target_link_libraries¶
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 |
练习¶
- 创建两个包:纯 CMake 核心库和 ament_cmake ROS2 节点,用 colcon 构建它们,观察构建顺序。
- 故意删除包 B 的
package.xml中对包 A 的依赖声明,观察 colcon 构建顺序的变化。 - 解释为什么
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 |
练习¶
- 为一个纯 CMake 库编写 ARM64 toolchain file,在 x86 主机上交叉编译。
- 使用 Docker + QEMU 构建一个 ROS2 包的 ARM64 镜像。
- 列出从 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 层缓存 | 合理分层,把频繁变化的层放在最后 |
练习¶
- 为一个 ROS2 包编写多阶段 Dockerfile,确保运行镜像不包含编译工具。
- 使用 Docker Compose 编排两个 ROS2 节点容器,验证它们能通过 topic 通信。
- 比较 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_package 的 PATHS 参数。
⚠️ 常见陷阱¶
| 类型 | 错误做法 | 现象 | 根本原因 | 正确做法 |
|---|---|---|---|---|
| 编程 | vcpkg 和 apt 同时提供同一库 | 版本冲突 | 搜索路径优先级不明 | 统一一个来源,或显式指定路径 |
| 工程 | 不锁定 vcpkg 版本 | CI 上依赖版本漂移 | vcpkg 仓库持续更新 | 使用 vcpkg.json + baseline 锁定 |
| 概念 | 认为包管理器替代构建系统 | 混淆职责 | vcpkg/conan 只管依赖获取 | CMake 仍然管编译和链接 |
| 思维 | 每个依赖都用包管理器 | 不必要的复杂度 | apt 能满足的就不需要 | apt 优先,不足时用包管理器 |
练习¶
- 用 vcpkg 安装 fmt 和 spdlog,在一个纯 CMake 项目中使用
find_package链接它们。 - 写一个
vcpkg.json清单文件,锁定至少三个依赖的版本。 - 在一个同时使用 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 配置 |
练习¶
- 为一个 ROS2 项目设计完整的 GitHub Actions CI,包括格式、编译、测试和 sanitizer 四个阶段。
- 配置 ASan(AddressSanitizer)构建并运行测试,故意引入一个越界访问验证 ASan 能否捕获。
- 设计"每次提交"和"每日全量"两套 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 或接口类隔离 |
| 思维 | 先写代码再想包结构 | 重构成本高 | 依赖方向在代码中固化 | 先设计依赖图再写代码 |
练习¶
- (跨章综合题)从零设计一个机械臂控制项目的包结构,包含运动学库、轨迹规划库、MoveIt2 桥接和 ROS2 节点。要求画出四层依赖图,并为核心库写 install/export 规则。
- 为案例中的
qp_solver_bridge实现 pimpl 隔离,使 public header 不暴露 OSQP 或 ProxSuite 类型。 - 编写 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_core、optimization_bridge、controller_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++ 核心库 |