带你读《LLVM编译器实战教程》之三:工具和设计

简介: 本书的前半部分将向您介绍怎么样去配置、构建、和安装LLVM的不同软件库、工具和外部项目。接下来,本书的后半部分将向您介绍LLVM的各种设计细节,并逐步地讲解LLVM的各个编译步骤:前段、中间表示(IR)、后端、即时编译(JIT)引擎、跨平台编译和插件接口。本书包含有大量翔实的示例和代码片段,以帮助读者平稳顺利的掌握LLVM的编译器开发环境。

点击查看第一章
点击查看第二章

第3章 工具和设计

LLVM项目由一些库和工具组成,它们一起构成一个大型的编译器基础架构。将所有这些零件连接在一起需要精心的设计,这是项目的关键。在整个过程中,LLVM都在强调“一切都是库”的理念,只有相当少量的代码是不可重用的,并且不包括特定的工具。尽管如此,仍然有大量的工具允许用户以多种方式从命令终端运行库。在本章中,我们将介绍以下主题:

  • LLVM核心库的概述和设计
  • 编译器驱动程序的工作原理
  • 编译器驱动程序进阶:了解LLVM中间工具
  • 如何编写你的第一个LLVM工具
  • 关于浏览LLVM源代码的常规建议

3.1 LLVM的基本设计原理及其历史

LLVM是一个众所周知的教学框架,这是因为它的几个工具的组织化程度很高,从而使得感兴趣的用户可以观察到编译过程的许多步骤。其设计决策可以追溯到十多年前的第一个版本,当时这个专注于后端算法的项目只是依靠GCC将C这样的高级语言转换成LLVM中间表示(intermediate representation,简称IR)。如今,LLVM的设计核心是它的IR。它使用的静态单赋值形式(SSA)具有两个重要特征:

  • 代码被组织为三地址指令
  • 它有数目不受限制的寄存器

但是,这并不意味着LLVM只有一种表示程序的形式。在整个编译过程中,其他中间数据结构都保持程序逻辑结构,并且有助于跨主要检查点进行编译。从技术上讲,这些结构也是程序的中间表示形式。例如,LLVM在不同的编译阶段采用以下额外的数据结构:

  • 将C或C++转换为LLVM IR时,Clang将使用抽象语法树(AST)结构(Trans-lation-UnitDecl类)在内存中表示程序。
  • 在将LLVM IR转换为特定于机器的汇编语言时,LLVM首先将程序转换为有向无环图(DAG)格式以便选择指令(SelectionDAG类),然后将其转换回三地址表示以便进行指令调度(MachineFunction类)。
  • 为了实现汇编器和链接器,LLVM使用第四种中间数据结构(MCModule类)在对象文件的上下文中保存程序表示。

相比于LLVM中其他形式的程序表示,LLVM IR是最重要的一个,它具有不仅是内存中的表示而且还能存储在磁盘上的特性。LLVM IR因使用特定的编码而能存在于外部世界的这一事实是在项目初期做出的另一个重要决策,反映了当时研究终身程序优化的学术兴趣。
在这个理念中,编译器的目标不只是在编译时进行优化,而且还要探索利用在安装时、运行时和空闲时(程序未运行时)的优化机会。这样,在整个程序的生命周期中都进行优化,这也解释这个概念的名字。例如,当用户没有运行程序并且计算机空闲时,操作系统可以启动编译器守护进程来处理运行时收集的性能分析数据,以便针对该用户的特定用例重新优化程序。
请注意,由于能够存储在磁盘上,LLVM IR(它是终身程序优化的关键)为对整个程序进行编码提供了另一种方式。当整个程序以编译器IR的形式存储时,还可以执行新的一系列跨越单个编译单元或C文件边界的非常有效的跨程序优化。因此,这也为进行强大的链接时优化提供了条件。
另一方面,如果终身程序优化成为现实,则程序分发需要在LLVM IR级别发生,这目前还没有实现。这意味着LLVM将作为平台或虚拟机运行,并与Java展开竞争,这也面临着严峻的挑战。例如,LLVM IR不是像Java那样独立于目标机器的。LLVM也没有投资于在安装后进行强大的基于反馈的优化。如果有兴趣进一步了解这些技术挑战,请阅读http://lists.cs.uiuc.edu/pipermail/llvmdev/2011-October/043719.html 上的“LLVMdev”讨论主题。
随着项目逐渐成熟,维护编译器IR在磁盘上表示的设计决策仍然是为了实现链接时优化,而较少关注终身程序优化的原始想法。最终,LLVM的核心库通过放弃低级虚拟机(Low Level Virtual Machine)这个名称,正式表明对成为一个平台不感兴趣,而仅仅由于历史原因使用了LLVM这个名称,从而明确了LLVM项目立志成为强大和实用的C/C++ 编译器,而不是Java平台的竞争对手。
尽管如此,除了链接时优化之外,磁盘表示本身也有很好的应用前景,有些组织正在努力将其实现。例如,FreeBSD社区希望在可执行文件中嵌入其LLVM程序表示,以允许进行安装时或离线的微架构优化。在这种情况下,即使程序编译为通用x86形式,当用户安装程序时(比如,在特定的Intel Haswell x86处理器上安装程序时),LLVM基础架构就可以使用二进制程序的LLVM表示形式,对程序进行特殊处理以使用Haswell支持的新指令。尽管这是一个正在评估的新想法,但它表明磁盘上的LLVM表示可应用于激进的新解决方案。我们能期望的优化主要针对微架构,因为Java中完全的平台无关性在LLVM中是不切实际的,目前仅在一些外部项目上探索这种可能性(参见PNaCl,Chromiu的Portable Native Client)。
作为编译器IR,用于指导核心库开发的两个LLVM IR的基本原则如下:

  • SSA表示和允许快速优化的无限寄存器
  • 通过将整个程序存储在磁盘IR表示中以实现便捷的链接时优化

