第一章:CMake导论与构建流程


本章将介绍CMake,不仅视其为一个工具,更将其视为解决跨平台软件编译这一复杂问题的系统性方案。我们将建立一个贯穿整个教程的核心工作流程。

什么是CMake?元构建系统的角色

在软件开发领域,尤其是在C/C++世界中,将源代码转换为可执行程序或库的过程被称为“构建”。传统的构建工具,如Make,依赖于平台特定的Makefile文件。为Windows、Linux和macOS等不同平台维护各自的Makefile是一项繁琐且易错的任务。CMake(Cross-platform Make的缩写)正是为了解决这一痛点而生。

CMake本身并不直接编译代码,它是一个“构建系统生成器”或“元构建系统” 。开发者在一个名为

CMakeLists.txt的、与平台无关的简单文本文件中描述项目的构建规则。随后,CMake会解析这个文件,并为目标平台的原生构建工具生成相应的项目文件,例如为Linux生成

Makefile,为Windows生成Visual Studio解决方案(.sln),或为macOS生成Xcode项目(.xcodeproj)。这种机制解决了软件构建中的诸多难题,包括:

  • 跨平台构建:一次编写CMakeLists.txt,即可在所有支持的平台上生成构建脚本。

  • 依赖管理:CMake能够自动搜索系统中的程序、库和头文件,简化了对外部依赖的处理。

  • IDE集成:能够为多种主流集成开发环境(IDE)生成项目文件,方便开发者使用自己熟悉的工具。

核心工作流程:配置、生成、构建

理解CMake的工作方式,关键在于掌握其独特的两阶段流程。许多初学者的困惑源于未能区分这两个阶段。他们可能会在添加一个新源文件后,期望构建系统能自动发现它,但这并不会发生,因为文件系统的变化发生在构建阶段,而构建系统的规则是在更早的配置生成阶段确定的。

  1. 配置与生成(Configure & Generate):此阶段由cmake命令触发。CMake会解析项目根目录及子目录中的CMakeLists.txt文件,执行其中的指令(如设置变量、检查平台特性、查找依赖库),并最终在指定的构建目录中生成原生构建系统所需的文件(例如,MakefileCMakeCache.txt)。这个阶段是“静态”的,它定义了接下来构建过程的“蓝图”。可以使用配置生成命令 cmake -B build ,表示将配置和生成产生的文件放在build文件夹目录下。

  2. 构建(Build):此阶段使用原生构建工具(如makeninja)来执行上一步生成的“蓝图”。构建工具会根据Makefile中的规则,调用编译器(如GCC、Clang)将源文件编译成目标文件(.o),然后调用链接器将目标文件和库链接成最终的可执行文件或库文件 。或者,可以使用CMake提供的跨平台构建命令

    cmake --build build,它会自动调用底层的原生构建工具,构建上一步产生的,在build文件夹下的配置和生成。

这个两阶段的分离是CMake设计的核心。配置生成阶段负责“规划”,构建阶段负责“执行”。

源码外构建(Out-of-Source Build):一项基本最佳实践

CMake强烈推荐“源码外构建” 。这意味着所有由构建过程产生的文件——包括生成的

MakefileCMakeCache.txt、目标文件(.o)和最终的二进制文件——都应存放在一个独立于源代码树的目录中,通常这个目录被命名为build

这种做法的好处是显而易见的:

  • 保持源码树纯净:源代码目录不会被任何生成的文件“污染”,便于版本控制和代码阅读。

  • 轻松清理:要清除所有构建产物,只需删除build目录即可(rm -rf build),无需担心误删源代码。

  • 支持多重构建配置:可以为同一份源代码创建多个独立的构建目录,例如build_debugbuild_release,分别用于调试和发布版本的构建,互不干扰。

Linux下的“Hello, CMake!”实战

让我们通过一个简单的例子来实践源码外构建流程。

  1. 项目结构

    hello_cmake/
    ├── CMakeLists.txt
    └── main.cpp
    
  2. 源代码 (main.cpp)

    #include <iostream>
    
    int main() {
        std::cout << "Hello, CMake!" << std::endl;
        return 0;
    }
    
  3. CMake脚本 (CMakeLists.txt):一个最基础的CMakeLists.txt文件仅需三行。

    # 指定CMake的最低版本要求
    cmake_minimum_required(VERSION 3.10)
    
    # 定义项目名称
    project(HelloCMake)
    
    # 添加一个可执行文件目标,由main.cpp编译而来
    add_executable(hello_app main.cpp)
  4. 构建命令:

    在hello_cmake目录下打开终端,执行 cmake -B build :

    yy@DESKTOP-RUCI32T:~/cmake/hello_cmake$ cmake -B build
    -- The C compiler identification is GNU 11.4.0
    -- The CXX compiler identification is GNU 11.4.0
    -- Detecting C compiler ABI info
    -- Detecting C compiler ABI info - done
    -- Check for working C compiler: /usr/bin/cc - skipped
    -- Detecting C compile features
    -- Detecting C compile features - done
    -- Detecting CXX compiler ABI info
    -- Detecting CXX compiler ABI info - done
    -- Check for working CXX compiler: /usr/bin/c++ - skipped
    -- Detecting CXX compile features
    -- Detecting CXX compile features - done
    -- Configuring done
    -- Generating done
    -- Build files have been written to: /home/yy/cmake/hello_cmake/build
    上述代码会生成一个build目录,并在下面生成cmake相关的文件。
  5. 紧接着执行构建 cmake --build build:
    yy@DESKTOP-RUCI32T:~/cmake/hello_cmake$ cmake --build build
    [ 50%] Building CXX object CMakeFiles/hello_app.dir/main.cpp.o
    [100%] Linking CXX executable hello_app
    [100%] Built target hello_app
  6. 运行 ./build/hello_app:
    yy@DESKTOP-RUCI32T:~/cmake/hello_cmake$ ./build/hello_app
    Hello, CMake!

在终端看到输出:“Hello, CMake!”。所有生成的文件都位于build目录内,而hello_cmake目录保持原样。注意有时候build目录下可能存在缓存,需要使用命令删除,重新构建,删除命令如下:

rm -rf build

为其他平台生成项目

CMake的跨平台能力体现在-G(Generator)选项上。可以通过cmake --help查看所有用法,在Generators部分可以看到:

yy@DESKTOP-RUCI32T:~/cmake$ cmake --help
Usage

  cmake [options] <path-to-source>
  cmake [options] <path-to-existing-build>
  cmake [options] -S <path-to-source> -B <path-to-build>

Specify a source directory to (re-)generate a build system for it in the
current working directory.  Specify an existing build directory to
re-generate its build system.

