代码仓库:melonedo/cmake-lecture

讲义:现代CMake入门

C项目

由于庞大的历史遗留问题,C(或C++)项目的编译是老大难问题。吸取了C的教训,如go、rust一类新的语言都会尽可能地把编译程序的方法标准化。然而C的标准仅仅确定了本身的语言,对于代码如何编译为二进制文件、不同的二进制文件间如何复用等,只能借由C的霸主地位,成为操作系统/编译器的“基础知识”。

通常一个C项目包括以下几个部分:

  • 头文件.h:头文件是项目内部或者项目外部调用API的接口说明。
  • 源文件.c:源文件是项目接口的实现方法,二进制文件的来源。
  • 依赖:项目需要依赖的外部库,通常包括头文件和源文件。
  • 编译指令:这个项目需要的头文件的路径、源文件的路径、依赖的路径以及编译需要的特殊要求,都需要在编译指令中给出。

C项目生成的结果无非两种:可执行文件和库。除非是临时调试,可执行文件和库最终都将会被打包分发到最终用户的电脑中。对于可执行文件来说,代码是无关紧要的,我们只要保证拥有二进制文件即可,也就是需要分发可执行文件本身及依赖(或者给出安装依赖的方法)。而库的最终用户也还是其他的码农,因此需要包括对应的头文件及编译指令,也就是需要分发公共头文件、二进制文件、依赖和编译指令。

源文件通常不直接分发,而是编译为静态或动态链接库再分发。对于使用者,这两种库的区别主要是静态链接库需要的部分会被复制到目标文件中,而动态链接库需要把库本身随着目标一同分发。

管理C项目

管理这样一个C项目的方法也是多如牛毛。使用Windows的项目中经常会使用Visual Studio的项目进行管理,而*nix上的项目很多使用autoconf、make等工具管理,而各种IDE也都有自己的项目格式。由于上述的混乱局面,新世纪出现了CMake、meson、ninja、xmake等一系列工具用于为上述复杂的项目格式再提供一个抽象层,目前推广最成功的是CMake。

CMake并不直接调用编译器,而只是提供一个描述C项目的DSL。根据一个项目编译要求的描述,CMake调用Generator生成一个可供VS、make、Xcode等编译管理工具使用的项目,再用编译。接受CMake仅作为一个抽象层这一点对于理解CMake是非常关键的,虽然CMake有一个图灵完备的DSL,但是这个DSL仅仅是生成项目,而不能直接在项目中执行。

CMake基本语法

字符串

和常见shell类似,CMake中基本的数据类型只有字符串。字符串总是作为函数的参数,有三种表示方法,分别是无引号参数"引号参数"[[方括号参数]],各自有不同的语法,不细说。和shell类似但和C等常见的语言不同,name仅仅代表一个字符串,对应"name"或者[[name]],而不是一个变量的引用。

变量环境变量

变量和环境变量(曾)是CMake中非常重要的概念,使用set赋值,用"前缀${变量名}后缀"的形式可以把前缀、变量、后缀的内容连接为一个字符串。要注意的是,无引号参数${变量}的形式会把变量当作一个列表,各个成员作为多个参数,即splat。因此最好使用"${变量}"的形式防止一不小心变量被当成列表。

函数

和常见的语言一样,函数和宏是CMake中代码复用的方法。CMake中函数的特点是没有返回值,参数只能接收一大堆字符串,不能搞函数式什么的(直到2020年的3.18版本才有CALL的玩法)。要返回东西,可以用set(${返回的变量名} PARENT_SCOPE)或宏来解决。宏和函数的区别只是宏本身没有作用域,返回直接可以直接set,但中间变量也会泄露到调用函数中。函数调用不区分大小写。

参数格式

CMake中函数做的事情远远比通用编程语言中复杂,参数的格式也非常地繁复。因此CMake提供了一个标准的参数解析器cmake_parse_arguments,即除了前几个按照顺序的参数外,都不需要按照顺序给出,且参数间通过一些关键词分隔,没有特殊的语法。

对象和属性

虽然属性在CMake 3.0开始就已经实装,并成为现代CMake推荐使用的配置方法,很多新的特性也只支持属性,但很多复古的教程中还没有普及属性,因此本文将重点介绍属性。