3.2 理解目前的LLVM

目前,LLVM项目已经发展起来,并拥有数量巨大的编译器相关工具。实际上,LLVM这个名称可能是以下任意一项:

  • LLVM项目/基础架构:这是对组成一个完整编译器的如下几个项目的总称:前端、后端、优化器、汇编器、连接器、libc++、compiler-RT和JIT引擎。例如,在“LLVM由几个项目组成”这句话中“LLVM”就是这个意思。
  • 基于LLVM的编译器:这是一个部分或全部使用LLVM基础架构所构建的编译器。例如,编译器可能使用LLVM作为前端和后端,但使用GCC和GNU系统库来执行最终的链接。例如,在“我用LLVM将C程序编译到MIPS平台”这句话中的“LLVM”就是这个意思。
  • LLVM库:这是LLVM基础架构的可重用代码部分。例如,在“我的项目使用LLVM的即时编译框架生成代码”这句话中“LLVM”就是这个意思。
  • LLVM核心:在中间语言级别和后端算法上进行的优化形成了项目开始时的LLVM核心。“LLVM和Clang是两个不同的项目”这句话中的“LLVM”就是这个意思。
  • LLVM IR:这是LLVM编译器中间表示。在诸如“我构建了一个前端来将我自己的语言翻译成LLVM”这样的句子中,“LLVM”就有LLVM IR的意思。

要了解LLVM项目,需要知道基础架构中最重要的部分:

  • 前端:这是将计算机程序语言(如C、C++和Objective-C)转换为LLVM编译器IR的编译步骤。它包括词法分析器、语法分析器、语义分析器和LLVM IR代码生成器。Clang项目提供了一个插件接口和一个单独的静态分析工具用于进行深度分析,同时实现了所有与前端相关的步骤。更多详细信息,请参阅第4章、第9章和第10章。
  • IR:LLVM IR既有用户可读的表示形式,也有二进制编码的表示形式。相应的工具和库提供了IR构建、组装和拆卸的接口。LLVM优化器还可以处理IR,以应用大多数优化。我们将在第5章详细解释IR。
  • 后端:这是负责生成代码的步骤。它将LLVM IR转换为特定于目标的汇编代码或目标代码二进制文件。寄存器分配、循环转换、窥视孔优化器以及特定于目标的优化/转换属于后端。我们在第6章对此进行深入分析。

图3-1列出了这些组件,让我们对在特定配置下使用的整个基础架构有一个总体认识。请注意,我们可以重新组织这些组件,并根据不同的需求有选择地使用它们,例如,如果我们不想探索链接时优化,则不使用LLVM IR链接器。

image.png

每个编译器组件之间的交互可以通过以下两种方式进行:

  • 在内存中:该方式通过一个单独的监督工具(如Clang)实现。该工具将每个LLVM组件作为一个库,并依赖于内存中分配的数据结构将一个阶段的输出作为输入提供给另一个阶段。
  • 通过文件:该方式通过用户实现。用户启动较小的独立工具,该工具将特定组件的结果写入磁盘文件,具体取决于用户是否使用此文件作为输入来启动下一个工具。
    因此,像Clang这样的高级工具可以整合使用其他几个更小的工具,具体做法是链接小工具的库来实现这些工具的功能。该功能的可能性来自LLVM的设计十分重视以库的形式进行大量代码重用。此外,整合了少量库的独立工具非常有用,因为这样的工具允许用户通过命令行直接与特定的LLVM组件交互。

例如,请看图3-2,该框图中下面三项是工具的名称,上面两项是实现其功能的库。在本例中,LLVM后端工具llc使用libLLVMCodeGen库实现部分功能,而仅用于启动LLVM IR级优化器的opt命令使用另一个库libLLVMipa实现与目标无关的过程间优化。最后,我们看到一个更强大的工具clang,它使用两个库来代替llc和opt,并向用户呈现更简单的接口。因此,用这样的高级工具执行的任何任务都可以分解成一系列低级任务,同时产生相同的结果。接下来的内容会继续说明这个概念。实际上,Clang能够执行整个编译过程,而不仅仅是完成opt和llc的工作。这就解释了为什么在静态构建中Clang二进制文件通常是最大的,因为它链接并利用整个LLVM生态系统。

image.png

3.3 与编译器驱动程序交互

