前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >小白入门笔记:CMake编译过程详解

小白入门笔记:CMake编译过程详解

作者头像
3D视觉工坊
发布2023-04-30 11:10:37
4.3K0
发布2023-04-30 11:10:37
举报

1、你好,CMake

1.1 CMake是什么?

我觉得针对这个问题最简单(但不是最正确的)的回答应该是:“CMake是服务于将源代111码转换成可执行的文件的工具”。

将源码转换为可工作应用会比较神奇。不仅是效果本身(即设计并赋予生命的工作机制),而且是将理念付诸于过程的行为本身。

CMake本身是一个工具集,由五个可执行的程序组成:cmake、ctest、cpack、cmake-gui和ccmake,其中cmake可以说是出镜率最高的明星级别程序了,它用于在构建项目的第一步,进行项目的配置、生成和构建项目的主要可执行文件的工作。其他的程序们ctest用于运行和报告测试结果的测试驱动程序,cpack用来生成安装程序和源包的打包程序,cmake-gui是 cmake 的图形界面,ccmake(注意ccmake比cmake多了一个c)也是用于图形界面,与cmake-gui不同的地方在于ccmake是基于控制台(terminal)的图形界面。

1.2 CMake设计初衷是为了什么?

CMake设计的出发点在于面向开发者,而开发者的工作流程基本是: 设计、编码和测试;理想情况下,这个过程应该从一个简单地按钮开始。在更改了代码之后,工具将以智能的、快速的、可扩展的方式,在不同的操作系统和环境中以相同的方式工作。支持多个集成开发环境(IDE) 和持续集成(CI) 流水,这些流水在更改提交到代码库后,就能对代码进行测试。为了寻求针对上述许多此类需求的一种答案,CMake便由此孕育而生。即是Cmake是抱着如此“崇高”的初衷隆重登场,但是很多接触CMake的初学者、技术人员和Cpp开发人员对其评价很不好甚至有些嗤之以鼻,确实,毋庸置疑的一点是使用CMake语言和工具要正确配置和使用源码需要一(ju)定(da)的工作量,但造成这些“操蛋”的爆肝工作量背后,并不是因为CMake 复杂,而是因为“自动化”的需求很复杂。抛开一切,单单就论“做出一个真正模块化的、功能强大的C++ 构建应用”以满足各种需求的难度,这个应该是很难吧?但是CMake 确实做到了(doge)。

两个概念需要区分:<build tree> 和<source tree>,分别对应构建树和源码树;构建树是目标/输出目录的路径,源码树是源码所在的路径。

构建软件是一个通用的过程:编译可执行程序和库、管理依赖关系、测试、安装、打包、生成文档和测试更多功能,当然了上述其中有一些步骤是可以跳过的,但至少我们需要使用CMake完成编译可执行程序。目前,CMake 的开发很活跃,并已成为C 和C++ 开发人员的行业标准。以自动化的方式构建代码的问题比CMake 出现的要早得多,所以会有很多选择:Make、Autotools、SCons、Ninja、Premake 等。但为什么CMake 可以后来居上呢?关于CMake,Rafa? ?widziński持有以下几个重要观点:

? 专注于支持现代编译器和工具链。

? CMake 是真正的跨平台——支持Windows、Linux、macOS 和Cygwin 的构建。

? 为主流IDE 生成项目文件:Microsoft Visual Studio, Xcode 和Eclipse CDT。此外,也是其他项目的模型,如CLion。

? CMake 操作在合适的抽象级别上——允许将文件分组到可重用的目标和项目中。

? 有很多用CMake 构建的项目,其提供了一种简单的方法将它们包含到自己的项目中。

? CMake 将测试、打包和安装视为构建过程的固有组成。

? 弃用旧的、未使用的特性,从而保持CMake 的精简。

CMake 提供了统一的、流线型的体验。不管是在IDE 中构建,还是直接从命令行构建,还照顾到构建后阶段。即使前面所有的环境都不同,持续集成/持续部署(CI/CD) 流水也可以轻松地使用相同的CMake 配置,并使用单一标准构建项目。

1.3 CMake在“暗地里”是怎么工作的?

表面上可以感受到的工作流程:“CMake 是在一端读取源代码,在另一端生成二进制文件的工具”。但正如上文所说CMake是一个工具集,那就说明了CMake 自己并没有构建任何东西的能力,CMake它依赖于系统中的其他工具来执行实际的编译、链接和其他任务。CMake好似一个在构建过程中工作的“协调器”,它清楚哪些步骤需要完成,理解最终目标是什么,以及忙碌于为构建工作找到合适的“工人”和“材料”。综上,这个过程有三个阶段:配置、生成、构建阶段,可见图2中的表示。

图2:CMake在配置、生成和构建阶段的示意图

2、基本的CMake语法

2.1 变量

普通变量、缓存变量、环境变量

普通变量、缓存变量和环境变量这三类变量组成了CMake变量这一个“复杂”的主题,让人头疼的一点在于上述三个变量在不同的作用域中的“被使用和修改”,而且CMake作用域之间变量如何影响的“特定规则”也会经常在CMake变量的使用过程中体现。

基本的变量操作指令是set()\unset(),变量名区分大小写并可以包含字符(使用括号和引号参数允许在变量名中包含空格。但当以后引用时,必须使用反斜杠来转义空格(\),因此,建议在变量名中只使用字母数字字符、减号(-) 和下划线(_))。具体的使用方式为在设置变量时只需使用set()并提供名称和值,要取消变量的设置时可以使用unset()并提供名称。

代码语言:javascript
复制
set(MyString1 "Text1")
set([[My String2]] "Text2")
set("My String 3" "Text3")


message(${MyString1})
message(${My\ String2})
message(${My\ String\ 3})


unset(MyString1)

