有趣的是,兔子洞最终落到了一个人的头上。我几年来的个人项目之一就是创建(实际上是探索)一个“假仿真器”--也就是一种用于计算机的仿真器,该仿真器从来不存在,全部用JavaScript编写。取而代之的是,这台机器将向20世纪80年代和90年代的8位和16位机器致敬。
不过,我喜欢硬着头皮做事情:这台机器也会基于一套新颖的指令集。指令集将类似于那个时代的指令集,但也更容易使用。于是,Retroputer诞生了。经过几年的时间,实现已经构建并改进了,尽管它可能永远不会“完成”(毕竟这是个人的探索)。
然后@bbcmicrobot成为了一件事,我希望能够为Retroputer做类似的事情。我的JS开发技能主要集中在前端,因此这将是获得更多后端技能的一种很酷的方式。一个问题是:反向计算器只能理解它自己的汇编语言。它还没有得到基本的支持。
所以我在这里,构建一个80年代风格的BASIC解释器-也就是,完全用汇编语言,就像以前一样。我想我应该分享这段旅程,因为我们并不经常深入研究与我们典型的抽象概念相去甚远的领域。我的日常驱动程序(JavaScript)使很多琐碎的事情变得微不足道,有时这些事情会让人感觉很神奇。理解流程的最低级别通常有助于理解这些抽象。
当我为Retroputer编写汇编器时,我能够使用一个非常好的工具,叫做Pegjs。这使得汇编器的自定义语法快速完成,但不幸的是,Retroputer ASM没有类似的功能。
解析实际上是分多个阶段进行的。使用编译器的语言将代码解析成抽象语法树(或类似概念),然后可以使用该树生成生成的本机代码。这样做的结果是程序必须在语法上是正确的,才能成功编译。
今天的一些解释器也有这个概念,因为与从原始源执行相比,生成中间AST并从中执行通常更有用。
但是,对于资源有限的机器上的基本解释器来说,最有效的资源解析方式是分多个阶段执行-其中一些阶段发生在运行时。然而,这意味着在程序运行并遇到有错误的代码区之前,通常无法检测到语法错误。
前两个步骤发生在用户进入(或加载)程序时。最后一次发生在程序运行时。从本质上说,前两种是建造飞机的粗糙脚手架,但不能保证飞行。最后一步实质上是扮演试飞员的角色--希望你能离开地面,但直到你尝试了才知道。
谢天谢地,Retroputer Basic不会因为在运行时引发错误而带来如此可怕的后果。
这是整个过程中最简单的部分。本质上,用户输入的行将转换为大写,以便以后的处理更容易(也更快)。BASIC对大小写不敏感,所以我们可以利用这一点。
但在汇编语言中,我们必须更详细地说明事情是如何完成的。我们需要读入一个字符,将其转换为大写,然后将其存储在某个位置。
ld y,0#y是我们的index_loop:ld al,[d,x,y]#[d,x]是指向字符串CMP al的指针,97#al(Char)在范围内吗?brs n_Continue#不是小写字符;Continue CMP al,123#高位brs!n_Continue#不是小写字符;Continue and al,0b1101_1111#大写!st[d,x,y],al#存储回(我们修改原始的)_Continue:Inc y#沿CMP al移动我们的索引,0#检查是否为空brs!Z_LOOP#否?回去拿更多。
上述内容与JavaScript版本的语义不完全匹配。一个重要的区别是,我们现在使用Unicode来处理文本,因此将输入从小写转换为大写通常更加困难,甚至可能是不可能的(取决于语言)。Retroputer生活在ASCII(确切地说,是它自己的变体,名为RetSCII)的世界中,这意味着所有支持的字符都被编码成8位。可悲的是,这对许多语言来说是不够的,但对那个时期来说也是如此。
这也意味着我们可以使用ASCII的一个很好的功能将小写转换为大写。在ASCII中,大写的“A”用65表示,小写的“a”用97表示。如果你熟悉你的2次方,这种不同应该会吸引你的眼球。
所以结果是小写字母用一个恰好在大写字母上方32的数字来表示。一旦我们知道有东西在射程内,我们所要做的就是减去32!
这很管用,但我们可以只做一点小摆设。对于反向计算器,这实际上不会比减法快,但是避免减法意味着我们在运算过程中不必担心进位/借入标志。结果是我们可以使用按位AND来关闭32位值的位。
和al,0b1101_1111#关闭32位中的位#versusclr c#Clear Carrysub al,32#减去32。
但有一个问题:并不是所有的东西都可以转换成大写。例如,如果用户包含字符串文字,我们必须更加小心。毕竟,我们不希望Retroputer Basic总是对用户大喊大叫,对吗?(虽然那个时代的许多计算机没有小写功能,但Retroputer没有同样的限制。)。
这意味着我们需要跟踪我们是否处于字符串文字的中间。在Basic中,这只有一个能指:双引号。如果我们检查一个字符是否是双引号,我们可以设置一个标志,并且根据标志的值,我们可以执行大写操作或不做任何事情。
事实证明,在JavaScript中没有内置来实现这一点,但是我们可以构建一个:
const len=theLine.length;let inside String=false;for(设i=0;i<;len;i++){const ch=thline[i];if(ch=`";`)inside String=!inside String;if(!inside String){const newch=ch.toUpperCase();if(ch!==newch)thline[i]=newch;}}。
现在,JS的逻辑与程序集版本的逻辑更加匹配,尽管我们更多地利用了JS的Unicode支持。
ld y,0#y是我们的索引ld bl,0#==inside String(False)_loop:ld al,[d,x,y]#[d,x]是指向字符串CMP al的指针,34#al是双引号吗?brs!z check_char#no?我们应该把它大写吗?XOR bl,0xFF#是吗?在字符串中切换inside String_check_char:cmp bl,0xFF#?BRS z_CONTINE#是吗?不要修改cmp al,97#是否为al(Char)在范围内?";a";brs n_Continue#不是小写字符;Continue CMP al,123#高位部分";z";brs!n_Continue#不是小写字符;Continue and al,0b1101_1111#大写!st[d,x,y],al#存储回(我们修改原始的)_Continue:Inc y#沿CMP al移动我们的索引,0#检查是否为空brs!Z_LOOP#否?回去拿更多。
到目前为止,我们所做的只是将输入文本转换为大写,但是这里还有一个额外的好处,那就是我们必须跟踪是否在字符串内。我们可以在这里做一轮语法检查!
如果在过程结束时我们发现inString仍然为true(bl=0xFF),我们可以触发一个错误,因为这意味着该行中的某个地方有一个未终止的字符串文字。
附注:事实证明,当涉及到字符串的引号终止时,许多基础知识都相当宽松。这是我在构建自己的翻译器时学到的许多东西之一。尽管如此,我还是觉得不对劲,所以Retroputer Basic不允许这样做。
解析的下一阶段涉及将输入的行转换为更高效的内容,以便Retroputer basic执行。这与我们将在这里得到的抽象语法树的概念非常接近-结果肯定不会是树。但这将是我们可以在运行时快速评估的东西。
早期微型计算机的一个共同特点是存储容量非常有限。Retroputer的内存比当时大多数机器的默认内存都要大,但它的内存仍然比现代机器少得多。因此,如果长的BASIC程序按照用户键入的方式存储,那么它们很容易消耗过多的内存。
为了节省空间,在将程序输入内存时对关键字进行标记化。此过程将关键字转换为单字节令牌。关键字的长度始终至少为两个字节,因此节省的时间可以累积起来。这也意味着我们可以在执行期间使用查找表来调用适当的汇编语言例程。
不过,Retroputer Basic比当时的大多数基础知识走得更远。它还可以将数字转换为二进制表示形式、标记字符串、计算变量引用等。坦率地说,这浪费了一些空间,但是性能优势(和执行的简易性)帮助超过了这一点。
数字被转换成它们的二进制形式,以避免每次遇到它们时都必须转换它们。对于只遇到一次的数字,这不会带来巨大的性能优势,但在紧凑的循环中,这是有益的,因为数字已经是计算机可以理解的形式。
因为内存有限,如果代码中有一个字符串可以按原样使用,那么这样做是有意义的。例如,打印“Hello,World”可以直接从程序行打印“Hello,World”,而不是分配新空间、复制字符串,然后打印它。
为了便于在执行期间跳过字符串,我们还存储了字符串本身的长度。
任何不是数字或字符串的都可能是关键字,因此我们需要查看关键字列表。这在JavaScript中是微不足道的,但在汇编语言中就不那么容易了!
一旦找到关键字,相关的令牌就存储在程序存储器中(而不是整个关键字本身)。这可以显著节省存储空间,特别是在打印可以减少到单字节的情况下!
回溯计算机基本变量名称仅对前两个字符(当前)有意义。这使得使用相当简单的数学表达式在数组中查找变量变得很简单。即使这样,这个计算也需要时间,所以如果我们不需要在每次遇到变量时都这样做就好了。
Retroputer Basic将计算此索引,并将其与变量名一起存储。除了变量名之外,它还存储变量的长度,以加快运行时执行。这会消耗大量空间,因此在内存有限的计算机上不是一个好的解决方案,但它适用于Retroputer Basic。
在这篇文章中,我不会在此步骤中使用汇编语言。我会把它留到以后的帖子里。不过,请放心,这需要大量代码。
最后,但绝对不是最不重要的,是在运行时检查语法。一旦您有了代码的标记化表示,这就相当简单了。
首先,作为执行阶段的一部分,Basic检查它当前是否正在查看令牌。所有令牌都设置了高位(因此它们的值为128或更高)。如果找到令牌,我们只需在向量表中查找它,就可以确定要调用哪个子例程。这也使得呈现语法错误变得微不足道-一些关键字作为语句没有意义,因此向量表只指向生成语法错误的例程。
一旦调用了语句的令牌处理程序,该处理程序就会承担额外的解析职责。它可以使用gettok、gettok-raw、peektok等来获取和超越令牌。如果令牌是例程没有预料到的,例程只返回错误代码。这就是捕获语法和类型错误的地方。
如果语句需要计算表达式,则执行另一个阶段的解析。在表达式解析期间,将使用另一个向量查找表,这意味着我们可以捕获数学表达式中没有意义的关键字,并引发相应的错误。例如,如果您试图输入print2+cls,您会在cls部分得到一个语法错误(cls是“清晰屏幕”的缩写)。
注意:我们还可以从该表中确定运算符优先级和函数所需参数的数量。这对于实际计算表达式很重要,但是我们也可以使用它们来捕捉用户可能没有提供足够参数的情况。
因为令牌直接映射到向量查找表中的条目,所以执行可以非常快速地进行,只需很少的工作。解析每种语句的工作留给处理程序本身,通常这不是太大的问题。打印和输入可能是最难解析的,但是每一步都是一次一个令牌。
因为很多检查直到运行时才会完成,这确实意味着您可以在错误发生之前得到部分结果。例如:
这也意味着,如果您的程序离开屏幕时,您实际上看不到文本,那么您在恢复方面可能会遇到麻烦。将打印语法错误,但如果您看不到它,请使用…。那你打算怎么做?
这种语法检查肯定有缺点,但它也有助于实现相当简单的解释器。
下一次我们将更详细地讨论第二个解析阶段是如何工作的,以及使用现代抽象和标准库使用JavaScript会容易得多。但这个过程中的每一步都让我更加欣赏我们的现代便利,以及表面下正在进行的工作有多少。