使用CMake构建项目
使用目标
最简单的CMake的项目是创建单个的二进制可执行文件,例如一个"hello_world.cpp",而随着项目逐渐复杂,可能会有成百上千的文件被加入其中。这时就需要有某种方式可以对文件进行划分,将其按照功能分成不同的单元,其中一个单元可能会依赖其他的单元,这样的单元就是目标。
概念
目标是个强大的概念,它极大地简化了项目的构建。
CMake中可以通过以下指令来创建目标:
add_executable()
add_library()
add_custom_target()
分别对应了创建可执行目标,库目标和自定义的目标,其中自定义的目标意思是允许你可以执行指定的命令行,在不检查输出是否为最新的情况下执行。
1 | add_custom_target(clean_files |
比如定义一个这样的自定义目标,将搜索所有扩展名为.txt的文件,并删除它们。不过自定义目标只有被添加到依赖关系中才会被构建。
CMake有一个很好的模块可以生成graphviz格式的依赖关系图,使用命令
cmake --graphviz=name.dot .
来执行。这个命令将会生成一个文本文件,将其导入到Graphviz可视化软件中就可以生成一个能展示依赖关系的图。
目标拥有属性的概念,其中一部分是可以被设置的,而另一部分是只读的,需要使用到目标的某些属性时,可以查阅CMake的相关文档说明,其提供了很大的“已知属性”的列表。
1 | get_target_property(<var> <target> <property-name>) |
通过以上的两个指令可以获取和设置目标的属性。
接口库
接口库是一个有趣的目标,其不会编译任何的东西,而是作为一个中间的工具目标来使用。比如包含一些头文件或者绑定需要传递的属性到一个逻辑单元中。就像创建一个库目标一样,接口库目标也是使用add_library
指令,不过需要加上INTERFACE关键字,在链接接口库时,也是使用target_link_library
指令加上INTERFACE关键字,像这样:
1 | add_library(Eigen INTERFACE src/eigen.h src/vector.h src/matrix.h) |
Eigen是一个接口库,通过生成器表达式将导出目标的include设置为${CMAKE_CURRENT_SOURCE_DIR}/src,将安装目标的include设置为include/Eigen。
生成器表达式
CMake通过配置、生成和运行构建工具三个阶段来构建解决方案,在配置阶段就拥有了所需的所有信息。但是,可能会遇到一个“先有鸡,还是先有蛋”问题,就是某一个目标需要知道另一个目标的二进制文件路径,但是只有在解析完了所有列表文件并完成配置后才能获取这些信息。
为了解决这个问题,就需要先给这些信息分配一个占位符,并将获取信息的工作推到生成阶段再执行。这就是生成器表达式要做的事情。
使用方式是这样:$<EXPRESSION:arg1,arg2,arg3>
。
表达式 | 参数 |
---|---|
EXPRESSION | arg1,arg2,arg3 |
以$符号和尖括号开头,用冒号分隔名称和参数,参数之间用逗号分隔,最后再用右尖括号关闭指令。另外,生成器表达式的参数允许嵌套,即参数可以是另一个生成器表达式或者变量等。
生成器表达式的表达式部分可以执行条件判断或者计算类型,计算类型只会是布尔值或者字符串。
逻辑运算
布尔型可以进行参数间的与或非比较,或者显式地将字符串转换为布尔值。显式地将字符串转换为布尔值时,字符串满足false的判断时为0,否则为1。
1 | $<NOT:arg> |
需要特别说明一下IF这个很怪异的表达式。常规的可以写成$<IF:condition, true_string, false_string>
,表示如果condition判断为true,结果展开成true_string字符串,如果判断为false,结果展开成false_string字符串。
还可以省略false的情况,$<IF:condition,true_string,>
,表示只有condition判断为true时,才展开成true_string字符串,否则没有任何操作。
最后,还可以简化成$<condition:true_string>
,直接忽略掉了表达式IF,简直难以理解,不多倒是很多情况下会使用到这种判断形式。
字符串比较
1 | $<STREQUAL:arg1,arg2> # 区分字符串大小写的比较 |
查询变量
1 | $<TARGET_EXISTS:arg> # 目标是否存在 |
生成器表达式还有更多复杂艰深的用法,估计也很难会用到,用到的时候再去查CMake的文档吧,没必要在这种地方浪费时间。
编译C++
创建和运行C++程序需要几步:
- 设计应用程序,编写代码。
- 将单个的.cpp文件(编译单元)编译为目标文件。
- 将目标文件链接到一个可执行文件中,并添加所有的依赖——动态库和静态库。
- 为了运行这个程序,操作系统将使用一个名为加载器的工具,将其机器码和所有必需的动态库映射到内存中。然后加载器读取头文件以确定程序从何处开始运行,并将控制权交给程序进程。
- C++程序运行。执行一个spacial_start函数来收集命令行参数和环境参数,启动线程,初始化静态符号,注册清理回调。开始从main函数处运行。
编译工作
CMake的主要任务集中于第二步的编译工作。编译器必须依次执行预处理、语言分析、汇编、优化和生成二进制文件来完成一个目标文件的创建。CMake提供了诸多指令来参与各个阶段的配置:
target_compile_features()
: 需要具有特定功能的编译器来编译此目标。target_sources()
: 向已定义的目标添加源。target_include_directories()
: 设置预处理器的包含路径。target_compile_definitions()
: 设置预处理器定义。target_compile_options()
: 特定于编译器的选项。target_precompile_headers()
: 预编译头文件。
每个指令的参数都类似于target_...(<target name> <INTERFACE|PUBLIC|PRIVATE> <value>)
。
管理目标源
随着项目的进行,要编译的文件会越来越多,当使用add_executable()
或add_library()
时,如果一个一个添加在后面显然不是一个明智的做法。
一种方法是使用file()
进行文件的的收集工作,通过使用GLOB模式来将全局的文件都收集到一个变量中。
1 | file(GLOB Helloworld_Src "*.h" "*.cpp") |
但是并不建议使用这种方法,原因在于CMake根据列表文件中的更改生成构建系统,如果不做更改,构建可能会在没有任何警告的情况下中断。此外,没有在目标声明中列出所有的源码将破坏诸如Clion等IDE的代码检查。
推荐的做法是使用target_sources()
追加源文件到之前创建的目标中:
1 | add_executable(main main.cpp) |
预处理
预处理器最基本的功能就是将#include
包含的头文件展开,其中尖括号包裹的会从系统的标准路径中查找,双引号包裹的会先从当前项目指定的路径中查找,然后再搜索系统路径。CMake提供了target_include_directories(<target> [SYSTEM] [AFTER|BEFORE] <INTERFACE|PUBLIC|PRIVATE> [item1...] [<INTERFACE|PUBLIC|PRIVATE> [item1...] ...])
来操作头文件的搜索路径,相当于在编译器的命令行中添加了-I
。其中,SYSTEM关键字通知编译器提供的头文件是标准的系统路径,AFTER和BEFORE关键字来设置路径是添加到INCLUDE_DIRECTORIES属性的前面还是后面,一般也不用显式指定。
另外,预处理器还要处理一些预处理阶段的宏,有些宏是从外部传递进去的,可以使用target_compile_definitions()
在CMake项目中将宏传递进去,例如target_compile_definitions(hello PRIVATE -DFOO)
是将宏变量FOO传递给目标hello。另外还可以使用configure_file(<input> <output>)
来生成一个配置头文件,包含了各种会使用到的配置参数。
1 | // configure.h.in |
然后在CMakeLists.txt文件中设置对应的变量:
1 | add_executable(configure configure.cpp) |
然后会在build的目录树下生成一个configure.h文件:
1 |
|
然后可以在需要使用到配置的文件中引用这个头文件:
1 |
|
链接
CMake中只提供了target_link_libraries()
指令参与链接阶段的相关配置,目的也很简单,就是将库链接到目标文件中。
但是库的概念是很宽泛的,静态库、动态库和模块都可以是链接到目标文件的库。
1 | target_link_libraries(<target> [STATIC|SHARED|MODULE] <lib>) |
通过指定关键字STATIC、SHARED或者MODULE,来决定是生成一个静态库,动态库或者是模型库。
模型库可以看作和动态库一样,唯一的不同在于,在当前的CMake项目中,预期模型库不会也不应该被链接到目标中(如果链接的话CMake也不保证是正常工作的,需要直接链接的话就应该使用动态库),而是通过显式地在程序中调用模型库,例如LoadLibrary()
(Windows)或者dlopen()/dlsym()
(Linux)等调用动态库的系统调用接口。
位置无关代码
我们知道,手动编译动态库时,需要在g++的命令后面添加-fPIC
标识来生成位置无关的代码,原理在这里不做深究,总之这是生成动态库的必要步骤。
CMake在生成动态库和模块时会自动添加这个标识,通过设置POSITION_INDEPENDENT_CODE这个属性为ON来实现。
重要的是,如果要生成的动态库要链接到其他的目标上,比如静态库或者对象库,对应的目标也需要手动地设置这个属性,否则CMake会在构建项目时检测到属性冲突。
1 | set_target_properties(dependency_target PROPERTIES POSITION_INDEPENDENT_CODE ON) |
链接顺序和未定义符号
在使用target_link_libraries()
指令链接多个库时,可能会报出“undefined reference to xxx”之类的错误提示。这无疑是很糟糕和令人恼火的,因为看起来我们已经正确地链接了所有需要使用到的库,而且往往报出来未定义引用还不是我们熟悉的。
这个错误出现的原因在于,我们链接的库可能还会有嵌套的链接,而且就像手动使用g++命令链接一样,target_link_libraries()
指令链接库时也会有先后顺序。
库的链接遵循从左到右的顺序,在多个库的链接时,前一个库中出现的未定义符号会被链接器保留下来,并期望可以在后面的库中找到相关的定义,而前一个库中没有使用到的符号会被链接器抛弃掉。
于是会出现这样的场景,lib1依赖于lib2并使用到了lib2中的符号a,主程序再依赖于lib1,当我们这样写target_link_libraries(main lib2 lib1)
时,表示先链接lib2再链接lib1,链接lib2时,并没有使用到符号a,那么lib2中的符号a将会被抛弃,而在链接lib1时,lib1使用到了符号a,并且lib1是最后一个链接的,表示符号a无法再找到了,那么链接器就会报错。
解决办法就是交换lib1和lib2的链接顺序,target_link_libraries(main lib1 lib2)
。
管理依赖关系
CMake通过find_package()
指令查找和引入外部的依赖库。
find_package()
将会在系统路径中查找指定的第三方包,每个平台都有自己的安装和查找包的方法,CMake的这个指令就是跨平台的封装,力图让管理依赖的方式变得简单些。
一般规范的三方库包会提供了一个适当的配置文件,允许CMake获取包所需的变量,很多流行的项目会与CMake兼容,并在下载安装时提供这个配置文件。
使用的流行库如果不提供,可能CMake将这些文件和CMake本身绑定在一起了(称为查找模块,为了区别于配置文件),可以去CMake官方文档中找找是不是内嵌了,CMake提供了很多主流库的查找模块,比如Boost,curl,GIF,JPEG,Qt,PostgreSQL等等。
最坏的情况是什么也没有提供,那么还可以有其他选择:
- 为特定的包提供查找模块,并将其绑定到自己的项目中。
- 编写一个配置文件,并要求包的维护人员将包一起发布。
调用find_package()
指令时,CMake会先查找匹配的查找模块,如果找不到,再去查找配置文件。搜索会从存储在CMAKE_MODULE_PATH变量中的路径开始,如果需要添加自定义的查找路径,可以配置这个变量。CMake会查找符合下面两种模式的文件名:
- <CamelCasePackageName>Config.cmake
- <kebab-case-package-name>-config.cmake
书中使用引入Protobuf包的例子来说明,这里不妨假装我们已经了解了Protobuf的使用方式。
1 | cmake_minimum_required(VERSION 3.20) |
上面的CMakeLists.txt文件将Protobuf引入了自己的项目,并使用了其中提供的方法将message.proto生成了cpp文件加入到项目中,随后就像使用自己的库一样,链接Protobuf库,并将其头文件加入到项目的头文件路径中。
当使用find_package()
指令时,可以预期将会得到一些变量:
- <PKG_NAME>_FOUND
- <PKG_NAME>_INCLUDE_DIRS或者<PKG_NAME>_INCLUDES
- <PKG_NAME>_LIBRARIES或者<PKG_NAME>_LIBS
- <PKG_NAME>_DEFINITIONS
- IMPORTED 由查找模块或配置文件指定的目标
另外,如果包支持所谓的现代CMake的话,将会导入目标来替代上述的变量,也建议优先使用目标。
例如Protobuf会导入目标protobuf::libprotobuf,protobuf::libprotobuf-lite,protobuf::libprotoc和protobuf::libprotoc。这样可以修改上述的CMakeLists.txt文件:
1 | target_link_libraries(main PRIVATE protobuf::libprotobuf) |
导入的目标protobuf::libprotobuf隐含了要包含的头文件,并传递进了我们的项目中。这样看起来更加简洁明了。
find_package()
指令有一些扩展的选项可供使用
1 | find_package(<name> [version] [EXACT] [QUIET] [REQUIRED]) |
- version:指定特定的版本,使用major.minor.patch.tweak格式
- EXACT:指定精确的版本
- QUIET:使包的相关消息静默
- REQUIRED:如果没有找到相关的包,将会停止执行并打印诊断信息。
编写自己的查找模块
如果你的库想要发布让别人使用并兼容CMake,就需要编写一个自己库的自定义查找模块了。
书中以pqxx为例,编写一个FindPQXX.cmake文件。
先了解下CMake文档中关于查找模块的一些约定:
- 使用
find_package(<PKG_NAME> REQUIRED)
时,CMake将提供一个<PKG_NAME>_FIND_REQUIRED变量,并设置为1。当没有找到库时,查找模块应该使用 message(FATAL_ERROR)。 - 使用
find_package(<PKG_NAME> QUIET)
时,CMake将提供一个<PKG_NAME>_FIND_QUIETLY变量,并设置为1。查找模块应该跳过打印诊断消息。 - CMake将提供<PKG_NAME>_FIND_VERSION变量,设置为调用列表文件所需的版本。查找模块应该找到适当的版本或发出FATAL_ERROR信息。
创建一个PQXX的查找模块需要以下几个步骤:
- 若库和头文件的路径已知 (由用户提供,或来自前一次运行的缓存),则使用这些路径并创建导入的目标。结束。
- 否则,找到嵌套依赖的库和头文件——PostgreSQL。
- 已知的路径中搜索二进制版本的 PostgreSQL 客户端库。
- 搜索 PostgreSQL 客户端包含头文件的已知路径。
- 检查是否找到库和 include 头文件。是的话,创建一个 IMPORTED 目标。
IMPORTED目标的创建会发生两次,一次是用户在命令行中指定库的路径,一次是自动搜索到。编写一个函数来封装搜索的结果。
要创建IMPORTED目标,只需要一个带有IMPORTED关键字的库(在CMakeLists.txt文件中的target_link_libraries()
指令会使用到它),另外库提供一个类型将其标记为UNKNOWN表示我们不想关心找到的库是静态的还是动态的,只是为链接器提供一个参数。
接下来将函数的参数导入到目标的属性中,这里导入库用IMPORTED_LOCATION,导入头文件用IMPORTED_INCLUDE_DIRECTORIES。
然后将相关路径设置为缓存变量,这样可以被用户的CMakeLists.txt文件访问到,不需要再次执行搜索了。并且标记为高级,这样才能在Gui中显示。
1 | function(add_imported_library library headers) |
接下来要判断用户是否指定了路径,如果用户通过-D指定了非标准位置的安装路径,只需要调用上面定义的函数,然后通过return()进行转义来放弃搜索。并且,如果之前配置过了,库的路径和头文件的路径也会被设置过,判断结果也为true,不用再执行搜索了。
1 | if(PQXX_LIBRARIES AND PQXX_INCLUDES) |
现在完成了第一个步骤,接下来就需要寻找嵌套依赖的库和头文件了,pqxx是PostgreSQL的C++的接口库,那么就需要依赖PostgreSQL。可以在自己的查找模块中使用另一个查找模块,但是要将REQUIRED和QUIET标识转发给嵌套的模块。在CMake的CMakeFindDependencyMacro模块中有一个find_dependency()
宏命令可以帮助我们找到需要的组件,在使用前需要先引入这个模块。
1 | include(CMakeFindDependencyMacro) |
要查找PQXX库,需要一个_PQXX_DIR变量的帮助(转换成cmake样式的路径),并使用find_library()
指令扫描路径,这个指令将会检查是否存在一个和NAMES关键字后面的名称匹配的库的二进制文件,如果找到了就把路径保存到PQXX_LIBRARY_PATH变量中,否则设置为<var>-NOTFOUND。
NO_DEFAULT_PATH关键字的意思是禁止默认行为。
1 | file(TO_CMAKE_PATH "$ENV{PQXX_DIR}" _PQXX_DIR) |
然后使用find_path()
来搜索所有已知的头文件。
1 | find_path(PQXX_HEADER_PATH NAMES pqxx/pqxx |
我们已经完成了依赖的库和头文件的路径搜索和添加,是时候检查一下定义的路径中是否包含-NOTFOUND值了,可以手动完成,打印诊断信息和终止构建,也可以使用CMake的FindPackageHandleStandardArgs模块来辅助完成。如果指定的路径都填充完成了,会将<PKG_NAME>_FOUND设置为1,并输出诊断信息(如果非QUEIT的话)。
最后如果找到了库,就在调用函数来定义导入的目标,并将路径存储在缓存中:
1 | include(FindPackageHandleStandardArgs) |
将上述的内容放到一个文件,我们就完成了PQXX的查找模块,它将创建一个PQXX::PQXX目标。
使用Git库
可以使用git的命令将其他git存储库作为当前项目的子模块。使用下述的git命令将其他库作为当前仓库的子模块加入进来。
1 | git submodule add <repository-url> |
如果当前项目已经有了子模块,需要初始化它们:
1 | git submodule update --init -- <local-path-to-submodule> |
那么,我们如果是用git进行CMake项目管理的话,就可以将依赖的其他git库在需要的时候通过git的子模块加入到自己的项目中。
1 | find_package(yaml-cpp QUIET) |
首先尝试直接找到yaml-cpp的包,如果不存在的话再使用git指令初始化子模块,这里假定子模块存放的路径是在extern路径下,这是个好的项目管理方式。
最后,将yaml-cpp项目作为子目录加入到当前的项目中来。