使用CMake构建项目

使用目标#

最简单的CMake的项目是创建单个的二进制可执行文件,例如一个"hello_world.cpp",而随着项目逐渐复杂,可能会有成百上千的文件被加入其中。这时就需要有某种方式可以对文件进行划分,将其按照功能分成不同的单元,其中一个单元可能会依赖其他的单元,这样的单元就是目标

概念#

目标是个强大的概念,它极大地简化了项目的构建。

CMake中可以通过以下指令来创建目标:

  • add_executable()
  • add_library()
  • add_custom_target()

分别对应了创建可执行目标,库目标和自定义的目标,其中自定义的目标意思是允许你可以执行指定的命令行,在不检查输出是否为最新的情况下执行。

add_custom_target(clean_files 
    COMMAND find . -name "*.txt" -type f -delete)

比如定义一个这样的自定义目标,将搜索所有扩展名为.txt的文件,并删除它们。不过自定义目标只有被添加到依赖关系中才会被构建。

CMake有一个很好的模块可以生成graphviz格式的依赖关系图,使用命令cmake --graphviz=name.dot .来执行。这个命令将会生成一个文本文件,将其导入到Graphviz可视化软件中就可以生成一个能展示依赖关系的图。

目标拥有属性的概念,其中一部分是可以被设置的,而另一部分是只读的,需要使用到目标的某些属性时,可以查阅CMake的相关文档说明,其提供了很大的“已知属性”的列表。

get_target_property(<var> <target> <property-name>)
set_target_property(<target1> <target2> ... PROPERTIES <prop1-name> <value> <prop2-name> <value> ...)

通过以上的两个指令可以获取和设置目标的属性。

接口库#

接口库是一个有趣的目标,其不会编译任何的东西,而是作为一个中间的工具目标来使用。比如包含一些头文件或者绑定需要传递的属性到一个逻辑单元中。就像创建一个库目标一样,接口库目标也是使用add_library指令,不过需要加上INTERFACE关键字,在链接接口库时,也是使用target_link_library指令加上INTERFACE关键字,像这样:

add_library(Eigen INTERFACE src/eigen.h src/vector.h src/matrix.h)

target_include_directories(Eigen INTERFACE
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src>
    $<INSTALL_INTERFACE:include/Eigen>)

target_link_library(executable Eigen)

Eigen是一个接口库,通过生成器表达式将导出目标的include设置为${CMAKE_CURRENT_SOURCE_DIR}/src,将安装目标的include设置为include/Eigen。

生成器表达式#

CMake通过配置、生成和运行构建工具三个阶段来构建解决方案,在配置阶段就拥有了所需的所有信息。但是,可能会遇到一个“先有鸡,还是先有蛋”问题,就是某一个目标需要知道另一个目标的二进制文件路径,但是只有在解析完了所有列表文件并完成配置后才能获取这些信息。
为了解决这个问题,就需要先给这些信息分配一个占位符,并将获取信息的工作推到生成阶段再执行。这就是生成器表达式要做的事情。
使用方式是这样:$<EXPRESSION:arg1,arg2,arg3>

表达式 参数
EXPRESSION arg1,arg2,arg3

以$符号和尖括号开头,用冒号分隔名称和参数,参数之间用逗号分隔,最后再用右尖括号关闭指令。另外,生成器表达式的参数允许嵌套,即参数可以是另一个生成器表达式或者变量等。
生成器表达式的表达式部分可以执行条件判断或者计算类型,计算类型只会是布尔值或者字符串。

逻辑运算#

布尔型可以进行参数间的与或非比较,或者显式地将字符串转换为布尔值。显式地将字符串转换为布尔值时,字符串满足false的判断时为0,否则为1。

$<NOT:arg>
$<AND:arg1,arg2,arg3,...>
$<OR:arg1,arg2,arg3,...>
$<BOOL:string_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,简直难以理解,不多倒是很多情况下会使用到这种判断形式。

字符串比较#

$<STREQUAL:arg1,arg2>   # 区分字符串大小写的比较
$<EQUAL:arg1,arg2>      # 转换成数字的比较
$<IN_LIST:arg,list>     # 是否在列表中
$<VERSION_EQUAL:v1,v2>  # 版本比较

查询变量#

$<TARGET_EXISTS:arg>    # 目标是否存在
$<CONFIG:args>          # 当前的配置模式(debug或者release等,不区分大小写)
$<PLATFORM_ID:args>     # 当前平台的id是否在参数中
$<LANG_COMPILER_ID:args>    # 编译器的id是否在参数中

生成器表达式还有更多复杂艰深的用法,估计也很难会用到,用到的时候再去查CMake的文档吧,没必要在这种地方浪费时间。

编译C++#

