【翻文+导读】《Writing a C Compiler》从0开始写C编译器
前言
据说,这是学习做编译器必读优秀文章,所以就做一下翻译以及导读,顺便自己也学习一下。作者Nora Sandler 也很用心,涉及一些如何从0开发编译器的实施计划和方法论 (Abdulaziz Ghuloum 的 增量开发的方式)。转载请留言申请即可
翻译《Writing a C Compiler, Part 1》
https://norasandler.com/2017/11/29/Write-a-Compiler.html
这是第一篇关于[编写自己C编译器]的博文,写编译器的目的如下:
1.学习抽象语法树AST,如何用语法表示一个程序源码,以及根据语法处理源码。便于使用内存生成器linters、静态分析程序和各种元编程。
讲解
其实我们用高级语言编程,然后利用编译器翻译成低级语言,其本质依然是利用低级语言操作计算机,如果懂得编译器原理,就等于可以更好的使用高级语言去控制低级语言。当然这得要求我们对低级语言有一定的熟悉程度这样才能更好的明白从高级语言到低级语言的,编译器做了什么。
否则,我们只是以一种科普的角度去了解这个过程,只停留在知其然不知其所以然的层面。除非我们可以一遍学习编译器原理,一边回顾低级语言指令的知识,我个人比较倾向于这种方式。
2.学习汇编和计算机底层调用细节。
讲解
因为接触编译器等于接触低级语言,所以学习编译器会顺带把汇编等底层知识也学习了。
3.写一个编译器(开始变成一个垃圾编译器制作者)
讲解
所以,学习编译器是为了了解程序怎么变成计算机指令,以及做一个编译器。
我已经开始用我自己写的编译器(无标点符号的C编译器 https://github.com/nlsandler/nqcc)撸了一周的代码了。我参考的是Abdulaziz Ghuloum 的 增量开发的方式(An Incremental Approach to Compiler Construction http://scheme2006.cs.uchicago.edu/11-ghuloum.pdf)有计划的写自己的编译器。我挺喜欢这个方法的。先做一个迷你编译器,只让它编译部分你想要的源码(比如只让他编译HelloWorld),然后逐步打补丁,直到能支持所有x86汇编码。 具体就是,我们先让编译器编译一个返回常量的程序,到后面能识别加减法代码等等,我们要确保每一个特性都是在我们能力范围之内。
讲解
假如你连继承都理解的半桶水,你肯定做不好一个能编译类继承特性的编译器。所以我们的编译器能支持的源码功能越弱智越好。
原则上就是做到每一个版本都是最小可用的版本,不要做一个完全没法用的半成品。
我的文章改编自Ghuloum的一篇教程论文,我实际上也是逐步给我的C编译器新增特性,我先后实现算数运算,条件判断,以及本地变量,函数调用等等。我还写了很多测试用例,去验证我的编译器每个版本是否都可以交付使用。
讲解
作者的研究思路不错,值得我们去学习,就是比较费时间。
作者,通过看别人的论文,自己动手做,并且写了测试用例去确保自己的产品最小可用。以此学习编译器知识,可以给鼓噪的学习过程带来很大的成就感。并且作者一边做一边写文章,也可以对自己的知识做一个沉淀总结。
就是比较费时间
前言
在开始前,你需要准备两件事情:1.选什么语言写编译器,2.思考怎么写词法分析器和语法分析器。
我建议使用具有 Sum Types 和模式匹配的语言(什么是Sum Types https://chadaustin.me/2015/07/sum-types/ ).比如 OCaml, Haskell, or Rust.
它可以构建和遍历AST。 我最开始用python写编译器,因为我擅长Python,后来我选择OCaml,虽然我不熟悉,但我做了正确的决定。
讲解
到这里我可以知道,作者其实是在写编译器后端程序,后端程序主要是处理AST,并将他转译为 汇编码。
而前端程序。Lexer和Parser作者倾向于使用工具库,其中作者为了能友好的于AST交互,所以采用了SumTypes以及 模式匹配的语言 。
了解这一点,我们后面可以顺着作者的思路了解作者做了什么。
你需要考虑要不要写一下编译器前端的lexer和parser,如果不感兴趣,也可以不写,直接使用 类似 flex 和bison 等工具库。在本文,我自己手写了词法分析器lexer和自顶向下递归的语法分析器parser 。可能用现成的工具库会更轻松,但我还是想试试重复造轮子,至于你自己看着办。
不管怎样,接下来我将化大量篇幅讲解怎么手写词法分析器和语法分析器。
Update 2/18/19
你还需要决定使用多少位的汇编码,选用32位的,因为 Ghuloum的教程论文也是用32位的。但后来我发现32位已经过时了,导致编译和运行二进制代码很麻烦(找不到好的运行环境),所以我决定用回64位的。并且在本文追加了一些64位的例子
对此我有两个建议:
1.如果你熟悉64位指令集,我建议你研究怎么把本文的32位指令改为64位的,我觉得这不难。
2.继续使用32位,就像本文之前一样,但是要求你用32位的操作系统。
讲解
我看到作者把这个大工程分为几周完成,每周只做一个功能。这种学习毅力值得表扬以及学习。
第一周 : Integers
本周我们要让编译器实现的特性是编译一个“返回 整型Integer 的main函数 ” 。
我们让编译器实现3个basic passes ,这将是一种费力不讨好的工作,但是这个骨架对于后期新增其他语言特性提供很大的便利。
讲解
basic passes 是什么?先挖个坑,后面再解释。
源代码如下:
文件名叫 return_2.c
int main() {
return 2;
}
我们的程序只有一个main函数 以及一个 return 命令, 并且 return 只返回 integer类型,而且只支持10进制。
之后我们用这个return_2.c给我们的编译器编译,观察编译后的结果,检查编译结果是否正确。
具体过程如下
$ ./YOUR_COMPILER return_2.c # compile the source file shown above
$ ./gcc -m32 return_2.s -o return_2 # assemble it into an executable
$ ./return_2 # run the executable you just compiled
$ echo $? # check the return code; it should be 2
2
从上面可以看出,我们的编译器只是将源码编译成 汇编码 return_2.s
然后由gcc 将其 汇编成 可执行文件。(这是汇编器和连接器干的活)
gcc 编译器也可以将源码变成汇编码(我们自己写的编译器也只是这件事而已)
下面我们看看gcc 编译后的会编码长什么样子(随便和我们自己写的编译器编译后的结果对照一下,差别多大)
$ gcc -S -O3 -fno-asynchronous-unwind-tables return_2.c
$ cat return_2.s
.section __TEXT,__text_startup,regular,pure_instructions
.align 4
.globl _main
_main:
movl $2, %eax
ret
.subsections_via_symbols
(我们暂时先无视 -S -O3 -fno-asynchronous-unwind-tables 这几个参数)
现在我们看看编译后的结果,我们先暂时忽略【 .section, .align and .subsections_via_symbols 】3个指令, 因为它与我们的主题暂时无关。 而且你删了他们,依然可以成功让gcc的汇编器和连接器工作,并生成正常的可执行文件.
注释
如果你对这三个指令感兴趣可以自己阅读,这里不翻译了
I think these directives will vary between platforms; these were generated using Homebrew gcc 7.2.0 on OS X. Here’s what they mean:
.section __TEXT,__text_startup,regular,pure_instructions tells the assembler that this is the text section, which contains assembly instructions (other sections might contain string literals, initialized data, debug information, etc.).
.align 4 tells the assembler to align all the instructions at 16-byte intervals - the 4 here is a power of 2, 2^4 = 16. On some architectures .align 4 would mean align at 4-byte intervals. This directive is important because instructions (and data) can be fetched more quickly from word-aligned addresses on most CPU architectures. On a 64-bit machine, a word is only 4 bytes, but some SIMD x86 instructions require data to be 16-byte aligned; I think that’s why GCC emits .align 4.
.subsections_via_symbols is used to eliminate dead code. It indicates that each chunk of assembly beginning with a symbol can be treated as an individual block, and removed if it’s not used by any other block. More info in the documentation here.↩
.globl _main 指令用于告诉连接器,程序的入口在_main函数这里(如果事类unix系统,main前面没有下划线)
而最终,我们关注的真正的汇编指令是:
_main: ; 函数名标签
movl $2, %eax ; move 常量2 给 EAX 寄存器
ret ; return 指令返回函数栈,返回结果默认位于 EAX寄存器中。
EAX寄存器的值会被当作main函数的返回值,也就是我们程序的退出码 exit code 。
一个重要旁注:整篇教程,我们使用的是AT&T的汇编语法,因为他是GCC的默认语法,网上的汇编代码习惯用intel的汇编语法,它与AT&T的语法典型区别就是操作参数的顺序反过来,这一点值得我们注意。
讲解:
我们以mov指令为例子,将一个常量赋值给某一个寄存器
AT&T汇编是这样的 movl $1, %ebx
Intel汇编这样的 mov ebx, 1;
知道这个常识就好。
上面那段代码唯一会改变也就是返回值了,因为我们可以用一种简单的实现方法来做我们的编译器,即使用正则表达式匹配源码中的返回值,然后把他填充到汇编吗模板中,下面我们用20行Python代码即可实现:
import sys, os, re
#expected form of a C program, without line breaks
source_re = r"int main\s*\(\s*\)\s*{\s*return\s+(?P<ret>[0-9]+)\s*;\s*}"
# Use 'main' instead of '_main' if not on OS X
assembly_format = """
.globl _main
_main:
movl ${}, %eax
ret
"""
source_file = sys.argv[1]
assembly_file = os.path.splitext(source_file)[0] + ".s"
with open(source_file, 'r') as infile, open(assembly_file, 'w') as outfile:
source = infile.read().strip()
match = re.match(source_re, source)
# extract the named "ret" group, containing the return value
retval = match.group('ret')
outfile.write(assembly_format.format(retval))
显然这里用正则表达式匹配源码不是长远之计,正确的做法是: 将编译过程分为3个阶段,词法分析,语法分析,代码生成。据我所知,这是最完美的标准编译器架构(有时候可能会在语法分析之后进行中间端优化,再进行代码生成)。
Lexing 词法分析
Lexer 词法分析(或者叫Scanner , tokenizer ),
在这个阶段,编译器会将源码字符串分解成token数组。 token 是语法分析器能够识别的最小单元。我们可以把程序看成是一段文字,而token就是这段文字中的单词,
而token 往往是以空格作为分隔符。
以下是常见的token : 变量名,关键词,常量,标点符号(比如大括号)。
在 return_2.c 这份代码中识别出的token 如下:
int 关键词
main 标识符
( 左括号
) 右括号
return 关键词
{ 左大括号
2 常量
; 分号
} 右大括号
从上面代码中我们可以发现,有些token 有数值,比如常量2,有些token没有数值,比如大小括号。
并且,在return_2.c例子中,空格不属于token(但是有些编程语言,会把空格识别为token。比如python)
以下用正则定义的字符都是我们的词法分析器需要去识别的token:
左大括号 {
右大括号 }
左小括号 (
右小括号 )
分号 ;
int关键词 int
return 关键词 return
标识符 [a-zA-Z]\w*
整型常量 [0-9]+
上次我们给token分了很多类型, 其实我们可以把所有的token的类型都定义为 关键词 keyword类型,不一定要分那么多类型。即 token == keyword。
课后作业:
写一个lex 函数 , 输入一个文件,返回tokens数组。这个函数会用两个测试用例进行测试,一个是正常的测试用例,一个是有错误的异常测试用例。(如果输入异常测试用例,要求我们的lexer程序不能报错,而是由parser提示词法分析异常)
出于简单考虑,我们的lexer只实现10进制,如果你有兴趣可以试试支持一下16进制。
你也许主要到了,我们并没有一种叫【负整数】的token, 这并不是忘记了。而是C语言本身并没有所谓的【负整型常量】,但是C语言有负数操作,他可以作用于正整型,以此实现【负整型】的功能。
Parsing
parsing 语法分析, 将token数组 转化为 抽象语法树 AST abstract syntax tree. 抽象语法树是常见的用来表示程序结构的一种方法。在很多编程语言中,复杂的语言结构都是由简单的语言结构组合而成,比如 条件判断和函数声明,都是由变量、常量等参与构成。AST 抽象语法树 就是要捕捉这种语法关系, 抽象语法树的root 节点 代表整个 【program 程序】, 每一个节点都有子节点来描述它的组成成分。
以下是一个例子:
if (a < b) {
c = 2;
return c;
} else {
c = 3;
}
上面这段代码是一段if语句,所以我们讲根节点标记为【if statement】.它有3个子节点:
1.条件节点
2.if body节点
3.else body节点
讲解;
if语句的3个AST子节点的命名挺有规律,至于命名对我们来说无所谓,只是起到标识的作用。
比如我们也可以叫他
ifbody1
ifbody2
ifbody3
...
每一个子节点还可以进一步细化。
比如 条件节点 可以以 <操作符 作为root,分割为 操作符的左参数 a 和 右参数 b。
赋值语句 例如 c=2 也可以拆分为 c 和 2.
在ifbody节点中可以有任意个子节点,每一个子节点都象征一个语句,在本例中,ifbody节点只有2个孩子节点,一个c=2赋值语句和一个return c 返回语句。他们按顺序排序列,正如子节点的排列顺序。
完整的AST结构如下图:
讲解:
可以看出整个程序结构图,顶部是结构,底部是变量或者常量。
构建AST的伪代码如下:
//create if condition
cond = BinaryOp(op='>', operand_1=Var(a), operand_2=Var(b))
//create if body
assign = Assignment(var=Var(c), rhs=Const(2))
return = Return(val=Var(c))
if_body = [assign, return]
//create else body
assign_else = Assignment(var=Var(c), rhs=Const(3))
else_body = [assign_else]
//construct if statement
if = If(condition=cond, body=if_body, else=else_body)
讲解:
上面的伪代码看看就好,对于数据结构 树 有一定基础的同学,往多叉树
里面加点数据不是什么复杂的事情。
也不是本文的关注点。
对于我们的return_2.c程序来说,它的AST伪代码就简单多了,伪代码如下:
program = Program(function_declaration)
function_declaration = Function(string, statement) //string is the function name
statement = Return(exp)
exp = Constant(int)
从伪代码可以看出我们的程序,由一个main函数组成, 后面我们定义程序的结构是一个函数集合。
讲解
经常用C语言的同学应该知道,一个c程序就是一个main函数加其他各种模块函数组成。
每一个函数都有自己的函数名函数体,还有函数的参数表,还有返回值类型。而return_2.c的AST定义我们并没有做得这么讲究,所以大家知道这么回事就好,将就着看。
其中有一点需要提示,就是我们Return语句 并没有很严格,只能匹配return 常量Constant(int).
如果需要返回复杂的表达式,比如return 2+2,这种在表达式中做运算。那么我们的return语句应该写成
statement = Return (exp) | Assign(variable, exp)
讲解:
这一段大家听过看起很陌生,尤其是没有看过EBNF的同学,自行百科一下Extended Backus–Naur form
他是一种语法定义语言。
用来定义语法用的,我们可以粗暴理解为他是类似正则表达式。
return_2.c的AST图如下:
最终我们用正式的Backus-Naur Form表示我们的AST,如下:
<program> ::= <function>
<function> ::= "int" <id> "(" ")" "{" <statement> "}"
<statement> ::= "return" <exp> ";"
<exp> ::= <int>
上面的每一行我们都叫他【a production rule】production规则。
它描述了一个语言的结构,是怎么由 oken和其他语言组合而成。
EBNF中 终端与非终端的定义:
Every symbol that appears on the left-hand side of a production rule (i.e. , , ) is a non-terminal symbol. Individual tokens (keywords, ids, punctuation, etc.) are terminal symbols.
讲解:
从这段文字我们可以理解到,所有token我们都称之为终端, 比如 keyword关键词,id等标识符,标点符号这类出现在源码中的token,我们都可以称之为 终端。
而其他变量我们称之为 非终端,非终端都会出现在每个EBNF production的左端。这个比较好理解
比如 <program>, <function>, <statement> <exp>
顺便提一下EBNF与AST的关系,EBNF是生成AST的规则,他必须定义所有用到的AST生成规则,否则等于源码中出现了无效字符,也就说我们说的语法错误。
production rule 翻译成中文叫 生成规则
由于本例非常简单,所以我们每一个非终端的都只有一个生成规则.
类似statement 正常我们除了有return语句肯定还有声明变量的语句,这个时候根据EBFN的规则,我们可以用 | 或 , 来给非终端定义多种声明方式如下
<statement> ::= "return" <int> ";" | "int" <id> "=" <int> ";"
这样我们的statement就可以匹配声明赋值语句了,之后我们写些更复杂的statement语句也可以生成对应的AST节点了。
接下来我们要将token数组转化为AST, 我们将采用一种叫【递归下降分析 recursive descent parsing】的算法去分析AST.
讲解
怎么将token转化为AST的算法很多,也是分析器parser最难的知识之一,网上有很多算法比如LL(k)分析器之类的,这些都是用来高效构建AST用的算法。
叫【递归下降分析 recursive descent parsing】 本质上就是一个后序遍历递归算法。
我们会为每一个生成规则 production rule 顶一个函数,这些函数用 【non-terminal 非终端名称】来命名,这些函数都会返回一个AST节点。 这个函数会不停 从token 数组的头部 提取 token , 并将其与 生成规则进行 比对以及组合构建,直到 完成 整个 生成规则的推导过程。
如果生成规则 production rule 包含了其他的非终端变量,那么它则会调用响应的非终端解析函数返回对应的AST节点。
下面我们来看看整个过程的伪代码如下:
def parse_statement(tokens):
tok = tokens.next()
if tok.type != "RETURN_KEYWORD":
fail()
tok = tokens.next()
if tok.type != "INT"
fail()
exp = parse_exp(tokens) //parse_exp will pop off more tokens
statement = Return(exp)
tok = tokens.next()
if tok.type != "SEMICOLON":
fail()
return statement
我们可以看到,生成规则是可以递归调用的(正如,我们数学表达式可以由多个不同表达组合而成一样)。因此我们称之为 递归下降式语法分析 recursive descent parsing。
课后作业
我们要写一个parse函数,接受 token 数组参数 返回AST,AST树的根节点是 Program节点。要求对于example 1 能够返回正确的AST, 对于example 2抛出错误信息。
如果可以的话, 当系统输入大于 INT_MAX最大数值, 则优雅的抛出 溢出异常信息。
AST有很多种表示形式(可以用map,也可以用自定义类),根据你所用的编程语言来决定具体的实现即可。
比如用OCaml语言的话,AST长这个样子:
type exp = Const(int)
type statement = Return(exp)
type fun_decl = Fun(string, statement)
type prog = Prog(fun_decl)
Code Generation 代码生成阶段
接下来我们将把AST生成汇编码,就像之前例子,我们将生成 4行汇编码。接下来我们将大致按照程序的执行顺序遍历AST,具体如下:
- 函数名
- 返回值
- 参数
(这个顺序不是固定的,怎么方便怎么来)
我采用后序遍历AST。
举个例子:后序遍历的话,对于return 语句,我们会先访问表达式,再组合成结果。如果是对于算术语句,我们会先找到参与运算的常量,再组合成算术指令。
讲解
我觉得作者例子不是很生动,多叉树的后序遍历网上大把,而且这个是数据结构基础知识,大家有必要自己学习一下【树的后序遍历】
下面我们来看看生成汇编每个步骤做什么:
- 生成函数声明(假如函数名是 foo)
.globl _foo
_foo:
<FUNCTION BODY GOES HERE>
- 生成return 语句 (假如,return 3;)
movl $3, %eax
ret
讲解:
看起来不难,其实就是将结构树按照后续遍历的顺序输出。把各种操作符转化成汇编指令的过程。
课后作业
写一个汇编生成函数,把AST转化为汇编码。
把结果按照String或File的方式直接返回即可。
可选任务 Pretty printing
我们知道AST是一个树形数据结构,为了能够在debug的时候方便我们查看AST的结构信息,你可以写一个toString()函数去打印AST信息。
像我的话,是这样打印AST的,你可以按照你的喜欢去设计这个输出格式。
FUN INT main:
params: ()
body:
RETURN Int<2>
把lexer、parser、code generation 打包成一个完整的程序
我们要求输入一个c语言编写的源代码文件,然后输出一个可执行文件。那么我们的程序具体要做的事情如下:
- 读取文件
- 词法分析Lex
- 语法分析Parse
- 生成汇编码Assembly
- 将汇编码写入.s文件
- 调用gcc生成可执行文件
gcc -m32 assembly.s -o out.exe
-m32 表示输出32位可执行文件binanry数据
-o out.exe 表示输出的可执行文件名
assembly.s就是我们的汇编码全路径文件名了
- 删除汇编文件 assembly.s 。
测试
测试用到的脚本都在这个git下面
https://github.com/nlsandler/write_a_c_compiler
具体执行命令大概这样
./test_compiler.sh /path/to/your/compiler
github 的readme.md有介绍怎么测,这里不细讲了。
总结
这里我们只是实现了对return常量的程序的编译程序。
后面支持加减乘除等功能在作者的下一篇文章里。
https://norasandler.com/2017/12/05/Write-a-Compiler-2.html
到这里我们大概知道编译的工作全过程,从词法分析到汇编码的生成过程。以及具体的实现细节,之后那些复杂的实现,就根据自己的兴趣学习研究即可,编译器从无到有的设计方法可以说是掌握了,这也是这边文章最大的意义所在。
网上也有很多有趣的编译器,比如如何用文言文编程之类的。如果感兴趣,我们也可以进一步研究。个人觉得本文核心知识点是lex + parse的编译器架构,EBNF语法表示法 以及 AST的生成算法,可以作为后面的学习方向。