由上面示例可以看到,对已定义变量的引用需要使用${} 语法,e.g. message(${MyString1}),其中message是用以构建过程中的打印,通过${}告诉CMake遍历作用域堆栈,尝试将${MyString1}替换为具体的值供message命令打印出来。值得注意的是在查询${MyString1}过程中,CMake若是没有找到对应的变量则会将其替换为空字符串并不会产生错误。另外,在通过${} 语法进行变量的求值和展开时,是由内而外执行的。

考虑包含以下变量的例子:

? MyInner 的值是Hello

? MyOuter 的值是${My

若使用message(”${MyOuter}Inner} World”),输出将是Hello World,这是因为${My 替换了${MyOuter},当与顶层值Inner} 结合时,会创建另一个变量引用${MyInner}。

当涉及到变量类别时,变量引用的工作方式有点奇怪。以下是通常情况适用的方式:

? ${} 用于引用普通变量或缓存变量。

? $ENV{} 用于引用环境变量。

? $CACHE{} 用于引用缓存变量。

2.1.1 环境变量

首先说明如何修改或创建一个环境变量,使用set(ENV{<variable>} <value>) 指令用以声明,使用unset(ENV{<variable>})来清除某一个环境变量,其中ENV表示环境变量标志性前缀,variable指变量名称,value则为变量值,需要注意的是设定或读取环境变量时,都通过ENV前缀来访问环境变量,读取环境变量值时,要在ENV前加$符号;但if判断是否定义时,不用加$符号。具体示例如下:

代码语言:javascript
复制
//示例1:
set(ENV{CXX} "clang++")
unset(ENV{VERBOSE})


//示例2:
set(ENV{CMAKE_PATH} "myown/path/example")
# 判断CMAKE_PATH环境变量是否定义
if(DEFINED ENV{CMAKE_PATH}) //注意此处ENV前没有$符号
message("CMAKE_PATH_1: $ENV{CMAKE_PATH}") //注意此处ENV前有$符号
else()
message("NOT DEFINED CMAKE_PATH VARIABLES")
endif()

设定环境变量后,其作用域只影响当前CMake进程,也就是说环境变量设定后是整个CMake进程的作用域都可用,但是不会影响CMake进程外的整个系统环境。

另一个需要注意的点在于,环境变量在启动CMake进程后会基于CMake在配置阶段中收集的信息在CMake生成阶段生成环境变量的副本,该副本会在单一的、全局的作用域中可用。即,若使用ENV 变量作为指令的参数,这些值将在CMake生成构建系统期间(配置阶段+生成阶段)插入,并且会将其嵌入到构建树中,在构建系统完成后即使再通过脚本或者命令行修改环境变量ENV{<variable>}的value,在构建阶段时该环境变量值也不会更新成新的value(因为在构建系统中保存的是之前环境变量的副本),具体实例如下:

代码语言:javascript
复制
//示例3:
//CMakeLists.txt:
cmake_minimum_required(VERSION 3.20.0)
project(Environment)
//在配置期间打印myenv环境变量
message("generated with " $ENV{myenv})
//在构建阶段过程中打印相同的变量
add_custom_target(EchoEnv ALL COMMAND echo "myenv in build
is" $ENV{myenv})

在上述示例3的CMakeLists.txt中是有两个展示阶段:第一将在配置期间打印myenv环境变量并通过add_custom_target() 添加一个构建阶段,第二将在构建阶段过程中打印相同的变量。构建上述CMakeLists.txt通过一个bash脚本文件执行,见下:

代码语言:javascript
复制
//示例4:
//bash脚本:
//先定义myenv环境变量,并打印
export myenv=first
echo myenv is now $myenv
// 基于CMakeList.txt生成一个构建系统
cmake -B build .
cd build
//基修改myenv环境变量,并打印
export myenv=second
echo myenv is now $myenv
//开始构建
cmake --build .

运行上面的代码,可以清楚地看到在配置过程中,设置的值会保留在生成的构建系统中:

代码语言:javascript
复制
1.  $ ./build.sh | grep -v "\-\-"
2.  myenv is now first
3.  generated with first
4.  myenv is now second
5.  Scanning dependencies of target EchoEnv
6.  myenv in build is first
7.  Built target EchoEnv

2.1.2 缓存变量

缓存变量可以通过$CACHE{<name>} 语法来引用,而设置一个缓存变量使用set(<variable> <value> CACHE <type> <docstring> [FORCE])指令,与用于普通变量的set() 指令相比,缓存变量的设定中有一些必需参数和关键字(CACHE &FORCE)。与环境变量不同的是,缓存变量是CMake进程在配置阶段收集相关信息后存储在在构建树中的CMakeCache.txt 文件中的变量,缓存变量不可像环境变量中在脚本使用但是可以通过cmake-gui或者ccmake来声明。

Cache Variable缓存变量相当于一个全局变量,在同一个CMake工程中任何地方都可以使用,比如父目录,子目录等,而如上文中缓存变量的指令格式是set(<variable> <value>... CACHE <type> <docstring> [FORCE])

# variable:变量名称

# value:变量值列表

# CACHE:cache变量的标志

# type:变量类型,取决于变量的值。类型为:BOOL、FILEPATH、PATH、STRING、INTERNAL

# docstring:必须是字符串,作为变量概要说明

# FORCE:强制选项,强制修改变量值

其中FORCE选项,在定义缓存变量时不加也能定义成功,但是修改时不加FORCE选项则修改无效,所以不论定义或修改缓存变量时,建议都加上FORCE选项,具体实例如下:

代码语言:javascript
复制
//设置一个string类型的缓存变量,名称为FOO,变量值为BAR
set(FOO "BAR" CACHE STRING "interesting value" FORCE)
//设置一个string类型的缓存变量,名称为CMAKE_BUILD_TYPE,变量值为Release
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)

