CMake入门和大型工程管理

最近在负责一个大型工程的CMake编译系统管理,整理一些工作过程中积累下来的知识片段和技巧。CMake是一个跨平台的编译工具。

基本操作

通过编写CMakeLists.txt指挥cmake进行构建和编译。
通常我们会在根目录新建一个build文件夹,然后依次执行:

cmake ..
make
make install

其中cmake命令主要任务是按照CMakeLists.txt编写的规则生成MakeFile,而make会按照MakeFile进行编译、汇编和链接,从而生成可执行文件或者库文件。make install则是将编译好的文件安装到指定的目录。
CMake常用的命令或函数包括:

  • 定义项目:
    project(myProject C CXX):该命令会影响PROJECT_SOURCE_DIRPROJECT_BINARY_DIRPROJECT_NAME等变量。另外要注意的是,对于多个project嵌套的情况,CMAKE_PROJECT_NAME是当前CMakeLists.txt文件回溯至最顶层CMakeLists.txt文件中所在位置之前所定义的最后一个project的名字。
    cmake_minimum_required(VERSION 3.0):指出进行编译所需要的CMake最低版本,如果不指定的话系统会自己指定一个,但是也会扔出一个warning

  • 搜索源文件:
    file(<GLOB|GLOB_RECURSE> <variable> <pattern>):按照正则表达式搜索路径下的文件,比如file(GLOB SRC_LIST "./src/*.cpp")
    aux_source_directory(<dir> <variable>):搜索文件内所有的源文件。

  • 添加编译目标:
    add_library(mylib [STATIC|SHARED] ${SRC_LIST})
    add_executable(myexe ${SRC_LIST})

  • 添加头文件目录:
    include_directories(<items>):为该位置之后的target链接头文件目录(不推荐)。
    target_include_directories(<target> <PUBLIC|INTERFACE|PRIVATE]> <items>):为特定的目标链接头文件目录。

  • 添加依赖库:
    link_libraries(<items>):为该位置之后的target链接依赖库。
    target_link_libraries(<target> <items>):为特定的目标链接依赖库。
    这里,常见的依赖库可能是以下几种情况:

    1. 在此次编译的工程里添加的目标,给出目标名;
    2. 外部库,给出路径和库文件全名;
    3. 外部库,通过find_package()等命令搜索到的。

    对于find_package(XXX),该命令本身并不直接去进行搜索,而是通过特定路径下的FindXXX.cmake或XXXConfig.cmake文件来定位头文件和库文件的位置,分别被称为Module模式和Config模式。该命令会定义一个XXX_FOUND变量,如果成功找到,该变量为真,同时会定义XXX_INCLUDE_DIRXXX_LIBRARIES两个变量,用于link和include。

  • 添加子目录:
    add_subdirectories(<dir>):子目录中要有CMakeLists.txt文件,否则会报错。

  • 包含其他cmake文件:
    include(./path/to/tool.cmake)
    set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ./path/to),随后include(tool)
    该命令相当于将tool.cmake的内容直接包含进来。

  • 定义变量:
    set(<variable> <value>... [PARENT_SCOPE])
    set(<variable> <value>... CACHE <type> <docstring> [FORCE])
    其中CACHE会将变量定义在缓存文件CMakeCache.txt里,可以在下次编译的时候读取。

  • 作用域:
    add_subdirectories(<dir>)会创建一个子作用域,里面可以使用父作用域里定义的变量,但里面定义的变量在父作用域不可见,同样,在子作用域修改父作用域里的变量不会影响父作用域。function()同样会产生一个子作用域。若想让子作用域里的定义或者修改在父作用域可见,需要使用PARENT_SCOPE标记。
    相对地,macro()include()不会产生子作用域。

  • 选项:
    add_option(MY_OPTION <ON|OFF>):会定义一个选项。在使用cmake命令时,可以通过-D改变选项的值。比如cmake .. -DMY_OPTION=ON

  • 编译选项:
    add_compile_options(-std=c++11)
    如果想要指定具体的编译器的选项,可以使用make_cxx_flags()cmake_c_flags()

  • 与源文件的交互:
    configure_file(XXX.in XXX.XX)会读入一个文件,处理后输入到新的位置。一方面,会替换掉#XXX或者@XXX@定义的内容。另一方面,会将文件里的#cmakedefine VAR …替换为#define VAR …或者/* #undef VAR */

  • 字符串操作、循环、判断、文件/变量存在判断等
    这些命令同样有用,请参考网络资料。

当代CMake理念

参考1: https://kubasejdak.com/modern-cmake-is-like-inheritance
翻译自: https://pabloariasal.github.io/2018/02/19/its-time-to-do-cmake-right/

