本文已摆烂,请见现代CMake入门

CMake是一个C语言的项目管理器,用于简便地指明一个C项目构建(build)应用/库的方法,并生成编译脚本(如Makefile)或IDE项目文件(vs解决方案sln),用于最终编译C文件。 相比于其他的的项目管理器,CMake的优点是:

  • 懂C。相比于通用的make,CMake的项目组织方法和C的特性息息相关,并且内置了众多常见的编译选项,可以避免记忆常见的命令参数在不同环境下的组合。
  • 通用。一方面大量项目使用CMake管理,CMake众多C工具中的最大公约数,可以用于生成VS的项目,也可以支持VSCode,也可以在*nix环境中适应gcc或clang。同时新的工具如ninja也都会适配CMake生成器。
  • 可以导入其他项目。一般的cpp项目管理器都不支持导入现成的项目,要手动配置编译参数。虽然支持的项目没那么多,但CMake仍是自动配置参数的唯一希望。

C项目

要理解CMake,首先要知道C项目指的是什么。C项目包括源代码(源文件.c/.cpp和头文件.h)以及对应的编译选项,生成若干个目标(target)。目标指的是可直接执行的应用(executable),或可以被用于构建新目标的(library)。

  • 头文件:即.h.hpp,是代码中不编译而直接共享的部分,包含源文件的接口,以及不编译为机器码的内联函数等。
  • 源文件:即.c.cpp,包含可以被编译为机器码的各种函数,在编译后只作为参考,通常不共享。
  • 目标(二进制文件):由源文件编译出的机器码,可以直接执行,也可以连接到其他的目标中。
  • 编译选项:编译过程中各种各样的编译要求,有时只对当前的库的编译产生影响,有时存在以来,比如如果外部库中不启用RTTI,那么使用的库也不能使用RTTI,这是用编译选项控制的。

如果只要把代码编译成一个应用,那么最终分发的通常仅仅是一个可执行文件,如果有特殊的动态库的以来,还需要保证用户拥有正确的动态库。但如果要把代码编译成一个库,则需要分发的内容不只包括运行时需要的动态库,还包括了这个库的头文件、库文件和需要的编译选项,这是C库分发的比较困难的地方,也是CMake建立抽象层的主要根据。

直接输入命令

下面我们以一个简单的项目为例,分别说明编译C代码的指令、用Make生成的方法、以及用CMake生成的方法。

这个项目需要生成一个动态库libhello,其中函数hello()会在标准输出流打印”Hello”,然后编写一个C应用调用这个动态库。代码如下:

// inc/hello.h
void hello(void);

// src/hello.c
#include <stdio.h>
void hello(void) { puts("Hello"); }

// main.c
#include "hello.h"
int main() { hello(); }

在*nix环境中,我们可以使用gcc生成并调用动态库:

mkdir -p build
gcc -c -o build/hello.o -fpic -I inc src/hello.c
gcc -shared -o build/libhello.so build/hello.o
gcc -o build/main -I inc main.c -L build -l hello

在Windows中同样可以调用MSVC编译,但指令不尽相同。

Make

直接用命令编译的局限性很明显:更改编译指令必须找到对应的命令然后逐个更改,这样的代码重复没有必要。为此,我们可以把上述的各种选项都用变量的形式书写。同时,直接用命令时没有额外的软件分析文件间的依赖,在文件改动后要么重新编译整个项目,要么 手动找出需要重新编译的所有依赖,非常麻烦。这个问题的通常解决方法是使用Make脚本Makefile。

make系统是一个以文件为主体的编译管理系统,可以根据指定的规则,在依赖改变时,逐步地生成中间产品和最终产品。比如上面的例子可以使用Makefile来处理:

CC ::= gcc
CPPFLAGS += -I inc
BUILD_DIR ?= build
LDFLAGS += -L $(BUILD_DIR)
LDLIBS += -l hello

all: $(BUILD_DIR)/main

$(BUILD_DIR)/main: $(BUILD_DIR)/main.o $(BUILD_DIR)/libhello.so

$(BUILD_DIR)/main.o: main.c inc/hello.h
	$(CC) -c $(CPPFLAGS) $(CFLAGS) -o $@ $<

$(BUILD_DIR)/hello.o: src/hello.c inc/hello.h
	$(CC) -c $(CPPFLAGS) $(CFLAGS) -fpic -o $@ $<

$(BUILD_DIR)/libhello.so: $(BUILD_DIR)/hello.o
	$(CC) -shared -o $@ $^

