一、构建系统与CI/CD:为什么它们必须在一起

说实话,我见过太多团队把构建系统和CI/CD割裂开来对待。他们觉得CMake是CMake,CI是CI,各管各的。结果呢?每次新成员入职,光配环境就要花一整天。我个人习惯是,从项目第一天就把构建系统和CI/CD当成一个整体来设计。

你想想看,构建系统解决的是「怎么编译」,CI/CD解决的是「自动编译、测试、部署」。这两者天然就是搭档。今天我们就聊聊,怎么把CMake和主流的CI平台——GitLab CI、GitHub Actions——深度集成起来。

核心观点:构建系统是CI/CD的「内功」,CI/CD是构建系统的「外功」。两者配合好了,团队效率翻倍。

1.1 GitLab CI 集成 CMake:一个实战案例

我在一个嵌入式项目中用过GitLab CI + CMake的组合。那个项目有3个目标平台:x86_64 Linux、ARM Cortex-A、还有Windows。如果没有CI,每次手动切换工具链,我估计得疯掉。

下面是一个典型的.gitlab-ci.yml配置,它展示了如何用CMake做多平台构建:

stages:
  - build
  - test
  - package

.build_template: &build_definition
  stage: build
  before_script:
    - apt-get update && apt-get install -y cmake ninja-build
  script:
    - mkdir -p build
    - cd build
    - cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Release
    - ninja
  artifacts:
    paths:
      - build/
    expire_in: 1 week

build:linux:
  <<: *build_definition
  tags:
    - linux

build:arm:
  <<: *build_definition
  tags:
    - arm-runner
  before_script:
    - apt-get update && apt-get install -y cmake ninja-build gcc-arm-linux-gnueabihf
  script:
    - mkdir -p build
    - cd build
    - cmake .. -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE=../arm-toolchain.cmake
    - ninja

这里有个小技巧:我用YAML的锚点(&build_definition)把公共部分抽出来了。这样每个平台的构建只需要覆盖差异部分。嗯,这个做法我用了好几年,维护成本极低。

我的建议:在GitLab CI中,尽量把CMake的配置参数(比如工具链文件路径、构建类型)通过CI变量传递,而不是硬编码在脚本里。这样换平台时只需要改变量,不用改CI配置。

1.2 GitHub Actions 多平台构建:矩阵才是王道

GitHub Actions的矩阵构建(matrix)是我最喜欢的特性之一。说白了,就是一次定义,自动生成多个构建任务。我曾经维护过一个库,需要同时支持Ubuntu、macOS、Windows,还有Debug和Release两种配置。

用矩阵配置,代码量直接减半:

name: Multi-platform Build
on: [push, pull_request]

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        build_type: [Debug, Release]
        include:
          - os: ubuntu-latest
            cmake_generator: "Unix Makefiles"
          - os: macos-latest
            cmake_generator: "Unix Makefiles"
          - os: windows-latest
            cmake_generator: "Visual Studio 17 2022"

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure CMake
        run: cmake -B build -G "${{ matrix.cmake_generator }}" -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
      - name: Build
        run: cmake --build build --config ${{ matrix.build_type }}
      - name: Test
        run: ctest --test-dir build -C ${{ matrix.build_type }}

你看,6个构建任务(3个OS × 2个构建类型)就这么搞定了。每个任务都是独立的,并行执行。我算过,原来手写6个job需要200多行YAML,现在不到40行。

注意:矩阵配置虽然方便,但别滥用。如果你的矩阵有4个维度(比如OS、架构、构建类型、编译器),那组合数会爆炸。我一般控制在2-3个维度,超过的话,考虑用动态矩阵或者分阶段构建。

1.3 缓存策略:ccache 和 sccache 的正确用法

说到CI,最让人头疼的就是构建时间。尤其是C/C++项目,每次全量编译动辄半小时。我早期在GitHub Actions上跑一个项目,每次push都要等25分钟。后来加了ccache,直接降到8分钟。

ccache的原理很简单:它缓存编译器的中间产物(.o文件)。如果源文件没变,就直接从缓存拿。sccache是Mozilla出的,支持分布式缓存,适合大型团队。

下面是在GitHub Actions中使用ccache的配置:

