7、CMake中的文件操作:file()命令详解(读写、下载、压缩、遍历)、configure_file()

文件操作,说白了就是CMake和操作系统打交道的那双手。你想想看,一个项目从源码到安装包,中间要经历多少文件层面的折腾?读取配置文件、下载依赖库、打包资源文件、遍历源文件列表……这些活儿,file()命令几乎全包了。

我个人习惯把file()比作瑞士军刀——功能多,但每个功能都有自己专属的“刀刃”。今天咱们就把这把刀拆开,一个一个看明白。

7.1 file()命令的“十八般武艺”

file()命令在CMake里是个多面手。它的基本语法很简单:

file(子命令 参数...)

不同的子命令干不同的事。我把它分成四大类:

类别 子命令 一句话说明
读写操作 READ、WRITE、APPEND、STRINGS 读文件、写文件、追加内容、读取字符串行
文件系统 COPY、RENAME、REMOVE、MAKE_DIRECTORY 复制、重命名、删除、创建目录
遍历与查找 GLOB、GLOB_RECURSE 匹配文件路径,递归遍历子目录
网络与压缩 DOWNLOAD、UPLOAD、ARCHIVE_EXTRACT 下载文件、上传文件、解压归档

嗯,这里要注意:file()是在配置阶段执行的,不是构建阶段。也就是说,当你运行cmake ..的时候,这些操作就已经发生了。

7.2 读写操作:最常用的基本功

先说说读写。我在项目中经常需要生成一些配置文件,比如版本号、编译时间戳之类的。

7.2.1 读取文件内容

file(READ "version.txt" VERSION_CONTENT)
message("版本内容: ${VERSION_CONTENT}")

如果你只想按行读取,用STRINGS更合适:

file(STRINGS "config.ini" CONFIG_LINES)
foreach(line ${CONFIG_LINES})
  message("配置行: ${line}")
endforeach()

我曾经踩过一个坑:读取大文件时,READ会把整个文件塞进一个变量里,内存一下子就上去了。后来我改用STRINGS配合LIMIT_INPUT参数,只读前几行,问题就解决了。

7.2.2 写入与追加

file(WRITE "output.txt" "第一行内容\n")
file(APPEND "output.txt" "追加的第二行内容\n")

这里有个细节:WRITE会覆盖已有文件,而APPEND是在末尾追加。我建议你在写构建脚本时,先用WRITE创建文件头,再用APPEND逐步添加内容。这样代码更清晰。

注意:如果文件路径不存在,WRITEAPPEND不会自动创建目录。你需要先用file(MAKE_DIRECTORY ...)把目录建好。

7.3 遍历文件:GLOB与GLOB_RECURSE

这是file()命令里最常用的功能之一。说白了,就是帮你自动收集源文件列表,省得你一个一个手动写。

file(GLOB SOURCES "src/*.cpp")
file(GLOB_RECURSE ALL_SOURCES "src/**/*.cpp")

GLOB只匹配当前目录,GLOB_RECURSE会递归进子目录。我个人习惯用GLOB_RECURSE,因为项目大了以后,目录结构经常调整,递归匹配能少改很多CMakeLists.txt。

但是——这里有个大坑。我曾经在一个项目里用GLOB_RECURSE收集源文件,结果新增了一个cpp文件后,重新运行cmake --build居然没编译它!

为什么会这样?因为GLOB的结果是在配置阶段缓存的。如果你不改CMakeLists.txt,CMake不会重新执行GLOB。解决方案有两个:

  1. 每次新增文件后,手动重新运行cmake ..
  2. CMAKE_CONFIGURE_DEPENDS告诉CMake监控目录变化
file(GLOB_RECURSE SOURCES "src/*.cpp")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "src/")
我的建议:小项目用GLOB没问题,但大项目或者库项目,我建议还是手动列出源文件。虽然麻烦点,但构建更可靠,也方便别人阅读。

7.4 下载与压缩:网络操作也不在话下

你想想看,如果你的项目需要从网上下载一个第三方库,难道要写个脚本?不用,file(DOWNLOAD ...)就能搞定。

file(DOWNLOAD "https://example.com/lib.zip" "${CMAKE_BINARY_DIR}/lib.zip"
     SHOW_PROGRESS
     EXPECTED_MD5 1234567890abcdef)

这里我特别想强调EXPECTED_MD5参数。我曾经有一次下载依赖库,结果服务器上的文件被替换了,编译到一半报错,排查了半天才发现是下载的文件不对。加上MD5校验后,文件不对就直接报错,省心多了。

解压操作也很直接:

file(ARCHIVE_EXTRACT INPUT "lib.zip"
     DESTINATION "${CMAKE_BINARY_DIR}/third_party")

支持的格式包括zip、tar、tar.gz、tar.bz2等。嗯,这里要注意:ARCHIVE_EXTRACT是CMake 3.18才引入的,如果你用的CMake版本比较老,可能需要用execute_process调用系统命令来解压。

7.5 configure_file():模板文件的魔法

这个命令我太喜欢了。它能把CMake变量直接嵌入到文件中,说白了就是模板引擎。

configure_file("config.h.in" "${PROJECT_BINARY_DIR}/config.h")

假设你的config.h.in内容是这样的:

#define VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define VERSION_MINOR @PROJECT_VERSION_MINOR@
#define BUILD_TIMESTAMP "@BUILD_TIMESTAMP@"

经过configure_file()处理后,@PROJECT_VERSION_MAJOR@会被替换成实际的版本号。我在项目中经常用它来生成版本头文件、配置文件,甚至一些脚本文件。

这里有两个模式:

  • @VAR@ 模式:只替换被@包围的变量,其他内容原样保留
  • ${VAR} 模式:会替换所有CMake变量引用,但需要加@ONLY参数来禁用
configure_file("config.h.in" "config.h" @ONLY)
核心区别:如果你在模板文件里用了${...},而且不想被CMake解析,一定要加@ONLY。否则CMake会把所有${...}都尝试替换,很可能报错。

我曾经接手过一个项目,模板文件里既有@VAR@又有${VAR},结果configure_file()${VAR}也替换了,导致生成的配置文件里全是乱码。排查了半天才发现是@ONLY没加。

7.6 知识体系总览

说了这么多,咱们用一张图把file()configure_file()的关系理清楚:

CMake文件操作 file() 命令 读写操作 遍历查找 下载与压缩 configure_file() 模板替换 变量嵌入 执行时机:配置阶段(cmake ..) 非构建阶段,非运行阶段

7.7 实战经验总结

最后,我把自己这些年用file()configure_file()的一些心得整理一下:

  • 能用configure_file()就别手动拼接字符串——模板替换更清晰,也更容易维护
  • 下载文件一定要加MD5校验——我曾经因为网络问题下载了损坏的文件,排查了一整天
  • GLOB结果不可靠——如果你用GLOB,记得加CMAKE_CONFIGURE_DEPENDS,或者干脆手动列文件
  • 文件路径尽量用绝对路径——${CMAKE_CURRENT_SOURCE_DIR}${CMAKE_CURRENT_BINARY_DIR}是你的好朋友
  • 解压操作注意CMake版本——3.18以下需要自己写execute_process

嗯,文件操作这块内容其实不难,但细节特别多。你只要记住一点:file()是配置阶段的工具,它帮你准备好构建所需的一切文件。而configure_file()则是把CMake的变量能力延伸到文件层面,让配置生成变得优雅。

下次你写CMakeLists.txt的时候,遇到文件相关的需求,先想想file()能不能搞定。大概率是能的。