「CMake」是一个跨平台的C++项目管理解决方案,它可以帮助用户方便地管理大型C++项目,支持生成不同平台的Makefile。本系列文章可以帮助你快速掌握CMake,所有内容来源于Mastering Cmake

本篇文章为该系列的第二篇,我们将讨论如何为你的项目编写一个基本的CMakeLists文件,包括基本的命令和你在项目中可能会遇到的问题。本文还将讨论CMakeLists文件的健壮性和可维护性。

2.1 编辑CMakeLists文件

你可以使用大多数的文本编辑器来编辑CMakeLists文件,比如Notepad++,VS Code等等。它们自带了CMake语法高亮显示和缩进支持。而其他的一些编辑器比如Emacs或者Vim,CMake包含缩进和语法高亮模块,你可以在源码的Auxiliary目录中找到它们,或者从CMake的下载页面下载。

在任何支持的生成器(Makefiles, Visual Studio等)中,如果你编辑了一个CMakeLists文件并使用CMake重新构建,生成器会根据需要自动调用CMake来更新生成的文件(例如Makefiles或项目文件)。这有助于确保生成的文件总是与CMakeLists文件同步。

2.2 CMake的语言

CMake语言由注释,命令和变量组成。

2.3 注释

注释由#开头,直到一行的末尾。可以查看手册中的cmake-language了解更多细节。

2.2 变量

想很多编程语言那样,CMakeLists文件有变量的概念。变量名是大小写敏感的,并且仅包含字母,数字和下划线。

CMake已经默认定义了很多有用的变量,你可以在cmake-variables手册中查看它们。这些变量都以CMAKE_开头。请避免在你的项目中使用这种形式命名的变量,以免产生冲突。

所有的CMake变量都在内部都作为字符串存储(虽然一些CMake变量有时可以解释为其他的类型)。

你可以使用set命令来设置变量的值。set命令的一种最简单的形式是,第一个参数是变量名,剩余部分是变量值。多个值的参数会被连接成到一个以分号分隔的列表,并作为字符串存储在变量中。

set(Foo "")      # 1 quoted arg -> value is ""
set(Foo a) # 1 unquoted arg -> value is "a"
set(Foo "a b c") # 1 quoted arg -> value is "a b c"
set(Foo a b c) # 3 unquoted args -> value is "a;b;c"

变量可以以${VAR}的语法格式进行引用并作为命令的参数,其中VAR是变量名。如果该变量未被定义,那么这个引用就会被空字符串所取代;否则就是该变量的值。变量替换在未加引号的参数展开之前执行,因此包含分号的变量值被分割成零个或多个参数,以代替原始的未加引号的参数。

set(Foo a b c)    # 3 unquoted args -> value is "a;b;c"
command(${Foo}) # unquoted arg replaced by a;b;c
# and expands to three arguments
command("${Foo}") # quoted arg value is "a;b;c"
set(Foo "") # 1 quoted arg -> value is empty string
command(${Foo}) # unquoted arg replaced by empty string
# and expands to zero arguments
command("${Foo}") # quoted arg value is empty string

系统的环境变量以及Windows的注册表值可以直接在CMake中使用。要使用系统环境变量,可以通过$ENV{VAR}语法。CMake也可以通过[HKEY_CURRENT_USER\\Software\\path1\\path2;key]形式的语法来引用注册表项,其中的path是注册表的路径。

2.2.1 变量作用域

Cmake的变量作用域与大多数编程语言有一点小小的差异,当你设置一个变量时,它不仅在当前的CMakeLists文件或者函数中可见,同时在所有子目录的CMakeLists文件,任何被调用的函数和宏,以及任何被include命令导入的文件中都是可见的。

当CMake处理一个新的子目录(或一个函数被调用)时,一个新的变量作用域就被创建且用调用作用域中所有变量的当前值初始化。子作用域中创建的新变量,或者对已经存在的变量的修改,都不会影响到父作用域。考虑以下例子:

