使用blight实现高保真构建工具封装与插桩

本文介绍了Trail of Bits开源的blight框架,用于封装和插桩C/C++构建工具,支持缓存优化、静态分析、性能剖析和安全加固等多种应用场景,提供灵活的API和配置机制。

高保真构建工具封装与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++构建很快变得复杂。它们也倾向于随着时间的推移积累编译性能问题:

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

为了修复这些性能问题,我们希望计时每个单独的工具调用并寻找痛点。更好的是,我们希望在不与构建系统太多纠缠的情况下向每个调用注入额外的剖析标志,如-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++编译器,有数千个命令行选项,包括数百个OS和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。烦人。
  • 正如我们将看到的,并非所有源输入都需要有扩展名。

yet more work for our tool wrapper.

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

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 修饰符,就像它对.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),封装工具将从那里运行。
  • 操作列表(Actions),每个操作可以在每个工具运行上注册两个单独的事件:
    • 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 设计