TL;博士:我为WebAssembly做了一个预算帕斯卡编译器,这样我就可以玩我和朋友10年前做的一个刽子手游戏。查看演示和github存储库。
大约一个月前,我在整理笔记本电脑中的旧文件时,发现了一些有趣的东西。这是一款基于控制台的刽子手游戏,我和我的朋友在Pascal制作,作为2011年编程入门课程1的最后一个项目。我刚刚读完罗伯特·尼斯特罗姆(Robert Nystrom)的《手工艺解释器》(Crafting Translators),所以我觉得转向编译器并尝试将《刽子手》游戏编译成WebAssembly会很有趣。以下是我在开发过程中学到的许多有趣的东西。
制作一个完整的Pascal编译器是一项非常耗时的任务。我希望这个项目足够小,可以在4-6周内完成,所以我决定只支持Pascal特性和语言结构的子集(因此称为“预算”)。我根据三个原则选择了要实现的功能:
编译器应该能够在不改变游戏源代码的情况下编译刽子手游戏。这意味着我需要处理通常无法处理的事情,比如文件、输出格式、pos、CLRSC、readkey等标准库方法。
编译器应该能够处理给定所选特性的“自然”存在的事物。例如,虽然游戏源代码没有任何递归调用或使用任何浮点数类型,但我认为不实现它们会很奇怪。然而,动态长度数组、动态内存分配、指针和完全实现的集合类型等都超出了需求。这个理由很武断,但我还是决定了。
编译器应该编译Pascal的严格子集。这意味着,虽然它不能编译某些Pascal程序,但它可以编译的所有程序都应该可以由其他完整功能的Pascal编译器(如FreePascal)编译。不应该有任何程序对此编译器有效,但对其他编译器无效。这说起来容易做起来难,我仍然不能100%确定实现是否确实是一个严格的子集。
所选功能的完整细节可以在存储库的自述中找到。总之,编译器可以处理:
我想让刽子手游戏可以在网页上玩。有三种方法可以做到这一点:(1)制作一个解释并运行Pascal程序的虚拟机,(2)将Pascal程序转换为Javascript,然后使用Function object运行它,(3)将Pascal程序编译为WebAssembly。我选择了第三个选项,因为我对WebAssembly感兴趣已经有相当一段时间了,它似乎比“仅仅”制作虚拟机或转换为Javascript更有趣、更具挑战性。
虽然可以手动生成WebAssembly二进制字节码,但我使用了binaryen js库,因为它更简单,而且还具有验证和优化功能。缺点是它相当大,即使使用parceljs打包,也大约有5MB。它还使用树表示来验证和优化WebAssembly模块,因此一些表达式,如多值元组和手动堆栈操作,更难表达。当时我不知道还有wabt。js,所以可以先生成文本格式的WebAssembly代码,然后将其转换为二进制格式。
WebAssembly有局部变量的概念,所以可以使用它实现局部使用的变量。但是,它只能存储整型或浮点值。对于基本类型,编译器可以直接将f64变量用于实数,将i32变量用于序数(整数、字符、布尔),但对于字符串、数组或记录等复杂类型,编译器需要在内存中维护调用堆栈,并将值的地址存储为i32局部变量。调用子例程时会分配调用堆栈中的值,子例程返回时会取消分配。调用堆栈由三部分实现:
两个全局变量SP(堆栈指针)和FP(帧指针)。SP将地址存储到值堆栈的顶部,FP将地址存储到调用帧堆栈的顶部。
调用帧堆栈,存储该调用的值堆栈基址和子例程id的内存区域。
程序测试;输入SmallStr=string[9];var-str:SmallStr;x:整数;程序a(strA:SmallStr;z:char);开始。。。终止程序b(strB:SmallStr);var-strB1:SmallStr;y:布尔;开始。。。y:=假;a(strB,';a';);终止开始b(str);终止
当调用过程a时,调用帧和值堆栈在内存中看起来像这样。请注意,变量x、y和z不是手动存储在内存中的。
对于非局部使用的变量,情况要复杂一些;即使用父作用域声明的变量,或使用变量作为var参数的参数。WebAssembly局部变量的一大限制是,它不能作为声明它的函数外部的指针引用,因此所有非局部使用的变量必须存储在内存中,而不管数据类型如何。例如,考虑下面的PASCAL程序。
程序测试;变量x1,x2:整数;程序外部(变量x:整数);变量y1,y2:整数;程序内部();var z:整数;从y1开始:=1;//在内部使用y1/。。。终止开始x:=2;内部();/。。。终止从外部开始(x1);//使用x1作为变量参数结尾的参数。
变量x1和y1将存储在内存中,而x2、y2和z将存储为WebAssembly局部变量。调用内部过程时,调用帧和值堆栈将如下所示。
这一部分看似简单,但实际上并非如此。我想在网页上模拟终端控制台。所以我很自然地使用了xterm。js图书馆。这不是最容易使用的东西,因为我需要手动处理库中的键和数据事件,但它仍然比重新实现终端UI快得多,也容易得多。终端模拟器成功了!
在这一点上,我意识到WebAssembly目前不支持对异步函数或协同路由的调用。如果导入的函数是异步函数或协同程序,则不会暂停执行以等待结果。有一种方法可以使用Asyncify从WebAssembly代码内部处理这个问题,但它涉及调用堆栈倒带,而且相当复杂。相反,我使用了Web Workers和Atomics wait and notify API的组合。基本上,已编译的Pascal程序是在Web Worker中实例化和执行的。当有一个异步调用时,比如readln,web工作者调用原子。等待()暂停自己。主UI线程将调用Atomics。在触发特定事件后(在本例中,当终端仿真器读取新行时),通知工作线程。所以问题解决了!嗯,还没有。
事实证明,Atomics等待并通知API需要SharedArrayBuffer才能工作,而SharedArrayBuffer只有在页面被跨源隔离时才启用3。通过向顶层文档http响应中添加额外的头,这实际上非常简单。很简单,如果你控制了为页面服务的服务器。我当时没有任何活动的VP,当然也没有权限更改Github页面服务器中的响应头。虽然VPS非常便宜,易于安装,而且我将来可能需要将其用于其他目的,但仅为提供静态内容而设置它仍然是一件非常麻烦和浪费的事情。幸运的是,我找到了stefnotch的一篇博客文章,它正好解决了我的问题。本文有更详细的解释,但它基本上是通过使用服务工作者手动将所需的头添加到响应中来工作的。
在我的编译器实现中有很多地方可以改进,但这里有一些更重要的地方。
我真的应该有解析器和类型检查器&;分解器可分为不同的模块。我将其组合在一起,这样它只需要两次(解析+类型检查,然后发射二进制)而不是三次或更多(解析、类型检查,然后发射二进制)。它的速度更快,但事后看来,差别并不大,而且它使解析器代码更加复杂。
当前,每次编译程序时都会重新编译运行库。预编译并将其“复制”到已编译的程序中应该不难,但我没有时间。此外,我应该用更高级的语言(如C)创建运行库,然后将其编译到WebAssembly,而不是在WebAssembly中手动创建。该方法存在一些问题(例如,Pascal运行时将包含C运行时,我需要删除它或处理它,以便它能很好地发挥作用),但我认为,如果我用一个完整的标准库成为一个认真的编译器,这样做会更容易、更好。
在program runner worker线程和主UI线程之间处理异步操作的消息传递非常混乱,而且紧密耦合。我不知道如何在不太笼统的情况下整理它。
总而言之,我对这个项目的结果很满意。我发现WebAssembly是一个有趣的编译目标,尽管在未来肯定会有一些成长的烦恼需要解决。我还发现,与十年前相比,这个项目很好地提醒了我现在的处境。希望在接下来的十年里,我能像我看到老刽子手游戏那样看待这个项目。
PTI-A课程,面向2012年之前的ITB学生。如果我没记错的话,该课程被重新组织为用于CS&;的PTI-B和DasPro课程;由于2012年课程大纲的改变,EE学生。 ↩︎︎
我完全知道有一种方法可以使用FreePascal和其他很多方法将Pascal编译成WebAssembly,但我也想做一个编译器! ↩︎︎
最初情况并非如此,直到熔毁和幽灵改变了一切。 ↩︎︎