C、Make、CMake
本文已摆烂,请见现代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_property
和set_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中用命令
set
或option
指定。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_link_libraries
指定库,包括库的组成部分头文件、二进制文件、编译选项
target_compile_options
指定编译选项
INTERFACE/PRIVATE/PUBLIC
他有、私有、他有及私有
INTERFACE库
不生成文件,用于指定头文件目录和编译选项。添加的文件仅用于在ide中展示
别人的库
find_package
获得上述目标
FetchContent
可以下东西,但是目前还没解决