steps:
  - name: Cache ccache
    uses: actions/cache@v3
    with:
      path: ~/.ccache
      key: ${{ runner.os }}-ccache-${{ hashFiles('**/*.cpp', '**/*.h') }}
      restore-keys: |
        ${{ runner.os }}-ccache-

  - name: Configure CMake with ccache
    run: |
      cmake -B build \
        -DCMAKE_C_COMPILER_LAUNCHER=ccache \
        -DCMAKE_CXX_COMPILER_LAUNCHER=ccache

这里有个坑:缓存key的设计很关键。我用hashFiles对源文件做哈希,这样只要源文件变了,缓存就失效。但如果你只改了一个注释,哈希也会变,缓存就浪费了。我个人习惯是,把哈希粒度放宽一点,比如只对src/目录下的文件做哈希。

对于sccache,配置稍微复杂一点,但支持跨机器共享缓存:

steps:
  - name: Setup sccache
    run: |
      cargo install sccache
      echo "SCCACHE_BUCKET=my-cache-bucket" >> $GITHUB_ENV
      echo "SCCACHE_REGION=us-east-1" >> $GITHUB_ENV
  - name: Build with sccache
    run: |
      export CC="sccache gcc"
      export CXX="sccache g++"
      cmake -B build
      cmake --build build

避坑指南:我曾经在sccache上踩过一个坑——缓存命中率很低。后来发现是因为编译器版本不一致。本地用GCC 11,CI用GCC 12,缓存永远不命中。所以,确保所有环境的编译器版本一致,这是缓存生效的前提。

1.4 构建矩阵配置:不只是OS和架构

很多人以为构建矩阵就是「不同操作系统 + 不同架构」。其实远不止这些。我常用的矩阵维度包括:

  • 编译器版本:GCC 11、GCC 12、Clang 16
  • 构建类型:Debug、Release、RelWithDebInfo
  • 标准版本:C++17、C++20、C++23
  • 特性开关:WITH_SSL、WITH_GPU、WITH_AVX2

下面是一个更复杂的矩阵配置示例:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    compiler: [gcc, clang]
    standard: [17, 20]
    exclude:
      - os: windows-latest
        compiler: clang
    include:
      - os: ubuntu-latest
        compiler: gcc
        cc: gcc-12
        cxx: g++-12
      - os: ubuntu-latest
        compiler: clang
        cc: clang-16
        cxx: clang++-16

这里用了exclude来排除不支持的组合(Windows + Clang),用include来添加每个组合的具体编译器路径。这种配置方式,说白了就是「先定义规则,再处理例外」。

1.5 知识体系总览

下面这张图,是我对本章知识体系的总结。它展示了构建系统、CI平台、缓存策略和矩阵配置之间的关系:

构建系统与CI/CD集成知识体系 构建系统 (CMake) GitLab CI 集成 YAML锚点复用 工具链文件传递 CI变量配置 GitHub Actions 矩阵构建 (Matrix) 多OS/编译器/标准 exclude/include 缓存策略 ccache / sccache 缓存key设计 分布式缓存 核心原则:构建系统是内功,CI/CD是外功 缓存命中率 > 80% 才算合格

1.6 一些实战心得

最后,分享几个我踩过的坑和总结的经验:

  1. 缓存不是银弹。ccache对头文件修改很敏感。如果你频繁改头文件,缓存命中率会很低。我建议把公共头文件单独放一个目录,减少变动频率。
  2. 矩阵配置要「先窄后宽」。刚开始只跑2-3个关键组合,等稳定了再扩展。我见过一个团队上来就配了20个组合,结果CI排队排到天荒地老。
  3. 本地和CI的环境要一致。我吃过最大的亏就是本地用Ubuntu 20.04,CI用22.04,结果链接器版本不同,链接失败。现在我都用Docker镜像来统一环境。
  4. 善用CI的并行能力。CMake的--parallel选项配合CI的多核机器,构建时间能再降30%。

一句话总结:构建系统和CI/CD的集成,本质上是在「自动化」和「灵活性」之间找平衡。CMake给了你灵活性,CI给了你自动化,而缓存和矩阵配置,就是让这个平衡更高效的工具。

公众号:蓝海资料掘金营,微信deep3321