2.1.3 变量作用域

? 函数作用域: 用于执行用function() 定义的自定义函数 ? 目录作用域: 当从add_subdirectory() 指令执行嵌套目录中的CMakeLists.txt 文件

如果对C/C++比较熟悉的话,CMake中变量的作用域就可以类比C/C++中的一些定义,举例来说,(1)、CMake中变量的函数作用域可类比成C/C++中函数的参数值传递(不是引用、也不是指针),也就是说在一般情况下CMake函数中对变量的修改不会影响到函数外的CMake变量value值,而CMake函数中的变量就是从parent scope中“查询”到并生成的副本;(2)、CMake中变量的目录作用域,也是类似于C/C++中的include文件依赖,也就是在子目录下的对变量的修改不会影响父目录中变量定义的value值;(3)、话已至此,不妨再类比一下CMake中的普通变量、缓存变量和环境变量,CMake普通变量就好比C/C++中的普通变量定义。都是作用在定义变量时所在的作用域(scope)之下;(4)、CMake缓存变量有些像C/C++中的指针,只是CMake中引用缓存变量的value值时不用像C/C++那样加一个“取地址符”,而且这个缓存变量(“指针”)是不对外部可见的(不能通过命令行修改和引用CMake缓存变量),如果想要CMake中修改后的缓存变量value值生效就必须加上FORCE关键字;(5)、CMake的环境变量就好比C/C++中的宏定义了,不仅对外部可见,同时CMake中还支持对环境变量(“宏”)的修改。

回归本质,CMake变量作用域作为一个通用概念是为了分离不同的抽象层,以便在调用用户定义的函数时,该函数中设置的变量是局部的,这些局部变量不会影响全局作用域,即使局部变量的名称与全局变量的名称完全相同。若显式需要,函数也应该具有对全局变量的读/写访问权。这种变量(或作用域) 分离必须在多个层面上工作——当一个函数调用另一个函数时,分离规则同样适用。针对变量的作用域,理解“副本“的概念是关键,当创建嵌套(子)作用域时,CMake只需用来自当前(父)作用域的所有变量的副本填充,后续嵌套(子)作用域命令将影响这些副本。但若完成了嵌套(子)作用域的执行,所有的副本都会删除,而原始的父作用域将恢复,嵌套作用域中操作的变量将不会更新到父作用域中。

接下来再根据CMake Documentation中的定义,感受一下CMake变量作用域的具体说明。第一,针对函数作用域(Function Scope):

A variable “set” or “unset” binds in this scope and is visible for the current function and any nested calls within it, but not after the function returns.---from cmake language

举个例子,当在函数内通过set()或unset()将变量”v”与当前函数作用域绑定时,变量”v”的新值仅在函数作用域内有效,出了这个作用域,如果这个作用域外也有同名的变量”v”,那么使用将是域外同名变量”v”的值。第二,针对目录作用域(Directory Scope):

Each of the Directories in a source tree has its own variable bindings. Before processing the CMakeLists.txt file for a directory, CMake copies all variable bindings currently defined in the parent directory, if any, to initialize the new directory scope. .---from cmake language

目录作用域的启用一般是在父目录下的CmakeList.txt中有add_subdirectory(“子目录路径”)指令,而在子目录的CMakeLists.txt会将父目录的所有变量拷贝到当前CMakeLists.txt中,当前CMakeLists.txt中的变量的作用域仅在当前子目录有效。

综上,不管是针对CMake函数作用域还是CMake目录作用域,其都有两个特点:向下有效和数值拷贝生成副本,在不使用特殊关键字的情况下,嵌套(子)作用域针对普通变量的修改不会影响到父作用域。针对变量,普通变量仅仅有效于当前作用域,而缓存变量和环境变量可以在全局作用域中使用。

2.2 控制结构

三类控制结构:条件块、循环、定义指令 没有控制结构,CMake 语言就不完整

CMake中的控制结构就是提供一个结构,让用户可以针对具体的情况来设置触发条件<condition> 表达式来控制需要执行的命令语言。在所有的控制结构中一般都是需要提供条件判断<condition> 表达式的,在if()、elseif()和while()的条件判断表达式的语法都是相同的。这些<condition> 表达式都是根据非常简单的语法求值,如逻辑运算、字符串和变量的求值、比较、CMake检查等,本文中不对上述的语法做详细的展开,但提醒一下条件<condition> 表达式中的“字符串和变量的求值”的语法中,需要注意求值时加引用符

2.2.1 条件块

CMake中的条件块是一个必须以if()开头并以endif()结尾的块状结构,在开头的if()和结尾的endif()之间可以添加任意数量的elseif(),但只能有单独一个的、可选的else(),其CMake条件块的结构命令如下:

代码语言:javascript
复制
if(<condition>)
<commands>
elseif(<condition>) # optional block, can be repeated
<commands>
else() # optional block
<commands>
endif()

在具体的条件判断流程中,若满足if() 指令中指定的<condition> 表达式,则执行第一部分的<commands>,如上例第2行部分,否则,CMake 将在属于该块中满足条件的第一个elseif() 指令节中执行命令。若没有这样的命令,CMake将检查是否提供了else(),并执行该部分代码中的指令,如上例中第6行的部分。若以上条件都不满足,则不会触发条件块中任何的指令,并在endif() 之后继续执行。

2.2.2 循环

CMake中的循环控制块是一个必须以while()开头创建并以endwhile()结尾的块状结构,只要while() 中提供的<condition> 表达式为true,其后续的指令都会执行,其CMake循环块的结构命令如下:

代码语言:javascript
复制
while(<condition>)
<commands>
endwhile()

