LLVM 项目博客

LLVM 项目新闻和幕后细节

使用 clang 和 lld 进行确定性构建

确定性构建可以降低持续集成成本,并提高您对构建和测试流程的信心。本文概述了确定性构建的含义、确定性构建的优势,以及如何使用 LLVM 工具实现确定性构建。

什么是确定性构建,以及它的优势

如果两次运行构建都生成完全相同的构建输出,则该构建被称为确定性可重复构建。

构建确定性有多种程度,它们越来越有用,但也越来越难实现。

  1. 基本确定性:在同一台机器上的同一目录中对相同的源代码进行完整构建,每次都会生成完全相同的输出,这意味着最终构建工件和所有中间文件的內容哈希不会改变。
    • 一旦您拥有了基本确定性,如果所有构建器都以相同的方式配置(操作系统版本、工具链、构建路径、检出路径等),它们就可以共享构建工件,例如通过使用 distcc。
    • 这也允许根据测试二进制文件和测试输入文件的哈希值对测试套件结果进行本地缓存。
    • 示例:./build src out ; mv out out.old ; ./build src out ; diff -r out out.old
  2. 增量基本确定性:与基本确定性类似,但输出二进制文件在部分重新构建中也不会改变。在使用文件修改时间来决定何时重新构建的构建系统中,这意味着例如更新 C++ 源文件的修改时间(不做任何实际更改)并重新构建将生成与完整构建相同的输出。
    • 这允许构建机器每次不进行完整构建,同时仍然允许缓存编译工件和测试结果。
    • 示例:./build src out ; cp -r out out.old ; touch src/foo.c ; ./build src out ; diff -r out out.old
  3. 本地确定性:与增量基本确定性类似,但构建也与构建目录的名称无关。在同一台机器上对相同源代码进行构建,每次都会生成完全相同的输出,与源代码检出目录或构建目录的位置无关。
    • 这允许机器在不同位置拥有多个构建目录,但仍然共享编译和测试缓存。
    • 示例: cp -r src src2 ; ./build src out ; ./build src2 out2 ; diff -r out out2
  4. 通用确定性:与 3 类似,但构建也与运行构建的机器无关。每个在给定版本中检出项目到任何目录并按照构建说明进行构建的人最终都会在构建输出中得到完全相同的位。
    • 由于确切的本地操作系统和本地安装的软件包不再重要,这允许开发人员与构建机器共享编译和测试缓存,而无需使用难以设置的容器。
    • 这也允许轻松验证其他人完成的构建,以确保输出二进制文件没有被篡改。
    • 示例:./build src out ; ssh remote ./build src out && scp remote:out out2 ; diff -r out out2

攻击计划

为了确保确定性构建保持确定性,您应该设置一个构建器来验证您的构建是确定性的。即使您的构建还没有确定性,您也可以设置一个机器人来验证构建的某些部分是确定性的,然后随着时间的推移扩展检查。

例如,您可以在固定构建目录中进行完整构建,然后将构建工件移出,并进行另一个完整构建。一旦您的编译具有基本确定性,添加一个步骤来检查两个构建目录之间的目标文件是否相同。您甚至可以为特定子目录或构建目标添加增量检查,同时努力实现完全的基本确定性。

一旦您的链接确定性,检查二进制文件是否也相同。一旦所有构建步骤都确定性,比较两个构建目录中的所有文件。

一旦您的构建具有增量确定性,对第一次构建执行增量构建,对第二次构建执行完整构建。一旦您的构建具有本地确定性,在不同的构建路径执行两次构建。

实现基本确定性

基本确定性需要确定性的工具(编译器、链接器等)。工具在内部不能以哈希表顺序输出内容,多线程程序不能以线程完成的顺序写入输出等。所有 LLVM 的工具在使用正确的标志运行时具有确定性的输出,但不一定默认情况下具有。

C 标准定义了预定义宏 __TIME____DATE__,它们扩展为编译源文件的时间。包括 clang 在内的几个编译器还定义了非标准 __TIMESTAMP__。这本质上是非确定性的。您不应该使用这些宏,并且可以使用 -Wdate-time 使编译器在使用它们时发出警告。

如果它们在您无法控制的第三方代码中使用,您可以使用 -Wno-builtin-macro-redefined -D__DATE__= -D__TIME__= -D__TIMESTAMP__= 使它们扩展为空。

当针对 Windows 时,clang 和 clang-cl 默认还会在输出 .obj 文件的 timestamps 字段中嵌入当前时间,因为 Microsoft 的 link.exe 在 /incremental 模式下,如果该字段设置不正确,会静默地错误链接文件。如果您不使用 link.exe 的 /incremental 标志,或者如果您使用 lld-link 链接,您应该将 /Brepro 传递给 clang-cl,使其不要将其当前 timestamps 写入输出。

