ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CMake get_filename_component 命令详解:路径拆分、模式速查与实战技巧

CMake get_filename_component 命令详解:路径拆分、模式速查与实战技巧 写 CMakeLists.txt 的时候只要涉及路径基本都绕不开get_filename_component这条命令。不管是把src/main.cpp变成main.cpp和main还是把相对路径翻成绝对路径再或者是定位一个编译期需要的外部工具它都能派上用场。这条命令算是 CMake 路径处理体系里最基础也最常用的一环。虽然看起来就是从路径里分字段但实际工作中能不能把路径拆得干净准确直接决定了后面的构建脚本好不好维护。我见过不少项目里用string(REGEX REPLACE ...)手工切扩展名或者对CMAKE_CURRENT_SOURCE_DIR一顿字符串拼接的写法碰到路径里的点和目录层级稍微复杂一点就出各种问题。这篇文章把它彻底讲透包括每个模式的行为细节、版本差异、容易踩的坑以及我在实际项目里总结的组合用法。1. 为什么单独的路径命令值得认真学1.1 CMake 里路径操作的真实痛点CMake 的日常开发里路径操作需求非常频繁从某个完整文件路径里取出文件名用于日志输出、白名单过滤、依赖管理剥离扩展名用于生成同名目标文件、中间产物把相对路径转成绝对路径用于跨目录引用处理多扩展名文件.tar.gz、.tar.bz2只取最后一级或取完整后缀在不同的操作系统上保持行为一致。这些需求如果全靠手写字符串操作会有一堆边界问题。先说分隔符。Windows 路径用\Unix 用/全局路径还可能带盘符C:。同一份 CMakeLists 在 Linux 上调通了放到 Windows 上字符串切分的逻辑可能就变了。再说文件名本身中文、空格、多个点、版本号里的点都会让找最后一个点切一刀这种朴素思路失效。还有相对路径里的..如果在拼接前不做规范化生成的 include 路径或安装路径会带上..轻则日志难看重则目标路径指向错误位置。get_filename_component的价值在于把这一大堆容易出错的细节封装成一组可预测的规则而且在不同平台上有一致的表现。它既不依赖正则表达式也不依赖具体平台的路径库写出来的 CMakeLists 拿到 Windows / Linux / macOS 上都能得到同样的拆分结果。这也是为什么我不建议在 CMake 里频繁用字符串切割处理路径——正则表达式写起来费劲读起来费神跨平台还容易翻车。1.2 在 CMake 路径处理体系中的定位了解这条命令还需要清楚它的定位。CMake 里的路径处理能力分散在几个不同地方file(RELATIVE_PATH ...)计算两个绝对路径之间的相对路径file(REAL_PATH ...)解析符号链接可以理解成get_filename_component(REALPATH)的文件操作版本cmake_path命令CMake 3.20提供更丰富的路径结构操作类似路径对象的完整 APIIS_ABSOLUTE、if(EXISTS ...)路径状态判断string(APPEND/REGEX ...)底层字符串操作。get_filename_component的特点是专门做从路径中提取某个成分这种拆分型工作。输入是一个文件路径输出是路径中的某一段。在这个分工里它的覆盖面最广、使用频率也最高。后面的章节会逐步展开最后你会看到它和file(RELATIVE_PATH)、cmake_path各自的适用边界。2. 语法拆解与模式速查2.1 参数逐个说清楚基本语法get_filename_component(VAR FileName COMP [CACHE]) get_filename_component(VAR FileName COMP [BASE_DIR dir])四个核心参数逐个过一遍。VAR是保存结果的变量名。建议用带下划线后缀的名字比如SRC_DIR、SRC_WE、FILE_EXT这样在后续复杂脚本里容易读。实际项目里常见的是FILE_DIR、FILE_NAME、FILE_EXT这种命名。变量命名看似小事但涉及路径的脚本往往一段逻辑里同时出现原始路径和多个解析结果名字不清不楚非常容易自我混淆。FileName是要做拆分的路径可以是字符串字面量、变量或表达式拼接。注意绝大多数解析操作是原样处理字符串只有ABSOLUTE和REALPATH模式才会真正结合当前目录做绝对化。所以在传相对路径时要清楚这一点不要指望NAME模式会去补全路径。COMP是模式标识取值很多下一节用表格列全。CACHE是可选关键字加上它结果变量会写入 CMakeCache.txt这个参数要谨慎使用原因在踩坑章节专门讲。BASE_DIR dir是 CMake 3.19 新增的选项作用是在ABSOLUTE/REALPATH模式下用指定目录而不是默认的当前源目录作为相对路径的根基。2.2 模式速查表假设输入路径是/work/build/apps/example.tar.gz各模式的输出如下模式结果说明DIRECTORY/work/build/apps去掉文件名后的目录部分PATH/work/build/appsCMake 3.14 起提供行为与 DIRECTORY 一致NAMEexample.tar.gz完整文件名包含所有扩展名NAME_WEexample去掉从第一个点开始的整段后缀NAME_WLEexample.tar只去掉最后一个点之后的内容EXT.tar.gz从第一个点开始的所有后缀字符串LAST_EXT.gz最后一个点之后的内容ABSOLUTE/work/build/apps/example.tar.gz相对路径转绝对路径并规范化REALPATH解析软链接后的真实路径会查文件系统路径不存在有风险PROGRAM在 PATH 中定位到的程序完整路径用于查找可执行程序这张表里NAME_WE/NAME_WLE的区别以及多点文件名是很多人忽略的坑。常规的test.cpp对所有模式的结果都一样一旦出现.tar.gz、v1.2.3.cmake这种多点文件各模式才会真正显出差异。这个场景在下载第三方库、解析版本号时特别常见。还有一个细节DIRECTORY对纯文件名不带任何路径的输入会返回空字符串这点在循环里要留意避免拼出以/开头的错误路径。2.3 版本差异与兼容性get_filename_component本身出现得很早但几个关键能力是后来陆续补上的CMake 3.14 新增PATH模式同时文档明确了DIRECTORY/PATH的语义CMake 3.14 新增LAST_EXT和NAME_WLE专门处理多点扩展名CMake 3.19 新增BASE_DIR选项解决了相对路径基于别的目录转绝对路径的需求CMake 3.20 之后还有cmake_path命令提供更丰富的路径能力。实际项目里如果 CI 环境还在用很老的 CMake 版本排查路径问题时要先看版本。我见过一个 CI 用的 CMake 3.5项目里EXT取出来的结果和本地 3.22 完全不一样就是因为老版本对扩展名的语义和新版本不同。写公共 CMake 模块时最好在顶部声明cmake_minimum_required(VERSION 3.19)或者至少在文档里标明版本要求避免队友在旧环境下踩坑。3. 高频场景实战3.1 从 glob 结果中摘出文件名做白名单过滤最典型的需求用file(GLOB_RECURSE ...)一口气收集一堆源文件然后只编译其中一部分。源文件主要靠路径区分但日志或目标名需要短文件名。代码file(GLOB_RECURSE ALL_SOURCES CONFIGURE_DEPENDS src/*.cpp) foreach(src IN LISTS ALL_SOURCES) get_filename_component(src_name ${src} NAME) if(src_name MATCHES ^test_) list(APPEND TEST_SOURCES ${src}) else() list(APPEND LIB_SOURCES ${src}) endif() endforeach()这里NAME模式保证了src/test_utils.cpp和src/helper/test_utils.cpp都能用同一个test_前缀规则过滤比直接比较整条路径可靠得多。配合CONFIGURE_DEPENDS源文件增删时 glob 会重新执行这套写法在中小型项目里用得很顺手。如果你还需要在循环里对每个文件生成对应的构建产物路径可以把NAME_WE也取出来方便做同名替换foreach(src IN LISTS ALL_SOURCES) get_filename_component(src_base ${src} NAME_WE) set(extra_dep ${CMAKE_CURRENT_BINARY_DIR}/deps/${src_base}.d) list(APPEND GENERATED_DEPS ${extra_dep}) endforeach()3.2 去掉扩展名生成中间目标构建过程中经常需要同名不同后缀的产物比如把schema.proto生成schema.pb.cc和schema.pb.h。用NAME_WE把主文件名取出来再拼上目标后缀非常直观set(PROTO_FILES proto/user.proto proto/order.proto ) foreach(proto_file IN LISTS PROTO_FILES) get_filename_component(proto_name ${proto_file} NAME_WE) set(generated_cc ${CMAKE_CURRENT_BINARY_DIR}/gen/${proto_name}.pb.cc) set(generated_h ${CMAKE_CURRENT_BINARY_DIR}/gen/${proto_name}.pb.h) list(APPEND GENERATED_SOURCES ${generated_cc}) endforeach()这里有个细节源文件放在proto/子目录下生成产物统一放进gen/用文件名做主键天然规避了 proto 目录层级变化导致的目标名冲突。如果两个不同目录恰好存在同名文件那需要再加一级目录信息用DIRECTORY取出相对路径后再拼进去。但作为命名习惯我倾向于模板文件本身就不要重名让构建脚本更简单。3.3 相对路径转绝对路径与 BASE_DIR 基准目录另一种高频场景拿到一个相对路径后续if(EXISTS ...)、configure_file(...)或target_include_directories(...)都期望它是绝对路径。ABSOLUTE模式就是为此准备的get_filename_component(TEST_DATA ${CMAKE_CURRENT_SOURCE_DIR}/../data/config.yaml ABSOLUTE) message(STATUS Using config: ${TEST_DATA})这里ABSOLUTE会帮忙清理路径里的..得到规范化的绝对路径比手工set(... ${CMAKE_CURRENT_SOURCE_DIR}/../data/...)干净得多。BASE_DIR则解决另一个问题有些路径是从构建目录出发的而不是源目录。CMake 3.19 之前你只能手工拼接CMAKE_BINARY_DIR现在可以直接指定基准get_filename_component(REL_OUT generated/config.cmake ABSOLUTE BASE_DIR ${CMAKE_BINARY_DIR})这个特性在编写可复用函数、处理路径是相对于另一个目录的约定时特别有用。我会在函数参数里支持一个BASE_DIR入参然后透传给内置选项这样调用方怎么传解析基准就是什么不会偷偷变成源目录。3.4 按扩展名对源文件分组CUDA、OpenCL、汇编文件混在项目里时需要根据扩展名给不同源文件附加不同编译属性。EXT模式和流控组合就能用file(GLOB_RECURSE MIXED_SOURCES CONFIGURE_DEPENDS src/*.cpp src/*.cu src/*.S ) foreach(src IN LISTS MIXED_SOURCES) get_filename_component(src_ext ${src} LAST_EXT) if(src_ext STREQUAL .cu) set_source_files_properties(${src} PROPERTIES LANGUAGE CUDA) elseif(src_ext STREQUAL .S) set_source_files_properties(${src} PROPERTIES LANGUAGE ASM) endif() endforeach()这里用LAST_EXT而不是EXT是有意的。扩展名判断关心的是真正的后缀也就是最后一个点之后的部分。EXT在处理foo.cu.cpp这种多点文件时会取更长的一串判断条件就会失效。我在实际项目里就遇到过同事把EXT用在扩展名分类上结果某个源文件叫shader.comp.glsl分类直接跑到错误分支。所以扩展名匹配场景默认优先LAST_EXT。4. 两个容易被忽略但很关键的模式4.1 REALPATH软链接解析与现实路径REALPATH模式和ABSOLUTE的差别在于它会去解析文件系统里的符号链接返回文件真正的位置。比如你在 Linux 上有软链接/work/build-master - /work/build-2024-06-01代码里用的是/work/build-master/bin/tool通过REALPATH得到的是/work/build-2024-06-01/bin/tool。这在把构建产物路径嵌入日志或元信息时很有用因为你希望用户看到的路径是真实可访问的路径而不是依赖一个可能变化的软链接。一个典型场景是跨模块依赖定位。主工程通过add_subdirectory(vendor/foo)引入子项目子项目内部可能用软链接把自己的源码树指向共享目录。解析之后再传给外部模块做 include 路径能避免每次构建受链接状态影响。写法if(EXISTS ${VENDOR_DIR}) get_filename_component(VENDOR_REAL ${VENDOR_DIR} REALPATH) target_include_directories(app PRIVATE ${VENDOR_REAL}/include) endif()必须注意的边界行为REALPATH需要路径存在于文件系统中并且对当前用户可访问。如果路径不存在不同版本的 CMake 表现不一致有些版本会返回空字符串有些版本会报 configure 错误。稳妥做法是像上面那样先if(EXISTS ...)判断再决定是否解析。Windows 上它的语义接近规范化绝对路径对符号链接和目录联接junction也会尝试解析但行为和 NTFS 的具体实现相关不要和 Linux 一一对应。如果在 Windows 上遇到解析结果和预期不同先用ABSOLUTE验证是不是单纯的分隔符问题。4.2 PROGRAM自动寻找外部可执行程序PROGRAM模式给找工具这件事省了不少代码。比如你在 Windows 上装了 Python但使用者可能把 python.exe 放在任意目录并加入了 PATH。你可以把命令名传给PROGRAM模式它会帮你找到完整路径set(REQ_PYTHON python) get_filename_component(PYTHON_FULL_PATH ${REQ_PYTHON} PROGRAM) if(NOT PYTHON_FULL_PATH) message(FATAL_ERROR python not found in PATH) endif() message(STATUS Python at: ${PYTHON_FULL_PATH})在 Unix 上它会沿着 PATH 目录逐个查找并检查可执行位在 Windows 上它会自动尝试 PATHEXT 里的.exe、.bat、.com后缀。如果传入的字符串本身带目录比如./tools/check.py它会先当作路径验证确认不存在再考虑 PATH 搜索。注意PROGRAM的返回值有个特点如果找到了完整路径返回那个路径如果没找到返回空字符串或原来的名字不同版本有差异。所以使用前最好判空不要假定返回值一定是有效路径。这个模式配合find_program使用效果更好find_program负责从全局缓存和标准查找路径中定位PROGRAM模式则在拿到候选名后快速解析成完整路径。两者各有侧重。4.3 与 file(GLOB ...) 组合生成文件清单的完整实操把前面的能力组合起来能写出一个比较完整的资源清单生成循环。以打包工具所需的配置模板为例file(GLOB CONFIG_TEMPLATES CONFIGURE_DEPENDS configs/*.in) set(CONFIG_FILES ) foreach(tpl IN LISTS CONFIG_TEMPLATES) get_filename_component(tpl_name ${tpl} NAME_WE) set(target_config ${CMAKE_CURRENT_BINARY_DIR}/configs/${tpl_name}.conf) configure_file(${tpl} ${target_config} ONLY) list(APPEND CONFIG_FILES ${target_config}) endforeach()思路是不管模板目录里有多少文件循环里用NAME_WE统一改产出文件名再用configure_file落地到构建目录。这套逻辑稳定且没有硬编码文件名新增模板时无需动 CMakeLists。唯一要提醒的是如果是给最终用户安装用的配置文件请把生成路径从 build 目录换成 install 前缀再配合install(FILES ...)使用不要让构建产物直接覆盖源目录。5. 组合思路和其他 CMake 机制配合5.1 file(RELATIVE_PATH) 配合生成相对路径get_filename_component擅长拆分单条路径file(RELATIVE_PATH)则擅长计算两条路径的关系。两者经常合作。比如你想把一组源文件全部改写为相对于某个公共根目录的相对路径set(ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}) file(GLOB_RECURSE ALL_SOURCES CONFIGURE_DEPENDS src/*.cpp) foreach(src IN LISTS ALL_SOURCES) file(RELATIVE_PATH rel_src ${ROOT_DIR} ${src}) message(STATUS Rel: ${rel_src}) endforeach()如果此时还需要把 src 里的文件名单独取出来那就再叠一层foreach(src IN LISTS ALL_SOURCES) file(RELATIVE_PATH rel_src ${ROOT_DIR} ${src}) get_filename_component(rel_name ${rel_src} NAME) list(APPEND REL_NAMES ${rel_name}) endforeach()组合使用时要注意循环内变量命名避免src里存的还是绝对路径却在取名时误用NAME覆盖了原始路径变量。5.2 字符串替换与路径拼接的配合多人项目里 source 列表通常用相对路径书写但生成 IDE 工程或者跨目录引用时需要绝对路径。有的写法是这样set(MY_SOURCE_LIST src/main.cpp src/module/helper.cpp ) foreach(rel_path IN LISTS MY_SOURCE_LIST) string(REPLACE / ${CMAKE_CURRENT_SOURCE_DIR}/ abs_path ${rel_path}) endforeach()这种string替换写法非常脆弱第一假设分隔符总是/第二容易拼出${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp和${CMAKE_CURRENT_SOURCE_DIR}src/main.cpp这种不统一的结果第三遇到..不会做规范化。正确做法就是ABSOLUTE模式一步到位foreach(rel_path IN LISTS MY_SOURCE_LIST) get_filename_component(abs_path ${rel_path} ABSOLUTE) endforeach()我的经验是只要不是做真正需要对路径文本定向处理的场景例如给 URL 加前缀路径拼接和转换就交给get_filename_component或file(REAL_PATH)。手写字符串和正则跨平台基本必翻车。5.3 generator expression 中使用的边界get_filename_component在 configure 阶段执行结果在生成阶段是普通常量。所以它不能接收 generator expression 里的$TARGET_FILE:tgt这种构建期值。如果你真的需要构建产物的目录/文件名应该直接使用 target propertyget_target_property(TARGET_DIR mylib BINARY_DIR) get_target_property(TARGET_NAME mylib NAME)两个选择configure 期能拿到原始路径的就用get_filename_component预处理构建期才能确定名字的用生成器表达式由构建系统计算。不要把两者混在一个变量里。下面这种写法是常见的误导# 错误示范在 configure 期解析 genex # get_filename_component(BAD_DIR $TARGET_FILE_DIR:mylib DIRECTORY) # 正确做法让 genex 由构建系统求值 set_property(TARGET mylib APPEND PROPERTY INTERFACE_INCLUDE_DIRECTORIES $TARGET_FILE_DIR:mylib/include)如果确实需要某个文件的目录而它正好是构建产物直接用目标属性即可不要在get_filename_component上跟 genex 较劲。5.4 与 cmake_path 命令的对比和迁移建议CMake 3.20 加入了cmake_path命令它能对整个路径做更细粒度操作例如cmake_path(GET /work/a/b/file.txt PARENT_STRING parent) cmake_path(GET /work/a/b/file.txt STEM stem) cmake_path(IS_PREFIX /work/a /work/a/b/file.txt result)cmake_path的语义有 Pythonpathlib的影子API 更丰富。但get_filename_component依然值得优先掌握原因有三个一是它支持的 CMake 版本更老公共模块里写这个大概率兼容二是它的模式正好覆盖绝大多数实际需求API 简单不容易记错三是 CMake 官方依然在维护它。我的建议是新写的代码统一用get_filename_component处理拆分需求只有需要 stem/suffix 之外的特殊路径结构时才上cmake_path。两种命令混用会增加阅读成本在一个项目里定好规矩反而对维护有利。6. 边角情况与排错方法6.1 多点文件名的行为差异这是最典型的查了文档才发现自己理解错的地方。以release-v1.2.0.tar.gz为例set(F release-v1.2.0.tar.gz) get_filename_component(R_EXT ${F} EXT) get_filename_component(R_LAST ${F} LAST_EXT) get_filename_component(R_WE ${F} NAME_WE) get_filename_component(R_WLE ${F} NAME_WLE) message(STATUS EXT${R_EXT} LAST_EXT${R_LAST}) message(STATUS NAME_WE${R_WE} NAME_WLE${R_WLE})我在 CMake 3.22 下实测的结果是EXT.2.0.tar.gz LAST_EXT.gz NAME_WErelease-v1 NAME_WLErelease-v1.2.0.tar也就是说EXT会把第一个点之后的全部内容当作扩展名NAME_WE返回的是第一个点之前的部分。如果你试图从这种文件名里提取版本号用NAME_WE会得到release-v1离想要的v1.2.0差得远。正确做法是用NAME_WLE得到release-v1.2.0.tar再做正则匹配版本号扩展名用LAST_EXT验证是不是.tar.gz。这个坑在解析第三方库下载包时特别容易踩。我的习惯是只要文件名可能出现多个点就主动用LAST_EXT和NAME_WLE不要依赖EXT和NAME_WE的直觉语义。6.2 路径含空格和中文CMake 变量可以存储带空格路径get_filename_component本身不会报错但如果后续把结果拼进未加引号的命令或宏参数就会被拆成多个参数。经验是set(DIR_WITH_SPACE C:/My Projects/src) get_filename_component(NAME_WITH_SPACE ${DIR_WITH_SPACE}/foo.cpp NAME) message(STATUS name: ${NAME_WITH_SPACE})写代码时给FileName参数加引号以及所有用到结果的场景也加引号这是避免大部分路径问题的基本习惯。中文路径同理。源码树里有非 ASCII 路径时唯一要小心的是第三方工具链对字符编码的支持CMake 本身和这条命令没有额外限制。6.3 REALPATH 对不存在文件的表现前面提过REALPATH的风险这里给一个完整的排错流程先用if(EXISTS ...)判断路径是否存在如果不存在且确定是 configure 期相对路径问题先转ABSOLUTE再REALPATH如果指定了BASE_DIR检查基准目录本身是否存在如果解析到空字符串不要断言是 CMake bug先检查权限和软链接状态。这条路径不存在的坑在 CI 流水线里特别容易发生。一次构建从干净的 checkout 目录开始某些中间目录还没生成REALPATH就会失败。我的做法是在需要解析的目录都确定存在之后再调用或者干脆用ABSOLUTE代替因为很多场景其实并不需要真正解析软链接只是想让路径绝对化。6.4 CACHE 参数优势与陷阱CACHE确实有它的用途当你希望 configure 出的路径在后续 CMake 运行里保留或者希望外部用户通过-D覆盖时CACHE是正统机制。但它有个常见坑一旦缓存变量存在后续不带FORCE的赋值不会改变它的值。get_filename_component(MY_THIRD_PARTY_DIR /opt/third_party ABSOLUTE CACHE) # 下一次 configure 时即使路径变了这个缓存值也不会自动更新所以在get_filename_component的结果上挂CACHE我一般只在明确不想覆盖时才用普通场景用普通变量更符合每次 configure 都重新计算的逻辑。真要持久化更多会配合option()或set(... CACHE PATH ...)把用户可配的路径和运行时计算出的派生路径分开。6.5 调试技巧message 输出各模式结果调试路径问题最直接的办法是把每个模式的结果打印出来set(TEST_PATH src/util/release-1.2.0.tar.gz) foreach(mode DIRECTORY NAME NAME_WE NAME_WLE EXT LAST_EXT ABSOLUTE) get_filename_component(RESULT ${TEST_PATH} ${mode}) message(STATUS [${mode}] ${RESULT}) endforeach()这样一次 configure 就能看到全部模式在目标路径上的实际返回值。如果某个模式和预期不一样基本能确定是模式选择问题还是路径传递问题。我每次写不熟悉的路径组合时都会先用message验证再放心进入大段逻辑。这个方法看起来简单但在多人协作的项目里非常好用——别人改了一处路径拼接你有没有第一时间看到各模式输出变化往往决定了问题是在 configure 阶段被拦住还是等到编译 / 安装阶段才爆出来。最后分享一个自己养成的习惯在公共 CMake 模块里我会把这类路径拆分封装成一组以_DIR、_NAME、_WE结尾的变量并在函数文档里写清楚每个变量的含义。这样即使半年后再回来维护代码也只需要扫一眼变量名。另外建议在项目里统一 CMake 最低版本并把get_filename_component和file(RELATIVE_PATH)的职责分开文件路径的拆分提取用前者路径间关系计算用后者。两者配合起来大部分路径处理都能写成可读性很好的 CMakeLists。
返回列表