4. 1.4 CMakeLists.txt语法基础

导语

经过前面三节的学习,我们已经了解了CMake的产生背景、完成了本地环境的安装配置,并成功运行了第一个Hello World项目。如果说之前的章节是在“欣赏CMake的风景”,那么从本节开始,我们将真正走进CMakeLists.txt的内部,系统地学习这门构建领域专用语言的基础语法。

CMake虽然名为构建系统生成器,但其配置脚本CMakeLists.txt本身就是一门功能完整的脚本语言。它包含变量、列表、条件判断、循环、函数和宏等常见编程要素。掌握这些基础语法,是你日后能熟练驾驭复杂项目、写出符合Modern CMake规范的构建脚本的根本前提。

本节内容较多,但都是干货。建议打开你的代码编辑器,跟着文中的示例亲手敲一遍,这比单纯阅读要有效得多。

1. 注释与消息输出

1.1 注释的写法

CMake只支持单行注释,使用井号#开头。从#开始到行尾的所有内容都会被解释器忽略。

# 这是一个单行注释
cmake_minimum_required(VERSION 3.20)  # 行尾注释也是允许的

# 多行注释需要每行都加 #
# 这是第二行注释
# 这是第三行注释

CMake没有原生多行块注释(不像C++的/* */)。如果你需要临时屏蔽一大段代码,最实用的办法是在每行前加#,或者利用if(FALSE)...endif()包裹(后面会讲到)。

1.2 message():你的第一个调试工具

在CMake中,message()不仅用于向终端打印文本,更重要的是它支持多种日志级别,可以帮助我们在配置阶段输出不同严重程度的诊断信息。

基本语法如下:

message([] "要输出的消息内容")

如果不指定<mode>,默认行为会输出到标准错误流,并且会暂停配置过程(在老版本CMake中),因此建议始终显式指定一个模式。常用的日志级别有:

  • FATAL_ERROR:立即终止CMake的配置和生成过程,并输出错误信息。
  • SEND_ERROR:继续处理当前目录及子目录,但跳过生成阶段,最终构建会失败。
  • WARNING:输出警告信息,不影响配置和生成。
  • AUTHOR_WARNING:专为项目作者准备的警告,可以通过-Wno-dev选项静默。
  • STATUS:输出一般的状态信息(最常用),前缀会带--,输出到标准输出流。
  • VERBOSE / DEBUG / TRACE:更细粒度的调试信息,需要通过--log-level选项开启显示。

来看一个完整的示例:

cmake_minimum_required(VERSION 3.20)
project(MessageDemo)

message(STATUS "项目配置开始...")
message(DEBUG "这是一条调试信息,通常看不到")
message(WARNING "这是一个警告,配置会继续")

# 模拟一个错误检查
set(SOME_CRITICAL_VAR "")
if(NOT SOME_CRITICAL_VAR)
    message(FATAL_ERROR "SOME_CRITICAL_VAR 未设置,无法继续!")
endif()

message(STATUS "你永远看不到这一行,因为上面FATAL_ERROR会终止程序")

运行cmake -B build时,你会看到类似输出:

-- 项目配置开始...
CMake Warning at CMakeLists.txt:7 (message):
  这是一个警告,配置会继续

CMake Error at CMakeLists.txt:11 (message):
  SOME_CRITICAL_VAR 未设置,无法继续!

如果你想查看DEBUG或TRACE级别的信息,需要在命令行提高日志级别:

cmake -B build --log-level=Debug

2. 变量定义与引用

2.1 使用 set 定义变量

CMake中定义变量使用set命令。和大多数脚本语言类似,CMake的变量本质上是字符串。即使看起来像是数字或布尔值,在存储时也都是字符串。

set(MY_NAME "CMake")
set(VERSION_NUMBER 3)       # 看起来是整数,实际是字符串 "3"
set(FLAG_ENABLE ON)         # 看起来是布尔值,实际是字符串 "ON"

