动手写个玩具编译器
文章目录
一直对源码如何变成可在电脑上运行的二进制文件很感兴趣,虽然在大学时学过编译原理相关课程,但当时就是为了应对考试,基本了解个大概基本什么都没剩下。这个长假终于有时间作了些研究,终于算是补齐了这块知识盲区。
想弄明白源码到底是如何变成二进制文件的,最好的方式莫过于动手写一个编译器试试。第一知觉,写个编译器应该很难吧。但在现在已经是2020年,各种工具文档满天飞,写个玩具编译器还真不是很难的事。
编译器理论知识
一个编译器是由一组有三个到四个组件(还有一些子组件)构成,数据以管道的方式从一个组件输入并流向下一个组件。在我们这个编译器中,可能会用到一些稍微不同的工具。下面这个图展示了我们构造一个编译器的步骤,和每个步骤中将使用的工具。
上图中Linking
这一步一般不需要我们做什么。在文法分析阶段,这里使用开源工具Lex,即如今的Flex,文法分析一般都伴随者语法分析,这里使用的语法分析工具将会是Yacc,或者说是Bison,最后一旦语义分析完成,我们将遍历我们的抽象语法树,并生成”bytecode 字节码”。做这一步,这里使用LLVM,它能生成快速LLVM IR。再使用LLVM自带的工具llc
将LLVM IR字节码文件编译为汇编代码文件,最后使用clang++
工具将汇编代码文件编译为二进制文件。
总结一下,步骤如下:
- 文法分析用
Flex
:将数据分隔成一个个的标记token (标示符identifiers,关键字keywords,数字numbers, 中括号brackets, 大括号braces, 等等etc.) - 语法分析用
Bison
: 在分析标记的时候生成抽象语法树. Bison 将会做掉几乎所有的这些工作, 这里只需要定义好抽象语法树就OK了。 - 组装用
LLVM
: 这里我们将遍历我们的抽象语法树,并未每一个节点生成字节/机器码。 - 最后用几个命令完成编译为二进制文件的工作。
定义要创造的语法
编译器的输入是源代码,既然是写个编译器,第一步是定义想要创造的语法。
test/exmaple.txt
|
|
上述这个语法跟C语言很像,只是每行少了;
号。
安装三套件
首先安装flex
、bison
、llvm
,分别用来进行词法分析、语法分析、构造AST及生成LLVM IR
|
|
使用Flex进行文法分析
面对一个源代码文件,编译器要做的第一件事就是扫描这个源文件,将其中包含的词法token解析出来。这个事情自己写代码用上一些正则表达式也可以搞定,但开源界已有了更好的选择Flex,一个快速的词法token扫描器。这个工具很简单,所有文档也就一页。
我们参考文档写词法定义文件。
token.l
|
|
可以看到上面一段%{
和%}
是cpp代码,引入了一些头文件,定了一些宏。
接着是%option noyywrap
,说明扫描完一个文件后即停止。
再接着就是最关键的一段了,在%%
与%%
之间。左侧是每个词法token匹配的正则,右侧是对于匹配到的词法token,返回对应的token常量。这里注意,如果是关键字token,不仅要返回token常量前要设置好yylval.token
。如果是标识符、数字常量token,则要在返回token常量前设置好yylval.string
。如果上述所有正则都没匹配到,则说明出现了未知的词法token,词法解析中止。
使用Bison进行语法分析
词法分析完成后,编译器面对的是一个个词法token,编译器要弄懂这些词法token究竟表达什么意义,这时就要进行语法分析了。怎么将理解出来的语法含义用数据结构表达出来,当然是用AST(抽象语法树)。AST(抽象语法树)既然是一颗树,树上就会有很多节点,这些节点根据语义的不同还需要是不同的类型,因此需要设计一下AST(抽象语法树)上节点的语义单元类型,如下图所示:
上图对应的cpp代码如下:
node.h
|
|
这个没有什么太多好说的,基本与上图是一一对应的,除了每个class有一个virtual llvm::*Value** codeGen(*CodeGenContext*& *context*);
虚函数定义,这个是给后面生成LLVM IR预留的。
接下来就可以定义语法了。
parser.y
|
|
上述这个文件定义了支持的各种语法结构,这里的核心是一条条语法规则,类似于BNF范式定义。这个感觉是最难看懂的一部分了,可以结合着YACC(BISON)使用指南看。
掌握了bison的用法,写语法定义文件不难,但很容易出现移位/归约冲突。这时就可以将冲突的示例打印出来仔细分析,并对语法定义文件进行调整了。
|
|
如果一切没什么问题,这时就可以生成语法解析cpp文件及词法扫描cpp文件了:
|
|
组装AST和生成LLVM IR
其实经过上面的步骤后,我们写个小程序就已经可以组装出AST了。
main.cpp
|
|
简单编译一下:
|
|
再执行一下:
|
|
这时应该就可以打印出programBlock
这个AST树的根节点了。
但组装出AST不是我们的目标,我们的目标是将抽象语法树AST转换为机器码。这意味着将每一个语义节点转换成等价的机器指令。LLVM将帮助我们把这步变得非常简单,因为LLVM将真实的指令抽象成类似AST的指令。这意味着我们真正要做的事就是将AST转换成抽象指令。 你可以想象这个过程是从抽象语法树的根节点开始遍历每一个树上节点并产生字节码的过程。现在就是使用我们在Node中定义的codeGen方法的时候了。例如,当我们遍历NBlock代码的时候(语义上NBlock代表一组我们语言的语句的集合),我们将调用列表中每条语句的codeGen方法。上面步骤代码类似如下的形式:
|
|
在实现批量codeGen
函数前,这里还实现一个简单的代码生成上下文对象,里面维护了一个简单的block栈。
codegen.h
|
|
最后为node.h
中定义的每一种语义单元编写好codeGen
代码实现。做这部分之前需要了解下LLVM IR,参考LLVM IR入门指南。
另外如何用cpp代码操作LLVM IR,暂时也找不到更好参考指引,要多参照llvm的examples研究,依葫芦画瓢。
codegen.cpp
|
|
上述文件中最上面的generateCode
及runCode
先不看,剩下的基本上全是为每一种语义单元编写好codeGen
代码实现,将该语义单元翻译为对应的LLVM IR。
generateCode
函数的作用是在最外层再包一个main
函数,并将最终生成的LLVM IR字节码输出。
runCode
函数的作用是使用LLVM自带的JIT执行引擎,直接运行LLVM IR字节码。
再然后这里写个main函数,将生成的LLVM IR字节码写入到文件中。
main.cpp
|
|
这里还通过createCoreFunctions
函数植入了一些内置核心函数。
编译出我们的编译器:
|
|
使用新鲜出炉的编译器将test/example.txt
编译为test/example.bc
:
|
|
test/example.bc
中的内容就是LLVM IR的字节码了。
我们可以用LLVM JIT
来执行一下:
|
|
生成的bc
文件不可读,可以用llvm-dis
工具反解析一下得到人类可阅读的LLVM IR字节码:
|
|
编译出二进制并执行
最后将上面得到的LLVM IR字节码文件编译出二进制文件:
|
|
测试下二进制文件是否可以正常执行:
|
|
因为我们在语法定义文件里支持了链接外部函数,因此这里还支持调用cpp的函数,演示如下:
test/example.txt
|
|
这里声明了一个外部的函数printi
,我们在cpp文件中实现它:
native.cpp
|
|
使用新鲜出炉的编译器将test/example.txt
编译为test/example.bc
:
|
|
将上面得到的LLVM IR字节码文件编译出二进制文件:
|
|
测试下二进制文件是否可以正常执行:
|
|
至此,一个工具性质的编译器就完成了。
总结
在编写一个简易编译器的过程中,查阅了相关多的资料,学习并加强了编译原理相关的知识,对代码是如何运行的有了更深刻的认识,整个探究过程还挺有意思的。
本篇涉及的所有代码见toy-compiler。
参考
- https://gnuu.org/2009/09/18/writing-your-own-toy-compiler/
- https://coolshell.cn/articles/1547.html
- https://blog.csdn.net/wp1603710463/article/details/50365640
- https://github.com/Evian-Zhang/llvm-ir-tutorial
- https://stackoverflow.com/questions/3167751/how-to-solve-a-shift-reduce-conflict
- https://llvm.zcopy.site/
文章作者 Jeremy Xu
上次更新 2020-10-06
许可协议 © Copyright 2020 Jeremy Xu