一个编译器驱动程序与汉堡店的售货员相似,售货员会与你交互,识别你的订单,将你的订单传到后端做出汉堡,然后把它和可乐或番茄酱小包一起端到你面前,从而完成你的订单。驱动程序负责整合所有必要的库和工具,以便为用户提供更友好的体验,使用户不必单独应付编译器的各个阶段,比如前端、后端、汇编器和链接器等。一旦你将程序源代码提供给编译器驱动程序,它就可以生成可执行文件。在LLVM和Clang中,编译器驱动程序就是clang工具。
假设有一个简单的C程序hello.c:

image.png

要为此简单程序生成可执行文件,请使用以下命令:

image.png

请按第1章中的说明获取LLVM的直接可使用版本。
对于熟悉GCC的人,请注意上述命令与GCC非常相似。实际上,Clang编译器驱动程序被设计成与GCC标志和命令结构相兼容,从而允许在许多项目中用LLVM替代GCC。对于Windows,Clang也有一个名为clang-cl.exe的版本,可模拟Visual Studio C++编译器命令行界面。Clang编译器驱动程序隐式地从前端到链接器调用所有其他工具。
如果想查看驱动程序为了完成你的命令而调用的所有其他工具,请使用-###命令参数:

image.png

Clang驱动程序调用的第一个工具是带有-cc1参数的clang自身,以便在启用编译器模式时禁用编译器驱动程序模式。它还使用了众多参数来调整C/C++选项。由于LLVM组件是库,因此clang -cc1会与IR生成器、目标机器的代码生成器以及汇编器库进行链接。因此,在解析之后,clang -cc1本身能够调用其他库,并监视内存中的编译过程,直到目标文件完成。之后,Clang驱动程序(与编译器clang -cc1不同)调用作为外部工具的链接程序来生成可执行文件,如上述输出行所示。它使用系统链接器完成编译,因为LLVM链接器lld仍在开发中。
注意,使用内存要比使用磁盘快得多,这使得中间编译文件很少被用到。这就解释了为什么Clang(LLVM前端,也就是第一个与输入交互的工具)负责在内存中执行剩余的编译工作,而不会产生要被其他工具读取的中间输出文件。

3.4 使用独立工具

我们也可以通过使用LLVM独立工具来练习之前描述的编译工作流程,这会将一个工具的输出链接到另一个工具的输出。虽然将中间文件写入磁盘会导致编译速度减慢,但是观察编译流水过程是一个有趣的教学练习。这个过程也让你有机会微调中间工具的参数,其中一些工具如下:

  • opt:这是一个旨在IR级对程序进行优化的工具。输入必须是LLVM位码文件(编码的LLVM IR),并且生成的输出文件必须具有相同的类型。
  • llc:这是一个通过特定后端将LLVM位码转换成目标机器汇编语言文件或目标文件的工具。你可以通过传递参数来选择优化级别、打开调试选项以及启用或禁用特定于目标的优化。
  • llvm-mc:这个工具能够汇编指令并生成诸如ELF、MachO和PE等对象格式的目标文件。它也可以反汇编相同的对象,从而转储这些指令的相应的汇编信息和内部LLVM机器指令数据结构。
  • lli:这个工具是LLVM IP的解释器和JIT编译器。
  • llvm-link:这个工具将几个LLVM位码链接在一起,以产生一个包含所有输入的LLVM位码。
  • llvm-as:该工具将人工可读的LLVM IR文件(称为LLVM汇编码)转换为LLVM位码。
  • llvm-dis:这个工具将LLVM位码解码成LLVM汇编码。

我们来看一个由分散在多个源文件中的函数组成的简单的C程序。第一个源文件是main.c,它的内容如下:

image.png

第二个文件是sum.c,它的内容如下:

image.png

我们可以用下面的命令编译这个C程序:

image.png

但是,我们使用独立工具也可以获得相同的结果。首先,我们改变clang命令以便为每个C源文件生成LLVM位码文件,然后停下来,而不是继续完成编译:

image.png

-emit-llvm标志告诉clang根据是否存在-c或-S标志来生成LLVM位码或LLVM汇编码文件。在前面的示例中,-emit-llvm和-c标志一起使用,将告诉clang以LLVM位码格式生成一个目标文件。使用-flto -c标志组合可以得到相同的结果。如果你打算生成人工可读的LLVM汇编码,请使用下述两个命令:

image.png

.bc和.ll分别是LLVM位码和汇编文件的文件扩展名。为了继续完成编译,后续步骤可以采取以下两种方式:

  • 从每个LLVM位码文件生成特定于目标的目标文件,并通过将其链接到系统链接器来构建可执行程序(图3-3的A部分):

image.png

  • 首先,将两个LLVM位码文件连接成最终的LLVM位码文件。然后,从最终的位码文件构建特定于目标的目标文件,并通过调用系统链接程序来生成可执行程序(图3-3的B部分):

image.png

-filetype=obj参数指定输出一个目标文件,而不是目标汇编文件。我们使用Clang驱动程序clang来调用链接器,但是,如果知道系统链接器与系统库链接所需要的所有参数,则可以直接使用系统链接器。
通过在后端调用(llc)之前链接IR文件,将允许最终生成的IR能够被opt工具提供的链接时优化机制进一步优化(请参阅第5章)。另外,llc工具可以生成汇编输出,可以使用llvm-mc对该输出进行进一步汇编。我们将在第6章介绍这个接口的更多细节。

3.5 深入LLVM内部设计