如果变量的值包含空格,必须用引号包裹;如果不包含空格,引号可以省略,但建议始终使用引号以提高可读性和安全性。

2.2 变量引用与解引用 ${}

定义了变量后,使用${VAR_NAME}的语法来引用(解引用)变量的值。这个过程也被称为变量求值

set(MY_NAME "CMake")
message(STATUS "当前的构建工具是: ${MY_NAME}")

set(NUM_A 10)
set(NUM_B 20)
# 注意:这里的 + 不会进行数学运算,而是字符串拼接!
set(RESULT "${NUM_A}${NUM_B}")  
message(STATUS "RESULT的值是: ${RESULT}")  # 输出 1020,不是 30

重要提示:CMake中${}的求值发生在命令参数解析阶段。这意味着在if()等特定命令中,有时可以直接使用变量名而不加${},这涉及到CMake的“裸变量”求值规则,我们稍后在条件判断中会详细说明。

2.3 取消变量定义 unset

如果希望让一个变量失效,可以使用unset命令:

set(TEMP_VAR "临时数据")
message(STATUS "未取消前: ${TEMP_VAR}")

unset(TEMP_VAR)
message(STATUS "取消后: ${TEMP_VAR}")  # 输出为空字符串

2.4 特殊变量类型初探

CMake中的变量分为普通变量、缓存变量(Cache Variables)和环境变量。本节先掌握普通变量即可。缓存变量通常用于在命令行通过-D选项传入(如cmake -D CMAKE_BUILD_TYPE=Release ..),后续章节会深入讲解。

3. 列表(List)操作

在CMake中,列表(List)并不是一个独立的数据类型,而是一种特殊的字符串——其元素通过分号(;)分隔。当你用空格分隔多个值调用set时,CMake内部会将其存储为分号分隔的字符串。

# 下面两种方式定义出的列表在CMake内部是完全相同的
set(FRUITS apple banana orange)           # 内部存储为 apple;banana;orange
set(FRUITS "apple;banana;orange")         # 显式使用分号分隔

message(STATUS "${FRUITS}")               # 输出时默认以分号分隔显示

3.1 列表的增删改查

CMake提供了专门的list命令来操作列表,基本涵盖了日常所需:

set(MY_LIST alpha beta gamma)

# 1. 追加元素 (APPEND)
list(APPEND MY_LIST delta)
message(STATUS "追加后: ${MY_LIST}")    # alpha;beta;gamma;delta

# 2. 在指定位置插入 (INSERT)
list(INSERT MY_LIST 1 "inserted")
message(STATUS "插入后: ${MY_LIST}")    # alpha;inserted;beta;gamma;delta

# 3. 获取指定索引的元素 (GET),索引从0开始
list(GET MY_LIST 2 ITEM_AT_2)
message(STATUS "索引2的元素: ${ITEM_AT_2}")  # gamma

# 4. 查找元素位置 (FIND)
list(FIND MY_LIST "beta" BETA_INDEX)
message(STATUS "beta的位置: ${BETA_INDEX}")  # 2(因为前面插入了元素)

# 5. 移除指定元素 (REMOVE_ITEM)
list(REMOVE_ITEM MY_LIST "inserted")
message(STATUS "移除后: ${MY_LIST}")

# 6. 获取列表长度 (LENGTH)
list(LENGTH MY_LIST LEN)
message(STATUS "列表长度: ${LEN}")

# 7. 移除重复项 (REMOVE_DUPLICATES)
list(APPEND MY_LIST alpha)
list(REMOVE_DUPLICATES MY_LIST)
message(STATUS "去重后: ${MY_LIST}")

3.2 列表与字符串的关系

因为列表本质是字符串,所以有时需要格外小心。例如:

set(MY_PATH "/usr/local/bin")
# 错误示范:这会把路径按分号拆开,因为 /usr/local/bin 中没有分号,所以没变化
# 但如果路径带空格,情况会更复杂
set(PATH_LIST ${MY_PATH})

对于包含空格的路径,建议使用引号避免被错误解析。列表操作在处理源文件集合、编译器标志集合时非常常用。

