CMake(一)

1,127 阅读7分钟

参考:www.bilibili.com/video/BV1fa…

引子

最近在工作中经常要使用到cmake,所有这里对零零散散的学习进行一个简单的总结。

首先,我们来看一个简单的命令。

g++ main.cpp -o a.out

该命令会调用编译器程序g++,来生成机器指令码,输出到a.out这个文件中(称为可执行文件)。

之后执行该命令,操作系统会读取刚刚生成的可执行文件,从而执行其中编译成的机器码。

但是,单文件编译虽然方便,但也有如下缺点:

  1. 所有的代码都堆在一起,不利于模块化和理解;
  2. 工程变大时,编译时间变得很长,改动一个地方就得全部重新编译。

因此,我们提出多文件编译的概念,文件之间通过符号声明相互引用。

  • g++ -c hello.cpp -o hello.o
  • g++ -c main.cpp -o main.o
  • 其中使用 -c 选项指定生成临时的对象文件 main.o,之后再根据一系列对象文件进行链接,得到最终的a.out
  • g++ hello.o main.o -o a.out

1.PNG

2.PNG

通过这种方式,当一个文件改变后,我们只需要将该文件进行重新编译即可。

为什么需要构建系统(Makefile)

  • 文件越来越多,一个个调用g++编译链接会变得很麻烦;
  • 于是,发明了 make 这个程序,你只需要写出不同文件之间的依赖关系,和生成这个文件的规则;
  • >make a.out,构建出a.out这个可执行文件;

优点:

  1. 当更新了 hello.cpp 时只会重新编译 hello.o,而不需要把 main.o 也重新编译一遍;
  2. 能够自动并行地发起对 hello.cpp 和 main.cpp 的编译,加快编译速度 (make -j);
  3. 用通配符批量生成构建规则,避免针对每个.cpp和.o重复写g++命令(%.o: %.cpp)。

缺点:

  1. make 在 Unix 类系统上是通用的,但在 Windows 则不然;
  2. 需要准确地指明每个项目之间的依赖关系,有头文件时特别头疼;
  3. make 的语法非常简单,不像 shell 或 python 可以做很多判断等;
  4. 不同的编译器有不同的 flag 规则,为 g++ 准备的参数可能对 MSCV 不使用。

3.PNG

构建系统的构建系统(CMake)

为了解决 make 的以上问题,跨平台的 CMake 应运而生

  • 只需要写一份 CMakeLists.txt,他就能在调用时生成当前系统所支持的构建系统;
  • CMake 可以自动检测源文件和头文件之间的依赖关系,导出到 Makefile里;
  • CMake 具有相对高级的语法,内置的函数能够处理 configure, install 等常见需求;
  • CMake 可以自动检测当前的编译器,需要添加哪些flag。比如 OpenMP,只需要在CMakeLists.txt 中指明 target_link_libraries(a.out OpenMP::OpenMP_CXX) 即可。

CMake的命令行调用

  • 读取当前目录的 CMakeLists.txt,并在 build 文件夹下生成 build/Makefile;
  • cmake -B build;
  • 让 make 读取 build/Makefile,并开始构建 a.out(类比与 Windows 中的 exe 文件);
  • cmake --build build
  • 生成a.out:build/a.out

4.PNG

CMake库相关

为什么需要库(library)

  • 有时候我们会有多个可执行文件,他们之间用到的某些功能是相同的,我们想把这些共用的功能做成一个库,方便大家一起共享;
  • 库中的函数可以被可执行文件调用,也可以被其他库文件调用;
  • 库文件又分为静态库文件动态库文件
  • 其中静态库相当于直接把代码插入到生成的可执行文件中,会导致体积变大,但是只需要一个文件即可运行;
  • 而动态库则只在生成的可执行文件中生成”插桩“函数,当可执行文件被加载时会读取指令目录中的.dll文件,加载到内存中空闲的位置,并且替换成相应的“插桩”指向的地址为加载后的地址,这个过程称为重定向。这样以后函数被调用就会跳转到动态加载的地址去。
  • Windows:可执行文件同目录,其次是环境变量%PATH%
  • Linux:ELF格式可执行文件的RPATH,其次是/usr/lib等

CMake中的静态库和动态库

  • CMake 除了 add_executable 可以生成可执行文件外,还可以通过 add_library 生成库文件;
  • add_library 的语法与 add_executable 大致相同,除了他需要指定是动态库还是静态库;
  • add_library(test STATIC source1.cpp source2.cpp) 生成静态库 libtest.a;
  • add_library(test SHARED source1.cpp source2.cpp) 生成动态库 libtest.so
  • 创建库以后,要在某个可执行文件中使用该库,只需要:
  • target_link_libraries(myexec PUBLIC test)

5.PNG

CMake中的子模块

  • 复杂的工程中,我们需要划分子模块,通常一个库一个目录,比如:

6.PNG

  • 要在根目录使用他,可以用 CMake 的 add_subdirectory 添加子目录,子目录也包含一个 CMakeLists.txt,其中定义的库在 add_subdirectory 之后就可以在外面使用;
  • 子目录的 CMakeLists.txt 里路径名(比如 hello.cpp)都是相对路径,这也是很方便地一点。

7.PNG

8.PNG