function(foo)
message(${test}) # test is 1 here
set(test 2)
message(${test}) # test is 2 here, but only in this scope
endfunction()

set(test 1)
foo()
message(${test}) # test will still be 1 here

在某些情况下,你可能希望函数或子目录在其父作用域内设置变量。CMake有一种从函数返回值的方法,可以通过使用PARENT SCOPE选项和set命令来实现。我们可以修改前面的例子,使函数foo在其父作用域中更改test的值,如下所示:

function(foo)
message(${test}) # test is 1 here
set(test 2 PARENT_SCOPE)
message(${test}) # test still 1 in this scope
endfunction()

set(test 1)
foo()
message(${test}) # test will now be 2 here

CMake中的变量都是按照set命令执行的顺序定义的。考虑以下的例子:

# FOO is undefined

set(FOO 1)
# FOO is now set to 1

set(FOO 0)
# FOO is now set to 0

为了理解变量的作用域,考虑以下的例子:

set(foo 1)

# process the dir1 subdirectory
add_subdirectory(dir1)

# include and process the commands in file1.cmake
include(file1.cmake)

set(bar 2)
# process the dir2 subdirectory
add_subdirectory(dir2)

# include and process the commands in file2.cmake
include(file2.cmake)

在这个例子中,由于变量foo一开始就被定义了,所以它会在处理dir1dir2时都被定义。与之相对的,只有在dir2的作用域中,才会定义bar。类似的,在处理file1.cmakefile2.cmake时,foo都会被定义,而bar只会在file2.cmake中被定义。

2.3 命令

一条命令包含命令名,左括号,空格分隔的参数和右括号。每个命令都是按照它在CMakeLists文件中出现的顺序生成的。你可以查看cmake-commands手册来了解所有的CMake命令。

CMake现在对于命令不再是大小写敏感的了,所以你可以使用COMMAND或者Command来代替command。最佳实践是采用全小写的方式来拼写命令。除了分隔符之外,所有的空白字符(空格符,换行符,制表符)都将被忽略。因此,只要命令名和左括号在同一行,那么一条命令就可以拆分成多行。

CMake命令参数是以空格隔开且大小写敏感的。命令参数可以使用引号包裹起来。用引号括起来的参数以双引号开始和结束,并且总是只表示一个参数。参数值中包含的任何双引号都必须使用反斜杠进行转义。考虑对需要转义的参数使用括号参数可以查看cmake-language手册。未被引号包裹的参数不能以双引号开头,并且如果你使用了分隔符,CMake会自动将其拆分为多个参数。例如:

command("")          # 1 quoted argument
command("a b c") # 1 quoted argument
command("a;b;c") # 1 quoted argument
command("a" "b" "c") # 3 quoted arguments
command(a b c) # 3 unquoted arguments
command(a;b;c) # 1 unquoted argument expands to 3

2.3.1 基本命令

正如前文所提到的,set命令和unset命令用于设置和取消一个变量。stringlistseparate_arguments命令提供了对字符串和列表的基本操作。

add_executableadd_library命令用于定义要构建的可执行文件和库的主要命令,同时还指明了这些可执行文件或库由哪些源文件生成。对于Visual Studio项目,源文件将像往常一样显示在IDE中,但项目使用的任何头文件将不会显示在IDE中。要显示头文件,只需将它们添加到可执行文件或库的源文件列表中;所有生成器都可以这样做。任何不直接使用头文件的生成器(比如基于Makefile的生成器)都将忽略头文件。

2.4 控制流

