什么是确定性构建?

确定性构建是指使用相同的源代码、相同的构建环境和构建指令,在两次构建中生成相同的二进制文件,即使它们是在不同的机器、构建目录和不同的名称下进行构建。如果保证即使从不同的文件夹编译也能生成相同的二进制文件,它们有时也被称为可重复构建或密封构建。

确定性构建并非自然而然发生的。普通的项目不会产生确定性构建,而它们未被产生的原因在每个操作系统和编译器中可能有所不同。

对于给定的构建环境,应保证确定性构建。这意味着某些变量(如操作系统构建系统版本目标架构)假定在不同的构建之间保持不变。

在过去的几年里,来自不同组织的许多努力都在致力于实现确定性构建,例如 Chromium可重复构建Yocto

确定性构建的重要性

确定性构建之所以重要,主要有两个原因:

  • 安全性。修改二进制文件而不是上游源代码,会使更改对原始作者不可见。这在医疗、航空航天和汽车等安全关键环境中可能是致命的。对于给定的输入,承诺相同的结果,允许第三方就正确的结果达成共识。

  • 可追溯性和二进制文件管理。如果您希望拥有一个存储二进制文件的仓库,则不希望从相同修订版本的源代码生成具有随机校验和的二进制文件。这可能会导致仓库系统将不同的二进制文件存储为不同的版本,而实际上它们应该是相同的。例如,如果您在 Windows 或 MacOs 上工作,即使是最简单的库也会导致二进制文件具有不同的校验和,因为这些操作系统的库格式中包含时间戳。

C/C++ 构建过程中涉及的二进制文件

在 C/C++ 的构建过程中,根据操作系统的不同,会创建不同类型的二进制文件。

  • Microsoft Windows。最重要的文件是扩展名为 .obj.lib.dll.exe 的文件。它们都遵循可移植执行体格式 (PE) 的规范。可以使用诸如 dumpbin 等工具分析这些文件。

  • Linux。扩展名为 .o.a.sonone(对于可执行二进制文件)的文件遵循可执行和可链接格式 (ELF)。可以使用 readelf 分析 ELF 文件的内容。

  • Mac OS。扩展名为 .o.a.dylibnone(对于可执行二进制文件)的文件遵循 Mach-O 格式规范。可以使用 otool 应用程序检查这些文件,该应用程序是 MacOs 中 XCode 工具链的一部分。

差异来源

许多不同的因素会导致您的构建不确定。因素在不同的操作系统和编译器之间会有所不同。每个编译器都有特定的选项来修复不确定性的来源。迄今为止,gccclang 是包含更多选项来修复差异来源的编译器。对于 msvc,有一些未公开记录的选项可以尝试,但最终,您可能需要修补二进制文件才能获得确定性构建。

编译器/链接器引入的时间戳

我们的二进制文件最终可能包含时间信息,这将导致它们不可重复,主要有两个原因:

  • 在源代码中使用 __DATE____TIME__ 宏。

  • 当文件格式的定义强制在目标文件中存储时间信息时。Windows 中的可移植执行体格式和 MacOs 中的 Mach-O 就是这种情况。在 Linux 中,ELF 文件不编码任何类型的时间戳。

让我们举一个此信息在 MacOs 中链接静态库的基本 hello world 项目中最终在哪里结束的例子。

.
├── CMakeLists.txt
├── hello_world.cpp
├── hello_world.hpp
├── main.cpp
└── run_build.sh

库在终端中打印一条消息

#include "hello_world.hpp"
#include <iostream>
void HelloWorld::PrintMessage(const std::string & message)
{
    std::cout << message << std::endl;
}

并且应用程序将使用它来打印“Hello World!”消息

#include <iostream>
#include "hello_world.hpp"
int main(int argc, char** argv)
{
    HelloWorld hello;
    hello.PrintMessage("Hello World!");
    return 0;
}

我们将使用 CMake 构建项目

