利用 blight 实现高保真构建工具插桩

本文介绍了 blight,一个用于包装和插桩 C/C++ 构建工具的开源框架。它支持缓存、静态分析、性能分析和构建保障,通过精确建模编译器命令行语法解决工具复杂性。

高保真构建工具插桩与 blight

TL;DR:我们开源了一个新框架 blight,用于轻松包装和插桩 C 和 C++ 构建工具。我们已在研究项目中使用它,并包含了一组有用的操作。您今天就可以将其用于自己的测量和插桩需求。

为什么要包装构建工具?

作为工程师,我们倾向于将构建工具视为整体:gcc、clang++ 等是黑盒,我们输入内容并获取输出。

然后,我们将构建工具封装在更高级的构建系统(Make、Ninja)或构建系统生成器(CMake)中,以避免 C 和 C++ 编译的常见麻烦:将各个输出链接在一起、维护调用间一致的标志、提供正确的链接器和包含目录等。

这对于日常开发来说足够好,但对于一些特定用例来说还不够。我们将在下面介绍其中一些。

缓存

大多数构建系统都有自己的缓存和中间管理机制(具有不同程度的健全性)。普通构建系统无法做到的是在单独项目之间缓存中间文件:每个项目的构建是封闭的,即使两个项目构建几乎相同的源依赖项。

这就是像 ccache 和 sccache 这样的工具出现的地方:两者都通过包装单个构建工具来提供全局中间缓存,并在特定输入的输出已存在时从正常工具调用中转移¹。

静态分析

现代 C 和 C++ 编译器支持多种语言标准,以及允许或禁止语言功能的定制标志。像 clang-tidy 这样的静态分析工具至少需要部分了解这些参数才能提供准确的结果;例如,它们不应为 C99 之前的 C 版本推荐 snprintf。因此,这些工具需要准确记录程序是如何编译的。

clang-tidy 和其他一些工具支持编译数据库格式,这本质上只是一个 JSON 格式的“命令对象”数组,每个对象包含与构建工具调用相关的命令行、工作目录和其他元数据。CMake 在使用其 Make 生成器时知道如何生成这些数据库;还有一个名为 Bear 的工具通过一些 LD_PRELOAD 技巧做同样的事情。

类似地:LLVM 是一个流行的静态分析目标,但通常很难通用地插桩现有构建系统以发出单个 LLVM IR 模块(或单独的位码单元)来代替单个可执行文件或对象中间文件。像 WLLVM 和 GLLVM 这样的构建工具包装器正是为此目的而存在的。

性能分析

C 和 C++ 构建很快变得复杂。它们也倾向于随着时间的推移积累编译性能问题:

  • 昂贵的标准头文件(如 <regex>)被引入并包含在常见头文件中,膨胀了单个翻译单元的编译时间。
  • 程序员变得聪明并编写复杂的模板和/或递归宏,这两者都是编译器性能的传统痛点。
  • 性能辅助模式(如前向声明)随着抽象破坏而侵蚀。

要修复这些性能问题,我们希望计时每个单独的工具调用并寻找痛点。更好的是,我们希望在每个调用中注入额外的性能分析标志,如 -ftime-report,而不必过多地折腾构建系统。一些构建系统允许通过设置 CC=time cc 或类似方式来实现前者,但当多个构建系统捆绑在一起时,这会变得棘手。后者通过修改 Make 中的 CFLAGS 或 CMake 中的 add_compile_options / target_compile_options 很容易做到,但当构建系统链接在一起或相互调用时,同样变得复杂。

构建和发布保障

C 和 C++ 是复杂的语言,很难(如果不是不可能)编写安全代码。

为了保护我们,我们的编译器添加了缓解措施(ASLR、W^X、控制流完整性)和额外的插桩(ASan、MSan、UBSan)。

不幸的是,构建复杂性再次阻碍了我们:当将多个构建缝合在一起时,很容易意外丢弃(或错误添加,对于发布配置)我们的强化标志²。因此,我们希望有一种方法可以基于所需标志的存在(或缺失)来预测特定构建的成功或失败,无论我们调用多少嵌套构建系统。这意味着像性能分析一样注入和/或移除标志,因此包装再次成为一个有吸引力的解决方案。

构建工具一团糟

我们已经提出了一些构建工具包装器的潜在用例。我们还看到,一个有用的构建工具包装器是那种对其包装的工具的命令行语法有相当了解的包装器:如何可靠地提取其输入和输出,以及正确建模许多改变工具行为的标志和选项。

不幸的是,这说起来容易做起来难:

