CMake安装阶段自定义操作全解析:install(CODE)与install(SCRIPT)实战指南
2026/9/7 1:26:37 网站建设 项目流程

CMake 的安装阶段,很多人把它当成一个可有可无的收尾动作:build 完,install 一下,把文件拷过去就算结束。但真到了需要“安装阶段自定义操作”的项目上,比如安装时写配置、写版本号、生成卸载脚本、拷贝动态依赖,最常见的做法反而是把逻辑一股脑塞进execute_process,结果每次 configure 就提前跑一遍,install 的时候却什么都没有;还有人把操作写进add_custom_command(TARGET ... POST_BUILD),文件明明已经生成,安装目录里却永远找不到。这个问题的根子在于:配置阶段、构建阶段、安装阶段三个阶段各有各的执行时机和使用边界,放错位置不是“晚一点跑”的问题,而是根本不会在预期时间跑。本文围绕安装阶段自定义操作,讲清楚install(CODE)install(SCRIPT)两个正规入口,再拆几个高频坑,帮你在下一次cmake --install时不再对着空目录发呆。

1. 先分清楚阶段:配置、构建、安装,哪些操作最容易放错位置

很多人写 CMake 的时候,对阶段的概念是模糊的。反正都是“在某个时刻执行一段命令”,为什么不顺手写进execute_process?因为execute_process的执行时机和安装阶段差了一个完整的构建流程,这个差距才是所有问题的来源。

1.1 三个阶段的触发方式和本质区别

把三个阶段先摆出来,后面的判断就全靠这张表。

阶段触发命令执行时间点典型适合的操作
配置阶段cmake -S . -B build生成构建系统时查找依赖、检查编译器、生成构建规则、检查平台特性
构建阶段cmake --build build编译链接时编译代码、生成中间文件、POST_BUILD 轻量处理
安装阶段cmake --install build部署或打包时拷贝产物到安装前缀、写配置、收集动态依赖、生成卸载信息

配置阶段负责“摸清环境”,构建阶段负责“产出文件”,安装阶段负责“把产物变成一套可运行、可部署的东西”。三者的输入和输出差异非常大,同一个操作放在不同阶段,效果完全不一样。

cmake --install这个命令是 CMake 3.15 之后才有的便捷写法。早期项目一般用make installninja install,本质都是执行构建系统里的 install 规则,最后走的还是同一个安装脚本。

1.2 execute_process 为什么是“提前执行”重灾区

execute_process在 configure 阶段执行,这一点经常被忽略。看一个很典型的错误写法:

execute_process(COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_CURRENT_SOURCE_DIR}/config.ini" "${CMAKE_INSTALL_PREFIX}/etc/config.ini" )

这段代码在cmake -S . -B build的时候就会执行。问题是:

  • 此时CMAKE_INSTALL_PREFIX可能还是默认值,你真正安装时如果用了--prefix /opt/app,文件不会跟着变。
  • 项目重新 configure 时,这段代码会再次执行,但安装目录不会自动清理,容易出现旧文件残留。
  • 如果 config.ini 本身是由构建阶段生成的,configure 时这个文件根本不存在,拷贝必然失败。

这类写法在单机测试时往往看不出问题,因为默认 prefix 下文件刚好能生成。一旦换到部署脚本、打包环境、交叉编译环境,立刻露馅。

1.3 POST_BUILD 也不是安装阶段

有人会说:那我不放 configure,放到构建后总行了吧?

add_custom_command(TARGET demo POST_BUILD COMMAND "${CMAKE_COMMAND}" -E copy "${CMAKE_CURRENT_BINARY_DIR}/demo_generated.ini" "${CMAKE_INSTALL_PREFIX}/etc/demo.ini" )

POST_BUILD 的执行时机是 target 构建完成之后、安装流程开始之前。它确实比 configure 晚,但文件还在 build 目录,安装流程还没启动。你做的只是把文件从 build 目录复制到 install prefix,如果后续 install 规则里根本没有这一项,安装目录自然还是空。

POST_BUILD 适合做什么?适合生成构建产物,或者对构建产物做校验、重命名、计算哈希,然后把结果放在 build 目录里,等待后续install(FILES ...)引用。它不该承担“把文件部署到最终位置”的职责。

1.4 安装阶段到底应该管什么

集合一下真正的安装阶段职责,你就能判断自己的操作该不该放这里:

  • 把 target 和文件安装到CMAKE_INSTALL_PREFIX下的正确目录
  • 安装时生成配置文件、版本文件、环境检查脚本
  • 收集可执行文件依赖的动态库并一起安装
  • 生成卸载清单和卸载脚本
  • 按组件区分安装内容,做--component选择性安装
  • 安装完成后打印部署说明、创建数据目录、设置文件权限