cmake_minimum_required(VERSION 3.0)
project(HelloWorld)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(HelloLibA hello_world.cpp)
add_library(HelloLibB hello_world.cpp)
add_executable(helloA main.cpp)
add_executable(helloB main.cpp)
target_link_libraries(helloA HelloLibA)
target_link_libraries(helloB HelloLibB)

我们使用完全相同的源代码构建两个不同的库,并使用相同的源代码构建两个二进制文件。如果我们构建项目并执行 md5sum 以显示所有二进制文件的校验和

mkdir build && cd build
cmake ..
make
md5sum helloA
md5sum helloB
md5sum CMakeFiles/HelloLibA.dir/hello_world.cpp.o
md5sum CMakeFiles/HelloLibB.dir/hello_world.cpp.o
md5sum libHelloLibA.a
md5sum libHelloLibB.a

我们将得到如下输出

b5dce09c593658ee348fd0f7fae22c94  helloA
b5dce09c593658ee348fd0f7fae22c94  helloB
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibA.dir/hello_world.cpp.o
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibB.dir/hello_world.cpp.o
adb80234a61bb66bdc5a3b4b7191eac7  libHelloLibA.a
5ac3c70d28d9fdd9c6571e077131545e  libHelloLibB.a

这很有趣,因为可执行文件 helloAhelloB 具有相同的校验和,中间的 Mach-O 对象文件 hello_world.cpp.o 也是如此,但 .a 文件并非如此。这是因为它们将中间目标文件的信息存储在 归档格式 中。此格式的标头定义包含一个名为 st_time 的字段,该字段由 stat 系统调用设置。如果我们使用 otool 检查 libHelloLibA.alibHelloLibB.a 以显示标头

> otool -a libHelloLibA.a   
Archive : libHelloLibA.a
0100644 503/20    612 1566927276 #1/20
0100644 503/20  13036 1566927271 #1/28
> otool -a libHelloLibB.a   
Archive : libHelloLibB.a
0100644 503/20    612 1566927277 #1/20
0100644 503/20  13036 1566927272 #1/28

我们可以看到该文件包含多个时间字段,这些字段将使我们的构建不确定。请注意,这些字段不会传播到最终的可执行文件,因为它们具有相同的校验和。如果使用 Visual Studio 在 Windows 中构建,也会出现此问题,但使用的是 可移植执行体 而不是 Mach-O

此时,我们可以尝试使情况变得更糟,并强制我们的二进制文件也变得不确定。如果我们更改 main.cpp 文件以包含 __TIME__

#include <iostream>
#include "hello_world.hpp"
int main(int argc, char** argv)
{
    HelloWorld hello;
    hello.PrintMessage("Hello World!");
    std::cout << "At time: " << __TIME__ << std::endl;
    return 0;
}

再次获取文件的校验和

625ecc7296e15d41e292f67b57b04f15  helloA
20f92d2771a7d2f9866c002de918c4da  helloB
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibA.dir/hello_world.cpp.o
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibB.dir/hello_world.cpp.o
b7801c60d3bc4f83640cadc1183f43b3  libHelloLibA.a
4ef6cae3657f2a13ed77830953b0aee8  libHelloLibB.a

我们看到现在我们也有了不同的二进制文件。我们可以使用诸如 diffoscope 等工具分析可执行文件,该工具向我们显示两个二进制文件之间的差异