为了将编译器解耦成多个工具,LLVM设计通常强制组件在高度抽象层次上发生交互。它将不同的组件分隔成不同的库,而且它是使用面向对象的范例用C++编写的,可以提供可插入的通道接口,从而允许在整个编译过程中方便地集成转换和优化步骤。

3.5.1 了解LLVM的基本库

LLVM和Clang的工作逻辑被精心组织到以下库中:

  • libLLVMCore:该库包含与LLVM IR相关的所有逻辑:IR构造(数据布局、指令、基本块和函数)以及IR校验器。它还负责编管理译器中各种编译流程。
  • libLLVMAnalysis:该库包含几个IR分析过程,如别名分析、依赖分析、常量折叠、循环信息、内存依赖分析和指令简化。
  • libLLVMCodeGen:该库实现与目标无关的代码生成和机器级别(LLVM IR的更低级版本)的分析和转换。
  • libLLVMTarget:该库通过通用目标抽象来提供对目标机器信息的访问接口。这些高级抽象为在libLLVMCodeGen中实现的通用后端算法与为下一个库保留的特定于目标的逻辑之间进行通信提供网关。
  • libLLVMX86CodeGen:该库具有特定于x86目标的代码生成信息、转换和分析过程,它们组成x86后端。请注意,每个目标机器都有一个不同的库,比如分别实现ARM和MIPS后端的LLVMARMCodeGen和LLVMMipsCodeGen库。
  • libLLVMSupport:该库包括一个通用工具集合。错误、整数和浮点处理、命令行解析、调试、文件支持和字符串处理都是在这个库中实现的算法示例,它们在LLVM各组件中通用。
  • libclang:该库实现了一个C接口(而不是C++接口),它是LLVM代码的默认实现语言,可以访问Clang的大部分前端功能:诊断报告、AST遍历、代码完成、游标映射和源代码。由于它使用C语言,使用更简单的接口,因此它允许以其他语言(如Python)编写的项目更容易地使用Clang功能,当然C接口设计得更为稳定,并允许外部项目依赖它。该库仅涵盖内部LLVM组件所使用的C++接口的一个子集。
  • libclangDriver:该库包含编译器驱动程序工具使用的一组类,用于理解类似于GCC的命令行参数,以便为外部工具完成编译的不同步骤准备作业和组织足够的参数。它可以根据目标平台管理不同的编译策略。
  • libclangAnalysis:该库是由Clang提供的一组前端级分析器。它具有CFG和调用图结构、代码可达性、格式字符串安全性等。

对于如何使用这些库来构建LLVM工具,我们举了一个例子,图3-4显示llc工具对libLLVMCodeGen、libLLVMTarget等库的依赖关系,以及这些库对其他库的依赖关系。不过请注意,前面的列表并不完整。
我们将把在上面省略的其他库留给后面的章节去介绍。对于版本3.0,LLVM团队编写了一个很好的文档,来介绍所有LLVM库之间的依赖关系。尽管文件已经过时,但它仍然提供了关于库的组织关系的有趣概述,可以通过http://llvm.org/releases/3.0/docs/UsingLibraries.html 访问该文档。

image.png

3.5.2 介绍LLVM的C++惯例

LLVM库和工具都是用C++编写的,以利用面向对象的编程范例,并增强其各组件之间的互操作性。另外,为了尽可能避免代码中的低效性,要求强制执行良好的C++编程惯例。

3.5.2.1 在惯例中看到多态性

继承和多态性通过将通用的代码生成算法留给基类来抽象后端的公共任务。在这个方案中,每个特定的后端都可以通过编写更少的必要方法来重写超类泛型操作,从而专注于实现其特殊性。LibLLVMCodeGen包含公共算法,而LibLLVMTarget包含用于抽象单个机器的接口。以下代码片段(来自llvm/lib/Target/Mips/MipsTargetMachine.h)展示了如何将MIPS目标机器的描述类声明为LLVMTargetMachine类的子类,并说明了这个概念。这段代码是LLVMMipsCodeGen库的一部分:

image.png

为了进一步阐述这个设计选择,我们将展示另一个后端示例,在该例中,与目标无关的寄存器分配器(它对所有后端都十分常见)需要知道哪些寄存器是保留的,不能用于分配。这个信息取决于具体的目标,并且不能被编码成通用的超类。我们通过使用MachineRegisterInfo::getReservedRegs()来执行此任务,这是一个必须由每个目标重写的通用方法。以下代码片段(来自llvm/lib/Target/Sparc/SparcRegisterInfo.cpp)显示SPARC目标如何重写此方法:

image.png

在此代码中,SPARC后端通过构建位向量来逐一选择哪些寄存器不能参加通用寄存器分配。

3.5.2.2 介绍LLVM中的C++模板

虽然LLVM经常使用C++模板,但要特别小心控制C++项目的编译时间,因为C++项目中典型的模板滥用问题会造成较长编译时间。一旦有可能,LLVM就会使用模板特化来允许实现快速和经常使用的任务。作为LLVM代码中的一个模板示例,我们来介绍一个函数,该函数检查作为参数传递的整数是否适合给定的位宽(模板参数)(代码来自llvm/include/llvm/Support/MathExtras.h):

image.png