除了while()循环结构外,还有一个相对更加常用和简介的循环结构块:foreach()。上文中while()循环块是需要具体的、额外的<condition> 表达式来控制需要执行的命令语言,而foreach()循环则是类似C/C++的for循环风格来控制的,只是foreach块的打开和关闭指令分别是foreach() 和endforeach(),其定义如下所示:

代码语言:javascript
复制
foreach(<loop_var> RANGE <min> <max> [<step>])
<commands>
endforeach()

上述中的<min>和<step>参数变量可选择配置,默认的话从0开始,min和max都必须是非负整数,在RANGE中max和min都是包括在循环内部的。如果设置了min的value值,则必须小于max的value值。

上文中提到foreach()是相对while()而言,在CMake中更加常用和简介的循环结构块,这个是因为foreach()在处理列表变量时十分便捷:

代码语言:javascript
复制
foreach(<loop_variable> IN [LISTS <lists>] [ITEMS <items>])

CMake 将从所有提供的<lists> 列表变量中获取元素,也就是输入循环中的list可以是多个,然后再是从所有显式声明的<items>中获取元素值,并将它们都存储在<loop_variable> 中,对每个项逐个执行<commands>。可以选择只提供列表,只提供值或者两者都提供,见下例:

代码语言:javascript
复制
set(MY_LIST 1 2 3)
foreach(VAR IN LISTS MY_LIST ITEMS e f)
message(${VAR})
endforeach()

上述示例中是声明了MY_LIST的列表变量为【1,2,3】,在foreach循环中会获取MY_LIST中的所有元素和<items>中的e、f值,存储在VAR中,在每一次循环中命令指令就是打印VAR的数值,上述代码的打印结果见下:

还是以上述foreach使用为例,foreach中还可以优化成一个更简化的指令行并获取相同的结果:

代码语言:javascript
复制
foreach(VAR 1 2 3 e f)

除此以外,从3.17 版本开始,foreach() 已经学会了如何压缩列表(ZIP_LISTS),

代码语言:javascript
复制
foreach(<loop_var>... IN ZIP_LISTS <lists>)

在压缩列表中CMake 将为每个提供的列表创建一个num_<N> 变量,用每个列表中的项填充该变量。同时,除了使用CMake自动创建的num_<N> 变量,用户也可以自定义传递多个<loop_var> 变量名(每个列表一个),每个列表将使用单独的变量来存储,详细见下:

代码语言:javascript
复制
//声明两个具有相同数量元素的list
set(L1 "one?two?three?four")
set(L2 "1?2?3?4?5")
//通过ZIP_LISTS关键子来“压缩“上面的两个列表,以在单次循环中处理相同索引的列表元素
// 示例1:通过num_<N> 变量存储获取元素
foreach(num IN ZIP_LISTS L1 L2)
message("num_0=${num_0}, num_1=${num_1}")
endforeach()


// 示例2:通过自定义传递两个<loop_var>变量,存储获取元素
foreach(word num IN ZIP_LISTS L1 L2)
message("word=${word}, num=${num}")
endforeach()

上面针对多个列表的压缩处理,前提条件是这些待处理的多个列表中的元素个数是相同的,若列表之间的项数不同,CMake 将不会为较短的列表定义变量。

2.2.3 定义指令

除了CMake官方提供和定义的一些指令以外,CMake还提供了用户进行自定义指令的方法:定义指令,CMake中的定义指令通过两种方法实现:macro()和function(),在这里还是可以将CMake中的定义指令macro()和function()的实现与C风格的宏定义和C++的函数定义比较:

? macro() 的工作方式像是查找和替换指令,而不是像function() 这样的实际子例程调用。与函数相反,宏不会在调用堆栈上创建单独的条目。所以宏中调用return() 将比在函数中返回调用语句的级别高一级(若已经在顶层作用域中,可能会终止执行)。

? function() 为本地变量创建一个单独的作用域,这与macro() 命令不同,后者在调用者的变量作用域中工作,所以使用CMake的function需要注意变量的作用域问题。

CMake中macro()和function()具体使用方法还是配合下面的示例进行说明。

||

代码语言:javascript
复制
//CMake中的宏
macro(<name> [<argument>?])
<commands>
endmacro()

完成CMake宏的声明之后就可以通过调用宏的名称<name>来执行宏(函数调用不区分大小写),下例将重点强调宏中变量作用域相关的问题:

代码语言:javascript
复制
//定义了一个名为MyMacro的宏,参数为myVar
macro(MyMacro myVar)
set(myVar "new value")
message("argument: ${myVar}")
endmacro()


set(myVar "first value")
message("myVar is now: ${myVar}")
//调用宏
MyMacro("called value")
message("myVar is now: ${myVar}")

若是运行上面的CMake配置,则可以得到如下的输出:

代码语言:javascript
复制
myVar is now: first value
argument: called value
myVar is now: new value

上例中尽管调用MyMacro尝试显式地将myVar 设置为“new value”,但后续message打印的${myVar}并不是“new value”,而是在第10行中传递给宏的参数${"called value"},也就是宏中对全局作用域中的myVar 变量的修改,并不影响宏中message(”argument:${myVar}”),这是因为传递给宏的参数没有视为真正的变量,而是作为常量查找并替换指令。所以宏MyMacro中对全局作用域中的myVar 变量的修改行为,是一种副作用!上述的例子是CMake不提倡的一种实践方式,因为一旦变量作用域和宏作为“查找和替换指令”的行为未被正确使用,就会产生难以描述的负面影响。

具体宏与函数的差异,可以往下阅读以完成概率和使用的对比理解。

||函数:

代码语言:javascript
复制
//CMake中的函数声明
function(<name> [<argument>?])
<commands>
endfunction()

还是使用一个经典的CMake函数的使用示例来进行详细说明:

