LLVM IR 代码生成与解析器、抽象语法树
概述
将基于词法分析器,为 Kaleidoscope 构建一个完整的解析器(Parser)。通过解析器,我们可以定义并构造抽象语法树(Abstract Syntax Tree,AST)。
构造的解析器使用两种方法进行语法分析:
- 递归下降分析法(Recursive Descent Parsing):用于基本表达式的解析。
- 算符优先分析法(Operator-Precedence Parsing):用于二元表达式的解析。
在实现解析器之前,我们先来介绍一下解析器的输出——抽象语法树。
抽象语法树
抽象语法树(AST)为编译器后续的各个阶段提供了一种易于解释的表示形式,比如:代码生成。通常,我们希望编程语言中的每一种结构都有一个对应的表示对象。为了实现这种映射关系,我们使用 AST 对语言进行分析建模。
在 Kaleidoscope 的 AST 中,我们设计了三种结构,分别是:
表达式
如下所示,ExprAST
是 AST 中表达式的基类定义,其包含了多种子类定义,分别用于对应不同的具体表达式。由于 Kaleidoscope 只有一种数据类型——双精度浮点类型,因此我们没有必要存储类型信息。当然,现实的编程语言通常都包含多种类型,这种情况下 ExprAST
会包含一个用于存储类型信息的字段。
1
2
3
4
5
|
/// ExprAST - Base class for all expression nodes.
class ExprAST {
public:
virtual ~ExprAST() {}
};
|
如下所示为 ExprAST
的各种子类定义,分别是:
NumberExprAST
:用于表示 数值表达式,其捕获字面量的数值保存于 Val
中。
VariableExprAST
:用于表示 变量表达式,其捕获变量名保存于 Name
中。
BinaryExprAST
:用于表示 二元表达式,其使用 Op
保存操作符,如:+
。使用 LHS
和 RHS
分别保存左子表达式和右子表达式。
CallExprAST
:用于表示 函数调用表达式,其使用 Callee
保存函数名。使用 Args
数组变量保存函数的各个参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
/// NumberExprAST - Expression class for numeric literals like "1.0".
class NumberExprAST : public ExprAST {
double Val;
public:
NumberExprAST(double Val) : Val(Val) {}
};
/// VariableExprAST - Expression class for referencing a variable, like "a".
class VariableExprAST : public ExprAST {
std::string Name;
public:
VariableExprAST(const std::string &Name) : Name(Name) {}
};
/// BinaryExprAST - Expression class for a binary operator.
class BinaryExprAST : public ExprAST {
char Op;
std::unique_ptr<ExprAST> LHS, RHS;
public:
BinaryExprAST(char op, std::unique_ptr<ExprAST> LHS, std::unique_ptr<ExprAST> RHS) : Op(op), LHS(std::move(LHS)), RHS(std::move(RHS)) {}
};
/// CallExprAST - Expression class for function calls.
class CallExprAST : public ExprAST {
std::string Callee;
std::vector<std::unique_ptr<ExprAST>> Args;
public:
CallExprAST(const std::string &Callee, std::vector<std::unique_ptr<ExprAST>> Args) : Callee(Callee), Args(std::move(Args)) {}
};
|
对于仅包含基本功能的编程语言而言,上述为全部的 AST 表达式节点定义。由于不包含条件控制流,因此这并不是图灵完备的;对此,我们将在后面进一步对其进行优化。
原型
如下所示,PrototypeAST
为 AST 中原型(Prototype)的定义。原型用于表示一个函数的原型,用于捕获函数的名称,各个参数的名称等。
1
2
3
4
5
6
7
8
9
|
class PrototypeAST {
std::string Name;
std::vector<std::string> Args;
public:
PrototypeAST(const std::string &name, std::vector<std::string> Args) : Name(name), Args(std::move(Args)) {}
const std::string &getName() const { return Name; }
};
|
函数
如下所示,FunctionAST
为 AST 中函数的定义。函数由函数原型和函数体组成,其分别使用 Proto
和 Body
进行存储。其中函数体是一个 AST 表达式结构。
1
2
3
4
5
6
7
|
class FunctionAST {
std::unique_ptr<PrototypeAST> Proto;
std::unique_ptr<ExprAST> Body;
public:
FunctionAST(std::unique_ptr<PrototypeAST> Proto, std::unique_ptr<ExprAST> Body) : Proto(std::move(Proto)), Body(std::move(Body)) {}
};
|
解析器基础
上文,我们定义了 AST 的结构,包括各种类型的节点。下面,我们来介绍如何通过解析器构建 AST。例如,对于表达式 x+y
可以通过如下方式将其解析成 AST。
1
2
3
|
auto LHS = std::make_unique<VariableExprAST>("x");
auto RHS = std::make_unique<VariableExprAST>("y");
auto Result = std::make_unique<BinaryExprAST>('+', std::move(LHS), std::move(RHS));
|
为此,我们需要实现一些辅助函数,如下所示。我们通过 CurToken
作为词法分析器的输出 token 缓冲区。解析器内部每次调用词法分析器,输出一个 token,存储在缓冲区中。解析器则通过读取 CurToken
用于后续的解析。
1
2
3
4
5
6
7
|
/// CurTok/getNextToken - Provide a simple token buffer. CurTok is the current
/// token the parser is looking at. getNextToken reads another token from the
/// lexer and updates CurTok with its results.
static int CurTok;
static int getNextToken() {
return CurTok = gettok();
}
|
除了处理错误,我们还定义了 LogError
函数。这里我们对于不同类型的错误处理均返回 nullptr
。
1
2
3
4
5
6
7
8
9
|
/// LogError* - These are little helper functions for error handling.
std::unique_ptr<ExprAST> LogError(const char *Str) {
fprintf(stderr, "LogError: %s\n", Str);
return nullptr;
}
std::unique_ptr<PrototypeAST> LogErrorP(const char *Str) {
LogError(Str);
return nullptr;
}
|
表达式解析
Kaleidoscope 文法的每一个产生式,我们定义一个对应的解析函数。关于表达式的解析,其实可以分为以下几种类型:
- 数值表达式:解析
NumberExprAST
。
- 括号表达式:解析
BinaryExprAST
。
- 标识符表达式:解析两种 AST 类型
VariableExprAST
和 CallExprAST
。
下面我们分别进行介绍。
数值表达式
对于数值表达式,我们定义如下解析函数。
1
2
3
4
5
6
7
|
/// 文法产生式
/// numberexpr ::= number
static std::unique_ptr<ExprAST> ParseNumberExpr() {
auto Result = std::make_unique<NumberExprAST>(NumVal);
getNextToken(); // consume the number
return std::move(Result);
}
|
当词法分析器分析当前 token 的类型为 tok_number
时,解析器会调用 ParseNumberExpr
解析函数,读取全局变量 NumVal
,从而获取数值,最终创建并返回一个 NumberExprAST
节点。
在 ParseNumberExpr
解析函数中,它将读取所有与产生式相关的 token,并将下一个 token 写入词法分析器缓存 CurTok
中,以用于后续的分析。这其实是递归下降分析的标准方式,即 预测分析——提前读取下一个 token 进行分析,避免深度优先搜索带来回溯开销。
括号表达式
对于括号表达式,我们定义如下解析函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
/// 文法产生式
/// parenexpr ::= '(' expression ')'
static std::unique_ptr<ExprAST> ParseParenExpr() {
getNextToken(); // eat (.
auto V = ParseExpression();
if (!V)
return nullptr;
if (CurTok != ')')
return LogError("expected ')'");
getNextToken(); // eat ).
return V;
}
|
在 ParseParenExpr
解析函数中,我们可以看到对于 LogError
的使用。当调用 LogError
时,表示当前 token 是 (
,在解析完子表达式之后,发现并没有与之匹配的 )
。比如,当我们使用 (4 x
替代 (4)
作为输入,解析器就会调用 LogError
进行报错处理。
除此之外,我们还可以发现内部递归调用了 ParseExpression
解析函数(后面我们会提到该函数)。通过递归调用,解析器能够处理递归语法,从而简化每一个文法产生式,最终返创建并返回一个 BinaryExprAST
节点。
注意,括号并不会构建 AST 节点,其最大的作用是辅助解析器进行分组。当 AST 构建完毕,括号也就不需要了。
标识符表达式
对于标识符表达式,我们定义如下解析函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
/// 文法表达式
/// identifierexpr
/// ::= identifier
/// ::= identifier '(' expression* ')'
static std::unique_ptr<ExprAST> ParseIdentifierExpr() {
std::string IdName = IdentifierStr;
getNextToken(); // eat identifier.
if (CurTok != '(') // Simple variable ref.
return std::make_unique<VariableExprAST>(IdName);
// Call.
getNextToken(); // eat (
std::vector<std::unique_ptr<ExprAST>> Args;
if (CurTok != ')') {
while (1) {
if (auto Arg = ParseExpression())
Args.push_back(std::move(Arg));
else
return nullptr;
if (CurTok == ')')
break;
if (CurTok != ',')
return LogError("Expected ')' or ',' in argument list");
getNextToken();
}
}
// Eat the ')'.
getNextToken();
return std::make_unique<CallExprAST>(IdName, std::move(Args));
}
|
解析器会在当前 token 类型为 tok_identifier
时调用 ParseIdentifierExpr
解析函数。其内部同样实现了递归分析和错处处理,并且通过预测分析的方式来判断当前的标识符是变量引用表达式还是函数调用表达式,从而分别进行处理。这里的预测分析是通过判断当前 token
下一个 token 是否是 (
实现的,从而分别构建 VariableExprAST
节点和 CallExprAST
节点。
主表达式
我们将四种类型的表达式统称为 主表达式(Primary Expression)。
为了方便外部对各种类型的表达式进行解析,我们提供一个主表达式解析函数,对外隐藏细节,对内提供实现,该解析方法如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
/// 文法产生式
/// primary
/// ::= identifierexpr
/// ::= numberexpr
/// ::= parenexpr
static std::unique_ptr<ExprAST> ParsePrimary() {
switch (CurTok) {
default:
return LogError("unknown token when expecting an expression");
case tok_identifier:
return ParseIdentifierExpr();
case tok_number:
return ParseNumberExpr();
case '(':
return ParseParenExpr();
}
}
|
ParsePrimary
解析函数的实现逻辑非常清晰,即通过读取 CurTok
进行预测分析,判断 token 类型,并调用对应解析函数,从而构建 AST。
二元表达式解析
二元表达式是表达式的一种,相比于其他三种表达式,二元表达式(Binary Expression)解析会更加复杂,因为它们通常具有二义性。比如,当我们输入字符串 x+y*z
,解析器可以解析成 (x+y)*z
或 x+(y*z)
。基于数学定义,我们期望解析器能将其解析为后者,因为乘法 *
的优先级是高于加法 +
的。
处理二义性的方式很多,其中一种优雅且高效的方式是 算符优先分析法。这种分析技术通过为操作符定义优先级来辅助递归分析。比如,我们可以通过如下方式来定义优先级。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
/// BinopPrecedence - This holds the precedence for each binary operator that is
/// defined.
static std::map<char, int> BinopPrecedence;
/// GetTokPrecedence - Get the precedence of the pending binary operator token.
static int GetTokPrecedence() {
if (!isascii(CurTok))
return -1;
// Make sure it's a declared binop.
int TokPrec = BinopPrecedence[CurTok];
if (TokPrec <= 0) return -1;
return TokPrec;
}
int main() {
// Install standard binary operators.
// 1 is lowest precedence.
BinopPrecedence['<'] = 10;
BinopPrecedence['+'] = 20;
BinopPrecedence['-'] = 20;
BinopPrecedence['*'] = 40; // highest.
...
}
|
我们以二元运算符为键,优先级为值存储于哈希表中,便于进行扩展。在 Kaleidoscope 中,我们仅支持 4 种二元运算符。GetTokPrecedence
函数根据当前 token 从哈希表中读取对应的优先级,如果 token 不是二元运算符,则返回 -1。
算符优先分析法的基本思想是:将具有二义性二元运算符的表达式分解为多个片段,依次进行解析。比如,对于表达式 a+b+(c+d)*e*f+g
。算符优先分析法会将其视为一系列由二元运算符分隔的主表达式。因此,解析器会首先分析头部的主表达式 a
,然后依次分析 [+, b]
,[+, (c+d)]
,[*, e]
,[*, f]
,[+, g]
。由于括号表达式也是主表达式,因此二元表达式的解析并不需要关注类似 (c+d)
这样的嵌套子表达式。
下面,我们来看一下具体的实现。
首先,我们将表达式分解为 一个主表达式+多个[binop, primaryexpr]
的形式,对应的解析函数如下所示。
1
2
3
4
5
6
7
8
9
10
11
|
/// 文法产生式
/// expression
/// ::= primary binoprhs
///
static std::unique_ptr<ExprAST> ParseExpression() {
auto LHS = ParsePrimary();
if (!LHS)
return nullptr;
return ParseBinOpRHS(0, std::move(LHS));
}
|
ParseBinOpRHS
函数用于解析 [binop, primaryexpr]
序列,其入参包含两个:优先级、已解析的表达式指针。值得注意的是,表达式 x
其实也是一个有效的表达式,在这种文法表达式 binoprhs
为空的情况下,ParseBinOpRHS
解析函数会将传入的已解析的表达式指针直接返回。
ParseBinOpRHS
函数的优先级的参数表示 最小算符优先级(Minimal Operator Precedence),即函数能够允许读取的运算符。比如,如果当前的分析内容是 [+, x]
,而传入 ParseBinOpRHS
函数的优先级为 40,那么函数不会读取任何 token,因为 +
的优先级为 20。
ParseBinOpRHS
函数的具体定义如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
/// 文法产生式
/// binoprhs
/// ::= ('+' primary)*
static std::unique_ptr<ExprAST> ParseBinOpRHS(int ExprPrec,
std::unique_ptr<ExprAST> LHS) {
// If this is a binop, find its precedence.
while (true) {
int TokPrec = GetTokPrecedence();
// If this is a binop that binds at least as tightly as the current binop,
// consume it, otherwise we are done.
if (TokPrec < ExprPrec)
return LHS;
// Okay, we know this is a binop.
int BinOp = CurTok;
getNextToken(); // eat binop
// Parse the primary expression after the binary operator.
auto RHS = ParsePrimary();
if (!RHS)
return nullptr;
// If BinOp binds less tightly with RHS than the operator after RHS, let
// the pending operator take RHS as its LHS.
int NextPrec = GetTokPrecedence();
if (TokPrec < NextPrec) {
RHS = ParseBinOpRHS(TokPrec + 1, std::move(RHS));
if (!RHS)
return nullptr;
}
// Merge LHS/RHS.
LHS =
std::make_unique<BinaryExprAST>(BinOp, std::move(LHS), std::move(RHS));
}
}
|
在 ParseBinOpRHS
函数内部,它会首先读取当前 token 的优先级。如果优先级低于设定 ExprPrec
,则直接返回传入的已解析的表达式 LHS
。如果优先级符合设定,那么将解析操作符之后的主表达式。
此时,我们已经解析了表达式的左部以及 RHS 序列的一个分段。接下来,我们需要决定如何关联表达式。比如,这里有两种关联方式:(a+b) binop <
未解析部分>
或 a + (b binop <
未解析部分>)
。为此,我们通过预测分析的方式,继续向前读取一个运算符的优先级,并与 BinOp
的优先级进行比较(例子中是 +
)。
如果 RHS
右边的运算符的优先级小于或等于当前操作符,那么我们选择 (a+b) binop <
未解析部分>
的关联方式。在例子中,当前的运算符和下一个运算符都是 +
,具有相同的优先级。
在例子中,解析函数会将 a+b+
解析为 (a+b)
,并在下一次循环中继续执行。接下来,它会将 (c+d)
作为主表达式进行解析,即直接解析 [+, (c+d)]
。继续解析,则会遇到 *
运算符。由于 *
的优先级大于 +
,因此将执行 if
语句的内部逻辑。其内部逻辑会对高优先级的部分作为进行整体解析,然后将其作为低优先级运算符右部。为此,我们递归地调用 ParseBinOpRHS
函数,并指定最低优先级为 TokPrec+1
。在例子中,会将 (c+d)*e*f
作为 +
的 RHS
。
最后,[+, g]
会在下一次循环中被解析。
此时,我们可以使用解析器对任意 token 序列进行解析并构建表达式。当检测到不属于表达式的 token 时停止解析。
函数相关解析
关于函数相关的解析,其实可以分为几种类型:
函数原型
对于函数原型,我们定义如下解析函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
/// 文法产生式
/// prototype
/// ::= id '(' id* ')'
static std::unique_ptr<PrototypeAST> ParsePrototype() {
if (CurTok != tok_identifier)
return LogErrorP("Expected function name in prototype");
std::string FnName = IdentifierStr;
getNextToken();
if (CurTok != '(')
return LogErrorP("Expected '(' in prototype");
// Read the list of argument names.
std::vector<std::string> ArgNames;
while (getNextToken() == tok_identifier)
ArgNames.push_back(IdentifierStr);
if (CurTok != ')')
return LogErrorP("Expected ')' in prototype");
// success.
getNextToken(); // eat ')'.
return std::make_unique<PrototypeAST>(FnName, std::move(ArgNames));
}
|
函数定义
对于函数定义,我们定义如下解析函数。其本质上就是函数原型与普通表达式的组合,后者用于表示函数体,如下图所示。
1
2
3
4
5
6
7
8
9
10
11
|
/// 文法产生式
/// definition ::= 'def' prototype expression
static std::unique_ptr<FunctionAST> ParseDefinition() {
getNextToken(); // eat def.
auto Proto = ParsePrototype();
if (!Proto) return nullptr;
if (auto E = ParseExpression())
return std::make_unique<FunctionAST>(std::move(Proto), std::move(E));
return nullptr;
}
|
外部原型
对于外部原型,我们定义如下解析函数。其本质上就是 extern
关键字与函数原型的组合。
1
2
3
4
5
6
|
/// 文法产生式
/// external ::= 'extern' prototype
static std::unique_ptr<PrototypeAST> ParseExtern() {
getNextToken(); // eat extern.
return ParsePrototype();
}
|
顶层表达式解析
为了允许用户输入任意的顶层表达式,并支持解析。我们定义一个匿名空值(零参数)函数来进行解析,如下所示。
1
2
3
4
5
6
7
8
9
|
/// toplevelexpr ::= expression
static std::unique_ptr<FunctionAST> ParseTopLevelExpr() {
if (auto E = ParseExpression()) {
// Make an anonymous proto.
auto Proto = std::make_unique<PrototypeAST>("__anon_expr", std::vector<std::string>());
return std::make_unique<FunctionAST>(std::move(Proto), std::move(E));
}
return nullptr;
}
|
ParseTopLevelExpr
解析函数会通过调用 ParseExpression
解析函数进行解析。当解析得到 BinaryExprAST
节点后,创建一个 PrototypeAST
节点,并使用 FunctionAST
节点将两者封装成一个匿名函数,最终返回 FunctionAST
节点。
至此,我们介绍了各种类型的表达式的解析,下面我们通过实现一个驱动器来对实际的代码进行解析。
驱动器
驱动器(Driver)的实现如下所示,其仅仅是在一个顶层的循环中调用所有类型的表达式解析函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
static void HandleDefinition() {
if (ParseDefinition()) {
fprintf(stderr, "Parsed a function definition.\n");
} else {
// Skip token for error recovery.
getNextToken();
}
}
static void HandleExtern() {
if (ParseExtern()) {
fprintf(stderr, "Parsed an extern\n");
} else {
// Skip token for error recovery.
getNextToken();
}
}
static void HandleTopLevelExpression() {
// Evaluate a top-level expression into an anonymous function.
if (ParseTopLevelExpr()) {
fprintf(stderr, "Parsed a top-level expr\n");
} else {
// Skip token for error recovery.
getNextToken();
}
}
/// 文法产生式
/// top ::= definition | external | expression | ';'
static void MainLoop() {
while (1) {
fprintf(stderr, "ready> ");
switch (CurTok) {
case tok_eof:
return;
case ';': // ignore top-level semicolons.
getNextToken();
break;
case tok_def:
HandleDefinition();
break;
case tok_extern:
HandleExtern();
break;
default:
HandleTopLevelExpression();
break;
}
}
}
|
值得注意的是,这里我们对顶层的分号进行了忽略处理。原因是,如果我们在命令行中输入 4 + 5
,解析器并不是我们输入的内容是否结束。例如,我们可以在下一行输入 def foo...
,在这种情况下,4 + 5
是顶层表达式的结尾,或者,我们可以输入 * 6
来续写表达式。对此,使用顶层分号,对应 4 + 5;
表达式,解析器才能知道表达式已经输入完成。
总结
通过 400 多行代码,我们定义了一门简单的语言,包括词法分析器、解析器和 AST 构造器。完成之后,我们可以通过可执行文件来验证 Kaleidoscope 代码,从而判断代码是否存储语法语义错误。
如下所示,为验证步骤及其结果。
1
2
3
4
5
6
7
8
9
10
11
12
|
ready> def foo(x y) x+foo(y, 4.0);
Parsed a function definition.
ready> def foo(x y) x+y y;
Parsed a function definition.
Parsed a top-level expr
ready> def foo(x y) x+y );
Parsed a function definition.
Error: unknown token when expecting an expression
ready> extern sin(a);
ready> Parsed an extern
ready> ^C
$
|
当然,代码仍然具有很大的扩展空间。我们可以定义新的 AST 节点,并以多种方式对语言进行扩展等。下一章,我们将介绍如何从 AST 生成 LLVM 中间表示 IR
。
LLVM IR 代码生成
概述
前面介绍了如何构建抽象语法树 AST。这一章,我们进一步将抽象语法树转换成 LLVM IR。此外,简单介绍 LLVM 的工作原理和使用方法。
注意:本章源码要求 LLVM 版本大于等于 3.7。
初始化设置
为了支持 LLVM IR 代码生成,我们需要实现相关的初始化设置。
首先,我们在每一个 AST 类中定义虚拟代码生成方法,如下所示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
/// ExprAST - Base class for all expression nodes.
class ExprAST {
public:
virtual ~ExprAST() = default;
virtual Value *codegen() = 0;
};
/// NumberExprAST - Expression class for numeric literals like "1.0".
class NumberExprAST : public ExprAST {
double Val;
public:
NumberExprAST(double Val) : Val(Val) {}
Value *codegen() override;
};
...
|
codegen()
方法为对应 AST 节点及其所依赖的内容生成 IR,返回一个 LLVM Value
对象。
Value
是 LLVM 中用于表示 静态单赋值(Static Single Assignment,SSA)寄存器 或 SSA 形式 的类。静态单赋值,顾名思义,其要求每个变量只能赋值一次,并且每个变量在使用之前定义。因此,在重新计算之前,变量的值不会发生改变。
其次,我们实现了一个 LogError
方法,用于在代码生成期间报告错误,比如:未声明的参数。如下所示:
1
2
3
4
|
Value *LogErrorV(const char *Str) {
LogError(Str);
return nullptr;
}
|
此外,我们定义了一系列全局静态变量,用于辅助代码生成,如下所示。
1
2
3
4
|
static std::unique_ptr<LLVMContext> TheContext;
static std::unique_ptr<IRBuilder<>> Builder;
static std::unique_ptr<Module> TheModule;
static std::map<std::string, Value *> NamedValues;
|
TheContenxt
是一个不透明的对象,它包含许多 LLVM 核心数据结构,比如类型和常量值表。我们不需要详细了解它,只需要一个单例来传递其所需的 API。
Builder
对象是一个辅助对象,用于生成 LLVM 指令。IRBuilder
类模板的实例能够追踪插入指令的当前位置,并能够创建新指令。
TheModule
是一个包含一系列函数和全局变量的 LLVM 数据结构。在许多方面,它是 LLVM IR 用来包含代码的顶层结构。其持有了生成的所有 IR 的内存,这也是 codegen()
方法返回 Value*
指针,而不是 unique_ptr<Value>
的原因。
NamedValues
用于存储当前作用域内所定义的值及其 LLVM 表示形式。本质上就是代码的符号表。在 Kaleidoscope 中,唯一可以被引用的是函数参数。因此,在为函数体生成代码时,函数参数将存储在 NamedValues
中。
在介绍了代码生成的基础设置之后,下面我们开始介绍如何为每个表达式生成代码。
表达式代码生成
表达式节点的 LLVM IR 代码生成非常简单。下面,我们分别进行介绍。
数值表达式
如下所示,为数值表达式的代码生成方法定义,其创建并返回一个 ConstantFP
。事实上,在 LLVM IR 中,数字常量由 ConstantFP
类表示,其内部将数值保存在 APFloat
中(APFloat
能够保存任意精度浮点常量)。注意,在 LLVM IR 中,常量都是唯一且共享的。因此,API 通常使用 foo::get(...)
的用法,而不是 new foo(..)
或 foo::Create(..)
。
1
2
3
|
Value *NumberExprAST::codegen() {
return ConstantFP::get(TheContext, APFloat(Val));
}
|
变量表达式
如下所示,为变量表达式的代码生成方法定义,其仅检查指定变量是否在符号表 NameValues
中(如果没有,则引用未知变量)并返回它的值。在目前的定义中,我们在 NamedValues
中仅存储函数参数。在后续文章节中,我们将在符号表中支持 循环归纳变量(Loop Induction Variable)和 局部变量(Local Variable)。
1
2
3
4
5
6
7
|
Value *VariableExprAST::codegen() {
// Look this variable up in the function.
Value *V = NamedValues[Name];
if (!V)
LogErrorV("Unknown variable name");
return V;
}
|
二元表达式
如下所示,为二元表达式的代码生成方法定义,其基本思想是:分别为二元表达式的左部和右部递归生成代码,然后根据操作符的类型分别计算二元表达式的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
Value *BinaryExprAST::codegen() {
Value *L = LHS->codegen();
Value *R = RHS->codegen();
if (!L || !R)
return nullptr;
switch (Op) {
case '+':
return Builder.CreateFAdd(L, R, "addtmp");
case '-':
return Builder.CreateFSub(L, R, "subtmp");
case '*':
return Builder.CreateFMul(L, R, "multmp");
case '<':
L = Builder.CreateFCmpULT(L, R, "cmptmp");
// Convert bool 0/1 to double 0.0 or 1.0
return Builder.CreateUIToFP(L, Type::getDoubleTy(TheContext), "booltmp");
default:
return LogErrorV("invalid binary operator");
}
}
|
在上述代码实现中,我们通过 LLVM Builder
类的相关方法来生成代码,其内部知道在何处创建指令,我们要做的就是指定指令的类型(如:CreateFAdd
),指定使用的操作数(如:L
和 R
),指定生成指令的名称(如:addtmp
)。
关于生成指令的名称,其实只是一个提示。比如如,上述代码中如果发出多个 addtmp
变量,LLVM 将自动为每个变量提供一个递增的、唯一的数字后缀。 指令的名称完全是可选的,它的作用仅仅是提高 LLVM IR 的可读性。
LLVM 指令有着严格规则约束,比如,加法指令的左右操作数的类型必须相同,加法的结果类型必须与操作数类型相同。由于 Kaleidoscope 中的所有值都是双精度值类型,这使得 add
、sub
和 mul
的代码非常简单。
此外,LLVM 规定 fcmp
指令必须返回一个 i1
值(一个一位整数)。然而,我们定义的 Kaleidoscope 希望该值是 0.0 或 1.0(浮点数)。为了获得这些语义,我们将 fcmp
指令与 uitofp
指令结合起来。后者通可以将输入整数转换为浮点值。与此相反,如果我们使用 sitofp
指令,那么 Kaleidoscope 的 <
运算符将根据输入值返回 0.0 和 -1.0。
函数调用表达式
如下所示,为函数调用表达式的代码生成方法定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
Value *CallExprAST::codegen() {
// Look up the name in the global module table.
Function *CalleeF = TheModule->getFunction(Callee);
if (!CalleeF)
return LogErrorV("Unknown function referenced");
// If argument mismatch error.
if (CalleeF->arg_size() != Args.size())
return LogErrorV("Incorrect # arguments passed");
std::vector<Value *> ArgsV;
for (unsigned i = 0, e = Args.size(); i != e; ++i) {
ArgsV.push_back(Args[i]->codegen());
if (!ArgsV.back())
return nullptr;
}
return Builder.CreateCall(CalleeF, ArgsV, "calltmp");
}
|
在上述方法实现中,在 LLVM Module 的符号表中查找对应的函数。LLVM Module 是 JIT 阶段存储函数的容器。通过为每个函数指定与用户指定的名称相同的名称,我们可以使用 LLVM 符号表为我们解析函数名称。
当发生调用函数时,我们递归地对每个入参进行代码生成,并创建一个 LLVM 函数调用指令。注意,LLVM 默认使用 C 调用规范,允许这些函数调用同时调用标准库函数,如 sin
和 cos
。
函数相关代码生成
函数和原型的代码生成涉及一系列的细节处理,这也使得其代码实现不如表达式代码生成实现那么简洁优雅。下面,我们来分别介绍函数原型和函数定义的代码生成。
函数原型
函数原型的代码生成也可以应用于函数定义和外部原型。如下所示,为函数原型的代码生成方法定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
Function *PrototypeAST::codegen() {
// Make the function type: double(double,double) etc.
std::vector<Type *> Doubles(Args.size(), Type::getDoubleTy(*TheContext));
FunctionType *FT = FunctionType::get(Type::getDoubleTy(*TheContext), Doubles, false);
Function *F = Function::Create(FT, Function::ExternalLinkage, Name, TheModule.get());
// Set names for all arguments.
unsigned Idx = 0;
for (auto &Arg : F->args())
Arg.setName(Args[Idx++]);
return F;
}
|
PrototypeAST::codegen
函数返回的类型是 Function *
,而不是 Value
。本质上,原型用于描述函数的外部接口,而不是表达式计算的值,因此其返回代码生成时对应的 LLVM 函数,即 Function *
类型。
FunctionType::get
函数为原型创建函数类型 FunctionType
。由于 Kaleidoscope 中的所有函数参数都是双精度类型,因此在调用 FunctionType::get
之前,我们根据参数的数量 Args.siz()
创建了一个包含 N
个 LLVM 双精度类型的向量。然后,使用 FunctionType::get
方法创建一个函数类型,该函数类型接受 N
个双精度类型的值作为参数,返回一个双精度类型的值作为结果。
Function::Create
函数使用函数类型创建 IR 函数,其指定函数类型、链接和名称,以及写入的目标模块。外部链接(External Linkage) 表示该函数可以在当前模块外部进行定义,或者可以被模块外部的函数所调用。传入的 Name
参数表示用户指定的名称,由于指定了 TheModule
,因此该名称会被注册在 TheModule
的符号表中。
最后,我们为每个函数参数设置名称。这个步骤并非必要,但保持名称一致会使 IR 更具有更好的可读性,并且允许后续代码直接引用参数名称,而不必在原型的 AST 中进行查找。
函数定义
如下所示,为函数定义的代码生成方法定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
Function *FunctionAST::codegen() {
// First, check for an existing function from a previous 'extern' declaration.
Function *TheFunction = TheModule->getFunction(Proto->getName());
if (!TheFunction)
TheFunction = Proto->codegen();
if (!TheFunction)
return nullptr;
// Create a new basic block to start insertion into.
BasicBlock *BB = BasicBlock::Create(*TheContext, "entry", TheFunction);
Builder->SetInsertPoint(BB);
// Record the function arguments in the NamedValues map.
NamedValues.clear();
for (auto &Arg : TheFunction->args())
NamedValues[std::string(Arg.getName())] = &Arg;
if (Value *RetVal = Body->codegen()) {
// Finish off the function.
Builder->CreateRet(RetVal);
// Validate the generated code, checking for consistency.
verifyFunction(*TheFunction);
return TheFunction;
}
// Error reading body, remove function.
TheFunction->eraseFromParent();
return nullptr;
}
|
对于函数定义,我们首先在模块的符号表中查找该函数的已有版本。如果 Module::getFunction
返回 null
则表示不存在,因此我们将通过 Proto->codegen
来生成。
然后,我们对 Builder
进行设置,具体分为两个步骤:
- 通过
BasicBlock::Create
函数创建一个 基本块(Basic Block),基本块随后被插入到 TheFunction
中。
- 通过
Builder->SetInsertPoint
函数设置基本块的末尾为指令的插入位置。LLVM 中的基本块是定义 控制流图(Control Flow Graph)中的函数的重要组成部分。 由于我们没有任何控制流语句,我们的函数此时只包含一个基本块。
随后,我们将函数参数添加到 NamedValues
符号表中,以便 VariableExprAST
节点进行访问。
当设置了代码插入点并注册了 NamedValues
符号表后,我们开始对函数体执行 codegen()
方法。如果没有发生错误,我们会进而创建一个 LLVM ret
指令,表示函数的终止。当函数构建完成后,我们调用 LLVM 的 verifyFunction
函数对生成的代码进行各种一致性检查,以确保我们的编译器是否一切正常。verifyFunction
函数非常重要,它能够检测出很多 bug。检测完毕且无错误,我们返回构造的函数。
最后的实现逻辑是错误处理。当错误发生时,我们仅通过调用 eraseFromParent
函数来删除过程中生成的函数。这样能够允许用户重新定义他们之前输入的错误函数,如果我们不删除它,它将存在于符号表中,并且无法重新定义。
驱动器适配
为了进行测试,我们对驱动器了进行简单的适配,将对 codegen
的调用插入到 HandleDefinition
、HandleExtern
等函数,并在其内部打印 LLVM IR。
对于顶层二元表达式,LLVM IR 的代码生成结果如下所示。我们可以看到解析器如何为将顶层表达式转换为匿名函数的,这对于我们后续支持 JIT 是非常有用的。此外,我们可以看到生成的 IR 是逐字翻译的,除了常量合并,没有作其他任何优化。在后续的文章中,我们会显式地进行优化。
1
2
3
4
5
6
|
ready> 4+5;
Read top-level expression:
define double @__anon_expr() {
entry:
ret double 9.000000e+00
}
|
对于简单的函数定义,如下所示,分别是 LLVM IR 的代码生成结果,以及生成过程的示意图。
1
2
3
4
5
6
7
8
9
10
11
12
|
ready> def foo(a b) a*a + 2*a*b + b*b;
Read function definition:
define double @foo(double %a, double %b) {
entry:
%multmp = fmul double %a, %a
%multmp1 = fmul double 2.000000e+00, %a
%multmp2 = fmul double %multmp1, %b
%addtmp = fadd double %multmp, %multmp2
%multmp3 = fmul double %b, %b
%addtmp4 = fadd double %addtmp, %multmp3
ret double %addtmp4
}
|
对于简单的函数调用,LLVM IR的代码生成结果如下所示。当我们调用 bar
函数时,其会消耗比较长的时间执行完毕。后续,我们添加条件控制流来优化递归。
1
2
3
4
5
6
7
8
9
|
ready> def bar(a) foo(a, 4.0) + bar(31337);
Read function definition:
define double @bar(double %a) {
entry:
%calltmp = call double @foo(double %a, double 4.000000e+00)
%calltmp1 = call double @bar(double 3.133700e+04)
%addtmp = fadd double %calltmp, %calltmp1
ret double %addtmp
}
|
如下所示,为声明外部 cos
函数以及调用 cos
函数的 IR 代码。
1
2
3
4
5
6
7
8
9
10
11
|
ready> extern cos(x);
Read extern:
declare double @cos(double)
ready> cos(1.234);
Read top-level expression:
define double @__anon_expr() {
entry:
%calltmp = call double @cos(double 1.234000e+00)
ret double %calltmp
}
|
当我们退出测试进程时(MacOS 系统,输入 cmd+D
),其会打印生成的 Module 的所有 IR 代码。我们可以看到很多方法相互引用,如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
ready> ^D
; ModuleID = 'my cool jit'
source_filename = "my cool jit"
define double @foo(double %a, double %b) {
entry:
%multmp = fmul double %a, %a
%multmp1 = fmul double 2.000000e+00, %a
%multmp2 = fmul double %multmp1, %b
%addtmp = fadd double %multmp, %multmp2
%multmp3 = fmul double %b, %b
%addtmp4 = fadd double %addtmp, %multmp3
ret double %addtmp4
}
define double @bar(double %a) {
entry:
%calltmp = call double @foo(double %a, double 4.000000e+00)
%calltmp1 = call double @bar(double 3.133700e+04)
%addtmp = fadd double %calltmp, %calltmp1
ret double %addtmp
}
declare double @cos(double)
|
总结
本文,我们为解析器支持了 LLVM IR 代码生成功能,后续在此基础上,为 Kaleidoscope 支持 JIT 代码生成以及优化器。
参考
- Kaleidoscope: Code generation to LLVM IR
- Static single-assignment form
参考文献了解
http://chuquan.me/2022/07/31/compiler-for-kaleidoscope-02/
http://chuquan.me/2022/08/07/compiler-for-kaleidoscope-03/