在这段代码中,请注意模板中的代码是如何处理所有位宽值N的。它有一个较早的比较,只要位宽大于64位就返回true,相反则建立两个表达式,它们是这个位宽的下限和上限,以检查x是否在这两个边界以内。将此代码与下面的模板特化相比较,该模板用于获取位宽为8这一常见情况下更快的代码:

image.png

该代码将比较的数量从三个减少到一个,从而证明了特化的合理性。

3.5.2.3 在LLVM中执行C++最佳惯例

编程时无意中引入错误的现象是很常见的,问题在于如何管理错误。LLVM理念建议尽可能使用在libLLVMSupport中实现的断点机制。注意,调试编译器可能特别困难,因为编译的产物是另一个程序。因此,如果能够更早检测出错误的行为,就不需要为了确定程序是否正确而编写一个并不重要的复杂输出,这样,就可以节省大量的时间。例如,让我们来看一段ARM后端代码,它改变常量池的布局,从而以跨函数的几个较小常量孤池重新分配它们。这个策略通常用在ARM程序中,以便用一个有限(相对于PC)的寻址机制来加载大型常量,因为一个较大的独立池可能被放置在距离使用它的指令很远的地方。该代码来自llvm/lib/Target/ARM/ARMConstantIslandPass.cpp,我们在下面展示它的一部分:

image.png

在这个片段中,代码遍历一个代表ARM常量池的数据结构,程序员期望这个对象的每个字段都遵守特定的约束条件。请注意程序员如何使用assert调用来保持对数据语义的控制。如果程序员在编写这段代码的时候发现有什么内容和自己的想法有差别时,程序将立即退出执行,并打印失败的断言调用。程序员在布尔表达式后缀&&"error cause!"的习惯用法不会影响assert的布尔表达式的计算,但如果失败则会在打印此表达式时给出关于断言失败的简短文本解释。一旦LLVM项目执行发布版本编译,断言对性能的影响就会被完全删除,因为它会禁用断言。
你将在LLVM代码中频繁看到的另一种常见做法是使用智能指针。一旦符号超出范围,智能指针将自动释放内存,它们在LLVM代码库中用于(例如)保持目标信息和模块。过去,LLVM提供了一个叫作OwningPtr的特殊智能指针类,它在llvm/include/llvm/ADT/OwningPtr.h中定义。从LLVM 3.5开始,这个类已被弃用,而被std::unique_ptr()替代,这是在C++ 11标准中引入的。
如果你对LLVM项目中采用的C++最佳惯例的完整列表感兴趣,请访问http://llvm.org/docs/CodingStandards.html。每位C++程序员都应该读一下。

3.5.2.4 在LLVM中使用轻量级字符串引用

LLVM项目有一个支持常见算法的数据结构扩展库,在该LLVM库中字符串有特殊的地位。它们属于C++中的一个类,并引发了热烈的讨论:我们应该在什么时候使用一个简单的char而不是C++标准库的string类?要在LLVM的上下文中讨论这个问题,可以考虑在整个LLVM库中密集使用字符串调用,来引用LLVM模块、函数和值等的名称。在某些情况下,LLVM处理的字符串可以包含空字符,但是,因为空字符会终止C风格的字符串,所以将常量字符串引用作为const char指针进行传递的方法是不可能的。另一方面,频繁使用const std::string&会引入额外的堆分配,因为string类需要拥有字符缓冲区。我们可以从下面的例子中看到这一点:

image.png
image.png

请注意,每次我们尝试在自己的缓冲区中创建字符串时,都会花费额外的堆分配来将此字符串复制到string对象的内部缓冲区,而该对象必须拥有自己的缓冲区。在第一种情况下,我们有一个堆栈分配的字符串,而在第二种情况下,字符串被当作一个全局常量。对于这种情况,C++中缺少一个简单的类,能够在我们只需要引用字符串时,避免不必要的分配。即使我们严格使用string对象,以避免不必要的堆分配,但对字符串对象的引用也会产生两个间接引用。由于string类已经使用一个内部指针来持有其数据,所以当我们访问实际数据时,传递字符串对象的指针会造成两次引用的开销。
我们可以利用一个LLVM类来更加高效地处理字符串引用:StringRef。这是一个轻量级类,它可以像const char*那样进行值传递,但是它也存储字符串的大小,从而允许空字符的存在。然而,与string对象不同,它并不拥有自己的缓冲区,因此永远不会分配堆空间,而只是引用其外部的字符串。在其他C++项目中也涉及这个概念,例如,Chromium使用StringPiece类来实现相同的目的。
LLVM还引入了另一个字符串操作类。为了通过几个连接构建一个新的字符串,LLVM提供了Twine类。该类只存储用来构成最终结果的字符串的引用,通过这种方式来推迟实际的连接。这是在C++ 11之前创建的技术,那时字符串连接的开销较高。
如果你有兴趣了解LLVM为程序员提供的其他通用类,那么应该保存在书签中的一个非常重要的文档是LLVM程序员手册,该手册讨论可能对任何代码都有用的LLVM通用数据结构。该手册位于http://llvm.org/docs/ProgrammersManual.html

3.5.3 演示可插拔的流程接口

