ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CMake安装阶段自定义操作全解析:install(CODE)与install(SCRIPT)实战指南

CMake安装阶段自定义操作全解析:install(CODE)与install(SCRIPT)实战指南 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 install、ninja 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.inmessage(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.sh或cmd /c xxxx。不是不行但麻烦很多平台不通用。Windows 上没有 bashLinux 上又没有 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 projectPROJECT_NAME\nversionPROJECT_VERSION\nbuild_typeCMAKE_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_levelinfo\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.inif(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 里生成并添加 targetconfigure_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 验证安装阶段是否真的执行无论哪种场景我都建议按这个流程验证cmake -S . -B build重新配置。cmake --build build完成构建。cmake --install build --prefix /tmp/stage安装到临时目录。检查/tmp/stage下的文件是否存在、内容是否正确。再跑一次 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 versionPROJECT_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 排错顺序遇到安装阶段问题我一般按这个顺序查先确认 configure 能过cmake -S . -B build --log-levelDEBUG看有没有前置报错。打开build/cmake_install.cmake搜你自己的 install(CODE) 片段确认写入脚本的到底是变量引用还是写死字符串。用干净临时目录安装cmake --install build --prefix /tmp/stage排除系统目录权限干扰。看安装脚本输出的message日志确认自定义操作是否真的执行了。检查目标目录文件是否生成、内容是否符合预期。检查install_manifest.txt里是否记录了这些文件。再跑一次安装确认没有重复写入、残留旧文件、权限异常。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 targetCMake 版本报错项目要求版本高于本机先执行 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 和交叉编译。安装阶段的自定义操作本身不难难的是搞清楚变量到底在哪里展开、脚本到底在哪里执行。只要把配置阶段和安装阶段的边界守住很多看起来玄乎的报错其实都能在十分钟内定位。
返回列表