从 GNU Make 到 CMake 快速入门

GNU Make用于控制如何从程序的源代码文件编译并链接为可执行文件,通过make命令从名称为makefile的文件中获取构建信息,该文件定义了一系列规则来指定源文件的编译先后顺序、是否需要重新编译、甚至于进行更为复杂的操作。通过makefile文件可以方便的实现工程的自动化编译,只需要执行make命令即可完成编译动作,从而极大的提高了开发人员的工作效率。

CMake 3.17是一款源代码构建管理工具,最初作为各种 Makefile 方言的生成器,后来逐步发展为现代化的构建系统,广泛用于 C 和 C++ 工程源代码的构建。官方提供的《CMake Tutorial》 为开发人员提供了一个循序渐进的指南,涵盖了 CMake 构建过程中常见问题的解决方案。如果需要构建从第三方发布的源代码包,则可以参考《User Interaction Guide》。而《Using Dependencies Guide》则主要针对需要使用第三方库的开发人员。

GNU Make

make是一款用于解释makefile文件当中命令的工具,而makefile关系到整个工程的编译规则。许多 IDE 集成开发环境都整合了该命令,例如:Visual C++ 里的nmake,Linux 里的 GNU make,本章节主要讲解 GNU make 相关的内容。开始进一步讲解之前,需要先了解一下 C/C++ 源代码的编译过程,具体内容可参见笔者的《基于 Linux 的 GCC 与 GDB 应用调试》 - 编译步骤一文:

  1. 预处理 Preprocessing:解析各种预处理命令,包括头文件包含、宏定义的扩展、条件编译的选择等;
  2. 编译 Compiling:对预处理之后的源文件进行翻译转换,产生由机器语言描述的汇编文件;
  3. 汇编 Assembly:将汇编代码转译成为机器码;
  4. 链接 Link:将机器码中的各种符号引用与定义转换为可执行文件内的相应信息(例如虚拟地址);

makefile 文件

基本规则

执行make命令时,实际会解析当前目录下的makefile文件,该文件用于告知make命令如何对源代码进行编译与链接,一个 makefile 的基本编写规则如下所示:

1
2
3
4
target ... : prerequisites ...
command
...
...
  • target:即可以是 1 个目标文件,也可以是 1 个执行文件,甚至还可以是 1 个标签;
  • prerequisites:生成该target 所依赖的文件或者其它target
  • command:该target所要执行的 Shell 命令,需要保持 1 个【Tab】的缩进;

上述的基本编写规则最终会形成一套依赖关系,其中target依赖于prerequisites,而生成规则定义在command;如果prerequisites中的文件比target上的文件要新,则command所定义的命令就会被执行

观察下面的例子,其中的反斜杠\表示换行,将其保存为一个makefile或者Makefile文件,然后在当前目录执行make命令,就可以生成可执行文件app。如果需要删除可执行文件以及中间生成的目标文件,则执行make clean命令即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
app : main.o kbd.o command.o display.o insert.o search.o files.o utils.o
gcc -o app main.o kbd.o command.o display.o insert.o search.o files.o utils.o

main.o : main.c defs.h
gcc -c main.c
kbd.o : kbd.c defs.h command.h
gcc -c kbd.c
command.o : command.c defs.h command.h
gcc -c command.c
display.o : display.c defs.h buffer.h
gcc -c display.c
insert.o : insert.c defs.h buffer.h
gcc -c insert.c
search.o : search.c defs.h buffer.h
gcc -c search.c
files.o : files.c defs.h buffer.h command.h
gcc -c files.c
utils.o : utils.c defs.h
gcc -c utils.c
clean :
rm app main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o

