开篇:从“识字”到“造句”
在前一节中,我们成功让第一个 CMake 项目跑了起来。相信你已经体验到了那种“引擎点火”的快感。但如果你打开生成的 CMakeLists.txt,可能还会有点懵:这些命令、括号、变量和字符串,到底遵循着怎样的规则?
如果把写 C++ 代码比作写作文,那么上一节我们只学会了“按模板抄写一篇范文”。而这一节,我们要正式学习 CMake 这门“语言”的语法基础——从如何写注释、定义变量,到条件判断、循环,再到函数和作用域。只有掌握了这些,你才能真正从“抄写”走向“创作”。
注释与消息输出:和你的构建系统对话
写好注释,方便未来的自己
CMake 的注释和 Shell、Python 类似,使用 # 开头。CMake 没有块注释(不像 /* */),所以多行注释需要每行都加上 #:
# 这是一个单行注释
# 这是多行注释的第一行
# 这是多行注释的第二行
cmake_minimum_required(VERSION 3.20)
message():构建系统的“对讲机”
调试 CMake 脚本最常用的命令就是 message()。它不仅能打印文本,还能指定日志级别,控制信息的严重程度:
FATAL_ERROR:立即终止 CMake 配置和生成过程。SEND_ERROR:记录错误,但继续处理,最终跳过生成阶段。WARNING:发出警告,不影响继续构建。AUTHOR_WARNING:针对项目作者的警告(可被-Wno-dev静默)。STATUS:用户可能感兴趣的信息(最常用,会显示在终端)。VERBOSE、DEBUG、TRACE:更详细的信息,需配合--log-level才能看到。
cmake_minimum_required(VERSION 3.20)
project(LearnCMake)
set(PROJECT_VERSION "1.0.0")
# 输出普通状态信息
message(STATUS "项目: ${PROJECT_NAME}")
message(STATUS "版本: ${PROJECT_VERSION}")
# 输出警告
message(WARNING "这是一个警告,构建仍会继续")
# 致命错误(取消注释下一行会导致配置失败)
# message(FATAL_ERROR "遇到致命错误,终止配置!")
💡 小贴士:在日常开发中,message(STATUS "...") 是你的好朋友。当变量值不符合预期时,先 message 打印出来看看,是最直接的调试手段。
变量定义与引用:CMake 世界的“储物柜”
set 与 unset
CMake 的变量就像储物柜的标签。使用 set 把值放进去,使用 unset 清空:
set(MY_NAME "CMake小白")
set(MY_NUMBER 42)
# 引用变量使用 ${变量名}
message(STATUS "你好, ${MY_NAME}")
message(STATUS "数字是: ${MY_NUMBER}")
# 取消定义
unset(MY_NAME)
message(STATUS "取消后: ${MY_NAME}") # 输出空字符串
变量本质是字符串
CMake 中所有变量本质上都是字符串。即使你把 42 赋值给变量,它存的还是字符串 "42"。因此,进行数学运算时需要使用 math(EXPR ...) 命令,而不能像 C++ 那样直接写 a + b。
引号的秘密:字符串 vs 列表
引号在 CMake 中非常关键,它决定了值是纯字符串还是会被解析为列表:
# 不加引号:会被视为一个包含两个元素的列表(分号是分隔符)
set(LIST_A a b c)
# 加引号:被视为一个完整的字符串
set(STR_A "a b c")
message(STATUS "LIST_A: ${LIST_A}") # 输出: a;b;c(内部是分号分隔)
message(STATUS "STR_A: ${STR_A}") # 输出: a b c
这是一个经典陷阱:当你想传递一个带有空格的路径时,一定要记得加引号!
列表(List)操作:增删改查一站式
既然不加引号的空格会被当作列表分隔符,那我们就需要学会操作列表。CMake 的列表本质上是分号(;)分隔的字符串。
set(SRCS main.cpp utils.cpp helper.cpp)
# 追加元素
list(APPEND SRCS app.cpp)
# 移除元素
list(REMOVE_ITEM SRCS utils.cpp)
# 获取长度
list(LENGTH SRCS len)
message(STATUS "源文件数量: ${len}")
# 获取指定下标的元素(从0开始)
list(GET SRCS 0 first_file)
message(STATUS "第一个文件: ${first_file}")
# 查找元素下标,找不到返回 -1
list(FIND SRCS "app.cpp" idx)
# 排序
list(SORT SRCS)
在后续章节中,我们会经常用 list(APPEND ...) 来动态收集源文件,这个命令务必记牢。
字符串操作:文本处理的“瑞士军刀”
CMake 提供了强大的 string() 命令,支持替换、查找、正则匹配、大小写转换等。
常用操作示例
set(TEXT "Hello CMake World")
# 1. 替换
string(REPLACE "CMake" "Build System" RESULT ${TEXT})
message(STATUS "替换后: ${RESULT}")
# 2. 查找位置
string(FIND ${TEXT} "CMake" POS)
message(STATUS "'CMake' 的位置: ${POS}")
# 3. 正则匹配提取
set(VERSION_STRING "版本号: v1.2.3")
string(REGEX MATCH "v([0-9]+\.[0-9]+\.[0-9]+)" VERSION ${VERSION_STRING})
message(STATUS "提取版本: ${VERSION}")
# 4. 大小写转换
string(TOUPPER "hello" UPPER_TEXT)
string(TOLOWER "WORLD" LOWER_TEXT)
正则表达式在处理版本号、提取路径中的文件名时非常有用。注意 CMake 的正则语法和 Perl 类似,写反斜杠时要记得转义(\d)。
条件判断:让构建“聪明”起来
程序离不开分支判断,CMake 使用 if/elseif/else/endif 结构:
set(ENABLE_TESTS ON)
set(BUILD_VERSION "1.5.0")
if(ENABLE_TESTS)
message(STATUS "测试功能已开启")
elseif(BUILD_VERSION VERSION_GREATER "1.0.0")
message(STATUS "版本大于 1.0,但测试未开启")
else()
message(STATUS "测试功能已关闭")
endif()
常见条件表达式
if(VAR):变量已定义且不是0、OFF、FALSE、""、NOTFOUND等假值。if(NOT VAR):取反。if(VAR1 AND VAR2)、if(VAR1 OR VAR2):逻辑与/或。if(EXISTS path):文件或目录是否存在。if(IS_DIRECTORY path):是否是目录。if(CMAKE_SYSTEM_NAME STREQUAL "Linux"):字符串精确匹配。if(VERSION VERSION_GREATER "2.0"):版本号比较(比字符串比较更智能)。
⚠️ 一个极易踩的坑
在 if() 中,CMake 有一个特殊规则:如果你直接写变量名(不加 ${}),它会检查该变量是否存在;如果你加了 ${},它会检查变量值所代表的变量。看下面这个例子:
set(VAR "OTHER")
set(OTHER "YES")
# 这里检查的是 VAR 是否定义 -> 真
if(VAR)
message(STATUS "VAR 为真")
endif()
# 这里 ${VAR} 展开为 OTHER,然后检查 OTHER 是否定义 -> 真
if(${VAR})
message(STATUS "${VAR} 为真")
endif()
unset(OTHER)
# 现在 OTHER 不存在了,${VAR} 展开为 OTHER,检查 OTHER -> 假
if(${VAR})
message(STATUS "这次不会执行")
endif()
对于初学者,我的建议是:在 if() 中判断变量是否定义时,尽量不加 ${},比如 if(ENABLE_TESTS),这样语义最清晰。
循环结构:批量处理的神器
foreach:遍历列表
foreach 是 CMake 中最常用的循环,特别适合遍历文件列表:
set(SOURCES main.cpp utils.cpp app.cpp)
foreach(src IN LISTS SOURCES)
message(STATUS "正在处理源文件: ${src}")
endforeach()
# 也可以直接写(IN LISTS 是推荐写法)
foreach(src ${SOURCES})
message(STATUS "源文件: ${src}")
endforeach()
你还可以用 RANGE 生成数字序列,类似 Python 的 range():
# 输出 0 到 5
foreach(i RANGE 5)
message(STATUS "i = ${i}")
endforeach()
# 输出 1 到 10,步长 2
foreach(i RANGE 1 10 2)
message(STATUS "i = ${i}")
endforeach()
while:条件循环
set(counter 0)
while(counter LESS 5)
message(STATUS "计数器: ${counter}")
math(EXPR counter "${counter} + 1") # 数学运算必须用 math()
endwhile()
循环控制
CMake 3.2 以上支持 break() 和 continue(),和 C++ 里的用法一样。
函数与宏:代码复用的基石
当你的项目变大,总有一些配置逻辑需要复用。CMake 提供了 function 和 macro 两种封装方式。
function:带作用域的“真函数”
function(print_summary name version)
message(STATUS "项目名称: ${name}")
message(STATUS "项目版本: ${version}")
# 访问超出声明数量的参数
message(STATUS "所有参数: ${ARGV}")
message(STATUS "额外参数: ${ARGN}")
endfunction()
print_summary("MyApp" "2.0" "额外信息1" "额外信息2")
在 function 内部,CMake 自动为你准备了几个特殊变量:
ARGC:传入的参数总数。ARGV:所有参数的列表。ARGN:超出显式声明参数名(name、version)的额外参数列表。
macro:文本替换的“伪函数”
macro 的语法和 function 几乎一样,但它本质上是文本替换(类似 C 语言宏),不会创建新的变量作用域:
macro(set_default VAR VALUE)
if(NOT DEFINED ${VAR})
set(${VAR} ${VALUE})
endif()
endmacro()
set_default(MY_OPTION ON)
message(STATUS "MY_OPTION = ${MY_OPTION}")
返回值怎么搞?
CMake 函数没有 C++ 那种 return 语句。通常有两种方式“返回”结果:
- 设置父作用域变量:使用
set(变量名 值 PARENT_SCOPE)。 - 设置缓存变量:使用
set(变量名 值 CACHE INTERNAL "描述")。
function(get_greeting RESULT_VAR)
set(${RESULT_VAR} "Hello from function!" PARENT_SCOPE)
endfunction()
get_greeting(msg)
message(STATUS "收到消息: ${msg}")
注意:必须在函数内部用 PARENT_SCOPE,否则变量只在函数内部可见。
作用域与变量传递:理解 CMake 的“地盘规则”
这是本节最重要、也最抽象的概念。CMake 中有三种主要的作用域,理解它们能帮你避免 80% 的变量相关的诡异 Bug。
1. 函数作用域(Function Scope)
调用 function() 时会创建一个新的作用域。在函数内用 set() 定义的变量,默认只在函数内有效,不会影响外部。
set(global_var "我是全局的")
function(test_scope)
set(local_var "我只在函数内")
set(global_var "我试图覆盖全局变量") # 这只会创建一个局部变量,不影响外部
endfunction()
test_scope()
message(STATUS "global_var = ${global_var}") # 仍是"我是全局的"
message(STATUS "local_var = ${local_var}") # 空,因为外部访问不到
如果你想修改外部的变量,必须使用 PARENT_SCOPE:
function(modify_var)
set(global_var "我真的修改了" PARENT_SCOPE)
endfunction()
modify_var()
message(STATUS "global_var = ${global_var}") # 变为"我真的修改了"
2. 目录作用域(Directory Scope)
当你在父目录的 CMakeLists.txt 中调用 add_subdirectory(child) 时,子目录会继承父目录中已定义的所有变量。但反过来不行:子目录中 set 的变量不会影响父目录,除非显式传回。
# 父目录 CMakeLists.txt
set(PARENT_MSG "来自父目录的消息")
add_subdirectory(sub)
message(STATUS "子目录设置的值: ${CHILD_MSG}") # 如果子目录没用 PARENT_SCOPE,这里为空
# sub/CMakeLists.txt
message(STATUS "在子目录收到: ${PARENT_MSG}") # 可以正常访问
set(CHILD_MSG "来自子目录" PARENT_SCOPE) # 必须用 PARENT_SCOPE 才能传回父目录
3. 缓存作用域(Cache Scope)
缓存变量是“持久化”的,它们被存储在 CMakeCache.txt 中,即使删除构建目录中的其他文件,缓存依然存在。你可以通过命令行修改它们:
set(BUILD_SHARED_LIBS OFF CACHE BOOL "是否构建动态库")
set(MY_CUSTOM_PATH "/usr/local" CACHE PATH "自定义安装路径")
缓存变量的查找优先级是:先查普通变量,再查缓存变量。如果一个普通变量和缓存变量同名,优先使用普通变量。
变量查找顺序总结
当 CMake 遇到 ${VAR} 时,它会按以下顺序查找:
- 当前函数作用域(如果在 function 内部)。
- 当前目录作用域。
- 父目录作用域(逐层向上)。
- 缓存作用域(Cache)。
找到了就停止,所以局部变量会“遮蔽”上层变量。
本节小结
在这一节中,我们系统学习了 CMakeLists.txt 的核心语法:
- 用
#写注释,用message()不同级别输出调试信息。 - 用
set/unset管理变量,牢记${VAR}引用规则。 - 列表本质是分号分隔的字符串,
list()命令负责增删改查。 string()能完成替换、查找、正则等文本处理任务。if判断和foreach/while循环让脚本拥有逻辑能力。function和macro帮你封装复用逻辑,注意PARENT_SCOPE传值。- 三种作用域(函数、目录、缓存)决定了变量的生命周期和可见性。
掌握这些语法后,你已经能读懂大部分简单的 CMakeLists.txt 了。但CMake的精髓不在于“脚本语法”,而在于目标(Target)导向的构建思维。从下一章开始,我们将进入 CMake 最核心的部分——目标与构建系统,学习如何用现代 CMake 的方式优雅地组织你的 C++ 项目。准备好了吗?


没有回复内容