Options
  -S <path-to-source>          = Explicitly specify a source directory.
  -B <path-to-build>           = Explicitly specify a build directory.
  -C <initial-cache>           = Pre-load a script to populate the cache.
  -D <var>[:<type>]=<value>    = Create or update a cmake cache entry.
  -U <globbing_expr>           = Remove matching entries from CMake cache.
  -G <generator-name>          = Specify a build system generator.
  -T <toolset-name>            = Specify toolset name if supported by
                                 generator.
  -A <platform-name>           = Specify platform name if supported by
                                 generator.
  --toolchain <file>           = Specify toolchain file
                                 [CMAKE_TOOLCHAIN_FILE].
  --install-prefix <directory> = Specify install directory
                                 [CMAKE_INSTALL_PREFIX].
  -Wdev                        = Enable developer warnings.
  -Wno-dev                     = Suppress developer warnings.
  -Werror=dev                  = Make developer warnings errors.
  -Wno-error=dev               = Make developer warnings not errors.
  -Wdeprecated                 = Enable deprecation warnings.
  -Wno-deprecated              = Suppress deprecation warnings.
  -Werror=deprecated           = Make deprecated macro and function warnings
                                 errors.
  -Wno-error=deprecated        = Make deprecated macro and function warnings
                                 not errors.
  --preset <preset>,--preset=<preset>
                               = Specify a configure preset.
  --list-presets[=<type>]      = List available presets.
  --workflow [<options>]       = Run a workflow preset.
  -E                           = CMake command mode.  Run "cmake -E" for a
                                 summary of commands.
  -L[A][H]                     = List non-advanced cached variables.
  -LR[A][H] <regex>            = Show cached variables that match the regex.
  --fresh                      = Configure a fresh build tree, removing any
                                 existing cache file.
  --build <dir>                = Build a CMake-generated project binary tree.
                                 Run "cmake --build" to see compatible
                                 options and a quick help.
  --install <dir>              = Install a CMake-generated project binary
                                 tree.  Run "cmake --install" to see
                                 compatible options and a quick help.
  --open <dir>                 = Open generated project in the associated
                                 application.
  -N                           = View mode only.
  -P <file>                    = Process script mode.
  --find-package               = Legacy pkg-config like mode.  Do not use.
  --graphviz=<file>            = Generate graphviz of dependencies, see
                                 CMakeGraphVizOptions.cmake for more.
  --system-information [file]  = Dump information about this system.
  --print-config-dir           = Print CMake config directory for user-wide
                                 FileAPI queries.
  --log-level=<ERROR|WARNING|NOTICE|STATUS|VERBOSE|DEBUG|TRACE>
                               = Set the verbosity of messages from CMake
                                 files.  --loglevel is also accepted for
                                 backward compatibility reasons.
  --log-context                = Prepend log messages with context, if given
  --debug-trycompile           = Do not delete the try_compile build tree.
                                 Only useful on one try_compile at a time.
  --debug-output               = Put cmake in a debug mode.
  --debug-find                 = Put cmake find in a debug mode.
  --debug-find-pkg=<pkg-name>[,...]
                               = Limit cmake debug-find to the
                                 comma-separated list of packages
  --debug-find-var=<var-name>[,...]
                               = Limit cmake debug-find to the
                                 comma-separated list of result variables
  --trace                      = Put cmake in trace mode.
  --trace-expand               = Put cmake in trace mode with variable
                                 expansion.
  --trace-format=<human|json-v1>
                               = Set the output format of the trace.
  --trace-source=<file>        = Trace only this CMake file/module.  Multiple
                                 options allowed.
  --trace-redirect=<file>      = Redirect trace output to a file instead of
                                 stderr.
  --warn-uninitialized         = Warn about uninitialized values.
  --no-warn-unused-cli         = Don't warn about command line options.
  --check-system-vars          = Find problems with variable usage in system
                                 files.
  --compile-no-warning-as-error= Ignore COMPILE_WARNING_AS_ERROR property and
                                 CMAKE_COMPILE_WARNING_AS_ERROR variable.
  --profiling-format=<fmt>     = Output data for profiling CMake scripts.
                                 Supported formats: google-trace
  --profiling-output=<file>    = Select an output path for the profiling data
                                 enabled through --profiling-format.
  -h,-H,--help,-help,-usage,/? = Print usage information and exit.
  --version,-version,/V [<file>]
                               = Print version number and exit.
  --help <keyword> [<file>]    = Print help for one keyword and exit.
  --help-full [<file>]         = Print all help manuals and exit.
  --help-manual <man> [<file>] = Print one help manual and exit.
  --help-manual-list [<file>]  = List help manuals available and exit.
  --help-command <cmd> [<file>]= Print help for one command and exit.
  --help-command-list [<file>] = List commands with help available and exit.
  --help-commands [<file>]     = Print cmake-commands manual and exit.
  --help-module <mod> [<file>] = Print help for one module and exit.
  --help-module-list [<file>]  = List modules with help available and exit.
  --help-modules [<file>]      = Print cmake-modules manual and exit.
  --help-policy <cmp> [<file>] = Print help for one policy and exit.
  --help-policy-list [<file>]  = List policies with help available and exit.
  --help-policies [<file>]     = Print cmake-policies manual and exit.
  --help-property <prop> [<file>]
                               = Print help for one property and exit.
  --help-property-list [<file>]= List properties with help available and
                                 exit.
  --help-properties [<file>]   = Print cmake-properties manual and exit.
  --help-variable var [<file>] = Print help for one variable and exit.
  --help-variable-list [<file>]= List variables with help available and exit.
  --help-variables [<file>]    = Print cmake-variables manual and exit.

Generators

The following generators are available on this platform (* marks default):
* Visual Studio 17 2022        = Generates Visual Studio 2022 project files.
                                 Use -A option to specify architecture.
  Visual Studio 16 2019        = Generates Visual Studio 2019 project files.
                                 Use -A option to specify architecture.
  Visual Studio 15 2017 [arch] = Generates Visual Studio 2017 project files.
                                 Optional [arch] can be "Win64" or "ARM".
  Visual Studio 14 2015 [arch] = Generates Visual Studio 2015 project files.
                                 Optional [arch] can be "Win64" or "ARM".
  Borland Makefiles            = Generates Borland makefiles.
  NMake Makefiles              = Generates NMake makefiles.
  NMake Makefiles JOM          = Generates JOM makefiles.
  MSYS Makefiles               = Generates MSYS makefiles.
  MinGW Makefiles              = Generates a make file for use with
                                 mingw32-make.
  Green Hills MULTI            = Generates Green Hills MULTI files
                                 (experimental, work-in-progress).
  Unix Makefiles               = Generates standard UNIX makefiles.
  Ninja                        = Generates build.ninja files.
  Ninja Multi-Config           = Generates build-<Config>.ninja files.
  Watcom WMake                 = Generates Watcom WMake makefiles.

