
我一度很抗拒改 SConstruct不是因为 SCons 难学而是每当要新增一个源文件就得手动在那个文件列表里加一行。更气人的是你常常会忘。忘了之后编译一路畅通链接器却在最后甩给你一个undefined reference。你查半天函数签名甚至不会想到问题其实出在构建脚本里——那个新文件压根没被编译。今天我想认真聊聊怎么彻底摆脱这种状态用 SCons 的深度搜索能力把源文件的收集从手工活变成构建自动化的一部分。这篇文章面向已经会用 SCons 写基本构建脚本、但还没把源文件列表解放出来的开发者。核心就一句话让构建工具自己去源码目录里找文件你只负责定边界。1. 手动源文件列表是构建自动化的头号障碍1.1 一次链接失败的完整复盘某次项目需要加一个加密辅助模块crypto/md5.c。我把函数写好头文件引用好编译二进制也过了但链接报undefined reference to md5_hash。我以为是头文件声明问题反复检查函数签名、编译选项最后才想起 SConstruct 里的源文件列表压根没加这个文件。因为没加入列表SCons 根本不会去编译crypto/md5.c自然没有对应的.o来参加链接。这是手动维护源文件最典型的翻车场景。这种事故有多频繁只要你的项目活过三个月文件数超过 30 个几乎每个人都会遇到。尤其多人协作时别人 add 了一个文件提交信息写“新增网络层”但忘了同步更新构建脚本。你在主分支一看编译也许还是过了因为旧文件没被改但链接必然失败或者某个功能在你本地神秘缺失。排查成本完全不亚于一个死锁问题。1.2 手写列表的四个隐藏成本第一是遗漏成本加文件容易忘删文件容易留残。第二是重复成本同一种类型文件的路径被反复抄写只要目录结构调整列表全要改。第三是冲突成本多人编辑同一个列表极易产生 merge conflict解决完冲突还可能丢失条目。第四是认知成本构建脚本本该描述“怎么构建”却被一大段“有哪些文件”占据真正的编译选项反而淹没在列表里。等你需要调整优化级别、添加宏定义时反而要在一堆路径里找半天。这些成本可能在文件数量少的时候感受不到。可一旦上了规模它们会叠加到让每次启动 SCons 都想逃跑的程度。我自己维护过一个 300 多源文件的项目SConstruct 光源文件列表就占了将近 400 行每次 code review 看到这个列表都想直接跳过。后来我意识到手动列表不只是“丑”它是构建自动化里最脆弱的一环。1.3 SCons 真正想要的是一组“节点”不是字符串刚用 SCons 时我也图省事直接把路径字符串丢给Program()。实际上 SCons 内部处理时会把它转成 File 节点但这个过程有损、滞后。最稳妥的做法是给Program()传 Node 对象或者能生成 Node 的Glob结果。Node 是被 SCons 跟踪的“构建图顶点”带有明确的绝对路径、是否存在、时间戳等信息。只有拿到了 NodeSCons 才能在增量构建时判断“这个文件有没有变”“要不要重编”。这么理解手写字符串列表相当于你告诉 SCons “就是这些文件看着办”用Glob搜索相当于你告诉 SCons “源码在这几个目录里你去把它们都登记到构建图里”。后者才是深度搜索自动化的起点。2. 用 Glob 把源码目录“翻个底朝天”2.1 Glob 的基础用法与返回类型在 SCons 脚本中Glob是一个全局函数也可以写成env.Glob。比如sources Glob(src/*.c)会返回src目录下所有.c文件对应的 Node 列表。注意不是字符串列表这一点很多新手会看错。如果你确实需要路径字符串可以加stringsTrue但日常构建不建议因为 Node 携带的信息比字符串丰富得多。我经常用sorted(Glob(src/*.c))来保证源文件列表稳定避免在某些系统上顺序漂移造成非确定性。虽然编译命令通常能并行执行但顺序稳定至少让生成文件的行为可复现。构建系统里不稳定的列表顺序会带来很隐蔽的问题比如命令行参数长度变化导致缓存失效或者两个目标依赖顺序不同导致随机失败。2.2 递归参数 recursive 与**通配符真正让 Glob 变“深度搜索”的是recursiveTrue。配合**通配符可以匹配任意层级目录。比如sources Glob(src/**/*.cpp, recursiveTrue)能同时匹配src/main.cpp和src/net/socket.cpp。**的本质是“零个或多个目录段”所以它甚至能把根目录下的文件也捞出来。不过要注意**只有在recursiveTrue时才有意义。如果你只写Glob(src/**/*.cpp)忘了加 recursive**通常会被当作普通字符匹配不到任何东西或者行为等同于*。这是我被坑过的点后面第 5 章会细说。这里想强调的是递归搜索不是 SCons 默认行为你需要显式地告诉它“深入到子目录”。2.3 过滤与排序让自动收集更可控深度搜索通常会把 tests、third_party 这类目录也扫进来。常见做法是先 Glob 全部再按目录前缀排除或者分区域 Glob只包含真正需要编译的目录。我更推荐“化整为零”的方式按业务模块写多个 Glob再相加。比如backend Glob(src/backend/**/*.cpp, recursiveTrue) frontend Glob(src/frontend/**/*.cpp, recursiveTrue) sources backend frontend这样每个模块的边界清晰无需使用复杂的排除逻辑。如果你一定要排除可以用列表推导式过滤。需要特别小心的是 Node 与字符串的比较我习惯用str(node)拿到相对路径后统一处理避免在 SCons Node 的魔法方法上踩坑。2.4 一个最小可用改造示例原始脚本常见写法是env Environment() env.Program(app, [ src/main.c, src/util.c, src/net/socket.c, src/net/protocol.c, ])改成env Environment() sources Glob(src/**/*.c, recursiveTrue) env.Program(app, sources)就这么几行以后新增.c文件SCons 会自动把它纳入编译。链接失败的隐患彻底消失。如果你担心src/test也会被扫进来那就把它排除掉或者单独把src/**/*.c改成两个模块的 Glob 相加。这个改造成本极低收益立竿见影。3. 从文件名搜索到依赖关系搜索SCons 扫描器在背后做了什么3.1 SCons 的两层“搜索”文件发现和依赖发现我们要分清两件事第一层是用 Glob 发现源文件。第二层是 SCons 的 Scanner 去读取每个源文件发现它依赖哪些头文件进而建立依赖图。很多人以为“深度搜索”就是 Glob恰恰忽略了后者。后者才是 SCons 能实现精准增量构建的关键。默认情况下对于 C/CSCons 使用内置的 CScanner它会解析源文件里的#include ...和#include ...。...会在源文件所在目录、include 路径中查找...会在 CPPPATH 指定的路径中查找。当这些头文件变化时所有包含它的源文件都会自动重编不需要你手动跟踪。这种“依赖发现”才是 SCons 深入源码内部的搜索能力。3.2 深度搜索的边界为什么扫描器不是“万灵药”默认扫描器只认识它支持的语言。如果你在项目里生成的是一些特殊文件比如模板文件、资源描述文件、协议定义文件SCons 默认不会知道 “a.txt 被 b.pb 依赖”。这时就需要你显式指定依赖比如通过env.Depends(target, source)或自定义 Builder/Scanner。还有Scanner 的搜索深度受限于它能够找到的内容。如果头文件不在 CPPPATH 里或者被#include的路径写成了相对某个不存在的目录扫描器可能找不到。所以想要“深度”可靠首先要保证项目目录结构和依赖关系足够清晰。目录约束不是构建脚本的事是项目纪律的事。3.3 给非常规文件写一个自定义 Scanner 的思路假设你有一批.in模板文件里面用//load config.xml表示依赖config.xml。要让 SCons 在这些.in文件变化时自动感知 config.xml 的变化可以写一个简单 Scanner。思路是写一个函数读取文件内容用正则提取被依赖的文件名返回这些文件名列表然后创建Scanner(function)最后在env.Append(SCANNERSscanner)里挂进去。这样每当 SCons 需要分析模板文件时就会调用你的函数去抽取依赖。这看起来有点复杂但它解决的是“深度搜索”的最后一公里。如果你做的项目里有代码生成器、配置模板、协议定义理解 Scanner 机制绝对比硬编码Depends舒服得多。Scanner 让依赖信息从文件中“长出来”而不是靠人写在构建脚本里。4. 实战三个典型项目的自动源文件收集改造4.1 单目录单一工程最简单的 Glob 替换项目 A 很小目录结构如下project/ ├── SConstruct └── src/ ├── main.c └── util.c手动版 SConstruct 大概是env Environment() env.Program(app, [src/main.c, src/util.c])自动版只需要env Environment() sources Glob(src/*.c) env.Program(app, sources)如果你还在src下加了个crypto/md5.c手动版要改两行自动版什么都不用改。这是我建议所有小项目先做的第一步把源文件列表替换成单层 Glob。单层 Glob 没有**的兼容性顾虑最安全。4.2 多级目录多模块递归 Glob 和排除逻辑项目 B 开始复杂了project/ ├── SConstruct └── src/ ├── app/ │ └── main.cpp ├── lib/ │ └── core.cpp ├── third_party/ │ └── something.cpp └── tests/ └── test.cpp我们要编app和lib跳过third_party和tests。最快的方案是分别 Globenv Environment() sources Glob(src/app/**/*.cpp, recursiveTrue) Glob(src/lib/**/*.cpp, recursiveTrue) env.Program(bin/app, sources)如果项目模块很多不想一个个写那就用排除法。一个清晰的排除函数长这样def is_excluded(path): excluded_dirs (src/third_party, src/tests) return any(path d or path.startswith(d /) for d in excluded_dirs) all_sources Glob(src/**/*.cpp, recursiveTrue) sources [node for node in all_sources if not is_excluded(str(node))]这里的str(node)返回的是以正斜杠分隔的相对路径在排除时统一用它判断避免在不同系统上路径分隔符不一致。这个方案适合模块边界不那么清晰、但有几个固定排除目录的历史工程。4.3 源码树与构建树分离VariantDir 下的深度搜索如果用了VariantDir构建时源文件路径会被映射到构建目录。典型做法env Environment() VariantDir(build, src) sources Glob(build/**/*.c, recursiveTrue) env.Program(app, sources)这表示 SCons 在build目录下寻找源文件节点但实际物理文件在src下。如果你反过来用Glob(src/**/*.c)再把源文件传给ProgramSCons 可能会把源文件当作真实路径处理导致 VariantDir 的映射失效出现“重复编译”或“源文件在构建目录下不存在”的错误。这个坑我踩过一次印象非常深刻。当时我把源码全 Glob 了结果VariantDir之后构建树里找不到文件SCons 报一堆 “scons: *** source ... not found”其实就是因为 Glob 的顺序放错了。先建立 VariantDir 映射再从构建目录的角度去 Glob这是标准姿势。4.4 验证增量构建自动收集不是牺牲性能改造后跑scons -n看日志应该只编译新增文件对应的目标。SCons 的构建图会记录每个源节点的签名。你新增一个src/net/udp.c下一次 scons 会发现这个源文件是新的生成新的对象文件然后链接。其他无关的.o不会重编。我实测过一个 300 源文件的项目从手动列表切到 Glob 递归搜索后全量编译时间几乎没变增量编译的行为更准了。原来最怕的“改了个头文件所有文件都重编”也得到缓解因为 SCons 用的是 MD5 或时间戳比较而不是粗暴地看整个目录时间。自动化收集不会让构建变慢反而因为列表准确减少了很多无谓的重编。5. 深度搜索的边界与踩坑记录5.1 Glob 一个文件都找不到时脚本却“正常”结束SCons 脚本是 PythonGlob(src/**/*.cpp, recursiveTrue)找不到任何文件时不会报错返回空列表。env.Program(app, [])有时会提示scons: nothing to build for target app但在某些组合下错误不明显。更隐蔽的是sources后面做了拼接混用空列表被悄悄忽略构建看似成功但产物是旧的。我建议在收集源文件后马上加一句if not sources: raise SCons.Errors.UserError(no source files found under src/)提前失败避免浪费后面所有时间。这个习惯帮我省掉了至少三次“为什么改了一上午代码构建出来的还是旧版本”的瞎折腾。5.2**零层匹配的版本行为差异SCons 底层用的是 Python 的 glob 语义不同 Python 版本对**的处理有差异。在 Python 3.5 以前**不被特殊处理你必须写*/*/*.cpp类似模式。在 Python 3.5 以上**配合recursiveTrue才能跨目录。SCons 4.x 基本跟随 Python 3。所以如果你还在老环境或者团队里有人用旧版容易出现 A 机器能扫到、B 机器扫不到的怪事。我的规避办法是不依赖**的零层匹配行为。也就是说不要假设src/**/*.cpp一定能匹配src/a.cpp。如果你要覆盖根目录和子目录就写两个 Globroot_sources Glob(src/*.cpp) sub_sources Glob(src/**/*.cpp, recursiveTrue) sources root_sources sub_sources这样老版本的 Python 也能工作无非是稍显啰嗦。反正构建脚本不是 API安全比优雅重要。5.3 exclude 参数和手工过滤的取舍Glob有一个 exclude 参数看起来很美。但我在实际项目里不太喜欢它因为它的匹配语义不够直观有时你传一个相对路径模式结果发现排除不掉还得转成绝对路径。更重要的是当你有多个排除模式时代码可读性下降。我一般用“正收集 白名单”代替“全收集 黑名单”。如果确实要排除使用清晰的函数排除了还要记得打印一下日志让后续维护者知道哪些目录被跳过。构建脚本也是一种代码别人会看。一句话惊醒梦中人自动搜索的代码如果写得不清晰那还不如手动列表好维护。5.4 构建产物也被扫进源列表的尴尬如果构建产物与源码混杂比如你在src目录下开启了VariantDir(build, src)又把编译生成的.o放回去那么Glob(src/**/*.cpp)不会扫到.o但可能扫到生成出来的.c文件例如用工具生成的 C 代码。这些生成文件可能还在构建过程中此时被 Glob 提前扫描到会导致两批文件互相竞争或者 SCons 误以为源文件已经存在。解决办法很简单把生成目录排除到源码树外构建产物绝不放在src下。这属于“项目卫生”问题深度搜索越彻底越要求目录干净。我见过最夸张的工程是把.c和.o放在同一个目录里Glob 一扫生成出来的中间文件全部变成“源文件”构建彻底乱套。5.5 遍历超大代码树的性能优化深度搜索听上去很美但代码仓库有上万个文件时每次执行Glob都可能遍历整个目录树明显拖慢scons的启动。我在一个嵌入式工程里见过 8000 文件Glob 递归用了接近 3 秒。优化手段有几个按模块分目录 Glob缩小范围把不参与构建的目录加入排除用 os.walk 时剪枝或者把收集结果缓存到文件只有当目录时间变化时才重新收集。但说句公道话绝大多数项目在数百文件量级Glob 的开销可以忽略。不必过早优化。只有当scons --debugtime显示出明显瓶颈时再考虑缓存也不迟。构建系统的设计原则永远是先正确再高效。一个错误的自动搜索比慢一点的自动搜索更可怕。6. 让自动收集成为团队规范项目结构约束与玩法6.1 约定源目录与排除目录把规则写进 README自动搜索不是银弹它依赖项目的目录纪律。我现在会在 README 里写明src下所有.c/.cpp/.cc都会被自动编入apptests/、third_party/、build/不会被编入新增模块请创建src/你的模块/。这样别人新增文件不需要知道构建脚本内部逻辑只要遵守目录约定即可。这个约定的价值在于把“构建脚本的隐性知识”外化成文档。你不需要提醒每个人“记得改 SConstruct”因为构建脚本已经不关心文件列表了。目录约定一旦建立新人上手速度会快非常多。6.2 封装 find_sources() 函数一份脚本处处复用我习惯把搜索逻辑封装到tools/sources.py里面提供find_sources(env, rootsrc, extensions(.cpp, .c), exclude_dirs())。不同项目之间复制即可。函数内部可以用 SCons 的 Glob也可以根据情况用 os.walk。返回之前做排序、去重、非空检查。import os from SCons.Errors import UserError def find_sources(env, rootsrc, extensions(.c, .cpp, .cc), exclude_dirs(tests, third_party, build)): def excluded(path): rel os.path.relpath(path, root) return any(rel d or rel.startswith(d os.sep) for d in exclude_dirs) result [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] [d for d in dirnames if not excluded(os.path.join(dirpath, d))] for name in filenames: if name.endswith(extensions): result.append(os.path.join(dirpath, name)) result.sort() if not result: raise UserError(ffind_sources: no source files under {root}) return result env Environment() src_nodes [env.File(p) for p in find_sources(env)] env.Program(app, src_nodes)这样既有 os.walk 的控制力又返回 Node 供 SCons 追踪。这个函数在团队内流传很广基本成了我们所有 SCons 项目的模板。6.3 与 SConscript、VariantDir 配合的目录拆分建议如果项目很大建议每个真实目录一个SConscript每个 SConscript 在自己的目录里调用Glob(*.c)收集本层文件然后向上归并。这样不需要在根目录写一个巨型 Glob模块边界也更清楚。# 根 SConstruct env Environment() env.VariantDir(build/app, src/app, duplicateFalse) env.SConscript(build/app/SConscript, exportsenv)在src/app/SConscript里Import(env) sources Glob(*.c) env.Program(app, sources)这种模式是 SCons 官方推荐的按目录分割。每个 SConscript 内部用 Glob就实现了分布式深度搜索。好处是每个模块的构建逻辑内聚坏处是文件数量多时 SConscript 的数量也会增加。我的经验是模块超过 10 个以后这种拆分带来的管理收益才开始明显。6.4 我的个人实践什么规模选什么搜索方式我现在的默认做法是小项目直接Glob(src/**/*.cpp, recursiveTrue)中大型项目用 SConscript 按模块切分模块内用Glob(*.cpp)模块外做白名单有大量生成文件或模板时再引入自定义 Scanner。手动列表基本已经放弃。这不是说手动列表一无是处而是对于“构建自动化”来说让构建工具主动感知文件系统是更符合直觉的趋势。最后分享一点个人体会深度搜索解放的不仅是你的双手还有你的注意力。把源文件维护从构建脚本里彻底拿掉之后SConstruct 终于只描述“怎么构建”而不是“有哪些文件”。我后来改构建脚本的频率低了很多新增文件只需要落盘剩下的交给 SCons。如果你还在手动维护源文件列表我建议你从最简单的Glob(src/**/*.c, recursiveTrue)开始改造哪怕一次只改一个项目。那个链接失败查半天的心情能少一次是一次。