输入make命令之后,就会开始执行上述的makefile文件,具体执行流程如下所示:

  1. make会在当前目录下查找Makefile或者makefile文件;
  2. 找到后将当中定义的第 1 个target作为最终的目标文件;
  3. 如果app文件不存在,或者其依赖的.o文件修改时间要比app执行文件更新。那么,他就会执行command定义的命令来生成app文件;
  4. 如果app依赖的.o文件也不存在,那么查找.o文件对应的依赖规则生成.o文件;
  5. 最后,基于工程中.c.h源文件生成.o依赖文件,然后再基于这些.o文件生成app执行文件;

定义变量

上面示例中app生成规则中的一系列.o文件反复出现,这里我们可以将其声明为一个变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o

app : $(objects)
cc -o app $(objects)
main.o : main.c defs.h
cc -c main.c
kbd.o : kbd.c defs.h command.h
cc -c kbd.c
command.o : command.c defs.h command.h
cc -c command.c
display.o : display.c defs.h buffer.h
cc -c display.c
insert.o : insert.c defs.h buffer.h
cc -c insert.c
search.o : search.c defs.h buffer.h
cc -c search.c
files.o : files.c defs.h buffer.h command.h
cc -c files.c
utils.o : utils.c defs.h
cc -c utils.c
clean :
rm app $(objects)

自动推导

GNU Make 可以自动识别并推导目标与依赖关系之后的command命令,只要make发现 1 个.o文件,就会自动将对应的.c文件添加至依赖关系当中,同时也会将对应的gcc -c命令推导出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
objects = main.o kbd.o command.o display.o \
insert.o search.o files.o utils.o

app : $(objects)
cc -o app $(objects)

main.o : defs.h
kbd.o : defs.h command.h
command.o : defs.h command.h
display.o : defs.h buffer.h
insert.o : defs.h buffer.h
search.o : defs.h buffer.h
files.o : defs.h buffer.h command.h
utils.o : defs.h

.PHONY : clean
clean :
rm app $(objects)

这种方法被称为make隐含规则,上述代码中.PHONY表示clean是一个伪目标文件,关于隐晦规则和伪目标文件的内容后续将会进行更为详细的介绍。

通过隐含规则可以进一步简化上面的makefile,这样虽然可以最大幅度减少代码,但是文件的依赖关系显得较为凌乱,所以这种风格较少被采用。

1
2
3
4
5
6
7
8
9
10
11
12
objects = main.o kbd.o command.o display.o insert.o search.o files.o utils.o

app : $(objects)
cc -o app $(objects)

$(objects) : defs.h
kbd.o command.o files.o : command.h
display.o insert.o search.o files.o : buffer.h

.PHONY : clean
clean :
rm app $(objects)

清理中间文件

习惯上,每个makefile文件都应该编写一个用于清理中间文件的规则,这样不仅便于重新编译,也有利于保持工程的整洁。

1
2
clean:
rm edit $(objects)

之前代码采用了上面较为简单粗暴的方式,但是更为稳健的方法是采用下面这样的风格:

1
2
3
.PHONY : clean
clean :
-rm edit $(objects)

.PHONY关键字用于表标识clean是一个伪目标rm命令前的小减号-表示忽略操作出现问题的文件,习惯上会将clean放置在makefile的最后。

Makefile 组成

Makefile 文件主要包含显式规则隐式规则变量定义文件指示注释

  • 显式规则:由Makefile编写者明确指定,用于描述如何生成target
  • 隐式规则:利用make命令的自动推导功能,简略书写Makefile
  • 变量的定义:通常为字符串,当Makefile被执行时,其中的变量会扩散到相应的引用位置;
  • 文件指示:在Makefile当中引用另外的Makefile,类似于 C 语言里的#include。或者根据条件指定Makefile的有效部分,类似于 C 语言中的#if。除此之外,还可以用于定义一条拥有多行的命令;
  • 注释:注释采用#字符,需要时可以采用反斜杠进行转义\#

注意Makefile中的命令command必须以【Tab】键开始。

引用其它 Makefile

使用include关键字可以将其它Makefile包含进来,类似于 C 语言中的#include预处理语句,被包含的文件会自动替换至包含位置。