通过指定不同的生成器,可以为各种IDE创建项目文件。

  • 为Windows/Visual Studio生成: 如果你在Windows上安装了Visual Studio 2022,可以使用以下命令生成一个解决方案文件(.sln):

    cmake -G "Visual Studio 17 2022"..
    
  • 为macOS/Xcode生成: 在macOS上,可以使用以下命令生成一个Xcode项目文件(.xcodeproj):

    cmake -G Xcode..
    
  • 还可以生成为Ninja,比如:cmake -B build -G "Ninja"

这极大地简化了跨团队、跨平台协作的流程,开发者可以在自己偏好的环境中工作,而无需维护复杂的、针对特定IDE的配置文件。

第二章:CMake语言:核心语法与命令


本章将介绍CMake语言本身的基本构件,重点讲解用于数据操作和信息输出的最常用命令。

变量:set()命令

变量是CMake脚本的基石。set()命令用于定义或修改变量的值。

  • 基本语法set(<variable> <value>...)

    # 将变量MY_VAR设置为单个值 "hello"
    set(MY_VAR "hello")
    # 将变量MY_SOURCES设置为一个值列表
    # 在CMake中,列表是以分号分隔的字符串
    set(MY_SOURCES main.cpp utils.cpp)
    # 上一行等价于:set(MY_SOURCES "main.cpp;utils.cpp")
    

    CMake中的列表本质上就是一个由分号分隔的字符串 。

  • 变量展开:使用${VAR}语法来获取(或称“解引用”)变量的值 。

    add_executable(my_app ${MY_SOURCES})
    
  • 普通变量 vs. 缓存变量:这是一个至关重要的区别。

    • 普通变量(Normal Variables):它们的作用域局限于当前的CMakeLists.txt文件或函数/宏内部,主要用于脚本内部的逻辑控制。它们不会在CMake的多次运行之间持久化 。

    • 缓存变量(Cache Variables):使用set(<variable> <value> CACHE <type> <docstring>)定义。这些变量会被写入构建目录下的CMakeCache.txt文件中,并在多次cmake运行之间保持其值 。它们主要用于接收用户的配置选项,例如通过命令行传递的

      -D参数(如cmake -DENABLE_TESTS=ON..) 。

      # 定义一个布尔类型的缓存变量,用于控制是否构建测试
      # 用户可以通过 cmake -DBUILD_TESTING=OFF.. 来修改它
      set(BUILD_TESTING ON CACHE BOOL "Enable building of tests")
      
  • 环境变量:可以通过$ENV{VAR}来读取环境变量,通过set(ENV{VAR} "value")来设置 。

  • 预定义变量:CMake预定义了许多有用的变量,通常以CMAKE_PROJECT_开头。熟悉这些变量能极大提高效率,例如:

    • CMAKE_SOURCE_DIR: 项目顶层源目录的绝对路径 。

    • CMAKE_BINARY_DIR: 项目顶层构建目录的绝对路径 。

    • CMAKE_CURRENT_SOURCE_DIR: 当前正在处理的CMakeLists.txt所在源目录的路径 。

    • PROJECT_NAME: 由project()命令设置的项目名称 。

    • CMAKE_CXX_STANDARD: 指定C++标准,如11, 14, 17, 20 。

与用户沟通:message()命令

message()命令是在配置生成阶段向终端打印信息的关键工具,对于调试和状态报告至关重要 。

  • 语法message([<mode>] "text"...)

  • 调试变量:打印变量值是其最常见的用途之一 。

    message(STATUS "Source directory is: ${CMAKE_SOURCE_DIR}")
    message(WARNING "This is a warning message.")
    message(FATAL_ERROR "A critical error occurred.")
    
  • 模式(Modes):不同的模式决定了消息的类型和输出位置(stdoutstderr),下表总结了常用模式及其用途 。

模式

用途

输出流

示例场景

(无)/NOTICE

重要的、面向用户的消息

stderr

宣布一个关键的配置选择,如“启用XX特性”。

STATUS

信息性的进度更新

stdout

“正在查找库 X...”

WARNING

非致命性问题

stderr

“使用了已弃用的特性,请更新。”

AUTHOR_WARNING

面向脚本开发者的警告

stderr

“策略 CMPxxxx 未设置。”

SEND_ERROR

致命错误,继续处理但跳过生成

stderr

“未找到必需的库。”

FATAL_ERROR

不可恢复的错误,立即停止所有处理

stderr

“禁止在源码目录中构建。”

处理列表:list()命令

虽然set命令可以创建列表,但list()命令族提供了更强大和可读性更好的列表操作方式 。

  • 读取

    • list(LENGTH <list_var> <output_var>): 获取列表长度。

    • list(GET <list_var> <index> <output_var>): 获取指定索引的元素。

  • 修改

    • list(APPEND <list_var> <element1>...): 在列表末尾追加元素。

    • list(REMOVE_ITEM <list_var> <item_to_remove>...): 移除所有匹配的元素。

  • 排序

    • list(SORT <list_var>): 按字母顺序排序。

    • list(REVERSE <list_var>): 反转列表。

示例:根据条件向源文件列表中添加文件。

set(MAIN_SOURCES main.cpp)
if(WIN32)
  list(APPEND MAIN_SOURCES windows_specific.cpp)
endif()
add_executable(my_app ${MAIN_SOURCES})

第三章:流程控制与作用域


本章将探讨如何使CMakeLists.txt文件变得动态和有组织,并引入作用域的概念,这是编写正确且可维护脚本的基础。

条件执行:if()/elseif()/else()/endif()

if()语句是实现条件逻辑的核心 。

  • 基本语法

    if(<condition>)
      #... commands...
    elseif(<other_condition>)
      #... other commands...
    else()
      #... fallback commands...
    endif()
    
  • 布尔值:条件可以是布尔常量,如ON, OFF, TRUE, FALSE等(不区分大小写),也可以是10

  • 变量检查

    • if(MY_VAR): 如果MY_VAR已定义且其值不是一个“假”常量(如0, OFF, NO, FALSE, N, IGNORE, NOTFOUND, 或空字符串),则为真 。

    • if(DEFINED MY_VAR): 仅检查MY_VAR是否被定义过,不关心其值 。

  • 比较操作

    • 字符串比较: STREQUAL, STRLESS, STRGREATER

    • 正则表达式匹配: MATCHES

    • 数字比较: EQUAL, LESS, GREATER

