3、CMake基础语法:注释、命令、变量、数据类型、列表、字符串操作与数学表达式

CMake 并非传统意义上的编程语言,而是一种构建脚本语言。它的核心使命是描述“如何从源代码生成构建系统”(如 Makefile、Ninja 文件或 Visual Studio 工程)。然而,要驾驭复杂的跨平台项目,仅靠零散的 set() 和 add_executable() 是远远不够的。本章将系统拆解 CMake 语言的底层语法单元:从注释规范、命令调用机制,到变量作用域与缓存变量,再到列表的扁平化存储特性、字符串的丰富操作接口以及数学表达式的计算能力。理解这些基础语法,是后续编写可维护、可扩展、可调试的 CMake 工程的关键基石。

CMake 基础语法体系 命令与注释 command(args...) # 单行注释 / #[[ ]] 变量体系 普通变量 / 缓存变量 set() / unset() / 作用域 数据类型 字符串 / 列表 / 布尔 文件路径 / 自定义类型 操作能力 字符串操作 数学表达式 解析与执行 展开变量 → 执行命令 生成构建系统 Makefile / Ninja / VS 工程 列表的扁平化存储 set(LIST a b c) → "a;b;c" 列表元素以分号分隔,无嵌套结构 引号影响分号是否作为分隔符 foreach() 遍历时自动拆分 字符串操作 string() FIND / REPLACE / REGEX / TOLOWER APPEND / LENGTH / SUBSTRING / JOIN 数学表达式 math() EXPR 支持 + - * / % 位运算 用于版本号计算、尺寸对齐、循环计数

上图展示了 CMake 语法体系的核心构成与流转关系。CMake 脚本由命令(Command)驱动,命令接收参数(Arguments),参数在解析阶段会经过变量展开(Variable Expansion)处理。所有数据在 CMake 中本质上都是字符串,但通过分号分隔符可以形成列表(List)。变量分为普通变量和缓存变量,它们共同构成了构建脚本的“内存”。字符串操作和数学表达式则提供了对数据进行加工的能力。下面我们逐一深入剖析。

3.1 注释与命令调用机制

核心原理:注释的三种形态与命令的解析规则

CMake 支持三种注释方式:单行注释(#)、括号注释(#[[ ... ]])以及命令行尾随注释。括号注释可以跨越多行,非常适合临时屏蔽大段调试代码。命令调用遵循 command(arg1 arg2 ...) 的格式,参数之间以空白字符(空格或换行)分隔。CMake 解析器在遇到命令名后,会将其后的所有内容视为参数列表,直到遇到右括号或换行(若括号未闭合则继续读取下一行)。

# 单行注释:说明该变量用于控制是否构建测试
set(BUILD_TESTING OFF) # 尾随注释:同样有效

#[[ 这是一个多行注释
可以在这里写很长的说明文字,
甚至包含 # 符号也不会提前终止注释。
]]
message(STATUS "Hello, CMake!")

命令名是大小写敏感的,但 CMake 官方约定使用小写。参数中的变量引用(${VAR})会在命令执行前被展开。如果变量未定义,展开结果为空字符串,这通常不会报错,但可能导致逻辑错误。