创建和运行C++程序需要几步:

  1. 设计应用程序,编写代码。
  2. 将单个的.cpp文件(编译单元)编译为目标文件。
  3. 将目标文件链接到一个可执行文件中,并添加所有的依赖——动态库和静态库。
  4. 为了运行这个程序,操作系统将使用一个名为加载器的工具,将其机器码和所有必需的动态库映射到内存中。然后加载器读取头文件以确定程序从何处开始运行,并将控制权交给程序进程。
  5. 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模式来将全局的文件都收集到一个变量中。

file(GLOB Helloworld_Src "*.h" "*.cpp")

add_executable(HelloWorld ${Helloworld_Src})

但是并不建议使用这种方法,原因在于CMake根据列表文件中的更改生成构建系统,如果不做更改,构建可能会在没有任何警告的情况下中断。此外,没有在目标声明中列出所有的源码将破坏诸如Clion等IDE的代码检查。

推荐的做法是使用target_sources()追加源文件到之前创建的目标中:

add_executable(main main.cpp)

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    target_sources(main PRIVATE gui_linux.cpp)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
    target_sources(main PRIVATE gui_windows.cpp)
endif()

预处理#

预处理器最基本的功能就是将#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>)来生成一个配置头文件,包含了各种会使用到的配置参数。

// configure.h.in
#cmakedefine FOO_ENABLE
#cmakedefine FOO_STRING1 "@FOO_STRING1@"
#cmakedefine FOO_STRING2 "${FOO_STRING2}"
#cmakedefine FOO_UNDEFINED "@FOO_UNDEFINED@"

然后在CMakeLists.txt文件中设置对应的变量:

add_executable(configure configure.cpp)

set(FOO_ENABLE ON)

set(FOO_STRING1 "abc")

set(FOO_STRING2 "def")

configure_file(configure.h.in configured/configure.h)