示例:平台特定逻辑

这是if语句最常见的应用之一,用于处理不同操作系统的差异。

if(WIN32)
  message(STATUS "Configuring for Windows platform.")
  # 添加Windows特定的源文件或定义
elseif(APPLE)
  message(STATUS "Configuring for Apple platform (macOS/iOS).")
  # 添加Apple特定的框架
elseif(UNIX AND NOT APPLE)
  message(STATUS "Configuring for a generic Unix-like platform (e.g., Linux).")
  # 添加Linux特定的链接选项
endif()

循环:foreach()/endforeach()

foreach()用于遍历列表中的每个元素 。

示例:遍历一个组件列表并为每个组件查找包。

set(MY_COMPONENTS Boost::thread Boost::filesystem)
foreach(COMPONENT ${MY_COMPONENTS})
  find_package(${COMPONENT} REQUIRED)
  message(STATUS "Found component: ${COMPONENT}")
endforeach()

代码复用:function() vs. macro()

当需要复用一段CMake代码时,可以选择function()macro()。它们看似相似,但在作用域和参数处理方面存在根本性差异,误用会导致难以调试的问题。

  • 函数(Function):通过function(name [args...])endfunction()定义。

  • 宏(Macro):通过macro(name [args...])endmacro()定义。

理解它们之间的区别对于编写模块化、可维护的CMake脚本至关重要。宏的行为类似于C语言的预处理器宏,进行文本替换;而函数的行为更像常规编程语言中的函数,拥有独立的作用域。

特性

function()

macro()

变量作用域

创建一个新的作用域。在函数内定义的变量默认是局部的,不会影响调用者 。

在调用者的作用域中执行。在宏内设置或修改的变量会直接影响调用者 。

向外传递变量

必须使用set(<var> <val> PARENT_SCOPE)才能影响调用者的作用域 。

直接在调用者作用域中修改变量,无需特殊指令。

参数处理

参数(如arg1, ARGN)是真正的CMake变量,可以被list等命令操作 。

参数是纯粹的字符串替换,不是真正的变量。对它们使用if(DEFINED...)list()等命令会产生意外结果 。

return()行为

从函数本身返回,控制流回到调用处 。

调用宏的那个作用域返回(例如,如果一个函数调用了宏,return()会使该函数返回),这可能导致非预期的控制流中断 。

典型用例

封装复杂逻辑,定义辅助工具,需要变量隔离以避免污染外部作用域。

简单的、重复性的代码片段;纯文本替换。

总的来说,优先使用function()。它的作用域隔离特性使得代码更安全、更易于理解和维护。只有在确实需要进行类似C预处理器的文本替换,或者需要修改调用者作用域中的大量变量且不关心封装性时,才考虑使用macro()

理解CMake作用域

CMake中的作用域机制是其模块化设计的核心。它旨在强制实现封装,避免旧式构建系统中常见的“全局变量地狱”。

  • 目录作用域:每次调用add_subdirectory(dir)都会创建一个新的子作用域。这个子作用域会继承父作用域所有变量的一份拷贝 。这意味着在子目录中修改一个继承来的变量,并不会影响父目录中的同名变量。

  • 向上传递变量:如果确实需要将子作用域中的变量值传递回父作用域,必须显式地使用set(<variable> <value> PARENT_SCOPE)

示例:在子目录中收集源文件列表。

# 在顶层 CMakeLists.txt 中
project(MyApp)
# 先定义一个空列表
set(APP_SOURCES "")
# 进入子目录,它会继承 APP_SOURCES 的空值
add_subdirectory(src)
# 此时 APP_SOURCES 已经被子目录修改
add_executable(my_app ${APP_SOURCES})

# 在 src/CMakeLists.txt 中
# 将源文件追加到 APP_SOURCES 变量中,并用 PARENT_SCOPE 将其“推送”回父作用域
set(APP_SOURCES
    main.cpp
    helper.cpp
    PARENT_SCOPE
)

虽然PARENT_SCOPE是可行的,但在现代CMake实践中,更推荐使用基于目标(target-based)的方法来传递信息,例如通过target_sources()target_link_libraries()PUBLIC/INTERFACE关键字。这些方法将依赖关系附加到目标本身,而不是通过手动传递变量,从而使项目结构更清晰、更健壮。

第四章:构建与链接库


对于任何非平凡的项目,库的管理都是核心任务。本章将深入探讨库的创建、链接和管理,并引入“现代CMake”的核心理念。

创建目标:add_library()

add_library()命令用于从源文件创建库目标 。

  • 创建静态库 (.a):静态库是在链接时将其代码完全并入可执行文件的归档文件 。

    add_library(my_static_lib STATIC src1.cpp src2.cpp)
    
  • 创建共享库 (.so):共享库(或动态链接库)是在程序运行时由动态链接器加载的独立文件。

    add_library(my_shared_lib SHARED src1.cpp src2.cpp)
    
  • BUILD_SHARED_LIBS开关:这是一个全局的CMake缓存变量。如果在CMakeLists.txt中没有指定STATICSHAREDadd_library()的行为将由BUILD_SHARED_LIBS的值决定。用户可以通过cmake -DBUILD_SHARED_LIBS=ONOFF来方便地在整个项目中切换构建静态库还是共享库。

现代链接方式:target_link_libraries()

target_link_libraries()是现代CMake中管理依赖关系的核心命令。它不仅仅是告诉链接器要链接哪个库,更重要的是,它管理着目标之间的使用需求(usage requirements)传递,如头文件路径、编译定义等。

  • PUBLIC, PRIVATE, INTERFACE关键字:这三个关键字是理解现代CMake的关键。它们定义了依赖项的属性如何从一个目标传递到另一个目标 。

    • PRIVATE:依赖项仅用于构建当前目标,其属性(如头文件路径)不会传递给链接到当前目标的消费者。

    • PUBLIC:依赖项既用于构建当前目标,其属性也会被传递给消费者。

    • INTERFACE:依赖项不用于构建当前目标,但其属性会传递给消费者。这主要用于纯头文件库(header-only libraries)。

示例:假设有三个目标:app(可执行文件)、libA(库)、libB(库)。app依赖libAlibA依赖libB

# libB/CMakeLists.txt
add_library(libB...)
target_include_directories(libB PUBLIC include) # libB的头文件需要被外部使用

# libA/CMakeLists.txt
add_library(libA...)
# libA的实现中用到了libB
target_link_libraries(libA PUBLIC libB) # 或 PRIVATE