代码语言:javascript
复制
//定义了一个名为MyFunction的函数,参数为FirstArg
function(MyFunction FirstArg)
message("Function: ${CMAKE_CURRENT_FUNCTION}")
message("File: ${CMAKE_CURRENT_FUNCTION_LIST_FILE}")
message("FirstArg: ${FirstArg}")
set(FirstArg "new value")
message("FirstArg again: ${FirstArg}")
message("ARGV0: ${ARGV0} ARGV1: ${ARGV1} ARGC: ${ARGC}")
endfunction()


set(FirstArg "first value")
//调用函数,并传参个数比函数声明时的多了一个
MyFunction("Value1" "Value2")
message("FirstArg in global scope: ${FirstArg}"))

示例中的CMAKE_CURRENT_FUNCTION、CMAKE_CURRENT_FUNCTION_LIST_DIR、CMAKE_CURRENT_FUNCTION_LIST_FILE和CMAKE_CURRENT_FUNCTION_LIST_LINE是CMake从3.17版本后为每个函数设置的官方变量,而同时CMake官方也定义了一些引用来访问命令调用中传递的参数, ${ARGC}输出参数的数量、${ARGV}输出所有参数的列表、${ARG0}, ${ARG1}, ${ARG2}输出特定索引处的实参值、${ARGN}输出最后一个预期参数之后由调用者传递的匿名参数列表。若是运行上面的CMake配置,则可以得到如下的输出:

代码语言:javascript
复制
Function: MyFunction
File: /root/examples/chapter02/08-definitions/function.cmake
FirstArg: Value1
FirstArg again: new value
ARGV0: Value1 ARGV1: Value2 ARGC: 2
FirstArg in global scope: first value

由上例我们可以得到两个重要的事实:第一,函数中对全局变量的修改只停留在函数作用域中,在函数结束后不会影响到父作用域中的变量value值。第二,传递给函数的实参值被真正使用在了函数的作用域内,在第13行调用函数MyFunction并传入Value1(Value2是“多余”的匿名实参值),而后在函数内打印message("FirstArg: ${FirstArg}")输出的是“Value1”,随后set(FirstArg "new value")再打印输出的是修改后的“new value”,结束函数后回到全局作用域打印变量输出的是第11行第一次声明的“first value”,如果是宏则会在最终输出“new value”了。

综上,CMake中的宏macro()和函数function()都是提供给用户以自定义指令的方法,只不过,CMake函数function()开放了自己的作用域(function scope),并可以在其作用域内安全的调用set()指令以提供函数的一个命名参数,任何更改都将是函数的局部更改(除非指定了PARENT_SCOPE),不影响PARENT SCOPE。

2.3 实用指令

2.3.1 message() 指令

CMake中打印指令,也就是message() 指令是用于将文本打印到标准输出,并且CMake通过提供MODE 参数,可以自定义输出的样式,并且在出现错误的情况下,可以停止代码:message(<mode> ”text”) 的执行,默认的MODE是“STATUS”,其他的可选MODE模式如下:

? FATAL_ERROR: 将停止处理和生成。

举个简单例子,使用FATAL_ERROR的模式,在CMake中只打印第一条消息,然后就停止执行:

代码语言:javascript
复制
message(FATAL_ERROR "First Message Printed")
message("Won't print this.")

? SEND_ERROR: 将继续处理,但跳过生成。

? WARNING: 继续处理。

? AUTHOR_WARNING: CMake 警告。继续处理。

? DEPRECATION: 若启用了CMAKE_ERROR_DEPRECATED 或

CMAKE_WARN_DEPRECATED 变量,将做出相应处理。

? NOTICE 或省略模式(默认): 将向stderr 输出一条消息,以吸引用户的注意。

? STATUS: 将继续处理,建议用于用户的主要消息。

? VERBOSE: 将继续处理,用于通常不是很有必要的更详细的信息。

? DEBUG: 将继续处理,并包含在项目出现问题时可能有用的详细信息。

? TRACE: 将继续处理,并建议在项目开发期间打印消息。通常,在发布项目之前,将这些类型的消息删除。

2.3.2 include() 指令

Modern CMake中重要的一个模块就是引用官方和CMake社区中已经配置好了的CMake模板,所谓的CMake模板就是将CMake代码划分到单独的.cmake文件中,以保持内容的有序和独立性。然后通过include()指令,从父列表文件引用:

代码语言:javascript
复制
include(<file|module> [OPTIONAL] [RESULT_VARIABLE <var>])

若提供文件名(一个扩展名为.cmake),CMake 将尝试打开并执行它。这里不会创建嵌套的、单独的作用域,因此对该文件中变量的修改会影响调用作用域。若文件不存在,CMake 将抛出一个错误,除非用optional 关键字指定为可选。若需要知道include() 指令操作是否成功,可以提供一个带有变量名的RESULT_VARIABLE 关键字,若include()引用成功,则用包含的文件的完整路径填充,失败则用未找到(NOTFOUND) 填充。

脚本模式下运行时,将从当前工作目录解析相对路径。要强制搜索与脚本本身相关的内容,请提供绝对路径:

代码语言:javascript
复制
include("${CMAKE_CURRENT_LIST_DIR}/<filename>.cmake")

若不提供路径,但提供了模块的名称(没有.cmake 或其他),CMake 将尝试找到一个模块并包含它。然后,CMake 将在CMake 模块目录CMAKE_MODULE_PATH 中,搜索名称为<module>.cmake的文件。

2.3.3 file() 指令

为了可以知道CMake 脚本可以做什么,CMake提供了一个可以快速浏览文件的操作命令:

代码语言:javascript
复制
file(READ <filename> <out-var> [...])
file({WRITE | APPEND} <filename> <content>...)
file(DOWNLOAD <url> [<file>] [...])

简而言之,file() 指令会以一种与系统无关的方式读取、写入和传输文件,并使用文件系统、文件锁、路径和存档。详情请参阅附录部分。

