预编译头文件和模块内部¶
本文档描述了 Clang 预编译头文件 (PCH) 和模块的设计与实现。如果您有兴趣了解最终用户视角,请参阅 用户手册.
使用预编译头文件与 clang
¶
Clang 编译器前端,clang -cc1
,支持两个命令行选项用于生成和使用 PCH 文件。
要使用 clang -cc1
生成 PCH 文件,请使用选项 -emit-pch
$ clang -cc1 test.h -emit-pch -o test.h.pch
当生成 PCH 文件时,clang
会透明地使用此选项。生成的 PCH 文件包含编译器在完成解析和语义分析后的内部表示的序列化形式。然后,可以使用 -include-pch 选项将 PCH 文件用作前缀头文件
$ clang -cc1 -include-pch test.h.pch test.c -o test.s
设计理念¶
预编译头文件的目的是提高项目的整体编译时间,因此预编译头文件的设计完全受性能问题驱动。预编译头文件的用例相对简单:当项目中几乎每个源文件都包含一组通用的头文件时,我们会将这组头文件 *预编译* 成一个单独的预编译头文件 (PCH 文件)。然后,在编译项目中的源文件时,我们首先加载 PCH 文件(作为前缀头文件),它充当这组头文件的替身。
预编译头文件实现提高了性能,因为
加载 PCH 文件比重新解析存储在 PCH 文件中的头文件包要快得多。因此,预编译头文件设计试图最大限度地减少读取 PCH 文件的成本。理想情况下,此成本不应随预编译头文件的大小而变化。
最初生成 PCH 文件的成本并不大,以至于抵消了由于消除最初解析捆绑头文件的需要而带来的每个源文件的性能提升。这在多核系统上尤其重要,因为 PCH 文件生成在所有编译都需要 PCH 文件更新时会使构建序列化。
模块(如在 Clang 中实现)使用与预编译头文件相同的机制来保存序列化 AST 文件(每个模块一个)并使用这些 AST 模块。从实现的角度来看,模块是预编译头文件的泛化,消除了对预编译头文件的一些限制。特别是,只能有一个预编译头文件,并且必须将其包含在翻译单元的开头。针对模块的 AST 文件格式扩展在有关 模块 的部分中讨论。
Clang 的 AST 文件采用紧凑的磁盘表示,最大限度地减少了创建时间和最初加载 AST 文件所需的时间。AST 文件本身包含 Clang 抽象语法树和支持数据结构的序列化表示,使用与 LLVM 的位码文件格式 相同的压缩位流存储。
Clang 的 AST 文件从磁盘“懒惰地”加载。当最初加载 AST 文件时,Clang 只从 AST 文件中读取少量数据来确定某些重要数据结构的存储位置。在此初始加载中读取的数据量与 AST 文件的大小无关,因此较大的 AST 文件不会导致 AST 加载时间变长。AST 文件中的实际头文件数据——宏、函数、变量、类型等——只有在用户代码中引用时才会加载,此时只将该实体(以及它依赖的实体)从 AST 文件中反序列化。使用这种方法,使用 AST 文件进行翻译单元的成本与实际从 AST 文件中使用的代码量成正比,而不是与 AST 文件本身的大小成正比。
当使用 -print-stats 选项时,Clang 会生成统计信息,描述实际从磁盘加载了多少 AST 文件。对于包含 Apple Cocoa.h
头文件(作为预编译头文件构建)的简单“Hello, World!”程序,此选项说明了实际需要多少预编译头文件
*** AST File Statistics:
895/39981 source location entries read (2.238563%)
19/15315 types read (0.124061%)
20/82685 declarations read (0.024188%)
154/58070 identifiers read (0.265197%)
0/7260 selectors read (0.000000%)
0/30842 statements read (0.000000%)
4/8400 macros read (0.047619%)
1/4995 lexical declcontexts read (0.020020%)
0/4413 visible declcontexts read (0.000000%)
0/7230 method pool entries read (0.000000%)
0 method pool misses
对于这个小型程序,实际上只从预编译头文件中反序列化了源位置、类型、声明、标识符和宏的一小部分。这些统计信息可用于确定是否可以通过使更多实现变得懒惰来改进 AST 文件实现。
预编译头文件可以链接。当您在包含现有 PCH 的同时创建一个 PCH 时,Clang 可以通过引用原始文件并仅将新数据写入新文件来创建新的 PCH。例如,您可以从所有在整个项目中非常常用的头文件中创建一个 PCH,然后为项目中每个包含特定于该文件的代码的源文件创建一个 PCH,以便重新编译文件本身非常快,而不会为每个文件重复来自通用头文件的数据。链接预编译头文件背后的机制将在 后面的部分 中讨论。
AST 文件内容¶
clang 生成的 AST 文件是一个对象文件容器,其中包含 clangast
(COFF) 或 __clangast
(ELF 和 Mach-O) 部分,其中包含序列化的 AST。对象文件容器中的其他特定于目标的部分用于保存 AST 中定义的数据类型的调试信息。基于 libclang 构建且不需要调试信息的工具也可以生成仅包含序列化 AST 的原始 AST 文件。
The clangast
部分组织成几个不同的块,每个块包含 Clang 内部表示一部分的序列化表示。每个块对应于 LLVM 的位流格式 中的块或记录。下面描述了每个逻辑块的内容。
The llvm-objdump
工具提供了一个 -raw-clang-ast
选项来从对象文件容器中提取 AST 部分的二进制内容。
The llvm-bcanalyzer 工具可用于检查 AST 部分的位流的实际结构。此信息既可用于帮助理解 AST 部分的结构,也可用于隔离 AST 表示仍可以优化的区域,例如通过引入缩写。
元数据块¶
元数据块包含几个记录,提供有关如何构建 AST 文件的信息。此元数据主要用于验证 AST 文件的使用。例如,为 32 位 x86 目标构建的预编译头文件不能在为 64 位 x86 目标编译时使用。元数据块包含有关以下方面的信息
- 语言选项
描述用于编译 AST 文件的特定语言方言,包括主要选项(例如,Objective-C 支持)和次要选项(例如,对“
//
”注释的支持)。此记录的内容对应于LangOptions
类。- 目标体系结构
描述为其生成 AST 文件的体系结构、平台和 ABI 的目标三元组,例如
i386-apple-darwin9
。- AST 版本
AST 文件格式的主版本号和次版本号。次版本号的更改不应影响向后兼容性,而主版本号的更改意味着更新的编译器无法读取旧的预编译头文件(反之亦然)。
- 原始文件名
用于生成 AST 文件的头文件的完整路径。
- 预定义缓冲区
虽然未明确存储为元数据的一部分,但预定义缓冲区用于验证 AST 文件。预定义缓冲区本身包含由编译器生成的代码,以根据当前目标、平台和命令行选项初始化预处理器状态。例如,当我们编译没有 Microsoft 扩展的 C 时,预定义缓冲区将包含“
#define __STDC__ 1
”。预定义缓冲区本身存储在 源管理器块 中,但其内容与其他元数据一起验证。
链接的 PCH 文件(即引用另一个 PCH 的文件)和模块(它可能导入其他模块)具有额外的元数据,其中包含此 AST 文件依赖的所有 AST 文件列表。每个这些文件都将与这个 AST 文件一起加载。
对于链接的预编译头文件,语言选项、目标体系结构和预定义缓冲区数据取自链的末尾,因为它们无论如何都必须匹配。
源管理器块¶
源管理器块包含 Clang 的 源管理器 类的序列化表示,它处理从源位置(如 Clang 的抽象语法树中表示)到源文件或宏实例化中实际列/行位置的映射。AST 文件的源管理器表示还包括有关在构建 AST 文件时(传递地)包含的所有头文件的信息。
源管理器块的绝大部分用于有关各种文件、缓冲区和宏实例化的信息,源位置可以引用这些信息。每个这些信息都由一个数字“文件 ID”引用,该 ID 是一个唯一的数字(从 1 开始分配),存储在源位置中。Clang 会序列化每种文件 ID 的信息,以及一个索引,该索引将文件 ID 映射到 AST 文件中存储该文件 ID 信息的位置。只有在前端需要时才会加载与文件 ID 相关联的数据,例如要发出包含头文件本身中的宏实例化历史记录的诊断信息。
源管理器块还包含有关在构建 AST 文件时包含的所有头文件的信息。这包括有关头文件的控制宏的信息(例如,当预处理器识别出头文件的内容依赖于像 LLVM_CLANG_SOURCEMANAGER_H
这样的宏时)。
预处理块¶
预处理块包含预处理器的序列化表示。具体来说,它包含了在用于构建 AST 文件的标头结束时定义的所有宏,以及构成每个宏的标记序列。只有当宏的名称第一次出现在程序中时,才会从 AST 文件中读取宏定义。这种宏定义的延迟加载是由对标识符表的查找触发的。
类型块¶
类型块包含翻译单元中引用的所有类型的序列化表示。每个 Clang 类型节点(PointerType
、FunctionProtoType
等)在 AST 文件中都有一个相应的记录类型。当类型从 AST 文件中反序列化时,记录中的数据将用于使用 AST 上下文重建相应的类型节点。
每个类型都有一个唯一的类型 ID,它是一个整数,唯一地标识该类型。类型 ID 0 表示 NULL 类型,小于 NUM_PREDEF_TYPE_IDS
的类型 ID 表示预定义类型(void
、float
等),而其他“用户定义”类型 ID 从 NUM_PREDEF_TYPE_IDS
开始,随着类型的出现而连续分配。AST 文件有一个关联的映射,从用户定义的类型块到类型块中序列化表示所在的类型块中的位置,从而能够延迟反序列化类型。当从 AST 文件中引用类型时,该引用使用向左移 3 位的类型 ID 进行编码。低三位用于表示 const
、volatile
和 restrict
限定符,如 Clang 的QualType 类。
声明块¶
声明块包含翻译单元中引用的所有声明的序列化表示。每个 Clang 声明节点(VarDecl
、FunctionDecl
等)在 AST 文件中都有一个相应的记录类型。当声明从 AST 文件中反序列化时,记录中的数据将用于构建和填充相应的 Decl
节点的实例。与类型一样,每个声明节点都有一个数字 ID,用于在 AST 文件中引用该声明。此外,一个查找表提供从该数字 ID 到预编译头文件中描述该声明的偏移量的映射。
Clang 的抽象语法树中的声明以分层结构存储。层次结构的顶层是翻译单元(TranslationUnitDecl
),它包含翻译单元中的所有声明,但实际上并没有作为特定声明节点写入。它的子声明(如函数或结构类型)也可能包含其他声明,依此类推。在 Clang 中,每个声明都存储在一个声明上下文中,由 DeclContext
类表示。声明上下文提供了一种机制来执行给定声明中的名称查找(例如,在结构中查找名为 x
的成员)并遍历存储在上下文中的声明(例如,遍历结构的所有字段以进行结构布局)。
在 Clang 的 AST 文件格式中,反序列化作为 DeclContext
的声明与反序列化存储在该声明上下文中的所有声明是单独的操作。因此,Clang 将反序列化翻译单元声明,而不反序列化该翻译单元中的声明。当需要时,存储在声明上下文中的声明将被反序列化。声明上下文中的声明有两种表示形式,分别对应于上面描述的名称查找和遍历行为。
当前端执行名称查找以在给定声明上下文中查找名称
x
时(例如,在表达式p->x
的语义分析期间,其中p
的类型在预编译头文件中定义),Clang 会引用一个磁盘上的哈希表,该哈希表将该声明上下文中的名称映射到表示具有该名称的每个可见声明的声明 ID。然后,将反序列化实际的声明以提供名称查找的结果。当前端遍历声明上下文中的所有声明时,所有这些声明将立即被反序列化。对于大型声明上下文(例如,翻译单元),此操作很昂贵;但是,大型声明上下文在正常编译中不会被遍历,因为这种遍历是不必要的。但是,代码生成器和语义分析通常会遍历结构、类、联合体和枚举的声明上下文,尽管在常见情况下,这些上下文包含的声明相对较少。
语句和表达式¶
语句和表达式在 AST 文件中的类型和声明块中都有存储,因为每个语句或表达式都将与类型或声明相关联。实际的语句和表达式记录存储在拥有该语句或表达式的声明或类型之后。例如,表示函数体的语句将存储在函数声明之后。
与类型和声明一样,Clang 的抽象语法树中的每个语句和表达式种类(ForStmt
、CallExpr
等)在 AST 文件中都有一个相应的记录类型,其中包含该语句或表达式的序列化表示。表达式中的每个子语句或子表达式都存储为单独的记录(这使大多数记录的大小固定)。在 AST 文件中,表达式的子表达式以反序存储,在拥有这些子表达式的表达式之前,使用一种逆波兰表示法。例如,表达式 3 - 4 + 5
将表示如下
|
|
|
|
|
|
在读取此表示形式时,Clang 会评估它遇到的每个表达式记录,构建相应的抽象语法树节点,然后将该表达式推送到堆栈中。当一个记录包含 *N* 个子表达式时——BinaryOperator
有两个——这些表达式将从堆栈顶部弹出。特殊的 STOP 代码表示我们已经到达了序列化表达式或语句的末尾;其他表达式或语句记录可能会跟随,但它们是不同表达式的部分。
标识符表块¶
标识符表块包含一个磁盘上的哈希表,它将 AST 文件中提到的每个标识符映射到标识符信息的序列化表示(例如,IdentifierInfo
结构)。序列化表示包含
实际的标识符字符串。
描述此标识符是否是内置名称、中毒标识符、扩展标记或宏的标志。
如果标识符是宏的名称,则它是预处理块中宏定义的偏移量。
如果标识符是翻译单元范围可见的一个或多个声明的名称,则这些声明的声明 ID。
当加载 AST 文件时,AST 文件读取机制将自己作为外部查找源引入标识符表。因此,当用户程序引用尚未看到的标识符时,Clang 将在标识符表中执行查找。如果找到标识符,它的内容(宏定义、标志、顶级声明等)将被反序列化,此时相应的 IdentifierInfo
结构将具有与解析 AST 文件中的标头后相同的内容。
在 AST 文件中,用于命名声明的标识符用整数值表示。一个单独的表提供从该整数值(标识符 ID)到磁盘上的哈希表中存储该标识符的位置的映射。当反序列化声明的名称、标记的标识符或 AST 文件中引用名称的任何其他结构时,将使用此映射。
方法池块¶
方法池块表示为一个磁盘上的哈希表,它有两个目的:它提供从 Objective-C 选择器的名称到具有该特定选择器的 Objective-C 实例和类方法集的映射(这是 Objective-C 语义分析所必需的),并且还存储 AST 文件中实体使用的所有选择器。方法池的设计类似于标识符表:当在程序的编译过程中第一次形成特定选择器时,Clang 将在选择器的磁盘上的哈希表中搜索;如果找到,Clang 将读取与该选择器关联的 Objective-C 方法到相应的前端数据结构中(Sema::InstanceMethodPool
和 Sema::FactoryMethodPool
分别用于实例和类方法)。
与标识符一样,选择器在 AST 文件中用数字值表示。一个单独的索引将这些数字选择器值映射到选择器在磁盘上的哈希表中的偏移量,并在反序列化引用该选择器的 Objective-C 方法声明(或其他 Objective-C 结构)时使用。
AST 阅读器集成点¶
AST 文件的“延迟”反序列化行为要求它们集成到 Clang 的几个完全不同的子模块中。例如,在名称查找期间延迟反序列化声明要求名称查找例程能够查询 AST 文件以查找存储在那里的实体。
对于每个需要与 AST 阅读器逻辑直接交互的 Clang 数据结构,都存在一个抽象类,它提供了两个模块之间的接口。ASTReader
类处理 AST 文件的加载,它继承了所有这些抽象类以提供 Clang 数据结构的延迟反序列化。ASTReader
实现以下抽象类
ExternalSLocEntrySource
此抽象接口与
SourceManager
类相关联,并在 源代码管理器 需要加载文件、缓冲区或宏实例化的详细信息时使用。IdentifierInfoLookup
此抽象接口与
IdentifierTable
类相关联,并在程序源代码引用尚未见过的标识符时使用。在这种情况下,AST 阅读器会在其 标识符表 中搜索此标识符,以加载与该标识符关联的任何顶层声明或宏。ExternalASTSource
此抽象接口与
ASTContext
类相关联,并在抽象语法树节点需要从 AST 文件加载时使用。它提供了解析其数值标识的声明和类型、在需要时读取函数主体以及读取存储在声明上下文中的声明(用于迭代或名称查找)的能力。ExternalSemaSource
此抽象接口与
Sema
类相关联,并在语义分析需要从 全局方法池 中读取信息时使用。
链式预编译头文件¶
链式预编译头文件最初旨在提高 IDE 中心操作的性能,例如在用户编辑特定源文件时进行语法高亮和代码完成。为了最大程度地减少对文件更改后所需重新解析的量,一种称为预编译前导的预编译头文件形式由解析源文件中的所有头文件(直到并包括最后一个 #include
)自动生成。当只有源文件更改(并且它所依赖的任何头文件都没有更改)时,该源文件的重新解析可以使用预编译的前导,并在 #include
后开始解析,因此解析时间与源文件的大小成正比(而不是它所有包含的内容)。但是,该翻译单元的编译可能已经使用预编译头文件:在这种情况下,Clang 将创建预编译的前导作为链式预编译头文件,它引用原始预编译头文件。这大大减少了序列化预编译前导以用于重新解析所需的时间。
链式预编译头文件之所以得名,是因为每个预编译头文件都可以依赖另一个预编译头文件,形成一个依赖链。然后,翻译单元将包含启动该链的预编译头文件(即,没有任何东西依赖它)。这种依赖关系的线性对于链式预编译头文件的语义模型很重要,因为最新的预编译头文件可以提供信息来覆盖它所依赖的预编译头文件提供的信息,就像头文件 B.h
包含另一个头文件 A.h
可以修改解析 A.h
产生的状态一样,例如,通过 #undef
在 A.h
中定义的宏。
链式预编译头文件以多种方式概括了 AST 文件模型
- ID 的编号
许多不同类型的实体(标识符、声明、类型等)都有从 1 或其他一些预定义常量开始并向上增长的 ID 号。每个预编译头文件都记录了它在每个类别中分配的最大 ID 号。然后,当生成依赖于(链到)另一个预编译头文件的新的预编译头文件时,它将从下一个可用的 ID 号开始计数。这样,就可以根据给定的 ID 号来确定哪个 AST 文件实际上包含该实体。
- 名称查找
在编写链式预编译头文件时,Clang 尝试只写入与它所基于的预编译头文件不同的信息。这改变了各种表的查找算法,例如 标识符表:搜索从最新的预编译头文件开始。如果未找到条目,则查找将继续到它所依赖的预编译头文件中的标识符表,依此类推。一旦查找成功,该结果将被视为最终结果,覆盖来自早期预编译头文件的任何结果。
- 更新记录
后面的预编译头文件可以通过多种方式修改早期预编译头文件中描述的实体。例如,后面的预编译头文件可以将条目添加到翻译单元或命名空间的各种名称查找表中,或者向 Objective-C 类添加新类别。这些更新中的每一个都捕获在存储在链式预编译头文件中的“更新记录”中,并将与原始实体一起加载。
模块¶
模块将链式预编译头文件模型进一步推广,从预编译头文件的线性链到 AST 文件的任意有向无环图 (DAG)。所有用于使链式预编译头文件工作相同的技术(ID 号、名称查找、更新记录)都与模块共享。但是,模块的 DAG 特性为该模型带来了许多额外的复杂性
- ID 的编号
链式预编译头文件中使用的简单线性编号方案在模块 DAG 中崩溃,因为不同的模块最终可能对它们从公共共享模块导入的实体使用不同的编号方案。为了解决这个问题,每个模块文件都提供有关它依赖的模块以及它分配给这些模块中实体的 ID 号的信息,以及它为自己的新实体所取的 ID 号。然后,AST 阅读器将这些“本地”ID 号映射到当前翻译单元的“全局”ID 号空间中,为实体(无论它们位于哪个 AST 文件中)和全局 ID 号提供一对一的映射。如果该翻译单元随后被序列化到 AST 文件中,则此映射将被存储起来,以便在导入 AST 文件时使用。
- 声明合并
对于给定的实体(从语言的角度来看),可以在不同的地方多次声明。例如,两个不同的头文件可能包含
printf
的声明,或者可能转发声明struct stat
。如果这些头文件中的每一个都包含在模块中,而某个第三方程序导入这两个模块,那么就会出现一个潜在的严重问题:对printf
或struct stat
的名称查找将找到两个声明,但 AST 节点是无关的。这将导致编译错误,因为名称查找中存在歧义。因此,AST 阅读器根据适当的语言语义执行声明合并,确保两个不相交的声明合并成一个单一的重新声明链(具有一个共同的规范声明),这样就好像其中一个头文件在另一个头文件之前包含一样。- 名称可见性
模块允许在模块创建期间出现的一些名称“隐藏”,以便它们不是模块的公共接口的一部分,并且对它的客户端不可见。AST 阅读器在各种 AST 节点(声明、宏等)上维护一个“可见”位,以指示该特定 AST 节点当前是否可见;Clang 中的各种名称查找机制会检查可见位以确定该实体(仍然在 AST 中,因为其他可见的 AST 节点可能依赖于它)是否可以通过名称查找找到。当导入一个新的(子)模块时,它可能会使现有的、不可见的、已经反序列化的 AST 节点可见;在通知导入时,AST 阅读器有责任找到并更新这些 AST 节点。