操作流程:参数展开的优先级

  1. 解析器读取一行命令,识别命令名与参数列表。
  2. 对每个参数进行变量展开:${VAR} 被替换为 VAR 的值。
  3. 若参数中包含转义字符(\n, \t, \" 等),则进行转义处理。
  4. 若参数被双引号包裹,则其中的分号(;)和空格不再作为列表分隔符,而是作为字符串字面量的一部分。
  5. 最终将处理后的参数列表传递给命令执行。
常见误区:初学者常混淆 ${VAR} 与 VAR。在 if() 语句中,直接写变量名会被自动解引用,但在 set() 或 message() 中必须显式使用 ${}。例如 if(USE_FEATURE) 实际上检查的是变量 USE_FEATURE 的值是否为真,而不是字符串 "USE_FEATURE"。

3.2 变量体系:普通变量、缓存变量与作用域

核心原理:变量的存储与查找规则

CMake 变量本质上是键值对,键是字符串,值也是字符串。变量分为两类:普通变量(Normal Variable)和缓存变量(Cache Variable)。普通变量存在于当前作用域(函数、宏或目录)中,离开作用域即销毁。缓存变量则存储在 CMakeCache.txt 文件中,在多次运行 CMake 时持久保留,常用于存储用户可配置的选项(如 CMAKE_BUILD_TYPE)。

变量查找遵循“由内向外”的规则:先在当前函数作用域查找,若未找到,则向父作用域(调用者)查找,直到目录作用域。若仍未找到,则查找缓存变量。如果缓存中也没有,则变量视为未定义。

判断方法:set() 与 unset() 的精确控制

# 设置普通变量
set(MY_VAR "hello")

# 设置缓存变量(带类型和注释)
set(MY_CACHE_VAR "world" CACHE STRING "A user-configurable option")

# 强制覆盖缓存变量(使用 FORCE)
set(MY_CACHE_VAR "new_value" CACHE STRING "" FORCE)

# 删除变量
unset(MY_VAR)
unset(MY_CACHE_VAR CACHE)

使用 set(... CACHE ...) 时,如果缓存中已存在同名变量,set() 默认不会覆盖它(除非加 FORCE)。这是为了让用户通过 cmake-gui 或命令行 -D 选项设置的值在重新配置时不被脚本覆盖。

案例拆解:跨目录传递变量

假设项目结构如下:

project/
├── CMakeLists.txt
└── src/
    ├── CMakeLists.txt
    └── util.c

根目录 CMakeLists.txt 中设置 set(GLOBAL_FLAG "ON"),子目录 src/CMakeLists.txt 中可以直接读取 ${GLOBAL_FLAG},因为子目录作用域会继承父目录的变量。但如果子目录中执行 set(GLOBAL_FLAG "OFF"),这只会修改子目录及其以下作用域的副本,不会影响父目录。若希望子目录修改父目录变量,必须使用 set(PARENT_SCOPE):

# 在 src/CMakeLists.txt 中
set(GLOBAL_FLAG "OFF" PARENT_SCOPE)
经验提醒:在大型项目中,应避免过度依赖 PARENT_SCOPE 传递变量,这会使依赖关系隐晦。推荐使用 target_compile_definitions() 或 target_link_libraries() 将信息绑定到目标上,实现更清晰的传递。

3.3 数据类型与列表的扁平化特性

核心原理:一切皆字符串,列表是分号分隔的字符串

CMake 没有真正的整数、浮点或布尔类型。所有值都以字符串形式存储。布尔值通过约定判断:ON/OFF、TRUE/FALSE、1/0、YES/NO 等均被识别为真/假。列表(List)是 CMake 中最重要的复合数据类型,它实际上是一个包含分号(;)的字符串。例如 set(LIST a b c) 存储为 "a;b;c"。

列表的扁平化特性意味着:列表不能嵌套。如果你尝试将一个列表作为元素加入另一个列表,内层列表的分隔符会被展开,导致元素被“拍平”。

操作流程:列表的创建、遍历与修改

# 创建列表
set(SOURCES main.cpp util.cpp renderer.cpp)

# 遍历列表
foreach(src ${SOURCES})
    message(STATUS "Source: ${src}")
endforeach()

# 追加元素
list(APPEND SOURCES audio.cpp)

# 获取长度
list(LENGTH SOURCES len)
message("Source count: ${len}")

# 查找元素索引
list(FIND SOURCES "util.cpp" idx)
message("Index of util.cpp: ${idx}")

注意:在 foreach() 中,如果列表元素本身包含分号(例如从文件读取的内容),需要谨慎处理。通常建议在设置列表时使用引号包裹包含特殊字符的元素:set(LIST "a;b" c) 会生成两个元素:"a;b" 和 c。

常见误区:引号与分号的分隔规则

写法实际存储值列表元素个数说明
set(L a b c)a;b;c3空格分隔
set(L "a b" c)a b;c2引号内空格保留
set(L "a;b" c)a;b;c2引号内分号不分割
set(L a;b c)a;b;c3未加引号,分号作为分隔符

从上表可以看出,引号是保护分号不被拆分的唯一方式。在传递文件路径时,如果路径包含空格(如 Windows 下的 C:\Program Files\... ),务必使用引号包裹。

3.4 字符串操作与数学表达式

核心原理:string() 命令的丰富子命令

string() 命令提供了超过 20 个子命令,涵盖查找、替换、正则匹配、大小写转换、拼接、截取等。数学表达式通过 math() 命令实现,支持基本的算术运算和位运算。

操作流程:常用字符串操作实战

set(FILENAME "MyProject_v2.1.0.tar.gz")

# 查找子串位置
string(FIND "${FILENAME}" "_v" pos)
message("Position of _v: ${pos}")  # 输出 9

# 替换
string(REPLACE ".tar.gz" ".zip" NEW_FILENAME "${FILENAME}")
message("New: ${NEW_FILENAME}")

# 正则匹配
if(FILENAME MATCHES "v([0-9]+)\\.([0-9]+)\\.([0-9]+)")
    set(MAJOR ${CMAKE_MATCH_1})
    set(MINOR ${CMAKE_MATCH_2})
    set(PATCH ${CMAKE_MATCH_3})
    message("Version: ${MAJOR}.${MINOR}.${PATCH}")
endif()

# 转小写
string(TOLOWER "${FILENAME}" LOWERCASE_NAME)

# 截取子串
string(SUBSTRING "${FILENAME}" 0 9 PREFIX)  # 取前9个字符

案例拆解:版本号比较与数学计算

在构建系统中,经常需要比较版本号或计算对齐大小。CMake 的 if() 支持 VERSION_LESS 等操作符,但有时需要手动解析:

set(CURRENT_VERSION "2.1.0")
set(MIN_REQUIRED "2.2.0")

# 使用 VERSION_LESS 直接比较
if(CURRENT_VERSION VERSION_LESS MIN_REQUIRED)
    message(FATAL_ERROR "Version too old!")
endif()

# 数学计算:计算对齐到 16 字节的大小
set(SIZE 100)
math(EXPR ALIGNED_SIZE "(${SIZE} + 15) / 16 * 16")
message("Aligned size: ${ALIGNED_SIZE}")  # 输出 112

# 位运算示例
math(EXPR FLAGS "0x01 | 0x04")
message("Flags: ${FLAGS}")  # 输出 5
风险提示:math() 命令的表达式求值基于 C 语言的整数运算。当进行除法时,结果会向零取整(截断)。如果需要进行浮点运算,CMake 原生不支持,通常需要借助外部工具或使用字符串处理模拟。此外,math() 中的变量引用必须显式使用 ${},否则会被当作字面量。

3.5 综合实战:构建一个带版本信息的可执行文件

下面通过一个完整的示例,将本章语法串联起来。假设我们需要构建一个 C++ 程序,并在编译时注入版本号和构建时间。

cmake_minimum_required(VERSION 3.16)
project(VersionDemo CXX)

# 定义版本号(普通变量)
set(VERSION_MAJOR 1)
set(VERSION_MINOR 4)
set(VERSION_PATCH 2)

# 拼接完整版本号
set(FULL_VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")

# 获取构建时间(使用 execute_process 调用系统命令)
execute_process(
    COMMAND date "+%Y-%m-%d %H:%M:%S"
    OUTPUT_VARIABLE BUILD_TIME
    OUTPUT_STRIP_TRAILING_WHITESPACE
)

# 生成配置文件
configure_file(
    ${CMAKE_CURRENT_SOURCE_DIR}/version.h.in
    ${CMAKE_CURRENT_BINARY_DIR}/version.h
    @ONLY
)

add_executable(demo main.cpp)
target_include_directories(demo PRIVATE ${CMAKE_CURRENT_BINARY_DIR})

# 打印构建信息
message(STATUS "Building Version: ${FULL_VERSION}")
message(STATUS "Build Time: ${BUILD_TIME}")

对应的 version.h.in 模板文件:

#pragma once
#define VERSION_MAJOR @VERSION_MAJOR@
#define VERSION_MINOR @VERSION_MINOR@
#define VERSION_PATCH @VERSION_PATCH@
#define FULL_VERSION "@FULL_VERSION@"
#define BUILD_TIME "@BUILD_TIME@"

在这个案例中,我们使用了普通变量存储版本号,通过 string() 拼接字符串,利用 execute_process() 获取外部命令输出,最后通过 configure_file() 将变量值注入到头文件中。整个过程展示了 CMake 语法在实际工程中的典型应用。

3.6 常见误区与总结复盘

误区后果正确做法
忘记使用 ${} 引用变量命令收到字面量字符串除 if() 外,一律显式使用 ${VAR}
在列表元素中未加引号保护分号列表被意外拆分包含特殊字符的元素使用双引号包裹
混淆普通变量与缓存变量配置无法持久化或意外覆盖用户可选项使用 CACHE,内部变量使用普通变量
在子目录中直接修改父目录变量修改不生效使用 PARENT_SCOPE 或重新设计传递方式
依赖浮点数学运算math() 只支持整数使用字符串处理或外部工具

本章内容覆盖了 CMake 语法的核心骨架。掌握这些基础后,你已经能够读懂绝大多数 CMakeLists.txt 文件,并能够编写结构清晰的构建脚本。下一阶段的学习将围绕控制流(if/foreach/while)和函数定义展开,这些内容将帮助你进一步抽象和复用构建逻辑。