# app/CMakeLists.txt
add_executable(app...)
target_link_libraries(app PRIVATE libA)

在这个例子中,target_link_libraries(libA... libB)中的关键字至关重要:

  • 如果使用PUBLIC,那么libA不仅自己会链接libB并包含其头文件,还会告诉CMake:“任何链接到我的目标(这里是app)也需要链接libB并包含其头文件。” app会自动获得libB的依赖。

  • 如果使用PRIVATE,那么libB只是libA的一个内部实现细节。libA会链接libB,但applibB的存在一无所知,也无法直接使用libB的头文件和功能。

下表清晰地总结了这些关键字的语义。

关键字

my_lib (目标) 的影响

my_app (my_lib的消费者) 的影响

target_link_libraries(my_lib PRIVATE dep)

my_lib 链接 depdep 的头文件目录和编译定义用于构建 my_lib

my_appdep 毫不知情。

target_link_libraries(my_lib PUBLIC dep)

my_lib 链接 depdep 的头文件目录和编译定义用于构建 my_lib

my_app 也会自动链接 dep,并获取其头文件目录和编译定义。

target_link_libraries(my_lib INTERFACE dep)

my_lib 链接 dep

my_app 链接 dep,并获取其头文件目录和编译定义。

Linux专题:管理运行时搜索路径(RPATH)

在Linux上,当一个可执行文件依赖于一个共享库(.so文件)时,操作系统在程序启动时需要找到这个.so文件。这个过程由动态链接器(ld.so)负责。如果库不在标准系统路径(如/lib, /usr/lib)或LD_LIBRARY_PATH环境变量指定的路径中,程序将无法启动,并报告“找不到共享库”的错误。

RPATH(Runtime Path)是一种将额外的库搜索路径直接嵌入到可执行文件或共享库本身的技术。这使得程序能够找到位于非标准位置的依赖库,对于创建可重定位的软件包至关重要。

CMake对RPATH的处理区分了两种场景:构建树和安装树。

  • 构建树RPATH:在构建目录中,CMake默认会自动为可执行文件设置RPATH,其值为指向项目内其他共享库目标的绝对路径。这确保了在构建目录中可以直接运行可执行文件进行测试,而无需设置LD_LIBRARY_PATH

  • 安装树RPATH:当项目被安装到最终位置(例如/usr/local)时,构建树的绝对路径RPATH显然是无效的。安装时的RPATH由CMAKE_INSTALL_RPATH变量控制。

使用$ORIGIN创建可重定位包

为了让安装后的程序不依赖于固定的安装路径(即实现“绿色”或“可重定位”),我们可以使用一个特殊的链接器变量$ORIGIN。在Linux上,$ORIGIN在运行时会被动态链接器解析为可执行文件或库自身所在的目录。

通过将CMAKE_INSTALL_RPATH设置为相对于$ORIGIN的路径,我们可以让可执行文件总是在其自身位置的相对路径下查找依赖库。

示例:假设安装后的目录结构如下:

install/
├── bin/
│   └── my_app
└── lib/
    └── libcore.so

我们希望my_app能够找到位于同级lib目录下的libcore.so。可以在顶层CMakeLists.txt中这样设置:

# 设置安装时的RPATH。$ORIGIN/../lib 表示从可执行文件所在目录向上走一级,再进入lib目录
# 注意:在CMake字符串中,'$'是特殊字符,需要转义为'\$'或将其放在引号外以避免被CMake变量展开
set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib")

# 确保在构建时就使用安装RPATH,这样构建目录和安装目录行为一致
set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)

这样设置后,安装的my_app二进制文件中的RPATH条目将是字面上的$ORIGIN/../lib。当运行/path/to/install/bin/my_app时,动态链接器会将$ORIGIN替换为/path/to/install/bin,然后搜索/path/to/install/bin/../lib,即/path/to/install/lib,从而正确找到libcore.so

第五章:使用对象库实现高级模块化


对象库(Object Library)是CMake中一个强大但常被误解的特性,它为代码组织提供了另一种方式。

什么是对象库?

一个对象库是一组由源文件编译而成的目标文件(.o文件)的集合,但这些目标文件既不会被归档成静态库(.a),也不会被链接成共享库(.so)。可以将其理解为一个“虚拟”或“临时”的库,它本身不产生任何库文件产物。

定义一个对象库的语法很简单:

add_library(my_obj_lib OBJECT src1.cpp src2.cpp)

如何使用对象库

对象库的用途是将其包含的目标文件“注入”到其他最终目标(可执行文件或共享/静态库)的源文件列表中。

  • 现代语法 (CMake 3.12+):推荐使用target_link_libraries。尽管名为“link”,但对于对象库,其作用更像是“组合”。

    add_executable(my_app main.cpp)
    target_link_libraries(my_app PRIVATE my_obj_lib)
    
  • 传统/显式语法:使用生成器表达式$<TARGET_OBJECTS:...>将对象文件作为源文件添加 。

    # 写法一
    add_executable(my_app main.cpp $<TARGET_OBJECTS:my_obj_lib>)
    # 写法二
    add_executable(my_app main.cpp)
    target_sources(my_app PRIVATE $<TARGET_OBJECTS:my_obj_lib>)
    

现代语法更为简洁直观,是首选方式。

使用场景与陷阱

对象库的核心价值在于代码复用,但其行为与普通库有本质区别,这导致了一个常见的陷阱。

  • 核心用例:当有一组公共的源文件需要被多个不同的最终目标(例如,两个不同的可执行文件,或者一个可执行文件和一个库)编译和包含时,使用对象库可以避免为这组公共源文件创建一个不必要的中间静态库。

  • 关键陷阱(传递性陷阱):对象库的依赖关系是非传递性的。这意味着,如果一个库libA链接了一个对象库objB,然后一个可执行文件app链接了libAapp不会自动获得objB中的目标文件。对象文件只会注入到直接链接该对象库的目标中。这是导致“未定义的符号”链接错误的常见原因。

示例:传递性陷阱

# obj_lib/CMakeLists.txt
add_library(common_utils OBJECT utils.cpp)

# lib_A/CMakeLists.txt
add_library(libA STATIC libA.cpp)
# libA的实现需要common_utils
target_link_libraries(libA PRIVATE common_utils)

# app/CMakeLists.txt
add_executable(my_app main.cpp)
# app的实现需要libA的功能
target_link_libraries(my_app PRIVATE libA)

上述配置在链接my_app时会失败,因为链接器找不到utils.cpp中定义的符号。原因是common_utils的目标文件只被注入到了libA中,并没有传递给my_app

正确做法my_app也必须直接链接common_utils

