Clang编译器工具链遵循各种编译器书籍中广泛描述的模式[^1][^2],而Clang的前端部分与典型的编译器前端有很大的不同。这种区别的主要原因是C++语言的复杂性。一些特性,如宏,可以修改源代码本身,而其他特性,如typedef,可以影响token的类型。Clang还可以生成多种格式的输出。例如,以下命令会生成图2.5所示程序的悦目的HTML视图: $ $ \ /llvm-project/install/bin/clang -Xclang -emit-html max.cpp -fsyntax-only 上述命令中,使用了-fsyntax-only选项,指示Clang只执行预处理器、解析器和语义分析阶段。 可以根据提供的编译选项,指导Clang前端执行不同的操作并产生不同类型的输出。
Clang编译器工具链遵循各种编译器书籍中广泛描述的模式2,而Clang的前端部分与典型的编译器前端有很大的不同。这种区别的主要原因是C++语言的复杂性。一些特性,如宏,可以修改源代码本身,而其他特性,如typedef,可以影响token的类型。Clang还可以生成多种格式的输出。例如,以下命令会生成图2.5所示程序的悦目的HTML视图:
$`<...>/llvm-project/install/bin/clang -cc1 -emit-html max.cpp
\end{shell}
通过-cc1选项向Clang前端传递了生成源程序HTML形式的参数,也可以通过-Xclang选项向前端传递一个选项,这需要一个额外的参数来表示该选项本身:
\begin{shell}`$ <...>/llvm-project/install/bin/clang -Xclang
-emit-html max.cpp -fsyntax-only
上述命令中,使用了-fsyntax-only选项,指示Clang只执行预处理器、解析器和语义分析阶段。
可以根据提供的编译选项,指导Clang前端执行不同的操作并产生不同类型的输出。这些操作的基础类称为前端操作(FrontendAction)。
Clang前端一次只能执行一个前端操作。前端操作是基于提供的编译器选项,前端执行的具体任务或过程。以下是可能的前端操作的一些列表(表只包括可用前端操作的一个子集):
| 前端操作 | 编译器选项 | 描述 |
|---|---|---|
| EmitObjAction | -emit-obj (default) | 编译为目标文件 |
| EmitBCAction | -emit-llvm-bc | 编译成LLVM字节码 |
| EmitLLVMAction | -emit-llvm | 编译成LLVM可读形式 |
| ASTPrintAction | -ast-print | 构建AST,然后美观地打印它们。 |
| HTMLPrintAction | -emit-html | 以HTML形式打印程序源代码 |
| DumpTokensAction | -dump-tokens | 打印预处理器token |
表2.1: 前端操作
图2.28所示的图描绘了基本的前端架构,与图2.4所示的架构类似。Clang特定的差异是显而易见的。
一个显著的变化是对词法分析器的命名。在Clang中,词法分析器称为预处理器。这种命名约定表明了,词法分析器实现封装在Preprocessor类中。这种改变受到了C/C++语言独特特性的启发,这些特性包括需要特殊预处理(特殊类型)token(宏)。
虽然传统的编译器通常在解析器中同时执行语法和语义分析,但Clang将这些任务分布在不同的组件中。Parser组件专注于语法分析,而Sema组件处理语义分析。
此外,Clang提供了生成不同形式或格式的输出的能力。例如,CodeGenAction类作为各种代码生成操作的基础类,如EmitObjAction或EmitLLVMAction。
使用图2.5中的max函数的代码来探索Clang前端内部的细节:
int max(int a, int b) if (a > b) return a; return b;
图2.29: max函数的源代码:max.cpp
通过使用-cc1选项,可以直接调用Clang前端,绕过驱动程序。这种方法能够更详细地检查和分析Clang前端的工作原理。
第一部分是词法分析器,在Clang中称为预处理器。主要目标是将输入程序转换为token流,可以使用-dump-tokens选项打印token流:
输出如下:
int ’int’ [StartOfLine] Loc=<max.cpp:1:1> identifier ’max’
[LeadingSpace] Loc=<max.cpp:1:5> l_paren ’(’ Loc=<max.cpp:1:8> int
’int’ Loc=<max.cpp:1:9> identifier ’a’ [LeadingSpace]
Loc=<max.cpp:1:13> comma ’,’ Loc=<max.cpp:1:14> int ’int’
[LeadingSpace] Loc=<max.cpp:1:16> identifier ’b’ [LeadingSpace]
Loc=<max.cpp:1:20> r_paren ’)’ Loc=<max.cpp:1:21> l_brace ’’
[LeadingSpace] Loc=<max.cpp:1:23> if ’if’ [StartOfLine]
[LeadingSpace] Loc=<max.cpp:2:3> l_paren ’(’ [LeadingSpace]
Loc=<max.cpp:2:6> identifier ’a’ Loc=<max.cpp:2:7> greater ’>’
[LeadingSpace] Loc=<max.cpp:2:9> identifier ’b’ [LeadingSpace]
Loc=<max.cpp:2:11> r_paren ’)’ Loc=<max.cpp:2:12> return ’return’
[StartOfLine] [LeadingSpace] Loc=<max.cpp:3:5> identifier ’a’
[LeadingSpace] Loc=<max.cpp:3:12> semi ’;’ Loc=<max.cpp:3:13>
return ’return’ [StartOfLine] [LeadingSpace] Loc=<max.cpp:4:3>
identifier ’b’ [LeadingSpace] Loc=<max.cpp:4:10> semi ’;’
Loc=<max.cpp:4:11> r_brace ’’ [StartOfLine] Loc=<max.cpp:5:1> eof
” Loc=<max.cpp:5:2>
图2.30: Clang打印token输出
有不同的token类型,如语言关键字(例如,int、return)、标识符(例如,max、a、b等)和特殊符号(例如,分号、逗号等)。这个小程序的token称为正常token,它们由词法分析器返回。
除了正常token外,Clang还有一种token类型,称为注释token。主要区别在于,这些token还存储额外的语义信息。例如,正常token可以解析器替换为一个包含关于类型或C++作用域信息的单个注释token。使用此类token的主要原因是为了性能,允许在解析器需要回退时防止重新解析。
由于注释token用于解析器的内部实现,假设使用LLDB的注释token示例:
namespace clangbook template <typename T> class A ; // namespace
clangbook clangbook::A<int> a;
图2.31: 使用注释token的源代码,annotation.cpp
代码的最后一行声明了变量a,其类型为:
clangbook::A。该类型以注释token的形式表示,如下面的LLDB演示所示:
图2.32: annotation.cpp的LLDB交互
Clang从图2.31中示例程序的第4行消耗了一个注释token。该token位于列1和列7之间。这对应于以下作为token使用的文本:clangbook::A。该token由其他token组成,例如’clangbook’、’::’等。将所有token组合成一个简化解析,并提高整体的解析性能。
C/C++语言有一些特定之处,影响了Preprocessor类的内部实现。首先是宏。Preprocessor类有两个不同的辅助类来检索token:
Lexer类用于将文本缓冲区转换为token流。
TokenLexer类用于从宏扩展中检索token。
这些辅助类中只能有一个同时激活。
C/C++的另一个特定之处是#include指令(也适用于import指令),需要维护一个包含的栈,其中每个包含都可以有自己的TokenLexer或Lexer,取决于其中是否包含宏扩展。Preprocessor类为每个#include指令保持了一个包含的lexer栈(IncludeMacroStack类),如图2.33所示。
解析器和语义分析器是Clang编译器前端的关键组件,处理源代码的语法和语义分析,产生AST作为输出。对于测试程序,可以使用以下命令可视化这个树:
$`<...>/llvm-project/install/bin/clang -cc1 -ast-dump max.cpp
\end{shell}
输出如下:
\begin{shell}
TranslationUnitDecl 0xa9cb38 <>
|-TypedefDecl 0xa9d3a8 <>
implicit __int128_t '__int128'
| '-BuiltinType 0xa9d100 '__int128'
...
'-FunctionDecl 0xae6a98 <max.cpp:1:1, line:5:1> line:1:5 max
'int (int, int)'
|-ParmVarDecl 0xae6930 <col:9, col:13> col:13 used a 'int'
|-ParmVarDecl 0xae69b0 <col:16, col:20> col:20 used b 'int'
'-CompoundStmt 0xae6cd8 <col:23, line:5:1>
|-IfStmt 0xae6c70 <line:2:3, line:3:12>
| |-BinaryOperator 0xae6c08 <line:2:7, col:11> 'bool' '>'
| | |-ImplicitCastExpr 0xae6bd8 col:7 'int'
| | | '-DeclRefExpr 0xae6b98 col:7 'int' lvalue ParmVar 0xae6930
'a' 'int'
| | '-ImplicitCastExpr 0xae6bf0 col:11 'int'
| | '-DeclRefExpr 0xae6bb8 col:11 'int' lvalue ParmVar 0xae69b0
'b' 'int'
| '-ReturnStmt 0xae6c60 <line:3:5, col:12>
| '-ImplicitCastExpr 0xae6c48 col:12 'int'
| '-DeclRefExpr 0xae6c28 col:12 'int' lvalue ParmVar 0xae6930
'a' 'int'
'-ReturnStmt 0xae6cc8 <line:4:3, col:10>
'-ImplicitCastExpr 0xae6cb0 col:10 'int'
'-DeclRefExpr 0xae6c90 col:10 'int' lvalue ParmVar 0xae69b0
'b' 'int'
\end{shell}
\begin{center}
图2.34: Clang AST dump的输出
\end{center}
Clang使用了一个手工编写的递归下降解析器\footnote{LLVM Community. Clang features. 2023. URL \url{https://clang.llvm.org/features.html}}。这个解析器很简单,这也是选中它的一个关键原因。此外,C/C++语言的复杂规则需要一个易于适应的解析器。
通过示例来探索它是如何工作的。解析从代表单个翻译单元的顶级声明开始,称为TranslationUnitDecl。
一个源文件及其通过预处理指令#include包含的所有头文件(16.5.1.2)和源文件,减去通过条件包含预处理指令(15.2)跳过的源行,称为翻译单元。
解析器首先识别出源代码的初始token对应于函数定义,如C++标准所定义\footnote{International Organization for Standardization. International Standard ISO/IEC 14882:2020(E) – Programming Languages – C++. International Organization for Standardization, 2020. URL \url{https://www.iso.org/standard/73560.html}, dcl.fct.def.general}:
\begin{shell}
function-definition :
... declarator ... function-body
...
\end{shell}
\begin{center}
图2.35: C++标准中的函数定义
\end{center}
相应的代码如下:
\begin{cpp}
int max(...) {
...
}
\end{cpp}
\begin{center}
图2.36: 对应于C++标准中函数定义的部分示例代码
\end{center}
函数定义需要一个声明器和函数体。首先从声明器开始:
\begin{shell}
declarator:
...
... parameters-and-qualifiers ...
...
parameters-and-qualifiers:
( parameter-declaration-clause ) ...
...
parameter-declaration-clause:
parameter-declaration-list ...
parameter-declaration-list:
parameter-declaration
parameter-declaration-list , parameter-declaration
\end{shell}
\begin{center}
图2.37: C++标准中的声明器定义
\end{center}
声明器在一个括号内指定参数声明列表,源码中的相应部分如下所示:
\begin{cpp}
... (int a, int b)
...
\end{cpp}
\begin{center}
图2.38: 对应于C++标准中声明器的部分示例代码
\end{center}
函数定义,还需要一个函数体。C++标准将函数体定义为:
\begin{shell}
function-body:
... compound-statement
...
\end{shell}
\begin{center}
图2.39: C++标准中的函数体定义
\end{center}
函数体由一个复合语句组成,C++标准定义为:
\begin{shell}
compound-statement:
{ statement-seq ... }
statement-seq:
statement
statement-seq statement
\end{shell}
\begin{center}
图2.40: C++标准中的复合语句定义
\end{center}
其描述了一组语句,这些语句都括在大括号内({... ).
我们的程序有两种类型的语句:条件(if)语句和返回语句。这些在C++语法定义中如下所示:
\begin{shell}
statement:
...
selection-statement
...
jump-statement
...
\end{shell}
\begin{center}
图2.41: C++标准中的语句定义
\end{center}
选择语句对应于我们程序中的if条件,而跳转语句对应于返回运算符。
更详细地检查跳转语句:
\begin{shell}
jump-statement:
...
return expr-or-braced-init-list;
...
\end{shell}
\begin{center}
图2.42: C++标准中的跳转语句定义
\end{center}
其中expr-or-braced-init-list定义为:
\begin{shell}
expr-or-braced-init-list:
expression
...
\end{shell}
\begin{center}
图2.43: C++标准中的返回表达式定义
\end{center}
return关键字后面跟着一个表达式和一个分号,其中有一个隐式转换表达式,自动将变量转换为所需的类型(int)。
通过LLDB调试器检查解析器的操作可能会很有启发:
\begin{shell}`$ lldb <...>/llvm-project/install/bin/clang – -cc1
max.cpp
调试器演示输出在图2.44中。在第1行,为返回语句的解析设置了断点。程序有两个返回语句。跳过了第一个调用(第4行),并在第二个方法调用(第9行)处暂停。调用栈(从第13行的’bt’命令)显示了解析过程的调用栈。这个栈反映了之前描述的解析块,遵循了C++语法详细说明。
图2.44: 在max.cpp示例程序中解析第二个返回语句
解析的结果是生成AST,也可以使用调试器检查AST的创建过程。为此,需要在clang::ReturnStmt::Create上设置断点:
图2.45: 在clang::ReturnStmt::Create处的断点
返回语句的AST节点由Sema组件创建。
返回语句解析的开始可以在frame #4找到:
图2.46: 调试器中的返回语句解析
这里,有对C99标准3 的引用,该标准为相应的语句提供了详细的描述。
代码假设当前token的类型为tok::kw_return,解析器调用clang::Parser::ParseReturnStatement方法。
不同C++构造的AST节点创建过程可能会有所不同,但一般来说,都会遵循图2.47所示的模式。
图2.47中,方框代表相应的类,函数调用用带有调用函数的边缘表示。解析器调用Preprocessor::Lex方法从词法分析器获取一个token,然后调用与token对应的函数,例如Parser::ParseXXX对于token
XXX。然后该方法调用Sema::ActOnXXX,使用XXX::Create创建相应的对象。这个过程后,用新的token重复。
现在已经全面探索了Clang中,典型的编译器前端流程的实现。词法分析器组件(预处理器)与解析器(包括解析器和语义分析器)协同工作,生成了未来代码生成的主要数据结构:抽象语法树(AST)。AST不仅对于代码生成至关重要,对于代码分析和修改也非常重要。Clang提供了对AST的便捷访问,从而使得开发各种编译器工具成为可能。
Alfred V. Aho, Monica S. Lam, Ravi Sethi, and Jeffrey D. Ullman.
Compilers: Principles, Techniques, and Tools. Addison-Wesley, 2
edition, 2006. ISBN 978-0-321-48681-3
Keith Cooper and Linda Torczon. Engineering A Compiler. Elsevier
Inc., 2nd edition, 2012. ISBN 978-0-12-088478-0. ↩
International Organization for Standardization (ISO). ISO/IEC
9899:1999 - Programming languages - C. International Organization
for Standardization (ISO), 1999. URL
https://www.iso.org/standard/23482.html。 ↩