1
include <filename>

filename可以是当前操作系统 Shell 命令或者文件(可以包含路径和通配符),include关键字之前可以存在空字符,但是绝不允许出现【Tab】键。

例如:存在 4 个 Makefilea.mkb.mkc.mkfoo.make以及 1 个变量$(bar)(包含e.mkf.mk) ,那么下面 2 条语句就是等价的:

1
2
3
include foo.make *.mk $(bar)

include foo.make a.mk b.mk c.mk e.mk f.mk

make命令开始执行时,会查找include的其它Makefile,如果没有指定绝对或者相对路径的话,make会首先在当前目录下查找,如果没有查询到则会进入如下目录:

  • 如果make命令执行时,带有-I或者--include-dir参数,那么make就会在该参数指定的目录下查找;
  • 此外,make还会去查找<prefix>/include目录(通常为/usr/local/bin或者/usr/include);

最后,如果文件未能找到,make将会生成警告信息,然后继续载入其它文件,一旦makefile读取完成,make会再次进行查询,如果依然未能找到,则报出一条致命错误信息。如果想让make忽略读取错误,则可以在include前添加减号-

1
-include <filename>

注意:其它版本make采用的兼容命令是sinclude,其作用与-include相同。

这里,重新再来总结一下 GNU Make 的工作步骤:

  1. 读取所有Makefile文件;
  2. 查找被include的其它Makefile
  3. 初始化Makefile文件当中定义的变量;
  4. 分析并且推导隐式规则;
  5. 创建target目标文件的依赖关系;
  6. 根据依赖关系,决定哪些target需要重新生成;

MAKEFILES 环境变量

如果当前定义了MAKEFILES环境变量,其值为采用空格分隔的其它Makefile,执行make时会将这个该环境变量的值include进来。但是与include所不同的是,该环境变量引入的Makefiletarget不会生效,其定义的文件如果发现错误,make也会不理会。

日常开发环境,不建议使用MAKEFILES环境变量,因为定义后会影响到所有make命令的执行。反而是在makefile文件出现一些莫名其妙错误的时候,需要检查当前是否定义了这个环境变量。

规则

规则描述了Makefile文件的依赖关系以及如何生成目标文件。定义在 Makefile 中的target可以有很多,但是第 1 条规则中的target会被确立为最终的目标。

1
2
3
4
5
6
7
8
9
# 一种格式
targets : prerequisites
command
...

# 另一种格式
targets : prerequisites ; command
command
...
  • targets目标文件名称,以空格分隔,可以使用通配符;
  • prerequisites目标文件的依赖,如果某个依赖文件比目标文件要新,那么就会重新进行生成;
  • commandShell 命令行,如果不与target : prerequisites在一行,那么必须以【Tab】开头;如果保持在一行,则可以采用分号;进行分隔;

注意:如果prerequisitescommand过长,可以使用反斜杠\进行换行。通常make会以 Bash Shell 也就是/bin/sh来执行命令。

通配符

make支持*?~三个通配符。~字符在 Linux 下表示当前用户的$HOME目录,在 Windows 下则根据环境变量HOME设置而定。

通配符可以应用在command当中,下面代码会在清除所有.o文件之前,查看一下main.c文件。

1
2
3
clean:
cat main.c
rm -f *.o

通配符还可以应用于prerequisites,下面代码中的print目标依赖于所有.c文件,其中的$?是后续将会讲到的自动化变量

1
2
3
print: *.c
lpr -p $?
touch print

通配符同样可以应用在变量中,但是并不会因此而自动展开,下面代码里变量objects的值就是*.o

1
objects = *.o

如果需要让通配符在变量当中展开,即让objects的值是所有.o文件名的集合。

1
objects := $(wildcard *.o)

Autoconf

autoconf

Automake

automake

CMake

