《CMake构建实战:项目开发卷》 - 笔记整理

0 阅读21分钟

CMake构建实战教程

基于《CMake构建实战:项目开发卷》- 许宏旭
整理时间:2025/11/05


目录

  1. 第1章 基础概念
  2. 第2章 为什么使用CMake
  3. 第3章 基础语法
  4. 第4章 常用命令
  5. 第5章 项目组织
    • [待补充内容]
  6. 第6章 CMake项目的生命周期
  7. 第7章 构建目标
  8. 第8章 生成器表达式
  9. 第9章 模块
  10. 第10章 CMake策略

第1章 基础概念

1.2 构建多源程序

注意Makefile中的缩进必须使用制表符(Tab键)而非空格。

如果需要指定自定义的Makefile文件名,可以使用-f参数。

示例:使用Makefile构建多源程序

# Makefile示例
CC=gcc
CFLAGS=-Wall -g
TARGET=myapp
OBJS=main.o utils.o helper.o

$(TARGET): $(OBJS)
	$(CC) $(OBJ) -o $(TARGET)

main.o: main.c
	$(CC) $(CFLAGS) -c main.c

utils.o: utils.c utils.h
	$(CC) $(CFLAGS) -c utils.c

helper.o: helper.c helper.h
	$(CC) $(CFLAGS) -c helper.c

clean:
	rm -f $(OBJS) $(TARGET)

使用自定义Makefile文件名:

make -f MyCustomMakefile

1.4 构建动态库

地址空间布局随机化(Address Space Layout Randomization,ASLR)

在32位Windows操作系统中,ASLR没有默认开启。此时,动态链接库将会被装载到偏好基地址(preferred base address)这里

在Windows操作系统中,一个程序如果想链接一个动态库,就必须在编译时链接动态库对应的导入库。我们可以简单地把".lib"导入库看作一种接口定义,在链接时提供必要信息;而".dll"动态库则包含运行时程序逻辑的目标代码。因此,编译链接时,只导入库提供的链接信息就够了;只有程序运行时,才需要动态库的存在。

**地址无关代码(Position-Independent Code,PIC)**就是指这种不访问内存绝对地址的代码。如果想让GCC编译器和Clang编译器生成地址无关代码,必须指定一个编译器参数-fPIC。

示例:使用GCC构建动态库

# 编译动态库(启用PIC)
gcc -fPIC -shared -o libmath.so math.c

# 链接动态库
gcc main.c -L. -lmath -o main

# 运行前设置库搜索路径
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./main

示例:Windows下构建动态库

# 编译生成.dll和.lib
cl /LD math.c /Fe:math.dll /link /IMPLIB:math.lib

# 链接时使用.lib
cl main.c math.lib /Fe:main.exe

当启用了地址无关代码之后,目标代码访问全局变量、调用全局函数时,都会使用**全局偏移表(Global Offset Table,GOT)**做一次中转。

由于ASLR特性的存在,动态链接库会在运行时被装载到随机的内存地址中,则GOT各个表项的值只能在运行时被替换——这就是动态重定位

GOT是作为一个跳板存在的,启用地址无关代码会导致访存次数增多,指令数增多,也就在一定程度上影响性能;另外,由于多了这些记录内存地址的条目,目标代码的体积也不可避免地要大一些。

程序既然有能力告诉动态链接器它需要链接哪些动态库,就也应该有本事提醒动态链接器去哪里搜索动态库。这些信息存储在程序的**动态节(dynamic section)**中,我们可以通过readelf命令查看:

$ readelf -d ./main

Linux可执行文件的动态节中有两个与动态库搜索路径相关的条目,一个是RPATH,一个是RUNPATH。二者的区别在于优先级,动态链接器会按照下面列举的顺序依次搜索:

  1. 动态节中的RPATH项指定的路径;
  2. 环境变量LD_LIBRARY_PATH指定的路径;
  3. 系统配置文件/etc/ld.so.conf指定的路径;
  4. 动态节中的RUNPATH项指定的路径。

1.5 引用第三方库

**头文件库(header-only library)**指只包含头文件(.h、.hpp等)的程序库。使用这种库非常方便,只需在程序中引用它的头文件,无须对库本身进行额外的编译。源程序引用头文件,相当于复制了头文件的内容,这样头文件库实际上也就成为了引用它的程序的一部分。所以使用头文件库只需编译引用它的程序,头文件库代码会自动被编译。

由于C++标准不保证编译后的**应用程序二进制接口(Application Binary Interface,ABI)**稳定性,不同版本的编译器编译出的程序无法保证相互引用而不出错。

1.6 旅行笔记

伪构建目标不止接口库这一种类型,它包含以下三种类型:

  • 接口库;
  • 导入目标;
  • 别名目标。

常见的要求也就是之前提到的那些:

  • 头文件搜索目录;
  • 链接库文件搜索目录;
  • 宏定义;
  • 其他编译链接参数等。

伪构建目标会有些不同,因为它们不需要被构建,自然也就不存在对应的构建要求,而只存在使用要求。

main怎样才算使用了库A?一定是引用了库所对应的头文件,并调用了其中的函数或类吗?

当然未必。比如库B中的某个函数可能会返回一个在库A中定义的类型,main又调用了库B中的该函数,这就意味着main间接使用了库A。具体来说,main一定是引用了库B的某个头文件才能调用其中的函数,而这个库B的头文件又一定直接或间接地引用了库A中的头文件,否则它返回的库A中定义的类型就是未定义类型了。

对于GCC来说,提供的链接库的参数-la和-lb的顺序对链接过程存在重要影响。链接器会根据参数指定的链接库顺序依次解析之前遇到过的未定义的符号,不走回头路。也就是说,静态库B中未定义的符号,链接器不会再回到A中去检索了。


第2章 为什么使用CMake

2.1 为什么使用CMake

CMake 3.0版本的推出改善了这一缺陷。因此人们也常把此后版本的CMake称为"现代CMake"

CMake 2是早于C++11的产物。

2.3 您好,CMake!

运行CMake脚本程序时不再区分不同的操作系统,毕竟CMake是跨平台的嘛!需要注意的是,对于跨平台的命令行中的目录分隔符,统一采用"/"

