在锈迹斑斑的情况下编写过多的Brainfuck编译器来学习汇编语言

2020-11-13 00:36:08

嘿,你!你有没有想过成为一名CPU语者?我也是!。我是一名前台网络开发人员,但低级汇编代码和编译器一直让我着迷。我在学习这两个方面都拖延了很长一段时间,但在我最近染上了铁锈,并在很多在线铁锈社区闲逛后,它给了我很大的动力,让我潜入其中。Rustaceans使用奇特的词汇和缩略语,比如自动向量化、内联、对齐、填充、链接、自定义分配器、字符顺序、系统调用、LLVM、SIMD、ABI、TLS和我不能跟上讨论的内容,因为我不知道这些东西是什么。我所知道的就是它在某种程度上与低级汇编代码有些模糊的联系,所以我决定通过在Rust中编写太多的Brainfuck编译器来学习汇编语言。多少才算太多?四!。我的编译目标是x86、ARM、WebAssembly和LLVM。

本文的目标是让任何有一定编程经验的人都能轻松理解,即使他们以前从未写过一条汇编线。

那么为什么选择x86呢?X86不仅仅是ISA,它也是ISA。大多数服务器、台式PC、笔记本电脑和家用游戏机都使用x86 CPU。

为什么是ARM?ARM不仅仅是一个ISA,它也是另一个ISA。大多数手机、平板电脑、移动游戏机和微控制器都使用ARM CPU。此外,苹果还宣布,他们将在2021年将所有笔记本电脑和台式机从x86换成ARM处理器,这似乎是一件很大的事情。

为什么是WebAssembly?WebAssembly有可能成为Web的未来,也是整个容器应用程序的未来!Docker的创始人所罗门·海克斯(Solomon Hykes)在推特上写道,如果WASM+WASI在2008年就存在了,我们就不需要创建Docker了。这就是它的重要性。服务器上的WebAssembly是计算的未来。标准化的系统界面是缺失的一环。让我们希望瓦西能胜任这项任务!

为什么选择LLVM?LLVM,因为它可以编译为x86、ARM或WebAssembly。还因为许多现代的、成功的编程语言,如Rust和Swift,都编译成LLVM,而不是直接编译成汇编语言。

由于以上所有目标都有很多名字,下面是其别名的快速列表:

如果你想亲自尝试一下这篇文章中的代码,那么你就幸运了!本文附带了一个配套的代码库,其中包含有关如何运行它的所有代码和说明。接下来使用配套的代码库是完全可选的,不使用它也可以很容易地阅读文章。

矛盾的是,Brainfuck是最广为人知的深奥编程语言。它的名声很大程度上是因为它的名字中有他妈的这个词,但业余的编译器开发者喜欢它,因为它是一种很小的语言,使得编写编译器变得很容易。有趣的事实:人们编写的愚蠢的编译器比实际的愚蠢的程序还多。有趣的事实:我没有为之前的有趣事实做过任何研究,但这可能是真的。

Brainfuck程序有一个隐式指针,即指针,可以自由移动30K无符号字节数组,所有这些字节最初都设置为0。

让我们先写一个头脑简单的翻译器。我们将把Brainfuck程序解析为VEC<;Inst&>,其中Inst定义为:

每个Inst的第一个USIZE是它的游程编码。LoopStart和LoopEnd的第二个USIZE是Vec<;Inst&>内匹配的LoopEnd或LoopStart之后的指令索引。跟踪这些小小的附加信息将使我们能够实现更高效的解释器,并从我们的编译器生成更高效的汇编。

我们将跳过剩余的脑洞翻译器代码,因为它非常乏味。让我们进入有趣的部分,试着解读一些愚蠢的节目吧!

如果您正在使用配套的代码库,我们将用来解释Brainfuck程序的命令是Just Interprete{{name}},其中{{name}}是./input目录中Brainfuck源文件的名称。

#prints&34;Hello world!";>;只需解释Hello_world Hello World!#打印100>;以下斐波纳奇数;只需解释fibonacci1、1、2、3、5、8、13、21、34、55、89#使用rot13密码加密来自stdin的行&>只需解释rot13未加密的textharapelcgrq grkg。