# app/CMakeLists.txt (修正后)
add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE libA common_utils)

这种行为模式揭示了对象库的本质:它不是一个用于链接的独立实体,而是一个命名的、可复用的编译单元集合target_link_libraries(app obj_lib)的真正含义是“用obj_lib的目标文件来构成app”,而非“将appobj_lib链接”。因此,建议仅在确实需要将一组源文件编译进多个最终产物时才使用对象库。对于常规的库依赖链(A依赖B,B依赖C),使用静态库是更简单、更符合直觉的选择。

第六章:连接CMake与源代码


本章重点介绍构建脚本与C++代码之间的通信机制,这使得条件编译等强大功能成为可能。

使用target_compile_definitions()传递定义

这是现代CMake中向编译器传递预处理器定义(即编译器的-D选项)的首选方式。

  • 语法target_compile_definitions(<target> <PRIVATE|PUBLIC|INTERFACE> [definitions...])

  • 示例

    target_compile_definitions(my_app PRIVATE
        "ENABLE_LOGGING=1"
        "VERSION_STRING=\"1.2.3\"" # 注意字符串值需要转义引号
    )
    

     

这等同于在编译my_app的源文件时添加编译选项-DENABLE_LOGGING=1-DVERSION_STRING="1.2.3"PUBLIC, PRIVATE, INTERFACE关键字同样适用于控制这些定义的传递性。

C++中的条件编译