2.3.4 execute_process() 指令

除了CMake官方和自定义的指令外,有时需要使用系统中可用的工具(毕竟,CMake 主要是一个构建系统生成器),CMake 为此提供了execute_process()指令以用来运行其他进程,并收集它们的输出。这个命令非常适合脚本,也可以在配置阶段的项目中使用。下面是命令的一般形式:

代码语言:javascript
复制
execute_process(COMMAND <cmd1> [<arguments>]? [OPTIONS])

CMake 将使用操作系统的API 来创建子进程(因此,诸如&&、|| 和> 等shell 操作符将不起作用)。可以通过不止一次地提供COMMAND <cmd> <arguments> 参数来连接命令,并将一个命令的输出传递给另一个命令。

若进程没有在要求的限制内完成任务,可以选择使用TIMEOUT <seconds> 参数来终止进程,并且可以根据需要设置WORKING_DIRECTORY <directory>。通过RESULTS_VARIABLE <variable> 参数,可以在列表中收集所有任务的退出代码。若只对最后执行命令的结果感兴趣,请使用单数形式:RESULT_VARIABLE <variable>。

为了收集输出,CMake 提供了两个参数:OUTPUT_VARIABLE 和ERROR_VARIABLE(以类似的方式使用)。若想合并stdout 和stderr,请对两个参数使用相同的变量。

简单的CMake构建

本章中参考的源代码可以从GitHub中获取,网址为https://github.com/dev-cafe/cmake-cookbook。开源代码遵循MIT许可:只要原始版权和许可声明包含在软件/源代码的任何副本中,可以以任何方式重用和重新混合代码。许可的全文可以在https://opensource.org/licenses/MIT 中看到。

3.1 简单的可执行文件生成

本节示例中将演示如何运行CMake配置和构建一个简单的项目,该项目由单个源文件组成,用于生成可执行文件。我们将用C++讨论这个项目。

将以下源代码编译为单个可执行文件:

代码语言:javascript
复制
#include <cstdlib>
#include <iostream>
#include <string>


std::string say_hello() { return std::string("Hello, CMake world!"); }


int main() {
std::cout << say_hello() << std::endl;
return EXIT_SUCCESS;
}

对应的CMakeLists.txt配置及注释如下:

完成对CMakeLists.txt配置后,可以通过创建build目录,在build目录下来配置项目:

代码语言:javascript
复制
mkdir -p build
cd build
cmake ..


-- The CXX compiler identification is GNU 8.1.0
-- Check for working CXX compiler: /usr/bin/c++
-- Check for working CXX compiler: /usr/bin/c++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /home/user/cmake-cookbook/chapter-01/recipe-01/cxx-example/build