更好的第一个问题是什么是ISA?ISA代表指令集体系结构。ISA是CPU可以实现的接口。目前最流行的ISA是x86_64和aarch64。如果我们使用x86_64指令编写代码,那么任何实现x86_64 ISA的CPU都将能够运行该代码。那么,装配和ISA是一回事吗?嗯,不完全是。简而言之,汇编是汇编器理解的任何语法。汇编器是一种实用程序,它允许人们以更友好的方式编写机器代码,比如使用注释、空格和机器指令的符号名称。因此,汇编是汇编者提供的ISA之上的一层薄薄的抽象层。我们将用来汇编所有x86_64和aarch64程序的汇编程序将是GNU汇编程序,通常缩写为GAS。我们将使用英特尔语法,而不是x86_64汇编的默认AT&;T语法,因为它更接近于aarch64汇编的ARM语法,这使得在两者之间切换不那么不协调。如果最后一句话对你没有任何意义,不要担心你有很好的同伴。此外,我们将在Linux环境中执行所有编译后的二进制文件,因此必要时我们将在汇编程序中直接调用Linux系统。

X86_64是基于寄存器的ISA。寄存器是一个容器,我们可以在其中存储数据。我们也可以在RAM中存储数据,但是RAM离CPU非常远,而寄存器直接在CPU中,是CPU完成所有实际工作的地方。X86_64中的所有指令都以某种方式直接或间接地在寄存器上操作。有许多不同类型的寄存器:一些存储整数,一些存储浮点数,一些存储整数向量,一些是通用的,有些是专用的,有些我们可以直接修改,而另一些我们只能间接修改(作为某些指令的副产品)。就本文而言,我们将使用的唯一寄存器是RAX、RDI、RSI、RDX和R12,它们都存储64位整数。

MOV将某些内容从<;src&>;移动到<;目标&>,其中<;src&>可以是文字值、寄存器或内存地址,而<;目标&>可以是寄存器或内存地址。

Mov rax,5#在rax mov rsi中存储5,rdi#将RDI中的值复制到rsi mov[r12],15#在r12中的内存地址存储15。

最后一条指令实际上是非法的,因为它是模棱两可的。在前两个例子中,很明显我们使用的是64位整数,因为我们使用64位寄存器作为操作数,然而在最后一个例子中,我们试图将值15存储在R12的内存地址中,但是值有多大呢?它占用1、2、4或8个字节吗?毕竟,我们需要知道要向内存写入多少字节。我们可以通过在歧义指令后面加上b(字节)、w(字,2字节)、l(长字,4字节)或q(四字,8字节)来清除歧义。因此,我们可以通过以下任意几种方式修复最后一条指令:

Movb[r12],15#将15作为1个字节写入r12 movw[r12]中的存储器地址,15#将15作为2个字节写入r12 mov1[r12]中的存储器地址,15#将15作为4个字节写入r12 movq[r12]中的存储器地址,15#将15作为8个字节写入R12中的存储器地址。

此外,尽管这一点可能已经很明显了,但如果我们想要取消引用存储在寄存器或标签中的内存地址,我们可以用方括号[]将其括起来。

Mov rax,r12#将值从r12复制到rax mov rax,[r12]#将值从存储在r12中的内存地址复制到rax。

添加<;DEST&>,<;src&>;#DEST<;-DEST+src子<;DEST&>,<;src&>#DEST<;-DEST-src

JMP<;Label&>;#无条件跳转到<;Label&>#下面的所有指令都是有条件的跳转,如果等于,则跳转到<;Label;#如果大于Jg;,则跳到<;Label&>;#如果不等于,则跳转到<;Label&>;#如果大于Jg;,则跳转到<;Label&>;#如果不等于Jg;,则跳转到<;Label;#如果大于Jg;,则跳转到<;Label&>;#如果不等于,则跳转到<;Label&>;#如果大于Jg;,则跳到<;Label&>。LABEL&>;#如果小于,则跳至<;LABEL&>;#如果小于或等于,则跳至<;LABEL&>;

Mov rax,5#在rax中存储5 mov r12,10#在r12中存储10添加rax,r12#rax<;-rax+r12=15 cmp rax,r12#在r标志中设置标志jge rax_is_sierger#读标志,跳转到rax_is_ger r12_is_ger:#一些指令JMP end rax_is_igger:#一些其他指令结束:#更多指令。

关于如何在函数调用之前、期间和之后为调用方和被调用方使用寄存器的规则称为调用约定。所谓惯例的问题在于,似乎每个人和他们的祖母都有一个。编译成汇编语言的ISA、操作系统和编程语言可以各自定义自己不同的调用约定。幸运的是,我们不可能用Brainfuck定义函数,所以在本文中我们不必深入任何调用约定的细节。

要进行系统调用,我们在设置了RX中的系统调用号以及RDI、RSI和RDX寄存器中的系统调用参数之后,使用syscall指令。

