跳转到内容
← 返回系统剖析
系统剖析当代28 分钟阅读

构建系统:从 Make 到可复现构建

Build Systems — From Make to Reproducible Builds

一个几乎所有程序员都干过的动作:改了代码,make,跑起来行为没变,怀疑人生半小时,最后敲了 make clean && make,一切正常。 这半小时不是手滑,是构建系统给了一个错误的答案。它告诉你"没有需要更新的东西",而这句话是假的。更值得注意的是行业的应对方式——我们没有去修这条判定规则,而是发明了一个叫 ma…

构建系统增量构建内容寻址可复现构建依赖图

一个几乎所有程序员都干过的动作:改了代码,make,跑起来行为没变,怀疑人生半小时,最后敲了 make clean && make,一切正常。

这半小时不是手滑,是构建系统给了一个错误的答案。它告诉你"没有需要更新的东西",而这句话是假的。更值得注意的是行业的应对方式——我们没有去修这条判定规则,而是发明了一个叫 make clean 的仪式,把它写进 README、写进 CI 脚本、写进新人培训。一个持续了五十年的正确性缺陷,被社会化成了一条习惯。

这篇文章讲清楚这条缺陷的机制、修好它需要什么、Bazel 这类系统付出了什么代价去修它,以及为什么修好之后,"构建时间"和"代码总量"就不再是同一件事。

破除误解

误解一:Make 的问题是慢,Bazel 的价值是快。

反了。Make 最贵的问题是它会给出错误的增量结果,而慢只是这个问题的补偿成本——make clean 之所以普遍,正是因为大家不再相信它的增量判断。一个诚实的说法是:Make 的增量构建是一个启发式,它在大多数时候碰巧对;而真正的增量构建要求的是证明:只要图上没有任何输入变过,就可以断言输出不变。这两件事在工程上的差距,等同于"通常没崩"和"不会崩"的差距。

误解二:Bazel 快是因为并行度高、机器多。

并行构建早就有了,make -j64 一行搞定,成本为零。Bazel 的速度不来自"把活分给更多核",而来自"这份活以前有人干过,答案存着"。要用上这句话,前提是能精确回答一个问题:什么叫"同一份活"? 只要回答里含有"编译器是系统里的那个 gcc""还读了几个没声明的头文件"这类模糊项,缓存就不能共享——因为你不知道别人机器上的那个"同一份活"是不是真的相同。Bazel 的沙箱、显式依赖、Starlark 的语言限制,全部是为了把这个问题变成一个可以用哈希回答的问题。速度是副产品。

误解三:可复现构建能防住供应链攻击。

可复现构建只保证一件事:从这份源码到这个二进制的映射是确定的,因而任何第三方都能独立重做一遍并逐位比对。 它完全不看源码是不是恶意的——一个后门写在源码里,重建一万次也会一万次得到同一个带后门的二进制,而且每次都"可复现"。它也不能自动排除编译器本身被污染的情况(肯·汤普森 1984 年图灵奖演讲里的那个假设)。它的真实作用是把信任从"构建者"转移到"源码 + 工具链":以前你只能相信发布方的机器,现在你可以自己重建、或者相信十个独立重建者的一致结论。这是信任的转移和分散,不是消除。

Make 的模型:一个 DAG,加一条会说谎的规则

Make 由斯图尔特·费尔德曼 1976 年 4 月在贝尔实验室写成,缘起是一位同事花了一整天调试一个"改了却没生效"的程序——实际上可执行文件根本没重新链接。费尔德曼因此拿了 2003 年的 ACM 软件系统奖。

Make 的模型有一半是对的。target: prerequisites 这样的规则确实定义了一张有向无环图(DAG,边表示"谁依赖谁",无环保证存在合法的构建顺序),Make 也确实按拓扑序遍历它。错的是判定"要不要重做"的那条规则:

mtime(目标) < max(mtime(各前置)),则重建。

这条规则用文件的修改时间近似文件的内容。近似在五种常见情形下会失效,每一种都对应真实世界里的一类事故:

一、时间戳不是内容。 git checkout 一个旧分支,工作区里的文件被写回旧版本,但 mtime 是"现在"。反过来,从备份或缓存里恢复一份文件,内容是新的而 mtime 是旧的,Make 直接跳过重建。时间戳回答的是"这个文件何时被写过",我们真正想问的是"这个文件的内容是什么",两者只在没人乱动时间的世界里等价。