这些操作都有一个共同点:它们依赖“最终安装位置”和“最终产物”。只要你的操作依赖这两样东西,就不能放在配置阶段或构建阶段里硬跑。

2. 两个正规入口:install(CODE) 和 install(SCRIPT)

CMake 之所以专门提供install(CODE)install(SCRIPT),就是为了把“任意自定义操作”挂载到安装阶段。先看最简单的用法。

2.1 install(CODE):把 CMake 代码延迟到安装时执行

install(CODE [[ message(STATUS "Installing demo extras to ${CMAKE_INSTALL_PREFIX}") file(MAKE_DIRECTORY "${CMAKE_INSTALL_PREFIX}/share/demo") ]])

这里的[[ ]]是 CMake 的括号参数语法。它会把里面的内容当作原始字符串处理,configure 阶段不展开${...},而是原样写进 build 目录里的cmake_install.cmake。等cmake --install执行的时候,这段代码里的${CMAKE_INSTALL_PREFIX}才真正展开。

也就是说:

  • configure 阶段:只把代码“写入安装脚本”
  • install 阶段:才真正“执行这段代码”

这就是它和execute_process最本质的区别。

2.2 install(SCRIPT):把独立 .cmake 脚本嵌进安装阶段

代码量一多,塞进install(CODE)可读性会变得很差。这时候用install(SCRIPT)更合适。

先准备一个模板文件cmake/install_post.cmake.in

message(STATUS "Post install for @PROJECT_NAME@") file(MAKE_DIRECTORY "${CMAKE_INSTALL_PREFIX}/share/@PROJECT_NAME@") file(WRITE "${CMAKE_INSTALL_PREFIX}/share/@PROJECT_NAME@/installed.txt" "@PROJECT_VERSION@\n")

然后在 CMakeLists.txt 里处理再挂载:

configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_post.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/install_post.cmake" @ONLY ) install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/install_post.cmake")

关键点在于configure_file@PROJECT_NAME@@PROJECT_VERSION@会在 configure 阶段被替换成固定字符串,而脚本里的${CMAKE_INSTALL_PREFIX}会保留到 install 阶段展开。这样就同时拿到了“配置阶段的值”和“安装阶段的动态前缀”。

2.3 为什么推荐 CMake 脚本而不是 shell 或 bat

新手经常会想在安装阶段执行bash script.shcmd /c xxxx。不是不行,但麻烦很多:

  • 平台不通用。Windows 上没有 bash,Linux 上又没有 cmd。
  • 环境变量、PATH、权限在各平台下行为不一致。
  • shell 脚本里的变量和 CMake 变量互不相通,容易传参出错。
  • 安装阶段被包进 CMake 脚本里,打包工具和 IDE 才能正确识别和执行。

统一用 CMake 脚本配合file()file(INSTALL)file(REMOVE)configure_file()execute_process()这些命令,跨平台行为会稳定很多。

2.4 什么时候用 CODE,什么时候用 SCRIPT

场景推荐方式原因
几行临时操作,比如创建目录、打印信息install(CODE [[ ... ]])改动小,直接写在 CMakeLists 里容易追踪
逻辑较长,需要 if/foreach/函数install(SCRIPT ...)独立文件便于维护和测试
需要引用 configure 时的值SCRIPT + configure_file先烤值,后执行
需要读取 install 脚本上下文变量CODE 或 SCRIPT 都可以两者都在 cmake_install.cmake 上下文里运行

我的习惯是:5 行以内用install(CODE),超过 5 行就抽成独立.cmake模板。

3. 实战:四个高频安装阶段自定义场景

场景比概念更容易建立判断。下面四个场景我都在项目里实际遇到过,代码可以直接当作模板改。

3.1 场景一:安装时写版本信息和构建参数

部署系统经常需要知道“当前目录里的这包程序是什么版本”。可以在 install 阶段生成一个 version.txt。

configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_version.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/install_version.cmake" @ONLY ) install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/install_version.cmake")

install_version.cmake.in内容:

file(WRITE "${CMAKE_INSTALL_PREFIX}/share/demo/version.txt" "project=@PROJECT_NAME@\nversion=@PROJECT_VERSION@\nbuild_type=@CMAKE_BUILD_TYPE@\n")