cmake --build .
Scanning dependencies of target hello-world
[ 50%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

3.2 简单构建和链接库文件:静态库和动态库

项目中会有单个源文件构建的多个可执行文件的可能,而且项目中的多个源文件,通常分布在不同子目录中,本小节的实践有助于项目的源代码结构的模块化、代码重用和关注点分离(这些都是Modern CMake中设计时的重要内容)。同时,这种分离可以简化并加速项目的重新编译。本示例中,我们将展示如何将源代码编译到库中,以及如何链接这些库。

回看第一个例子,这里并不再为可执行文件提供单个源文件,我们现在将引入一个类,用来包装要打印到屏幕上的消息。更新一下的hello-world.cpp:

代码语言:javascript
复制
#include "Message.hpp"
#include <cstdlib>
#include <iostream>
int main() {
Message say_hello("Hello, CMake World!");
std::cout << say_hello << std::endl;
Message say_goodbye("Goodbye, CMake World");
std::cout << say_goodbye << std::endl;
return EXIT_SUCCESS;
}

Message类包装了一个字符串,并提供重载过的<<操作,并且包括两个源码文件:Message.hpp头文件与Message.cpp源文件。Message.hpp中的接口包含以下内容:

代码语言:javascript
复制
#pragma once
#include <iosfwd>
#include <string>
class Message {
public:
Message(const std::string &m) : message_(m) {}
friend std::ostream &operator<<(std::ostream &os, Message &obj) {
return obj.printObject(os);
}
private:
std::string message_;
std::ostream &printObject(std::ostream &os);
};

Message.cpp实现如下:

代码语言:javascript
复制
#include "Message.hpp"
#include <iostream>
#include <string>
std::ostream &Message::printObject(std::ostream &os) {
os << "This is my very nice message: " << std::endl;
os << message_;
return os;
}

这里有两个文件需要编译,所以CMakeLists.txt必须进行修改。本例中,先把它们编译成一个库,而不是直接编译成可执行文件,具体实施和注释如下所示:

本小节中引入了两个新命令:add_library 和 target_link_libraries:

  • add_library(message STATIC Message.hpp Message.cpp):生成必要的构建指令,将指定的源码编译到库中。add_library的第一个参数是目标名。整个CMakeLists.txt中,可使用相同的名称来引用库。生成的库的实际名称将由CMake通过在前面添加前缀lib和适当的扩展名作为后缀来形成。生成库是根据第二个参数(STATIC或SHARED)和操作系统确定的。
  • target_link_libraries(hello-world message): 将库链接到可执行文件。此命令还确保hello-world可执行文件可以正确地依赖于消息库。因此,在消息库链接到hello-world可执行文件之前,需要完成消息库的构建。

编译成功后,构建目录包含libmessage.a一个静态库(在GNU/Linux上)和hello-world可执行文件。此外,CMake还接受其他值作为add_library的第二个参数的有效值:

? STATIC:用于创建静态库,即编译文件的打包存档,以便在链接其他目标时使用,例如:可执行文件。

? SHARED:用于创建动态库,即可以动态链接,并在运行时加载的库。可以在CMakeLists.txt中使用add_library(message SHARED Message.hpp Message.cpp)从静态库切换到动态共享对象(DSO)。

? OBJECT:可将给定add_library的列表中的源码编译到目标文件,不将它们归档到静态库中,也不能将它们链接到共享对象中。如果需要一次性创建静态库和动态库,那么使用对象库尤其有用。

? MODULE:又为DSO组。与SHARED库不同,它们不链接到项目中的任何目标,不过可以进行动态加载。该参数可以用于构建运行时插件。

使用条件块控制编译

目前为止,看到的示例比较简单,CMake执行流是线性的:从一组源文件到单个可执行文件,也可以生成静态库或动态库。为了确保完全控制构建项目、配置、编译和链接所涉及的所有步骤的执行流,CMake提供了自己的语言。本节中,将探索条件结构if-else- else-endif的使用,修改后的CMakeLists.txt和相关注释如下所示:

3.4 向用户显示选项

前面3.3的配置中引入了条件句:通过硬编码的方式给定逻辑变量值。不过,这会影响用户修改这些变量。CMake代码没有向读者传达,该值可以从外部进行修改。推荐在CMakeLists.txt中使用option()命令,以选项的形式显示逻辑开关,用于外部设置,从而切换构建系统的生成行为。本节的示例将展示如何使用这个命令:

完成了上述的CmakeLIst.txt文件修改后,可以通过CMake的-D 的CLI选项将信息传递给CMake来切换库的行为:

代码语言:javascript
复制
$ mkdir -p build
$ cd build
$ cmake -D USE_LIBRARY=ON ..
-- ...
-- Compile sources into a library? ON
-- ...
$ cmake --build .
Scanning dependencies of target message
[ 25%] Building CXX object CMakeFiles/message.dir/Message.cpp.o
[ 50%] Linking CXX static library libmessage.a
[ 50%] Built target message
Scanning dependencies of target hello-world
[ 75%] Building CXX object CMakeFiles/hello-world.dir/hello-world.cpp.o
[100%] Linking CXX executable hello-world
[100%] Built target hello-world

3.5 指定编译器

CMake可以根据平台和生成器选择编译器,还能将编译器标志设置为默认值。然而通常应该控制编译器的选择。本小节将考虑构建类型的选择,并展示如何控制编译器标志:

3.6、构建类型切换

CMake可以配置构建类型,例如:Debug、Release等。配置时,可以为Debug或Release构建设置相关的选项或属性,例如:编译器和链接器标志。控制生成构建系统使用的配置变量是CMAKE_BUILD_TYPE。该变量默认为空,CMake识别的值为:

? Debug:用于在没有优化的情况下,使用带有调试符号构建库或可执行文件。

? Release:用于构建的优化的库或可执行文件,不包含调试符号。

? RelWithDebInfo:用于构建较少的优化库或可执行文件,包含调试符号。

? MinSizeRel:用于不增加目标代码大小的优化方式,来构建库或可执行文件。

具体的CMakeLists.txt配置及注释如下:

随后验证CMake的输出,如下:

代码语言:javascript
复制
# $ mkdir -p build
# $ cd build
# $ cmake ..
# ...
# -- Build type: Release
# -- C flags, Debug configuration: -g
# -- C flags, Release configuration: -O3 -DNDEBUG
# -- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
# -- C flags, minimal Release configuration: -Os -DNDEBUG
# -- C++ flags, Debug configuration: -g
# -- C++ flags, Release configuration: -O3 -DNDEBUG
# -- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
# -- C++ flags, minimal Release configuration: -Os -DNDEBUG


# $ cmake -D CMAKE_BUILD_TYPE=Debug ..
# -- Build type: Debug
# -- C flags, Debug configuration: -g
# -- C flags, Release configuration: -O3 -DNDEBUG
# -- C flags, Release configuration with Debug info: -O2 -g -DNDEBUG
# -- C flags, minimal Release configuration: -Os -DNDEBUG
# -- C++ flags, Debug configuration: -g
# -- C++ flags, Release configuration: -O3 -DNDEBUG
# -- C++ flags, Release configuration with Debug info: -O2 -g -DNDEBUG
# -- C++ flags, minimal Release configuration: -Os -DNDEBUG

不同配置使用了哪些标志,这主要取决于选择的编译器。需要在运行CMake时显式地打印标志,也可以仔细阅读运行CMake --system-information的输出,以了解当前平台、默认编译器和语言的默认组合是什么。

3.7 编译器选项设置

前面的示例展示了如何探测CMake,从而获得关于编译器的信息,以及如何切换项目中的编译器。后一个任务是控制项目的编译器标志。CMake为调整或扩展编译器标志提供了很大的灵活性,可以选择下面两种方法:

? CMake将编译选项视为目标属性。因此,可以根据每个目标设置编译选项,而不需要覆盖CMake默认值。

?可以使用-D 的CLI标志直接修改CMAKE_<LANG>_FLAGS_<CONFIG>变量。这将影响项目中的所有目标,并覆盖或扩展CMake默认值。

本示例中将展示这两种方法,具体的代码示例可见https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-08 :

本例中,警告标志有-Wall、-Wextra和-Wpedantic,将这些标示添加到geometry目标的编译选项中,其中compute-areas和 geometry的目标都将使用-fPIC标志。CMake的编译选项可以添加三个级别的可见性INTERFACE、PUBLIC和PRIVATE,具体的定义和设计区分如下:。

? PRIVATE,编译选项会应用于给定的目标,不会传递给与目标相关的目标。

# 示例中, 即使compute-areas将链接到geometry库,compute-areas也不会继承geometry目标上设置的编译器选项。

? INTERFACE,给定的编译选项将只应用于指定目标,并传递给与目标相关的目标。

? PUBLIC,编译选项将应用于指定目标和使用它的目标。

CMake通过环境变量VERBOSE,传递给本地构建工具,用以通过本地构建日志验证这些标志是否按照我们的意图正确使用,下面的示例中会设置环境变量VERBOSE=1:

代码语言:javascript
复制
# $ mkdir -p build
# $ cd build
# $ cmake ..
# $ cmake --build . -- VERBOSE=1
# ... lots of output ...
# [ 14%] Building CXX object CMakeFiles/geometry.dir/geometry_circle.cpp.o
# /usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_circle.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_circle.cpp
# [ 28%] Building CXX object CMakeFiles/geometry.dir/geometry_polygon.cpp.o
# /usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_polygon.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_polygon.cpp
# [ 42%] Building CXX object CMakeFiles/geometry.dir/geometry_rhombus.cpp.o
# /usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_rhombus.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_rhombus.cpp
# [ 57%] Building CXX object CMakeFiles/geometry.dir/geometry_square.cpp.o
# /usr/bin/c++ -fPIC -Wall -Wextra -Wpedantic -o CMakeFiles/geometry.dir/geometry_square.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/geometry_square.cpp
# ... more output ...
# [ 85%] Building CXX object CMakeFiles/compute-areas.dir/compute-areas.cpp.o
# /usr/bin/c++ -fPIC -o CMakeFiles/compute-areas.dir/compute-areas.cpp.o -c /home/bast/tmp/cmake-cookbook/chapter-01/recipe-08/cxx-example/compute-areas.cpp
# ... more output ...

3.8 为语言设定标准

编程语言有不同的标准,即提供改进的语言版本。启用新标准是通过设置适当的编译器标志来实现的。前面的示例中,我们已经展示了如何为每个目标或全局进行配置。3.1版本中,CMake引入了一个独立于平台和编译器的机制,用于为C++和C设置语言标准:为目标设置<LANG>_STANDARD属性,本小节中的示例可见:https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-01/recipe-09,下面将展示具体的实施的CMake配置和相关注释:

需要注意的一点是,如果语言标准是所有目标共享的全局属性,可以在全局的CMakeList.txt中的开始就将

? CMAKE_<LANG>_STANDARD

? CMAKE_<LANG>_EXTENSIONS、

? CMAKE_<LANG>_STANDARD_REQUIRED

变量设置为相应的值。做了语言标准的声明后,CMakeList.txt中所有目标上的对应属性都将使用这些设置,如:

3.9 使用控制流

本章前面的示例中,已经使用过if-else-endif的条件控制块,在本文中的第二章还介绍了CMake提供的创建循环的语言工具:foreach endforeach和while-endwhile。两者都可以与break结合使用,以便尽早从循环中跳出。本示例将展示如何使用foreach,来循环源文件列表。示例中将应用这样的循环,在引入新目标的前提下,来为一组源文件进行优化降级:

最后,在回顾第二章中的循环语句的使用方式,以foreach()为例,其有四种使用方式:

1. foreach(loop_var arg1 arg2 ...):其中提供循环变量和显式项列表,以上例中的列表变量为例,当为sources_with_lower_optimization中的项打印编译器标志集时,使用此表单。注意,如果项目列表位于变量中,则必须显式展开它;也就是说,${sources_with_lower_optimization} 必须作为参数传递。

e.g.:foreach(_source ${sources_with_lower_optimization}),foreach(p LIB BIN INCLUDE CMAKE)

2. 通过指定一个范围,可以对整数进行循环,例如:foreach(loop_var range total)或foreach(loop_var range start stop [step])。

3. 对列表值变量的循环,例如:foreach(loop_var IN LISTS [list1[...]]) 。参数解释为列表,其内容就会自动展开。

4. 对变量的循环,例如:foreach(loop_var IN ITEMS [item1 [...]])。参数的内容没有展开。

参考资料

【1】Rafa? ?widziński,《Modern CMake for C++》

【2】CMake Documentation,https://cmake.org/cmake/help/latest/

【3】CMake Cookbook,https://www.bookstack.cn/read/CMake-Cookbook/README.md

【4】cmake常用指令入门指南,https://www.cnblogs.com/yinheyi/p/14968494.html

本文仅做学术分享,如有侵权,请联系删文。

本文参与?腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-04-12,如有侵权请联系?cloudcommunity@tencent.com 删除

本文分享自 3D视觉工坊 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与?腾讯云自媒体分享计划? ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.1 CMake是什么?
  • 1.2 CMake设计初衷是为了什么?
  • 1.3 CMake在“暗地里”是怎么工作的?
  • 2、基本的CMake语法
    • 2.1 变量
      • 2.1.1 环境变量
      • 2.1.2 缓存变量
      • 2.1.3 变量作用域
    • 2.2 控制结构
      • 2.2.1 条件块
      • 2.2.2 循环
      • 2.2.3 定义指令
    • 2.3 实用指令
      • 2.3.1 message() 指令
      • 2.3.2 include() 指令
      • 2.3.3 file() 指令
      • 2.3.4 execute_process() 指令
  • 简单的CMake构建
    • 3.1 简单的可执行文件生成
      • 使用条件块控制编译
        • 3.4 向用户显示选项
          • 3.5 指定编译器
            • 3.6、构建类型切换
              • 3.7 编译器选项设置
                • 3.8 为语言设定标准
                  • 3.9 使用控制流
                  • 参考资料
                  相关产品与服务
                  持续集成
                  CODING 持续集成(CODING Continuous Integration,CODING-CI)全面兼容 Jenkins 的持续集成服务,支持 Java、Python、NodeJS 等所有主流语言,并且支持 Docker 镜像的构建。图形化编排,高配集群多 Job 并行构建全面提速您的构建任务。支持主流的 Git 代码仓库,包括 CODING 代码托管、GitHub、GitLab 等。
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
                  http://www.vxiaotou.com