GCC,历史上占主导地位的开源 C 和 C++ 编译器,有数千个命令行选项,包括数百个操作系统和 CPU 特定选项,这些选项可能以微妙的方式影响代码生成和链接。此外,因为生活中没有什么是容易的,GCC 前端对于接受值的选项至少有四种不同的语法:

  • -oVALUE(例如:-O{,0,1,2,3}-ooutput.o-Ipath
  • -flag VALUE(例如:-o output.o-x c++
  • -flag=VALUE(例如:-fuse-ld=gold-Wno-error=switch-std=c99
  • -Wx,VALUE(例如:-Wl,--start-group-Wl,-ereal_main

其中一些一致地重叠,而其他一些只在少数特定情况下重叠。工具包装器需要处理每一种,至少达到包装器预期功能所需的程度。

Clang,(相对)新来者,努力与 gcc 和 g++ 编译器前端兼容。为此,大多数需要为正确包装 GCC 前端而建模的选项对于 Clang 前端是相同的。也就是说,clang 和 clang++ 添加了自己的选项,其中一些在功能上与常见的 GCC 选项重叠。举个例子:clang 和 clang++ 前端支持 -Oz 用于超越(GCC 支持的)-Os 的激进代码大小优化。

最后,奇怪的那些:有 Intel 的 ICC,显然同样努力与 GCC 兼容。还有 Microsoft 的 cl.exe 前端用于 MSVC,据我所知,它在功能上不兼容³。

更仔细的检查还揭示了一些程序员经常相信的关于他们的 C 和 C++ 编译器的错误观念:

“编译器一次只接受一个输入!”

这 admittedly 较少被相信:大多数 C 和 C++ 程序员很早就意识到这些调用…

1
2
3
4
cc -c -o foo.o foo.c
cc -c -o bar.o bar.c
cc -c -o baz.o baz.c
cc -o quux foo.o bar.o baz.o

…可以被替换为:

1
cc -o quux foo.c bar.c baz.c

这对于在命令行上快速构建东西很好,但对于缓存来说不太有利(我们不再有单独的中间对象),并且对于构建工具包装器来说更难建模(我们必须找出输入,即使与其他编译器标志交织在一起)。

“编译器一次只产生一个输出!”

与上面类似:C 和 C++ 编译器将乐意为每个输入产生一个单独的中间输出,只要您不通过 -o 明确要求单个可执行输出:

1
cc -c foo.c bar.c baz.c

…产生 foo.o、bar.o 和 baz.o。这对于缓存再次很好,但需要一点额外的工作。为了正确缓存每个输出,我们需要将每个输入的文件名转换为适当的隐式输出名称。这应该像将源扩展名替换为 .o 一样简单,但并不保证:

  • Windows 主机(等)使用 .obj 而不是 .o。烦人。
  • 正如我们将看到的,并非所有源输入都需要有扩展名。

对我们的工具包装器来说又是更多的工作。

“cc 只编译 C 源文件,c++ 只编译 C++ 源文件!”

这是一个常见的误解:cc 和 c++(或 gcc 和 g++,或…)是完全不同的程序,恰好共享大量命令行功能。

实际上,即使它们是单独的二进制文件,它们通常只是公共编译器前端的薄垫片。特别是,c++ 对应于 cc -x c++,而 cc 对应于 c++ -x c

1
2
# 使用 cc 编译 C++ 程序
cc -x c++ -c -o foo.o foo.cpp

-x <language> 选项启用了一个特别烦人但有用的功能:能够将文件编译为特定语言,即使它们的后缀不匹配。这在做代码生成时很方便:

1
2
# 向编译器承诺 junk.gen 实际上是一个 C 源文件
cc -x c junk.gen

“编译器一次只编译一种语言!”

即使有了上述内容,程序员也假设编译器前端的每个输入必须是相同语言,即,您不能在同一调用中混合 C 和 C++ 源文件。但这并不正确:

1
2
# 将 C 源文件和 C++ 源文件编译成各自的对象文件
cc -c -x c foo.c -x c++ bar.cpp

当前端理解文件后缀时,您甚至不需要 -x <language> 修饰符,就像它对 .c 和 .cpp 所做的那样:

1
2
# 与上面相同
cc -c foo.c bar.cpp

并非每个构建工具都是编译器前端

我们上面忽略了一个关键事实:并非每个构建工具都共享 C 和 C++ 编译器前端的一般语法。实际上,我们感兴趣的有五组不同的工具:

  1. “编译器工具”如 cc 和 c++,通常用 CC 和 CXX 覆盖。我们目前专注于 C 和 C++;常见看到类似的变量用于 Go(GO)、Rust(RUSTC)等。
  2. C 预处理器(cpp),通常用 CPP 覆盖。大多数构建通过编译器前端调用 C 预处理器,但有些直接调用它。
  3. 系统链接器(ld),通常用 LD 覆盖。像预处理器一样,链接器通常通过前端交互,但在处理自定义工具链和链接器脚本时偶尔会自己出现。
  4. 系统汇编器(as),通常用 AS 覆盖。可以像预处理器和链接器一样通过前端使用,但也可以独立看到。
  5. 系统归档器(ar),通常用 AR 覆盖。与最后三个不同,归档器没有集成到编译器前端中,例如,用于静态库构建;用户期望直接调用它。

blight 的架构

我们已经看到在包装和准确建模构建工具行为时出现的一些复杂性。所以现在让我们看看 blight 如何缓解这些复杂性。

像大多数(所有?)构建工具包装器一样,blight 的包装器取代了它们包装的对应物。例如,要使用 blight 的 C 编译器包装器运行构建:

1
2
# -e 告诉 make 总是优先使用环境中的 CC
CC=blight-cc make -e

手动设置每个 CC、CXX 等是繁琐且容易出错的,因此 blight CLI 提供了一些 shell 代码生成魔法来自动化过程:

1
2
3
# --guess-wrapped 搜索 $PATH 以找到合适的工具来包装
eval "$(blight-env --guess-wrapped)"
make -e

在底层,每个 blight 包装器对应于 Tool 的具体子类(例如,blight.tool.AS 用于汇编器),每个子类至少具有以下内容:

  • 包装工具将运行的参数向量(args)。
  • 包装工具将运行的工作目录(cwd)。
  • 一个操作列表,每个操作可以在每个工具运行上注册两个单独的事件:
    • before_run(tool)—在每个工具调用之前运行。
    • after_run(tool)—在每个成功的工具调用之后运行。

Tool 的各个子类使用 mixin 模式进行专门化。例如,blight.tool.CC……

…专门化 CompilerTool,这是一个 mixin 的难题:

每个 mixin 依次提供一个或多个工具之间共同的建模功能。

例如,ResponseFileMixin 专门化 Tool.args 的行为,用于支持 @file 语法通过磁盘文件提供额外参数的工具(特别是 CC、CXX 和 LD):

其他 mixin 大量使用 Python 3 的 Enum 类来严格建模常见工具标志的预期行为,如 -std=STANDARD……

其中 Std:

采取行动

默认情况下,blight 绝对什么都不做:它只是一个用于包装构建工具的框架。当我们开始在每个工具调用之前和之后注入操作时,魔法发生了。

在内部,操作 API 镜像工具 API:每个工具在 blight.action 下有一个对应的类(例如,CC → CCAction):

要添加一个操作,您只需在 BLIGHT_ACTIONS 环境变量中指定其名称。可以用 : 作为分隔符指定多个操作,并将按从左到右的顺序执行。只有“匹配”特定工具的操作才会运行,这意味着父类为 CCAction 的操作永远不会(错误地)在 CXX 调用上运行。

为了将概念带回家,这里是 blight 运行两个库存操作:IgnoreWerror 和 InjectFlags:

在这种情况下,IgnoreWerror 剥离它在编译器工具(即 CC 和 CXX)中看到的任何 –Werror 实例,而 InjectFlags 通过一组嵌套变量注入一组可配置的参数。我们稍后将看到该配置是如何工作的。

例如,这里是 InjectFlags:

特别是,注意 InjectFlags 是一个 CompilerAction,意味着其事件(在这种情况下,只是 before_run)只有在底层工具是 CC 或 CXX 时才会执行。

编写和配置您自己的操作

编写一个新操作很简单:它们位于 blight.actions 模块中,并且都继承自 blight.action.Action 的一个专门化。

例如,这里是一个操作,它在每个 tool.AS(即标准汇编器)调用之前和之后打印一条友好消息……

…而 blight 将处理其余部分—您需要做的就是在 BLIGHT_ACTIONS 中指定 SayHello!

配置

对于需要配置的操作呢,比如一个可配置的输出文件?

记住上面的 InjectFlags 操作:每个加载的操作可以选择通过 self._config 字典接收配置设置,操作 API 在幕后从 BLIGHT_ACTION_ACTIONNAME 解析,其中 ACTIONNAME 是实际操作名称的大写形式。

该环境变量是如何解析的?非常简单:

拼写出来,配置字符串根据 shell 词法规则拆分,然后再次从 KEY=VALUE 对拆分。

这应该是大多数配置需求

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计