这样做的好处是,版本号是 configure 阶段确定下来的,但写入文件这件事发生在 install 阶段。如果用户用--prefix /tmp/stage安装,version.txt 就会出现在/tmp/stage/share/demo/下面,而且版本信息和构建类型完全匹配。

3.2 场景二:安装时生成应用默认配置

有些配置不能随源码分发,需要在安装时根据目标路径生成。比如程序安装到哪里,配置里的 base_dir 就要指向哪里。

install(CODE [[ file(WRITE "${CMAKE_INSTALL_PREFIX}/etc/demo.conf" "[demo]\nbase_dir=${CMAKE_INSTALL_PREFIX}\nlog_level=info\n") ]])

这段代码直接写在 CMakeLists 里,优点是简单直观。注意${CMAKE_INSTALL_PREFIX}用的是括号参数,install 阶段拿到的是真正的前缀,而不是 configure 时段写死的老值。

如果配置项很多,还是建议用 3.1 的configure_file模板方式,把复杂逻辑放到独立脚本里。

3.3 场景三:安装时收集并拷贝动态依赖

这个功能是很多人最想要的,但坑也最多。CMake 3.21 之后提供了file(GET_RUNTIME_DEPENDENCIES),专门用来在安装阶段解析可执行文件或库依赖的动态库。注意,这个命令只能用在安装脚本上下文里,平时在 configure 阶段调用会直接报错。

install(CODE [[ if(WIN32) file(GET_RUNTIME_DEPENDENCIES RESOLVED_DEPENDENCIES_VAR _dlls UNRESOLVED_DEPENDENCIES_VAR _missing EXECUTABLES "${CMAKE_INSTALL_PREFIX}/bin/demo.exe" PRE_EXCLUDE_REGEXES "api-ms-*|ext-ms-*" POST_EXCLUDE_REGEXES ".*system32/.*\\.dll" ) foreach(_dll IN LISTS _dlls) file(INSTALL DESTINATION "${CMAKE_INSTALL_PREFIX}/bin" TYPE SHARED_LIBRARY FILES "${_dll}") endforeach() if(_missing) message(WARNING "Missing dependencies: ${_missing}") endif() endif() ]])

几点提醒:

  • 如果你用的是 CMake 3.21 以下的版本,这个命令不存在,需要另想办法。
  • Windows 系统库一般通过PRE_EXCLUDE_REGEXES排除,否则会尝试拷贝一堆系统 DLL。
  • RESOLVED_DEPENDENCIES_VAR得到的是绝对路径列表,需要自己foreach拷贝。
  • CUDA、MPI 这类大型依赖库,建议先确认工具链和运行时版本,再决定要不要自动收集。配置阶段如果已经出现cmake_cuda_compiler not set这类报错,整个 configure 都过不去,安装阶段自然轮不到。

3.4 场景四:生成卸载脚本

CMake 默认不提供 uninstall target,但我们可以用install_manifest.txt自己生成。安装时会自动在 build 目录生成这个文件,里面记录了本次安装写入了哪些文件。

先写cmake/uninstall.cmake.in

if(NOT EXISTS "@CMAKE_BINARY_DIR@/install_manifest.txt") message(FATAL_ERROR "Cannot find install_manifest.txt. Run install first.") endif() file(STRINGS "@CMAKE_BINARY_DIR@/install_manifest.txt" _files) foreach(_file IN LISTS _files) if(EXISTS "${_file}" OR IS_SYMLINK "${_file}") message(STATUS "Removing: ${_file}") file(REMOVE "${_file}") endif() endforeach()

再在 CMakeLists 里生成并添加 target:

configure_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/uninstall.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/uninstall.cmake" @ONLY ) add_custom_target(uninstall COMMAND "${CMAKE_COMMAND}" -P "${CMAKE_CURRENT_BINARY_DIR}/uninstall.cmake" USES_TERMINAL )

之后用cmake --build build --target uninstall就能清理已安装的文件。

3.5 验证安装阶段是否真的执行

无论哪种场景,我都建议按这个流程验证:

  1. cmake -S . -B build重新配置。
  2. cmake --build build完成构建。
  3. cmake --install build --prefix /tmp/stage安装到临时目录。
  4. 检查/tmp/stage下的文件是否存在、内容是否正确。
  5. 再跑一次 install,确认没有重复写入或残留旧文件。

用临时 prefix 验证最大的好处是,不会污染系统环境,出问题随时删目录重来。

4. 变量展开时机:双引号、[[ ]] 和 configure_file 到底怎么选

安装阶段自定义操作最容易翻车的不是命令写错,而是变量展开时机不对。下面集中拆一下。