link.exe 和 lld-link 也将当前时间写入输出 .dll 或 .exe 文件。要使它们改为将二进制文件的哈希值写入此字段,您也可以将 /Brepro 传递给链接器。但是,某些工具,例如 Windows 7 的应用程序兼容性数据库,试图将该字段解释为实际时间戳,如果将其设置为二进制文件的哈希值,可能会感到困惑。对于这种情况,lld-link 还提供了一个 /timestamp: 标志,您可以指定一个显式时间戳,该时间戳将被写入输出。您可以使用它来例如写入代码构建时的提交时间,而不是当前时间,以使其确定性。(但请参阅下面关于嵌入提交哈希值的脚注)。

Visual Studio 的汇编器 ml.exe 和 ml64.exe 也坚持将当前时间写入它们的输出。在无法轻松修复工具以首先写入正确输出的情况下,您需要编写包装器来修复之后的事实。例如,ml.py 是 Chromium 项目使用的包装器,用于使 ml 的输出确定性。

macOS 的 libtool 和 ld64 也坚持将时间戳写入它们的输出。您可以在包装器中将环境变量 ZERO_AR_DATE 设置为 1,以使它们的输出确定性,但这会使旧版 Xcode 版本的 lldb 感到困惑。

Gcc 有时在某些符号混淆情况下使用随机数。Clang 不会这样做,因此不需要将 -frandom-seed 传递给 clang。

最好使您的构建尽可能独立于环境变量,以便环境中的意外本地更改不会影响构建输出。您应该将 /X 传递给 clang-cl,使其忽略 %INCLUDE% 并通过 -imsvc 开关显式传递系统包含目录。同样,最新的 lld-link 版本(LLVM 10 及更高版本,截至本文撰写时仍未发布)理解标志 /lldignoreenv 标志,这使得 lld-link 忽略 %LIB% 环境变量;通过 /libpath: 显式传递系统库目录。

关于将 git 哈希嵌入二进制文件的脚注
将构建二进制文件的 git 提交哈希值或 svn 版本嵌入到二进制文件的 --version 输出中,或者使用该版本作为缓存键以在版本更改时使磁盘缓存失效,可能很诱人。

这不会影响构建的确定性,但会影响您是否使用确定性构建来缓存测试运行结果的命中率。如果您的二进制文件嵌入当前提交,它保证会在每次提交时更改,并且您将无法跨提交缓存测试结果。即使只是修复注释中的错别字、添加非代码文档或仅影响某些二进制文件使用的代码的提交也会更改每个二进制文件。

对于缓存失效,请考虑使用更细粒度的内容,例如仅包含缓存处理代码的目录的最新提交,或包含缓存处理代码的所有源文件的哈希值。

对于 --version 输出,如果您的构建完全确定性,二进制文件本身(及其动态库依赖项)的哈希值可以作为稳定的版本标识符。您可以保留一个从二进制哈希到生成该二进制文件的所有提交哈希值的映射。

仅限 Windows:出于同样的原因,仅使用最新提交的时间戳作为 /timestamp: 可能不是最佳选择。将最新提交的时间戳舍入到 6 小时(或类似)粒度是避免时间戳在每次提交时更改二进制文件的一种可能方法,同时仍然保持时间戳接近现实。对于生产构建,二进制文件的符号服务器密钥是(可执行文件大小、时间戳)对,因此在这里拥有相当细粒度的时间戳对于不将来自连续提交的二进制文件映射到同一个符号服务器密钥非常重要。根据您将生产二进制文件推送到符号服务器的频率,您可能希望使用最新提交的时间戳作为 /timestamp: 用于正式构建,或者您可能希望使用比在开发构建中使用的更细粒度。

实现增量确定性

拥有确定性增量构建主要需要拥有正确的增量构建,这意味着如果文件被更改并且构建重新运行,那么使用此文件的每个文件都需要重新构建。

这与构建系统高度相关,因此本文无法详细说明。

一般来说,每个构建步骤都需要正确声明它依赖的所有输入。

某些工具,例如 Visual Studio 的 link.exe 在 /incremental 模式下,其设计意图是在每次运行时写入不同的输出。如果您关心构建确定性,请不要使用这类本质上非确定性的工具。

构建不应依赖于环境变量,因为构建系统通常不建模环境变量的依赖关系。

实现局部确定性