> diffoscope helloA helloB
--- helloA
+++ helloB
├── otool -arch x86_64 -tdvV {}
│┄ Code for architecture x86_64
│ @@ -16,15 +16,15 @@
│  00000001000018da	jmp	0x1000018df
│  00000001000018df	leaq	-0x30(%rbp), %rdi
│  00000001000018e3	callq	0x100002d54 ## symbol stub for: __ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEED1Ev
│  00000001000018e8	movq	0x1721(%rip), %rdi ## literal pool symbol address: __ZNSt3__14coutE
│  00000001000018ef	leaq	0x162f(%rip), %rsi ## literal pool for: "At time: "
│  00000001000018f6	callq	0x100002d8a ## symbol stub for: __ZNSt3__1lsINS_11char_traitsIcEEEERNS_13basic_ostreamIcT_EES6_PKc
│  00000001000018fb	movq	%rax, %rdi
│ -00000001000018fe	leaq	0x162a(%rip), %rsi ## literal pool for: "19:40:47"
│ +00000001000018fe	leaq	0x162a(%rip), %rsi ## literal pool for: "19:40:48"
│  0000000100001905	callq	0x100002d8a ## symbol stub for: __ZNSt3__1lsINS_11char_traitsIcEEEERNS_13basic_ostreamIcT_EES6_PKc
│  000000010000190a	movq	%rax, %rdi
│  000000010000190d	leaq	__ZNSt3__1L4endlIcNS_11char_traitsIcEEEERNS_13basic_ostreamIT_T0_EES7_(%rip), %rsi #

这表明 __TIME__ 信息已插入二进制文件,使其不确定。让我们看看我们可以做些什么来避免这种情况。

Microsoft Visual Studio 的可能解决方案

Microsoft Visual Studio 有一个链接器标志 /Brepro,该标志未被 Microsoft 记录在案。此标志将 可移植执行体 格式的时间戳设置为 -1 值,如下面的图像所示。

With BRepro flag

要使用 CMake 激活该标志,如果创建 .exe,则必须添加以下行

add_link_options("/Brepro")

或者对于 .lib,使用以下行

set_target_properties(
    TARGET
    PROPERTIES STATIC_LIBRARY_OPTIONS "/Brepro"
)

问题在于,此标志使二进制文件在最终二进制文件为 .exe 时可重复(关于文件格式中的时间戳),但不会从 .lib 中删除所有时间戳(与上面讨论的 Mach-O 对象文件相同的问题)。.lib 文件的 COFF 文件头 中的 TimeDateStamp 字段将保留。从 .lib 二进制文件中删除此信息的唯一方法是修补 .lib,用任何已知值替换对应于 TimeDateStamp 字段的字节。

GCC 和 CLANG 的可能解决方案

  • gcc 检测 SOURCE_DATE_EPOCH 环境变量的存在。如果设置了此变量,则其值指定一个 UNIX 时间戳,用于替换 __DATE____TIME__ 宏中的当前日期和时间,以便嵌入式时间戳变得可重复。该值可以设置为已知时间戳,例如源代码或包的最后修改时间。

  • clang 使用 ZERO_AR_DATE,如果设置了该变量,则会重置 归档文件 中引入的时间戳,将其设置为 纪元 0。请注意,这不会修复 __DATE____TIME__ 宏。如果我们想修复这些宏的效果,我们应该修补二进制文件或伪造系统时间。

让我们继续我们的 MacOs 示例项目,看看设置 ZERO_AR_DATE 环境变量后的结果。

export ZERO_AR_DATE=1

现在,如果我们构建可执行文件和库(省略源代码中的 __DATE__ 宏),我们将得到

b5dce09c593658ee348fd0f7fae22c94  helloA
b5dce09c593658ee348fd0f7fae22c94  helloB
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibA.dir/hello_world.cpp.o
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibB.dir/hello_world.cpp.o
9f9a9af4bb3e220e7a22fb58d708e1e5  libHelloLibA.a
9f9a9af4bb3e220e7a22fb58d708e1e5  libHelloLibB.a

现在所有校验和都相同了。并分析 .a 文件头

> otool -a libHelloLibA.a
Archive : libHelloLibA.a
0100644 503/20    612 0 #1/20
0100644 503/20  13036 0 #1/28
> otool -a libHelloLibB.a
Archive : libHelloLibB.a
0100644 503/20    612 0 #1/20
0100644 503/20  13036 0 #1/28

我们可以看到库头的 timestamp 字段已设置为零值。

