从规范生成WebAssembly 6502仿真器的代码

2020-11-16 21:29:45

编写仿真器使旧的计算机硬件起死回生是一种流行的爱好,我最近通过自己的目标--编写一款Atari 2600仿真器--一直在享受这一爱好。然而,编写CPU仿真器可能会有些重复和乏味,所以我想我应该探索一种不同的方法-从规范生成CPU仿真代码,而不是手动转换它。这篇博文分享了丰硕的成果。

雅达利2600在70年代末和80年代初是一款非常受欢迎的游戏机,将乒乓球、越狱和太空入侵者等游戏带入了人们的家中。当时硬件(特别是内存)的成本非常高,所以为了制造出一款价格合理的游戏机,2600的研发团队不得不变得相当有创意。要了解这台机器到底有多有创意,我强烈推荐你读一读《竞速光束》一书--2600的内存如此之少,以至于它没有传统的视频RAM(一块直接映射到屏幕上的内存区域),而是在电视扫描每一帧时更新了影响图像‘像素’的各种寄存器!

自从读了这本书后,我一直想尝试模仿2600,以便更多地了解它的硬件,几个月前我就开始了这段旅程。

我决定使用AssemblyScript编写仿真器,这是一种派生于文字脚本的语言,自我上次尝试以来已经有几年时间了,它会编译成WebAssembly,我非常想看看它是如何演变的。

编写计算机模拟器的一个相当标准的方法是首先“构建”CPU,然后开始处理围绕它的硬件。然而,我的主要兴趣和动机是探索2600如何“赛跑光束”,更具体地说是CPU(中央处理器)和TIA(电视接口适配器)之间的关系;因此,我开始并行构建这两个处理器,实现恰好足以支持TIA功能的CPU指令。

幸运的是,这个控制台上有很多很棒的文档,其中最著名的是面向新手的《Atari 2600编程》一书。我一直在逐章阅读这本书,依次实现支持每个示例所需的仿真器功能。

在这里,你可以看到我已经实现了各种扫描线同步功能、背景颜色和不对称的运动场图形:

然而,我已经到了一个有意义的阶段,解决6502 CPU(或者更准确地说,6507,一个更小的封装中的相同芯片),并完整地实现它的指令集,这也是这篇博客文章主要关注的。

顺便说一句,如果你打算尝试编写自己的仿真器,雅达利2600不是一个容易的起点。大多数人通过简单得多的芯片-8来熟悉仿真的基本概念。不久前我就是这么做的,在Rust中编写了一个Chip-8仿真器。

Atari 2600使用MOS 6507作为CPU,在更小的封装中使用6502,从而降低了成本并缩小了可寻址内存范围。我开始处理这个仿真器的方式与我处理芯片8的方式完全相同。从结构上看,这些仿真器看起来都是一样的;从内存中读取下一条指令,打开操作码,从内存或寄存器中读取任何相关的操作数,执行操作,然后写入适当的位置。然而,这并不是一项特别具有挑战性的任务,因为6502支持的52个指令有点单调乏味!

我使用的是6502指令集的众多在线参考资源中的一个,这个特别的资源非常简洁和结构化(这将在后面讨论)。

下面是一条指令示例,以及对累加器(三个CPU寄存器之一)中当前保存的值应用的按位AND操作:

与和存储器与累加器A&A和M->;A N Z C I D V++-寻址汇编程序OPC字节周期-立即数和#oper 29 2 2零位和运算25 2 3零位,X和oper,X 35 2 4绝对值和op 2D 34绝对值,X和op,X 3D 3 4*绝对值,Y和op,Y 39 3 4*。X)21 2 6(间接),Y和(Opera),Y 31 2 5*。

操作本身的伪代码表示A&;M-&>A、Read、按位AND和ASSIGN。

一个迷你表,标题为-N Z C I D V,表示对CPU各种状态寄存器的影响。在这种情况下,负值和零值寄存器根据操作结果进行更新。

此信息下方的表格详细说明了此指令支持的各种寻址模式。在每种情况下,操作都是相同的,但是操作数的来源是不同的。例如,立即模式直接使用操作数的值(即程序存储器中AND操作码后面的字节),而绝对X模式操作数是一个16位地址,它与X寄存器值相结合,将值的地址提供给累加器的“AND”。

这些不同的寻址模式需要不同数量的读取和写入,因此它们需要不同数量的时钟周期才能完成,如周期列中所述。此外,根据运行时涉及的值,某些指令的周期可能会有所不同-在这种情况下,星号*表示如果跨越页边界,则需要额外的周期。从编写雅达利2600仿真器的角度来看,正确处理这一点是至关重要的,因为这些时钟周期流逝,电视‘光束’正在竞速!

该规范只详细说明了52条指令中的一条,包含了大量信息,在我最初的实现中产生了相当多的仿真代码和相关的单元测试。

