NES模拟器开发 - 初始篇(How To Write a Computer Emulator)

NES模拟器开发 - 初始篇(How To Write a Computer Emulator)

翻译整理 by Yiran Xie

转载请包含此网址

 

这里选最经典的文章:《How To Write a Computer Emulator》作为NES项目开篇的文章进行翻译整理。 

 

前言:

非常经典的入门文章,精炼地描述了一个像NES这样的模拟器应该如何去着手编写。在reference完整的nes模拟器编写的文档中,这篇可以说是必被引用的,当然其中一部分原因是因为它的诞生年代最早,无数模拟器coder都是从这篇文章开始入门的。

 

 

 

1.emulation和simulation的区别

简单说,一个在NES平台上的吃豆人游戏,把它的平台完整模拟出来,然后加载这个原版rom,这就是emulation

而“山寨”一个吃豆人的游戏,使之有相同的游戏模式,这就是simulation

 

 

 

2.模拟器合法吗?

如果模拟器硬件的资料是公开的,则模拟过程合法。而rom本身,是有知识产权的

 

 

 

3.模拟器的编写方式

这里说了intepretation、static recompilation、dynamic recompliation三种。其中常用的是第一种,模式如下:

模拟器从ROM中读取代码,然后经过解码后,执行相应的命令。(更详细的可以看我翻译整理的chip8的教程)

while (CPUIsRunning)

{

    Fetch OpCode

    Interpret OpCode

}

这种设计的好处是容易debug,容易移植,容易同步(只需计算过了多少CPU周期,然后让模拟的其他部件和CPU保持同步);坏处是效率低

 

 

 

4.主要使用的语言

这里作者推荐了汇编和C

 

 

 

5.如何模拟CPU

这里提供了一个框架:

//PC(程序计数器)和Counter(计算CPU剩余周期的)进行初始化
Counter = InterruptPeriod; PC = InitialPC; for(;;) { OpCode = Memory[PC++];//读取opcode Counter -= Cycles[OpCode];//更新counter,Cycles[]中存放的是每个操作吗所需要的周期数 switch(OpCode)//根据opcode执行相应操作 { case OpCode1: case OpCode2: ... } if(Counter<=0) { /* 检查中断和其他周期性的事 */
......
Counter
+= InterruptPeriod; if(ExitRequired) break; } }

其中,

1).Cycles[]中存放的是每个操作吗所需要的周期数。特别注意,有些指令例如conditional jump(条件型跳转)或者跳往subroutine calls(跳往子程序),需要的周期数,可能会因为后面的参数不同而异。

2).在提取操作码后,会从CPU周期计数器(counter)里扣除相应的周期数

3).switch()跳转会建立一个jump table。除了这个方式用于跳转到相应地opcode执行以外,还有两种方法,一种用函数指针表,另一个用label+goto语句

4).如果counter<=0,则我们进入一段代码去做一些周期性的事,例如检查有没有中断,下面会讨论更多的“周期性的事”

 

 

 

 

6.如何访问内存?

最简单的方法是把内存模拟成一维的数组,访问它就很容易了

Data=Memory[Address1]; /* Read from Address1 */

Memory[Address2]=Data; /* Write to Address2  */

但是这种简易的做法,并非永远可行,原因如下:

1).模拟器的内存可能用的是分页(paged memory),这些页也称为banks。这种方法经常被用来扩展内存,尤其当地址空间较小时(例如64kb)

2).内存镜像(mirrored memory)。内存空间的某些区域可能有多个镜像,可以用数个不同的地址来存取。比如写到$4000的数据,会同时出现在$6000和$8000.ROMs可能也会被镜像,因为地址解码方式的不完全

3).只读保护。有一些卡带保存的游戏(例如MSX)会尝试向自己的ROM里写数据,如果这个写操作成功的话,它会拒绝继续执行。这是一种保护的方式。为了让这些软件能被执行,你应该禁止向ROM段进行写操作

4).I/O映射

一些系统中可能会有I/O映射。向内存中的这些指定区域(端口)进行访问可能会带来一些“特殊的效果”,因此这种操作应该被追踪

于是我们编写以下的函数来处理内存访问:

//页式内存的访问
static
inline byte ReadMemory(register word Address) { return(MemoryPage[Address>>13][Address&0x1FFF]);//分为页号,页内便宜 } static inline void WriteMemory(register word Address,register byte Value) { MemoryPage[Address>>13][Address&0x1FFF]=Value; }

由于内存的读写发生是非常频繁地,因此你希望这条指令是高效的。同时为了减少函数跳转的开销,这里用了inline。为了让某些不支持inline或者_inline关键字的编译器也能正确处理,这里让函数变成了static。同时应该能想到,内存read要比write频繁地多,因此一些“脏活累活”应该留给write去做,而让read尽可能的简单,比如mirrored memory产生的额外操作,就应该尽可能地教给write去做。

 

 

 

7.周期性的任务

包括:

-屏幕刷新

-VBlank和HBlank系统中断

-更新时钟

-更新声音参数

-更新输入设备状态

-其他

 

为了要模拟这些任务,应当把各种任务的周期与CPU周期进行绑定。比如,CPU运行在2.5MHz,显示刷新的频率为50Hz(PAL制式的标准)那么意味着每50000(25000000 / 50 )个CPU周期应该发生一次VBlank中断。

现在假设整个屏幕(包含VBlank)有256条横向水平线(scanline),其中212条在屏幕显示的范围内(44条在VBlank),那么横向水平线的刷新率约为195个CPU周期(50000 / 256).

在那之后,我们应该产生一个VBlank系统中断,然后在VBlank期间不做任何事情,即195 * (256 - 212)。

小心地计算每个任务所需要的CPU周期,然后取他们的最大公约数作为中断检查的周期。

 

 

 

8.优化代码

作者这里主要给出了编译优化时使用的参数和一些关于C语言编写的建议。暂时觉得不重要,略过

 

 

 

 

9.大小端系统

例如把0x12345678写入内存,

大端系统(Big-endian):高地址低字节

                       0  1  2  3

                     +--+--+--+--+

                     |12|34|56|78|

                     +--+--+--+--+

小端系统(Little-endian):高地址高字节

                      0  1  2  3

                     +--+--+--+--+

                     |78|56|34|12|

                     +--+--+--+--+

大端的CPU包括6809,Motorola 680x0系列,PowerPC以及Sun SPARC。小端系统包括6502,以及它后续的65816,Zilog Z80和大多数的Intel芯片(包括8080 8086), DEC Alpha等。

 

比如编写Z80 cpu,它是小端的,而你编写的平台也是小端的,那么相安无事。而如果你的平台是大端的,怎么就会出错。

一个解决办法是:

typedef union

{

  short W;        /* Word access */



  struct          /* Byte access... */

  {

#ifdef LOW_ENDIAN

    byte l,h;     /* ...小端系统 */

#else

    byte h,l;     /* ...大端系统 */

#endif

  } B;



} word;

当这样定义以后,每一次访问它时,按照B.l和B.h去分别访问。

测试大小端的一个方法:

  int *T;



  T=(int *)"\01\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";

  if(*T==1) printf("This machine is high-endian.\n");

  else      printf("This machine is low-endian.\n");

 

 

 

 

 

10.使程序模块化

应当把模拟器内核和接口分开,在单独一个文件里调用API实现在某个特定系统上得模拟。模块化的另一个好处是可以分别debug,例如可以先单独编写CPU模拟,进行debug;再写PPU(图像模拟)等等。

你可能感兴趣的:(emulator)