使构建输出独立于签出或构建目录的名称意味着构建输出不得包含绝对路径,或包含这两个目录名称的相对路径。

一种可能的安排方法是将所有构建目录放到签出目录中。例如,如果您的代码位于 path/to/src,那么您可以在 .gitignore 中添加“out”,并将构建目录放在 path/to/src/out/debug、path/to/src/out/release 等等位置。从每个构建工件到源代码的相对路径为“../../”加上源代码目录中源代码文件的路径,这对于每个构建目录都是相同的。

C 标准定义了预定义宏 __FILE__,它扩展到当前源代码文件的名称。如果 clang 使用绝对路径调用(`clang -c /absolute/path/to/my/file.cc`),它将扩展为绝对路径;如果使用相对路径调用(`clang ../../path/to/my/file.cc`),它将扩展为相对路径。要使您的构建局部确定,请向 clang 传递 .cc 文件的相对路径

默认情况下,clang 在内部使用绝对路径来引用编译器内部头文件。传递 -no-canonical-prefixes 可以使 clang 对这些内部文件使用相对路径。

向 clang 传递相对路径会使 clang 将 __FILE__ 扩展为相对路径,但调试信息中的路径默认仍然是绝对路径。传递 -fdebug-compilation-dir . 可以使调试信息中的路径相对于构建目录。(在 LLVM 9 之前,这是一个内部 clang 标志,必须使用 `-Xclang -fdebug-compilation-dir -Xclang .`)。当使用 clang 的集成汇编器(默认)时,-Wa,-fdebug-compilation-dir,. 将对从汇编输入创建的目标文件执行相同的操作。(对于 ml.exe / ml64.exe,请参见上面“基本确定性”部分链接的脚本。)

使用这种方法意味着调试器不会自动找到属于您的二进制文件的源代码。目前,无法告诉调试器解析相对于二进制文件位置的相对路径 (DWARF 提案gdb 修补程序)。有关如何配置常用调试器以使其正常工作,请参见本节末尾。

有一些标志试图使编译器即使在传递给编译器的文件名是绝对路径的情况下,也能在输出中生成相对路径 (-fdebug-prefix-map-ffile-prefix-map-fmacro-prefix-map)。 不要使用这些标志
  • 它们通过添加 lhs=rhs 替换模式来工作,并且 lhs 必须是绝对路径才能从输出中删除绝对路径。这意味着,虽然它们使编译输出与路径无关,但它们使编译命令本身与路径相关,从而阻碍了分布式编译缓存。使用 -grecord-gcc-switches-frecord-gcc-switches 时,编译命令会嵌入到调试信息中,甚至嵌入到目标文件中本身,因此在这种情况下,这些标志甚至会破坏局部确定性。(-grecord-gcc-switches-frecord-gcc-switches 在 clang 中默认都为 false。)
  • 它们不会影响使用分裂时 dwo 文件中的路径;向编译器传递相对路径是使这些路径成为相对路径的唯一方法。
在 Windows 上,PDB 包含相对路径的情况非常少见。您可以将 /pdbsourcepath:X:\fake\prefix 传递给 lld-link,使其将目标文件中的所有相对路径解析到固定的绝对路径,以确保最终的 PDB 包含绝对路径。由于绝对路径针对固定前缀,因此不会影响确定性。通过这种方式,clang-cl 和 lld-link 创建的二进制文件和 PDB 将完全确定且与构建路径无关。

同样在 Windows 上,链接器默认情况下会将生成的 PDB 文件的绝对路径放到输出二进制文件中。当您传递 /debug 时,请传递 /pdbaltpath:%_PDB%,使链接器改为写入生成的 PDB 文件的相对路径。如果您有从二进制文件中提取 PDB 名称的自定义构建步骤,您必须确保这些脚本能够处理相对路径。Microsoft 的工具(调试器、ETW)在大多数情况下可以很好地处理这种情况,并且您可以在它们无法处理的情况下添加一个符号搜索路径(当二进制文件在运行之前被复制时)。

使调试器能够很好地处理局部确定性构建
目前,没有调试器提供解析调试信息中相对于调试二进制文件所在目录的相对路径的选项。

一些调试器(gdb、lldb)确实尝试解析相对于 cwd 的相对路径,因此使调试工作的一个简单方法是在调试之前 cd 到您的构建目录中。

如果您不想要求开发人员 cd 到构建目录中才能进行调试,则必须进行调试器特定的配置调整。

为了确保开发人员不会错过这一点,您可以让自定义初始化脚本设置一个环境变量,并在测试二进制文件启动早期查询它是否已设置,如果环境变量未设置,则以类似“将 `source /path/to/your/project/gdbinit` 添加到您的 ~/.gdbinit”的消息退出。

