创建一个模拟器街机。 第一部分

图片

编写街机仿真器是一个很棒的教育项目,在本教程中,我们将非常详细地研究整个开发过程。 想要真正掌握处理器吗? 然后创建一个模拟器是学习它的最佳方法。

您将需要有关C的知识以及汇编程序的知识。 如果您不懂汇编语言,那么编写模拟器是学习它的最佳方法。 您还需要掌握十六进制数学(也称为基数16或简称“十六进制”)。 我将谈论这个话题。

我决定为使用8080处理器的Space Invaders机器选择一个模拟器,该游戏和该处理器非常受欢迎,因为在Internet上您可以找到许多有关它们的信息。 您将需要它来完成项目。

本教程的整个源代码已上传到github 。 如果您尚未熟练使用git,则在github页面上有一个“下载ZIP”按钮,可让您下载包含所有代码的存档。

二进制和十六进制数简介


在“普通”数学中,使用十进制数字系统。 该数字的每个数字可以有一个从零到九的值,当我们超过9时,我们在下一个数字中加一个数字,然后再从零开始。 这一切都非常简单明了,您可能从未想过。

您可能已经知道或听说过计算机可以处理二进制数据。 计算机极客将base-10十进制数学称为基数,将二进制称为base-2。 用二进制表示法,数字的每个数字只能有两个值,零或一。 在二进制代码中,计数如下:0、1、10、11、100、101、110、111、1000。这些不是十进制数字,因此您不能将它们称为“零,一,十,十一,一百,一百一十”。 它们的发音为“零,一,一零,一,一,零零”,等等。 我很少大声读出二进制数字,但是如果需要,您需要清楚地指出所使用的数字系统。 十,十一和一百在二进制符号中没有意义。

以十进制表示的数字具有以下数字:单位,数十,数百,数千,数万等。 在二进制系统中,以下数字:单位,减号,4、8等。 在计算机科学中,每个二进制位的值称为一个位。 8位组成一个字节。

用二进制术语来说,一串数字很快变得很长。 以二进制形式表示十进制数字20,000,需要16位数字:0b100111000100000。 要解决此问题,使用十六进制数系统(也称为基数16(或十六进制))很方便。 在以16为底的数字中,每个数字包含16个值。 对于从0到9的值,将使用与以10为底的字符相同的字符,但是对于其余6个值,将以字母的前6个字母(从A到F)的形式使用替换。

十六进制系统中的帐户执行如下:0 1 2 3 4 5 6 7 8 9 ABCDEF 10 11 12,等等。 在十六进制中,数十,百等不具有与十进制相同的含义,因此人们分别发音数字。 例如,$ A57被大声发音为“ A-5七”。 为了清楚起见,您还可以添加十六进制,例如“ A五七十六进制”。 在十六进制系统中,十进制数字20,000的等效值为$ 4E20-与二进制系统的16位相比,它的格式更紧凑。

我认为选择十六进制系统是因为从二进制到十六进制的转换非常自然,反之亦然。 每个十六进制数字对应于相似二进制数的4位(4位)。 2个十六进制数字组成一个字节(8位)。 单个十六进制数字可称为半字节,有些人甚至通过y将其写为“半字节”。

每个十六进制数字是4个二进制数字
十六进制57
二元101001010111

除非另有说明,否则在编写C代码时,该数字应为十进制(以10为底)。 为了告诉C编译器该数字是二进制的,我们将数字零和字母b小写0b1101101 ,如下所示: 0b1101101 。 十六进制数可以用C代码编写,方法是在零和x的开头加小写: 0xA57 。 某些汇编语言使用美元符号$: $A57表示一个十六进制数字。

如果您考虑一下,二进制,十六进制和十进制数之间的联系是显而易见的,但是对于第一位在计算机发明之前就已经想到了这一点的工程师来说,这应该已经成为了一个有识之士。

了解所有这些吗? 太好了

处理器简介


如果您已经知道这一点,则可以安全地跳过本节。

