Mugo是一个可以编译自己的去的玩具编译器

2021-04-12 19:01:24

摘要:本文介绍了Mugo,一个用于Go编程语言的微小子集的单通证编译器。它输出(非常天真)x86-64装配,并支持足够的语言来实现Mugo编译器:int和字符串类型,片段,函数,当地人,全局和基本表达式和语句。

自从我开始编码以来,我对编制者感到着迷。我的第一个编程项目之一是“第三”,是8086 DOS的自托管的编译器。索引令人难以置信的易于编译:没有表达式或陈述,并且每个空间分隔的令牌都会直接编译给呼叫指令 - 通常通过直接线程等技术。

像C的典型语言并与表达式和语句一起进行更复杂的语法,因此它们需要真实的解析器和代码生成器。这些语言的编译器通常很复杂,强大,但我们认为,如果您坚持基本类型和非优化的输出,则仍然可以编写一个简单的类型。

Mugo是Fabrice Bellard的混淆微小C编译器的精神,虽然我当然是我的更多行人,并且不会很快赢得IoCcc。 Bellard的编译器仅实现足够的C来编译到Native I386 Linux可执行文件。

我想用Go做一些这样的事情,减去混淆。这个想法始于淋浴思想:“我想知道可以编译自己的最小的最小的子集吗?” Fabrice的C编译器是在2048字节的混淆C中实施的,而我的是1600行格式化。

虽然这是一个漫长的周末做有趣的练习,但它是一个玩具 - 它留下了Go的所有伟大功能:用户定义的类型,接口,Goroutines,频道,地图,垃圾收集,甚至绑定检查!我与Mugo的目标是教育:对我来说,希望也为你。这样做的练习有助于揭开我们的工具如何工作。

Mugo是一部分Go,所以源代码可以与Mugo一起编译。在我看来,这使得它更有趣。它还使测试更容易:当Go-Compliced版本的装配输出与Mugo编译版的输出相同时,我知道它正在工作 - 当Diff Mugo2.asm Mugo3.asm显示时,这是一件美丽的事情没有输出!

在我开始之前,我仔细考虑了我应该包括哪个功能子集。我知道我需要某种容器类型来存储编译器状态:例如,变量的名称和类型,以及函数签名和返回类型。但哪个容器?

Go有指针,它们更安全但并不像C一样强大,因为你不能做指针算术。 Bellard的编译器大量使用C指针,但那些不会进入的人。

关于结构或地图怎么样?嗯,实施那些更加复杂,并没有真正解决存储物品清单的最常见问题。所以我决定没有所有这些都可以做,只是需要切片。

int类型,十进制整数文字,字符常量和在int上运行的大多数表达式:+, - ,*,/,%,==,!=,<,<,>,和> =,使用操作员优先权处理。编译器识别Bool类型名称Bool,但与INT(&&,||以及!在这些伪bool上运行)。

字符串类型,包括带有\ eScapes的字符串常量,使用==或!=,与+,len()的字符串连接。

切片,但只有[] int和[]字符串。不支持切片文字并制作(),因此构建一个切片,您必须创建一个空的切片并附上它。支持删除和分配切片项,如片[:n]表达式和len()。

类型检查存在但不完整。我在它所作的地方检查类型或者它帮助调试的地方,但它肯定没有详尽无遗。

陈述:如果和否则,条件{...},返回和go's:=短变量声明。

变量和常数。但是,var和const仅支持顶层;您必须使用:=对于当地人(无论如何更常见)。仅支持键入的整数常量。

顶级功能,包括递归。但是,不支持函数值和匿名功能。函数只能具有单个返回值,并不包含variadic函数。

I / O,使用三个预定义函数:GetC从STDIN,打印和日志写字符串读取单个字符,向STDOUT和STDERR读取。

去语法,但是削减到这里需要什么。例如,不支持许多构造,例如++和 - 范围循环以及许多其他构造。支持//的单行注释。

那就是关于它的!如果我离开了上面的列表,那可能不包括在内。就像我说,一个小的子集。

我在建立这个时咨询了漂亮而简洁的Go语言规格,尽管我几乎肯定有一些错误。然而,实施的是似乎工作就像我的“差异测试”所示。

Mugo是一个单通式编译器,它会在Parser中输出x86-64汇编。 (它是为Linux编写的,但在麦斯卡斯或Windows上工作并不难。)没有内存抽象语法树 - 构建只有在任何情况下只有切片就会棘手。

它也很天真。没有优化 - 我基本上将基于强大的寄存器的CPU转换为哑堆栈机器,然后按堆栈和从堆栈中的中间值推出和弹出中间值。可能是真实编译器的复杂性的一半是代码生成 - 另一半在类型的类型中 - 在Mugo中,这两种东西都非常简化。