4. 字符串操作

既然CMake的核心数据类型是字符串,string命令自然是一个非常强大的工具箱。它提供了查找、替换、正则匹配、大小写转换、哈希计算等功能。

4.1 查找与替换

set(MY_STR "Hello CMake World")

# 查找子串位置
string(FIND "${MY_STR}" "CMake" POS)
message(STATUS "CMake出现在位置: ${POS}")  # 输出 6

# 替换子串(所有匹配项)
string(REPLACE "World" "Developer" NEW_STR "${MY_STR}")
message(STATUS "替换后: ${NEW_STR}")       # Hello CMake Developer

# 大小写转换
string(TOUPPER "${MY_STR}" UPPER_STR)
string(TOLOWER "${MY_STR}" LOWER_STR)

4.2 正则表达式匹配

正则表达式在提取版本号、解析文件名等场景下极为有用。string(REGEX MATCH ...)匹配第一个结果,string(REGEX MATCHALL ...)匹配所有结果,string(REGEX REPLACE ...)进行替换。

set(VERSION_STRING "当前版本是 v2.5.1,发布日期 2024-01-15")

# 提取版本号
string(REGEX MATCH "v([0-9]+\.[0-9]+\.[0-9]+)" FULL_MATCH "${VERSION_STRING}")
message(STATUS "完整匹配: ${FULL_MATCH}")   # v2.5.1

# 使用括号捕获组提取内容
string(REGEX MATCH "v([0-9]+)\.([0-9]+)\.([0-9]+)" _ "${VERSION_STRING}")
# CMake中捕获组结果会放入 CMAKE_MATCH_n 变量
message(STATUS "主版本: ${CMAKE_MATCH_1}")  # 2
message(STATUS "次版本: ${CMAKE_MATCH_2}")  # 5
message(STATUS "修订号: ${CMAKE_MATCH_3}")  # 1

# 正则替换:将日期格式从 YYYY-MM-DD 改为 YYYY/MM/DD
string(REGEX REPLACE "([0-9]{4})-([0-9]{2})-([0-9]{2})" "\1/\2/\3" NEW_DATE "${VERSION_STRING}")
message(STATUS "新格式: ${NEW_DATE}")

CMake的正则语法遵循大部分POSIX扩展正则标准。需要特别注意的是,在CMake字符串中,反斜杠是转义字符,所以正则里的.需要写成\.,捕获组引用1要写成\1

5. 条件判断

CMake的条件判断使用if/elseif/else/endif()结构。这里有两个容易让新手困惑的地方:

  1. endif()后面需要写一对空括号,虽然括号内可以写对应的条件以增强可读性,但CMake并不要求括号内内容与if匹配。
  2. if命令内部对变量的求值规则比较特殊:如果直接使用变量名(不加${}),CMake会将其视为一个变量名,并自动去查找该变量名的值进行判断。

5.1 基本语法与常见条件

set(MY_VAR "hello")
set(EMPTY_VAR "")
set(NUM_VAR 42)

# 1. 检查变量是否定义且非空
if(MY_VAR)
    message(STATUS "MY_VAR 存在且非空")
endif()

# 2. 检查变量是否未定义或为空
if(NOT EMPTY_VAR)
    message(STATUS "EMPTY_VAR 为空或未定义")
endif()

# 3. 字符串比较(必须加引号防止空格问题)
if("${MY_VAR}" STREQUAL "hello")
    message(STATUS "字符串完全相等")
endif()

# 4. 数值比较
if(NUM_VAR GREATER 40)
    message(STATUS "NUM_VAR 大于 40")
elseif(NUM_VAR EQUAL 42)
    message(STATUS "NUM_VAR 等于 42")
else()
    message(STATUS "NUM_VAR 小于 40")
endif()

# 5. 逻辑组合
if(MY_VAR AND NUM_VAR GREATER 10)
    message(STATUS "两个条件都满足")
endif()

5.2 if 命令的“裸变量”规则

