12、CMake高级特性:自定义命令、自定义目标、生成器表达式与compile_commands.json
说实话,CMake 用久了你会发现,它最强大的地方不是帮你编译几个源文件。真正让 CMake 从「好用」变成「不可或缺」的,是它那套高级特性。今天我就带你把这四个硬骨头啃下来:自定义命令、自定义目标、生成器表达式,还有那个让 IDE 和静态分析工具爱不释手的 compile_commands.json。
12.1 自定义命令:add_custom_command
先问个问题:如果你的构建过程需要执行一个外部脚本、生成一份文档、或者跑一个代码生成器,CMake 能搞定吗?
能。靠的就是 add_custom_command。
这个命令有两种用法:一种是为某个目标添加额外的构建步骤,另一种是生成一个文件。我个人更常用的是第二种——生成文件。
add_custom_command(
OUTPUT generated.h
COMMAND python3 ${CMAKE_SOURCE_DIR}/scripts/gen_header.py
DEPENDS ${CMAKE_SOURCE_DIR}/scripts/gen_header.py
COMMENT "Generating generated.h..."
)
你看,这个命令告诉 CMake:「嘿,generated.h 这个文件不是现成的,你得跑个 Python 脚本才能生成它。」然后你可以在 add_executable 里直接引用这个文件,CMake 会自动管理它的依赖关系。
我的经验:我在一个嵌入式项目中遇到过需要根据 Excel 表格生成 C 语言配置头文件的情况。当时就是用 add_custom_command 配合 Python 脚本搞定的。每次表格更新,CMake 检测到依赖变化,自动重新生成头文件。省了多少手动复制粘贴的功夫啊。
还有一种常见场景:编译后执行一些操作,比如复制库文件到指定目录。
add_custom_command(
TARGET mylib POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:mylib> ${CMAKE_BINARY_DIR}/lib/
COMMENT "Copying mylib to lib directory..."
)
这里 POST_BUILD 表示在目标构建完成后执行。还有 PRE_BUILD 和 PRE_LINK,不过说实话,POST_BUILD 是我用得最多的。
注意:如果你用 OUTPUT 形式的 add_custom_command,一定要确保这个命令确实生成了那个文件。我曾经犯过一个错误:脚本执行成功了,但文件没写出来,结果后续编译直接报错。排查了半天才发现是脚本里路径写死了。
12.2 自定义目标:add_custom_target
有时候你不需要生成一个具体的文件,只是想定义一个「动作」——比如运行测试、打包文档、清理临时文件。这时候就该 add_custom_target 出场了。
add_custom_target(
run_tests
COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure
DEPENDS myapp
COMMENT "Running all tests..."
)
这个目标没有输出文件,它只是一个「标签」。你运行 make run_tests 的时候,CMake 会先确保 myapp 是最新的,然后执行测试命令。
自定义目标有个特点:它默认不会被构建。除非你显式指定它,或者用 ALL 关键字把它加到默认构建中。
add_custom_target(
docs ALL
COMMAND doxygen ${CMAKE_SOURCE_DIR}/Doxyfile
COMMENT "Generating documentation..."
)
加了 ALL 之后,每次构建都会生成文档。嗯,不过我个人不太建议这么做——文档生成通常比较慢,按需触发更合理。
核心区别:
add_custom_command生成文件,可以被其他目标依赖add_custom_target定义动作,没有输出文件- 两者经常配合使用:用
add_custom_target驱动一组add_custom_command
12.3 生成器表达式:Generator Expressions
生成器表达式是 CMake 里最容易被忽视、但最强大的特性之一。说白了,它就是一种「延迟求值」的机制——在 CMake 配置阶段不计算,等到生成构建系统的时候才计算。
语法很简单:$<...>。里面可以放条件判断、配置信息、目标属性等等。
target_compile_definitions(
myapp PRIVATE
$<$<CONFIG:Debug>:DEBUG_MODE>
$<$<CONFIG:Release>:NDEBUG>
)
这段代码的意思是:如果是 Debug 配置,定义 DEBUG_MODE;如果是 Release 配置,定义 NDEBUG。你想想看,如果没有生成器表达式,你可能得写两个 target_compile_definitions,分别放在 if(CMAKE_BUILD_TYPE STREQUAL "Debug") 里面。多麻烦。
生成器表达式还能用在很多地方:
- 条件链接库:
$<$<PLATFORM_ID:Linux>:-lrt> - 获取目标属性:
$<TARGET_FILE:mylib> - 布尔逻辑组合:
$<$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Linux>>:SPECIAL_FLAG>
避坑指南:我曾经在 add_custom_command 的 COMMAND 里直接用了生成器表达式,结果发现它根本没展开。后来才意识到:COMMAND 里的生成器表达式只在某些生成器(比如 Ninja)下才支持。Makefile 生成器就不行。所以如果你要跨平台,最好用 generator-expressions 只在 COMPILE_DEFINITIONS、LINK_LIBRARIES 这些标准属性里用。
12.4 compile_commands.json
这个文件,说白了就是 CMake 给你的一份「编译命令清单」。它记录了每个源文件是怎么被编译的——编译器路径、编译选项、包含路径、宏定义,一清二楚。
怎么生成?简单:
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
或者在你的 CMakeLists.txt 里设置:
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
然后你就会在构建目录里看到一个 compile_commands.json 文件。它的结构大概是这样的:
[
{
"directory": "/home/user/project/build",
"command": "/usr/bin/g++ -c -I../include -DDEBUG -o main.o ../src/main.cpp",
"file": "../src/main.cpp"
},
{
"directory": "/home/user/project/build",
"command": "/usr/bin/g++ -c -I../include -DDEBUG -o utils.o ../src/utils.cpp",
"file": "../src/utils.cpp"
}
]
这个文件有什么用?用处大了去了。
- IDE 支持:VS Code、CLion、Qt Creator 都能读取这个文件,给你提供精准的代码补全和跳转
- 静态分析:clang-tidy、cppcheck 可以直接用这个文件分析你的代码,不需要你手动配置包含路径
- 代码索引:像 cquery、ccls 这些语言服务器,靠的就是
compile_commands.json来理解你的项目结构
我的建议:不管项目大小,都把这个选项打开。它不会影响构建速度,但能让你在代码编辑和静态分析上省下大量时间。我接手过一个遗留项目,没有这个文件,IDE 里全是红色波浪线。打开之后,世界清净了。
12.5 知识体系总览
这四个特性之间的关系,我画了一张图帮你理一理:
这四个特性,每一个单独拿出来都能解决一类问题。但真正厉害的是把它们组合起来用。比如:用生成器表达式控制自定义命令的行为,然后把自定义命令的输出文件通过 compile_commands.json 暴露给 IDE。这样一来,你的构建系统就不再是「死」的,而是活的、智能的。
嗯,今天就先聊到这里。这些特性你用得越多,越能体会到 CMake 设计的精妙之处。