二、时间戳的粒度是有限的。 传统文件系统的 mtime 只有 1 秒精度(macOS 上的 HFS+ 至今如此),而一次编译经常在同一秒内完成。脚本生成源文件、随即编译,两个动作落在同一秒,< 判定为假,重建被跳过。ext4、APFS 记到纳秒能缓解,但这意味着同一个 Makefile 的正确性取决于它跑在哪个文件系统上

三、时钟不是全局的。 NFS 挂载、容器卷、跨机器 rsync,文件的 mtime 来自另一台机器的时钟。GNU Make 遇到这种情况会打印 warning: Clock skew detected. Your build may be incomplete.——这句警告本身就是模型缺陷的自白:它知道自己可能算错,但除了提醒你,什么也做不了。

四、输入远不止文件。CFLAGS-O2 改成 -O0,把 CC 从 gcc 换成 clang,升级了系统的 libc 头文件,改了 $PATH 让另一个编译器排在前面——所有这些都真实地改变了输出,而它们一个都不在依赖图上。Make 的图只画了文件,而编译这个函数的真实参数里,文件只占一半。

五、依赖是手写的,所以是不全的。 C 语言的头文件依赖靠 gcc -MMD 生成 .d 文件再 -include 回来——注意这是先编译一次才知道依赖是什么的循环,是一块补丁而不是模型的一部分。首次构建、生成式代码、跨目录的递归 Make,都会漏边。彼得·米勒 1998 年发表于 AUUGN 的《Recursive Make Considered Harmful》把这一类问题归结成同一句话:递归 Make 把一张完整的图人为切成了几张互不相识的子图,每个子 Make 只看见自己那块,跨块的边根本不存在,于是构建顺序对了、内容错了。

还有一个不算 bug 却代价高昂的性质:没有早停(early cutoff)。你在一个头文件里改了个注释,重新生成的文件内容与旧版逐字节相同,但 mtime 变新了,于是依赖它的三百个编译单元全部重编。Make 无法知道"输出没变",因为它从不看内容。

把五条加一条放在一起,结论是清楚的:make clean 不是懒惰,是理性——面对一个可能给出错误答案的增量系统,全量重建是唯一可靠的止损。

正确的增量构建需要什么

要让"跳过重建"成为一个可以断言的结论,而不是一次赌博,需要三个条件同时成立:

  1. 依赖图必须完整。 每一个真实影响输出的输入都必须在图上——包括编译器二进制本身、命令行参数、被读取的环境变量。凡是没在图上的输入,都是一颗定时炸弹。
  2. 判定必须基于内容,不是时间。 用密码学哈希(SHA-256 之类)给每个输入取指纹。内容变了指纹必变,内容没变指纹必不变,与谁在什么时候写过这个文件无关。
  3. 动作必须是纯函数。 同样的输入必须给出同样的输出。否则缓存里存的那个答案,对下一个人是错的。

莫霍夫、米切尔与佩顿·琼斯 2018 年在 ICFP 发表的《Build Systems à la Carte》把构建系统拆成两个正交的选择:调度器(决定以什么顺序执行任务)与重建器(决定某个任务要不要执行)。真正决定正确性与可缓存性的是后者,它有四个层次:

重建器策略依据代表系统能否早停缓存能否共享
脏位(dirty bit)时间戳新旧Make
校验痕迹(verifying traces)存上次各输入的哈希Shake、Ninja(命令行层面)
构造痕迹(constructive traces)存输入哈希与产出的值Bazel
深度构造痕迹(deep constructive traces)只看终端输入(源码 + 工具链)Nix、Buck

从第一行到第二行,买到的是正确性:换了编译器参数、改了内容再改回来,系统都判得准。从第二行到第三行,买到的是云缓存:既然连输出都存了,别人算过的结果我可以直接下载——这是"构建时间与代码量脱钩"的分水岭。第四行更激进:Nix 认为只要源码和工具链的哈希相同,中间产物就必然相同,因此连中间步骤都不必逐层核对,代价是它必须假定每一步都严格确定。

Ninja 是个有意思的中间态:它把每条命令行的哈希记进 .ninja_log,命令变了就重建——这补上了 Make 最刺眼的那个洞;但在文件层面它仍然看 mtime。它的定位本来就不是"正确",是"在正确的图已经被 CMake 生成好之后,尽可能快地跑完"。