在C++代码中,可以使用预处理指令(#ifdef, #if, #else, #endif)来检查由CMake传递的宏定义,从而选择性地编译代码块。

示例:根据构建类型(Debug/Release)启用不同的日志级别

# 在 CMakeLists.txt 中
# CMAKE_BUILD_TYPE 是一个标准变量,通常在调用cmake时通过 -DCMAKE_BUILD_TYPE=Debug 设置
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
  target_compile_definitions(my_app PRIVATE APP_DEBUG_MODE)
endif()
// 在 C++ 源代码中 (例如 main.cpp)
#include <iostream>

void log_message(const std::string& msg) {
#ifdef APP_DEBUG_MODE
    // 这段代码只在Debug模式下被编译
    std::cout << " " << msg << std::endl;
#else
    // 这段代码在非Debug模式下被编译
    // 也许什么都不做,或者写入文件
#endif
}

int main() {
    log_message("Application starting...");
    return 0;
}

这种模式将构建配置与代码功能紧密联系起来,是实现平台特定代码、功能开关和调试功能的标准方法 。

使用configure_file()生成文件

当需要传递更复杂的信息(如版本号、文件路径等)时,configure_file()是比target_compile_definitions()更强大的工具。它会复制一个输入模板文件(通常以.in结尾)到一个输出文件,并在此过程中替换文件内容中的@VAR@${VAR}占位符为对应的CMake变量值。

示例:将项目版本号写入一个头文件,供C++代码使用。

  1. 创建模板文件 (config.h.in):

    #ifndef CONFIG_H
    #define CONFIG_H
    
    #define PROJECT_NAME "@PROJECT_NAME@"
    #define PROJECT_VERSION_MAJOR @MyProject_VERSION_MAJOR@
    #define PROJECT_VERSION_MINOR @MyProject_VERSION_MINOR@
    
    #endif // CONFIG_H
    

    注意@VAR@语法。

  2. CMakeLists.txt中配置和使用:

    # 设置项目名称和版本号
    project(MyProject VERSION 1.2)
    # 当project()命令带有VERSION时,CMake会自动定义
    # MyProject_VERSION_MAJOR, MyProject_VERSION_MINOR等变量 [12]
    
    # 配置config.h.in文件,生成config.h到构建目录
    configure_file(
      "${PROJECT_SOURCE_DIR}/config.h.in"
      "${PROJECT_BINARY_DIR}/config.h"
    )
    
    # 为了让源代码能找到生成的config.h,需要将构建目录添加到头文件搜索路径
    target_include_directories(my_app PRIVATE "${PROJECT_BINARY_DIR}")
    
  3. 在C++代码中使用:

    #include "config.h" // 包含生成的头文件
    #include <iostream>
    
    int main() {
        std::cout << "Welcome to " << PROJECT_NAME << " version "
                  << PROJECT_VERSION_MAJOR << "." << PROJECT_VERSION_MINOR
                  << std::endl;
        return 0;
    }

这个流程是CMake项目中传递版本信息和其他配置时事实上的标准做法。

第七章:管理源文件


如何将项目的源文件告知CMake,是一个看似简单却充满争议的话题。本章将探讨各种方法的优劣,并给出符合现代CMake理念的最佳实践。

传统方式:显式变量列表

在早期的CMake项目中,常见做法是创建一个变量来收集所有源文件。

set(SOURCES
    main.cpp
    module1/file1.cpp
    module2/file2.cpp
)
add_executable(my_app ${SOURCES})

对于小型项目尚可,但随着项目规模和目录结构的增长,这个列表会变得异常庞大且难以维护。

“便捷”方式及其陷阱:file(GLOB)

file(GLOB)命令可以使用通配符自动查找文件,极大地简化了CMakeLists.txt

file(GLOB SOURCES "*.cpp")
add_executable(my_app ${SOURCES})

这种方法的诱惑力在于其简洁性,但它隐藏着一个巨大的陷阱,也是CMake官方不推荐使用它的原因 。问题在于,

file(GLOB)是在配置生成阶段运行的。这意味着它只在运行cmake命令时扫描一次文件系统。如果之后开发者添加或删除了一个源文件,生成的Makefile并不会知道这个变化。构建时,新文件不会被编译,删除的文件可能会导致链接错误。必须手动重新运行cmake才能更新构建系统,这破坏了增量构建的可靠性 。

CONFIGURE_DEPENDS选项可以缓解这个问题。它会告诉CMake,如果glob的结果发生变化,就在下次构建时自动重新运行配置步骤。但这会带来配置时间的开销,并且在某些情况下仍可能存在问题 。

现代方式:target_sources()

对于模块化的项目,推荐的最佳实践是使用target_sources()命令 。该命令允许在目标(由add_executableadd_library创建)被定义之后,从任何地方(通常是子目录)向其添加源文件。

这种方法有几个核心优势:

  • 源文件列表的局部化:每个子目录只负责列出自己的源文件,避免了在顶层CMakeLists.txt中维护一个巨大的、跨所有目录的文件列表 。

  • 清晰的模块化:项目结构更清晰,每个模块的CMakeLists.txt都包含了构建该模块所需的所有信息。

  • 健壮性:避免了因不慎重用变量名而将源文件添加到错误目标上的风险 。

示例

# 在顶层 CMakeLists.txt 中
cmake_minimum_required(VERSION 3.1)
project(MyModularApp)

# 先定义目标,但不指定源文件
add_library(core_lib)
add_executable(my_app)

# 包含子目录
add_subdirectory(core)
add_subdirectory(app)

# 链接库
target_link_libraries(my_app PRIVATE core_lib)

# 在 core/CMakeLists.txt 中
# 向在父作用域中定义的 core_lib 目标添加源文件
target_sources(core_lib PRIVATE
    shape.cpp
    calculator.cpp
)

# 在 app/CMakeLists.txt 中
# 向在顶层作用域中定义的 my_app 目标添加源文件
target_sources(my_app PRIVATE
    main.cpp
    ui.cpp
)

通过这种方式,源文件的管理被分散到各自的模块中,使得整个项目更加模块化和易于维护。

第八章:融会贯通


本章将作为整个教程的总结性实践,演示一个符合现代CMake理念的真实项目结构和配置。

我们将创建一个名为ShapeCalculator的应用程序。它依赖于一个名为Core的共享库来进行几何计算,而Core库又依赖于一个名为Utils的静态库来进行日志记录。

目录结构

一个良好组织的项目结构是可维护性的基础 。

ShapeCalculator/
├── CMakeLists.txt         # 顶层CMake脚本
├── app/
│   ├── CMakeLists.txt
│   └── main.cpp
├── core/
│   ├── CMakeLists.txt
│   ├── include/core/
│   │   └── shapes.h
│   └── src/
│       └── shapes.cpp
└── utils/
    ├── CMakeLists.txt
    ├── include/utils/
    │   └── logging.h
    └── src/
        └── logging.cpp

分步实现

1. utils 静态库

这个库提供一个简单的日志功能。

  • utils/include/utils/logging.h:

    #pragma once
    #include <string>
    void log_message(const std::string& msg);
    
  • utils/src/logging.cpp:

    #include "utils/logging.h"
    #include <iostream>
    void log_message(const std::string& msg) {
        std::cout << "[LOG] " << msg << std::endl;
    }
    
  • utils/CMakeLists.txt:

    # 创建一个名为 "utils" 的静态库
    add_library(utils STATIC)
    
    # 使用 target_sources 添加源文件
    target_sources(utils PRIVATE
        src/logging.cpp
    )
    
    # 将 "include" 目录设为 PUBLIC 头文件目录
    # 这意味着任何链接到 "utils" 的目标都将自动获得这个包含路径
    target_include_directories(utils PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include> # 用于安装的路径
    )
    

2. core 共享库

这个库执行核心计算,并使用utils库来记录日志。

  • core/include/core/shapes.h:

    #pragma once
    class Circle {
    public:
        explicit Circle(double radius);
        double getArea() const;
    private:
        double radius_;
    };
    
  • core/src/shapes.cpp:

    #include "core/shapes.h"
    #include "utils/logging.h" // 依赖于 utils 库
    #include <cmath>
    
    // 如果定义了VERBOSE_LOGGING,则打印更详细的日志
    #ifdef VERBOSE_LOGGING
        #define LOG(msg) log_message(msg)
    #else
        #define LOG(msg)
    #endif
    
    Circle::Circle(double radius) : radius_(radius) {
        LOG("Circle created with radius " + std::to_string(radius));
    }
    
    double Circle::getArea() const {
        double area = M_PI * radius_ * radius_;
        LOG("Calculating area: " + std::to_string(area));
        return area;
    }
    
  • core/CMakeLists.txt:

    # 创建一个名为 "core" 的共享库
    add_library(core SHARED)
    
    target_sources(core PRIVATE
        src/shapes.cpp
    )
    
    target_include_directories(core PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
    )
    
    # 链接到 utils 库。使用 PRIVATE,因为 utils 是 core 的内部实现细节,
    # 不希望 core 的使用者(如 app)直接接触到 utils。
    target_link_libraries(core PRIVATE utils)
    
    # 如果 ENABLE_VERBOSE_LOGGING 选项为 ON,则添加编译定义
    if(ENABLE_VERBOSE_LOGGING)
        target_compile_definitions(core PRIVATE VERBOSE_LOGGING)
    endif()
    

3. app 可执行文件

这是最终的用户应用程序。

  • app/main.cpp:

    #include "core/shapes.h" // 依赖于 core 库
    #include <iostream>
    
    int main() {
        Circle c(10.0);
        std::cout << "The area of the circle is: " << c.getArea() << std::endl;
        return 0;
    }
    
  • app/CMakeLists.txt:

    # 创建可执行文件
    add_executable(ShapeCalculator)
    
    target_sources(ShapeCalculator PRIVATE
        main.cpp
    )
    
    # 链接到 core 库。
    # 因为 core 对 utils 的依赖是 PRIVATE 的,所以 ShapeCalculator 不知道 utils 的存在。
    target_link_libraries(ShapeCalculator PRIVATE core)
    

4. 顶层 CMakeLists.txt

这个文件将所有模块串联起来。

cmake_minimum_required(VERSION 3.12)

project(ShapeCalculator LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 添加一个用户可配置的选项来控制日志详细程度
option(ENABLE_VERBOSE_LOGGING "Enable verbose logging in the core library" OFF)

# 添加子目录,CMake将依次处理它们的CMakeLists.txt
add_subdirectory(utils)
add_subdirectory(core)
add_subdirectory(app)

最终构建与运行

现在,整个项目已经配置完毕。在Linux上,我们可以按照标准流程进行构建和运行。

  1. 进入项目根目录 ShapeCalculator/

  2. 构建和配置项目:

    yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ cmake -B build
    -- The CXX compiler identification is GNU 11.4.0
    -- Detecting CXX compiler ABI info
    -- Detecting CXX compiler ABI info - done
    -- Check for working CXX compiler: /usr/bin/c++ - skipped
    -- Detecting CXX compile features
    -- Detecting CXX compile features - done
    -- Configuring done
    -- Generating done
    -- Build files have been written to: /home/yy/cmake/ShapeCalculator/build
    
    或带参数配置:
    yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ cmake -B build -DENABLE_VERBOSE_LOGGING=ON
    -- The CXX compiler identification is GNU 11.4.0
    -- Detecting CXX compiler ABI info
    -- Detecting CXX compiler ABI info - done
    -- Check for working CXX compiler: /usr/bin/c++ - skipped
    -- Detecting CXX compile features
    -- Detecting CXX compile features - done
    -- Configuring done
    -- Generating done
    -- Build files have been written to: /home/yy/cmake/ShapeCalculator/build
  3. 构建项目:

    yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ cmake --build build
    [ 16%] Building CXX object utils/CMakeFiles/utils.dir/src/logging.cpp.o
    [ 33%] Linking CXX static library libutils.a
    [ 33%] Built target utils
    [ 50%] Building CXX object core/CMakeFiles/core.dir/src/shapes.cpp.o
    [ 66%] Linking CXX shared library libcore.so
    [ 66%] Built target core
    [ 83%] Building CXX object app/CMakeFiles/ShapeCalculator.dir/main.cpp.o
    [100%] Linking CXX executable ShapeCalculator
    [100%] Built target ShapeCalculator
  4. 运行应用程序:

    构建完成后,可执行文件和库文件会出现在build目录下的相应子目录中(例如,app/ShapeCalculator和core/libcore.so)。CMake自动处理了RPATH,所以我们可以直接运行。

    yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ build/app/ShapeCalculator
    Area of the circle: 78.5398

如果开启了详细日志,上述代码会有一个报错:

yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ cmake --build build
[ 16%] Building CXX object utils/CMakeFiles/utils.dir/src/logging.cpp.o
[ 33%] Linking CXX static library libutils.a
[ 33%] Built target utils
[ 50%] Building CXX object core/CMakeFiles/core.dir/src/shapes.cpp.o
[ 66%] Linking CXX shared library libcore.so
/usr/bin/ld: ../utils/libutils.a(logging.cpp.o): warning: relocation against `_ZSt4cout@@GLIBCXX_3.4' in read-only section `.text'
/usr/bin/ld: ../utils/libutils.a(logging.cpp.o): relocation R_X86_64_PC32 against symbol `_ZSt4cout@@GLIBCXX_3.4' can not be used when making a shared object; recompile with -fPIC
/usr/bin/ld: final link failed: bad value
collect2: error: ld returned 1 exit status
gmake[2]: *** [core/CMakeFiles/core.dir/build.make:98: core/libcore.so] Error 1
gmake[1]: *** [CMakeFiles/Makefile2:160: core/CMakeFiles/core.dir/all] Error 2
gmake: *** [Makefile:91: all] Error 2

这个链接错误是一个在Linux/Unix环境下混合使用静态库和动态库时非常典型的场景,问题的根本原因在于代码的位置独立性 (Position-Independent Code, PIC)。

你将看到来自core库的日志输出。这个例子完整地展示了如何使用现代CMake组织一个模块化项目,管理依赖传递,并实现与源代码的交互。

  1. 动态库的要求:在我们的项目中,core库是一个动态库(.so文件)。动态库在程序运行时被加载到内存中,其加载的地址是不固定的。因此,构成动态库的所有代码都必须是“位置无关”的,即使用相对地址而非绝对地址。编译器通过添加-fPIC标志来生成这种代码 。  
  2. 静态库的默认行为:utils库是一个静态库(.a文件)。静态库在链接时,其代码会被直接复制并嵌入到最终的目标(在这里是libcore.so)中。默认情况下,为静态库生成的代码不是位置无关的,因为它们通常被链接到地址固定的可执行文件中 。
  3. 冲突发生:当链接器尝试将libutils.a(默认未使用-fPIC编译)中的代码合并到libcore.so(必须是位置无关的)中时,就发生了冲突。链接器发现libutils.a中的代码使用了绝对地址,这在动态库中是不允许的,因此报错并提示“recompile with -fPIC” 。 

为了解决这个问题,我们需要明确告诉CMake,即使utils是一个静态库,也应该用位置无关代码(-fPIC)来编译它,因为它最终会被用在一个动态库中。最规范、最现代的CMake做法是为utils目标设置POSITION_INDEPENDENT_CODE属性 。 修改后的utils/CMakeLists.txt文件,内容如下:  

# 创建一个名为 "utils" 的静态库
add_library(utils STATIC)

# 关键修复:为 utils 目标设置 POSITION_INDEPENDENT_CODE 属性
# 这会告诉 CMake 在编译 utils 的源文件时添加 -fPIC 标志
set_target_properties(utils PROPERTIES POSITION_INDEPENDENT_CODE ON)

# 使用 target_sources 添加源文件
target_sources(utils PRIVATE
    src/logging.cpp
)

# 将 "include" 目录设为 PUBLIC 头文件目录
# 这意味着任何链接到 "utils" 的目标都将自动获得这个包含路径
target_include_directories(utils PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include> # 用于安装的路径
)

以参数重新编译后,运行结果如下:

yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ rm -rf build
yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ cmake -B build -DENABLE_VERBOSE_LOGGING=ON
-- The CXX compiler identification is GNU 11.4.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/yy/cmake/ShapeCalculator/build
yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ cmake --build build
[ 16%] Building CXX object utils/CMakeFiles/utils.dir/src/logging.cpp.o
[ 33%] Linking CXX static library libutils.a
[ 33%] Built target utils
[ 50%] Building CXX object core/CMakeFiles/core.dir/src/shapes.cpp.o
[ 66%] Linking CXX shared library libcore.so
[ 66%] Built target core
[ 83%] Building CXX object app/CMakeFiles/ShapeCalculator.dir/main.cpp.o
[100%] Linking CXX executable ShapeCalculator
[100%] Built target ShapeCalculator
yy@DESKTOP-RUCI32T:~/cmake/ShapeCalculator$ build/app/ShapeCalculator
[Log]: Circle created with radius: 5.000000
Area of the circle: [Log]: Circle area calculated: 78.539816
78.5398

结论


CMake已经成为C++生态系统中事实上的标准构建系统生成器。它通过将复杂的构建逻辑抽象到一个统一的、跨平台的脚本语言中,极大地提高了项目的可维护性和可移植性。掌握现代CMake,特别是其基于目标的依赖管理思想,是每一位严肃的C++开发者必备的技能。

本教程从CMake的基础工作流程和核心语法出发,深入探讨了作用域、库的构建与链接、条件编译以及高级模块化技术。我们强调了“源码外构建”的重要性,详细解析了PUBLICPRIVATEINTERFACE关键字在传递依赖关系中的核心作用,并针对Linux平台特别讲解了RPATH和$ORIGIN在创建可重定位软件包中的应用。通过对比functionmacrotarget_sourcesfile(GLOB),我们阐明了现代CMake的最佳实践及其背后的设计哲学。

最终,通过一个完整的实战项目,我们将所有理论知识融会贯通,展示了如何构建一个结构清晰、依赖关系明确、易于维护的模块化C++应用程序。遵循本教程中概述的原则和技术,开发者将能够自信地驾驭CMake,为任何规模的项目创建健壮、高效且真正跨平台的构建系统。