在实现了几个模式之后,我采取了明显的步骤来寻找模式,无论是从规范本身还是从我正在编写的代码中的简单重构机会来看。然而,尽管尝试了各种技术(包括偷看别人的JavaScript、Rust和C#实现),我还是找不到我满意的模式。更具体地说,我找不到一种模式可以让我通过视觉检查轻松地确定我的实现是否正确地实现了规范,这让我很沮丧!

正是在这一点上,我有了这个想法,这是一个我可以自动化的过程,而不是手动将规范转换为代码。我的希望是,与验证仿真器实现的正确性相比,更容易实现并确保此转换过程的正确性。换句话说,如果规范是正确的,并且翻译过程是正确和无错误的,那么它生成的仿真器也一定是正确的。

6502规范是一个大约600行的文本文件,在某种意义上是一种域特定语言(DSL),使用常规模式将其编译(或转换)到CPU仿真器实现是有意义的。在之前的一篇博客文章中,我已经讨论过这个主题,该文章通过创建我自己的针对WebAssembly的语言,探索了标准的记号-解析器-发射器(tokeniser-parser-emitter)模式。在这种情况下,“语言”在结构上更加僵化,记号分割器不会提供太多优势,因此在这里我选择了一个更简单的两阶段解析器-发射器结构。

解析器的作用是输出抽象语法树(AST)。在此上下文中,此步骤的主要目的是读取规范并生成指令对象数组,每个指令对象包含描述其功能的相关信息。

解析器的实现没有什么特别值得注意的,它有170行打字代码,主要与字符串争论有关。如果你感兴趣,可以在GitHub上查看代码。

{";名称";:";和";,";类型";:";分配";,";表达式";:";A&;M";,";受理人";:";A";,";描述";:";和带有累加器";,";标志";:{";零";:";已修改";,";负数";:";已修改";},";寻址模式";:[{";操作码";:";29";,";周期";:2,";模式";:";立即";,";周期修改器";:";无";},{";操作码";:";25";,";周期";:3,";模式";:";Zeropage";,";cycleModiizer";:";None";},{";opcode";:";35";,";Cycle";:4,";模式";:";Zeropage,X";,";cycleModiizer";:";None";},{";操作码";:";2D";,";周期";:4,";模式";:";绝对";,";周期修改器";:";无";},{";操作码";:";3D";,";Cycle";:4,";模式";:";绝对,X";,";周期修饰符";:";页面边界交叉";},{";操作码";:";39";,";Cycle";:4,";模式";:";绝对,X";,";周期修饰符";:";页面边界交叉";},...],}

需要注意的一个重要元素是type属性,在本例中是赋值。解析器识别指令类型,其他示例包括分支和测试,它大致描述了该指令表示的操作类型,并因此描述了生成的代码的“形状”。

此外,本例中的Expression属性的值为A&;M。我也可以将这些表达式解析为嵌套的AST,但是这些表达式太琐碎了,因此不会增加太多价值。

生成器/发射器从解析器获取AST输出,并生成生成的仿真器代码,在本例中,该代码是用AssemblyScript编写的。

Const GenerateInstructions=(指令:指令)=>;指令。地址模式。MAP(地址=&>;{开关(指令。Type){case";Assignment";:Return GenerateAssignment(指令,地址);Case";Branch";:return GenerateBranch(指令,地址);case";test";:return GenerateTest(指令,地址);case";Empty";:return GenerateEmpty(指令,地址);case";Jump";:return GenerateJump(指令,地址);case";Push";:return GeneratePush(指令,地址);case";POP";:return GeneratePop(Instruction,Address);}})。加入(";\n";);

这将遍历指令的每种寻址模式,根据指令类型调用生成器。

下面是一个示例生成器,在本例中,赋值类型遵循AND指令的示例:

Const GenerateAssignment=(指令:AssignmentInstructions,地址:AddressingMode)=>;`案例0x${地址。操作码}:/*${指令。名称}*/{${applyAddressingMode(地址。模式)}常量结果:U16=${replaceRegisters(指令。表达式)};${setFlags(指令。标志)}${assignValue(指令。受让人,地址。模式)}${handleCycle(地址)};}Break;`;

此代码为该特定操作码(指令和寻址模式的组合)生成所需的CASE语句。代码体‘应用’寻址模式来定位操作数值,执行表达式A&;M的简单转换,设置状态标志,分配结果值并更新剩余时钟周期。如果您对这些函数的任何细节感兴趣,请查看源代码。

让我们看一下AND指令的Zeropage,X寻址模式的输出:

案例0x35:/*and*/{const addr=(this.。记忆。读一下(这个。)。PC++)+这个。XRegister)&;0xff;常量记忆:U16=此。记忆。Read(Addr);常量结果:U16=this。累加器&;Memval;这个。状态寄存器。ZERO=U8(结果==0);这。状态寄存器。负=U8(结果!==0);这。累加器=(Result&;0xff)AS U8;这。周期剩余=3;}中断;

如果您重新参考原始规范,您会发现它执行了所需的AND运算,正确地设置了零和负状态标志,最后更新了剩余的周期数。

使用这种代码生成方法,170行解析器和270行生成器能够直接从规范生成6502仿真器。如果您感兴趣,请看一下1,995行生成的文件。与从规范中手动实现它相比,我对它的正确性更有信心--也得到了更多的乐趣!

我已经阅读了很多关于仿真和6502的文章,但还没有看到其他人采用这种方法。我确信以前已经有人这么做了,然而,公平地说,大多数人只是“埋头苦干”,手工制作他们的仿真器。

现在我的6502已经完成了,我可以开始处理一些更有趣的雅达利2600功能了。精灵是我的下一个目标。然而,由于6502是BBC Micro(我的第一台家用电脑)、NES、C64和许多其他电脑和游戏机的核心,我很容易分心!

我是Scott Logic的技术总监,是一位多产的技术作家、博客作者和演讲者。

我的博客包含了很多主题的帖子,包括WebAssembly、HTML5/JavaScript以及D3和D3FC的数据可视化。你还可以找到一大堆关于以前对技术感兴趣的帖子,包括iOS、SWIFT、WPF和Silverlight。

我是Finos的董事会成员,该组织鼓励金融领域的开源合作。我在GitHub上也非常活跃,为许多不同的项目做出了贡献。