Skip to content

CMake简介

起源

CMake是一个相对年轻的项目,于2000年为了解决美国国家医学图书馆出资的Visible Human Project项目下的Insight Segmentation and Registration Toolkit(ITK)软件的跨平台建构的需求而被创造出来。Kitware公司由于其VTK(Visualization Toolkit)的成功得到了这项任务,并且一直维护CMake至今。

CMake是什么

CMake并不是一个编译器(根据源代码生成特定平台上的可执行文件的软件),也不是一个构建器(按照既定的指令调用编译器对源代码进行处理从而得到可执行文件的软件),而是一个更高层面的项目管理工具。

换言之,仅仅使用CMake和编译器无法完成程序的编译,还需要一个构建器(builder)。早期的CMake通常使用GNU make作为构建器,但是只能应用于Linux中。在Windows下需要使用MSVC的构建器。2011年Google推出Ninja构建器之后,由于其速度更快,逐渐成为了新的选择。并且在Windows和Linux下都可以使用。

这里“项目管理”这个词借鉴了传统IDE中“项目”(Project)这个用法,当然在MSVC中应当叫做“解决方案”(Solution)。几乎所有的现代IDE都支持项目和子项目等层次化的管理方式,这也符合大型项目模块化设计和开发的需求。

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的过程非常繁琐。有程序员用“吃💩”一样来形容这个维护的过程。

CMake在C语言编译中所起的作用

正如gcc指令所使用的一些参数那样,C语言开发过程中开发者必须的操作包括:

  • 指定需要编译的源代码文件:生成目标文件(库或者可执行文件)都需要编译哪些源代码。
  • 指定包含路径:否则编译器无法找到用户自己编写的头文件。
  • 指定链接路径:否则链接器找不到需要链接的库文件。
  • 指定需要链接的库:否则链接器找不到外部函数的定义。

如果使用CMake来管理一个工程,这几个过程会变得更加显式。

仍然使用上面的工程目录结构,这次不需要手动编写Makefile,而是需要编写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_requiredproject这两个语句是一个工程的固定格式,用于指定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
│   │   ├── 3.22.1
│   │   │   ├── CMakeCCompiler.cmake
│   │   │   ├── CMakeDetermineCompilerABI_C.bin
│   │   │   ├── CMakeSystem.cmake
│   │   │   └── CompilerIdC
│   │   │       ├── CMakeCCompilerId.c
│   │   │       ├── a.out
│   │   │       └── tmp
│   │   ├── CMakeDirectoryInformation.cmake
│   │   ├── CMakeOutput.log
│   │   ├── CMakeTmp
│   │   ├── Makefile.cmake
│   │   ├── Makefile2
│   │   ├── TargetDirectories.txt
│   │   ├── cmake.check_cache
│   │   ├── hello.dir
│   │   │   ├── DependInfo.cmake
│   │   │   ├── build.make
│   │   │   ├── cmake_clean.cmake
│   │   │   ├── cmake_clean_target.cmake
│   │   │   ├── compiler_depend.make
│   │   │   ├── compiler_depend.ts
│   │   │   ├── depend.make
│   │   │   ├── flags.make
│   │   │   ├── hello
│   │   │   │   └── src
│   │   │   ├── link.txt
│   │   │   └── progress.make
│   │   ├── progress.marks
│   │   └── run_hello.dir
│   │       ├── DependInfo.cmake
│   │       ├── build.make
│   │       ├── cmake_clean.cmake
│   │       ├── compiler_depend.make
│   │       ├── compiler_depend.ts
│   │       ├── depend.make
│   │       ├── flags.make
│   │       ├── link.txt
│   │       └── progress.make
│   ├── Makefile
│   └── cmake_install.cmake
├── hello
│   ├── inc
│   │   └── hello.h
│   ├── lib
│   └── src
│       └── hello.c
└── main.c

接下来在build目录下执行make指令,即可得到库文件libhello.a和可执行程序run_hello。

发展

  • 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相关著作:

参考资料

[1] CMake历史

[2] CMake-Wiki