#直接Linux系统调用mov rax,60#系统调用退出(Code)mov RDI,0#退出代码,0成功syscall#进行系统调用mov rax,0#系统调用读取(fd,buf_adr,buf_len)mov RDI,0#stdin mov RSI文件描述符,1234#指向某个缓冲区mov RDX的内存地址,1#Buffer';S长度(字节)syscall#make system call#syscall返回rax mov rax中读取的字节数,1#用于写入的syscall number(fd,buf_adr,buf_len)mov RDI,1#stdout mov RSI的文件描述符,1234#某个缓冲区mov RDX的内存地址,1#Buffer的字节长度syscall#make system call#syscall返回写入rav RDX的字节数。

我们现在知道一些x86_64指令,实际上足以编写一个脑洞大开的编译器,但我们还没有把一个完整的程序组合在一起。这就是汇编器的用武之地。如上所述,我们将对所有x86_64代码使用GNU汇编器。让我们来看看一个刚刚退出的简单x86_64程序。

#./Examples/x86_64/exit.s#GNU汇编程序,英特尔语法,x86_64 Linux.Data.equsys_exit,60.equexit_code,0.text.global_start:#exit(Code)mov rax,sys_exit mov RDI,exit_code syscall。

以点为前缀的单词。称为汇编器指令,它们指导汇编器如何汇编我们的程序集。

以冒号:为后缀的单词是标签,它们可以指向数据或指令。_start标签指向我们程序的第一条指令。

.global<;Label>;意味着使链接器可见。链接器是一个将汇编器的汇编输出转换为实际可执行程序的程序,它需要知道我们的程序从哪里开始,因此有_start标签。

为了让我们的程序更令人兴奋,让我们从标准输入中读取一个字符,如果它是小写的,我们会将其设置为大写,如果它是大写的,我们会将其设置为小写,然后我们会将切换后的大小写字符写入标准输出。

#./Examples/x86_64/Switch_Case.s#GNU汇编程序,英特尔语法,x86_64 Linux.Data#exit(Code).equsys_exit,60.equexit_code,0#WRITE(FD,buf_adr,buf_len).equsys_WRITE,1.equSTDOUT,1#read(fd,buf_adr,buf_len).equsys_read,0.equSTDIN,.eQu ASCII_A,97#快速ASCII刷新器:#65-91=';A';-';Z';#97-123=';A';-';Z';#例如#';A';+32=';A';A';-32=';A';.eque case_diff,32#内存中的单字节字符:.byte 0.text.global_start_start:#read(STDIN,CHAR,1)mov rax,SYS_read mov RDI,STDIN mov RSI,Offset Char mov RDX,1 syscall CMPB[char],ASCII_A#如果char处的字节为小写JGE make_upercase#,则将其设置为大写Make_Low ercase。Case_diff#字符写入时的大写字节:#WRITE BYTE to stdout#WRITE(STDOUT,CHAR,1)mov rax,SYS_WRITE mov RDI,STDOUT mov RSI,Offset Char mov RDX,1 syscall#exit(Exit_Code)mov rax,syscall_exit mov RDI,exit_code syscall。

.byte允许我们通过编写逗号分隔的整数文字列表来定义字节数组。在上面的程序中,我们只需要1个字节。

默认情况下,gas取消引用标签,因此mov rsi,char会将值0复制到rsi中。然而,我们不想复制CHAR的值,但是我们想复制CHAR本身的文字值,即它的内存地址。我们可以使用OFFSET关键字来实现这一点,这是我们在mov RSI,Offset Char中所做的。

如果您正在使用配套的代码库,我们将用来编译和运行x86_64示例程序的命令只有carx{{name}},其中{{name}}是./Examples/x86_64目录中的x86_64源文件的名称。

#从stdin读取字符,切换大小写,打印到stdout&>只有carx switch_CaseaAExit代码:0>;只有carx switch_CaseAaExit代码:0

好的,首先,我们需要一个30K字节的零初始化数组。根据我们在上一节中学到的内容,我们可以使用我们的编译器生成以下代码:

然而,即使对于一个由编译器生成的解决方案,它看起来也相当愚蠢。幸运的是,对于我们来说,有一种更简单的方式来定义大量零初始化数据:

.bss类似于.data,因为我们在它下面定义数据项,但主要的区别是我们不初始化数据项,我们只声明它们的大小,并且它们会自动为我们零初始化。(=。.lcomm数组,30000表示,";使符号数组指向30K字节的零初始化数组。";

我们必须做出的最后一个小决定是,我们将使用哪个寄存器来存储数组指针。有很多可供选择的,但让我们使用R12,因为它是一个通用的被调用者保存的寄存器,这意味着如果我们进行任何函数或系统调用,我们保证这些调用不会覆盖R12。

现在,我们可以为任何编译的Brainfuck程序生成页眉和页脚模板:

#标题样板#GNU汇编程序,英特尔语法,x86_64 Linux.Data.equsys_exit,60.equSuccess,9.equSYS_WRITE,1.equSTDOUT,1.equsys_read,0.equSTDIN,0.bss.lcomm数组,30000.text.global_start_start:MOV r12,Offset ARRAY###实际编译的Brainfuck程序位于此处###页脚样板#mov rax,sys_exit mov rdi,成功系统调用。

现在,让将Brainfuck命令映射到x86_64指令。我们还应该考虑如何将多个重复命令合并为单条指令。

#递增数组指针#>;add r12,1#>;>;add r12,2#递减数组指针#<;subR12,1#;<;subR12,2#递增指针#+addb[r12],1#++addb[r12],2#递减字节#-subb[r12],1#-subb[r12],2#从stdin&读取字节。存储在指针#、mov rax、sys_read mov RDI、STDIN mov RSI、r12 mov RDX、1 syscall#、、mov rax、sys_read mov RDI、stdin mov RSI、r12 mov RDX、1 syscall mov rax、sys_read mov RDI、STDIN mov RSI、r12 mov RDX、1 syscall#在指向标准输出的指针处写入字节。MOV RAX、SYS_WRITE MOV RDI、STDOUT MOV RSI、R12 MOV RDX、1系统调用#.。MOV RAX、SYS_WRITE MOV RDI、STDOUT mov RSI、R12 mov RDX、1 syscall mov rax、SYS_WRITE mov RDI、STDOUT mov RSI、R12 mov RDX、1 syscall

不幸的是,没有简单的方法来合并多个,或者。命令生成的指令比执行单个或更少的指令要少。因为我们为系统调用设置的寄存器可能会被系统调用过程覆盖,所以我们必须在每次调用之前重新设置寄存器。

为匹配循环生成匹配标签是我们在解析器中已经解决的问题。为了检查最简单的情况,我们的解析器将像这样解析下面的Brainfuck程序[-]:

[LoopStart(1,3),//index 0,Goto 3 DecByte(1)//index 1 LoopEnd(1,1),//index 2,Goto 1]。

在我们的VEC<;Inst>;中使用LoopStart(n,goto)及其索引,我们可以生成以下标签:

在我们的Vec<;Inst>;中使用LoopEnd(n,goto)及其索引,我们可以生成以下标签:

多个堆叠循环很有趣,因为它与单个循环没有什么不同。如果当前字节为零,则不管我们有多少个字节[我们连续有多少个字节,因为它会跳过所有字节到达最外层的匹配项]。此外,如果当前字节不是零,那么它与我们连续有多少个字节无关,因为它会跳回到最里面的匹配。我们的解析器处理确定要进行哪些跳转的繁重工作,因此无论源代码中有多少堆叠循环,我们的标签生成方案都保持不变。

如果您正在使用配套的代码库,我们将用来将Brainfuck程序编译成x86_64并运行它们的命令只有carx{{name}},其中{{name}}是./input目录中Brainfuck源文件的名称。

#print&34;Hello world!";>;Just Carx Hello_WorldHello World!#打印小于100的斐波纳奇数1,1,2,3,5,8,13,21,34,55,89#使用rot13密码加密stdin中的行;只打印Carxrot13未加密的textharapelcgrq grkg

一切都按预期进行。我很好奇编译后的程序比解释器快多少,所以我会运行一个非常不科学和非正式的基准测试,方法是计算解释最耗费CPU的Brainfuck程序需要多长时间。/input/mandelbrot.b对比x86_64编译版本执行需要多长时间。

>;Just Benchmark mandelbrot#程序输出被省略#解释的mandelbrot.b4.95s用户0.01s系统99%cpu 4.960总计#x86_64编译的mandelbrot.breal 0m1.214s用户0m1.149ssys 0m0.041s。

哇,还不错!我们的编译版本运行速度是解释版本的4倍以上。在我看来,这是一个令人印象深刻的改进,考虑到脑筋急转弯的程序是多么简单,我们只是在单个数组中移动一个指针,并增加或减少一些字节。

与x86_64一样,aarch64也是基于寄存器的ISA。对于我们的示例和我们的编译器x0、x1、x2、x8、x19和x20,我们将使用以下64位寄存器。

MOV将某些内容从<;src&>;移动到<;est&>;,其中<;src&>可以是文字值或寄存器,<;目标&>可以是寄存器。您可能已经注意到,与x86_64版本的mov不同,aarch64版本的mov不能直接在内存上操作。这不仅适用于mov指令,而且通常适用于所有aarch64指令。唯一可以在内存上操作的aarch64指令是ldr和str,其中ldr将数据从内存加载到寄存器,而str将数据从寄存器存储到内存。例如,下面这条x86_64指令:

Addq[r12],100//将存储在r12中内存地址的8字节整数加100。

LDR x20,[x19]//加载存储在。

.