3、CMake基础语法:注释、命令、变量、数据类型、列表、字符串操作与数学表达式
CMake 并非传统意义上的编程语言,而是一种构建脚本语言。它的核心使命是描述“如何从源代码生成构建系统”(如 Makefile、Ninja 文件或 Visual Studio 工程)。然而,要驾驭复杂的跨平台项目,仅靠零散的 set() 和 add_executable() 是远远不够的。本章将系统拆解 CMake 语言的底层语法单元:从注释规范、命令调用机制,到变量作用域与缓存变量,再到列表的扁平化存储特性、字符串的丰富操作接口以及数学表达式的计算能力。理解这些基础语法,是后续编写可维护、可扩展、可调试的 CMake 工程的关键基石。
上图展示了 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})会在命令执行前被展开。如果变量未定义,展开结果为空字符串,这通常不会报错,但可能导致逻辑错误。
操作流程:参数展开的优先级
- 解析器读取一行命令,识别命令名与参数列表。
- 对每个参数进行变量展开:
${VAR}被替换为 VAR 的值。 - 若参数中包含转义字符(
\n,\t,\"等),则进行转义处理。 - 若参数被双引号包裹,则其中的分号(
;)和空格不再作为列表分隔符,而是作为字符串字面量的一部分。 - 最终将处理后的参数列表传递给命令执行。
${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;c | 3 | 空格分隔 |
set(L "a b" c) | a b;c | 2 | 引号内空格保留 |
set(L "a;b" c) | a;b;c | 2 | 引号内分号不分割 |
set(L a;b c) | a;b;c | 3 | 未加引号,分号作为分隔符 |
从上表可以看出,引号是保护分号不被拆分的唯一方式。在传递文件路径时,如果路径包含空格(如 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)和函数定义展开,这些内容将帮助你进一步抽象和复用构建逻辑。