我必须播放的一个技巧是针对本地变量声明(Go's:=语法)。因为只有一个通行证,你不知道你有多少本地人或者他们的类型是什么,直到你完成了解析函数。因此,除了通常的RBP帧指针舞,除了常规的RBP帧指针舞蹈中,我的功能序幕将从堆栈指针中减去64个字节,以允许最多8个局部变量的空间(Mugo中最使用的是7个单元格)。

; func添加(x int,y int)int {;返回x + y; };功能prologueadd:推rbp; RBP是Flass PointerMov RBP,RSPSUB RSP,64;为任何当地人提供空间; (此功能不使用);获取和推送局部变量x,然后ypush qword [rbp + 24]按键[RBP + 16]; +操作rbxpop raxadd rax,rbxpush rax;弹出结果返回到rax"返回"流行rax;功能外表(恢复堆栈和帧指针)MOV RSP,RBPOP RBPRET 16;退货和自由空间;呼叫者推动x和y

按RBPMOV RBP,RSPMOV QWORD [RBP-8],RDIMOV QWORD [RBP-16],rsimov RDX,QWORD [RBP-8] MOV RAX,QWORD [RBP-16]添加RAX,RDXPOP RBPRET

在调用者方面,要生成呼叫添加,Mugo会产生以下代码:

;添加(1,2)按键QWORD 1;推第一个argpush qword 2;推第二argcall添加;调用functionpush rax;将返回值推回堆栈

正如您所看到的,Mugo使用它自己非常低效的ABI,这与x86-64 abi相同,标准abi将前六个“单元格”(64位值)放入寄存器中。

为简单起见,Mugo的ABI将参数推到堆栈上。它们按顺序推动,因此它们以相反的顺序在内存中最终结束。但是,Mugo确实使用寄存器返回值:rax,然后是RBX和RCX如果有更多的单元格。就像Go一样,int是一个小区,字符串是两个(地址和长度),切片是三个(地址,长度和容量)。

为了为字符串连接和切片附加分配内存,Mugo使用琐碎的“凹凸分配器”。换句话说,它沿固定的1MB块的内存中的指针凸起,并且如果将其耗尽,则退出内存up。它永远不会释放内存,没有垃圾收集器。非常适合短跑课程!

func genfuncstart(名称字符串){print(" \ n")打印(名称+&#34 ;: \ n")打印("推rbp \ n" )打印(" mov rbp,rsp \ n")打印("子rsp," + itoa(localspace)+" \ n")//当地人的空间}

一旦Mugo运行,我使用NASM组装输出,以及LD链接器来构建可执行文件。以下是Makefile的示例,显示了我如何构建三个版本的编译器:

#使用gomugo构建编译器:go build -o build / mugo#用go-bugomugo2构建编译器:build / mugo< mugo.go> build / mugo2.asm nasm -felf64 -o build / mugo2.o构建/ mugo2.asm ld -o build / mugo2 build / mugo2.o#构建与mugo-count的mugomugo3:build / mugo2< mugo.go> build / mugo3.asm nasm -felf 64 -o build / mugo3 .o build / mugo3.asm ld -o build / mugo3 build / mugo3.o diff build / mugo2.asm build / mugo3.asm#确保输出匹配!

还有一个从一个简单的测试中生成覆盖报告,它本身就可以生成一个覆盖率报告:

coverage:go test -c -o build / mugo_test -cover构建/ mugo_test -test.coverprofile构建/ coverage.out \< mugo.go> / dev / null go工具封面-html构建/ coverage.out -o build /coverage.html.

我最初包括编译器未使用的几个功能,因此他们没有测试,并在覆盖报告中显示为红色。除了几件事之外,它似乎是一致的,就像!不是运算符和字符串切片分配,我删除了未使用的功能。现在我完全覆盖了特征,不包括错误处理。

一两次我不得不违反GDB进行调试。我的x86汇编技巧绝对生锈,我从来没有写过严肃的64位装配。我相信也有很多方法可以改善输出,即使是单通机约束,我也将这些改进作为读者的练习。 :-)

Go有很好的,简单的语法,很容易令授权和解析。 Mugo在其Lexer中使用了一个看法的一个字符,以及典型的递归血管下降解析器。

Lexer基本上是关于下一个字符上的IF语句的一组大集,它存储在全局整数C中。这是它看起来的片段:

func next(){//跳过空白和评论,并查找/ operator for c ==' /' || C ==' ' || c ==' \ t' || c ==' \ r' || c ==' \ n' {如果c ==' /' {nextchar()如果c!=' /' {token = tdivide return} nextchar()//评论,跳过C&gt的行结束; = 0&& C!=' \ n' {nextchar()}}如果c ==' \ n' {nextchar()//分号插入:golang.org/ref/spec#semicolons如果令牌== tident ||令牌== tintlit || token == tstrlit || token == treturn ||令牌== trparen || token == trbracket ||令牌== trbrace {token = tsemicolon return}}否则{nextchar()}}如果c

在解析器中,我试图在Go规范中使用来自语法的Produmons的名称,例如表达式,varspec和操作数。当然,他们中的许多人从Go Spec中的内容缩小,所以我们正在处理语言的子集。以下是解析功能的一些示例 - 请注意对代码生成功能的调用是如何混合的:

func literal()int {如果令牌== tintlit {genIntlit(tokenint)next()返回typeint}如果令牌== tstrlit {genstrlit(tokenstr)next()返回typestring} else {错误("预期整数字符串文字")返回0}} func simpleestmt(){// funky解析此处以处理分配识别名称:= tokenstr期望(tident,"赋值或呼叫语句")如果令牌== tassign {next ()lhstype:= vartype(identname)Rhstype:=表达式()如果lhstype!= rhstype {错误(" can' t分配" + typename(rhstype)+" to" 34; +类型名称(LHStype))} GenAssign(IdenName)}否则如果令牌== tdeclassign {next()键入:=表达式()dependelocal(indername)genassign(identname)}如果令牌== tlparen {geniderifer( identName)典型:=参数()gendiscard(典型值)//丢弃返回值}如果令牌== tlbracket {nex T()IndexExpr()期望(Trbracket,"]" )期望(tassign," =")表达式()gensliceassign(identname)} else {错误("预期的分配或呼叫没有#34; + tokenname(令牌))} func语句(){如果token == tif {ifstmt()}如果令牌== tfor {forstmt()}否则如果令牌== treturn {returnstmt()} else {simpleestmt()}}

其中一个略微凌乱的事情是上面的“简单陈述” - 对于生成语法树的解析器,您可能会调用表达式来解析左侧,然后查看是否有= or:=赋值,并解析右侧。但我们无法调用表达式,或者将编译代码来获取该表达式而不是分配给它。所以我们必须解析一个标识符,然后检测下一个接下来的是分配,函数调用或切片表达式。我相信上面的代码无法正确处理所有边缘案例,但它足够好。

操作员优先级使用递归血统处理,如&&和||以下操作员(Orexpr和Andexpr名称不在Go规范中出现):

func andexpr()int {典型:= comparisoonexpr()for token == tand {op:= op {典型:= andexpr()for for token == tor {op:=令牌next()typright:= andexpr()typ = genbinary(op,in,typlight)} return youp} func表达式()int {return orexpr() }

递归下降解析器中有两个递归前进引用:表达式(表达式解析中的各种函数必须调用表达式)和块(块元素最终嵌套入块)。 Go不需要或允许前进引用,因此Mugo在启动时使用正确的签名定义这两个功能。

编译器会跟踪一组全局切片中的变量名称和类型信息:

var(globals []字符串//全局名称和类型globaltypes [] int locals []字符串//本地名称和类型localtypes [] int funcs [] string //函数名称funcsigindexes [] int // indexes in in funcsigs funcsigs [] int //每个func:rettype n arg1type ... argntype)

前四个是非常不言自明的,但是Funcsigs Slice有点,很好。这真的是一片结构。在Real Go Code中,您可能会将Funcsig结构定义并将所有三个“Func”切片定义为函数名称的地图:

但Mugo不支持结构或地图,因此我必须将这些字段填充到扁平的int中,有funcsigindexes [i]指向funcsigs切片中的伪结构的开始(用于索引i的函数) 。

因为它没有优化,Mugo显然比去慢得多,所以我不会做广大的性能测试。但是只是为了好玩,我写了一点程序来测试一个基本循环的性能与一些整数算法 - 它会将数字与一到十亿的数量总和:

var(结果int)func main(){sum:= 0 i:= 1,用于i< = 1000000000 {sum = sum + ii = i + 1}结果= sum //所以Go' t优化它}

在我的机器上,此过程的Go版本在0.34秒内运行。 Mugo版本在5.7秒内运行 - 大约17次。我想这对有史以来的一些最差的装配代码来说并不差。参考,此相同循环的Python版本在1分和38秒内运行...动态类型的字节码解释器对重整数算法来说不是一个不错的选择。

有趣的是,如果我使Sum本身成为全局变量而不是本地,则Mugo版本不变,但Go版本在1.7秒内而不是0.34运行。我怀疑Mugo是如此慢的原因,这是在堆栈上的内存中做的一切 - 即使堆栈在CPU缓存中,寄存器始终会更快。

性能的另一个方面是代码大小:可执行文件Mugo Builds远小于使用Go构建的构建。使用Go的Mugo二进制文件是1.6MB。 Mugo建造的Mugo仅为56KB - 约1/29尺寸!这不是一个公平的战斗 - Mugo不在Goroutine调度程序,垃圾收集器或运行时类型信息(参见此GO FAQ问题)。但是,这确实提出了一个有趣的问题:对于简单的CLI工具,我想知道我们是否可以通过死亡简单的调度程序和GC减少二进制大小?

正如我所提到的,我一直对口译员和编译器感兴趣。 如果您喜欢这篇文章,这里有一些相关项目: 第三:我在几年前写的8086个DOS的第四个编译器。 另请参见Richard Jones'Jonesforth.s有关如何构建第四个编译器的教程。 loxlox:用于制作口译员的解释器,用于在Lox本身中写入的解释员的LOX编程语言(您是否开始注意到主题?)。 ZZT在Go:描述了Pascal-to-go转换器,我写信给Adrian Siekierka重建ZZT的Go港口。 我希望你喜欢这篇文章 - 我肯定很有趣地创造了mugo。 随意发送给我反馈!