这是CMake中最容易踩坑的地方之一。看下面两段代码,效果可能完全不同:

set(FOO "BAR")

# 写法A:不加 ${},CMake会把 FOO 当作变量名,查找其值 "BAR"
# 然后判断字符串 "BAR" 是否为真(非空即真)
if(FOO)
    message(STATUS "写法A: FOO 为真")
endif()

# 写法B:加了 ${},CMake先求值得 "BAR"
# 然后 if 会把 "BAR" 当作一个新的变量名去查找!
# 如果变量 BAR 未定义,结果就会是假
if(${FOO})
    message(STATUS "写法B: 这行可能不执行!")
endif()

最佳实践:在进行存在性判断时,直接使用if(MY_VAR);在进行字符串内容比较时,始终使用引号包裹并加${},如if("${MY_VAR}" STREQUAL "expected")

5.3 文件与路径相关的条件

# 检查文件是否存在
if(EXISTS "${CMAKE_SOURCE_DIR}/README.md")
    message(STATUS "找到 README 文件")
endif()

# 检查是否是目录
if(IS_DIRECTORY "${CMAKE_SOURCE_DIR}/src")
    message(STATUS "src 是目录")
endif()

6. 循环结构

6.1 foreach 遍历列表

foreach是CMake中最常用的循环结构,特别适合遍历文件列表、编译选项列表等。

set(SRC_FILES main.cpp utils.cpp helper.cpp)

foreach(SRC IN LISTS SRC_FILES)
    message(STATUS "处理源文件: ${SRC}")
endforeach()

# 也可以直接写范围(类似Python的range)
foreach(I RANGE 1 5)
    message(STATUS "当前数字: ${I}")
endforeach()

# RANGE 也可以指定步长
foreach(I RANGE 0 10 2)
    message(STATUS "偶数: ${I}")
endforeach()

6.2 while 条件循环

当循环次数不确定,由某个条件控制时,使用while

set(COUNTER 0)

while(COUNTER LESS 5)
    message(STATUS "计数器: ${COUNTER}")
    math(EXPR COUNTER "${COUNTER} + 1")  # math 用于数值运算
endwhile()

注意CMake没有i++这样的自增语法,数值运算必须使用math(EXPR ...)命令。

7. 函数(function)与宏(macro)

当CMakeLists.txt变得复杂时,将重复逻辑封装成函数或宏是必要的。理解两者的区别对于写出正确的CMake脚本至关重要。

7.1 函数(function)

函数在调用时会创建一个新的作用域。函数内部定义的变量默认只在函数内部可见,不会影响到调用方。函数参数通过${ARG0}${ARG1}…或命名的${arg1}访问,额外的参数可以通过${ARGN}获取。

function(print_list PREFIX)
    # ARGN 包含除显式声明参数外的所有剩余参数
    foreach(ITEM IN LISTS ARGN)
        message(STATUS "${PREFIX}: ${ITEM}")
    endforeach()
endfunction()

# 调用
print_list("[FILE]" main.cpp utils.cpp helper.cpp)

7.2 宏(macro)

宏更像C语言中的宏,它是文本替换。宏不会创建新的作用域,而是在调用处直接展开代码。因此,宏内部设置的变量会影响到调用方的上下文。

macro(set_debug_flags)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0")
    message(STATUS "宏内部修改了 CMAKE_CXX_FLAGS")
endmacro()

set_debug_flags()
# 这里 CMAKE_CXX_FLAGS 已经被修改了

Modern CMake建议:优先使用function而非macro,因为函数的作用域隔离能有效避免变量污染。宏仅在需要修改调用者作用域的变量,或者需要“返回”多个值时考虑使用。

7.3 返回值机制

CMake函数没有return value的概念,通常通过两种机制“返回”数据:

  1. PARENT_SCOPE:将变量设置到父作用域(即调用方)。
  2. 设置调用者指定的变量名:通过参数传入一个变量名,函数内部向其写入。