一个流程(pass)是指一次转换分析或优化。LLVM API允许你轻松地在程序编译生命周期的不同部分注册任何流程,这是LLVM设计中值得称道的亮点。流程管理器用于注册、调度和声明流程之间的依赖关系。因此,PassManager类的实例在不同的编译器阶段都是可用的。
例如,目标可以在代码生成期间的多个点自由地应用自定义优化,例如,在寄存器分配之前和之后,或者在汇编码生成之前。为了说明这一点,我们展示一个例子,其中X86目标在汇编码生成之前有条件地注册一对自定义流程(来自lib/Target/X86/X86TargetMachine.cpp):

image.png
image.png

请注意后端如何使用特定目标信息来判断是否应该添加流程。在添加第一个流程之前,X86目标会检查它是否支持SSE2多媒体扩展。对于第二个流程,它会检查是否有特别的填充请求。
在图3-5中,A部分是一个示例,它展示了如何在opt工具中插入优化流程,B部分说明代码生成中可以插入自定义目标优化的几个目标钩子。请注意,插入点分散在不同的代码生成阶段。当你编写第一个流程并需要决定在何处运行时,此图表会非常有用。第5章会详细描述PassManager接口。

image.png

3.6 编写你的第一个LLVM项目

在本节中,我们将展示如何使用LLVM库的编写你的第一个项目。在前面的章节中,我们介绍了如何使用LLVM工具来生成与程序相对应的中间语言文件,即位码文件。现在我们将创建一个程序,该程序能够读取此位码文件并打印其中定义的函数名称,以及它们的基本块数量,从而显示LLVM库的易用性。

3.6.1 编写Makefile

链接LLVM库需要使用长命令行,如果没有构建系统的帮助,想写出这些命令行是不切实际的。在下面的代码中,我们展示了一个Makefile文件(基于在DragonEgg中使用的代码)来完成这个任务,同时解释所提到的每个部分。如果复制并粘贴此代码,将会丢失制表符。请记住,Makefile依赖于制表符来指定定义规则的命令,因此,应该手动插入制表符:

image.png

第一部分定义将用作编译器标志的第一个Makefile变量。第一个变量决定llvm-config程序的位置,在这里,它需要在你的路径中。llvm-config工具是一个LLVM程序,它可以打印构建需要与LLVM库链接的外部项目的各种有用信息。
例如,定义在C++编译器中使用的标志集时,请注意,我们要求Make启动llvm-config --cxxflags shell命令行,该命令行将打印用于编译LLVM项目的C++标志集。这样,我们就使得项目源码的编译与LLVM源码兼容。最后一个变量定义要传递给编译器预处理器的标志集。

image.png

在第二个片段中,我们定义了Makefile规则。第一个规则总是默认的,我们用它构建hello-world可执行文件。第二个是通用规则,它将所有C++文件编译成目标文件,我们将预处理器标志和C++编译器标志传递给它。我们还使用$(QUIET)变量来省略屏幕上出现的完整命令行,但是如果你想要一个详细的构建日志,则可以在运行GNU Make时定义VERBOSE。
最后一个规则链接所有目标文件(在这里只有一个)来构建与LLVM库链接的项目可执行文件。这部分工作是由链接器完成的,但是一些C++标志也可能会生效。因此,我们将C++和链接器标志都传递给命令行。我们用'command'结构来完成此操作,它指示shell用'command'的输出替换这部分内容。在我们的例子中,命令是llvm-config --libs bitreader core support。--libs标志向llvm-config请求提供用于链接到所请求的LLVM库的链接器标志列表。这里,我们请求libLLVMBitReader、libLLVMCore和libLLVMSupport。
由llvm-config返回的标志列表是一系列-l链接器参数,如-lLLVMCore -lLLVMSupport。但请注意,传递给链接器的参数顺序很重要,并且要求你将依赖于其他库的参数放在前面。例如,由于libLLVMCore使用libLLVMSupport提供的通用功能,因此正确的顺序是-lLLVMCore -lLLVMSupport。
顺序很重要,因为一个库就是一个目标文件的集合,在将项目与库链接时,链接器只选择到目前为止已知的目标文件来解析所见到的未定义符号。因此,如果它正在处理命令行参数中的最后一个库,并且该库恰好使用了已经处理过的库中的符号,则大多数链接器(包括GNU ld)将不会返回去包括有可能缺失的目标文件,从而导致构建失败。
如果你想避免这个问题,并强制链接器迭代访问每个库,直到所有必要的目标文件都被解析,则必须在库列表的开始和结束处使用--start-group和--end-group标志,但这可能会减慢链接器速度。在构建完整的依赖关系图时,为了避免因为要弄清楚链接器参数的顺序而头疼,可以简单使用llvm-config --libs,让它为你做这些工作,就像我们之前做的那样。
Makefile文件的最后一部分定义了一条清理规则以删除编译器生成的所有文件,使我们可以从头开始重新启动构建。清理规则的格式如下:

image.png

3.6.2 编写代码

下面展示这个流程的完整代码。它相对较短,因为它建立在LLVM流程基础设施上,后者替我们完成了大部分工作。

image.png
image.png

