CMake 前置知识 - 在你学习 CMake 之前

前言

这篇文章不是用来介绍 CMake 的使用方式的,作者也并没有深入了解过 CMake,甚至没有自己写过独立项目。写下这篇文章只是为了指引像一两年前我的人:

  1. 刚刚走出 devc++,初次接触命令行、多文件编译
  2. 第一次部署 C++ 项目,看到复杂的项目构造和不熟悉的构建过程而不知所措
  3. 热切地希望通过学习 CMake 来掌握项目开发能力

那时候的我像渴望飞翔的雏鹰,看到大家都用 CMake 自己也想学,希望能提高自己的能力。然而询问学长得到的答案基本都是 “这种东西用到再查就行了”、“CMake 不用学习,会用就行”,自己查则很多都看不懂,用起来也遇到很多配置错误很难完成部署。

如果我能回去指导那时候的我,我希望能有这样一篇文章,铺平从 devc++ 到 CMake 的路,或者至少给出一张更加清晰的地图,告诉我不要着急,迷雾会在未来扫清。

C++ 编译过程简述

这部分知识会在 编译原理 课程中系统介绍,也可以通过阅读 CSAPP 自学,B站 up 主 九曲阑干 将该书制作成视频,可以借此快速入门。这里我会简单过一遍。

在 IDE 如 devc++ 中,我们写下 hello.cpp 源文件后,需要先对它进行编译生成 hello.exe 文件后才能运行。但事实上从高级语言到机器语言的 编译 操作并非一步到位的,而是至少包含预处理、编译、汇编和链接四个步骤。

预处理阶段负责对输入的源文件标准化。C++ 对程序的写法是比较宽松的,你可以在各种地方加入任意空格,可以反斜杠换行,还可以用诸如 #define MAX((A),(B)) ... 这类宏指令,他们都需要被预编译器修剪、展开,以方便编译阶段处理。

编译阶段会将预处理过的源代码一步步解释成程序执行流,并在这过程中进行优化和信息提取,最终生成汇编代码。这部分对应编译原理的词法分析、语法分析、语义分析、中间代码生成等等内容,因此不会在此展开。你只需要知道最后生成的汇编代码依然是人类可读的,只是不再包含层次结构,每一条语句也只会执行最基础的操作即可。

汇编阶段会将编译出的汇编文件转换成目标文件,这是一种人类不可读的二进制代码,你可以理解为可执行文件的一部分。注意到这里为止都是对单个源代码文件来说的,也就是 hello.cpp 预处理为 hello.i,编译为 hello.s,汇编为 hello.o

链接阶段会将可执行文件的不同部分组合起来,输出最终的可执行文件。不同的部分可能包括自己写的多个源代码目标文件(比如这里的 hello.cpp 、标准库文件(比如包含 iostream 的动态链接库)、用户库文件,它们会以或静态或动态的方式组合在一起。这一步骤经常发生错误,但对平常只写单文件的开发者又很陌生,遇到时往往会不知所措。所以以后当你遇到以 链接失败 之类开头的错误,或者错误信息是“找不到函数”“变量名重复”之类的,你就知道是怎么回事了。

以上这些编译过程一般都会由编译器(如gcc、clang、msvc)按默认流程一步执行到位,我们也可以选择性地执行到某个位置,而这正是 构建 的基础。

什么是构建工具

构建就是将项目编译打包成可执行文件的过程,而构建工具源于以下需求:

  1. 文件很多,手动编译费时费力
  2. 如果单纯写一个脚本全部编译一遍,那没修改过的源文件就会被重复编译,很浪费时间
  3. 希望简化编译脚本,方便地提供配置、编译、测试、清理、安装等功能

可见,如果我们只有一个源文件,大可以直接调用编译器生成可执行文件,后面的 make 和 cmake 啥的就没有必要了。

但很多软件是有以上需求的,于是 Makefile、ninja 等工具应运而生。他们会识别没编译过的或被修改过的源代码,将他们编译为目标文件,再链接起来。以 Makefile 为例,我们加入另一个源文件 some_func.cpp,然后写一个简单的编译脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
hello.exe: hello.o some_func.o
g++ hello.o some_func.o -o hello.exe

hello.o: hello.cpp
g++ -c hello.cpp -o hello.o

some_func.o: some_func.cpp
g++ -c some_func.cpp -o some_func.o

clean:
del hello.exe
del hello.o
del some_func.o

将这个脚本和源文件一起保存在同一目录,命名为 makefile,即可用 make 完成构建。

可以看到 makefile 的格式很简单,就是一组组的文件生成规则列下来,每个文件都可能依赖其他文件,会标注在冒号后面,然后下面用 Tab 缩进接生成文件所需要的命令。写好之后运行 make 程序,他就会默认生成第一个文件,根据依赖递归地执行命令。make 会根据文件的修改时间判断哪些是不需要重新生成的,从而减少构建时间。

为什么需要 CMake

初步使用过 CMake 后就会发现,CMake 其实是用来生成 Makefile 等构建配置文件的工具,那为什么有了构建工具还需要 CMake 这样套娃呢?据我了解有以下原因:

  1. 跨构建工具:构建工具有很多种,比如 makefile、ninja、msbuild等等,我们希望用一个统一的配置文件生成不同构建工具的配置
  2. 跨编译器:编译器也有很多种,比如 gcc、clang、msvc等等,我们希望用一个配置文件方便地生成使用不同编译器的构建文件
  3. 依赖管理:大型软件一般会产生诸多依赖库(不是文件依赖),而编写构建过程的人并不清楚开发者将依赖放在哪里,所以需要有一个系统找到各种依赖来完成编译链接过程
  4. 灵活配置:构建工具是以文件作为生成任务的,但有时我们需要更复杂的配置,比如区分 Debug、Release 的程序,比如允许让程序选择性地包含某些功能模块等等

总的来说,cmake 让构建过程实现了跨平台、自动化、灵活配置。如果你不需要这些功能,而仅仅是包含多个文件、运行在本地、没有什么依赖的个人项目,使用构建工具就已经足够了。

如果你已经做好准备,要开创一个大事业,那就准备好使用 CMake 吧。

P.S. CMake 的依赖管理好像只负责找到依赖,而不具备包管理器应有的下载、安装、罗列、卸载等操作,你需要其他如 conan 的工具来做到这一点。。所以说 rust 的 cargo 真是一个很先进的工具。

接下来要学习什么

看完这一切后,如果你决定使用 CMake,你只需要知道下面知识就行:

  1. 一般先创建 build 文件夹来存放构建产生的文件,在此目录下运行构建指令。如果你忘了,你会发现仓库根目录下一片狼藉。。
  2. 确保一切工具准备到位,包括编译工具(推荐 clang)、构建工具(推荐 ninja)、CMake,并且配好了环境变量
  3. 在 build 目录下运行 cmake ..,其中 .. 是包含 CMakeLists.txt 的目录。CMake 会尝试寻找合适的工具链并生成构建配置,第一次操作请检查工具链选择是否符合你的想法,因为 CMake 可能会找不到工具链或选择了错误的工具链。
  4. 运行 cmake --build . 完成构建。CMake 会根据配置调用构建工具完成构建

如果你还想编写 CMake 文件,我可能提供不了太多帮助,可以参考下面的内容:

  1. 快速入门:CMake 教程 | 菜鸟教程
  2. 一些书(中文,现搜的,算是mark一下):
    1. Modern CMake 简体中文版
    2. CMake Cookbook
  3. 官方教程(要求英文阅读):官方教程

另外,请确保你已经学习过 软件工程,这能解答很多为什么这样设计的困惑。