中央处理器(CPU)是设计用于运行程序的机器。 CPU的基本块是寄存器和指令。 作为软件开发人员,您可以将这些寄存器视为变量。 在我们的8080处理器中,除其他寄存器外,还有称为A,B,C,D和E的8位寄存器。这些寄存器可以解释为以下C代码:

 unsigned char A, B, C, D, E; 

所有处理器还具有程序计数器(Program Counter,PC)。 您可以将其用作指针。

 unsigned char* pc; 

对于CPU,程序是十六进制数的序列。 8080中的每个汇编语言指令对应于程序中的1-3个字节。 为了找出哪个命令对应哪个编号,处理器手册(或Internet上有关8080处理器的任何其他信息)很有用。

命令(指令)的名称通常是这些命令执行的操作的助记符。 在8080中加载的助记符是MOV(移动),并且ADD用于执行加法。

例子


指令计数器指示的当前存储值为0x79。 这符合8080处理器的MOV A,C指令MOV A,C代码中的汇编代码看起来像A=C;

相反,如果PC中的值为0x80,则处理器将执行ADD B 在C中,它对应于字符串A = A + B;

可以在此处找到8080处理器指令的完整列表。 为了实现我们的仿真器,我们将使用此信息。

时机


在CPU中,每条指令的执行需要一定的时间(定时),以周期为单位。 在现代处理器中,由于时序取决于许多不同方面,因此很难获得此信息。 但是在像8080这样的老式处理器中,时间是恒定的,并且该信息通常由处理器制造商提供。 例如,从寄存器到寄存器MOV的传输指令需要1个周期。

时序信息对于在处理器中编写高效的代码很有用。 程序员可能会寻求避免花费很多周期才能完成的指令。

对我们来说更重要的是,我们将使用时序信息来模拟处理器。 为了使游戏以与原始版本相同的方式工作,必须以正确的速度执行指令。 一些仿真器为此付出了很多努力,但是当我们做到这一点时,我们将不得不决定要获得什么精度。

逻辑运算


在关闭二进制和十六进制数字主题之前,我们应该讨论逻辑运算。 您可能已经习惯于在代码中使用逻辑,例如,在if ((conditionA) and (conditionB))这样的结构中使用逻辑。 在直接与硬件一起使用的程序中,您通常必须操纵单个数字位。

AND运算


这是两个个位数之间的AND运算(AND)(真值表)的所有可能结果。

Xÿ结果
000
01个0
1个00
1个1个1个

仅当两个值均等于1时,AND的结果才等于1。 当我们将两个数字与AND运算相结合时,一个数字的每个位的AND就是另一个数字的对应位的AND。 结果存储在目标号码的此位中。 只看一个例子可能更好:

二元十六进制
来源x01个1个01个01个1个$ 6B
来源y1个1个01个001个0$ D2
x与y01个00001个0$ 42

在C语言中,逻辑AND运算符是一个简单的&符号。

或(OR)


OR操作以类似的方式工作。 唯一的区别是,如果x或y值中的至少一个等于1,则结果将等于1。

Xÿ结果
000
01个1个
1个01个
1个1个1个

二元十六进制
来源x01个1个01个01个1个$ 6B
来源y1个1个01个001个0$ D2
1个1个1个1个1个01个1个$ Fb

在C语言中,逻辑“或”运算由竖线“ |”表示。

为什么这很重要?


在许多较旧的处理器中,尤其是在街机中,游戏通常只需要使用该数字的一点即可。 通常会有类似的代码:

  /*  1:     */ char *buttons_ptr = (char *)0x2043; char buttons = *buttons_ptr; if (buttons & 0x4) HandleLeftButton(); /*  2:  LED-    */ char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led | 0x40; //,  LED   6 *LED_pointer = led; /*  3:   LED- */ char * LED_pointer = (char *) 0x2089; char led = *LED_pointer; led = led & 0xBF; //  6 *LED_pointer = led; 