构建文件夹信息传播到二进制文件

如果在不同的文件夹中编译相同的源代码,有时文件夹信息会传播到二进制文件。这主要有两个原因:

  • 使用包含当前文件信息的宏,例如 __FILE__ 宏。

  • 创建存储源文件位置信息的调试二进制文件。

继续我们的 Hello World macOS 示例,让我们将源文件分离,以便展示其对最终二进制文件的影响。项目结构如下所示。

.
├── run_build.sh
├── srcA
│   ├── CMakeLists.txt
│   ├── hello_world.cpp
│   ├── hello_world.hpp
│   └── main.cpp
└── srcB
    ├── CMakeLists.txt
    ├── hello_world.cpp
    ├── hello_world.hpp
    └── main.cpp

如果我们在 Debug 模式下构建二进制文件。

cd srcA/build
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
cd .. && cd ..
cd srcB/build
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
cd .. && cd ..
md5sum srcA/build/hello
md5sum srcB/build/hello
md5sum srcA/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
md5sum srcB/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
md5sum srcA/build/libHelloLib.a
md5sum srcB/build/libHelloLib.a

我们将得到以下校验和

3572a95a8699f71803f3e967f92a5040  srcA/build/hello
7ca693295e62de03a1bba14853efa28c  srcB/build/hello
76e0ae7c4ef79ec3be821ccf5752730f  srcA/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
5ef044e6dcb73359f46d48f29f566ae5  srcB/build/CMakeFiles/HelloLib.dir/hello_world.cpp.o
dc941156608b578c91e38f8ecebfef6d  srcA/build/libHelloLib.a
1f9697ef23bf70b41b39ef3469845f76  srcB/build/libHelloLib.a

文件夹信息从目标文件传播到最终的可执行文件,导致我们的构建不可重现。我们可以使用 diffoscope 来查看二进制文件之间的差异,以了解文件夹信息嵌入的位置。

> diffoscope helloA helloB
--- srcA/build/hello
+++ srcB/build/hello
@@ -1282,20 +1282,20 @@
...
 00005070: 5f77 6f72 6c64 5f64 6562 7567 2f73 7263  _world_debug/src
-00005080: 412f 006d 6169 6e2e 6370 7000 2f55 7365  A/.main.cpp./Use
+00005080: 422f 006d 6169 6e2e 6370 7000 2f55 7365  B/.main.cpp./Use
 00005090: 7273 2f63 6172 6c6f 732f 446f 6375 6d65  rs/carlos/Docume
 000050a0: 6e74 732f 6465 7665 6c6f 7065 722f 7265  nts/developer/re
 000050b0: 7072 6f64 7563 6962 6c65 2d62 7569 6c64  producible-build
 000050c0: 732f 7361 6e64 626f 782f 6865 6c6c 6f5f  s/sandbox/hello_
-000050d0: 776f 726c 645f 6465 6275 672f 7372 6341  world_debug/srcA
+000050d0: 776f 726c 645f 6465 6275 672f 7372 6342  world_debug/srcB
 000050e0: 2f62 7569 6c64 2f43 4d61 6b65 4669 6c65  /build/CMakeFile
 000050f0: 732f 6865 6c6c 6f2e 6469 722f 6d61 696e  s/hello.dir/main
 00005100: 2e63 7070 2e6f 005f 6d61 696e 005f 5f5a  .cpp.o._main.__Z
...
@@ -1336,15 +1336,15 @@
...
 000053c0: 6962 6c65 2d62 7569 6c64 732f 7361 6e64  ible-builds/sand
 000053d0: 626f 782f 6865 6c6c 6f5f 776f 726c 645f  box/hello_world_