一个对象的属性使用set_property赋值,并使用get_property获取。CMake中的对象包括了全局(GLOBAL),目录(DIRECTORY),目标(TARGET)等,如指定生成的VS解决方案的默认启动项可用set_property(DIRECTORY "${PROJECT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT main)

生成器表达式

由于CMake DSL只能在描述项目时运行,此时一些信息还不能获取,因此可以采用生成器表达式$<...>把一些逻辑推移到最终生成项目时再使用。典型的例子是configuration此时可能是不确定的,并且同一个属性可能会被编译、连接、安装等多个环节运行,这些都需要用生成器表达式的方法区分。

用CMake描述一个C项目

了解CMake DSL显然不足以用于实际,于是让我们在几个简单的示例中体会CMake描述项目的方法。

生成可执行文件

CMake项目是一个包括了CMakeLists.txt的文件夹。首先新建一个文件夹,文件夹中新建CMakeLists.txt作为这个项目的描述,然后再在合适的目录结构中写入对应C代码。可执行文件的目录结构比较随意,我的选择是

hello
├──CMakeLists.txt
└──source
   └──hello.cpp

hello.cpp的内容是

#include <iostream>
int main() {
    std::cout << "Hello!" << std::endl;
}

CMakeLists.txt的内容是

cmake_minimum_required(VERSION 3.0)
project(hello LANGUAGES CXX)
add_executable(hello source/hello.cpp)

下面逐行讲解各CMake函数的作用

cmake_minimum_required(VERSION 3.0)

和C++一样,CMake保持完全的向后兼容性。完全兼容并不意味着所有的功能不能更新,CMake还是会在每个版本丰富现有的功能。因此我们需要在一个CMakeLists.txt的最开头写上cmake_minimum_required确定使用的版本。

project(hello LANGUAGES CXX)

project新建一个CMake项目。项目的名称hello会被用在IDE的项目名。 project的参数中最重要的是LANGUAGES,这个参数决定了启用的语言。可用的语言包括CCXXCUDAFORTRAN等,默认启用CCXX。这里我们只使用c++,因此手动指定了不用C,这样可以减少生成项目用的时间。在利用CMake编译CUDA程序中我介绍了使用CMake管理CUDA项目的方法,即在调用project时启用CUDA语言,然后一切和普通C项目没有区别。

add_executable(hello source/hello.cpp)

add_executable添加一个可执行文件,并列出编译需要的源文件。在后续也可以用target_sources补充需要的源文件。

生成项目并编译

至此我们已经说明了编译这个可执行文件的所有要求,可以开始编译了。最古老的编译指令是:

mkdir build
cd build
cmake ..
cmake --build . # 也可能直接就开始`make`了

如果CMake版本在3.13以上,则可以使用-B参数避免新建文件夹和cd的麻烦:

cmake -B build # CMake 3.13
cmake --build build

如果是VS,CMake还提供打开IDE项目的功能,可以使用熟悉的F5大法运行和调试:

cmake --open build # CMake 3.11

生成动态链接库

但都用上了这么复杂的管理工具了,还只生成普普通通的可执行文件有什么意思呢,何不试试生成个可以在其他语言中调用的动态链接库!

下面这个项目生成一个库libadd,提供一个函数add可以把两个整数相加。同时还定义了一个可执行文件使用上述的库。如果想把这个库整个打包,可以参考CMake导出及安装包模板

add
├──CMakeLists.txt
├──include
|  └──add.h
└──source
   ├──add.c
   ├──test-add.c
   └──test-add.py

add.h的内容是

#ifndef ADD
#define ADD
#include "add_export.h"
ADD_EXPORT int add(int a, int b);

#endif /* ADD */

add.c的内容是

#include "add.h"
int add(int a, int b) { return a + b; }

test-add.c的内容是

#include "add.h"
#include "stdio.h"

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

CMakeLists.txt的内容是

cmake_minimum_required(VERSION 3.0)
project(add LANGUAGES C)

# 动态链接库本身
add_library(add SHARED)
target_sources(add PRIVATE source/add.c)
target_include_directories(add PUBLIC include)

# 处理DLL_EXPORT
include(GenerateExportHeader)
generate_export_header(add)
target_include_directories(add PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

# 使用动态链接库
add_executable(test-add source/test-add.c)
target_link_libraries(test-add PRIVATE add)

# CMake的小功能,这么写可以指定VS启动项
set_property(DIRECTORY "${PROJECT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT test-add)

说明动态链接库

下面三行指定了动态链接库的名称、包含路径和源文件。add_library(SHARED)已经表面了这个库是动态链接库,不需要再手动地指定fPIC之类的编译选项。

add_library(add SHARED)
target_sources(add PRIVATE source/add.c)
target_include_directories(add PUBLIC include)

处理DLL_EXPORT

Windows中符号默认是不导出到DLL中的,而Linux是默认导出。无论默认是什么,标注清楚什么符号是导出的总是好事。为此我们通常会编写一个XXX_export.h的头文件声明DLL_EXPORT之类的宏,CMake为我们提供了一个简单的方法生成:

# 这个功能由一个module提供,需要include
include(GenerateExportHeader)
generate_export_header(add)
# 跟上面一样,添加包含路径
target_include_directories(add PUBLIC ${CMAKE_CURRENT_BINARY_DIR})

如果觉得这样太麻烦,可以把上述三行替换成set_target_properties(add PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON),并且头文件中省略ADD_EXPORT。这样会默认导出所有符号。

使用动态链接库

有了CMake的支持,使用动态链接库非常地简单:

add_executable(test-add source/test-add.c)
target_link_libraries(test-add PRIVATE add)

target_link_libraries并不是一个简单的添加链接的库的命令,实际上在CMake中是用于添加依赖。这里添加的依赖包括了一个C项目的所有部分,即包括了头文件、二进制文件、编译选项。也就是说,target_link_libraries(test-add PRIVATE add)实际上把add的dll文件、头文件包含路径及其他需要编译选项都纳入了test-add的编译要求中。

VS调试运行

cmake -B build
cmake --open build
# 在VS中F5

在python中调用

不妨试试在python中用ctypes来调用这个库。test-add.py的内容是

from ctypes import CDLL, c_int
# python搜索动态链接库的具体方法和系统有关,但给定完整路径总不会错。
libadd_path = "add.dll的完整路径"
libadd = CDLL(libadd_path)
libadd.add.argtypes = [c_int, c_int]
libadd.add.restype = c_int
print(libadd.add(1,2))
# 输出3

注:使用以下指令在cmake --build build --target run-python时自动填充libadd_path。test-add.py中改为libadd_path = r"${LIBADD_PATH}"

file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/configure-python.cmake
    "configure_file(${CMAKE_CURRENT_SOURCE_DIR}/source/test-add.py ${CMAKE_CURRENT_BINARY_DIR}/test-add.py)")
add_custom_command(OUTPUT test-add.py VERBATIM
    COMMAND ${CMAKE_COMMAND} -D LIBADD_PATH=$<TARGET_FILE:add> -P ${CMAKE_CURRENT_BINARY_DIR}/configure-python.cmake)
find_package(Python3 COMPONENTS Interpreter)
add_custom_target(run-test-add-python COMMAND Python3::Interpreter test-add.py DEPENDS test-add.py)

对象、属性、初始化和继承

上文中使用了好几个target_XXX系列的函数,我留到现在再详细说明。在CMake中,只有属性是最终影响生成的结果的,变量和函数都只能间接地通过影响各种对象的属性来描述项目。参考cmake-properties(7),可以看到CMake有非常大量的属性可供配置,这些属性都对应了大大小小的功能。同样在这个页面可以看到,CMake中具有属性的对象包括全局、目录、目标、源文件等。

初始化

但很显然我们并不会关心大部分的属性,CMake默认提供的值已经足够合理。实际上,给各个属性提供的默认值来自一些变量。比如目标的C_STANDARD属性来自变量CMAKE_C_STANDARD。改变这个变量会改变所有目标要求的C标准。

但要注意上述默认值的填充发生在一个对象被创建时。通常这只和目标(target)有关,而目标的创建发生在add_executable或者add_library调用时。这意味着在创建了可执行文件后,如CMAKE_CXX_FLAGS之类的变量就不会再影响该目标的属性。

除了全局变量外,目录的属性也可以用来初始化目标的属性,这对应一系列以add_开头的函数,但这样同样是比较复杂的全局状态,不如后面的继承好用,因此不推荐使用。

继承

上面使用动态链接库的示例中我们可以看到,一个target_link_libraries就可以把一个库的几乎所有使用的要求都传递给用这个库的目标。这说明实际上CMake具有一定的继承机制。

CMake DSL中,target_XXX系列指定属性的命令,如target_link_options都会要求用户明确这个属性的继承要求,即PRIVATEPUBLIC或者INTERFACE。当一个属性是PRIVATE时,这个属性只对当前的目标生效;当一个属性是INTERFACE时,这个属性只对依赖了这个目标的对象生效;当一个属性是PUBLIC时,这个属性同时对当前对象和依赖了这个目标的对象生效。当使用target_link_libraries指定了一个目标的依赖时,这个依赖所有的INTERFACE或者PUBLIC属性都会自动成为这个目标的依赖,不需要额外的说明。

内部实现:这些函数修改的属性都包括两个版本,一个版本有INTERFACE_前缀,另一个版本没有。前一个版本储存INTERFACE的内容,后一个版本储存PRIVATE的内容,PUBLIC的内容会同时存储在两个版本中。 注:由于XXX_link_libraries用于声明依赖,如果真的要用来声明连接的库的话,使用$<LINK_ONLY:...>来限制。

由于有了继承,当我们想要使用一个库时,只需要用target_link_libraries声明依赖即可,不需要再重复地说明各种编译选项。

不过这个继承关系只限于给定的几个编译选项,如果要复用其他属性的话,可以自定义函数来初始化这些属性。

用继承控制语境

在用继承的方法来编写CMake普及之前,网上已经有大量的代码使用各种属性对应的初始化变量来编写CMake代码了。在CMake中使用全局变量的坏处和在通用编程语言中使用全局变量的影响是一样的,都不利于控制代码的副作用,因此建议把这样的代码改为直接使用属性和继承的方法编写。改变的方法也非常简单,只需要把对变量的修改改为对文件的修改即可。

第三方库

用继承的方式管理依赖实在是比用一大堆变量来得方便,尤其是这个依赖可以跨越项目的界限时。CMake支持将当前项目的目标和变量导出为XXXConfig.cmake,并和生成的二进制文件打包到一起。这些配置文件可以用find_package命令加载。而对于不使用CMake的项目,CMake以FindXXX的形式模拟上述的支持。CMake分发的FindXXX模组可见Find Modules

对于导出项目的方法,查看CMake导出及安装包模板

配置(configure)、导出(export)和编译(build)

CMake支持多种后端,这也意味着CMake需要支持多种后端选择配置(configuration)的方法。实际上需要支持的方法只有两种,在导出时决定要使用什么配置,如make,通过指定CMAKE_BUILD_TYPE变量来选择配置;在导出时导出所有支持的配置,如VS项目,这时候CMake会根据CMAKE_CONFIGURATION_TYPES变量的内容来导出所有的配置。

CMake在根据一个项目生成编译文件前,首先会运行项目的CMake代码,生成各种对象并确定对应的属性,同时也可以生成一些文件。当生成项目时,所有用户编写的CMake项目代码都已经执行完毕,只作为一个整体的数据。但此时要使用的配置还未确定,也就是说,一个CMake项目无法在CMake DSL运行期间知道使用的配置,而只能为所有的配置都描述对应的要求。

CMake在这个问题上的解决方法是多样的。一方面,CMake中很多的变量和属性都有带配置名字的变体,如代表导入的库的名字的属性IMPORTED_LOCATION有一个对应不同配置的版本IMPORTED_LOCATION_<CONFIG>;另一方面,CMake专门提供了在导入时可以运行的CMake命令,即生成器表达式$<...>。这两个方法都可以达到在导出时做出运算的目的。

相关阅读

awesome-cmake

An Introduction to Modern CMake

Bottom-up CMake introduction

The Ultimate Guide to Modern CMake

It’s Time To Do CMake Right