30、实战项目:从零构建一个跨平台C++项目(库+可执行文件+测试+安装+CPack打包+CI集成)

终于到了这一章。说实话,前面讲了那么多CMake的语法和技巧,如果你没有亲手把一个项目从零搭起来,那些知识始终是散的。我个人习惯,学完一个工具链之后,一定要找个周末,关掉所有干扰,完整地跑一遍“从代码到交付”的全流程。

这一章,我们就来干这件事。我会带着你,从一个空目录开始,一步步构建出一个包含静态库、可执行文件、单元测试、安装规则、CPack打包、以及GitHub Actions CI集成的完整项目。你想想看,这才是真实世界里一个C++库该有的样子。

本章核心目标: 掌握一个工业级CMake项目的标准骨架,能直接复制到你的下一个项目里用。

30.1 项目结构设计

先别急着写代码。我每次开始一个新项目,第一件事是在纸上画目录结构。好的结构能让你少踩很多坑。下面是我推荐的一种布局:

my_project/
├── CMakeLists.txt              # 顶层CMake文件
├── cmake/
│   └── MyProjectConfig.cmake.in # 用于安装后的find_package支持
├── include/
│   └── my_project/
│       └── calculator.h        # 公共头文件
├── src/
│   ├── CMakeLists.txt
│   ├── calculator.cpp          # 库的实现
│   └── main.cpp                # 可执行文件入口
├── tests/
│   ├── CMakeLists.txt
│   └── test_calculator.cpp     # 单元测试
├── scripts/
│   └── ci_build.sh             # CI脚本(可选)
└── .github/
    └── workflows/
        └── ci.yml              # GitHub Actions配置

嗯,这里要注意:include/my_project/ 这种嵌套目录,是为了防止头文件冲突。我在项目中遇到过好几次,不同库都叫 utils.h,结果链接时一片混乱。从那以后,我强制要求所有公共头文件都放在以项目名命名的子目录下。

30.2 顶层CMakeLists.txt:项目的“大脑”

顶层文件负责定义项目、设置C++标准、添加子目录。这是整个构建系统的入口。我习惯把版本号、许可证、以及一些全局编译选项都放在这里:

cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

# 设置C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 让install导出的目标可以被find_package找到
include(GNUInstallDirs)

# 添加子目录
add_subdirectory(src)

# 如果开启了测试,添加测试子目录
option(BUILD_TESTS "Build unit tests" ON)
if(BUILD_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

# 安装配置
install(
    TARGETS my_project_lib
    EXPORT MyProjectTargets
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
    PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/my_project
)

install(
    EXPORT MyProjectTargets
    FILE MyProjectTargets.cmake
    NAMESPACE MyProject::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject
)

# CPack打包配置
set(CPACK_PACKAGE_NAME "MyProject")
set(CPACK_PACKAGE_VERSION "1.0.0")
set(CPACK_GENERATOR "TGZ;DEB;RPM")
include(CPack)

为什么要把 BUILD_TESTS 做成一个option?因为CI环境里你可能只想跑测试,而用户安装时根本不需要测试代码。说白了,这就是“关注点分离”。

30.3 库的实现:src/CMakeLists.txt

库的CMake文件相对简单。我习惯把库和可执行文件分开定义,这样测试和安装都能复用库目标:

# 定义库
add_library(my_project_lib STATIC
    calculator.cpp
)

target_include_directories(my_project_lib
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../include>
        $<INSTALL_INTERFACE:include>
)

set_target_properties(my_project_lib PROPERTIES
    PUBLIC_HEADER "../include/my_project/calculator.h"
)

# 定义可执行文件
add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE my_project_lib)

这里有个细节:$<BUILD_INTERFACE>$<INSTALL_INTERFACE> 是生成器表达式。它们的作用是——在构建时用源码目录的头文件,在安装后则用安装目录的头文件。我曾经因为没写这个,导致安装后的项目找不到头文件,排查了半天。

30.4 单元测试:tests/CMakeLists.txt

测试我推荐用 FetchContent 拉取Google Test。这样不需要把第三方库的源码提交到你的仓库里:

include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG release-1.12.1
)
FetchContent_MakeAvailable(googletest)

add_executable(test_calculator test_calculator.cpp)
target_link_libraries(test_calculator PRIVATE
    my_project_lib
    GTest::gtest_main
)

add_test(NAME CalculatorTests COMMAND test_calculator)

为什么用 GTest::gtest_main 而不是 GTest::gtest?因为前者自带main函数,你只需要写测试用例就行。少写一行是一行,对吧?

30.5 安装与CPack打包

安装规则已经在顶层CMakeLists.txt里定义好了。你只需要运行:

mkdir build && cd build
cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local
cmake --build .
cmake --install .

然后CPack打包就更简单了:

cpack -G TGZ   # 生成tar.gz
cpack -G DEB   # 生成Debian包
cpack -G RPM   # 生成Red Hat包

嗯,这里要注意:生成DEB和RPM包时,你的系统需要安装相应的打包工具(dpkg-devrpm-build)。我在Ubuntu上第一次打包DEB时,就因为没装 dpkg-dev 而报错,折腾了半小时。

30.6 CI集成:GitHub Actions

最后,我们把整个流程自动化。在 .github/workflows/ci.yml 里写入:

name: CI

on: [push, pull_request]

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        build_type: [Debug, Release]

    runs-on: ${{ matrix.os }}

    steps:
    - uses: actions/checkout@v3

    - name: Configure
      run: cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}

    - name: Build
      run: cmake --build build --config ${{ matrix.build_type }}

    - name: Test
      run: ctest --test-dir build --output-on-failure

    - name: Package
      run: cpack --config build/CPackConfig.cmake -G TGZ

这个配置会在三个操作系统上,分别以Debug和Release模式构建、测试、打包。说白了,一次提交,全平台验证。我现在的项目都这么配,省心。

30.7 知识体系总览

下面这张图,把整个项目的构建流程和各个模块的关系画了出来。你可以把它当作一个“地图”,以后写项目时照着这个结构来:

顶层CMakeLists.txt 项目定义 · 全局设置 · 子目录 src/ 库 (my_project_lib) 可执行文件 (my_app) tests/ Google Test 单元测试 ctest 运行 安装 & CPack cmake --install cpack -G TGZ/DEB/RPM CI (GitHub Actions) Ubuntu / Windows / macOS 构建 → 测试 → 打包 交付产物 库文件 · 头文件 · 安装包 链接
我的小建议: 第一次搭建时,不要一次性写完所有代码。先让库能编译,再添加可执行文件,然后加测试,最后搞安装和CI。每一步都验证通过,再走下一步。这样出了问题,你马上知道是哪里出的问题。

30.8 避坑指南

最后,分享几个我亲身踩过的坑:

  • 路径问题: 我曾经把 CMAKE_INSTALL_PREFIX 设成了相对路径,结果安装时文件散落一地。记住,安装路径一定要用绝对路径。
  • 测试找不到库: 如果测试链接了库,但库还没构建完,ctest会报错。解决办法是在 add_test 之前确保库目标已经存在。
  • CPack版本号不一致: 我见过有人手动在CPack配置里写版本号,结果和项目版本号对不上。直接用 ${PROJECT_VERSION} 变量,一劳永逸。
  • CI中Windows路径反斜杠: 在Windows上,CMake生成的路径是反斜杠,但某些shell脚本不认。用 cmake -E 命令代替原生shell命令,可以跨平台。

好了,这一章的内容就到这里。你现在应该有了一个完整的、可以直接上手的跨平台C++项目模板。把它保存好,以后每次开新项目,都可以从这里开始。


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