Make的主体就是定义一系列的变量,以及定义一系列的生成规则生成各个文件。变量可以用$(variable)$x的形式引用,后一种形式只能用于单字符的变量名。另外make还提供一些特殊的自动变量,这些变量的值由规则确定,如$@表示产物,@<表示第一个依赖,@^表示所有的依赖。为了省略括号,这些变量都是单个字符,因此只能挑选符号。规则的形式是产物: 依赖1 依赖2 ...,后面一行以制表符\t开头,说明根据依赖产生产物运行的指令。产物和依赖都可以通配符选择多个文件。Make的使用时需要指定一个产物,将在产物不存在或者依赖更新后自动地生成产物,且自动地处理间接依赖,即规划运行顺序和分析依赖。同时,通过修改变量,可以方便地修改一整组文件的编译命令。

但make并不内置语言相关的知识,仅仅是针对常见的后缀名定义了一系列内置产生式,如由.cpp产生.o的规则为$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c,这个规则利用了变量,方便调节。

CMake

CMake则是一套专为C设计的产生系统,将根据用户提供以及程序内置的知识,生成编译工具可以使用的项目文件,从而用一套代码管理多组编译工具。

运行框架

虽然CMake是一个强大的C语言命令库,但是CMake的前端的设计是比较复杂的。CMake中一个项目用一个包含了CMakeLists.txt的文件夹代表。当配置这个项目时,CMake将根据项目的代码和用户环境要求,用描述式的方法确定每个目标生成的要求,这些要求用CMake中的目标(target)的属性(property),然后再把这些要求导出(export)到一个使用于某个编译工具的项目(以下称为编译项目)中。除了对象和目录的属性,这个过程中还涉及了来自用户输入、系统环境、中间状态的各种变量,这些变量包括环境变量(environment variable),普通变量(normal variable),缓存变量(cache variable)。

CMake脚本需要结合项目要求和用户环境,最终为各个目标配置合适的属性。为目标配置了属性后,这些属性将被导出到一个编译项目中。根据不同的配置(Release/Debug)或不同用途(编译/安装),一个目标可能会被多次导出;同时一个目标的属性可能会被不同的工具(编译、连接、包含)使用,因此使用CMake时要确保目标的各项属性以各种方式导出时都达到想要的效果。也就是说,CMake中不只要配置这些属性的含义,还要配置这些属性在不同语境下的含义。

类型

和Shell一样,CMake中基本类型只有字符串,数字、布尔类型都是用对应的字符串表示。由于字符串的基本地位,CMake中一串字符,如foo(a b c)调用后foo收到的是”a”, “b”, “c”三个字符串。

另外CMake中还有列表类型。当传递参数时,多个参数用空白分割,以一个列表的形式传入函数中。但列表本身只是一个用;分割的字符串,因此”a;b;c”也是一个列表。

参数

参数有三种形式,不带引号str、带引号"str"、带方括号[[str]]。不带引号使用时,如果参数是${变量}的形式,而且变量是一个列表,列表中的每个成员会分别作为函数的参数传递,即传递多个参数。另外两种形式保证传递的是一个参数。使用方括号时不能使用任何转义和变量。

变量

CMake中的变量不能简单地用等号赋值,使用方法如下:

类型 取值 设值
环境变量 $ENV{<var>} set(ENV{<var>} <value>...)
普通变量 ${<var>} set(<var> <value>... [PARENT_SCOPE])
缓存变量 ${<var>}$CACHE{<var>} set(<variable> <value>... CACHE <type> <docstring> [FORCE])

另外变量还有作用域和互相覆盖的问题。

属性

属性使用get_propertyset_property进行取值和设值,另外也有set_target_properties一类把参数类型写在函数名的形式。

函数和宏

cmake中可以使用函数来重用代码,根据调用的形式,函数会获得长度为ARGC的参数列表ARGV,其中已命名的参数会赋值到对应变量中,未命名的参数列表赋值到ARGN中。由于函数没有返回值,因此通常使用set(${output_variable} value PARENT_SCOPE)的方法来在调用处的作用域中赋值。宏和函数类似,但宏本身没有作用域,会像C一样展开到调用处。

复杂参数

CMake中很多函数的参数非常复杂,需要使用类似于关键字参数的形式。这些参数通常是由cmake_parse_arguments负责解析,因此参数的顺序通常是不重要的。

命令和变量

CMake同样提供直接操作命令和变量的方法。这不只可以用于编译C,还可以用于生成代码,比如调用脚本生成一个C文件。

add_custom_command(OUTPUT main
                   COMMAND gcc main.c -o main
                   DEPENDS main.c)