第一个CMake示例:Hello World

CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(HelloWorld)

# 添加可执行文件
add_executable(hello_world main.cpp)

main.cpp:

#include <iostream>

int main() {
    std::cout << "Hello, CMake!" << std::endl;
    return 0;
}

构建步骤:

# 创建构建目录
mkdir build
cd build

# 配置和生成
cmake ..

# 构建
cmake --build .

# 运行
./hello_world

使用CMake脚本模式(-P参数):

hello.cmake:

message(STATUS "Hello from CMake script!")
message(STATUS "This is a cross-platform script!")

执行脚本:

cmake -P hello.cmake

注意: CMake脚本中使用路径分隔符时统一使用"/",即使在Windows上也是如此。


第3章 基础语法

尽管CMake脚本语言最主要的用途是编写组织项目构建的CMake目录程序,但它其实是一个图灵完备的脚本语言,也可以用于编写通用的功能脚本

3.1 CMake程序

当CMake被用于构建时,显然需要处理项目的源程序。处理的入口,就是项目顶层目录下的CMakeLists.txt。这与Makefile相似。另外,在CMakeLists.txt中,可能还会通过add_subdirectory命令将一些子目录追加到构建目录中。当然,这要求子目录中也有一个CMakeLists.txt。

指定-P参数运行CMake命令行工具可以执行脚本类型的CMake程序

示例:基本的CMakeLists.txt结构

# CMakeLists.txt - 项目根目录
cmake_minimum_required(VERSION 3.10)
project(MyProject)

# 添加子目录
add_subdirectory(src)
add_subdirectory(tests)

src/CMakeLists.txt:

# 子目录的CMakeLists.txt
add_library(mylib STATIC mylib.cpp)

tests/CMakeLists.txt:

# 测试子目录
add_executable(test_mylib test.cpp)
target_link_libraries(test_mylib mylib)

使用-P参数执行脚本:

# script.cmake
message(STATUS "Running CMake script mode")
message(STATUS "Platform: ${CMAKE_SYSTEM_NAME}")
cmake -P script.cmake

3.3 命令调用

CMake程序几乎完全由命令调用构成。之所以说"几乎",是因为除此之外,也就只剩下注释和空白符了。CMake程序中的if条件分支、for循环等程序结构统一采用命令调用形式。这也是CMake程序语法有点"怪"的原因之一。不过好处也很明显,语法结构单一,理解起来相对简单。

如果有多个参数,不同于其他编程语言常用逗号分隔参数,在CMake中应当使用空格或换行符等空白符将它们分隔开

示例:命令调用语法

# 单行命令调用
message(STATUS "Hello CMake")

# 多参数命令(使用空格分隔)
add_executable(myapp main.cpp utils.cpp helper.cpp)

# 多参数命令(使用换行符分隔)
add_executable(
    myapp
    main.cpp
    utils.cpp
    helper.cpp
)

# 条件分支命令
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    message(STATUS "Debug build")
else()
    message(STATUS "Release build")
endif()

# 循环命令
set(SOURCES main.cpp utils.cpp)
foreach(SOURCE ${SOURCES})
    message(STATUS "Processing: ${SOURCE}")
endforeach()

3.4 命令参数

引号参数是用引号包裹在内的参数,而且CMake规定它必须使用双引号。引号参数会作为一个整体传递给命令,引号中间的空白符都会作为这个整体中的一部分。也就是说,引号参数中不仅能够包含空格,还可以包含换行符

在引号参数中,代码行末的反斜杠\可以避免参数内容中出现换行

示例:命令参数的使用

# 包含空格的参数需要引号
message(STATUS "Hello World with spaces")

# 包含换行符的参数
message(STATUS "This is a multi-line \
message that spans \
multiple lines")

# 混合使用引号和非引号参数
set(MY_VAR "value with spaces" value_without_spaces)

# 路径参数(跨平台统一使用/)
set(INCLUDE_DIR "include/subdir")
include_directories("${INCLUDE_DIR}")

# 多个参数示例
target_compile_definitions(
    mytarget
    PRIVATE
    "DEBUG_MODE=1"
    "VERSION=\"1.0.0\""
)

3.5 变量

CMake却有三种变量分类。

  • 普通变量。大多数变量都是普通变量,它们具有特定的作用域。
  • 缓存变量。顾名思义,它就是能够被缓存起来的变量,会被持久化到缓存文件CMakeCache.txt。CMake程序每次被执行时,都会从被持久化的缓存文件中读取缓存变量的值。这可以用于避免每次都执行一些耗时的过程来获得数据。例如,当使用CMake构建项目时,它第一次配置时会检测编译器路径,然后将其作为缓存变量持久化,这样可以避免每次执行都重新进行检测。缓存变量主要用于构建过程,cmake -P执行脚本程序时不会对缓存变量进行修改。缓存变量具有全局作用域。
  • 环境变量。即操作系统中的环境变量,因此它对于CMake进程而言具有全局的作用域。

示例:变量的使用

# 1. 普通变量
set(MY_VAR "Hello")
set(NUMBER 42)
message(STATUS "MY_VAR = ${MY_VAR}")
message(STATUS "NUMBER = ${NUMBER}")

# 普通变量作用域示例
function(my_function)
    set(LOCAL_VAR "Local")  # 函数局部变量
    set(PARENT_VAR "Parent" PARENT_SCOPE)  # 设置到父作用域
endfunction()

my_function()
# message(STATUS ${LOCAL_VAR})  # 错误:无法访问
message(STATUS "PARENT_VAR = ${PARENT_VAR}")

# 2. 缓存变量
set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type")
set(ENABLE_TESTS ON CACHE BOOL "Enable tests")

# 使用缓存变量
if(ENABLE_TESTS)
    message(STATUS "Tests are enabled")
endif()

# 3. 环境变量
set(ENV{PATH} "$ENV{PATH}:/custom/path")
message(STATUS "PATH = $ENV{PATH}")

# 读取环境变量
if(DEFINED ENV{HOME})
    message(STATUS "Home directory: $ENV{HOME}")