4.1 双引号为什么会在配置阶段提前展开

看这段代码:

install(CODE "message(\"prefix is ${CMAKE_INSTALL_PREFIX}\")")

configure 阶段,CMake 处理这个参数时会直接展开${CMAKE_INSTALL_PREFIX},生成到cmake_install.cmake里的是一段固定字符串,比如/usr/local。之后你再执行cmake --install build --prefix /opt/app,脚本里打印的还是旧的/usr/local,前缀变化完全不会反映到你的自定义操作里。

如果你确实想把某个 configure 阶段的值固定进脚本,用双引号不一定错,但必须注意转义,引号嵌套经常写得很痛苦。

4.2 [[ ]] 为什么适合安装阶段动态变量

[[ ]]是原始字符串,CMake 在 configure 阶段不展开里面的${...},所以这些变量引用会被原样写进cmake_install.cmake,到 install 阶段才展开。

非常适合这些场景:

  • ${CMAKE_INSTALL_PREFIX}
  • ${CMAKE_INSTALL_COMPONENT}
  • ${CMAKE_INSTALL_CONFIG_NAME}
  • ${CMAKE_INSTALL_DO_STRIP}

这些都是 install 阶段才会被正确赋值的变量。

4.3 既有配置阶段值,又有安装阶段前缀时怎么办

最常见的情况是:版本号是 configure 阶段确定的,安装前缀却是 install 阶段用户临时传入的。这时可以用configure_file或者string(CONFIGURE)

模板方式前面已经给过,再演示一次string(CONFIGURE)

set(_script [[ message(STATUS "Install dir is ${CMAKE_INSTALL_PREFIX}") file(WRITE "${CMAKE_INSTALL_PREFIX}/version.txt" "version=@PROJECT_VERSION@\n") ]]) string(CONFIGURE "${_script}" _script @ONLY) install(CODE "${_script}")

string(CONFIGURE ... @ONLY)只替换@PROJECT_VERSION@,不影响${CMAKE_INSTALL_PREFIX}。最后传给install(CODE)的字符串里,版本号已经是固定值,前缀还是变量引用,等 install 阶段再展开。

这个技巧适合不想额外建模板文件的场景;如果逻辑超过几行,还是建议走 3.1 的configure_file + install(SCRIPT)路线,可读性更好。

4.4 路径、引号、转义和 Windows 细节

Windows 上经常出现路径带空格、盘符、反斜杠和引号打架的问题。我的经验是:

  • 路径尽量用正斜杠/,CMake 能正确处理。
  • 使用[[ ]]可以减少引号转义,路径里的双引号不会被“吃”掉。
  • 读外部传入路径时,先用file(TO_CMAKE_PATH)统一格式。
  • 不要为了图省事直接cmd /c copy ...,用${CMAKE_COMMAND} -E copy更安全。

如果安装目录路径里有空格,且你在脚本里手动拼接字符串,一定要给路径加引号,否则 install 阶段执行时会被拆成多个参数。

5. 高频坑点与排错链路

脚本写出来能不能一次跑通,取决于环境。下面这些坑基本属于“看起来是 CMake 语法问题,实际是环境或变量问题”。

5.1 先确认版本和工具链,再谈安装阶段

很多人排查安装阶段问题,上来就盯着 install(CODE) 里的脚本。我建议先确认两边:

一是 CMake 版本。项目要求“3.26 or higher”,你还在用2.8.12,那 configure 阶段就会直接失败,根本轮不到 install。执行cmake --version先对齐版本,再谈后续。

二是编译器。enable_language(CUDA)之后报cmake_cuda_compiler not set,说明 CUDA 编译器没找到。配置阶段的报错必须清零,安装阶段的自定义操作才有意义,否则后面拷贝 CUDA 运行库、处理依赖都是空中楼阁。

三是工具链。交叉编译时,configure 阶段的变量可能来自 toolchain 文件,但这些变量到 install 阶段不一定还存在。凡是依赖目标平台信息的值,都要在 configure 阶段用configure_file烤进脚本。

5.2 DESTDIR、权限、重复安装

安装脚本在打包环境里经常被DESTDIR影响。make install DESTDIR=/tmp/pkg会把文件安装到/tmp/pkg/usr/local/bin这种目录,这不是 bug,是打包流程的正常行为。如果你的自定义脚本没考虑 DESTDIR,安装位置就会和你预期不一致。

权限也是一个常见坑。file(WRITE)生成的文件默认权限不一定满足要求,部署到系统目录时尤其明显。需要执行权限的文件,优先用install(FILES ... PERMISSIONS ...)或者file(INSTALL ... PERMISSIONS ...)明确指定权限,而不是靠 umask 碰运气。

