搞了多年C项目构建最让我头疼的往往不是编译报错而是几百行CMakeLists.txt里不断重复的add_library、target_include_directories、install三板斧。后来我慢慢发现CMake里foreach这个命令是我用得最勤、收益最大的一条。无论是批量处理源文件、按平台生成配置还是动态创建几十个targetforeach都能把原本需要复制粘贴几十遍的体力活压缩成几行逻辑清晰的循环。这篇内容适合所有写CMake的人尤其是刚接手多模块工程、被一堆重复target折磨到崩溃的开发者。我会先把foreach的每一种语法模式拆开讲透再给几个可以直接抄走的实战场景最后把这几年的踩坑记录一次性抖出来。看完你会发现CMake的循环其实没有网上说的那么多黑魔法搞清楚底层机制之后大部分问题都能自己推导出来。1. 先把foreach放在正确的位置它是一个“列表展开机”1.1 先理解CMake的列表底层机制CMake里没有数组没有vector列表本质上就是一个分号分隔的字符串。这句话看起来简单却是理解foreach一切行为的地基。比如执行set(L a b c)之后变量L的真实值是a;b;c。当你写${L}时CMake做的是字符串替换替换之后原来的位置会变成三个参数a、b、c。foreach接收的就是这么一串被拆好的参数。很多人以为foreach(x ${MY_LIST})会自动遍历列表里的每个元素——不是的。加引号后${MY_LIST}整体变成一个参数循环体只执行一次x拿到的是整个分号字符串。这种误解催生了foreach相关的第一号bug后面第5.1节我会专门展开。这个机制也解释了为什么foreach的RANGE、IN ITEMS、IN LISTS这些模式参数形式长得不一样本质是CMake在决定“此刻应该把哪些东西当作循环元素”。理解这一层你看到的就不是一堆需要死记硬背的语法规则而是一套有内在逻辑的设计。1.2 foreach解决的是什么问题构建过程天然充满重复模式一堆源文件要一起进target一组库要统一加编译选项十几个工具程序有着相同的目标结构。手写每个case当然能跑可每次新增一个模块都要复制一份几百行的CMake代码改起来更是灾难。foreach的价值不只是“让代码变短”这么简单而是把数据和逻辑分开。数据放在列表变量里逻辑放在循环体里以后加新模块只需要改数据。这也是工程化思维在构建脚本里的体现。另一个常被拿来和foreach对比的机制是生成器表达式也就是$$CONFIG:Debug:...那一套。生成器表达式能做条件选择比如按配置Debug/Release挑不同的编译参数但做不了“循环”——它无法凭空复制出二十个target定义。foreach在多target、多平台配置、多文件分组这三种场景下几乎是唯一合适的工具。2. 六种遍历方式一次拆透2.1 最朴素的参数遍历foreach(var item1 item2 ...)foreach最初级的用法就是直接列参数foreach(foo a b c) message(STATUS 当前是 ${foo}) endforeach()a b c是三个独立参数循环三次foo依次是a、b、c。如果列表事先存在变量里可以展开后再遍历set(NAMES a b c) foreach(foo ${NAMES}) message(STATUS 当前是 ${foo}) endforeach()NAMES展开后变成a;b;c分号再被拆成三个参数所以效果和直接写foreach(foo a b c)完全一样。老CMake代码里这种写法很多我现在一般会推荐用下一小节的IN LISTS理由后面说。需要注意foreach关键字的第一个参数是循环变量名它不会参与展开剩下的全部参数才是要遍历的元素列表。如果列表里某个元素恰好是另一个变量的名字展开时也会先被替换这属于“间接展开”行为用到的时候要心里有数。2.2 RANGE模式数值循环与步长计算数值循环用RANGE是foreach里最直观的模式foreach(i RANGE 10) # 0 1 2 ... 10 foreach(i RANGE 1 5) # 1 2 3 4 5 foreach(i RANGE 1 10 2) # 1 3 5 7 9三个参数的语义分别是start stop step。只有一个参数时范围是0..N两个参数是start..stop三个参数加步长。端点都包含在范围内。很多人会把RANGE 10记成“循环10次”其实这是11次0到10。想循环N次写foreach(i RANGE 1 N)更不容易数错。步长计算就是基础的等差数列问题start k*step不超过stop就继续。负步长同样支持foreach(i RANGE 5 1 -1)得到5 4 3 2 1做倒序遍历很方便。注意step不能为0否则CMake直接报错。RANGE模式常见的应用场景有三个一是固定次数的循环比如重复生成文件二是生成编号序列比如batch0.bin到batch9.bin三是利用步长跳过某些值比如RANGE 0 20 5只处理5的倍数。2.3 IN LISTS模式处理列表变量的正确姿势前面说过foreach(foo ${NAMES})能跑但IN LISTS模式更推荐set(NAMES a b c) foreach(foo IN LISTS NAMES) message(STATUS 当前是 ${foo}) endforeach()IN LISTS后面跟的必须是变量名不能加${}。你提供名字CMake自己去查这个名字对应的列表并展开。可以一次性传多个列表名按顺序依次遍历各自的元素set(A x y) set(B 1 2) foreach(v IN LISTS A B) # v依次为 x y 1 2 endforeach()这个模式最大的优势是语义直观——写代码的人一眼就知道数据源是哪个变量不会出现“展开后参数数量和我预期不一样”的意外。对CMake这种弱类型脚本语言来说可读性就是可维护性。这里有个容易混淆的点IN LISTS后面不能写字面量值。如果你写foreach(v IN LISTS a b)CMake会把a和b当作两个变量名去查而不是把它们当作两个字面元素。如果恰好这两个变量没定义循环体一次都不会执行。所以IN LISTS只适合遍历变量列表想遍历临时值看下一节。2.4 IN ITEMS模式直接遍历字面量如果就是想遍历临时写的一串值用IN ITEMSforeach(foo IN ITEMS a b c) message(STATUS 当前是 ${foo}) endforeach()这里的a b c直接被当作三个待遍历的元素CMake不会去查它们是不是变量。和IN LISTS的区别一句话概括IN LISTS后面的名字是“内容的出处”IN ITEMS后面就是“内容本身”。两种模式还能混着写顺序没有硬性要求我习惯LISTS在前、ITEMS在后set(BASE apps tools) foreach(t IN LISTS BASE ITEMS extra1 extra2) # t依次为 apps tools extra1 extra2 endforeach()动态变量列表和固定补充项拼在一起遍历是我在配置模块列表时常有的需求。比如默认模块写在变量里临时额外模块直接追加在ITEMS段代码既清楚又好改。2.5 ZIP_LISTS模式多列表按位同步遍历ZIP_LISTS是CMake 3.17引入的用来处理“两个列表按位置成对出现”的场景非常优雅set(PLATFORMS linux windows macos) set(ARCHS x86_64 amd64 arm64) foreach(pa ZIP_LISTS PLATFORMS ARCHS) message(平台${pa_0} 架构${pa_1}) endforeach()循环体内pa_0对应第一个列表PLATFORMS的当前元素pa_1对应第二个列表ARCHS的当前元素。数字下标不够直观的话CMake还允许直接用列表名访问pa_PLATFORMS等价于pa_0。ZIP_LISTS后面跟的同样是变量名而不是变量值。列表长度不一致是一个历史坑CMake 3.20之前短的列表先耗尽会直接报错中断整个configure3.20之后由策略CMP0121控制默认补空字符串继续循环直到最长列表结束。后面第5.3节我会专门演示这个差异。2.6 endforeach的收尾约定foreach要以endforeach()结束括号里允许重复循环变量名foreach(foo IN LISTS NAMES) ... endforeach(foo)不加也没关系但加上之后嵌套循环里一眼就能看出当前end和哪个foreach配对能省掉大量核对代码的时间。CMake官方风格也鼓励这么做。缩进方面建议循环体统一用4个空格别让一串endforeach挤成一团否则排查括号配对的成本会直线上升。3. 四个可以直接抄的实战场景3.1 批量收集多个子目录的源文件项目结构一旦复杂源文件往往分散在好多个子目录。典型场景是core、net、ui、tools各有一批.cpp想一次性收进一个可执行程序set(MODULE_DIRS core net ui tools) foreach(dir IN LISTS MODULE_DIRS) file(GLOB MODULE_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/${dir}/*.cpp) list(APPEND ALL_SOURCES ${MODULE_SOURCES}) list(APPEND ALL_INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/${dir}) endforeach() add_executable(myapp ${ALL_SOURCES}) target_include_directories(myapp PRIVATE ${ALL_INCLUDE_DIRS})这里每个子目录都会执行一次GLOB收集结果追加到总源文件列表。ALL_INCLUDE_DIRS也同时在循环里累积最后一次性设置include路径。要提醒的是GLOB类操作是“惰性的”。新增一个.cpp文件后CMake的glob结果不会自动更新必须手动重新运行CMake或触发configure才能生效。持续集成场景下这种意外很容易让人卡在“编译失败但明明文件都在”的困惑里。我的经验是源文件列表尽量显式写GLOB只用于资源文件、测试数据这类“增加后不需要立刻参与编译”的文件。3.2 为多个平台生成配置头文件多平台适配里最常见的需求就是为不同平台生成不同内容的config.hset(PLATFORM_CONFIGS linux macos windows) foreach(platform IN LISTS PLATFORM_CONFIGS) set(CURRENT_PLATFORM ${platform}) configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/config.h.in ${CMAKE_CURRENT_BINARY_DIR}/${platform}/config.h ONLY ) endforeach()config.h.in模板里写#ifndef CONFIG_H #define CONFIG_H #define CURRENT_PLATFORM CURRENT_PLATFORM #endif循环执行三次就生成了三份配置头。以后添加新平台只需要在PLATFORM_CONFIGS列表里加一个名字。configure_file有个隐蔽坑不加ONLY时它会尝试替换.in文件里所有看起来像${VAR}的内容。如果模板里有其他本来就带${}的文本片段会被无差别替换成空值或错误值。我习惯一律加ONLY只认VAR这种显式标记这样能避开绝大多数误替换。3.3 一次创建一组结构相同的可执行程序很多工具工程会有一批这样的target每个target对应一个目录目录下只有一个main.cpp所有target都链接同一个库。手写几遍还行十几个就开始痛苦了set(TOOLS fmt_tool calc_tool dump_tool ) foreach(tool IN LISTS TOOLS) add_executable(${tool} tools/${tool}/main.cpp) target_link_libraries(${tool} PRIVATE common_lib) target_compile_definitions(${tool} PRIVATE TOOL_NAME${tool}) install(TARGETS ${tool} RUNTIME DESTINATION bin) endforeach()循环里同时完成了可执行文件定义、链接、宏定义和安装规则。以后加新工具只需在TOOLS列表加一行名字并把源码放到对应目录剩下的活循环替你干了。这个模式有两个注意点target名必须唯一列表里不要出现重复项目录命名最好和target保持一致否则循环体构造路径时容易对不上。另外在compile_definitions里传字符串值时记得加引号比如TOOL_NAME${tool}避免值里出现空格或特殊符号时被参数化。3.4 多配置下编译选项的取舍初学者看到Debug和Release要设置不同编译选项第一反应是用foreach去改CMAKE_CXX_FLAGSforeach(config IN ITEMS Debug Release) if(config STREQUAL Debug) set(CMAKE_CXX_FLAGS_DEBUG -g -O0) elseif(config STREQUAL Release) set(CMAKE_CXX_FLAGS_RELEASE -O3) endif() endforeach()单配置生成器Unix Makefiles或Ninja下这种思路有机会生效但遇到Visual Studio、Xcode这类多配置生成器构建时的配置是运行时决定的configure阶段的这种条件循环就靠不住了。更稳的做法是用生成器表达式让编译选项推迟到构建阶段再选择target_compile_options(myapp PRIVATE $$CONFIG:Debug:-g -O0) target_compile_options(myapp PRIVATE $$CONFIG:Release:-O3)那foreach在这里就完全没用了吗也不是。如果你需要在configure阶段就根据配置生成不同文件、创建不同目录结构foreach依然不可替代。我自己的原则是编译选项类需求优先用生成器表达式文件、目录、工具链的批量生成才用foreach。4. 循环控制与作用域把foreeach当脚本语言来用4.1 break和continue的使用场景CMake的foreach支持标准循环控制命令break()终止循环continue()跳过当前迭代进入下一次。这两个命令从CMake 3.8开始就能用。一个典型需求是从列表里找第一个满足条件的值set(RESULT ) foreach(item IN LISTS CANDIDATES) if(DEFINED item AND item MATCHES ^valid_.*) set(RESULT ${item}) break() endif() endforeach()找到即停避免后续无用的遍历。continue()的场景更常见于过滤foreach(item IN LISTS INPUTS) if(item STREQUAL skip) continue() endif() message(处理 ${item}) endforeach()这两个命令只作用于当前层的foreach。嵌套循环里想从内层退出外层不能靠break两下解决通常要拆一个状态变量做标记外层循环检查到标记再break。真到了这种程度我建议先把代码重构一下说明逻辑已经复杂到不太适合硬塞进循环里了。4.2 循环体不产生作用域变量会泄漏出来CMake的foreach循环体没有独立作用域——循环内set的变量循环结束后依然存在保留的是最后一次迭代的值。这一点和C、Python的习惯差别很大set(LAST ) foreach(i RANGE 1 5) set(LAST ${i}) endforeach() message(${LAST}) # 输出 5这个特性在收集结果时非常好用可以放心地在循环里list(APPEND)到一个外部变量循环结束后拿到完整结果。反过来它也是双刃剑如果循环里不小心覆盖了一个外部正在用的变量后面的构建逻辑全会被带偏而且这种错误非常隐蔽。担心变量污染的时候CMake 3.25以后可以用block()开一个局部隔离域foreach(i RANGE 1 3) block() set(temp_var 局部${i}) endblock() endforeach() # temp_var 在这里不可见不过block()会让脚本结构复杂一点老版本也不识别。我的看法是优先做好变量命名和注释非必要不上block。4.3 嵌套循环的变量遮蔽问题内外层循环使用同一个变量名是嵌套循环里最高频的低级错误set(LIST_A a1 a2) set(LIST_B b1 b2) foreach(item IN LISTS LIST_A) foreach(item IN LISTS LIST_B) message(${item}) endforeach() endforeach()内层循环结束后外层item已经被改成b2外层循环继续时已经拿不到a2。很多人debug半天都没反应过来其实是变量名冲突。解决方案很朴素外层叫outer_item内层叫inner_item泾渭分明endforeach()后面再带上变量名一眼看清配对关系。嵌套循环的复杂度本来就比单层高别再让变量命名把水搅浑。5. 高频踩坑现场foreach常见问题速查5.1 引号一加只循环一次这是foreach新手最经典且最容易反复犯的错误set(LIST a b c) # 错误写法循环体只执行一次 foreach(item ${LIST}) message(${item}) # itema;b;c endforeach()引号让${LIST}成为一个整体字符串参数foreach把它当成唯一元素循环自然只跑一次。想看列表里的每个元素两种正确姿势foreach(item IN LISTS LIST) # 或者 foreach(item ${LIST})IN LISTS写法更推荐理由前面说过很多次不经过参数展开的间接过程语义直接指向变量本身。5.2 未定义变量、空字符串与空列表的边界这三种状态经常让人摸不着头脑set(EMPTY ) # 变量存在值为空字符串 set(UNDEF) # 变量未定义 set(LIST) # 取消定义恢复成未定义对foreach来说遍历一个值为空的变量和遍历一个未定义变量结果一样0次循环不报错。真正麻烦的是调试阶段——你想知道列表到底是没有内容还是根本没定义。打印值是看不出来的建议用DEFINEDif(DEFINED MY_LIST) message(STATUS 已定义内容${MY_LIST}) else() message(STATUS 未定义变量) endif()list(LENGTH)虽然也能判断空列表但如果变量未定义list命令会直接报错。所以稳妥写法永远是先DEFINED检查再做其他操作。5.3 ZIP_LISTS长度不一致的行为差异ZIP模式把多个列表按位绑在一起最怕长度对不上cmake_minimum_required(VERSION 3.20) set(A 1 2 3) set(B x) foreach(pa ZIP_LISTS A B) message(${pa_0} ${pa_1}) endforeach() # 输出 # 1 x # 2 # 3在CMake 3.20以上且工程声明的最低版本达到3.20时默认走CMP0121的NEW行为缺的位置自动补空字符串循环继续跑到最长列表结束。旧版本则会在短列表耗尽时直接报错“list lengths differ”。被补空的元素打印出来就是一行空字符串视觉上容易让人误以为数据丢了实际这是标准行为。如果不想看到这种空输出进循环后加个if(NOT pa_1)过滤。兼容老版本时两个列表长度一致最省心。显式检查并补齐是另一个通用办法list(LENGTH A LA) list(LENGTH B LB) if(LA GREATER LB) math(EXPR DIFF ${LA} - ${LB}) foreach(i RANGE 1 ${DIFF}) list(APPEND B ) endforeach() endif()5.4 循环变量用了保留关键字foreach的语法关键字包括IN、RANGE、ITEMS、LISTS、ZIP_LISTS。循环变量名不能叫这些名字否则解析器会把它当成语法结构而不是变量名。foreach(RANGE RANGE 1 5) # 解析错误 endforeach()这种错误报错信息通常很晦涩可能只告诉你语法有问题但看不出具体原因。经验之谈循环变量用短名词idx、src、tool别用和命令关键字相近的词。在CMake这种弱类型脚本里变量名就是文档的一部分命名越规范后续维护成本越低。5.5 遍历列表时别顺手改它在循环体里往正在遍历的列表追加元素是典型的自找麻烦set(L a b c) foreach(x IN LISTS L) list(APPEND L d) # 不要这样做 endforeach()不同CMake版本在处理“遍历过程中列表被修改”时的具体行为并不完全一致结果不可预测。轻则遍历不完整重则死循环——CI上面挂住一个死循环的CMake脚本可不好受。正确思路是先收集到临时列表循环结束以后再合并set(L a b c) set(TEMP ) foreach(x IN LISTS L) if(x STREQUAL b) list(APPEND TEMP x2 y2) endif() endforeach() list(APPEND L ${TEMP})原列表的遍历过程保持稳定不会出幺蛾子。下面把上面几个高频问题整理成速查表问题现象根本原因解决方案循环只执行一次列表被引号包裹成单参数用 foreach(item IN LISTS LIST)去掉引号循环体一次都不执行传入未定义变量名先 DEFINED 检查确认数据源存在ZIP_LIST 报长度不一致两个列表元素个数不同升级 CMake 到3.20或提前补齐短列表内层循环改乱了外层变量循环变量名冲突内外层变量名严格区分死循环或遍历不完全循环中修改被遍历列表先收集到临时列表循环后再合并6. 从入门到顺手一些经验之谈到这章不说语法了聊点我自己折腾CMake攒下的体会。foreach最迷人的一点是它把构建脚本从“手写重复清单”变成了“描述规则”。同样一份CMakeLists用了foreach之后新增模块的成本从“复制粘贴二十行再改错一个名字”变成“在列表里加一个条目”。这份收益在多target、多平台的工程里会被放大得非常明显。但foreach也不是万能的。重复次数很少、逻辑只出现一两次的时候直接写出来比强行套循环更清晰。工程里常说“三次以上才抽象”CMake同样适用。第一处重复可以忍第二处还可以忍等到第三处出现时再用foreach整理代码反而比从一开始就乱套循环要干净得多。调试循环的小技巧也别忽略。message(STATUS item${item})在确认循环状态时能帮上大忙但打印时一定要加引号写成message(STATUS item${item})。如果元素内部有意外空格加引号才能看出真实边界不加引号的话打印内容看起来会错位很容易被误读成变量本身的问题。版本兼容性是写给别人用的CMake时绕不开的坎。ZIP_LISTS是3.17新增block()要到3.25break和continue从3.8开始就有。给别人写脚本时先看看对方的cmake_minimum_required声明再决定敢不敢用新特性避免在旧环境里直接挂掉。如果让我给一条最真诚的建议多用IN LISTS少用裸的${VAR}展开。不是后者一定错而是前者表达意图更准确、更耐读。构建脚本这东西写的时候总以为只有自己看三个月后回来看的通常还是自己那一刻你会感谢当初把代码写得清清楚楚的自己。