-000053e0: 6465 6275 672f 7372 6341 2f62 7569 6c64  debug/srcA/build
+000053e0: 6465 6275 672f 7372 6342 2f62 7569 6c64  debug/srcB/build
 000053f0: 2f6c 6962 4865 6c6c 6f4c 6962 2e61 2868  /libHelloLib.a(h
 00005400: 656c 6c6f 5f77 6f72 6c64 2e63 7070 2e6f  ello_world.cpp.o
 00005410: 2900 5f5f 5a4e 3130 4865 6c6c 6f57 6f72  ).__ZN10HelloWor
...

可能的解决方案

同样,解决方案将取决于使用的编译器

  • msvc 无法设置选项来避免将此信息传播到二进制文件。获得可重现二进制文件的唯一方法是在构建步骤中使用修补工具来去除此信息。请注意,由于我们正在修补二进制文件以实现可重现的构建,因此不同构建使用的文件夹的字符长度应相同。

  • gcc 有三个编译器标志可以解决此问题
    • -fdebug-prefix-map=OLD=NEW 可以去除调试信息中的目录前缀。
    • -fmacro-prefix-map=OLD=NEWgcc 8 开始可用,用于解决由于使用 __FILE__ 宏导致的不可重现性。
    • -ffile-prefix-map=OLD=NEWgcc 8 开始可用,它是 -fdebug-prefix-map-fmacro-prefix-map 的并集。
  • clang 从 3.8 版本开始支持 -fdebug-prefix-map=OLD=NEW,并正在努力在未来版本中支持其他两个标志。

解决此问题的最佳方法是将这些标志添加到编译器选项中。如果我们使用的是 CMake

target_compile_options(target PUBLIC "-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.")

文件顺序馈送到构建系统

如果读取目录以列出其文件,文件排序可能会成为问题。例如,Unix 没有确定性的顺序来返回 readdir()listdir() 的目录内容,因此依赖这些函数来馈送构建系统可能会产生不确定的构建。

例如,如果构建系统将链接器文件存储在一个容器(例如常规的 python 字典)中,该容器可以以不确定的顺序返回元素,则会出现同样的问题。这将导致每次文件链接的顺序不同,并生成不同的二进制文件。

我们可以通过更改 CMake 中的文件顺序来模拟此问题。如果我们修改前面的示例,使其拥有不止一个库源文件

.
├── CMakeLists.txt
├── CMakeListsA.txt
├── CMakeListsB.txt
├── hello_world.cpp
├── hello_world.hpp
├── main.cpp
├── sources0.cpp
├── sources0.hpp
├── sources1.cpp
├── sources1.hpp
├── sources2.cpp
└── sources2.hpp

我们可以看到,如果我们更改 CMakeLists.txt 中的文件顺序,编译结果将不同。

cmake_minimum_required(VERSION 3.0)
project(HelloWorld)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(HelloLib hello_world.cpp 
                     sources0.cpp 
                     sources1.cpp 
                     sources2.cpp)
add_executable(hello main.cpp)
target_link_libraries(hello HelloLib)

如果我们进行两个连续的构建,命名为 AB,交换 sources0.cppsources1.cpp 在文件列表中的位置,则产生的校验和将为

30ab264d6f8e1784282cd1a415c067f2  helloA
cdf3c9dd968f7363dc9e8b40918d83af  helloB
707c71bc2a8def6885b96fb67b84d79c  hello_worldA.cpp.o
707c71bc2a8def6885b96fb67b84d79c  hello_worldB.cpp.o
694ff3765b688e6faeebf283052629a3  sources0A.cpp.o
694ff3765b688e6faeebf283052629a3  sources0B.cpp.o
0db24dc6a94da1d167c68b96ff319e56  sources1A.cpp.o
0db24dc6a94da1d167c68b96ff319e56  sources1B.cpp.o
fd0754d9a4a44b0fcc4e4f3c66ad187c  sources2A.cpp.o
fd0754d9a4a44b0fcc4e4f3c66ad187c  sources2B.cpp.o
baba9709d69c9e5fd51ad985ee328172  libHelloLibA.a
72641dc6fc4f4db04166255f62803353  libHelloLibB.a

