CMake简介
起源
CMake是一个相对年轻的项目,于2000年为了解决美国国家医学图书馆出资的Visible Human Project项目下的Insight Segmentation and Registration Toolkit(ITK)软件的跨平台建构的需求而被创造出来。Kitware公司由于其VTK(Visualization Toolkit)的成功得到了这项任务,并且一直维护CMake至今。
CMake是什么
CMake并不是一个编译器(根据源代码生成特定平台上的可执行文件的软件),也不是一个构建器(按照既定的指令调用编译器对源代码进行处理从而得到可执行文件的软件),而是一个更高层面的项目管理工具。
换言之,仅仅使用CMake和编译器无法完成程序的编译,还需要一个构建器(builder)。在Windows下可以使用msbuild作为构建器,和msvc编译器配合。2011年Google推出的Ninja构建器(JetBrains CLion默认选项)也可使用。
C语言的构建过程
为了说明CMake在C语言程序开发中所起的作用,这里先用一点篇幅简要介绍一下C语言从源代码到可执行文件过程中都发生了什么事情。
C语言程序员编写的源代码文件需要经过一系列的处理才能得到最终的可执行文件。这一系列的处理通常可以被分为这三个部分:
- 编译
- 汇编
- 链接
其中编译过程又可以分为预处理和编译两个过程。所谓的预处理,会将全部的include语句和define语句展开。之后通过编译将C语言中的程序语句转换为汇编语句,并按照用户输入的参数进行优化。通常我们将从源代码文件、库文件(其他项目已经编译好的库文件,在当前项目中不进行开发和维护而直接使用)到可执行文件的过程称为构建(在现代IDE中通常被称为build)。
对于一个最简单的HelloWorld程序,如下所示。
#include <stdio.h>
int main()
{
printf("hello, world!\n");
return 0;
}
msvc编译器的时候比较复杂,不如gcc直观,因此下面的编译和运行都使用Ubuntu下的gcc完成。
在终端中使用gcc构建,即可得到可执行文件。并且不需要链接标准库以外的其他库,也不需要包含标准头文件之外的其他头文件,因此实际上不需要做上面五件事情中的后三件事情,只要指定生成目标文件所需要的源代码文件即可。
gcc main.c -o hello
由于只有一个源代码,并且没有包含标准头文件之外的头文件,也没有链接标准库之外的库文件,因此只需要指定要编译的源代码即可。
为了模拟一个更加普适的开发场景,将输出Hello信息的代码封装为一个库,并单独编译打包成一个静态库文件,再从主函数中调用这个库。
// main.c
#include "hello.h"
int main()
{
const char* name = "CMake";
say_hello(name);
return 0;
}
// hello.h
#ifndef HELLO_H_
#define HELLO_H_
#include <stdio.h>
void say_hello(const char* name);
#endif
// hello.c
#include "hello.h"
void say_hello(const char* name)
{
printf("Hello, %s!\n", name);
}
目录中的文件结构如下所示。hello作为一个库,其源代码被放在一个单独的目录中,目录里包括一个头文件目录和一个源代码文件目录。为了目录结构更加清晰,将库代码和头文件放在单独的一个目录中,此时这个简单的小项目中的目录结果如下所示。
.
├── hello
│ ├── inc
│ │ └── hello.h
│ └── src
│ └── hello.c
└── main.c
首先使用gcc编译hello这个库
cd hello
gcc -c src/hello.c -Iinc
ar rcs lib/libhello.a hello.o
gcc -c的作用是只编译不汇编和链接,得到一个目标文件hello.o,然后使用静态库打包指令ar得到可链接的静态库文件libhello.a。注意Linux下的静态链接库通常以.a作为拓展名,并且以lib开始,lib和.a之间的字符串才是静态库的名称。
得到静态库文件之后,可以对main.c进行编译和链接,最终得到可执行文件
cd ..
gcc -o run_hello main.c -Ihello/inc -Lhello/lib -lhello
这里使用了gcc的多条参数:
-I用于指定包含路径,路径以当前所在的路径为起点,紧跟着-I中间没有空格。-L用于指定链接路径。-l用于指定要链接的静态库的名称,这里-lhello参数会让编译器在标准库目录和前面-L参数指定的目录下寻找libhello.a这个文件,lib这个前缀和.a这个后缀编译器会自动加上。
最终函数的输出是run_hello这个可执行文件,执行之后的结果为
Hello, CMake!
Make工具的出现
如果一个项目中包含多个不同的库,那么修改了其中部分库之后,就必须重新编译和链接所有依赖这些库的库和可执行文件。当项目复杂起来之后,程序员需要付出大量的时间和精力来维护库与库之间的依赖关系。而make这样的构建工具的出现,极大减少了程序员的工作量。
仍然使用上面的例子,目前项目中包含这样一些文件,一个hello库和一个主函数源代码main.c。
.
├── hello
│ ├── build
│ │ └── hello.o
│ ├── inc
│ │ └── hello.h
│ ├── lib
│ └── src
│ └── hello.c
└── main.c
可以编写这样一个Makefile文件
.PHONY: clean all
all: libhello.a main.c hello/inc/hello.h
@mkdir -p build
gcc -o build/run_hello main.c -Ihello/inc -Lhello/lib -lhello
libhello.a: hello/src/hello.c hello/inc/hello.h
@mkdir -p hello/build
gcc -c hello/src/hello.c -Ihello/inc -o hello/build/hello.o
ar rcs hello/lib/libhello.a hello/build/hello.o
clean:
rm -rf build & rm hello/lib/libhello.a & rm -rf hello/build
需要注意Makefile文件中指令前面必须是制表符,不能是空格。Makefile实现了两个重要的功能:
- 将复杂的指令简化为简单的指令。比如在命令行中执行
make指令,GNU make就会在当前路径下搜索Makefile文件,并按照Makefile文件中定义好的指令执行,不在需要用户输入长长的编译指令。 - 实现了不同目标文件之间相互依赖关系的自动化管理。比如上面的Makefile文件中,指定了
all目标(当然这是一个伪目标,实际产生的文件是build/run_hello)依赖于libhello.a这个文件。那么当make接收到用户的指令要生成all目标时,如果发现libhello.a的依赖文件相比上一次构建的时候已经产生了新的变化,就会重新编译libhello.a。
基于makefile的构建过程需要程序员维护大量的编译器指令,极大限制了程序开发的效率。为了解决这一问题,集成开发环境(IDE)出现了。
现代C/C++构建过程
Windows下的Visual Studio是一个经典的IDE,在完成基本环境的配置之后(包含路径、库路径、链接库)可以实现一键构建。同时提供了丰富的选项,包括构建模式(调试/发布模式)、编译器优化、符号导出等等。但是,Visual Studio可以说是专为微软的msvc编译器设计的(尽管现在也开始提供了对clang编译器以及Linux平台的兼容),在跨平台、开放性上始终有难以克服的技术债。同时,IDE动辄数十GB的安装大小对计算机本身的性能也有一定的要求。
CMake和经典的IDE有所不同,其更像是一个项目管理工具,允许程序员以简单的语言指定项目的构建目标以及构建过程中需要遵守的规则。CMake会根据程序员给定的配置信息,生成满足要求的makefile或msbuild solution工程,并进一步调用生成器(Ninja或者msbuild)构建项目,得到最终的可执行文件或者链接库。目前,较新版本的CMake 3.31的软件大小仅100MB+,并且可以方便地和几乎任意主流的编辑器协同工作,实现了高度的轻量化和可定制化。
CMake通过一组CMakeLists.txt文件来管理项目的配置信息。典型的工程目录结构如下:
.
├── CMakeLists.txt
├── hello
│ ├── inc
│ │ └── hello.h
│ ├── lib
│ └── src
│ └── hello.c
└── main.c
CMakeLists.txt文件中的内容如下
cmake_minimum_required(VERSION 3.21)
project(Hello VERSION 0.1 LANGUAGES C)
add_library(hello STATIC
hello/src/hello.c
)
target_include_directories(hello PUBLIC
hello/inc
)
add_executable(run_hello main.c)
target_link_libraries(run_hello PRIVATE
hello
)
其中前面两行cmake_minimum_required和project这两个语句是一个工程的固定格式,用于指定CMake的最低版本要求和工程的名称,暂时不去深究。
add_library用于指定生成一个链接库,hello是库的名称,STATIC指定库的类型是静态链接库,hello/src/hello/c指定了生成库所需要的源代码文件,这里只包含一个源代码文件。hello.c中包含了hello.h这个头文件,不在系统头文件目录中,因此需要手动指定,这个过程通过target_include_libraries语句实现。语句中需要首先指定目标的名称(hello),然后指定包含的类型PUBLIC,意味着每一个依赖hello库的目标都会自动添加上下面的包含路径,hello/inc是要添加的包含路径。
接下来的add_executable用于指定生成一个可执行文件,run_hello是可执行文件的名称,main.c是所需要的源代码文件,可以有多个源代码文件。main.c中使用了hello库中的函数,因此需要链接到hello库,这个过程通过target_link_libraries语句实现。由于之前已经指定了hello库的包含路径是PUBLIC类型,因此指定run_hello目标链接到hello之后,run_hello目标的包含路径中会自动添加hello的全部包含路径。
接下来只需要执行cmake .即可,其中.是cmake指令的参数,用于指定CMakeLists.txt文件相对当前路径所在的路径。如果直接在根目录下执行就直接指定当前路径作为参数即可。但是CMake会生成大量的中间文件,为了和工程源代码文件隔离,通常会在根目录下建立一个build目录用于保存CMake生成的中间文件。
mkdir build
cd build
cmake ..
在build路径内部,由于CMakeLists.txt文件在上一级目录中,因此需要使用..作为参数。
执行完成之后,CMake没有生成库文件和可执行程序,而是生成了一个makefile文件。如下所示。这也正是为什么说CMake不是一个构建器,而是一个工程管理工具。
.
├── CMakeLists.txt
├── build
│ ├── CMakeCache.txt
│ ├── CMakeFiles
│ ├── Makefile
│ └── cmake_install.cmake
├── hello
│ ├── inc
│ │ └── hello.h
│ ├── lib
│ └── src
│ └── hello.c
└── main.c
接下来在build目录下执行make指令,即可得到库文件libhello.a和可执行程序run_hello。
现代CMake已经把外部生成作为标准的使用方法,可以通过参数配置自动生成build目录,并自动将中间文件生成到该目录中。
cmake -S . -B build -G "Visual Studio 17 2022" -A x64 -T ClangCL && cmake --build build --config Release
其中各个参数的含义为:
-S .:使用当前路径作为项目根目录,当前路径下必须有一个CMakeLists.txt文件,并且此文件作为当前CMake工程的根配置文件;-B build:指定build为构建目录,所有的中间文件和直接生成的目标文件都会保存在此目录下;-G "Visual Studio 17 2022":指定生成器,这里使用mvsc2022作为生成器;-A x64:指定程序的目标运行平台为x64平台;-T ClangCL:指定需要使用的工具链,也即具体的编译器,需要和生成器配合,msvc生成器(msbuild)支持微软自己的msvc编译器cl以及在此基础上开发的clang-cl(兼容clang编译器),这里指定使用clang-cl编译器;
&&表示执行完第一项指令之后,紧跟着直接执行第二项指令,各参数含义为:
--build build:指定生成目录,也即上一条指令中-B参数指定的目录;--config Release:指定编译方式,包括Release和Debug两种。
如果要使用Ninja作为生成器的话,则指令为
cmake -G Ninja -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=clang-cl
最后的两个选项分别指定了构建类型为Release和编译器为clang-cl。
如果不希望在命令行中输入这些,可以在项目根目录下设置一个CMakePresets.json文件
{
"version": 3,
"configurePresets": [
{
"name": "clang-ninja",
"displayName": "Clang-CL x64 Ninja",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build",
"cacheVariables": {
"CMAKE_C_COMPILER": "clang-cl",
"CMAKE_CXX_COMPILER": "clang-cl",
"CMAKE_BUILD_TYPE": "Release"
}
}
]
}
这样在构建时只需要:
cmake --preset clang-ninja
cmake --build build
基于CMake的C++ IDE
上面已经提到CMake相比其他IDE的一个重要优点是极致的轻量化,仅仅几百兆的大小,在性能一般的计算机上会给程序员比较好的开发体验。同时可以搭配任意中意的编辑器进行开发。
现在,VS Code中的CMake Tools插件对CMake的支持非常好,项目的配置、构建、调试基本都有一键式的解决方案。因此,目前使用VS Code+CMake开发C++程序的体验非常好。
此外,VS Code丰富的插件是选择这一组合的另一理由。AI辅助编程的应用越来越普遍,而几乎所有的AI大模型都提供有VS Code的插件。甚至早期出现的一些所谓的AI IDE实际上就是VS Code的分支开发。
在VS Code中安装CMake Tools插件(需要注意开发者是Microsoft)之后,通过Ctrl + Shift + P打开命令面板,输入CMake: Configure,选择对应的配置,即可完成项目的配置。选择CMake: Build(或者快捷键F7)即可完成项目的构建。选择CMake: Debug(或者快捷键Shift + F5)即可启动程序调试,支持直接在程序中设置断点。选择CMake: Run(或者快捷键Ctrl + Shift + F5)即可运行程序。程序构建的模式(调试或发布)由CMake: Select Variant指定。同时还支持使用Google Test或者CTest等框架编写测试代码。可以使用CMake: Run Tests运行测试代码。
发展
- 2002年,CMake在ITK项目中取得了成功,Kitware顺势把VTK的构建也迁移到了CMake并延续至今。
- 2003年,Ken Martin和Bill Hoffman编写了《精通CMake:一个跨平台构建系统》。一个成功的软件,仅仅性能强大还不够,还需要足够优秀的文档系统,让开发者能顺利使用。
- 2004年,CMake获得了国家医学影像计算联盟 (NA-MIC) 提供的大量开发资金。
- 2006年,KDE转向了CMake。
- 2008年,CMake首次被应用于LLVM编译器。
- 2011年,CMake提供了ninja支持。
- 2016年,LLVM抛弃了autoconf,转向了CMake。
- 2017年,Microsoft Visual Studio首次提供了针对CMake的支持。
- 2019年,Qt转向了CMake。
- 2020年,Microsoft Visual Studio增加了对CMake Presets的支持;CMake增加了对Ninja Multi config的支持;C++20的特性也开始得到了支持。
- 2022年,Bryce Adelstein Lelbach在他的C++Now conference研讨会中将CMake称为“C++标准构建系统”。
- 2023年,CMake主导了编译器描述C++20模块依赖信息的标准格式,产生了p1689规范。目前p1689规范已经被Visual Studio、Clang、和GCC三大编译器实施,并且被CMake用于支持C++20 module的构建。
可以注意到,越来越多的大型项目开始转向CMake作为项目管理工具,包括著名的OpenCV、Qt等大型的项目。充分证明了CMake的应用潜力。是入门C/C++开发非常值得学习的一项技能。
CMake相关资源
最重要的资源当然是CMake的Documentation,可以说浩如烟海,并且关联到具体的CMake版本。
其次还有大量的CMake相关著作:
- 2003年,Ken Martin和Bill Hoffman编写了《精通CMake:一个跨平台构建系统》。
- 2012年,Stephen Kelly编写了《Modern CMake》。
- 2018年,Craig Scott编写了《CMake进阶:实践指南》。
参考资料
[1] CMake历史
[2] CMake-Wiki