[Compile翻译]LLVM内部结构,第一部分:比特码格式

703 阅读9分钟

原文地址:blog.yossarian.net/2021/07/19/…

原文作者:blog.yossarian.net/

发布时间:2021年7月19日

前言

我已经写了几篇关于LLVM本身的文章,主要是关于你可以用LLVM做的事情或LLVM如何表示特定的程序特性。

我在这些方面收到了一些很好的反馈,但我想把一个子系列的帖子集中在LLVM的实现本身:文件格式、解析策略和LLVM的公共接口(API、CLI和可消耗的输出文件)背后的算法选择。我将在写这些文章的同时,致力于开发一系列用于摄取LLVM中间表示法的纯Rust库,其最终目标是能够用纯Rust对编译成LLVM IR的程序进行只读分析。

对于那些不知道什么是LLVM的人来说,这篇文章对LLVM的组件和中间表示法有一个更广泛的背景。

Quis repraesentābit ipsos repraesentātinēs?1

LLVM的中间表示法不仅仅是程序通过编译阶段时在内存中的一系列变化:它是一个稳定的程序模型,可以保存在磁盘上,随后重新激活,用于分析,在clang或其他LLVM工具的全新调用中运行2

那么:LLVM是如何在磁盘上忠实地保存其中间表示的呢?

答案是两方面的。LLVM提供了文本形式和二进制形式(用LLVM的说法是比特码),前者看起来像C-家族的编程语言,后者更密集,(表面上)更容易解析。这两种形式在语义上是相同的,而且产生和转换都很简单。

# create foo.bc for foo.c
$ clang -emit-llvm -c foo.c
$ file foo.bc
foo.bc: LLVM IR bitcode

# convert foo.bc into foo.ll (human readable IR)
$ llvm-dis foo.bc
$ file foo.ll
foo.ll: ASCII text, with very long lines

# create foo.ll directly from foo.c
$ clang -S -emit-llvm -c foo.c

LLVM的IR的文本形式读起来很有趣,但它 "只是 "文本:用机器解析起来很慢而且(也许)很烦人,但对了解情况的人来说很容易理解。

相比之下,比特码的形式是非常难以捉摸的,即使按照二进制格式的标准:除了一些可打印的字符串,没有明显的固定大小或长度固定的结构。作为对这种不可捉摸性的交换,它在以节省空间的方式存储LLVM的中间表示法方面表现得非常出色。比较一下SQLite的3.36.0合并版的表示法。

sqlite3.csqlite3.llsqlite3.bc
8.0mb14.0mb1.8mb

小一个数量级并不坏3 4。让我们来看看我们需要做什么来解析自己的比特码格式。

解析LLVM的比特码

LLVM是一个文档齐全的项目,比特码格式也不例外。总结一下。

  • LLVM的比特码格式是一种更通用的容器格式(想想MKV或MP4)的特殊化,即比特流。
  • 位流容器指定了一个块的序列,每个块包含更多(嵌套)的块和/或数据记录。数据记录是比特流结构的叶子,记录特定格式的信息。块和记录的概念大致对应于程序特征(认为是函数、基本块)及其组件(认为是指令、类型)的IR级概念。

这一切听起来都是很好的结构,这就引出了一个问题:为什么在一个比特码文件中没有可见的结构(当用十六进制编辑器查看时)?

答案是在比特码和比特流的位中:块和记录不是在字节(或像字一样大的边界)上排列的;它们被编码为可变长度的比特域。这些位域可以共享一个字节或跨越多个字节,与二进制对象相比,比特码文件具有极高的熵(用binvis.io可视化)。

image.png

sqlite3.bc的熵的可视化,1.8MB

image.png

sqlite3.o的熵的可视化,1.3MB

块和数据记录都是由缩写ID识别的,在LLVM文档中通常简称为 "缩写ID"。在真正的自我规范中,一个比特码文件所使用的大部分缩写ID并不是由容器格式指定的--它们是在比特流本身中通过DEFINE_ABBREV记录的出现来定义的5。事实上,容器格式只指定了4个极其基本的缩写ID:两个用于管理块范围(Enter_SUBBLOCK和END_BLOCK),DEFINE_ABBREV本身,以及UNABBREV_RECORD作为记录的通用低效 "逃逸舱 "编码6

这些缩写ID(以及它们引用的缩写格式,很快就会有更多的介绍)都是紧紧围绕引入它们的块进行的7,但有一个例外:描述LLVM模块的比特流块本身可以包含一个BLOCKINFO块,它又可以为任何后续的块定义多个缩写,这些块与BLOCKINFO指定的块ID匹配。

如果所有这些转述和缩写还不够复杂,比特流容器格式使用三种不同的技术来确定记录的长度和它们的组成比特字段。

  • 每个区块记录以ENTER_SUBBLOCK缩写ID开始,并记录一个新的缩写ID长度。这个新的长度用于解码当前区块中所有后续的缩写ID,子区块定义它们自己的新的缩写ID长度8

  • 所有非默认的缩写ID都有一个相关的缩写定义(通过DEFINE_ABBREV),它指定了块或记录本身的布局。这使得LLVM比特码的发射器可以非常巧妙地处理其记录的大小:IR的内存表示知道每个需要存储的东西的确切数量,并可以利用这一点为每个字段选择准确的比特数量。

  • 最后,可变宽度的整数("VBRs "或LLVM文档中的VBRn)。这些都是以n个比特为单位,每个比特的高位表示流中的后续比特的延续。这些给了比特流容器在一个小的比特空间中表示常见的预期值的能力,同时还为更大的值提供了一个逃生舱门9。还有带符号的VBRs,它可以有效地打包带符号的整数,并通过上下文与普通的VBRs区分开来(例如,在LLVM的IR中,预计会出现负偏移或常数的情况)。