endif()

3.9 命令定义

函数会产生一个新的作用域,因此函数内部直接使用set命令定义的变量是不能被外部访问的。为了实现这个目的,必须为set命令指定PARENT_SCOPE参数,使得变量定义到外部作用域。

示例:函数和宏的定义

# 定义函数
function(print_message msg)
    message(STATUS "Message: ${msg}")
    set(LOCAL_VAR "Local")
    set(RETURN_VAR "Returned" PARENT_SCOPE)  # 返回到父作用域
endfunction()

print_message("Hello from function")
message(STATUS "Returned: ${RETURN_VAR}")

# 定义宏(宏不创建新作用域)
macro(print_message_macro msg)
    message(STATUS "Macro Message: ${msg}")
    set(MACRO_VAR "Macro variable")
endmacro()

print_message_macro("Hello from macro")
message(STATUS "Macro var: ${MACRO_VAR}")  # 可以访问

# 函数参数示例
function(add_numbers)
    set(sum 0)
    foreach(arg ${ARGN})
        math(EXPR sum "${sum} + ${arg}")
    endforeach()
    set(SUM_RESULT ${sum} PARENT_SCOPE)
endfunction()

add_numbers(1 2 3 4 5)
message(STATUS "Sum = ${SUM_RESULT}")

第4章 常用命令

4.7 配置模板文件:configure_file

configure_file命令用于配置模板文件。该命令会根据模板文件中定义的模板修改文件中的内容,并将结果输出到指定路径中。

示例:使用configure_file生成配置文件

config.h.in(模板文件):

#ifndef CONFIG_H
#define CONFIG_H

// CMake变量会被替换
#define PROJECT_NAME "@PROJECT_NAME@"
#define VERSION_MAJOR @VERSION_MAJOR@
#define VERSION_MINOR @VERSION_MINOR@
#define BUILD_TYPE "@CMAKE_BUILD_TYPE@"
#define INSTALL_PREFIX "@CMAKE_INSTALL_PREFIX@"

// 条件替换
#cmakedefine ENABLE_DEBUG
#cmakedefine01 ENABLE_FEATURE_X

#endif // CONFIG_H

CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(MyProject VERSION 1.2.3)

set(ENABLE_DEBUG ON)
set(ENABLE_FEATURE_X OFF)

# 配置模板文件
configure_file(
    "${CMAKE_CURRENT_SOURCE_DIR}/config.h.in"
    "${CMAKE_CURRENT_BINARY_DIR}/config.h"
)

# 添加到包含目录
include_directories(${CMAKE_CURRENT_BINARY_DIR})

add_executable(myapp main.cpp)

main.cpp:

#include "config.h"
#include <iostream>

int main() {
    std::cout << "Project: " << PROJECT_NAME << std::endl;
    std::cout << "Version: " << VERSION_MAJOR << "." << VERSION_MINOR << std::endl;
    
#ifdef ENABLE_DEBUG
    std::cout << "Debug mode enabled" << std::endl;
#endif
    
    return 0;
}

4.11 执行代码片段:cmake_language

CMake在其3.18版本以后,提供了一个新的命令cmake_language,用以实现这种动态执行代码片段的功能。

示例:使用cmake_language动态执行代码

cmake_minimum_required(VERSION 3.18)

# 动态执行代码片段
cmake_language(EVAL CODE "message(STATUS \"Hello from eval\")")