我们的程序使用cl命名空间中的LLVM工具(cl代表命令行)来实现我们的命令行接口。我们只需调用ParseCommandLineOptions函数并声明cl::opt 类型的全局变量,以显示我们的程序接收单个参数,并且该参数是包含位码文件名的string类型。
之后,我们实例化一个LLVMContext对象,以存放与LLVM编译相关的所有数据,从而使LLVM是线程安全的。MemoryBuffer类为内存块定义一个只读接口,ParseBitcodeFile函数将使用这个对象来读取我们的输入文件的内容,并解析文件中LLVM IR的内容。在检查完错误并确保一切正常后,我们遍历该文件中模块的所有函数。LLVM模块的概念类似于翻译单元,其中包含所有编码到位码文件中的内容,也是LLVM层次结构中的最高实体,在它后面是函数,然后是基本块,最后是指令。如果函数只是一个声明,则丢弃它,因为我们想查找函数定义。当我们找到这些函数定义时,将打印它们的名称和它包含的基本块的数量。
如果编译此程序,并使用-help运行,可以查看已为你的程序准备好的LLVM命令行功能。之后,查找要转换为LLVM IR的C或C++文件,然后将其转换并使用程序进行分析:

image.png

如果要进一步了解可从函数中提取的内容,请参阅LLVM Doxygen文档中关于llvm::Function类的内容,网址为http://llvm.org/docs/doxygen/html/classllvm_1_1Function.html 。作为一个练习,请尝试扩展这个例子,以打印每个函数的参数列表。

3.7 关于LLVM源代码的一般建议

在进一步学习LLVM实现之前,注意还有一些值得理解的要点,主要是针对开源软件领域的新程序员。如果你在公司内部的一个封闭源代码的项目中工作,那么你可能会从项目中比你年长的程序员那里得到很多帮助,并对许多起初听起来可能很晦涩的设计决定有更深入的了解。如果遇到问题,组件的作者可能愿意口头向你解释。其好处是,在解释的时候,他甚至可以读懂你的面部表情,弄清楚你什么时候不了解某个特定的关键点,并调整他的话语来为你提供一个更合适的解释。
但是,由于在大多数社区项目中人们都是远程工作的,因此通常无法进行面对面的沟通。所以,开源社区有更大的动机采用更强的文档机制。另一方面,即使是在英文书写的文档中明确指出所有的设计决定,文档本身也可能并不是最让人期待的东西。大部分文档中重要的部分是代码本身,从这个意义上说,编写清晰的代码是有压力的,因为你还需要帮助其他人在没有英文文档的情况理解代码。

3.7.1 将代码理解为文档

尽管LLVM中最重要的部分都有相应的英文文档,并且我们在本书中也引用了这些文档,但我们的最终目标是让你准备好直接阅读代码,因为这是深入了解LLVM基础结构的先决条件。我们将为你提供必要的基本概念,以帮助你了解LLVM的工作原理,并且让你从理解LLVM代码中享受到乐趣,能够在即使没有阅读英文文档或缺乏英文文档的情况下读懂大部分代码。即使这样做可能是有挑战性的,但当你开始这样做的时候,你就会更加深入地了解这个项目,并且越来越有信心自己去做一些改变。这样,你将成为一名了解LLVM内部知识的程序员,并且可以帮助邮件列表中的其他人。

3.7.2 请求社区的帮助

电子邮件列表的存在提醒你,你并不是一个人在战斗。它们是Clang前端的cfe-dev列表和LLVM核心的llvmdev列表。请花点时间从以下地址订阅两个列表:

项目中有很多人在努力实现你也感兴趣的事情,所以很有可能你会针对别人已经做过的事情提问。
在寻求帮助之前,请先自己动脑思考,并尝试在没有帮助的情况下理解代码,看看自己能飞得多高,并尽力拓展你的知识。如果遇到一些令你感到困惑的事情,可以向列表发出一封电子邮件,清楚说明你已经探索过这个问题但没有结果,然后再寻求帮助。通过遵循这些准则,你将有更好的机会获取问题的最佳答案。

3.7.3 应对更新:使用SVN日志作为文档

LLVM项目在不断变化,实际上,你可能会发现一个非常常见的情况是,你经常需要更新LLVM版本,并发现充当与LLVM库接口的软件部分出现问题。在尝试再次读取代码以查看其更改情况之前,请使用合适的代码修订版本。
为了实际看看这么做如何有效,让我们练习将前端Clang从3.4更新到3.5。假设你为实例化BugType对象的静态分析器编写了一段代码:

image.png

这个对象用来生成你自己的检查器(更多细节请参阅第9章),用于报告特定种类的错误。现在,让我们将整个LLVM和Clang代码库更新到3.5版本,并编译这些代码行。我们将得到以下输出:

image.png

发生此错误是因为BugType构造函数从一个版本更改为另一个版本。如果你很难确定如何使你的代码适应新版本,则需要访问更改日志,这是一个重要的文档,它会记录特定时期的代码更改情况。幸运的是,对于使用代码修订系统的每个开源项目,我们都可以通过查询代码修订服务器来获取影响特定文件的提交消息,从而轻松获得更改日志。在LLVM的情况下,甚至可以使用浏览器通http://llvm.org/viewvc访问ViewVC 来这样做。
在这里,我们需要查看定义这个构造方法的头文件中有什么变化。通过查看LLVM源代码树,可以在include/clang/StaticAnalyzer/Core/BugReporter/BugType.h找到该文件。