缩略语和BLOCKINFO

如上所述:每个缩写都有一个相应的缩写定义,其在流中的位置决定了其相应的缩写ID。

换句话说:缩写ID从未在任何地方明确定义过。它们是隐式定义的,是适用于当前块范围的DEFINE_ABBREV记录的出现顺序的一部分。

那么,我们如何确定哪些缩写适用于当前区块范围?我们应用三个规则。

  1. 不管我们当前的范围如何,缩写0到3是由比特流容器本身定义的:它们是END_BLOCK、ENTER_SUBBLOCK、DEFINE_ABBREV和UNABBREV_RECORD,如上所述。
  2. 接下来,从4的初始计数器状态开始,我们检查特殊的BLOCKINFO块。BLOCKINFO(基本上)包含一个块ID到缩写记录的映射--我们给每个与我们当前作用域的块ID相匹配的记录分配一个顺序的缩写ID。
  3. 最后,我们计算所有存在于我们当前作用域中的 DEFINE_ABBREV 记录。这些记录也得到了连续的缩写ID。

这些规则在抽象的情况下可能很难理解,所以请考虑下面这个位流容器格式的LLVM IR模块的符号表示(我给DEFINE_ABBREVs起了符号名,以便理解)。

BLOCK MODULE_BLOCK (BLOCK ID #8)
  BLOCK BLOCKINFO (BLOCK ID #0)
    RECORD SETBID #8 (MODULE_BLOCK)
    RECORD DEFINE_ABBREV [...] // "A"
    RECORD DEFINE_ABBREV [...] // "B"
    RECORD SETBID #12 (FUNCTION_BLOCK)
    RECORD DEFINE_ABBREV [...] // "C"
  RECORD DEFINE_ABBREV [...] // "D"
  RECORD DEFINE_ABBREV [...] // "E"
  BLOCK FUNCTION_BLOCK (BLOCK ID #12)
    RECORD DEFINE_ABBREV [...] // "F"

在这里,我们有三个潜在的块作用域(顶层的MODULE_BLOCK,有一个嵌套的BLOCKINFO和FUNCTION_BLOCK)。每个的非默认缩写ID是。

  • MODULE_BLOCK:A(#4),B(#5),D(#6),E(#7
  • BLOCKINFO。无(只有内置的缩写ID!)。
  • function_block: c (#4), f (#5)

请注意,这里有一些微妙之处:MODULE_BLOCK被指定为顶级块,BLOCKINFO被嵌套在其中,但BLOCKINFO可以(在本例中也是如此)包含缩写定义,我们必须解释这些缩写以正确解析MODULE_BLOCK。因此,我们有可能需要在比特流中向前跳跃,解释BLOCKINFO,然后才能正确解释我们的实际起点10。

我只是想烧烤解释IR!

所有这些文字,我们并没有明显地接近真正从LLVM生成的比特码文件中获得IR语义。呜呼。

这里的所有内容都是解释比特流容器的重要背景,但是从比特流中获得实际的IR语义是一个完全不同的、独立的野兽。我打算在随后的文章中(大约一个月)专门讨论这个问题:利用这里的基元和黑客技术来建立一个可以解释比特码模块的Rust库11。反过来,这个库将成为一个更高级别的库的底层,该库为实际分析LLVM IR提供了令人愉快的API。所有这些都不需要构建或链接到你的系统中的LLVM的构建!

这个(漫长)过程的第一步已经完成。我正在开源bitcursor.rs的初始版本,它将在比特流容器上提供一个比特粒度的光标。它也已经支持了VBRn解码。


  1. 在此向Juvenal表示歉意

  2. 这方面最简单的例子是LLVM对LTO的实现:任何重新纠错的LLVM IR的 "完整性"(w.r.t.信息)对于任何链接时间优化的正确性都是必要的。

  3. 有趣的是,应用天真的压缩(仅仅是gzip)可以显著缩小差距:sqlite3.ll.gz只有1.9MB,而sqlite3.bc.gz是1.2MB。LLVM的作者选择了一个相对复杂的比特码格式而不是一个更简单的格式(这也会比目前的格式压缩得更好),可能有一些原因,但我不知道。

  4. 我把使用的命令保存在这里。 clang是clang-10,版本10.0.0-4ubuntu1。

  5. 这就是LLVM的比特流容器开始有点像BER的地方,这不是一个令人愉快的相似之处。

  6. 在随后的文章中会有更多关于这些的内容(以及所有实际的针对IR的内容)。

  7. 意味着某个特定块的父块和子块都不能使用该缩写。

  8. 把LLVM位流解释器看作是一个可爱的小堆栈机是很合适的,这是很多地方之一。

  9. 为了使其直观化:如果你有一个{0...15}的数值空间,其分布偏向于{0...6}的范围,那么你希望绝大多数的数值都适合于一个VBR3的块。那些不适合的应该是少数,它们稍大的代表性(两个块而不是一个)是可以忽略不计的。

  10. 从我的理解来看,无论如何。文档明确指出MODULE_BLOCK是解释单个LLVM IR模块时的顶级块,它可以包含一个BLOCKINFO。似乎对BLOCKINFO包含SETBID MODULE_BLOCK(#8)记录没有限制,(根据规范)应该无条件地适用于任何匹配的块,包括父块。

这个库要比博文的时间长。


通过www.DeepL.com/Translator(免费版)翻译