重复安装的问题我在 3.5 提过。file(APPEND)会在二次 install 时重复追加内容,如果这个操作不是幂等的,后果就是配置越来越长、卸载清单越来越乱。除非确实要追加日志,否则优先file(WRITE)覆盖写入。

5.3 排错顺序

遇到安装阶段问题,我一般按这个顺序查:

  1. 先确认 configure 能过:cmake -S . -B build --log-level=DEBUG,看有没有前置报错。
  2. 打开build/cmake_install.cmake,搜你自己的 install(CODE) 片段,确认写入脚本的到底是变量引用还是写死字符串。
  3. 用干净临时目录安装:cmake --install build --prefix /tmp/stage,排除系统目录权限干扰。
  4. 看安装脚本输出的message日志,确认自定义操作是否真的执行了。
  5. 检查目标目录文件是否生成、内容是否符合预期。
  6. 检查install_manifest.txt里是否记录了这些文件。
  7. 再跑一次安装,确认没有重复写入、残留旧文件、权限异常。

5.4 常见问题映射表

现象可能原因先检查什么
文件没有出现在安装目录操作写在 execute_process 或 POST_BUILD全文搜索 execute_process、add_custom_command
安装前缀不对install(CODE) 用了双引号,变量提前展开看 cmake_install.cmake 里是变量还是绝对路径
脚本里变量为空引用了 install 阶段不存在的普通变量改用 configure_file 烤值
Windows 路径带空格导致报错拼接字符串时没有加引号统一用双引号,内部代码尽量用 [[ ]]
重复安装后内容重复file(APPEND) 导致改成 file(WRITE) 或先 REMOVE
找不到 install_manifest.txt还没有成功执行过 install先安装一次,再执行 uninstall target
CMake 版本报错项目要求版本高于本机先执行 cmake --version,再决定升级

6. 进阶:组件安装、交叉编译和长期维护

基础用法跑通之后,再往后就进入生产化阶段。这里的核心不是“能不能执行”,而是“在各种复杂环境下还能不能稳定执行”。

6.1 COMPONENT 组件安装与脚本判断

大型项目经常拆成 runtime、dev、doc 等组件。install(CODE)也支持按组件判断。

install(TARGETS demo RUNTIME DESTINATION bin COMPONENT runtime ) install(CODE [[ if(CMAKE_INSTALL_COMPONENT STREQUAL "runtime") message(STATUS "runtime component: extra setup") file(MAKE_DIRECTORY "${CMAKE_INSTALL_PREFIX}/var/log/demo") elseif(CMAKE_INSTALL_COMPONENT STREQUAL "dev") message(STATUS "dev component: install headers and cmake config") endif() ]])

安装时用cmake --install build --component runtime只装运行组件,用--component dev只装开发组件。这样自定义操作也能跟着组件选择性执行。

6.2 CPack 打包场景要注意

CPack 打包本质上也是执行安装规则,只是把安装目标指向临时打包目录。所以你的install(CODE)install(SCRIPT)在打 DEB、RPM、tar.gz 时也会执行。

这带来两个结果:

  • 自定义脚本必须在“打包目录”这个环境里也能跑,不能假设脚本运行时的当前目录是源码目录。
  • 脚本里如果用了file(WRITE)写到某个固定绝对路径,打出来的包内容就是错的。

建议把自定义安装脚本里所有路径都基于CMAKE_INSTALL_PREFIX,并且保留成 install 阶段动态展开的形式,不要写死到源码目录或 build 目录。

6.3 长期维护建议

几个经验,适合放在项目里长期坚持:

  • 自定义安装脚本保持短小,每个脚本只做一件事。
  • 脚本里尽量多打message(STATUS),部署环境出问题时能快速定位。
  • 所有依赖 configure 阶段参数的脚本,统一走configure_file模板,不要到处散落string(CONFIGURE)
  • 每次修改安装逻辑之后,至少做一次“清空前缀目录重装”,确认没有依赖上一次安装的残留。
  • cmake --install build --prefix /tmp/stage写进 CI 流水线,安装阶段出问题越早发现越好。

我最后给的建议还是那一句:先把单任务的安装流程跑稳,再考虑组件、CPack 和交叉编译。安装阶段的自定义操作本身不难,难的是搞清楚变量到底在哪里展开、脚本到底在哪里执行。只要把配置阶段和安装阶段的边界守住,很多看起来玄乎的报错其实都能在十分钟内定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询