# 动态执行代码块
set(code_block "
    set(DYNAMIC_VAR \"Dynamic Value\")
    message(STATUS \"Dynamic variable: \${DYNAMIC_VAR}\")
")
cmake_language(EVAL CODE "${code_block}")

# 调用函数
function(my_function arg)
    message(STATUS "Function called with: ${arg}")
endfunction()

cmake_language(CALL my_function "test argument")

# 动态定义和执行
set(script "
    function(dynamic_func)
        message(STATUS \"This is a dynamically defined function\")
    endfunction()
    dynamic_func()
")
cmake_language(EVAL CODE "${script}")

第5章 项目组织

[待补充内容]


第6章 CMake项目的生命周期

6.1 CMake项目的生命周期

现在通过一个静态库实例来演示CMake的配置和生成阶段。

CMake默认提供如下四种构建模式。

  • Debug调试模式,禁用代码优化,便于调试。
  • Release发布模式,启用代码优化并针对速度优化,启用内联并丢失调试符号,几乎无法调试。
  • RelWithDebInfo发布调试模式,启用代码优化,但保留符号且不会内联函数,仍可调试。
  • MinSizeRel最小体积发布模式,启用代码优化,但针对二进制体积进行优化,使其尽可能小

然后,创建一个C程序main.c,在其中调用mylib静态库提供的加法函数add,并输出1加2的结果。

完整示例:静态库项目

项目结构:

project/
├── CMakeLists.txt
├── mylib/
│   ├── CMakeLists.txt
│   ├── mylib.h
│   └── mylib.c
└── main.c

mylib/mylib.h:

#ifndef MYLIB_H
#define MYLIB_H

int add(int a, int b);

#endif

mylib/mylib.c:

#include "mylib.h"

int add(int a, int b) {
    return a + b;
}

mylib/CMakeLists.txt:

add_library(mylib STATIC mylib.c)
target_include_directories(mylib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(MyProject)

# 添加子目录(包含静态库)
add_subdirectory(mylib)

# 创建可执行文件
add_executable(main main.c)
target_link_libraries(main mylib)

main.c:

#include "mylib.h"
#include <stdio.h>

int main() {
    int result = add(1, 2);
    printf("1 + 2 = %d\n", result);
    return 0;
}

构建命令:

# 创建构建目录(源外部编译)
mkdir build
cd build

# 配置阶段(指定构建类型)
cmake .. -DCMAKE_BUILD_TYPE=Debug

# 或者使用其他构建类型
# cmake .. -DCMAKE_BUILD_TYPE=Release
# cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo
# cmake .. -DCMAKE_BUILD_TYPE=MinSizeRel

# 生成和构建阶段
cmake --build .

# 运行
./main

6.2 项目配置与缓存变量

CMake在配置阶段会产生持久化缓存文件。这些持久化缓存文件会在后续执行CMake时直接被加载,因此通常用于存储一些花费较大代价获取的信息。例如,CMake在第一次配置项目时,会检测当前编译环境,搜索确定编译器、链接器等工具的路径,并将这些信息保存到缓存中。总而言之,缓存变量能够被持久化,通常被用于实现对项目的配置。

在CMake配置阶段产生的持久化缓存文件CMakeCache.txt中,定义了CMake在该阶段通过对环境(如编译器、构建工具等)的检测所作出的默认配置。

CMAKE_<编程语言>_FLAGS,即<编程语言>编译器的参数选项列表。该配置对所有构建模式生效。图6.6中就有对C和CXX(即C++)编程语言的相关配置。

6.3 CMake命令行的使用

构建目录一般会在源文件目录之外,以免污染源文件目录。这种在源文件目录之外进行构建的方式,通常称为源外部编译(out-of-source build)。对应地,在源文件目录中直接生成构建的二进制文件的构建方式,称为源内部编译(in-source build)

下面看看用于配置和生成项目的CMake命令行该如何书写。

上述参数用于指定构建系统生成器的名称。若该参数被省略,CMake会先尝试读取CMAKE_GENERATOR环境变量中指定的值。若其值仍不存在,则选用默认的生成器。

isDirty,表示CMake是否从一个修改过的Git目录树中构建

示例:CMake命令行使用

# 1. 源外部编译(推荐)
mkdir build
cd build
cmake ..
cmake --build .

# 2. 指定生成器
cmake -G "Unix Makefiles" ..
cmake -G "Visual Studio 16 2019" ..
cmake -G "Ninja" ..

# 3. 设置缓存变量
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local

# 4. 指定构建类型和并行编译
cmake .. -DCMAKE_BUILD_TYPE=Debug
cmake --build . --parallel 4

# 5. 构建特定目标
cmake --build . --target mylib
cmake --build . --target install

# 6. 清理构建
cmake --build . --target clean

# 7. 查看缓存变量
cmake -L ..  # 列出所有缓存变量
cmake -LA .. # 列出所有缓存变量(包括高级选项)

# 8. 设置环境变量
CMAKE_GENERATOR="Ninja" cmake ..

CMakeLists.txt中检查构建类型:

cmake_minimum_required(VERSION 3.10)
project(MyProject)

# 设置默认构建类型(如果未指定)
if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release)
endif()

message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")

# 根据构建类型设置编译选项
if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    add_compile_options(-g -O0)
    add_definitions(-DDEBUG)
elseif(CMAKE_BUILD_TYPE STREQUAL "Release")
    add_compile_options(-O3 -DNDEBUG)
endif()

第7章 构建目标

7.1 二进制构建目标

二进制构建目标是全局可见的。也就是说,无论在哪个子目录的CMake目录程序中创建了一个二进制构建目标,都可以被当前项目中的其他所有CMake目录程序访问到。

该命令会创建一个可执行文件类型的构建目标,其中第一个参数<目标名称>是必选参数,且应当在项目中唯一。

目标名称并不一定是最终可执行文件的名称。最终生成的文件名可以通过OUTPUT_NAME目标属性来修改

EXCLUDE_FROM_ALL参数用于将该目标的EXCLUDE_FROM_ALL属性设置为真值。该属性表示是否将当前目标排除在表示构建全部的目标(all或ALL_BUILD)之外。当目标的EXCLUDE_FROM_ALL属性为真值时,它在项目构建时默认不会被构建,除非通过命令行参数显式地指定该构建目标,如使用cmake --build命令行的--target参数指定该构建目标。

如果想要构建myProgramExcludedFromAll目标,需要手动指定它:

示例:创建可执行文件目标

# 基本用法
add_executable(myapp main.cpp)

# 多个源文件
add_executable(myapp main.cpp utils.cpp helper.cpp)

# 排除在默认构建之外
add_executable(tool EXCLUDE_FROM_ALL tool.cpp)

# 修改输出文件名
add_executable(myapp main.cpp)
set_target_properties(myapp PROPERTIES OUTPUT_NAME "my_application")

# 构建时需要使用
# cmake --build . --target tool

示例:创建库目标

# 静态库
add_library(mylib_static STATIC mylib.cpp)

# 动态库
add_library(mylib_shared SHARED mylib.cpp)

# 模块库(插件)
add_library(mymodule MODULE mymodule.cpp)

# 根据BUILD_SHARED_LIBS决定库类型
set(BUILD_SHARED_LIBS ON)
add_library(mylib mylib.cpp)  # 将创建动态库

# 目标文件库
add_library(mylib_objects OBJECT obj1.cpp obj2.cpp)

# 使用目标文件库
add_executable(myapp main.cpp $<TARGET_OBJECTS:mylib_objects>)

一般库目标包括静态库目标、动态库目标和模块库目标,其定义形式如下。

<库类型>参数有以下三个取值。

  • STATIC,代表该构建目标为静态库构建目标。
  • SHARED,代表该构建目标为动态库构建目标。动态库构建目标的POSITION_INDEPENDENT_CODE属性会被设置为真值,以支持地址无关代码,相关原理参见第1章。
  • MODULE,代表该构建目标为模块库构建目标。模块库是一种插件形式的动态链接库,不会在构建时被链接到任何一个程序中,仅用于运行时动态链接(通过LoadLibrary或dlopen等API)。模块库构建目标的POSITION_INDEPENDENT_CODE属性也会被设置为真值。

若省略<库类型>参数,则目标库类型取决于CMake变量BUILD_SHARED_LIBS的值

如果一个库不导出任何符号名称,则它不能被声明为动态库(SHARED)

目标文件库类型的构建目标仅编译其包含的源文件,生成一系列目标文件,并不会将这些目标文件打包或链接到某个库文件中。因此目标文件库是一个逻辑上的概念,实际是很多目标文件的集合。创建一个目标文件库目标同样使用add_library命令,参数形式如下:

add_library(<目标名称> OBJECT [<源文件>...])

如果想在构建其他可执行文件或库时链接目标文件库对应的目标文件,只需在add_executable和add_library命令的<源文件>参数中指定下面这个表达式:

$<TARGET_OBJECTS:<目标文件库的目标名称>>

遍历目录中的源文件:aux_source_directory

aux_source_directory(<目录> <结果变量>)

7.2 伪构建目标

add_library(<目标名称> INTERFACE)

导入目标,顾名思义,指导入项目之外的可执行文件或库的目标,但不会构建它。可以想象,导入目标必然有一个与路径相关的属性,指向其导入的那个可执行文件或库。

可执行文件导入目标

使用add_executable命令可以创建一个可执行文件导入目标,对应参数形式如下。

add_executable(<目标名称> IMPORTED [GLOBAL])
add_library(<目标名称>
    <STATIC|SHARED|MODULE|UNKNOWN|OBJECT|INTERFACE>
    IMPORTED [GLOBAL])

STATIC、SHARED、MODULE参数分别用于导入外部的静态库、动态库和模块库。

add_executable(<目标名称> ALIAS <指向的实际目标名称>)
add_library(<目标名称> ALIAS <指向的实际目标名称>)

示例:接口库(头文件库)

# 创建接口库
add_library(header_only_lib INTERFACE)

# 设置使用要求
target_include_directories(header_only_lib INTERFACE
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

target_compile_features(header_only_lib INTERFACE cxx_std_17)

# 使用接口库
add_executable(myapp main.cpp)
target_link_libraries(myapp header_only_lib)

示例:导入目标

# 导入静态库
add_library(third_party_lib STATIC IMPORTED)
set_target_properties(third_party_lib PROPERTIES
    IMPORTED_LOCATION "/path/to/libthird_party.a"
    INTERFACE_INCLUDE_DIRECTORIES "/path/to/include"
)

# 导入动态库(Windows)
add_library(third_party_dll SHARED IMPORTED)
set_target_properties(third_party_dll PROPERTIES
    IMPORTED_LOCATION "C:/path/to/third_party.dll"
    IMPORTED_IMPLIB "C:/path/to/third_party.lib"
    INTERFACE_INCLUDE_DIRECTORIES "C:/path/to/include"
)

# 使用导入目标
add_executable(myapp main.cpp)
target_link_libraries(myapp third_party_lib)

示例:别名目标

# 创建库
add_library(mylib STATIC mylib.cpp)

# 创建别名
add_library(mylib::mylib ALIAS mylib)

# 使用别名(推荐方式,提供命名空间)
add_executable(myapp main.cpp)
target_link_libraries(myapp mylib::mylib)

7.3 子目录

add_subdirectory(<源文件目录> [<二进制目录>] [EXCLUDE_FROM_ALL])

该命令用于将<源文件目录>这个子目录加入项目。该子目录中必须含有一个CMake目录程序,即CMakeLists.txt。当CMake执行该命令时,会立即进入子目录中执行这个目录程序,而当前目录程序(即调用该命令的目录程序)的执行会被暂停,直到子目录的目录程序执行结束。

7.4 项目:project

project(<项目名称> [<编程语言>...])
project(<项目名称>
    [VERSION <主版本号>[.<次版本号>[.<补丁版本号>[.<修订版本号>]]]]
    [DESCRIPTION <项目描述>]
    [HOMEPAGE_URL <项目主页URL>]
    [LANGUAGES <编程语言>...])

示例:project命令的使用

# 基本用法
project(MyProject)

# 指定编程语言
project(MyProject C CXX)

# 完整版本信息
project(MyProject
    VERSION 1.2.3.4
    DESCRIPTION "A sample CMake project"
    HOMEPAGE_URL "https://example.com"
    LANGUAGES CXX
)

# 使用项目变量
message(STATUS "Project name: ${PROJECT_NAME}")
message(STATUS "Version: ${PROJECT_VERSION}")
message(STATUS "Version major: ${PROJECT_VERSION_MAJOR}")
message(STATUS "Version minor: ${PROJECT_VERSION_MINOR}")
message(STATUS "Version patch: ${PROJECT_VERSION_PATCH}")
message(STATUS "Version tweak: ${PROJECT_VERSION_TWEAK}")

7.5 属性:get_property、set_property

CMake中的属性根据作用域分为以下7种类型:

  • 全局属性;
  • 目录属性;
  • 目标属性;
  • 源文件属性;
  • 缓存变量属性;
  • 测试属性;
  • 安装文件属性。

宏定义构建要求

COMPILE_DEFINITIONS属性用于定义编译(预处理)C和C++程序时所用到的宏。该属性值为列表字符串,每一个元素都代表一个宏定义。其元素格式为<宏名称>或<宏名称>=<值>。

示例:设置和获取属性

# 设置目标属性
set_property(TARGET mytarget PROPERTY CXX_STANDARD 17)
set_property(TARGET mytarget PROPERTY CXX_STANDARD_REQUIRED ON)

# 获取目标属性
get_property(std TARGET mytarget PROPERTY CXX_STANDARD)
message(STATUS "C++ standard: ${std}")

# 设置目录属性
set_property(DIRECTORY PROPERTY MY_DIR_PROP "value")

# 获取目录属性
get_property(dir_prop DIRECTORY PROPERTY MY_DIR_PROP)

编译特性构建要求

COMPILE_FEATURES属性用于定义作为构建要求的编译特性,常用于指定C和C++语言标准的版本,也可以用于细粒度控制要求的编译特性,如C++的constexpr特性等。CMake会对编译器支持的编译特性进行检查。该属性仅作为目标属性使用。

# 设置C++标准
set_target_properties(mytarget PROPERTIES
    CXX_STANDARD 17
    CXX_STANDARD_REQUIRED ON
)

# 或使用target_compile_features
target_compile_features(mytarget PRIVATE cxx_std_17)
target_compile_features(mytarget PRIVATE cxx_constexpr)

头文件目录构建要求

INCLUDE_DIRECTORIES属性用于定义头文件的搜索目录,这些目录必须是绝对路径。

# 设置目标属性
set_target_properties(mytarget PROPERTIES
    INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/include"
)

如果设置当前构建目标的LINK_LIBRARIES属性值为某个库目标,CMake会将被链接的库目标及其依赖的使用要求递归传递到当前构建目标的构建要求中。

链接目录构建要求

LINK_DIRECTORIES属性用于定义构建时链接器的搜索目录,通常用于设置部分第三方库二进制文件的所在目录。

# 设置链接目录属性
set_target_properties(mytarget PROPERTIES
    LINK_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/libs"
)

7.6 属性相关命令

设置目标链接库:target_link_libraries

target_link_libraries(<构建目标> <库文件|库目标>...)
target_link_libraries(<构建目标>
    <PRIVATE|INTERFACE|PUBLIC> <库文件|库目标>...
    [<PRIVATE|INTERFACE|PUBLIC> <库文件|库目标>...]...)

该命令会将对<库文件|库目标>的链接过程作为<构建目标>的构建要求或使用要求,而具体是作为构建要求还是使用要求,取决于PRIVATE、INTERFACE和PUBLIC参数。

忽略该参数(即使用第一种命令形式)时,默认采用PUBLIC参数的方式,即将指定的所有<库文件|库目标>同时设置为<构建目标>的构建要求和使用要求。

<构建目标>参数不可以是别名目标,因为别名目标是只读的构建目标。所有用于设置目标属性的命令均不能用于别名目标

静态库相当于目标文件的集合,因此构建静态库不需要真正链接其依赖的其他库。静态库及其依赖的库都会在最终被链接到动态库或可执行文件时一起被链接。

PRIVATE参数表示<构建目标>使用但不传递其后<库目标>的使用要求,即<库目标>的使用要求不作为<构建目标>的使用要求,而仅作为其构建要求。

  • INTERFACE参数表示<构建目标>仅传递其后<库目标>的使用要求,即<库目标>的使用要求会作为<构建目标>的使用要求,但不会作为其构建要求。
  • PUBLIC参数表示<构建目标>使用并传递其后<库目标>的使用要求,即<库目标>的使用要求会同时作为<构建目标>的使用要求和构建要求。

target_link_libraries命令在<库文件|库目标>参数是一个 <库目标>时有两层含义:一方面定义了<构建目标>与<库目标>的依赖关系,另一方面定义了<库目标>使用要求的传递方式。

PUBLIC = PRIVATE (构建要求) + INTERFACE (使用要求)

  • 接口库(如头文件库)、导入库等伪构建目标不存在构建过程,所有构建相关的属性都应使用INTERFACE参数定义为使用要求;
  • 当构建目标仅在内部代码实现中使用某库,而不暴露依赖库的部分在构建目标的接口(头文件)中时,应使用PRIVATE参数链接依赖的库;
  • 当构建目标仅在接口(头文件)中暴露了依赖库的部分,而没有在内部代码实现中使用该依赖库时,应使用INTERFACE参数链接依赖的库;
  • 当构建目标在接口(头文件)中暴露了依赖库的部分,同时也在内部代码实现中使用了该依赖库时,应使用PUBLIC参数链接依赖的库。

示例:PRIVATE、INTERFACE、PUBLIC的使用

# 库A:内部实现使用了math库
# math_lib.h
add_library(math_lib STATIC math.cpp)

# 库B:接口中暴露了math库的类型
# mylib.h
add_library(mylib STATIC mylib.cpp)
target_link_libraries(mylib 
    PRIVATE math_lib      # 仅在内部使用
    INTERFACE header_lib   # 仅在接口中暴露
    PUBLIC common_lib      # 内部和接口都使用
)

# 可执行文件
add_executable(myapp main.cpp)
target_link_libraries(myapp mylib)
# myapp会自动链接到common_lib(因为PUBLIC传递)
# 但不会链接到math_lib(因为PRIVATE不传递)

完整示例:构建要求和使用要求的传递

# 头文件库
add_library(header_only INTERFACE)
target_include_directories(header_only INTERFACE include/)

# 静态库(内部使用math库)
add_library(math_lib STATIC math.cpp)

# 接口库(暴露header_only)
add_library(mylib STATIC mylib.cpp)
target_include_directories(mylib 
    PUBLIC include/          # 使用者和构建者都需要
    PRIVATE src/            # 仅构建者需要
)
target_link_libraries(mylib 
    PRIVATE math_lib        # 仅构建时需要
    INTERFACE header_only   # 仅使用者需要
)

# 可执行文件
add_executable(myapp main.cpp)
target_link_libraries(myapp mylib)
# myapp会自动获得:
# - include/目录(PUBLIC传递)
# - header_only(INTERFACE传递)
# 但不会获得:
# - src/目录(PRIVATE不传递)
# - math_lib(PRIVATE不传递)

设置头文件目录:include_directories

include_directories([AFTER|BEFORE] [SYSTEM] <目录>...)

该命令仅对当前目录及其子目录中的构建目标生效,用于将<目录>设置为构建目标的头文件搜索目录。<目录>可以是绝对路径或相对于当前源文件目录的相对路径。

调用该命令后,<目录>会被同时加入到当前目录程序的INLCUDE_DIRECTORIES目录属性,以及当前目录程序中定义的构建目标的INLCUDE_DIRECTORIES目标属性中。

设置目标头文件目录:target_include_directories

target_include_directories(<构建目标>
    [SYSTEM] [AFTER|BEFORE]
    <PRIVATE|INTERFACE|PUBLIC> <目录>...
    [<PRIVATE|INTERFACE|PUBLIC> <目录>...]...)

该命令用于将<目录>加入到<构建目标>的头文件搜索目录列表中。<目录>可以是绝对路径或相对于当前源文件目录的相对路径。其他参数与include_directories命令中的对应参数功能一致。

设置链接库:link_libraries

link_libraries([库文件或库目标]...)

该命令仅对当前目录及其子目录中的构建目标生效,用于将<库文件或库目标>链接到构建目标中。只有在该命令调用之后创建的构建目标会被设置链接库属性。

设置链接目录:link_directories

link_directories([AFTER|BEFORE] <目录>...)

该命令仅对当前目录及其子目录中的构建目标生效,用于将<目录>设置为构建目标的链接库搜索目录。<目录>可以是绝对路径或相对于当前源文件目录的相对路径。

设置目标链接目录:target_link_directories

target_link_directories(<构建目标>
    [BEFORE]
    <PRIVATE|INTERFACE|PUBLIC> <目录>...
    [<PRIVATE|INTERFACE|PUBLIC> <目录>...]...)

该命令用于将<目录>设置为<构建目标>的链接库搜索目录。<目录>可以是绝对路径或相对于当前源文件目录的相对路径。

7.6.16 无须递归传递的例程

# 示例:无需递归传递的情况
add_library(utils STATIC utils.cpp)
target_include_directories(utils PRIVATE utils_private/)

add_executable(myapp main.cpp)
target_link_libraries(myapp utils)
# myapp不会获得utils_private/目录(PRIVATE不传递)

7.6.17 存在间接引用的例程

# 示例:间接引用
add_library(libA STATIC libA.cpp)
add_library(libB STATIC libB.cpp)
target_link_libraries(libB PUBLIC libA)  # libB公开链接libA

add_executable(myapp main.cpp)
target_link_libraries(myapp libB)
# myapp会自动链接libA(通过libB的PUBLIC传递)

7.9 设置依赖关系:add_dependencies

设置依赖关系:add_dependencies

有时候我们希望构建目标A在构建目标B构建之后再构建,也就是说,构建目标A依赖构建目标B。通常来说,如果通过target_link_libraries为构建目标A链接了构建目标B,那么这个依赖关系会自动被建立。不过有时,这两个构建目标并没有这种明显的链接关系,如某个可执行文件可能在运行时依赖模块库(参见7.1.2小节),但模块库并不能被链接。此时就需要借助add_dependencies命令来显式地指定二者的依赖关系:

add_dependencies(<构建目标> [<依赖的构建目标>]...)

示例:显式设置依赖关系

# 生成代码的工具
add_executable(code_generator generator.cpp)

# 生成的文件
add_custom_command(
    OUTPUT generated.cpp
    COMMAND code_generator ${CMAKE_CURRENT_SOURCE_DIR}/template.in
            ${CMAKE_CURRENT_BINARY_DIR}/generated.cpp
    DEPENDS generator.cpp template.in
)

# 使用生成的文件
add_executable(myapp main.cpp generated.cpp)

# 显式设置依赖:确保code_generator在生成generated.cpp之前构建
add_dependencies(myapp code_generator)

# 模块库示例
add_library(plugin MODULE plugin.cpp)
add_executable(myapp main.cpp)

# 运行时依赖模块库,但无法链接
add_dependencies(myapp plugin)
# 确保plugin在myapp之前构建

7.10 小结

很多属性都有专门的设置命令,而且常常是一对命令:一个用于设置目录属性及目录中构建目标的属性,如include_directories;一个用于设置指定构建目标的属性,如target_include_directories。

CMake 3.0以后的版本之所以被称为现代CMake,正是因为它开创了面向目标的构建配置时代。总而言之,面向目标的属性设置能够将构建目标的配置相互解耦,是现代CMake推崇的做法。


第8章 生成器表达式

**生成器表达式(generator expression)**是由CMake生成器进行解析的表达式,因此,这些表达式只有在CMake的生成阶段才被解析为具体的值

生成器表达式的语法是$<...>,在配置阶段不会被解析,只有在生成阶段才会被解析为具体的值。

示例:生成器表达式的使用

# 条件表达式
target_compile_definitions(mytarget PRIVATE
    $<$<CONFIG:Debug>:DEBUG_MODE>
    $<$<CONFIG:Release>:RELEASE_MODE>
)

# 目标文件表达式
add_executable(myapp main.cpp $<TARGET_OBJECTS:myobjects>)

# 路径表达式
target_include_directories(mytarget PRIVATE
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    $<INSTALL_INTERFACE:include>
)

# 字符串比较
target_compile_options(mytarget PRIVATE
    $<$<CXX_COMPILER_ID:GNU>:-Wall>
    $<$<CXX_COMPILER_ID:MSVC>:/W4>
)

# 逻辑表达式
target_compile_definitions(mytarget PRIVATE
    $<$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Linux>>:LINUX_DEBUG>
)

# 获取属性值
target_include_directories(mytarget PRIVATE
    $<TARGET_PROPERTY:other_target,INTERFACE_INCLUDE_DIRECTORIES>
)

# 安装路径生成器表达式
install(TARGETS mylib
    LIBRARY DESTINATION $<IF:$<PLATFORM_ID:Windows>,bin,lib>
    ARCHIVE DESTINATION lib
)

常用生成器表达式:

# 配置相关
$<CONFIG:Debug>           # 如果是Debug配置返回1
$<CONFIG:Release>         # 如果是Release配置返回1

# 编译器相关
$<CXX_COMPILER_ID:GNU>    # 如果是GCC编译器返回1
$<CXX_COMPILER_ID:MSVC>    # 如果是MSVC编译器返回1

# 平台相关
$<PLATFORM_ID:Linux>      # 如果是Linux平台返回1
$<PLATFORM_ID:Windows>    # 如果是Windows平台返回1

# 目标相关
$<TARGET_FILE:mytarget>   # 目标文件的完整路径
$<TARGET_LINKER_FILE:mytarget>  # 链接文件路径

# 构建/安装接口
$<BUILD_INTERFACE:...>    # 构建时使用
$<INSTALL_INTERFACE:...>  # 安装时使用

8.4 小结

生成器表达式是在CMake的生成阶段解析的

由于CMake是构建系统的生成器,它自身不会独立进行程序的构建,所以CMake在生成阶段就不得不与目标构建系统有所耦合,很多信息也不得不等到生成阶段才能获得。


第9章 模块

CMake模块程序与脚本程序具有相同的扩展名,都是.cmake,但不同的是,脚本程序可以看作一个入口程序,或者说主程序,能够独立执行,而模块程序则是代码复用单元,通常用于提供一些辅助功能等,如同CMake语言的类库,通常会被CMake目录程序或脚本程序引用。

9.2 常用的预置功能模块

9.2.3 用于生成导出头文件的模块:GenerateExportHeader

9.3 查找模块

**查找模块(find module)**是一系列用于搜索第三方依赖软件包(包括库或可执行文件)的模块。对查找模块的引用一般不使用include命令,而是使用find_package命令

该命令会调用名为"Find<软件包名>.cmake"的查找模块来完成对软件包的搜索

示例:使用find_package查找第三方库

# 查找OpenSSL
find_package(OpenSSL REQUIRED)
if(OpenSSL_FOUND)
    message(STATUS "OpenSSL found: ${OPENSSL_LIBRARIES}")
    include_directories(${OPENSSL_INCLUDE_DIR})
    target_link_libraries(myapp ${OPENSSL_LIBRARIES})
endif()

# 查找Boost(使用组件)
find_package(Boost REQUIRED COMPONENTS system filesystem)
if(Boost_FOUND)
    message(STATUS "Boost version: ${Boost_VERSION}")
    target_include_directories(myapp PRIVATE ${Boost_INCLUDE_DIRS})
    target_link_libraries(myapp ${Boost_LIBRARIES})
endif()

# 查找Qt5
find_package(Qt5 REQUIRED COMPONENTS Core Widgets)
target_link_libraries(myapp Qt5::Core Qt5::Widgets)

# 查找Python
find_package(Python3 COMPONENTS Interpreter Development)
if(Python3_FOUND)
    message(STATUS "Python found: ${Python3_EXECUTABLE}")
    target_include_directories(myapp PRIVATE ${Python3_INCLUDE_DIRS})
    target_link_libraries(myapp ${Python3_LIBRARIES})
endif()

示例:创建自定义查找模块

# FindMyLib.cmake
find_path(MYLIB_INCLUDE_DIR
    NAMES mylib.h
    PATHS
        /usr/include
        /usr/local/include
        ${CMAKE_SOURCE_DIR}/third_party/include
)

find_library(MYLIB_LIBRARY
    NAMES mylib
    PATHS
        /usr/lib
        /usr/local/lib
        ${CMAKE_SOURCE_DIR}/third_party/lib
)

include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(MyLib
    FOUND_VAR MYLIB_FOUND
    REQUIRED_VARS MYLIB_LIBRARY MYLIB_INCLUDE_DIR
)

if(MYLIB_FOUND)
    set(MYLIB_LIBRARIES ${MYLIB_LIBRARY})
    set(MYLIB_INCLUDE_DIRS ${MYLIB_INCLUDE_DIR})
endif()

mark_as_advanced(MYLIB_INCLUDE_DIR MYLIB_LIBRARY)

使用自定义查找模块:

# 将FindMyLib.cmake放在CMAKE_MODULE_PATH中
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

find_package(MyLib REQUIRED)
target_link_libraries(myapp ${MYLIB_LIBRARIES})
target_include_directories(myapp PRIVATE ${MYLIB_INCLUDE_DIRS})

第10章 CMake策略

10.1 CMake策略(以CMP0115为例)

**CMake策略(policy)**的名称以CMP开头,后面跟着一个代表策略编号的四位整数,如CMP0115就是第115号策略。每一个策略都对应着新旧两种不同的行为:NEW行为和OLD行为。

CMP0115的详情页面中可以了解到,在CMake 3.19及以前的版本中,为add_executable等命令指定<源文件>参数时,可以省略源文件的扩展名,如add_executable(A main),CMake会自动根据项目采用的编程语言尝试为其添加扩展名并查找对应的源文件。然而在CMake 3.20及以后的版本中,CMake要求<源文件>参数必须显式指定文件的扩展名,如add_executable(A main.cpp)。

示例:CMP0115策略的影响

# CMake 3.19及以前(OLD行为)
# 可以省略扩展名,CMake会自动查找
add_executable(myapp main)  # CMake会查找main.cpp或main.c

# CMake 3.20及以后(NEW行为)
# 必须显式指定扩展名
add_executable(myapp main.cpp)  # 正确
# add_executable(myapp main)    # 错误:无法找到文件

示例:管理策略行为

# 设置策略为NEW行为
cmake_policy(SET CMP0115 NEW)

# 或者使用版本号自动设置策略
cmake_minimum_required(VERSION 3.20)  # 自动设置CMP0115为NEW

10.2 指定CMake最低版本要求:cmake_minimum_required

cmake_minimum_required(VERSION <最低版本>)

示例:指定CMake版本要求

# 必须在CMakeLists.txt的最开始
cmake_minimum_required(VERSION 3.10)

# 推荐同时指定项目和版本
cmake_minimum_required(VERSION 3.18)
project(MyProject VERSION 1.0.0)

# 版本要求会影响策略行为
# CMake 3.18会自动设置CMP0115为NEW(如果适用)

10.3 管理策略行为:cmake_policy

图10.1 不同版本对应的策略行为

示例:策略管理

cmake_minimum_required(VERSION 3.10)

# 设置特定策略
cmake_policy(SET CMP0115 NEW)

# 获取策略状态
cmake_policy(GET CMP0115 policy_status)
message(STATUS "CMP0115 status: ${policy_status}")

# 如果需要兼容旧代码,可以临时使用OLD行为
cmake_policy(PUSH)
cmake_policy(SET CMP0115 OLD)
# 这里可以使用旧行为
add_executable(old_style main)  # 旧行为允许
cmake_policy(POP)  # 恢复之前的策略设置

完整的CMakeLists.txt示例(包含策略管理):

# 第一行必须是cmake_minimum_required
cmake_minimum_required(VERSION 3.18)

# 设置项目信息
project(MyProject
    VERSION 1.0.0
    DESCRIPTION "A CMake tutorial project"
    LANGUAGES CXX
)

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

# 添加可执行文件(必须显式指定扩展名)
add_executable(myapp main.cpp)

# 如果使用CMake 3.20+,下面这行会报错
# add_executable(myapp main)  # 错误:需要显式扩展名

参考资料

  • 《CMake构建实战:项目开发卷》- 许宏旭
  • 整理时间:2025/11/05
  • 来源:微信读书

本文档内容基于原书读书笔记整理,保留了所有原文内容,并进行了结构化组织以便学习。