在示例1中,在内存中分配的地址$ 2043是控制面板上按钮的地址。 该代码读取并响应按下的按钮。 (当然,在《太空侵略者》中,该代码将采用汇编语言!)

在示例2中,游戏要点亮一个LED指示器,该指示器位于内存中分配的$ 2089地址的第6位。 该代码应读取现有值,仅更改一位,然后将其写回。

在示例3中,您需要关闭示例2中的指示器,因此代码应重置地址$ 2089的位6。 这可以通过对指示符控制字节执行“与”运算来实现,其值只有第6位为零,因此,我们将仅影响6,而其余位保持不变。

这通常称为“蒙版”。 在C语言中,通常使用NOT运算符来写一个掩码,用代号(“〜”)表示。 因此,我没有写0xBF ,而是写~0x40并得到了相同的数字,但并没有付出很多努力。

汇编语言简介


如果您阅读本教程,则可能熟悉计算机编程,例如Java或Python。 这些语言使您仅用几行代码即可完成许多工作。 如果代码可以在尽可能少的行中完成尽可能多的工作,甚至可以使用内置库的功能,则被认为是巧妙编写的代码。 这样的语言称为“高级语言”。

相比之下,在汇编语言中,没有内置的救生功能,并且可能需要许多简单的代码行来完成简单的任务。 汇编语言被视为低级语言。 在其中,您必须习惯于“必须采取哪些特定步骤顺序才能完成此任务?”的风格的思考。

您需要了解的有关汇编语言的最重要的事情是,每一行都被翻译成一个处理器命令。

从C语言考虑这样的构造:

 int a = b + 100; 

在汇编语言中,必须按以下顺序执行此任务:

  1. 将变量B的地址加载到寄存器1中
  2. 将该存储器地址的内容加载到寄存器2中
  3. 将直接值0x64添加到寄存器2
  4. 将变量A的地址加载到寄存器1中
  5. 将寄存器2的内容写入寄存器1中存储的地址

在代码中,它将如下所示:

  lea a1, #$1000 ;   a lea a2, #$1008 ;   b move.l d0,(a2) add.l d0, #$64 mov (a1),d0 

值得注意的是:

  • 在高级语言中,编译器决定将变量放置在内存中的位置。 在汇编器中编写代码时,您将自己负责将要使用的每个内存地址。
  • 在大多数汇编语言中,方括号表示“此地址处的内存”。
  • 在大多数汇编语言中,#表示代数,也称为立即数。 例如,在上面示例的第1行中,代码实际上将值#0x1000写入寄存器a1。 如果代码看起来像move.l a1, ($1000) ,则a1将在地址0x1000处接收内存的内容。
  • 每个处理器都有自己的汇编语言,因此很难将代码从一个处理器移植到另一个处理器。
  • 这不是真正的处理器汇编语言,我以它为例。

但是,高级智能程序员和汇编器向导之间有一个共同点。 汇编程序程序员认为,尽可能高效地完成任务并减少使用的指令数量是一种荣幸。 通常,对街机的代码进行了高度优化,并且每个额外的字节和周期都会榨出所有汁液。

堆栈


让我们更多地讨论汇编语言。 在任何相当复杂的计算机程序中,都使用汇编程序子例程。 大多数CPU具有称为堆栈的结构。

想象一下堆栈形式的堆栈。 如果需要保存一个数字,可以将其放在堆栈的顶部。 当我们需要将其取回时,我们从堆的顶部取出。 汇编程序员将弹出堆栈中的数字称为“ push”,将其弹出即称为“ pop”。

假设我的程序需要调用子例程。 我可以编写类似的代码:

  0x1000 move.l (sp), d0 ;  d0   0x1004 add.l sp, #4 ;     0x1008 move.l (sp), d1 ;  d1   0x1010 add.l sp, #4 ;  .. 0x1014 move.l (sp), a0 0x1018 add.l sp, #4 0x101C move.l (sp), a1 0x1020 add.l sp, #4 0x1024 move.l (sp), #0x1030 ;   0x1028 add.l sp, #4 0x102C jmp #0x2040 ;   - 0x2040 0x1030 move.l a1, (sp) ;    0x1034 sub.l sp, #4 ;    0x1038 move.l a0, (sp) ;    0x103c sub.l sp, #4  .. 

上面显示的代码将值d0,d1,a0和a1压入堆栈。 大多数处理器使用堆栈指针。 按照惯例,它可以是常规寄存器,用作堆栈指针,也可以是具有某些指令功能的特殊寄存器。

在68K系列处理器上,堆栈指针仅由约定确定;否则为常规寄存器。 在我们的8080处理器中,SP寄存器是一个特殊的寄存器。 它具有PUSH和POP命令,仅需一条命令即可从堆栈中写入和弹出。

在我们的模拟器项目中,我们不会从头开始编写代码。 但是,如果您需要用汇编语言分析程序,那么最好学会识别这种构造。

高级语言


用高级语言编写程序时,每次函数调用都会执行所有保存和恢复寄存器的操作。 我们不会考虑它们,因为编译器会处理它们。 用高级语言进行的函数调用会占用大量内存和处理器时间。

您是否曾在无限循环中调用子例程时遇到程序崩溃的情况? 之所以会发生这种情况,是因为每个函数调用都会将寄存器值压入堆栈,并且在某个时刻内存用完了堆栈。 (如果堆栈变得太大,则称为堆栈溢出或堆栈溢出。)

您可能听说过内联函数。 它们通过在调用函数中包含例程代码来避免保存和恢复寄存器。 代码变大了,但是由于这个原因,一些命令和对内存的读/写操作得以保存。

通话约定


当编写仅调用代码的汇编程序时,您可以自己决定例程之间如何通信。 例如,例程完成后如何返回调用函数? 一种方法是将返回地址写入特定的寄存器。 另一个是将返回地址放在堆栈顶部。 很多时候,决定取决于处理器支持什么。 8080具有一个CALL命令,可将函数的返回地址压入堆栈。 也许您将使用此8080命令来实现子例程调用。

还需要再做一个决定。 寄存器保存是调用函数或子例程的责任吗? 在上面的示例中,寄存器由调用函数存储。 但是,如果我们有32个寄存器,该怎么办? 当例程仅使用一小部分寄存器时,保存和恢复32个寄存器将浪费时间。

权衡可能是混合方法。 假设我们选择一种策略,其中例程可以使用r10-r32寄存器而不保存其内容,但是不能破坏r1-r9。 在类似的情况下,调用函数知道以下内容:

  • 从函数返回时,r1-r9的内容将保持不变
  • 我不能依靠r10-r32的内容
  • 如果在调用子例程后需要r10-r32中的值,那么在调用它之前我需要将其保存在某个地方

同样,每个例程都知道以下内容:

  • 我可以摧毁R10-R32
  • 如果要使用r1-r9,则需要先保存内容并还原它,然后再返回调用我的函数

阿比


在大多数现代平台上,此类策略是由工程师创建的,并发布在称为ABI(应用程序二进制接口)的文档中。 多亏了本文档,编译器创建者才知道如何编译可以调用其他编译器编译的代码的代码。 如果要编写可在这种环境下运行的汇编程序代码,则需要了解ABI并根据它编写代码。

当您无权访问源代码时,了解ABI还可以帮助调试代码。 ABI定义了函数参数的位置,因此在考虑任何子程序时,您可以检查这些地址以了解传递给函数的内容。

回到模拟器


大多数手写汇编代码(尤其是针对较旧的处理器和街机游戏的代码)都不遵循ABI。 程序是汇编的,可能没有很多例程。 每个例程仅在紧急情况下保存和恢复寄存器。

如果您想了解程序的功能,最好先标记目标为CALL命令的地址。

Source: https://habr.com/ru/post/zh-CN418635/


All Articles