目标文件 .o 是相同的,但 .a 库和可执行文件不是。这是因为库中的插入顺序取决于文件列出的顺序。

编译器创建的随机性

例如,当在 gcc 中激活 链接时优化(使用 -flto 标志)时,会出现此问题。此选项会在二进制文件中引入随机生成的名称。避免此问题的唯一方法是使用 -frandom-seed 标志。此选项提供一个种子,gcc 在原本使用随机数的地方使用它。它用于生成某些必须在每个编译文件中不同的符号名称。它还用于在覆盖率数据文件及其生成的目标文件中放置唯一的标记。此设置必须对每个源文件都不同。一种选择是将其设置为文件的校验和,这样碰撞的概率非常低。例如,在 CMake 中,可以使用类似这样的函数来实现

set(LIB_SOURCES
    ./src/source1.cpp
    ./src/source2.cpp
    ./src/source3.cpp)

foreach(_file ${LIB_SOURCES})
    file(SHA1 ${_file} checksum)
    string(SUBSTRING ${checksum} 0 8 checksum)
    set_property(SOURCE ${_file} APPEND_STRING PROPERTY COMPILE_FLAGS "-frandom-seed=0x${checksum}")
endforeach()

一些使用 Conan 的技巧

Conan 的 钩子 可以帮助我们实现构建的可重现性。此功能使我们能够在确定的点自定义客户端行为。

钩子的一种用法是在 pre_build 步骤中设置环境变量。下面的示例调用函数 set_environment,然后在 post_build 步骤中使用 reset_environment 恢复环境。

def set_environment(self):
    if self._os == "Linux":
        self._old_source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
        timestamp = "1564483496"
        os.environ["SOURCE_DATE_EPOCH"] = timestamp
        self._output.info(
            "set SOURCE_DATE_EPOCH: {}".format(timestamp))
    elif self._os == "Macos":
        os.environ["ZERO_AR_DATE"] = "1"
        self._output.info(
            "set ZERO_AR_DATE: {}".format(timestamp))

def reset_environment(self):
    if self._os == "Linux":
        if self._old_source_date_epoch is None:
            del os.environ["SOURCE_DATE_EPOCH"]
        else:
            os.environ["SOURCE_DATE_EPOCH"] = self._old_source_date_epoch
    elif self._os == "Macos":
        del os.environ["ZERO_AR_DATE"]

钩子也可用于在 post_build 步骤中修补二进制文件。有一些不同的二进制文件分析和修补工具,例如 duciblepefilepe-parsestrip-nondeterminism。使用 ducible 修补 PE 二进制文件的钩子示例可能如下所示

class Patcher(object):
...
    def patch(self):
        if self._os == "Windows" and self._compiler == "Visual Studio":
            for root, _, filenames in os.walk(self._conanfile.build_folder):
                for filename in filenames:
                    filename = os.path.join(root, filename)
                    if ".exe" in filename or ".dll" in filename:
                        self._patch_pe(filename)

    def _patch_pe(self, filename):
        patch_tool_location = "C:/ducible/ducible.exe"
        if os.path.isfile(patch_tool_location):
            self._output.info("Patching {} with md5sum: {}".format(filename,md5sum(filename)))
            self._conanfile.run("{} {}".format(patch_tool_location, filename))
            self._output.info("Patched file: {} with md5sum: {}".format(filename,md5sum(filename)))
...

def pre_build(output, conanfile, **kwargs):
    lib_patcher.init(output, conanfile)
    lib_patcher.set_environment()

def post_build(output, conanfile, **kwargs):
    lib_patcher.patch()
    lib_patcher.reset_environment()

结论

确定性构建是一个复杂的问题,与使用的操作系统和工具链高度耦合。本介绍应该有助于理解不确定性的最常见原因以及如何避免它们。

参考文献

一般信息

工具

用于比较二进制文件的工具

用于修补文件的工具

用于分析文件的工具