从简单入门
本文为学习过程中的推导记录,参考这位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 件事情:
- 提升效率(语言设计层面,我们关注一下新特性就行)
- 减少重复(指养成代码复用的习惯,以及杜绝造低质量轮子)
- 减轻依赖(需要的就加依赖,不需要的别滥加)
静态库和动态库
关于库的意义已经说过很多次了,保密 + 高效
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:
-
cmake 会在生成
.dll
的同时,生成.lib
,这里的 lib 不是独立的静态库,而是为动态库在链接期服务的原因是,msvc 在运行期间调用 dll,需要在编译链接期通过 lib 提前告知 dll 的大致接口,提高运行期间的效率
因此我们将生成的 lib 文件以静态库正常链接,然后把 dll 文件放在可执行文件可以触及的地方即可
- 这点非常重要,同时配置静态库和动态库,是 Windows 上配动态库环境的特有环节
-
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 步:预处理 → 编译 → 汇编 → 链接
-
预处理:处理掉头文件、宏定义和注释这三种语句(依然保留基本语法的
.i
) -
编译:将每个源文件编译为汇编文件(可以打开和编辑的
.s
) -
汇编:将汇编翻译为二进制汇编文件(无法用文本工具打开)
-
链接:将二进制文件组合起来
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.o
和 add.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 下:
- 程序所在目录
- 系统目录(System32,Windows)
- path 变量
- Linux 下:
- 环境变量
LD_LIBRARY_PATH
- 默认库目录(如
/usr/lib
等) - 程序指定的路径
- 环境变量
最大的区别就是,Linux 的程序是在编译中就确定了动态库的搜索路径,所以不用把动态库放在同目录下
ldd exec
可以查看动态库搜索地址列表