gdb
`dir path/to/build/dir` 告诉 gdb 要解析相对路径的目录。

`show debug-file-directory` 打印 gdb 在其中查找 dwo 文件的目录列表。查询该列表,附加 `:path/to/build/dir`,并调用 `set debug-file-directory` 将您的构建目录添加到该搜索路径中。

例如,请参见 Chromium 的 gdbinit(它还执行一些其他无关的操作)。

lldb
`settings set target.source-map ../.. /absolute/path/to/build/dir` 可以映射所有 .cc 文件在使用上述设置时将引用的“../..” 前缀,该设置使用绝对路径。这需要 Xcode 10.3 或更高版本;与 Xcode 10.1 附带的 lldb 在此设置下存在问题。

例如,请参见 Chromium 的 lldbinit

Visual Studio 的调试器和 windbg
如果您使用的是上述设置,/PDBSourcePath:X:\fake\prefix 将与“..\..\my\file.cc”相对路径结合,使您的代码出现在“X:\my\file.cc”中。要使 Windows 调试器找到它们,您有两个选项
  1. 在启动调试器之前在 cmd.exe 中运行 `subst X: C:\src\real\root`,以创建一个将 X: 映射到实际源代码位置的虚拟驱动器。windbg 和 Visual Studio 都可以通过这种方式加载 X: 上的代码。
  2. 将“C:\src\real\root”添加到每个调试器的源代码搜索路径中。
    • Windbg: 运行 `.srcpath+ C:\src\real\root`。您也可以通过 _NT_SOURCE_PATH 环境变量或通过 文件->源代码文件路径(Ctrl+P)来设置。或者在从命令行启动 windbg 时传递 `-srcpath C:\src\real\root`。
    • Visual Studio: IDE 具有 “调试源文件”属性。将 C:\src\real\root 添加到“包含源代码的目录”中,转到 项目->属性(Alt+F7)->通用属性->调试源文件->包含源代码的目录。
或者,您可以将实际构建目录的绝对路径传递给 /PDBSourcePath:,而不是像“X:\fake\prefix”这样的东西。这样,所有 PDB 都会在其中包含“正确”的绝对路径,而您的编译步骤仍然与路径无关,并且可以跨机器共享缓存。但是,由于可执行文件包含对 PDB 哈希的引用,因此您的任何二进制文件都将与路径无关。这种设置不需要任何调试器配置,但它不允许您的构建局部确定性。

实现通用确定性

到目前为止,您的构建输出只要每个人都使用相同的编译器和链接器二进制文件,并且每个人都使用 SDK 和系统库的版本,就会是确定的。

使您的构建独立于此需要确保每个人都自动使用相同的编译器、链接器和 SDK。

这看起来可能需要做很多工作,但除了构建确定性之外,这项工作还为您提供了交叉构建(例如,您可以在 Windows 主机上构建产品的 Linux 版本)。

它还会对您的代码中使用的编译器、链接器和 SDK 进行版本控制,这意味着您将能够自动将所有机器人和开发人员更新到新版本(如果更新导致问题,则很容易恢复)。

您需要将当前使用的编译器、链接器和 SDK 版本存储在源代码控制存储库中的文件中,并从某种在拉取最新版本源代码后运行的钩子中,从某种云存储服务中下载正确版本的编译器、链接器和 SDK。

然后,您需要修改构建文件以使用 --sysroot(Linux)、-isysroot(macOS)、-imsvc(Windows)来对构建使用这些隔离的 SDK。它们需要位于您的源代码根目录下,以避免回归构建目录名称不变性。

您还需要确保您的构建不依赖于环境变量,如“实现增量确定性”中已经提到的,因为不同机器之间的环境可能非常不同且难以控制。

构建步骤不应该在构建输出中嵌入当前机器的主机名或登录的用户名,或者类似的信息。

总结

这篇文章解释了确定性构建是什么,构建确定性如何跨越一个范围(从局部、仅固定构建目录路径到完全独立于主机操作系统),而不是仅仅是二进制的,以及如何使用 LLVM 的工具使您的构建确定性。它还涉及了使您的测试缓存更有效的技术。

感谢 Elly Fong-Jones 帮助编辑和构建这篇文章,并感谢 Adrian McCarthy、Bob Haarman、Bruce Dawson、Dirk Pranke、Fumitoshi Ukai、Hans Wennborg、Kai Naschinski、Reid Kleckner、Rui Ueyama 和 Takuto Ikuta 阅读草稿并提出改进建议。