add_custom_command(OUTPUT main.c
                   COMMAND python generate-main.py
                   DEPENDS generate-main.py)

add_custom_target(greet
                  COMMAND ./main
                  DEPENDS main.c)

这样可以声明两个互相有依赖关系的命令,调用后者会生成main.c而调用前者会生成main。CMake(生成的项目)会根据声明的或自动判断的依赖来按顺序调用。

除了add_custom_command,CMake还有一个类似的函数add_custom_target,用于指定一个命令,而不自动利用生成的文件判断依赖关系,因此可以指定简单的命令向控制台输出或运行程序。

变量

同样地,CMake也提供利用变量来指定参数的方法,可以通过配置环境变量CFLAGS或者CMake变量CMAKE_C_FLAGS来达到和上面例子一样的效果。CMake默认生成的编译指令其实是一个类似于模板,具体内容会填入这些选项设定的参数。

CMake中定义一个变量有多种方法:

  • 调用时cmake时添加选项-DFLAG=value,如cmake -D CMAKE_CXX_FLAGS=-Ofast
  • 在CMakeLists.txt中用命令setoption指定。set更加通用,而option专门用于开关,还支持依赖。

使用变量时语法为${变量名}。要注意的是有些语句(如if)诞生于变量之前,可以直接用变量名的形式引用变量。

函数和属性

仅仅使用裸的命令和变量指定编译方法显然不利于复用,CMake提供的方法是使用一系列的函数来复用代码。

以下是一个函数的定义和调用。

# 定义
function(create_executable name)
  add_custom_command(OUTPUT hello.o
                     COMMAND ${C_COMPILER} -c hello.c
                     DEPENDS hello.c)
  add_custom_command(OUTPUT main.o
                     COMMAND ${C_COMPILER} -c main.c
                     DEPENDS main.c)

  add_custom_target(${name}
                    COMMAND ${C_COMPILER} main.o hello.o -o hello
                    DEPENDS main.o hello.o)
endfunction()

# 调用
create_executable(hello-target)

上面是一个简单的例子,可以看到通常CMake中函数的用途非常局限,仅仅是获取一些变量的值,并利用这些函数的值来调用其他的函数。

可以看到上述的函数的例子中,CMake的函数并没有返回值,而实际的使用中我们会希望获取函数计算的结果,一个简单的方法是利用宏。宏和函数不同,函数体中可以定义新的变量,这些变量的作用域可以不超出函数体;而宏中定义的变量会在调用宏的作用域可见,如果知道这些变量的名字,实际上可以用来返回结果。

下面的例子定义了宏compile,这个宏生成编译source_file的命令,并定义一个变量output_file,用于这个文件编译结果的路径。

# 定义
macro(compile source_file)
  get_filename_component(output_file ${source_file} NAME_WE)
  set (output_file ${output_file}.o)
  add_custom_command(OUTPUT ${output_file}
                     COMMAND ${C_COMPILER} -c ${source_file}
                     DEPENDS ${source_file})
endmacro()

# 调用

属性

set_property get_property

target_compile_options

可以灵活地设定全局或者只针对若干目标指定,可以避免副作用互相影响。

生成器表达式(generator expression)

生成器表达式是CMake中一种特殊的函数,特点是可以获取表达式的语境信息,不需要特意传入参数,比如可以根据语境确定当前的语言。

比如如果要向CUDA的编译器nvcc传入参数

target_compile_options(enable_utf8 INTERFACE
    $<$<BUILD_INTERFACE:$<COMPILE_LANGUAGE:CUDA>>: -Xcompiler=/utf-8 >)

$<COMPILE_LANGUAGE:CUDA>保证-Xcompiler=/utf-8只传给CUDA的编译选项,且$<BUILD_INTERFACE:...>说明这不作为源文件的路径。这些功能都不需要显示地传递参数。

编译模型

自己的库

只介绍最现代的“面向目标”的方法,add_include_directories、include_directories等不介绍。

add_executable/add_library

生成目标 “构造函数”

静态/动态库

不需要指定具体命令 INTERAFACE见下

target_include_directories

指定头文件(目录) 具体需要什么头文件在c代码中include

指定库,包括库的组成部分头文件、二进制文件、编译选项

target_compile_options

指定编译选项

INTERFACE/PRIVATE/PUBLIC

他有、私有、他有及私有

INTERFACE库

不生成文件,用于指定头文件目录和编译选项。添加的文件仅用于在ide中展示

别人的库

find_package

获得上述目标

FetchContent

可以下东西,但是目前还没解决