【CMake】 工程化实战:CTest 测试 + CPack 打包 + 外部命令集成全解析


🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!


🎬 博主简介:


文章目录

  • 前言
  • [一. CTest:CMake 原生自动化测试框架](#一. CTest:CMake 原生自动化测试框架)
    • [1.1 CTest 核心功能](#1.1 CTest 核心功能)
    • [1.2 实战:为 MyMath 数学库添加单元测试](#1.2 实战:为 MyMath 数学库添加单元测试)
      • [1.2.1 编写测试代码](#1.2.1 编写测试代码)
      • [1.2.2 CMakeLists 配置详解](#1.2.2 CMakeLists 配置详解)
      • [1.2.3 运行测试](#1.2.3 运行测试)
    • [1.3 核心命令总结](#1.3 核心命令总结)
  • [二. CPack:一键生成跨平台安装包](#二. CPack:一键生成跨平台安装包)
    • [2.1 CPack 核心能力](#2.1 CPack 核心能力)
    • [2.2 实战:打包 MyMath 动态库与可执行文件](#2.2 实战:打包 MyMath 动态库与可执行文件)
      • [2.2.1 基础配置与安装规则](#2.2.1 基础配置与安装规则)
      • [2.2.2 CPack 打包配置](#2.2.2 CPack 打包配置)
      • [2.2.3 生成与测试安装包](#2.2.3 生成与测试安装包)
    • [2.3 关键原理:ORIGIN 与动态库运行时查找](#2.3 关键原理:ORIGIN 与动态库运行时查找)
    • [2.4 核心命令总结](#2.4 核心命令总结)
  • [三. CMake 调用外部命令:以 Protobuf 代码生成为例](#三. CMake 调用外部命令:以 Protobuf 代码生成为例)
    • [3.1 为什么需要调用外部命令](#3.1 为什么需要调用外部命令)
    • [3.2 核心 API:add_custom_command 与 add_custom_target](#3.2 核心 API:add_custom_command 与 add_custom_target)
    • [3.3 实战:集成 Protobuf 编译器](#3.3 实战:集成 Protobuf 编译器)
      • [3.3.1 环境准备与 proto 文件编写](#3.3.1 环境准备与 proto 文件编写)
      • [3.3.2 CMakeLists 完整配置与逐行解读](#3.3.2 CMakeLists 完整配置与逐行解读)
      • [3.3.3 编译运行验证](#3.3.3 编译运行验证)
    • [3.4 外部命令调用最佳实践](#3.4 外部命令调用最佳实践)
  • 四、总结与核心考点提炼
  • 结尾:

前言

在 C/C++ 开发中,CMake 早已成为跨平台构建的事实标准,但多数开发者仅掌握了基础的编译链接流程,却忽略了 CMake 生态中三个决定项目工程化水平的核心模块:CTest(自动化测试框架)CPack(跨平台打包工具)和外部命令集成能力。这三个工具构成了 CMake 完整的 "构建 - 测试 - 交付" 闭环:CTest 让单元测试与构建流程无缝集成,CPack 实现一键生成多格式安装包,外部命令集成则能将代码生成、格式检查等自动化任务纳入构建体系。本文将结合实战案例,从原理到源码逐行拆解,帮你掌握这三个核心技能,打造专业级 CMake 工程。

本篇飞书笔记


一. CTest:CMake 原生自动化测试框架

1.1 CTest 核心功能

CTest 是 CMake 内置的测试驱动工具,无需额外安装依赖,支持单元测试、集成测试、性能测试等多种测试类型,能够自动执行测试用例并生成详细的测试报告。其核心优势在于与 CMake 构建系统深度集成,测试用例的编译、运行与项目构建流程完全统一。

1.2 实战:为 MyMath 数学库添加单元测试

我们以一个简单的数学库(包含加法和减法函数)为例,演示如何使用 CTest 进行自动化测试。

1.2.1 编写测试代码

创建test_math.cpp测试文件,使用 C 标准库的assert宏验证函数正确性:

cpp 复制代码
#include <iostream>
#include "math/math.h"
#include <cassert>

int main() {
    // 测试加法函数
    assert(add(2, 3) == 5);
    std::cout << "加法测试通过" << std::endl;

    // 测试减法函数
    assert(sub(2, 3) == -1);
    std::cout << "减法测试通过" << std::endl;

    std::cout << "所有测试通过!" << std::endl;
    return 0;
}

1.2.2 CMakeLists 配置详解

在测试目录下创建CMakeLists.txt,完成 CTest 的集成配置:

cmake 复制代码
cmake_minimum_required(VERSION 3.18)
project(TestMyMath LANGUAGES CXX)

# 1. 开启CTest测试功能(必须在add_test之前调用)
include(CTest)

# 2. 添加测试可执行文件
add_executable(test_math test_math.cpp)

# 3. 查找并链接被测的MyMath库(假设已通过install安装到系统)
find_package(MyMath CONFIG REQUIRED)
target_link_libraries(test_math PRIVATE MyMath::MyMath)

# 4. 将测试用例注册到CTest
add_test(
    NAME MathLibraryTest  # 测试用例名称(CTest中显示的标识)
    COMMAND test_math     # 要执行的测试命令
)

关键配置解读

  • include(CTest):加载 CTest 模块,启用测试功能,必须放在所有add_test命令之前
  • find_package(MyMath CONFIG REQUIRED):以 Config 模式查找已安装的 MyMath 库
  • add_test():将可执行文件注册为 CTest 测试用例,CTest 会自动执行该程序并根据返回值判断测试是否通过(返回 0 为通过,非 0 为失败)

1.2.3 运行测试

执行标准的 CMake 构建流程后,即可通过 CTest 运行测试:

bash 复制代码
# 创建构建目录并进入
mkdir build && cd build

# 配置项目
cmake ..

# 编译测试程序
make

# 运行所有测试用例(两种方式等价)
ctest
# 或者
make test

运行结果示例:

Plain 复制代码
Test project /home/bit/workspace/CMakeClass/test_ctest/build
    Start 1: MathLibraryTest
1/1 Test #1: MathLibraryTest .................. Passed 0.00 sec

100% tests passed, 0 tests failed out of 1

Total Test time (real) = 0.00 sec

1.3 核心命令总结

命令 作用
include(CTest) 加载 CTest 模块,启用测试功能
add_test(NAME <name> COMMAND <cmd>) 注册测试用例到 CTest
ctest 执行所有已注册的测试用例
make test make 方式执行测试(等价于 ctest)

二. CPack:一键生成跨平台安装包

2.1 CPack 核心能力

CPack 是 CMake 内置的打包工具,能够基于项目的install()规则,自动收集可执行文件、库文件、头文件和文档,生成符合平台规范的安装包。支持的格式包括:

  • Linux:DEB、RPM、TGZ、STGZ
  • Windows:ZIP、MSI
  • macOS:DMG、TGZ

其核心优势在于无需编写复杂的打包脚本,只需配置少量参数即可实现跨平台打包。

2.2 实战:打包 MyMath 动态库与可执行文件

我们继续使用 MyMath 动态库项目,演示如何使用 CPack 生成包含可执行文件和动态库的压缩包。

2.2.1 基础配置与安装规则

首先在顶层CMakeLists.txt中配置安装规则,这是 CPack 打包的基础(CPack 会自动收集所有install()指令指定的文件):

cmake 复制代码
cmake_minimum_required(VERSION 3.18)
project(TestMyMath LANGUAGES CXX)

# 添加子目录
add_subdirectory(my_lib)  # 构建MyMath动态库
add_subdirectory(app)     # 构建使用MyMath的可执行文件

# 开启CPack打包功能
include(CPack)

my_lib/CMakeLists.txt(动态库配置与安装)

cmake 复制代码
# 收集源文件
file(GLOB SRC_LISTS "src/*.cpp")

# 添加动态库目标
add_library(MyMath SHARED ${SRC_LISTS})

# 设置头文件包含路径(构建时和安装时)
target_include_directories(MyMath PUBLIC
    "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>"
    "$<INSTALL_INTERFACE:include>"
)

# 设置库属性:输出路径、版本号、位置无关代码
set_target_properties(MyMath PROPERTIES
    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib
    OUTPUT_NAME MyMath
    VERSION 1.2.3
    SOVERSION 20
    COMPILE_OPTIONS "-fPIC"
)

# 1. 安装动态库文件
install(TARGETS MyMath
    LIBRARY DESTINATION lib  # 动态库安装到lib目录
)

# 2. 安装头文件
install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/include/
    DESTINATION include
    FILES_MATCHING PATTERN "*.h"
)

app/CMakeLists.txt(可执行文件配置与安装)

cmake 复制代码
# 收集源文件
file(GLOB SRC_LISTS "*.cpp")

# 添加可执行文件
add_executable(main ${SRC_LISTS})

# 链接MyMath动态库
target_link_libraries(main PRIVATE MyMath)

# 设置可执行文件属性:输出路径、安装时RPATH
set_target_properties(main PROPERTIES
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
    INSTALL_RPATH "$ORIGIN/../lib"  # 关键:运行时动态库查找路径
)

# 安装可执行文件
install(TARGETS main
    RUNTIME DESTINATION bin  # 可执行文件安装到bin目录
)

2.2.2 CPack 打包配置

在顶层CMakeLists.txt中添加 CPack 的元数据配置:

cmake 复制代码
# 开启CPack打包功能
include(CPack)

# 设置包名称
set(CPACK_PACKAGE_NAME "MyMathApp")
# 设置包版本号
set(CPACK_PACKAGE_VERSION "1.0.0")
# 设置生成的包格式(TGZ为tar.gz压缩包)
set(CPACK_GENERATOR "TGZ")

2.2.3 生成与测试安装包

执行构建和打包命令:

bash 复制代码
cd build
cmake ..
make
cpack  # 生成安装包

CPack 会在 build 目录下生成MyMathApp-1.0.0-Linux.tar.gz压缩包,解压后目录结构如下:

Plain 复制代码
MyMathApp-1.0.0-Linux/
├── bin/
│   └── main          # 可执行文件
└── lib/
    ├── libMyMath.so  # 动态库
    ├── libMyMath.so.20
    └── libMyMath.so.1.2.3

测试解压后的程序:

bash 复制代码
cd MyMathApp-1.0.0-Linux/bin
./main

输出结果:

Plain 复制代码
3 + 4 = 7
3 - 4 = -1

2.3 关键原理:$ORIGIN 与动态库运行时查找

CPack 打包后,可执行文件能够正确找到同包内的动态库,核心在于我们设置了INSTALL_RPATH "$ORIGIN/../lib"

O R I G I N 是什么? ORIGIN是什么? ORIGIN是什么?ORIGIN是Linux动态链接器ld的特殊变量,表示当前可执行文件所在的目录。当我们设置INSTALL_RPATH "$ORIGIN/.../lib"时,CMake会将这个路径写入可执行文件的RUNPATH` 段中。

程序运行时,动态链接器会按照以下顺序查找动态库:

  1. 环境变量LD_LIBRARY_PATH指定的路径
  2. 可执行文件RUNPATH段中指定的路径
  3. 系统默认路径(/lib/usr/lib等)

在我们的例子中,$ORIGIN会被动态链接器替换为MyMathApp-1.0.0-Linux/bin/,因此$ORIGIN/../lib就指向了MyMathApp-1.0.0-Linux/lib/,从而正确找到同包内的动态库。

验证 RUNPATH 设置 : 使用readelf命令查看可执行文件的动态段信息:

bash 复制代码
readelf -d bin/main | grep PATH

输出结果:

Plain 复制代码
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/../lib]

2.4 核心命令总结

命令 / 变量 作用
include(CPack) 加载 CPack 模块,启用打包功能
install(TARGETS ...) 定义安装规则(CPack 的基础)
CPACK_PACKAGE_NAME 设置安装包名称
CPACK_PACKAGE_VERSION 设置安装包版本号
CPACK_GENERATOR 设置生成的包格式
INSTALL_RPATH 设置安装后可执行文件的运行时库查找路径
cpack 执行打包命令
make package make 方式执行打包(等价于 cpack)

三. CMake 调用外部命令:以 Protobuf 代码生成为例

3.1 为什么需要调用外部命令

在实际项目中,我们经常需要在构建过程中执行外部工具,例如:

  • 使用 Protobuf 编译器protoc.proto文件生成 C++ 代码
  • 使用 Doxygen 生成文档
  • 使用 Clang-Format 格式化代码
  • 执行自定义的代码生成脚本

CMake 提供了add_custom_commandadd_custom_target两个核心命令,用于将这些外部任务集成到构建流程中。

3.2 核心 API:add_custom_command 与 add_custom_target

add_custom_command

用于定义一个自定义命令,生成指定的输出文件。当其他目标依赖这些输出文件时,CMake 会自动执行该命令。

基本语法:

cmake 复制代码
add_custom_command(
    OUTPUT output1 output2 ...  # 命令生成的文件
    COMMAND command1 args1      # 要执行的命令
    COMMAND command2 args2      # 可以执行多个命令
    DEPENDS depend1 depend2 ... # 命令依赖的文件(依赖文件变化时重新执行)
    COMMENT "注释信息"          # 构建时显示的提示信息
    VERBATIM                    # 确保命令参数正确转义(推荐总是添加)
)

add_custom_target

用于定义一个自定义目标,该目标不生成实际的文件,仅用于执行一系列命令。可以将其作为其他目标的依赖,确保命令在构建时执行。

基本语法:

cmake 复制代码
add_custom_target(
    target_name          # 自定义目标名称
    DEPENDS depend1 depend2 ...  # 依赖的文件或目标
)

3.3 实战:集成 Protobuf 编译器

Protobuf 是 Google 开发的序列化框架,需要使用protoc编译器将.proto接口定义文件编译为 C++ 代码。我们将演示如何将这个过程集成到 CMake 构建流程中。

3.3.1 环境准备与 proto 文件编写

首先安装 Protobuf 库和编译器:

bash 复制代码
sudo apt update
sudo apt install libprotobuf-dev protobuf-compiler

创建proto/person.proto文件,定义 Person 消息结构:

protobuf 复制代码
syntax = "proto3";
package example;

message Person {
    string name = 1;
    int32 id = 2;
    string email = 3;
}

3.3.2 CMakeLists 完整配置与逐行解读

cmake 复制代码
cmake_minimum_required(VERSION 3.18)
project(ProtocExample LANGUAGES CXX)

# 设置C++标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 1. 查找Protobuf库(要求版本>=3.0)
find_package(Protobuf 3.0 REQUIRED)

# 2. 收集所有proto文件
file(GLOB PROTO_FILES ${CMAKE_CURRENT_SOURCE_DIR}/proto/*.proto)

# 3. 创建生成代码的输出目录
set(PB_OUT_DIR ${CMAKE_CURRENT_BINARY_DIR}/pb)
file(MAKE_DIRECTORY ${PB_OUT_DIR})

# 4. 初始化生成的源代码文件集合
set(GEN_SRCS "")  # 存储生成的.cc文件
set(GEN_HEADS "")  # 存储生成的.h文件

# 5. 为每个proto文件生成对应的C++代码
foreach(PROTO ${PROTO_FILES})
    # 5.1 获取proto文件的文件名(不带扩展名)
    get_filename_component(BASE_NAME ${PROTO} NAME_WE)

    # 5.2 将生成的文件添加到源代码集合
    list(APPEND GEN_SRCS "${PB_OUT_DIR}/${BASE_NAME}.pb.cc")
    list(APPEND GEN_HEADS "${PB_OUT_DIR}/${BASE_NAME}.pb.h")

    # 5.3 定义自定义命令,生成C++代码
    add_custom_command(
        # 输出文件:protoc生成的.cc和.h文件
        OUTPUT "${PB_OUT_DIR}/${BASE_NAME}.pb.cc"
               "${PB_OUT_DIR}/${BASE_NAME}.pb.h"
        # 执行protoc命令
        COMMAND protoc
        ARGS --cpp_out=${PB_OUT_DIR}  # C++代码输出目录
             -I ${CMAKE_CURRENT_SOURCE_DIR}/proto  # proto文件搜索路径
             ${PROTO}  # 要编译的proto文件
        # 依赖:当proto文件变化时,重新执行命令
        DEPENDS ${PROTO}
        # 构建时显示的提示信息
        COMMENT "从${BASE_NAME}.proto生成C++代码"
        # 确保参数正确转义
        VERBATIM
    )
endforeach()

# 6. 添加自定义目标,触发生成所有proto代码
add_custom_target(generate_protobuf DEPENDS ${GEN_SRCS} ${GEN_HEADS})

# 7. 将生成的代码编译成静态库
add_library(MyProto STATIC ${GEN_SRCS})
# 添加依赖:确保编译MyProto之前先执行generate_protobuf生成代码
add_dependencies(MyProto generate_protobuf)
# 设置静态库的头文件包含路径(供使用者使用)
target_include_directories(MyProto INTERFACE ${PB_OUT_DIR})
# 链接Protobuf库
target_link_libraries(MyProto PUBLIC protobuf::libprotobuf)

# 8. 添加主程序可执行文件
add_executable(main main.cpp)
# 链接我们生成的MyProto静态库
target_link_libraries(main PRIVATE MyProto)

关键配置解读

  • find_package(Protobuf 3.0 REQUIRED):查找系统中安装的 Protobuf 库
  • file(GLOB PROTO_FILES ...):自动收集所有 proto 文件
  • foreach(PROTO ${PROTO_FILES}):遍历每个 proto 文件,为每个文件生成对应的编译命令
  • add_custom_command():定义 protoc 编译命令,当 proto 文件变化时自动重新生成代码
  • add_custom_target(generate_protobuf):创建一个总目标,依赖所有生成的代码文件
  • add_dependencies(MyProto generate_protobuf):确保编译静态库之前,代码已经生成完毕

3.3.3 编译运行验证

创建main.cpp文件,测试 Protobuf 的序列化和反序列化功能:

cpp 复制代码
#include <iostream>
#include <string>
#include "person.pb.h"

int main() {
    // 1. 创建Person对象并设置字段
    example::Person person;
    person.set_name("Bit");
    person.set_id(10086);
    person.set_email("Bit@example.com");

    // 2. 序列化到字符串
    std::string serialized_data;
    person.SerializeToString(&serialized_data);
    std::cout << "序列化后大小: " << serialized_data.size() << " 字节" << std::endl;

    // 3. 反序列化
    example::Person parsed_person;
    parsed_person.ParseFromString(serialized_data);

    // 4. 输出解析结果
    std::cout << "\n解析结果:" << std::endl;
    std::cout << parsed_person.DebugString() << std::endl;

    return 0;
}

执行构建和运行:

bash 复制代码
mkdir build && cd build
cmake ..
make
./main

输出结果:

Plain 复制代码
序列化后大小: 25 字节

解析结果:
name: "Bit"
id: 10086
email: "Bit@example.com"

3.4 外部命令调用最佳实践

  1. 总是使用 VERBATIM 参数:确保命令参数在不同平台下正确转义,避免空格和特殊字符导致的问题
  2. 明确依赖关系 :通过DEPENDS指定命令依赖的文件,确保依赖变化时命令自动重新执行
  3. 使用自定义目标管理多个命令:当有多个相关的自定义命令时,创建一个总目标来统一管理
  4. 将生成的代码编译成库:不要直接将生成的代码添加到主程序的源文件列表中,而是编译成独立的库,提高构建效率

四、总结与核心考点提炼

1. CTest 自动化测试

  • 核心流程:include(CTest)开启测试 → 编译测试可执行文件 → add_test()注册测试用例 → ctest运行测试
  • 测试通过标准:测试程序返回值为 0,非 0 则视为失败
  • 优势:与 CMake 构建系统深度集成,无需额外工具

2. CPack 跨平台打包

  • 基础:CPack 基于install()规则自动收集文件,因此必须先正确配置安装规则
  • 关键:INSTALL_RPATH "$ORIGIN/../lib"解决打包后动态库查找问题
  • 常用格式:TGZ(跨平台压缩包)、DEB/RPM(Linux 安装包)、MSI(Windows 安装包)

3. 外部命令集成

  • 核心命令:add_custom_command()定义生成文件的命令,add_custom_target()定义执行命令的目标
  • 依赖管理:通过DEPENDSadd_dependencies()确保构建顺序正确
  • 典型场景:Protobuf 代码生成、文档生成、代码格式化等自动化任务

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:掌握这三个模块,你就能将 C/C++ 项目的构建、测试、交付流程完全自动化,大幅提升开发效率和项目质量。在实际工程中,这些工具还可以与 CI/CD 系统结合,实现代码提交后自动构建、测试和打包,打造完整的 DevOps 流水线。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど