WhatisChip8?什么是8位机 在开始这个项目之前,我从未听说过8位机,所以我想大多数人也没有听说过,除非他们已经进入了模拟器。Chip8是一种非常简单的解释型编程语言,于20世纪70年代为业余计算机开发。人们编写了基本的Chip8程序,模仿当时流行的游戏,如Pong,俄罗斯方块,太空侵略者,可能还有其他独特的游戏,失去了时间的歼灭。 玩这些游戏的虚拟机实际上是Chip8解释器,而不是技术上的模拟器,因为模拟器是模拟特定机器硬件的软件,而Chip8程序不与任何特定硬件相关联。通常,Chip8解释器用于图形计算器。 尽管如此,它已经足够接近于成为模拟器,对于任何想要学习如何构建模拟器的人来说,它通常是开始的项目,因为它比创建NES模拟器或除此之外的任何东西都要简单得多。对于许多CPU概念来说,这也是一个很好的起点,比如内存、堆栈和IO,这些都是我在JavaScript运行时无限复杂的世界中每天要处理的事情。WhatGoesIntoaChip8Interpreter?8位机解释器 我必须做很多预习才能开始理解我正在做什么,因为我以前从未学习过计算机科学的基础知识。所以我写了理解位,字节,基,并在JavaScript中编写十六进制转储,其中大部分内容。 总而言之,该文章有两个主要要点:位和字节位是二进制数字0或1,真或假,开或关。八位是一个字节,它是计算机使用的基本信息单位。数字基数十进制是我们最常处理的基数系统,但计算机通常使用二进制(基数2)或十六进制(基数16)。二进制的1111、十进制的15和十六进制的f都是相同的数字。 CPU是执行程序指令的计算机的主处理器。在这种情况下,它由下面描述的各种状态位以及包含获取,解码和执行步骤的指令周期组成。Memory内存Programcounter计算计算器Registers寄存器Indexregister寄存器索引Stack栈Stackpointer栈指针Keyinput输入Graphicaloutput图像输出Timers时钟Memory内存 8位计算机,可以访问高达4千字节的内存(RAM)。(这是软盘上存储空间的0。002。CPU中的绝大多数数据都存储在内存中。 4kb是4096字节,JavaScript有一些有用的类型化数组,比如Uint8Array,它是某个元素的固定大小的数组在这种情况下是8位。letmemorynewUint8Array(4096) 您可以像访问和使用此数组一样访问和使用此数组,从内存〔0〕到内存〔4095〕,并将每个元素设置为最大255的值。任何高于此值的内容都将回退到该值(例如,内存〔0〕300将导致内存〔0〕255)。 Programcounter程序计数器 程序计数器将当前指令的地址存储为16位整数。Chip8中的每条指令都将在完成后更新程序计数器(PC),以进入下一条指令,方法是访问以PC为索引的存储器。 在8位机内存布局中,内存中0x1FF0x000是保留的,因此它从0x200开始。letPC0x200memory〔PC〕willaccesstheaddressofthecurrentinstruvtion 您会注意到内存阵列是8位的,而PC是16位整数,因此两个程序代码将被组合成一个大的字节序操作码。Registers寄存器 存储器通常用于长期存储和程序数据,因此寄存器作为一种短期存储器存在,用于即时数据和计算。8位机有16个8位寄存器。它们被称为V0到VF。letregistersnewUint8Array(16)Indexregister索引寄存器 有一个特殊的16位寄存器,用于访问内存中的特定点,称为I。I寄存器通常用于读取和写入内存,因为可寻址内存也是16位的。letI0Stack栈 8位机能够进入子例程,以及一个用于跟踪返回位置的堆栈。堆栈是16个16位值,这意味着程序在经历堆栈溢出之前可以进入16个嵌套的子例程。letstacknewUint16Array(16)Stackpointer栈指针 堆栈指针(SP)是一个8位整数,指向堆栈中的某个位置。即使堆栈是16位,它也只需要是8位,因为它只引用堆栈的索引,因此只需要0彻底15。letSP1stack〔SP〕willaccessthecurrentreturnaddressinthestackTimers定时器 8位机能够发出光荣的一声蜂鸣声。说实话,我没有费心为音乐实现实际输出,尽管CPU本身都设置为与它正确接口。有两个计时器,都是8位寄存器一个声音计时器(ST)用于决定何时发出蜂鸣声,一个延迟计时器(DT)用于在整个游戏中对某些事件进行计时。它们在60Hz下倒计时。letDT0letST0Keyinput输入 8位机的设置是为了与惊人的十六进制键盘接口。它看起来像这样:123C456D789EA0BF 在实践中,似乎只使用了几个键,你可以将它们映射到你想要的任何4x4网格,但它们在游戏之间非常不一致。Graphicaloutput图形输出 8位机使用单色64x32分辨率显示器。每个像素要么打开,要么关闭。 可以保存在内存中的精灵为8x158个像素宽x15个像素高。Chip8还附带了字体集,但它只包含十六进制键盘中的字符,因此总体上不是最有用的字体集。CPU 将它们放在一起,您将获得CPU状态。 CPUclassCPU{constructor(){this。memorynewUint8Array(4096)this。registersnewUint8Array(16)this。stacknewUint16Array(16)this。ST0this。DT0this。I0this。SP1this。PC0x200}}DecodingChip8Instructions指令解码 8位机有36条指令。此处列出了所有说明。所有指令的长度均为2个字节(16位)。每条指令都由操作码(操作码)和操作数(操作码)编码,操作数据作。 如两个操作x1y2ADDx,y 其中ADD是操作码,x、y是操作数。这种类型的语言称为汇编语言。此指令将映射到:xxy 使用此指令集,我必须将此数据存储在16位中,因此每个指令最终都是从0x0000到0xffff的数字。这些集合中的每个数字位置都是一个半字节(4位)。 那么我怎么能从nnnn到像ADDx,y这样的东西,这更容易理解呢?好吧,我将首先查看Chip8中的一条指令,它与上面的例子基本相同: Instruction Description 8xy4 ADDVx,Vy 那么,我们在这里处理的是什么呢?有一个关键字ADD和两个参数Vx和Vy,我们在上面建立的它们是寄存器。 如操作指令:ADD(add)SUB(subtract)JP(jump)SKP(skip)RET(return)LD(load) 数据类型:地址(I)寄存器(Vx,Vy)常数(NorNNfornibbleorbyte) 下一步是找到一种方法将16位操作码解释为这些更易于理解的指令。BitMasking位操作constopcode0x8124constmask0xf00fconstpattern0x8004constisMatch(opcodemask)patterntrueconstx(0x81240x0f00)81(0x81240x0f00)is100000000inbinaryrightshiftingby8(8)willremove8zeroesfromtherightThisleavesuswith1consty(0x81240x00f0)42(0x81240x00f0)is100000inbinaryrightshiftingby4(4)willremove4zeroesfromtherightThisleavesuswith10,thebinaryequivalentof2constinstruction{id:ADDVXVY,name:ADD,mask:0xf00f,pattern:0x8004,arguments:〔{mask:0x0f00,shift:8,type:R},{mask:0x00f0,shift:4,type:R},〕,} Disassemblerfunctiondisassemble(opcode){FindtheinstructionfromtheopcodeconstinstructionINSTRUCTIONSET。find((instruction)(opcodeinstruction。mask)instruction。pattern)Findtheargument(s)constargsinstruction。arguments。map((arg)(opcodearg。mask)arg。shift)Returnanobjectcontainingtheinstructiondataandargumentsreturn{instruction,args}}ReadingtheROM 由于我们将此项目视为仿真器,因此每个8位程序文件都可以被视为一个ROM。ROM只是二进制数据,我们正在编写程序来解释它。我们可以把Chip8CPU想象成一个虚拟游戏机,而一个8位ROM是一个虚拟游戏卡带。 RomBuffer。jsclassRomBuffer{param{binary}fileContentsROMbinaryconstructor(fileContents){this。data〔〕ReadtherawdatabufferfromthefileconstbufferfileContentsCreate16bitbigendianopcodesfromthebufferfor(leti0;ibuffer。length;i2){this。data。push((buffer〔i〕8)(buffer〔i1〕0))}}} 指令周期获取、解码、执行 现在,我已经准备好解释指令集和游戏数据。CPU只需要对它做点什么。指令周期包括三个步骤获取、解码和执行。Fetch获取数据Decode译码Execute执行 FetchGetaddressvaluefrommemoryfunctionfetch(){returnmemory〔PC〕} DecodeDecodeinstructionfunctiondecode(opcode){returndisassemble(opcode)} ExecuteExecuteinstructionfunctionexecute(instruction){const{id,args}instructionswitch(id){caseADDVXVY:Performtheinstructionoperationregisters〔args〔0〕〕registers〔args〔1〕〕UpdateprogramcountertonextinstructionPCPC2breakcaseSUBVXVY:etc。。。}} CPU。jsclassCPU{constructor(){this。memorynewUint8Array(4096)this。registersnewUint8Array(16)this。stacknewUint16Array(16)this。ST0this。DT0this。I0this。SP1this。PC0x200}Loadbufferintomemoryload(romBuffer){this。reset()romBuffer。forEach((opcode,i){this。memory〔i〕opcode})}Stepthrougheachinstructionstep(){constopcodethis。fetch()constinstructionthis。decode(opcode)this。execute(instruction)}fetch(){returnthis。memory〔this。PC〕}decode(opcode){returndisassemble(opcode)}execute(instruction){const{id,args}instructionswitch(id){caseADDVXVY:this。registers〔args〔0〕〕this。registers〔args〔1〕〕this。PCthis。PC2break}}}CreatingaCPUInterfaceforIO输入输出 所以现在我有了这个CPU,它正在解释和执行指令并更新它自己的所有状态,但我现在还不能用它做任何事情。为了玩游戏,你必须看到它并能够与之互动。 这就是输入输出或IO的用武之地。IO是CPU与外部世界之间的通信。输入是CPU接收的数据输出是从CPU发送的数据 CpuInterface。jsAbstractCPUinterfaceclassclassCpuInterface{constructor(){if(new。targetCpuInterface){thrownewTypeError(Cannotinstantiateabstractclass)}}clearDisplay(){thrownewTypeError(Mustbeimplementedontheinheritedclass。)}waitKey(){thrownewTypeError(Mustbeimplementedontheinheritedclass。)}getKeys(){thrownewTypeError(Mustbeimplementedontheinheritedclass。)}drawPixel(){thrownewTypeError(Mustbeimplementedontheinheritedclass。)}enableSound(){thrownewTypeError(Mustbeimplementedontheinheritedclass。)}disableSound(){thrownewTypeError(Mustbeimplementedontheinheritedclass。)}}classCPU{Initializetheinterfaceconstructor(cpuInterface){this。interfacecpuInterface}execute(instruction){const{id,args}instructionswitch(id){caseCLS:Usetheinterfacewhileexecutinganinstructionthis。interface。clearDisplay()}}Screen显示 屏幕的分辨率为64像素宽x32像素高。因此,就CPU和接口而言,它是一个64x32的位网格,这些位要么打开要么关闭。要设置一个空屏幕,我可以制作一个零的3D数组来表示所有关闭的像素。帧缓冲区是内存的一部分,其中包含将呈现到显示器上的位图图像。 MockCpuInterface。jsInterfacefortestingclassMockCpuInterfaceextendsCpuInterface{constructor(){super()Storethescreendataintheframebufferthis。frameBufferthis。createFrameBuffer()}Create3DarrayofzeroescreateFrameBuffer(){letframeBuffer〔〕for(leti0;i32;i){frameBuffer。push(〔〕)for(letj0;j64;j){frameBuffer〔i〕。push(0)}}returnframeBuffer}Updateasinglepixelwithavalue(0or1)drawPixel(x,y,value){this。frameBuffer〔y〕〔x〕value}} 在DRW函数中,CPU将循环遍历它从内存中提取的子画面,并更新子画面中的每个像素(为简洁起见,省略了一些细节)。caseDRWVXVYN:Theinterpreterreadsnbytesfrommemory,startingattheaddressstoredinIfor(leti0;iargs〔2〕;i){letlinethis。memory〔this。Ii〕Eachbyteisalineofeightpixelsfor(letposition0;position8;position){。。。Getvalue,x,andy。。。this。interface。drawPixel(x,y,value)}} clearDisplay()函数是将用于与屏幕交互的唯一其他方法。这是与屏幕交互所需的所有CPU接口。Keys按键1234QWERASDFZXCVprettierignoreconstkeyMap〔1,2,3,4,q,w,e,r,a,s,d,f,z,x,c,v〕 按键按下状态。this。keys00b1000000000000000Vispressed(keyMap〔15〕,orindex15)0b00000000000000111and2arepressed(index0,1)0b0000000000110000QandWarepressed(index4,5)caseSKPVX:SkipnextinstructionifkeywiththevalueofVxispressedif(this。interface。getKeys()(1this。registers〔args〔0〕〕)){Skipinstruction}else{Gotonextinstruction}Screen显示器 对于所有实现,包含屏幕数据位图的帧缓冲区都是相同的,但屏幕与每个环境的接口方式将不同。 带着祝福,我只是定义了一个屏幕对象:this。screenblessed。screen({smartCSR:true}) 并在像素上使用fillRegion或clearRegion,并使用完整的unicode块进行填充,使用帧缓冲区作为数据源。drawPixel(x,y,value){this。frameBuffer〔y〕〔x〕valueif(this。frameBuffer〔y〕〔x〕){this。screen。fillRegion(this。color,,x,x1,y,y1)}else{this。screen。clearRegion(x,x1,y,y1)}this。screen。render()}Keys按键 按键处理程序与我对DOM的期望没有太大区别。如果按下某个键,处理程序将传递该键,然后我可以使用该键查找索引并使用已按下的任何其他新键更新keys对象。this。screen。on(keypress,(,key){constkeyIndexkeyMap。indexOf(key。full)if(keyIndex){this。setKeys(keyIndex)}})setInterval((){Emulateakeyupeventtoclearallpressedkeysthis。resetKeys()},100)Entrypoint入口点 terminal。jsterminal。jsconstfsrequire(fs)const{CPU}require(。。classesCPU)const{RomBuffer}require(。。classesRomBuffer)const{TerminalCpuInterface}require(。。classesinterfacesTerminalCpuInterface)RetrievetheROMfileconstfileContentsfs。readFileSync(process。argv。slice(2)〔0〕)InitializetheterminalinterfaceconstcpuInterfacenewTerminalCpuInterface()InitializetheCPUwiththeinterfaceconstcpunewCPU(cpuInterface)ConvertthebinarycodeintoopcodesconstromBuffernewRomBuffer(fileContents)Loadthegamecpu。load(romBuffer)functioncycle(){cpu。step()setTimeout(cycle,3)}cycle()