一些人士指出,CMake应该是基于Targets目标和Properties属性的,应有面向对象的思想。
目标指的当然就是library和executable。目标的属性则具有两种不同的作用域:INTERFACE(接口)和PRIVATE(私有)。私有属性适用于构建目标本身时内部使用,而接口属性则是由目标的使用者在外部使用的。也就是说,接口属性定义了使用要求,而私有属性则定义了目标本身的构建要求。
此外,属性也可以被定义为PUBLIC(公有),当且仅当其既是私有又是接口。
比如,假如一个工程里有如下文件:

libjsonutils
├── CMakeLists.txt
├── include
│   └── jsonutils
│       └── json_utils.h
├── src
│   ├── file_utils.h
│   └── json_utils.cpp
└── test
    ├── CMakeLists.txt
    └── src
        └── test_main.cpp

我们注意到,include/中有json_utils.h头文件,这是我们想对外暴露的公共文件;而src/中有额外的头文件file_utils.h,这个文件仅在构建中使用,不想对外暴露。这两个头文件都应该在构建的时候被包含(include) ;另一方面,jsontuils的使用者又仅仅需要知道公开的头文件,因此INTERFACE_INCLUDE_DIRS只需要包含include/,而没有src/
为此,可以在CMakeLists.txt使用如下代码(这里使用了CMake的generator expression特性):