CMake语言提供三种控制流来帮助你组织CMakeLists文件,并且使得它们更易于维护。

  1. 条件语句(如if
  2. 循环结构(如foreachwhile
  3. 过程定义(如macrofunction

2.4.1 条件语句

首先我们考虑if语句。在很多情况下,CMake中的if语句和其他语言类似。它根据表达式的值决定是否执行块中的语句。例如:

if(FOO)
# do something here
else()
# do something else
endif()

CMake同样支持多分支的elseif,例如:

if(MSVC80)
# do something here
elseif(MSVC90)
# do something else
elseif(APPLE)
# do something else
endif()

2.4.2 循环结构

foreachwhile命令允许你处理一些重复性的任务。break命令可以在foreachwhile循环正常退出前跳出循环。

foreach命令可以让你循环处理一个列表中的元素。下列例子来自VTK:

foreach(tfile
TestAnisotropicDiffusion2D
TestButterworthLowPass
TestButterworthHighPass
TestCityBlockDistance
TestConvolve
)
add_test(${tfile}-image ${VTK_EXECUTABLE}
${VTK_SOURCE_DIR}/Tests/rtImageTest.tcl
${VTK_SOURCE_DIR}/Tests/${tfile}.tcl
-D ${VTK_DATA_ROOT}
-V Baseline/Imaging/${tfile}.png
-A ${VTK_SOURCE_DIR}/Wrapping/Tcl
)
endforeach()

foreach命令的第一个参数是迭代循环中的变量名;剩下的参数是要进行迭代的列表值。在这个例子中,foreach的循环体值使用了一个命令add_test。在循环体中,每一次循环tfile的引用都会被替换成列表中的下一个元素。第一轮迭代时,${tfile}会被替换成TestAnisotropicDiffusion2D。下一轮迭代时,${tfile}会被替换成TestButterworthLowPassforeach会一直执行直到所有元素都遍历一遍。

值得一提的是,foreach循环支持嵌套,并且循环变量将在任何其他变量展开之前先被替换。这意味着在foreach的循环体中,你可以使用循环变量构建变量名。在下面的代码中,循环变量tfile首先被展开,然后与_TEST_RESULT连接。最终展开的变量名将用于检查该变量的值是否与FAILED相匹配。

if(${${tfile}}_TEST_RESULT} MATCHES FAILED)
message("Test ${tfile} failed.")
endif()

while命令提供了基于条件的循环。它的条件表达式的格式与if 命令中的一样。考虑下面的例子(来自CTest,注意CTest在内部更新了CTEST_ELAPSED_TIME的值):

#####################################################
# run paraview and ctest test dashboards for 6 hours
#
while(${CTEST_ELAPSED_TIME} LESS 36000)
set(START_TIME ${CTEST_ELAPSED_TIME})
ctest_run_script("dash1_ParaView_vs71continuous.cmake")
ctest_run_script("dash1_cmake_vs71continuous.cmake")
endwhile()

2.4.1 过程定义

macrofunction命令支持重复的任务,这些任务可能分散在CMakeLists文件中。一旦定义了宏或函数,任何在其定义之后处理的CMakeLists文件都可以使用它。

CMake中的函数和C、C++中的非常像。你可以给它们传入参数,这些参数就将作为函数中的遍历。类似的,CMake默认已经定义了一些标准变量,比如ARGCARGVARGNARGV0ARGV1等等。函数调用拥有与一个动态作用域。一个函数内部就是一个新的作用域,这一点和你使用add_subdirectory命令非常像。调用函数时定义的所有变量仍然是已定义的,但对变量的任何更改或新变量只存在于函数内部。当函数返回时,这些变量就会消失。更简单地说:当你调用一个函数时,一个新的变量范围会产生:当它返回时,变量作用域消失。

function命令定义了一个新函数。第一个参数是要定义的函数名;所有附加参数都是函数的形参。

function(DetermineTime _time)
# pass the result up to whatever invoked this
set(${_time} "1:23:45" PARENT_SCOPE)
endfunction()

# now use the function we just defined
DetermineTime(current_time)

if(DEFINED current_time)
message(STATUS "The time is now: ${current_time}")
endif()

注意,在本例中,_time用于传递返回变量的名称。set命令使用了_time的值,该值为current_time。最后,set命令使用PARENT SCOPE选项在调用者的作用域(而不是当前作用域)中设置变量。

宏的定义和调用方式与函数相同。主要的区别是,宏不会产生和销毁一个新的变量作用域,并且宏的参数不被视为变量,而是作为字符串在执行之前被替换。这非常类似于C或C++中的宏和函数之间的区别。第一个参数是要创建的宏的名称;所有其他参数都是宏的形参。

# define a simple macro
macro(assert TEST COMMENT)
if(NOT ${TEST})
message("Assertion failed: ${COMMENT}")
endif()
endmacro()

# use the macro
find_library(FOO_LIB foo /usr/local/lib)
assert(${FOO_LIB} "Unable to find library foo")

上面的简单示例创建了一个名为assert的宏。该宏定义了两个参数:第一个是要测试的值,第二个是要在测试失败时打印的注释。宏的主体是一个简单的if命令,其中包含一个message命令。当遇到endmacro命令时,宏的主体结束。可以像调用命令一样简单地使用宏的名称来调用宏。在上面的例子中,如果没有找到FOO_LIB,则会打印一条消息,提示错误条件。

宏命令还支持定义接受变量参数列表的宏。如果你想定义一个具有可选参数或多个签名(signature)的宏,这个特性非常有用。变量参数可以使用ARGCARGV0ARGV1等来引用,而不是使用形参。ARGV0表示宏的第一个参数,ARGV1表示下一个,依此类推。你还可以使用形参和变量参数的混合形式,如下面的示例所示。

# define a macro that takes at least two arguments
# (the formal arguments) plus an optional third argument
macro(assert TEST COMMENT)
if(NOT ${TEST})
message("Assertion failed: ${COMMENT}")

# if called with three arguments then also write the
# message to a file specified as the third argument
if(${ARGC} MATCHES 3)
file(APPEND ${ARGV2} "Assertion failed: ${COMMENT}")
endif()

endif()
endmacro()

# use the macro
find_library(FOO_LIB foo /usr/local/lib)
assert(${FOO_LIB} "Unable to find library foo")

在本例中,两个必需的参数是TESTCOMMENT。这些必需的参数可以按名称引用,就像在本例中一样,也可以通过引用ARGV0ARGV1来引用。如果希望将参数作为一个列表处理,请使用ARGVARGN变量。ARGV(与ARGV0ARGV1等相反)是宏的所有参数的列表,而ARGN是在正式参数之后的所有参数的列表。在宏内部,可以根据需要使用foreach命令遍历ARGVARGN

return命令从函数、目录或文件中返回。请注意,与函数不同,宏是就地展开的,因此不能处理返回。

2.5 正则表达式

一些CMake命令,如ifstring,使用正则表达式或可以接受正则表达式作为参数。在最简单的形式中,正则表达式是用于搜索精确字符匹配的字符序列。然而,很多时候要找到的确切序列是未知的,或者只需要在字符串的开头或结尾匹配。由于有几个不同的约定来指定正则表达式,CMake标准在string命令文档中有描述。

2.6 高级命令

有一些命令可能非常有用,但通常不用于编写CMakeLists文件。本节将讨论其中一些命令,以及它们的作用。

首先,考虑add_depentencies命令,该命令在两个目标之间创建依赖关系。当目标确定目标时,CMAKE会在目标之间自动创建依赖关系。例如,CMAKE将自动为取决于库目标的可执行目标创建一个依赖关系。add_depentencies命令通常用于指定目标之间的目标间依赖关系,其中至少一个目标是自定义目标(请参阅添加自定义命令(Add Custom Command)部分)。

include_regular_expression命令也与依赖关系有关。该命令控制用于追踪源代码依赖性的正则表达式。默认情况下,CMAKE将跟踪源文件的所有依赖项,包括stdio.h等系统文件。如果你使用include_regular_expression命令指定正则表达式,则该正则表达式将用于限制头文件的处理。例如,如果你的软件项目的头文件全部以前缀foo开始(例如foomain.c,foostruct.h等),则可以指定一个类似^foo.*$的正则表达式来限制依赖检查的范围。