CMake学习——在无意识状态中推导c++代码构建思路(
本文最后更新于 211 天前,其中的信息可能已经有所发展或是发生改变。

从简单入门

本文为学习过程中的推导记录,参考这位up:比飞鸟贵重的多_HKL

目前看到了第四讲

CMake 的底层逻辑

一个厨师做菜,抛开味道不谈,他至少要知道需要什么原材料,以及原材料在哪

代码的执行,分为预处理 → 编译 → 链接。我们通过 CMake 的脚本辅助这一过程

其中一部分是指定项目的信息,以及执行动作(比如进行一次链接生成一个目标),这个都是固定的没啥好讲的

  • 而我们还要关注的,就是这些过程中提供的参数 / 变量,能否让预处理(头文件)、编译、链接(库)都能找到需要的文件和路径。众所周知只是找东西的话那可比设计逻辑简单多了
  • 既可以以 CMakeLists.txt 的路径作为基准,也可以使用提供的指令来帮助寻找

最简单的语法分析

这里使用对 cmake 支持较好的 vscode,对于一个新的 cmake 构建项目,首先在项目下创建一个 CMakeLists.txt

然后 Ctrl+Shift+P 进行 CMake 配置。接下来可以在界面底栏操作

首先,想象一下如果在一个 GUI 里构建项目,我们应该指定:CMake 的版本,项目名称,ok 这就写了两行

cmake_minimum_required(VERSION 3.15)
project(cmake_study)

接着,大的项目肯定是分目录的,CMake 考虑到这一点,提供了分目录的构建,可以联系到分目录的 CMakeLists.txt

注意一定是有 CMakeLists.txt 的分目录。这样的好处就是把构建过程模块化,便于编写

之后在主目录 cmake 就能同时构建分目录

add_subdirectory(./test1_1)

接下来,我们在分目录下写几个简单的文件:main.cpp add.cpp add.h

然后分目录下的 CMakeLists.txt 这样写:

add_executable(test1_1 main.cpp add.cpp)

有人会想,头文件需要加进去吗?没这个必要,因为在编译的时候,头文件就是源文件的一部分

有人说头文件存在的意义是什么呢,别想太多,库的意义是啥,头文件的意义就是啥

好,但是同一个项目下的源文件很难都在一个目录啊,那引用头文件还得找相对路径,怎么办呢,别急:

include_directories(.)

这里头文件正好和 CMakeLists.txt 一个路径,如果是其他目录下的构建需要头文件,就一样用相对路径

至于这个引用路径的作用范围,这么说吧,在使用该指令的当前 CMakeLists.txt 下参与构建的所有源文件,都会扫描一遍这个引用目录寻找头文件,比如下面的例子:

add_executable(test1_1 main.cpp src/add.cpp)
include_directories(.)
# 子目录下的 src/add.cpp 也能引用主目录下的add.h
# 其他未参与构建的源文件则不能引用,但没参与所以也不报错,只有编辑器红线警告

但这样的添加头文件方法是有隐患的,看不出来也很正常——

假设我们构建不止一个目标,我们也许要更准确地建立头文件依赖关系,而不是全加上依赖。这里补充一点东西:

所有编程技术不断优化与升级,都在做 3 件事情:

  1. 提升效率(语言设计层面,我们关注一下新特性就行)
  2. 减少重复(指养成代码复用的习惯,以及杜绝造低质量轮子)
  3. 减轻依赖(需要的就加依赖,不需要的别滥加)

静态库和动态库

关于库的意义已经说过很多次了,保密 + 高效

C++ 程序构建过程中,编译和汇编结束会为库留好位置,接着按跳转地址去找库,并联系为一体,称为链接

首先,我们构建出一个最简单的库,完全使用前面的知识点,除了改一下指令

注意,随着库的诞生,我们的测试程序结构逐渐复杂,但是我们依然可以从主目录的 CMakeLists.txt 出发,并选择自己想构建的目标(vscode 帮我们做好了许多工作)

总之,可以勤用 add_subdirectory 指令来构建子目录下的目标,让每个 CMakeLists.txt 都专注于自己的目录

通过外层的导引到存放源文件那一级的 CMakeLists.txt,添加这条指令:

add_library(add_static add.cpp)

这和创建可执行文件非常相似,当然也可以指定库的类型,默认为静态库

接着找到生成的 .lib 文件,放在需要的位置,接下来的工作是在其他源文件的编译 → 链接中调用该库

注意库是在链接的时候调用,并不参与编译,所以得专门提供一个指令来给目标链接上库

相信在第一次使用库的时候,一定会发现,怎么有这么多带 link 的指令,该用哪个呢?

这时候千万别慌,对目前学习的级别来说,跑起来是主要的,优化是次要的,所以无视 target 开头的

(其实如果循序渐进地学习,每次卡住的时候,都可以试着倒退一步,看看能不能找到新的启发)

link_directories(${CMAKE_CURRENT_SOURCE_DIR}/lib)   # 自己摸索的时候犯了个低级错误, 忘记 ${ } 了
link_libraries(add_static.dll)  # 在库路径下寻找
# 或者一步到位
link_libraries(${CMAKE_CURRENT_SOURCE_DIR}/lib/add_static.lib)

CMake 2.8 以后的版本,添加链接目录需要使用绝对路径。当然,借助宏的话也很简单

到这里应该就能发现端倪了吧:指令的含义都是字面意思,寻找路径,寻找库,就和前面的寻找头文件一样

最终我们带上了库之后,构建可执行文件的简单指令如下(偷偷把库文件名改了):

link_libraries(${CMAKE_CURRENT_SOURCE_DIR}/lib/libadd_static.a)

include_directories(.)

add_executable(test1_1 main.cpp)

那么是时候补充一下 target_ 前缀的含义了

这就是前面所说的“减轻依赖”,寻找的依赖项,都只用于这一个目标,相比之下没有前缀的就是全局,甚至连动态库也都给你一起链接上(这个甚至其实我也不完全确定,但总体是这样)

target_include_directories(test1_1 .)   # 为目标引入头文件目录
target_link_directories(test1_1 ${CMAKE_CURRENT_SOURCE_DIR}/lib)    # 为目标引入库目录
target_link_libraries(test1_1 libadd_static.a)  # 为目标链接静态库

ok 前面的只是开胃菜好吧,抓紧跟上,接下来请出今天的重量级,动态库

有人会说,动态库不是更好搞吗,众所周知动态库是运行期间才调用的,按理说好像不是可执行程序构建的一部分

但是,正因为动态库运行时才使用,所以,需要用什么内容,如何跳转,我们都要提前做好工作

别急,先生成一个动态库——毕竟库本身还是代码,所以在生成上没有什么新的阻碍

从这里开始,记得检查自己的目录结构,动态库的源文件最好和可执行程序的源文件分开放,用两个 CMakeLists.txt

(毕竟添加子目录的功能就是用来独立空间的)

构建动态库,这里补充了前面的一点,即生成库的时候,有一个可选参数,目前已知有 STATIC / SHARED,顾名思义

add_library(add_shared SHARED add.cpp)

生成的动态库可以不放在 lib 目录里,可以建个 dll 目录或者放在 bin 里

然而动态库的调用,甚至 msvc 和 gcc 的逻辑还不一样:

  • msvc:

    1. cmake 会在生成 .dll 的同时,生成 .lib,这里的 lib 不是独立的静态库,而是为动态库在链接期服务的

      原因是,msvc 在运行期间调用 dll,需要在编译链接期通过 lib 提前告知 dll 的大致接口,提高运行期间的效率

      因此我们将生成的 lib 文件以静态库正常链接,然后把 dll 文件放在可执行文件可以触及的地方即可

      • 这点非常重要,同时配置静态库和动态库,是 Windows 上配动态库环境的特有环节
    2. msvc 规定,被导出成动态库的声明和定义前需要加 __declspec(dllexport)

      而动态库内的定义要被调用,则前面需要加 __declspec(dllimport)

      至于为什么要多这么一步,这里引用一下结论:

      • __declspec(dllexport) 标记为可以被外部直接使用
      • __declspec(dllimport) 标记为被外部确定使用,有利于生成更好的代码,不再需要间接的调用转接

      那么问题来了,同一个符号如何在库构建和被链接时使用不同关键字?CMake 提供了添加 #define 的功能

      target_compile_definitions(EXPORT) # 相当于在所有代码前加了一条 #define EXPORT

      用一个头文件 export.h 保存以下内容,并被使用该前缀的源文件(其实头文件就行)引用

      #pragma once
      #ifdef EXPORT
      #define MGC_CMAKE_API __declspec(dllexport)
      #else
      #define MGC_CMAKE_API __declspec(dllimport)
      #endif

      因为链接时没有这个定义,所以使用了下面的那个值

      因为头文件没有被编译,原封不动从构建库搬到编译程序,所以宏定义才被保留下来不被替换,实现双重定义

      以下为测试用的源文件:

      // add.h
      #pragma once
      #include "export.h"
      CMAKE_STUDY_API int add(int a, int b);
      // add.cpp -> add_library(add_shared SHARED add.cpp)
      #include "add.h"
      CMAKE_STUDY_API int add(int a, int b){
       return a + b;
      }
      // main.cpp -> add_executable(test1_1 main.cpp)
      #include "add.h"
      #include 
      
      int main(){
       int c = add(3, 4);
       std::cout << c << std::endl;
       return 0;
      }

CMake 大一统

CMake 的魅力何在,make 构建程序究竟有着怎样的渊源,接下来我们会在思考中回顾一遍

gcc 编译源代码有 4 步:预处理 → 编译 → 汇编 → 链接

  1. 预处理:处理掉头文件、宏定义和注释这三种语句(依然保留基本语法的 .i

  2. 编译:将每个源文件编译为汇编文件(可以打开和编辑的 .s

  3. 汇编:将汇编翻译为二进制汇编文件(无法用文本工具打开)

  4. 链接:将二进制文件组合起来

gcc 命令提供的参数,可以选择编译的选项,比如指定生成哪一步的结果

例如:g++ main.cpp -o main 接受一个源文件,直接生成最终的可执行文件

又比如:g++ -c main.s -o main.o 接受一个编译后的汇编文件,生成汇编后的二进制文件

或者像下面这样,每一步都生成文件,最后生成结果(以前面的简单 add 程序为例):

g++ -E main.cpp -o main.i
g++ -E add.cpp -o add.i

g++ -S main.i -o main.s
g++ -S add.i -o add.s

g++ -c main.s -o main.o
g++ -c add.s -o add.o

g++ main.o add.o -o main

具体的参数可以看文档,事实上平时不太能直接用到 gcc 的命令:

参考文档:gcc 命令

总之,只要顺序执行就能生成可执行程序,所以第一时间想办法脚本化,Makefile 诞生了:

main: main.o add.o
    g++ main.o add.o -o main
main.o add.o: main.s add.s
    g++ -c main.s -o main.o
    g++ -c add.s -o add.o
main.s add.s: main.i add.i
    g++ -S main.i -o main.s
    g++ -S add.i -o add.s
main.i add.i: main.cpp add.cpp
    g++ -E main.cpp -o main.i
    g++ -E add.cpp -o add.i

基本格式就是 目标文件: 需要的原料,下一行缩进开始写需要的命令列表,执行 make 会在目录搜索 Makefile

其中如果需要的原料也要由别的原料生成,就按同样的格式添一条

实际上 make 的流程是:以构造目标文件开始,搜索需要的文件,若不存在,再搜索构造该文件的方法,以此类推

以上面的脚本为例,假如我一开始从什么犄角旮旯里摸出 main.oadd.o 放到目录下,则第二行以后就忽略了

  • 有人问,下面的命令有要求吗?其实没有,你还可以加一句 echo <message>,但 make 执行完一组命令后,会检查原料是否生成了指定的文件,没有就报错

    (怎么总是有人问这种刁钻的问题,哦原来是我自问自答啊,那没事了)

    main: main.cpp add.cpp
    g++ main.cpp add.cpp -o main
    echo 111

总之,无论是上世纪还是如今,脚本都是用于即刻上手编写可复用操作的工具,对于命令行更是提高效率的工具

那么其实到了 Makefile + make 这一步,且不说语法好不好,Linux 下快速构建生成目标的工作就差不多了

那么接下来是 Windows?等等,我可没准备分系统讲啊,就算上面的 msvc 我也是针对编译器独有的特性来讲的

再说,Windows 编程早在多年前就已经走上了与 Linux 不同的图形画风,有 VS 不用的人,什么成分我不好评价

(说的不就是 2022 年的我吗,折腾到走火入魔忘记正事了)

  • 接下来我们回顾一下 CMake 的工作:

    在 Linux 平台下(默认 gcc)cmake 生成 Makefile,再 make

    在 Windows 平台下(默认 msvc)cmake 生成 .sln(solution),这正是 VS 的解决方案工程文件,开箱即用

    • 甚至连下一步的生成也实现了,cmake --build(这里要指定生成文件存放路径),在 gcc 环境会 make, msvc 也同理(当然鼠标点一下就完事了)

最后,在配置时使用参数将这个宏启用,你会发现,熟悉的脚本命令,又回来了

cmake .. -DCMAKE_VERBOSE_MAKEFILE=ON
# 在cmake --build 时输出更详细的命令信息
# verbose 啰嗦

而这次不会再害怕这些信息了,因为它们就是熟悉的 gcc 命令

关于细节的补充——make,静态库

虽然但是,我们还是读一下这些信息吧

(假设我们生成了 libadd_static.a),而 CMakeLists.txt 如下:

add_executable(test1_2 main.cpp)
target_link_directories(test1_2 PUBLIC lib)
target_link_libraries(test1_2 PUBLIC add_static)
target_include_directories(test1_2 PUBLIC ../test1_1)

截取 make 时的输出片段如下:

[ 50%] Building CXX object CMakeFiles/test1_2.dir/main.o
/usr/bin/c++  -I/home/xcr/MGC/dev/cmake_study/test1_2/../test1_1  -MD -MT CMakeFiles/test1_2.dir/main.o -MF CMakeFiles/test1_2.dir/main.o.d -o CMakeFiles/test1_2.dir/main.o -c /home/xcr/MGC/dev/cmake_study/test1_2/main.cpp
[100%] Linking CXX executable test1_2
/home/xcr/MGC/env/cmake/bin/cmake -E cmake_link_script CMakeFiles/test1_2.dir/link.txt --verbose=1
/usr/bin/c++ -rdynamic CMakeFiles/test1_2.dir/main.o -o test1_2   -L/home/xcr/MGC/dev/cmake_study/test1_2/lib  -Wl,-rpath,/home/xcr/MGC/dev/cmake_study/test1_2/lib -ladd_static 
make[2]: Leaving directory '/home/xcr/MGC/dev/cmake_study/test1_2/build'
  • 每个进度数后面是一条生成目标

  • 可能疑惑的地方:

    /usr/bin/c++ 是当前系统默认的 c++ 编译程序,一般就指 g++ 指令

    -MT -MD -MF -rdynamic 等是中途的一些动作或者选项,不需要关注,因为不用手写

    其中 -wl-rpath 是指定动态库路径,虽然没给动态库,但默认把静态库路径加上了,这里去掉也行

  • 依赖的指定(这和 CMakeLists.txt 里写的依赖相关):

    -c 后面跟要被编译但不链接的源文件,-o 后面跟输出的文件

    -I 后面跟头文件路径,-L 后面跟库文件路径,-l 后面跟链接的库名(不用空格)

于是梳理过后的信息如下:

# [ 50%] Building CXX object CMakeFiles/test1_2.dir/main.o
g++
  -I/home/xcr/MGC/dev/cmake_study/test1_2/../test1_1
  -c /home/xcr/MGC/dev/cmake_study/test1_2/main.cpp
  -o CMakeFiles/test1_2.dir/main.o
# [100%] Linking CXX executable test1_2
g++ CMakeFiles/test1_2.dir/main.o
  -L/home/xcr/MGC/dev/cmake_study/test1_2/lib
  -ladd_static
  -o test1_2

再补充一个,静态库的生成,CMakeLists.txt 如下:

add_library(add_static STATIC add.cpp)

截取 make 时的输出片段如下:

[ 50%] Building CXX object CMakeFiles/add_static.dir/add.cpp.o
/usr/bin/c++    -MD -MT CMakeFiles/add_static.dir/add.cpp.o -MF CMakeFiles/add_static.dir/add.cpp.o.d -o CMakeFiles/add_static.dir/add.cpp.o -c /home/xcr/MGC/dev/cmake_study/test1_1/add.cpp
[100%] Linking CXX static library libadd_static.a
/home/xcr/MGC/env/cmake/bin/cmake -P CMakeFiles/add_static.dir/cmake_clean_target.cmake
/home/xcr/MGC/env/cmake/bin/cmake -E cmake_link_script CMakeFiles/add_static.dir/link.txt --verbose=1
/usr/bin/ar qc libadd_static.a CMakeFiles/add_static.dir/add.cpp.o
/usr/bin/ranlib libadd_static.a
make[2]: Leaving directory '/home/xcr/MGC/dev/cmake_study/test1_1/build'
  • 可能疑惑的地方:

    /usr/bin/ar 等用于生成库的程序,用法和参数有兴趣可以找个时间专门学一下

于是梳理过后的信息如下:

# [ 50%] Building CXX object CMakeFiles/add_static.dir/add.cpp.o
g++
  -c /home/xcr/MGC/dev/cmake_study/test1_1/add.cpp
  -o CMakeFiles/add_static.dir/add.cpp.o
# [100%] Linking CXX static library libadd_static.a
/usr/bin/ar qc libadd_static.a CMakeFiles/add_static.dir/add.cpp.o
/usr/bin/ranlib libadd_static.a
关于细节的补充——动态库

回到大一统的标题,如果我们要统一,就要了解动态库,前面初次生成动态库的时候已经补充了 msvc 下 dll 的特性

然而我们在非 msvc 下是不能使用那些宏定义的,这里有个简单的办法

#pragma once

#ifndef LINUX
#ifdef EXPORT
#define MGC_CMAKE_API __declspec(dllexport)
#else
#define MGC_CMAKE_API __declspec(dllimport)
#endif

#else
#define MGC_CMAKE_API
#endif

我们可能很少使用这种特性,但是,如果 #define 没有设定值,那么代码中的宏则会直接删除,这样就不影响 gcc 了

与此同时,在 CMakeLists.txt 中,我们加入一条定义:

add_definitions(-DLINUX)

关于增加定义的指令,前面有个 target_compile_definitions,另外还有 add_compile_definitions

其中后两者不需要在宏前面加 -D,此外除了 target 显然要指定目标外,目前没什么讲究

硬要说的话,add_compile_definitions 相比 add_definitions 是在较新版本才加入的

add_definitions 是有渊源的,因为编译原生的参数形式就是 g++ -DMACRO=xxx,可以观察 Makefile

此外,搜索调用动态库的方式也有点区别:

  • Windows 下:
    1. 程序所在目录
    2. 系统目录(System32,Windows)
    3. path 变量
  • Linux 下:
    1. 环境变量 LD_LIBRARY_PATH
    2. 默认库目录(如 /usr/lib 等)
    3. 程序指定的路径

最大的区别就是,Linux 的程序是在编译中就确定了动态库的搜索路径,所以不用把动态库放在同目录下

ldd exec 可以查看动态库搜索地址列表

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