add_library(JSONUtils src/json_utils.cpp)
target_include_directories(JSONUtils
    PUBLIC 
        $<INSTALL_INTERFACE:include>    
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

对于目标的依赖项,同样有INTERFACEPRIVATE的区分。
比如:

find_package(Boost 1.55 REQUIRED COMPONENTS regex)
find_package(RapidJSON 1.0 REQUIRED MODULE)

target_link_libraries(JSONUtils
    PUBLIC
        Boost::boost RapidJSON::RapidJSON
    PRIVATE
        Boost::regex
)

这种情况,rapidjson和Boost::boost都应当被定义成接口类型的依赖,并被传递到目标的使用者那边,因为用户所导入的头文件中调用了这两个库的工具。这意味着JSONUtils的用户不仅需要JSONUtils的接口属性,同时也需要其接口类型的依赖的接口属性(在我们的情况下,定义了boost和rapidjson的公共头文件),甚至接口类型的依赖的接口类型的依赖的接口属性,等等。
对于CMake而言,它会将Boost::boostRapidJSON::RapidJson的所有接口属性添加到JSONUtils的接口属性中。这意味着JSONUtils的用户会传递获取依赖链条上所有的接口属性。
另一方面Boost::regex则仅在我们目标的内部使用,并且可以作为私有依赖。这种情况下,Boost::regex的接口属性会被添加到JSONUtils的私有属性中,而不会传递到用户那里。

导入目标

当我们执行find_package(Boost 1.55 REQUIRED COMPONENTS regex)的时候,CMake实际执行了FindBoost.cmake脚本,并由此导入了目标Boost::boostBoost::regex,这是为什么我们能通过target_link_libraries()来依赖这些目标。
然而部分第三方库并不那么守规矩,比如RapidJSON的RapidJSONConfig.cmake:

get_filename_component(RAPIDJSON_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH)
set(RAPIDJSON_INCLUDE_DIRS "/usr/include")
message(STATUS "RapidJSON found. Headers: ${RAPIDJSON_INCLUDE_DIRS}")

它实际上并没有定义目标,只是定义了RAPIDJSON_INCLUDE_DIRS一个变量。
这种情况,我们可以自己编写FindRapidJSON.cmake文件:

# FindRapidJSON.cmake
#
# Finds the rapidjson library
#
# This will define the following variables
#
#    RapidJSON_FOUND
#    RapidJSON_INCLUDE_DIRS
#
# and the following imported targets
#
#     RapidJSON::RapidJSON
#
# Author: Pablo Arias - pabloariasal@gmail.com

find_package(PkgConfig)
pkg_check_modules(PC_RapidJSON QUIET RapidJSON)

find_path(RapidJSON_INCLUDE_DIR
    NAMES rapidjson.h
    PATHS ${PC_RapidJSON_INCLUDE_DIRS}
    PATH_SUFFIXES rapidjson
)

set(RapidJSON_VERSION ${PC_RapidJSON_VERSION})

mark_as_advanced(RapidJSON_FOUND RapidJSON_INCLUDE_DIR RapidJSON_VERSION)

include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(RapidJSON
    REQUIRED_VARS RapidJSON_INCLUDE_DIR
    VERSION_VAR RapidJSON_VERSION
)

if(RapidJSON_FOUND)
    set(RapidJSON_INCLUDE_DIRS ${RapidJSON_INCLUDE_DIR})
endif()

if(RapidJSON_FOUND AND NOT TARGET RapidJSON::RapidJSON)
    add_library(RapidJSON::RapidJSON INTERFACE IMPORTED)
    set_target_properties(RapidJSON::RapidJSON PROPERTIES
        INTERFACE_INCLUDE_DIRECTORIES "${RapidJSON_INCLUDE_DIR}"
    )
endif()

导出自己的库

如果想让自己的工程能够被别人通过简单的命令使用:

find_package(JSONUtils 1.0 REQUIRED)
target_link_libraries(example JSONUtils::JSONUtils)

我们需要做两件事:首先,需要导出目标JSONUtils::JSONUtils;随后,需要允许下游应用find_package(JSONUtils)的时候能够导入这个目标。
首先我们要将目标导出到一个能够导入目标的JSONUtilsTargets.cmake

include(GNUInstallDirs)
install(TARGETS JSONUtils
    EXPORT jsonutils-targets
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)

install(EXPORT jsonutils-targets
  FILE
    JSONUtilsTargets.cmake
  NAMESPACE
    JSONUtils::
  DESTINATION
    ${CMAKE_INSTALL_LIBDIR}/cmake/JSONUtils
)

这样,我们安装了一个JSONUtilsTargets.cmake文件,这里面包含了导入JSONUtils的命令,只需要在别的文件中使用这个文件就可以导入。
下一步,我们制作一个JSONUtilsConfig.cmake

get_filename_component(JSONUtils_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH)
include(CMakeFindDependencyMacro)

find_dependency(Boost 1.55 REQUIRED COMPONENTS regex)
find_dependency(RapidJSON 1.0 REQUIRED MODULE)

if(NOT TARGET JSONUtils::JSONUtils)
    include("${JSONUtils_CMAKE_DIR}/JSONUtilsTargets.cmake")
endif()

大型工程

在第一部分介绍的都是基本命令,对于大型工程来说,会用到一些不太常用的概念或者功能。

什么是Project?

对于大型工程来说,project的概念变得更为重要。通常来说,简单的工程只需要有一个project,而对于复杂的工程,有可能会出现project的嵌套。
Project通常指的是一个逻辑上相对独立、完整,能够独立编译的集合。通常来说,如果某一个CMakeLists.txt文件中出现了project()命令,那你应该能以该文件所在的目录为根目录进行一次完整的编译。
https://stackoverflow.com/questions/26878379/in-cmake-what-is-a-project)该命令也会如上文所说的,影响CMAKE_PROJECT_NAME等变量的值。

文件组织

文件组织方式就见仁见智了。不过通常来说,为了方便cmake的管理,建议以modules的形式扁平地组织,并且在每个module中设置有限的文件层次。比如说我们有一个moduleA,其下面有src、include和test三个目录,而在include目录下面,再根据具体的功能分为不同的目录,再下一级就只有头文件。
这样在添加头文件目录的时候,统一添加为*/moduleA/include,而在源文件或者其他头文件包含的时候,可以从include下一级目录开始:#include "abc/a.hpp"

模块下的CMakeLists.txt

在一个模块下,可以遵循以下规律编写CMakeLists.txt:

  1. 设置内部模块依赖
  2. 搜索内部依赖模块的头文件和库文件
  3. 设置项目内第三方模块依赖
  4. 搜索项目内第三方模块依赖库的头文件和库文件
  5. 设置和搜索本地的外部依赖库
  6. 添加编译目标
  7. 包含头文件目录、链接库文件
  8. 设置安装规则(比如一些配置文件)
  9. 设置单元测试

头文件暴露

有的时候,有些头文件只供内部使用,不想暴露在install后的头文件目录里。那就将其放在src路径下。

依赖顺序管理

CMake中链接库的顺序是a依赖b,那么b放在a的后面。
例如目标test依赖a库、b库, a库又依赖b库,那么顺序如下:
target_link_libraries(test a b)
另外,假如目标test依赖a库, a库又依赖b库,但test不直接依赖b库,那么test不用链接b库。
如果在一个工程中有多个target,那么可以用add_dependencies(<target> [<target-dependency>]...)命令,来定义依赖关系。这样CMake会首先编译被依赖的目标,随后再编译依赖的目标。

INTERFACE|PUBLIC|PRIVATE

INTERFACE|PUBLIC|PRIVATE

如何调试

nm -a <target>命令查看符号表。
如果出现

Undefined symbols for architecture x86_64:
  "_main"

可能是在没有main的cpp文件定义add_executable。
构造函数和析构函数声明了就要定义,要么用default。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容