image.png

要查看影响该特定头文件的提交邮件,可以访问http://llvm.org/viewvc/llvm-project/cfe/trunk/include/clang/StaticAnalyzer/Core/BugReporter/BugType.h?view=log ,然后将在浏览器中看到日志。现在,我们看到了在编写本书时三个月前发生的特定修订,当时LLVM正在更新到v3.5:

image.png

这个提交邮件是非常全面的,解释了BugType构造函数改变的所有原因:以前,用两个字符串实例化这个对象并不足以知道哪个检查器发现了一个特定的错误。因此,现在必须通过传递你的检查器对象的实例来实例化对象,该对象将存储在BugType对象中,并且可以很容易发现每个错误是由哪个检查器产生的。
现在,我们更改我们的代码以符合以下经过更新的接口。我们假设这个代码是作为Checker类的函数成员的一部分运行的,通常在实现静态分析器检查器的情况下均是如此。因此,this关键字应该返回一个Checker对象:

image.png

3.7.4 结束语

当你听说LLVM项目有很好的文档资源时,不要指望能找到一个精确描述所有代码细节的英文页面。这意味着,当你依赖于阅读代码、接口、注释和提交邮件时,你将能够理解LLVM项目,并跟进最新的变化。不要忘记通过练习修改源代码去了解原理,这意味着你需要准备好你的CTAGS去开始探索!

3.8 总结

在本章中,我们从历史的视角向你介绍了LLVM项目中使用的设计决策,并概述了其中最重要的项目。我们还展示了如何以两种不同的方式使用LLVM组件:首先,使用编译器驱动程序,这是一个高级工具,可以在单个命令中执行整个编译;其次,使用单独的LLVM独立工具。除了在磁盘上存储中间结果(这会减慢编译速度)之外,这些工具还允许我们通过命令行与LLVM库的特定片段进行交互,从而更好地控制编译过程,它们是了解LLVM如何工作的绝佳方式。我们还展示了LLVM中使用的几种C++编码风格,并解释了应该如何对待LLVM代码文档,以及如何通过社区寻求帮助。
在下一章中,我们将详细介绍Clang前端的实现及其库文件。

相关文章
|
2月前
|
算法 Linux 编译器
⭐⭐⭐⭐⭐Linux C++性能优化秘籍:从编译器到代码,探究高性能C++程序的实现之道
⭐⭐⭐⭐⭐Linux C++性能优化秘籍:从编译器到代码,探究高性能C++程序的实现之道
165 2
|
2月前
|
存储 算法 编译器
【C++ 泛型编程 进阶篇】C++模板元编程深度解析:探索编译时计算的神奇之旅
【C++ 泛型编程 进阶篇】C++模板元编程深度解析:探索编译时计算的神奇之旅
112 0
|
6月前
|
NoSQL 测试技术 Shell
万字总结简化跨平台编译利器CMake,从入门到项目实战演练!(下)
万字总结简化跨平台编译利器CMake,从入门到项目实战演练!(下)
|
6月前
|
Unix Linux 编译器
万字总结简化跨平台编译利器CMake,从入门到项目实战演练!(上)
万字总结简化跨平台编译利器CMake,从入门到项目实战演练!
|
6月前
|
存储 IDE 编译器
万字总结简化跨平台编译利器CMake,从入门到项目实战演练!(中)
万字总结简化跨平台编译利器CMake,从入门到项目实战演练!(中)
|
12月前
|
存储 机器学习/深度学习 Rust
Rust 快速入门60分① 看完这篇就能写代码了
Rust 快速入门60分① 看完这篇就能写代码了
324 1
|
存储 程序员 编译器
程序环境和预处理 C语言入门到入土(进阶篇)(一)
程序环境和预处理 C语言入门到入土(进阶篇)(一)
程序环境和预处理 C语言入门到入土(进阶篇)(一)
|
编译器 Linux C语言
程序环境和预处理 C语言入门到入土(进阶篇)(二)
程序环境和预处理 C语言入门到入土(进阶篇)(二)
程序环境和预处理 C语言入门到入土(进阶篇)(二)
|
SQL 自然语言处理 前端开发
编译原理笔记1:概述编译相关的基本知识
编译器的工作步骤 在开始说任何东西之前,我们先来大致看一下编译器是怎么工作的——从代码到程序,大概要经过下面这样的步骤——这里用粗浅的语言进行解释,先有个印象即可,后面还会提到 词法分析:编程语言的语句,由一堆堆的单词组成——比如变量类型名、变量名、函数名、值、符号等。
编译原理笔记1:概述编译相关的基本知识
|
C++ 编译器 C语言
带你读《LLVM编译器实战教程》之二:外部项目
本书的前半部分将向您介绍怎么样去配置、构建、和安装LLVM的不同软件库、工具和外部项目。接下来,本书的后半部分将向您介绍LLVM的各种设计细节,并逐步地讲解LLVM的各个编译步骤:前段、中间表示(IR)、后端、即时编译(JIT)引擎、跨平台编译和插件接口。本书包含有大量翔实的示例和代码片段,以帮助读者平稳顺利的掌握LLVM的编译器开发环境。

热门文章

最新文章