CMake 教程提供了一个循序渐进的指南,涵盖了常见的构建系统问题。本文涉及的示例代码可以在 CMake 源码树Help/guide/tutorial目录下找到,每个步骤都拥有其相应的子目录,循序渐进直至提供完整的解决方案。

基本出发点

最为基础的项目是从源代码构建可执行文件,这样只需要一个 3 行的CMakeLists.txt文件,这将是整个教程的起点。在【Step1】目录当中创建如下CMakeLists.txt文件:

1
2
3
4
5
6
7
cmake_minimum_required(VERSION 3.10)

# set the project name
project(Tutorial)

# add the executable
add_executable(Tutorial tutorial.cxx)

CMake 支持大写小写混合大小写的命令,上面的CMakeLists.txt文件使用了小写命令。教程源代码Step1目录中提供了用于执行数字平方根计算的cxx文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 用于执行数字平方根计算的简单程序 */
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <string>

int main(int argc, char* argv[]) {
if (argc < 2) {
std::cout << "Usage: " << argv[0] << " number" << std::endl;
return 1;
}

const double inputValue = atof(argv[1]); // 将输入数据转换为 double 类型
const double outputValue = sqrt(inputValue); // 计算平方根
std::cout << "The square root of " << inputValue << " is " << outputValue
<< std::endl;
return 0;
}

添加版本号和配置头文件

我们要添加的第一个特性是为项目提供 1 个版本号。虽然源代码中也可以完成这件事,但是使用CMakeLists.txt可以提供更好的灵活性。首先,修改CMakeLists.txt文件,使用project()命令设置项目名称和版本号。

1
2
3
4
cmake_minimum_required(VERSION 3.10)

# 设备项目名和版本号
project(Tutorial VERSION 1.0)

然后,继续编写配置,把一个头文件上保存的版本号传递到源代码:

1
configure_file(TutorialConfig.h.in TutorialConfig.h)

由于配置文件将会被写入到二叉树,所以必须将该目录添加至搜索包含文件的路径列表当中,在CMakeLists.txt文件的末尾添加以下行:

1
2
3
target_include_directories(Tutorial PUBLIC
"${PROJECT_BINARY_DIR}"
)

在当前目录下创建TutorialConfig.h文件,并且包含如下内容:

1
2
3
/* 配置主、副版本号 */
#define Tutorial_VERSION_MAJOR @Tutorial_VERSION_MAJOR@
#define Tutorial_VERSION_MINOR @Tutorial_VERSION_MINOR@

当 CMake 配置该头文件以后,上述的@Tutorial_VERSION_MAJOR@@Tutorial_VERSION_MINOR@的值将会被替换。

接下来修改tutorial.cxx来包含上面的TutorialConfig.h头文件,并最终通过修改后的tutorial.cxx打印版本号。

1
2
3
4
5
6
7
8
9
#include "TutorialConfig.h"

if (argc < 2) {
/* 打印版本号 */
std::cout << argv[0] << " Version " << Tutorial_VERSION_MAJOR << "."
<< Tutorial_VERSION_MINOR << std::endl;
std::cout << "Usage: " << argv[0] << " number" << std::endl;
return 1;
}

指定 C++ 标准

接下来,将tutorial.cxx文件中的atof替换为std::stod,从而为项目添加一些 C++11 特性。同时,删除#include <cstdlib>

1
const double inputValue = std::stod(argv[1]);

CMake 中启用特定 C++ 标准支持的最简单方法是使用CMAKE_CXX_STANDARD变量,这里将CMakeLists.txt文件里的CMAKE_CXX_STANDARD变量设置为11,并将CMAKE_CXX_STANDARD_REQUIRED设置为True

1
2
3
4
5
6
7
8
cmake_minimum_required(VERSION 3.10)

# 设置项目名称与版本
project(Tutorial VERSION 1.0)

# 指定 C++ 标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

编译与测试

从命令行导航到 CMake 源代码树的 Help/guide/tutorial 目录,并运行以下命令:

CMake GUI