function(get_greeting OUT_VAR)
    set(${OUT_VAR} "Hello from function!" PARENT_SCOPE)
endfunction()

get_greeting(MY_GREETING)
message(STATUS "收到的问候: ${MY_GREETING}")  # Hello from function!

注意:如果不加PARENT_SCOPEMY_GREETING在函数外部将是未定义状态。

8. 作用域与变量传递机制

理解CMake的作用域模型是掌握变量传递的关键。CMake有三种主要的作用域:

8.1 函数作用域(Function Scope)

每次调用function时,CMake会压栈创建一个新的作用域。在这个作用域内set的变量,在函数返回后就会自动销毁,调用者看不到这些修改。

set(GLOBAL_MSG "我是全局消息")

function(test_scope)
    set(LOCAL_MSG "我是局部消息")
    set(GLOBAL_MSG "我被函数内部修改了")
    message(STATUS "函数内部 GLOBAL_MSG = ${GLOBAL_MSG}")
endfunction()

test_scope()
message(STATUS "函数外部 GLOBAL_MSG = ${GLOBAL_MSG}")
# message(STATUS "函数外部 LOCAL_MSG = ${LOCAL_MSG}")  # 错误!LOCAL_MSG 不存在

运行后你会发现,函数外部GLOBAL_MSG的值没有被修改!因为函数内部的set默认只影响函数作用域。如果你确实想修改外部的变量,必须显式使用PARENT_SCOPE

function(test_scope_override)
    set(GLOBAL_MSG "我真的被修改了" PARENT_SCOPE)
endfunction()

8.2 目录作用域(Directory Scope)

每个CMakeLists.txt文件(以及通过add_subdirectory添加的子目录)拥有自己的目录作用域。子目录可以“看到”父目录中定义的变量,但默认情况下,子目录对变量的修改不会反向传递到父目录。

# 父目录 CMakeLists.txt
set(PARENT_VAR "来自父目录")
message(STATUS "父目录: ${PARENT_VAR}")

add_subdirectory(subdir)
message(STATUS "回到父目录后: ${PARENT_VAR}")  # 仍然是 "来自父目录"

# subdir/CMakeLists.txt
message(STATUS "子目录能读到: ${PARENT_VAR}")
set(PARENT_VAR "被子目录修改")  # 这只影响子目录及更深层的目录
message(STATUS "子目录修改后: ${PARENT_VAR}")

8.3 缓存作用域(Cache Scope)

缓存变量是最高级别的作用域,它们存储在构建目录的CMakeCache.txt文件中,持久化保存,并且对所有目录和函数都可见。定义缓存变量的语法是:

set(MY_CACHE_VAR "默认值" CACHE STRING "这是一个缓存变量的描述")

缓存变量通常用于用户配置选项(如构建类型、安装路径等)。如果变量已经在缓存中存在,set(CACHE)不会覆盖已有值,这是为了让用户在命令行通过-D传入的值保持稳定。

# 用户命令行传入,优先级高于CMakeLists.txt中的默认值
cmake -B build -D MY_CACHE_VAR="用户自定义值"

小结

本节我们系统学习了CMakeLists.txt的基础语法,涵盖了从简单的消息输出到复杂的作用域机制。以下是本节的要点回顾:

  • message()支持多种日志级别,善用它们可以大幅提升调试效率。
  • CMake变量本质是字符串,使用${}解引用,列表本质是分号分隔的字符串。
  • if()命令中的“裸变量”规则是一个常见坑点,进行字符串比较时务必加引号。
  • 函数会创建新作用域,宏则是文本替换;优先使用函数来保持代码的模块化与隔离性。
  • 作用域分为函数作用域、目录作用域和缓存作用域;理解它们才能正确传递变量。

掌握这些语法基础后,下一节我们将正式学习CMake的核心概念——目标(Target),包括可执行文件、静态库、动态库的定义与属性设置,这也是Modern CMake最核心的部分。我们下节再见!

请登录后发表评论

    没有回复内容

正在唤醒异次元光景……