因为 hello.h 被移到了 hellolib 子文件夹里,因此 main.cpp 里也要改成:

9.PNG

  • 如果要避免修改代码,我们可以通过 target_include_directories 指定 a.out 的头文件搜索目录:(其中第一个 hellolib 是库名,第二个是目录):

10.PNG

  • 这样甚至可以用 <hello.h> 来引用这个头文件了,因为通过 target_include_directories 指定的路径会被视为与系统路径等价:

11.PNG

  • 但是这样如果另一个 b.out 也需要用 hellolib 这个库,难道也得再指定一遍搜索路径吗?
  • 不需要,其实我们只需要定义 hellolib 的头文件搜索路径,引用他的可执行文件 CMake 会自动添加这个路径:

12.PNG

  • 这里用了 . 表示当前路径,因为子目录里的路径是相对路径,类似还有 .. 表示上一层目录;
  • 此外,如果不希望让引用 hellolib 的可执行文件自动添加这个路径,把PUBLIC改成PRIVATE即可。这就是他们的用途:决定一个属性要不要在被 link 的时候传播。

目标的一些其他选项

  • 除了头文件搜索目录外,还有这些选项,PUBLIC 和 PRIVATE 对他们同理:

    • target_include_directories(myapp PUBLIC /usr/include/eigen3) #添加头文件搜索目录
    • target_link_libraries(myapp PUBLIC hellolib) # 添加要链接的库
    • target_add_definitions(myapp PUBLIC MY_MACRO=1) # 添加一个宏定义
    • target_compile_options(myapp PUBLIC -fopenmp) # 添加编译器命令行选项
    • target_sources(myapp PUBLIC hello.cpp other.cpp) # 添加要编译的源文件
  • 以及可以通过下列指令(不推荐使用),把选项加到所有接下来的目标去(上述会指定目标,下面的命令针对所有目标):

    • include_directories(/opt/cuda/include) # 添加头文件搜索目录
    • link_directories(/opt/cuda) # 添加库文件的搜索路径
    • add_definitions(MY_MACRO=1) # 添加一个宏定义
    • add_compile_options(-fopenmp) # 添加编译器命令行选项

第三方库

作为纯头文件引入

这里举几个例子:

  • nothings/stb - 涵盖图像、声音、字体等,只需单头文件
  • Tencent/rapidjson - 单纯的 JSON 库
  • fmtlib/fmt - 格式化库,提供 std::format 的替代品
  • gabime/spdlog - 能适配控制台,安卓等后端的日志库

只需要把他们的 include 目录或头文件下载下来,然后 include_directories(spdlog/include)即可。

缺点:

函数直接实现在头文件里,没有提前编译,从而需要重复编译同样内容,编译时间长。

cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_executable(a.out main.cpp)
target_include_directoires(a.out PUBLIC glm/include)

作为子模块引入

  • 第二友好的方式则是作为 CMake 子模块引入,也就是通过 add_subdirectory;

  • 方法就是把那个项目的源码放到你工程的根目录:

  • 举几个例子:

    • fmtlib/fmt - 格式化库
    • google/googletest - 谷歌单元测试框架
    • google/benchmark - 谷歌性能评估框架
    • glfw/glfw - OpenGL 窗口和上下文管理
    • abseil/abseil-cpp 旨在补充标准库没有的常用功能
    • bombela/backward-cpp 实现了 C++ 的堆栈回溯便于调试
    • libigl/libigl - 各种图形学算法大合集
cmake_minimum_required(VERSION 3.12) 
project(hellocmake LANGUAGES CXX)
​
add_subdirectory(fmt)
​
add_executable(a.out main.cpp)
target_link_libraries(a.out PUBLIC fmt)

引用系统中预安装的第三方库

  • 可以通过 find_package 命令寻找系统中的包/库

  • find_package(fmt REQUIRED)

  • target_link_libraries(myexec PUBLIC fmt::fmt)

  • 为什么是 fmt::fmt 而不是简单的 fmt?

  • 现代 CMake 认为一个包(package)可以提供多个库,又称为组件(components),比如TBB这个包,就包含了tbb,tbbmalloc,tbbmalloc_proxy这三个组件;

  • 可以指定要用哪几个组件:

    find_package(TBB REQUIRED COMPONENTS tbb tbbmalloc REQUIRED)
    target_link_libraries(myexec PUBLIC TBB::tbb TBB::tbbmalloc)
    
cmake_minimum_required(VERSION 3.12)
project(hellocmake LANGUAGES CXX)
​
add_executable(a.out main.cpp)
​
find_package(fmt REQUIRED)
target_link_libraries(a.out PUBLIC fmt::fmt)

第三方库 - 常用 package 列表

  • fmt::fmt
  • spdlog::spdlog
  • TBB::tbb
  • OpenMP::OpenMP_CXX

不同的包之间常常有着依赖关系,而包管理器的作者为 find_package 编写的脚本 (例如/usr/lib/cmake/TBB/TBBConfig.cmake)能够自动查找所有依赖,并利用刚刚提到的 PUBLIC PRIVATE 正确处理依赖项,比如如果你引用了 OpenVDB::openvdb 那么 TBB::tbb 也会被自动引用。

其他的引用格式和文档参考:

cmake.org/cmake/help/…