target_include_directories(configure PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

然后会在build的目录树下生成一个configure.h文件:

#define FOO_ENABLE
#define FOO_STRING1 "abc"
#define FOO_STRING2 "def"
/* #undef FOO_UNDEFINED "@FOO_UNDEFINED@" */

然后可以在需要使用到配置的文件中引用这个头文件:

#include <iostream>
#include "configured/configure.h"

using namespace std;

int main() {
#ifdef FOO_ENABLE
    cout << "FOO_ENABLE: ON" << endl;
#endif
    cout << "FOO_STRING1: " << FOO_STRING1 << endl;
    cout << "FOO_STRING1: " << FOO_STRING2 << endl;
}

链接#

CMake中只提供了target_link_libraries()指令参与链接阶段的相关配置,目的也很简单,就是将库链接到目标文件中。
但是库的概念是很宽泛的,静态库、动态库和模块都可以是链接到目标文件的库。

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会在构建项目时检测到属性冲突。

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的使用方式。

cmake_minimum_required(VERSION 3.20)

project(FindProtobufPackage CXX)

find_package(Protobuf REQUIRED)

protobuf_generate_cpp(GENERATED_SRC GENERATED_HEADER message.proto)

add_executable(main main.cpp ${GENERATED_SRC} ${GENERATED_HEADER})

target_link_libraries(main PRIVATE ${Protobuf_LIBRARIES})

target_include_directories(main PRIVATE ${Protobuf_INCLUDE_DIRS} ${CMAKE_CURRENT_BINARY_DIR})

上面的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文件:

target_link_libraries(main PRIVATE protobuf::libprotobuf)

target_include_directories(main PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

导入的目标protobuf::libprotobuf隐含了要包含的头文件,并传递进了我们的项目中。这样看起来更加简洁明了。

find_package()指令有一些扩展的选项可供使用

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的查找模块需要以下几个步骤:

  1. 若库和头文件的路径已知 (由用户提供,或来自前一次运行的缓存),则使用这些路径并创建导入的目标。结束。
  2. 否则,找到嵌套依赖的库和头文件——PostgreSQL。
  3. 已知的路径中搜索二进制版本的 PostgreSQL 客户端库。
  4. 搜索 PostgreSQL 客户端包含头文件的已知路径。
  5. 检查是否找到库和 include 头文件。是的话,创建一个 IMPORTED 目标。

IMPORTED目标的创建会发生两次,一次是用户在命令行中指定库的路径,一次是自动搜索到。编写一个函数来封装搜索的结果。
要创建IMPORTED目标,只需要一个带有IMPORTED关键字的库(在CMakeLists.txt文件中的target_link_libraries()指令会使用到它),另外库提供一个类型将其标记为UNKNOWN表示我们不想关心找到的库是静态的还是动态的,只是为链接器提供一个参数。
接下来将函数的参数导入到目标的属性中,这里导入库用IMPORTED_LOCATION,导入头文件用IMPORTED_INCLUDE_DIRECTORIES。
然后将相关路径设置为缓存变量,这样可以被用户的CMakeLists.txt文件访问到,不需要再次执行搜索了。并且标记为高级,这样才能在Gui中显示。

function(add_imported_library library headers)

    add_library(PQXX::PQXX UNKNOWN IMPORTED)

    set_target_properties(PQXX::PQXX PROPERTIES
        IMPORTED_LOCATION ${library}
        INTERFACE_INCLUDE_DIRECTORIES ${headers}
    )

    set(PQXX_FOUND 1 CACHE INTERNAL "PQXX found" FORCE)

    set(PQXX_LIBRARIES ${library}
        CACHE STRING "Path to pqxx library" FORCE)

    set(PQXX_INCLUDES ${headers}
        CACHE STRING "Path to pqxx headers" FORCE)

    mark_as_advanced(FORCE PQXX_LIBRARIES)

    mark_as_advanced(FORCE PQXX_INCLUDES)

endfunction()

接下来要判断用户是否指定了路径,如果用户通过-D指定了非标准位置的安装路径,只需要调用上面定义的函数,然后通过return()进行转义来放弃搜索。并且,如果之前配置过了,库的路径和头文件的路径也会被设置过,判断结果也为true,不用再执行搜索了。

if(PQXX_LIBRARIES AND PQXX_INCLUDES)

    add_imported_library(${PQXX_LIBRARIES} ${PQXX_INCLUDES})

    return()

endif()

现在完成了第一个步骤,接下来就需要寻找嵌套依赖的库和头文件了,pqxx是PostgreSQL的C++的接口库,那么就需要依赖PostgreSQL。可以在自己的查找模块中使用另一个查找模块,但是要将REQUIRED和QUIET标识转发给嵌套的模块。在CMake的CMakeFindDependencyMacro模块中有一个find_dependency()宏命令可以帮助我们找到需要的组件,在使用前需要先引入这个模块。

include(CMakeFindDependencyMacro)
find_dependency(PostgreSQL)

要查找PQXX库,需要一个_PQXX_DIR变量的帮助(转换成cmake样式的路径),并使用find_library()指令扫描路径,这个指令将会检查是否存在一个和NAMES关键字后面的名称匹配的库的二进制文件,如果找到了就把路径保存到PQXX_LIBRARY_PATH变量中,否则设置为<var>-NOTFOUND。
NO_DEFAULT_PATH关键字的意思是禁止默认行为。

file(TO_CMAKE_PATH "$ENV{PQXX_DIR}" _PQXX_DIR)

find_library(PQXX_LIBRARY_PATH NAMES libpqxx pqxx
    PATHS
        ${_PQXX_DIR}/lib/${CMAKE_LIBRARY_ARCHITECTURE}
        # other path
        /usr/lib
    NO_DEFAULT_PATH
)

然后使用find_path()来搜索所有已知的头文件。

find_path(PQXX_HEADER_PATH NAMES pqxx/pqxx
    PATHS
        ${_PQXX_DIR}/include
        # other path
        /usr/include
    NO_DEFAULT_PATH
)

我们已经完成了依赖的库和头文件的路径搜索和添加,是时候检查一下定义的路径中是否包含-NOTFOUND值了,可以手动完成,打印诊断信息和终止构建,也可以使用CMake的FindPackageHandleStandardArgs模块来辅助完成。如果指定的路径都填充完成了,会将<PKG_NAME>_FOUND设置为1,并输出诊断信息(如果非QUEIT的话)。
最后如果找到了库,就在调用函数来定义导入的目标,并将路径存储在缓存中:

include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
    PQXX DEFAULT_MSG PQXX_LIBRARY_PATH PQXX_HEADER_PATH
)

if(PQXX_FOUND) 
    add_imported_library (
        "${PQXX_LIBRARY_PATH}" "${POSTGRES_LBRARIES}" "${PQXX_HEADER_PATH}" "${POSTGRES_INCLUDE_DIRECTORIES}" 
    )
endif()

将上述的内容放到一个文件,我们就完成了PQXX的查找模块,它将创建一个PQXX::PQXX目标。

使用Git库#

可以使用git的命令将其他git存储库作为当前项目的子模块。使用下述的git命令将其他库作为当前仓库的子模块加入进来。

git submodule add <repository-url>

如果当前项目已经有了子模块,需要初始化它们:

git submodule update --init -- <local-path-to-submodule>

那么,我们如果是用git进行CMake项目管理的话,就可以将依赖的其他git库在需要的时候通过git的子模块加入到自己的项目中。

find_package(yaml-cpp QUIET)

if(NOT yaml-cpp_FOUND)

    message("yaml-cpp not found, initializing git submodule")

    executable_process(
        COMMAND git submodule update --init -- extern/yaml-cpp WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
    )

    add_subdirectory(extern/yaml-cpp)

endif()

target_link_libraries(target PRIVATE yaml-cpp)

首先尝试直接找到yaml-cpp的包,如果不存在的话再使用git指令初始化子模块,这里假定子模块存放的路径是在extern路径下,这是个好的项目管理方式。
最后,将yaml-cpp项目作为子目录加入到当前的项目中来。

作者:cwtxx

出处:https://www.cnblogs.com/cwtxx/p/18718212

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

posted @   cwtxx  阅读(17)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示