Bazel 式的封闭构建:用约束换可缓存

Bazel 是 Google 内部构建系统 Blaze 的开源版本,2015 年 3 月放出,同年 9 月进入 beta,2019 年 10 月发布 1.0,此后走 LTS 节奏(7.0 在 2023 年 12 月,8.0 在 2024 年 12 月)。它的设计目标从一开始就不是"给单个项目提速",而是"让一个两千万文件的单体仓库还能被构建"。

它把构建拆成三个阶段:加载(解析 BUILD 文件)→ 分析(求值规则,产出动作图)→ 执行(真正跑动作)。

BUILD 文件用的是 Starlark——一种被刻意削弱的 Python 方言:没有 I/O,不能读文件、不能问系统装了什么,不允许递归,循环只能遍历有限集合,数据结构在传出去之后不可变。为什么要削弱?因为分析阶段的结果本身也要能缓存、能并行、能重放。一个可以随手 os.environsubprocess 的配置语言,其求值结果就不是它输入的函数,整栋楼的地基就没了。

分析阶段产出的是动作(action)——这是整个系统的原子。一个动作是一个结构体,至少包含:

argv:        ["/tools/gcc", "-c", "foo.c", "-o", "foo.o", "-O2"]
inputs:      {foo.c: sha256:ab3f…, stdio.h: sha256:9c1d…, gcc: sha256:77e0…}
outputs:     ["foo.o"]
env:         {PATH: "/bin:/usr/bin"}
platform:    {container-image: "…", OSFamily: "Linux"}
```

把这个结构体序列化后取哈希,得到 action key。注意 inputs 里存的是文件内容的摘要而不是路径——所以两台机器上路径不同、mtime 不同、但内容相同的输入,会算出同一个 key。整个缓存机制就架在这一点上。

要让这个 key 不撒谎,必须堵住"动作偷偷读了没声明的东西"。Bazel 的手段是沙箱:Linux 上 linux-sandbox 用 mount、PID、network 等命名空间拼出一个只包含已声明输入的目录树;macOS 上用 sandbox-exec;再退一步有 processwrapper-sandbox沙箱在这里不是安全机制,是正确性机制——它把"读到了一个没声明的头文件"从一个静默的、几周后才炸的正确性 bug,变成一条当场报出的 file not found。环境同理:--incompatible_strict_action_envPATH 固定成静态值,不再继承你的 shell;工具链通过 toolchain 规则显式声明,而不是"系统上装的那个"。外部依赖也一样,http_archive 必须写 sha256——否则同一个 URL 明天返回不同内容,key 就成了谎言;bzlmod 的 MODULE.bazel.lock 把整棵外部依赖树的解析结果钉死(Bazel 7 起默认启用 bzlmod,Bazel 8 起默认不再读 WORKSPACE)。

代价写在 Bazel 自己的文档里:no-sandboxno-cacheno-remote 这三个标签的存在,本身就是对"总有动作封闭不了"的承认。一旦有人为了让某个祖传脚本跑通而打上 no-sandbox,这个动作就退回到了 Make 的世界——而且更危险,因为周围所有人还以为它是可信的。

远程缓存与远程执行:为什么构建时间能和代码量脱钩

有了内容哈希的 action key,剩下的是把它变成网络协议。Bazel 定义的 Remote Execution API v2bazelbuild/remote-apis,Buck2、Pants 等也在用)只有两个存储:

  • CAS(内容寻址存储):按内容摘要存 blob。同样的内容全世界只存一份。
  • ActionCache:一张映射表,键是 action digest,值是 ActionResult(输出文件各自的摘要、退出码、stdout/stderr)。

输入目录树用 Merkle 树描述:一个 Directory 消息里列出文件(各带内容摘要)和子目录(各带子目录的摘要),递归上去,整棵输入树最终归结为一个 root digest。于是"在这样一棵目录树里执行这条命令"这件事,可以被一个哈希完整表达——这正是 Git 用树对象表示快照的同一招,只是这里用来表示"计算的输入"。

一次动作的完整流程:

  1. 客户端本地算出 action digest;
  2. GetActionResult 查 ActionCache——命中就直接拿输出摘要,动作根本不执行;
  3. 未命中,先 FindMissingBlobs 问服务端"这堆输入你有哪些没有",只上传缺的那部分;
  4. Execute 让远端某台 worker 跑,结果写回 CAS 与 ActionCache。

脱钩发生在第 2 步。 一次典型的 CI 构建里,绝大多数动作的输入在这次改动中一个比特都没变,因此它们的 digest 与上一次完全相同,全部命中缓存。构建耗时于是正比于受这次改动影响的那个子图,而不是仓库总量。这就是为什么在一个超过 20 亿行代码、每个工作日约 4 万次提交的仓库里(Potvin 与 Levenberg 2016 年在 CACM 描述的 Google 单体仓库),改一行代码仍然可以在几分钟内构建并测试完毕。

上限在哪里?两处。一是关键路径:依赖链最长的那一串动作必须串行执行,机器再多也压不下去——这是构建时间的理论下界。二是网络:中间产物在 CAS 和本地之间来回搬运,大项目里传输量可能超过计算量。Bazel 对第二点的答案叫 "Build without the Bytes":只下载最终请求的顶层产物,中间产物留在远端只保留摘要(--remote_download_outputs=toplevel,Bazel 7 起为默认)。

这一整套的前提只有一条:动作必须是纯的。一个不纯的动作污染的不是自己的机器,是共享缓存——它把一个错误答案以正确的键写进去,此后所有人都会命中它,而且没人知道该怀疑谁。回头看沙箱,就不会觉得那是洁癖了。

可复现构建:从"我能缓存"到"你能验证"

把纯度要求推到极限,就得到可复现构建:给定同一份源码、同样的构建指令与同样声明的构建环境,任何人在任何时候都能得到逐位相同的产物。

这件事之所以不平凡,是因为工具链里到处是不确定性的来源:

不确定性来源具体表现常见对策
嵌入时间__DATE__/__TIME__、tar 与 zip 的时间戳字段SOURCE_DATE_EPOCH 环境变量
构建路径__FILE__、调试信息里的绝对路径-ffile-prefix-map
目录读取顺序readdir 返回顺序随文件系统状态变化,导致链接顺序不同显式排序
哈希表遍历顺序符号表、字典的迭代顺序随内存地址变化有序容器 / 固定种子(GCC 的 -frandom-seed
并行调度make -j 的完成顺序影响归档内的成员次序归档前排序
环境泄漏locale、时区、构建者用户名与主机名环境固定并记录

SOURCE_DATE_EPOCH 是 Reproducible Builds 项目 2015 年定义的一条极小规范:构建系统把一个 Unix 时间戳放进这个环境变量,所有工具都用它替代"当前时间"。GCC 7 起支持,Clang、CMake、dpkg、Sphinx 等陆续跟进。比对差异的工具是 diffoscope,它会递归拆开归档、反汇编、解包,把"两个二进制不一样"细化到"这一处是嵌入的构建日期"。

它为什么是供应链防御。 2020 年的 SolarWinds 事件里,攻击者没有改公开源码、没有偷签名密钥,而是渗透进构建流程,在编译期把恶意代码注入 Orion 产品,产物带着完全合法的官方签名分发给了约一万八千家机构。针对这条攻击路径,源码审计看不见,签名验证看不见,SBOM 看不见——唯一能发现它的检查是:拿同一份源码独立重建一次,看二进制是否逐位相同。 可复现构建的真正贡献,是把"验证"从一件只有构建者能做的事,变成一件任何人都能做的事,从而把单点信任换成多方独立重建的共识(SLSA、rebuilderd 这类框架与基础设施做的就是把这个共识流程工程化)。

它的边界同样清楚。汤普森 1984 年的《Reflections on Trusting Trust》指出:一个被污染的编译器可以在编译"干净的编译器源码"时把自己复制进去,源码里找不到任何痕迹——而这样的构建照样完美可复现。已知的两条出路都很重:大卫·惠勒 2005 年提出的多样化双重编译(用另一个来源完全不同的编译器交叉验证),以及 GNU Guix 2023 年完成的全源码自举——把整条工具链的二进制种子压缩到一个 357 字节的 hex0 程序,小到可以逐字节人工审阅,再从它一层层构建出 M2-Planet、GNU Mes 直到 GCC。

进展是实打实的:Debian 从 2013 年开始系统性推动这件事,如今 reproduce.debian.net 上 amd64 架构的可复现比例已在九成以上。但注意这个数字花了十三年,而且改的大多不是构建系统,是几千个上游软件包里的几千处小随意

代价:为什么大多数项目不该迁

一篇只讲好处的文章会让你以为下一步是把公司的 Makefile 换成 BUILD。真实的账不是这么算的。

迁移成本主要不在工具,在生态观念的冲突。 语言原生的包管理器(pip、npm、cargo、Maven)的世界观是"解析器给我一个能用的环境"——依赖解析可以联网、可以跑 setup.py、可以按当前平台探测。封闭构建的世界观是"我要精确声明每一条边"。这两者不是接口不兼容,是前提互斥。所以每接一门语言就需要一层规则集(rulespython、rulesgo、rules_js 都经历过不止一次推倒重来),每引入一个第三方库都可能需要有人手写 BUILD 文件。

一个具体的例子:绝大多数 C/C++ 库靠 ./configure 在构建时探测系统有没有某个符号、某个头文件——这个行为在封闭构建里是非法的,因为它的输出取决于没声明的输入。可选的两条路是:手写一份 BUILD 把配置结论钉死(准确,但每次上游升级都要重做),或者用 rules_foreign_cc 把整套 autotools 塞进一个巨大的动作(省事,但这个动作内部无法细粒度增量,恰好丢掉了迁移的主要收益)。这是典型的"要么改造世界,要么放弃收益"。

日常摩擦是持续的、分摊到每个人的。 加一句 import 要同步改 BUILDdeps;漏了一条依赖,报错信息出现在沙箱层而不是编译器层,新人看不懂;IDE 的跳转与补全需要额外一层导出(比如用 aspect 生成 compile_commands.json);本地一次冷启动分析千万级动作图,内存占用以 GB 计。

粗略的回本公式:

收益 ≈ 缓存命中率 × 单次构建时长 × 构建频次 − 声明成本 × 变更频次

一个构建一分钟、五个人维护的仓库,右边那项永远赢——迁 Bazel 是纯亏。反过来,一个多语言、上千工程师、CI 每天跑几千次的单体仓库,左边那项大到没有别的选择。中间地带有一排成本低得多的方案:

方案依赖判定覆盖范围远程缓存迁移成本
Make + -MMDmtime全部极低
CMake + Ninjamtime + 命令行哈希全部
ccache / sccache预处理后源码 + 编译器 + 参数的哈希仅编译单元极低
Gradle 构建缓存任务输入哈希JVM 生态为主
Turborepo / Nx任务级输入哈希前端工作区
Nix / Guix深度构造痕迹全部(含系统依赖)
Bazel / Buck2构造痕迹 + 沙箱全部很高

ccache 值得单独一提:它用"预处理后的源码 + 编译器 + 参数"的哈希做键,思路与 Bazel 完全一致,但只管一个编译单元这一层,因此几乎零迁移成本就能拿到很大一块收益。这提示了一条常被忽略的路径——内容寻址缓存的价值可以增量获取,不必整体迁移

最后一条同样重要的诚实话:可复现构建与 Bazel 不是一回事,不要捆绑购买。 Debian 用的是 dpkg 加几千个上游补丁,不是任何一个封闭构建系统;Bazel 也不会自动让你的产物可复现(只要某个动作嵌入了 __DATE__,它照样每次不同,只是被缓存掩盖了)。前者要的是"消除工具链里的随意性",后者要的是"把依赖声明完整"。它们互相帮忙,但可以分别推进,而分别推进往往是唯一现实的做法。

跨域连接

  • 网络科学:构建系统的性能特征几乎完全由那张 DAG 的拓扑决定,而不是由代码行数决定——依赖图和真实世界的网络一样是重尾的:少数基础库(日志、字符串、协议定义)的入度极高,改动它们会引发覆盖大半个仓库的重建雪崩,而叶子模块改多少次都只影响自己。这解释了两个日常现象:为什么"删掉一条没用的 #include"有时能把 CI 时间砍掉三成,以及为什么再多机器也压不下构建时间——最长依赖链构成的关键路径是串行的下界,它的长度是图的结构属性,与并行度无关。分层、接口切分、依赖倒置这些架构手段,在构建视角下的真实作用是缩短关键路径。
  • 概率论:整套内容寻址体系押注在一个概率判断上——两份不同的内容不会算出同一个摘要——这不是数学上的保证,而是碰撞概率小到可以当零用。生日界给出的结论是:在 n 位输出的哈希下,随机碰撞需要约 2^(n/2) 个样本才变得可能,SHA-256 对应 2^128,远超任何构建系统的规模。但"随机碰撞"与"人为构造碰撞"是两个模型:MD5 在 2004 年、SHA-1 在 2017 年被攻破的都是后者。共享缓存把这件事从理论变成了威胁模型——谁能往 CAS 里写入,谁就可能用一个精心构造的同摘要 blob 替换掉别人的输入。这就是为什么远程缓存的写权限必须比读权限严格得多。
  • 工业工程与质量:封闭构建做的事,在制造业有一个用了一百年的名字——消除变差。标准作业规定"同样的输入、同样的工序、同样的环境",为的正是让输出可预测;沙箱就是防呆(poka-yoke),它不劝人别用未声明的依赖,而是让这件事在物理上做不到。可复现构建的地位则相当于过程能力指标:一次构建不可复现,说明生产过程里存在未受控的变量,此时任何产品检验的结论都只对这一件产品有效。行业发现的另一件事也在这里重演——检出缺陷最便宜的时刻是它产生的那一刻,把"未声明依赖"从几周后的诡异 bug 提前成当场的编译错误,正是自働化里的"异常即停线"。
  • 供应链:软件的信任问题在实体制造业里被研究了几十年,对应关系精确到令人不安——来料检验对应"这个二进制真的来自那份源码吗",图纸与实物一致性对应可复现构建,单一供应源风险对应"整条工具链只有一个上游",而多源采购的软件版本就是多个独立重建者对同一制品给出的一致结论。制造业早就知道,签收单和合格证证明的是"谁交的货",不是"货是对的"——这正是数字签名与 SBOM 的局限:它们回答来源与成分,不回答"这批货是不是按图纸做的"。SolarWinds 事件里失守的恰恰是产线本身,而产线的完整性只能靠独立复现来验。
  • 什么是真理:可复现构建到底证明了什么,是一个认识论问题而非工程问题——逐位一致属于融贯论式的证据:多方独立重建互相印证,得到的是"这些结果彼此一致",不是"这个二进制忠实实现了源码的意图"。后者属于符合论,需要的是对源码语义的判断,而这恰恰是任何自动化流程给不出的。汤普森的自复制编译器把这条界线摆到了明面上:一个说谎的编译器可以让谎言完美自洽,融贯的证据链在这里全部通过。工程上的回应——多样化双重编译与全源码自举——本质上是缩小"必须无条件相信的那一小块",从整条工具链缩到 357 字节。信任无法归零,只能被压缩到可以人工审阅的尺寸。

参考文献

  • Stuart I. Feldman, Make — A Program for Maintaining Computer Programs, Software: Practice and Experience, 1979 年
  • Peter Miller, Recursive Make Considered Harmful, AUUGN Journal of AUUG Inc., 1998 年
  • Andrey Mokhov, Neil Mitchell, Simon Peyton Jones, Build Systems à la Carte, Proceedings of the ACM on Programming Languages (ICFP), 2018 年
  • Rachel Potvin, Josh Levenberg, Why Google Stores Billions of Lines of Code in a Single Repository, Communications of the ACM 59(7), 2016 年
  • Bazel 项目, Remote Execution API v2bazelbuild/remote-apis)与 Bazel 8.0 LTS 发布说明, 2024 年 12 月
  • Ken Thompson, Reflections on Trusting Trust, Communications of the ACM 27(8), 1984 年

延伸阅读

  • Reproducible Builds 项目, DocumentationSOURCEDATEEPOCH Specification, reproducible-builds.org——按不确定性来源逐条列出成因与修法,是做这件事最实用的清单
  • David A. Wheeler, Countering Trusting Trust through Diverse Double-Compiling, ACSAC 2005 及 2009 年博士论文——把"编译器可能说谎"这个假设变成可执行的验证流程
  • Janneke Nieuwenhuizen 等, The Full-Source Bootstrap: Building from Source All the Way Down, GNU Guix 博客, 2023 年——从 357 字节的 hex0 一路构建到 GCC 的完整记录