这是一篇转载的翻译,放在这里仅为了保持系列的完成性,也是一篇个人感觉比较好的模拟器编写入门文章
如何编写模拟器
我在写这篇文章之前收到很多人的邮件,他们希望编写一个模拟器却不知从何下手。文章中提到的任何观点和建议都来自我个人,切勿将其当成绝对真理。我的文章主要讨论“解释型”模拟器,而不是“编译型”模拟器,这是因为我对重编译技术没有太多经验。我在文章中列出一两个链接,读者可以查找这些技术的相关信息。
如果你觉得这篇文章还有不足或是有所指正,请你通过邮件发给我。我不回复那些来争吵的,问无聊问题的,还有来要ROM映像的邮件。我在这篇文章的资源列表中丢失了几个重要的FTP和WWW网址,如果你知道任何有价值的网址也可以告诉我。另外FAQ中如果有问题也可以给我发邮件。
这篇文章由Bero翻译为日文,由Jean-Yuan Chen翻译为www.ppxbbs.com/SiliconValley/Vista/8177/howemu.htm中文,由Maxime Vernier翻译为法文,另一个较老的法文版本由Guillaume Tuloup翻译(链接可能已经失效了),HOWTO部分的西班牙版本由Santiago Romero翻译,另外,Mauro Vilani将其翻译为意大利语,巴西葡萄牙语版本由Leandro翻译。
目录
你决定编写一个软件模拟器了吗?很好,这篇文章也许可以给你一些帮助,它包含编写模拟器时常见的技术问题,提供了模拟器内部的“设计蓝图”,一定程度上可供你借鉴。
常见问题
· 可以模拟什么?
· “模拟”的概念,与“simulation”的区别。
· 对专有的硬件模拟是否合法?
· “解释型”模拟器的概念,与“编译型”模拟器的区别。
· 编写模拟器从何入手?
· 使用何种编程语言?
· 如何获取所模拟硬件的信息?
实现
· 如何模拟CPU?
· 如何实现对模拟内存的访问?
· 轮转任务的概念。
编程技术
· 如何优化C代码?
· 什么是大小端?
· 如何让程序可移植?
· 如何让程序模块化?
· 更多
可以模拟什么?
基本上只要是微处理器内部的任何部件都可以模拟。当然只有那些运行或多或少程序的设备才有模拟的需要,包括:
· 计算机
· 计算器
· 电子游戏机
· 街机
· 其它
有必要知道你可以模拟任何一种计算机系统,不论它有多复杂(例如Commordore的Amiga计算机),只不过这种模拟的性能可能非常低。
模拟的概念,与“simulation”的区别?
模拟试图去模拟一个设备的内部设计,而“simulation”则模拟设备的功能。例如,一个程序能模拟Pacman(即“吃豆先生”,译者注)街机的硬件,并能运行Pacman的ROM,就可以称之为模拟器。而为你机器所编写的Pacman游戏,只是使用了和真实街机相同的图像,就称之为simulator。
对专有硬件的模拟是否合法?
尽管这个问题处于一种“灰色”地带,似乎只要模拟器包含的信息没有非法手段得到,对专有硬件的模拟应该是合法的。但要注意的是,如果发布受版权保护的系统ROM(如BIOS等)就是非法行为。
“解释”模拟器的概念,与“编译型”模拟器的区别。
模拟器可以采用三种基本方案,加以组合可以取得更好的效果。
· 解释型
模拟器将所模拟的代码按字节从存储器中读入,译码并执行对所模拟的寄存器、存储器和I/O的操作。这种模拟器的大致算法如下:
while(CPUIsRunning)
{
Fetch OpCode
Interpret OpCode
}
这种模拟器的优点在于:便于调试、移植和同步(可以方便地计算出时钟周期,并将模拟操作绑定到时钟周期上)。
而明显的缺点就是性能低,解释过程占用了大量的CPU时间,要获得比较良好的速度就需要在相对较快的机器上运行。
· 静态重编译
这种技术是将所模拟的程序翻译为本机的汇编代码,生成一个可执行文件,可以在本机运行而无需任何工具。虽然静态重编译看上去不错,但并不总是可以实现的。例如,无法对自修改代码进行静态重编译,因为除非去运行这些代码,否则无法知道它们最终变成什么形态。为了避免这种情况,可以将静态重编译器与解释器、动态重编译器结合使用。
· 动态重编译
动态重编译与静态重编译原理上是相同的,但出现在程序的执行过程中。与一次重编译所有代码不同,在执行过程中遇到CALL或JUMP指令时才进行重编译,可以与静态重编译结合来提高速度。你可以从Ardi的白皮书中获得动态重编译技术的更多内容,他是Macintosh重编译型模拟器的作者。
编写模拟器从何入手?
要编写一个模拟器,必须掌握计算机编程和数字电路的一般知识,另外有汇编编程经验会更加方便。
1 选择一种编程语言。
2 查找所模拟硬件的所有信息。
3 编写CPU模拟代码或得到已有的CPU模拟代码。
4 编写所模拟硬件其它部分的设计代码,至少完成一部分。
5 编写一个内置调试器非常有用,可以中止模拟并观察程序执行的状态。另外需要一个所模拟系统的反汇编器,如果没有可以自己写一个。
6 在模拟器上运行运行程序。
7 用反汇编器和调试器来观察程序使用硬件的情况,适时地调整自己的代码。
使用何种编程语言?
使用最多的两种就是C和汇编,下面是它们各自的优缺点:
· 汇编语言
+ 一般可以生成高速代码。
+ 可以将所模拟CPU的寄存器直接存放到宿主机CPU的寄存器上。
+ 模拟指令的大部分操作码与宿主机操作码相似。
- 代码不能移植,无法运行在不同架构的机器上。
- 调试和维护代码比较困难
· C
+ 代码可以移植到不同的机器和操作系统中。
+ 调试和维护代码相对容易。
+ 对真实硬件工作的种种假设可以快速地进行测试。
- C生成的代码通常比汇编代码慢。
掌握使用的编程语言对编写一个模拟器是相当重要的,工程越复杂,就要使代码更加优化,从而获得更快的速度。计算机模拟器绝不是用来学习编程语言的工程例子。
如何获取所模拟硬件的信息
下面是可能需要查找资料的地方:
新闻组
· comp.emulators.misc
这是一个讨论计算机模拟的新闻组,许多模拟器作者都会阅读,虽然里面也有一定程度的水分。在发言之前可以先阅读里面的FAQ。
· comp.emulators.game-consoles
和comp.emulators.misc相似,只不过主要讨论电子游戏机模拟器。同样在发言之前阅读FAQ。
· comp.sys./emulated-system/
comp.sys.*的下级栏目包含各种不同的计算机,从中可以获得大量有用的技术信息。可以按如下格式输入:
comp.sys.msx MSX/MSX2/MSX2+/TurboR computers
comp.sys.sinclair Sinclair ZX80/ZX81/ZXSpectrum/QL
comp.sys.apple2 Apple ][
etc.
发言之前请阅读相关的FAQ。
· alt.folklore.computers
· rec.games.video.classic
FTP
Console and Game Programmingsite in Oulu, Finland
Arcade Videogame Hardwarearchive at ftp.spies.com
Computer History and Emulationarchive at KOMKON
Arcade Videogame Hardwarearchive at ftp.spies.com
Computer History and Emulationarchive at KOMKON
WWW
如何模拟CPU?
对于那些想编写自己的CPU模拟器内核或是对模拟器工作原理感兴趣的人,我提供了一个C编写的典型CPU模拟器框架。在实际应用中,你可能需要增减部分内容。
Counter=InterruptPeriod;
PC=InitialPC;
for(;;)
{
OpCode=Memory[PC++];
Counter-=Cycles[OpCode];
switch(OpCode)
{
case OpCode1:
case OpCode2:
...
}
if(Counter<=0)
{
/* Check for interrupts and do other */
/* cyclic tasks here */
...
Counter+=InterruptPeriod;
if(ExitRequired) break;
}
}
首先,为CPU周期计数器(Counter)和程序计数器(PC)赋一个初值:
Counter=InterruptPeriod;
PC=InitialPC;
周期计数器用来存放离下一次可能中断发生所剩的CPU时钟周期,注意的是当它越界时,中断不一定必然发生。周期计数器有多种用途,比如用作同步时钟,或是更新屏幕的扫描线。另外,PC存放模拟器下一次读取的操作码在存储器的地址。
赋完初值后,开始主循环:
for(;;)
{
循环也可能这样实现:
while(CPUIsRunning)
{
CPUIsRunning是布尔变量,这样有一个好处,就是可以通过设置CPUIsRunning=0随时退出循环。但是,每一轮循环时对这个变量的检查也会消耗大量CPU时间,所以还是尽可能避免采用这种方法。当然,不要这样来实现循环:
while(1)
{
因为这样的话,某些编译器会生成判断“1”是“真”还是“假”的代码,你当然不愿意编译器在每一轮循环中做这些无用功。
现在,在循环中首先要读取下一条指令的操作码,并修改程序计数器PC:
OpCode=Memory[PC++];
虽然这是读取所模拟存储器最简单的方法,但并不总是可行的,文章之后会再增加更多的通用方法。
读取操作码之后,周期计数器要减去执行该指令所需的时钟周期数:
Counter-=Cycles[OpCode];
数组Cycles包含执行每条指令所需的CPU周期数。要注意某些指令(如条件转移或子程序调用)根据操作数的不同,所需的周期数也不同,可以在以后的代码中进行调整。
现在要做的是对指令译码并执行:
switch(OpCode)
{
Switch结构通常被误认为缺乏效率,因为会被编译一系列的if()…else if()…语句。这种情况在少量的case分支时确实存在,但是大量的分支结构(100-200甚至更多)会被编译生成跳转表,这样是非常高效的。
译码有另外两种方法:一种是生成一个函数表,并调用对应的函数,由于这种方法采用间接调用函数,效率比使用Switch要低;另一种方法是建立一个标号表,使用goto语句来实现,这种方法比用Switch要快一些,但它只适用于支持“precomputed labels”的编译器,其它编译器则不允许创建地址标号表。
成功译码并执行之后,要检查是否有中断发生,同时也执行一些需要系统时钟同步的任务。
if(Counter<=0)
{
/* Check for interrupts and do other hardware emulation here */
...
Counter+=InterruptPeriod;
if(ExitRequired) break;
}
有关周期性任务的内容在文章后面会有介绍。
要注意这里并不是简单地采用Counter=InterruptPeriod来赋值,而是采用Counter+=InterruptPeriod:这样做可以使周期计数更加精确,因为周期计数器有可能会出现负值的情况。
再看这一行:
if(ExitRequired) break;
由于每轮循环都检查是否退出开销太大,所以只在周期计数器越界时检查:这样当ExitRequired=1时模拟始终会退出,而且不会消耗太多CPU时间。
如何访问所模拟的存储器?
要访问所模拟的存储器,最简单的办法就是将其视为一个字节数组,访问的方法很简单:
Data=Memory[Address1]; /* Read from Address1 */
Memory[Address2]=Data; /* Write to Address2 */
但是这种简单的方法在下面几种情况并不总是适用的:
· 页面存储器
地址空间被分成若干个可切换的页面(或者块),当地址空间比较小(64KB),这种存储方法用来扩展存储空间。
· 镜像存储器
同一块存储空间可以由若干个不同地址来访问。如在地址$4000写入的数据可能会同时出现在地址$6000和$8000中。ROM也可以利用不完全译码映射到镜像存储空间中。
· ROM保护
某些存储在卡带上的软件(如MSX游戏)会试图向自身的ROM写入数据,如果写入成功机器就无法工作,这就经常需要进行复写保护。为了在模拟器上模拟这些软件,需要禁止向ROM中写入数据。
· 存储空间映射I/O
系统中会有一些I/O设备映射到存储空间,对这些存储空间的访问会产生“特殊效应”,所以需要进行跟踪。
为了应付这些问题,我们引入两个函数:
Data=ReadMemory(Address1); /* Read from Address1 */
WriteMemory(Address2,Data); /* Write to Address2 */
对于像页面访问、镜像和I/O处理等问题,在这些函数内部进行处理。
模拟过程需要频繁地调用ReadMemory()和WriteMemory(),所以在模拟框架中经常大量使用这两个函数,这就要求必须采用尽可能高效的方法去实现它们。下面是这两个函数实现页面地址空间访问的例子:
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,那就试着将函数改为静态函数:一些编译器(如WatcomC)会采用内联的方式对较短的静态函数进行优化。
另外要注意大多数情况下,ReadMemory()被调用的次数会比WriteMemory()多。所以让ReadMomery()尽可能地简短,在WriteMemory()中实现大多数代码。
· 关于存储镜像要注意的一个小问题:
正如之前所讲的,很多计算机都有镜像RAM,在某处写入的值会出现在其它位置。虽然这种情况可以在ReadMemory()中处理,但并不可取,因为ReadMemory()被调用的次数比WriteMemory()要多得多,在WriteMemory()中实现存储镜像要更有效率。
轮转任务的概念
轮转任务是指在所模拟的机器中周期性出现的事件,诸如:
· 屏幕刷新
· 垂直空白(VBlank)和水平空白(HBlank)中断
· 更新定时器
· 更新声音参数
· 更新键盘/游戏杆状态
· 其它
为了模拟这些任务,要将其绑定适当的CPU周期数。例如,假设CPU以2.5MHz运行,显示采用50Hz的刷新频率(PAL视频标准),则每隔
2500000/50 = 50000 CPU cycles
出现一次垂直空白中断。
现在我们假设整个屏幕(包括垂直空白)有256条扫描线,其中212条会显示在屏幕上(另外44条成为垂直空白),这样每隔
50000/256 ~= 195 CPU cyles
必须刷新一条扫描线。
在这之后,必须产生一个垂直空白中断,并且在
(256-212)*50000/256 = 44*50000/256 ~= 8594 CPU cycles
内(垂直空白期)不做任何工作。
对每个任务所需的CPU周期数进行仔细的计算,然后取它们的最大公约数作为中断周期,并绑定到所有任务中(周期计数器越界时并不一定执行这些任务)。
如何优化C代码?
首先,在编译器中选择正确的优化选项可以让代码提高更多额外的性能。根据我的经验,下列几种选项的组合可以获得最快的执行速度:
Watcom C++ -oneatx -zp4 -5r -fp3
GNU C++ -O3 -fomit-frame-pointer
Borland C++
如果你发现上述这些编译器或其它编译器有更好的选项设置,请让我知道。
· 循环展开要注意的小事项:
在优化时选择“循环展开”选项有时很管用,这个选项会试着将比较短的循环展开为顺序代码。但根据我的经验,这个选项并不会提高多少性能,选择该选项反而会在某些十分特殊的情况下破坏你的代码。
优化C代码本身要比选择编译器选项稍微复杂,而且通常依赖于编译代码所用的CPU。下面几条准则一般适用于所有CPU,但是不要把它们当成绝对的真理,因为使用环境可能不同。
· 使用profiler工具!
在一款优秀的profiling工具下运行你的程序(我立刻想到GPROF),会出现很多让你意想不到的有意思的东西。你可能会发现一些看似无关紧要的代**比其它代码更加频繁地被调用,从而导致程序整体运行速度变慢。要提高程序性能,可以对这部分代码进行优化或者直接用汇编重写。
· 避免C++
避免使用任何结构体,这样你不得不用C++编译器而不是C编译器来编译你的程序:C++编译器会生成更多不必要的代码。
· 整数长度
尽量使用CPU所支持的基准长度的整数类型,即用int来代替short或者long。这样编译器在生成最终代码时可以减少那部分用来转换不同长度数据类型的代码,也可以减少存储器访问的时间,因为某些CPU在读写那些长度可以与地址边界对齐的数据时速度最快。
· 寄存器分配
在每个程序块中尽可能地少用变量,将最常用的变量声明为寄存器(虽然大多数新编译器会自动将变量放到寄存器中)。相对于那些只有少量专用寄存器的CPU(如Intel 80x86),这样做对于那些有着大量通用寄存器的CPU(如PowerPC)是更有意义。
· 将小循环展开
如果你正好有一个只执行几次的小循环,那就手动将它展开为一段线性代码,这始终是一个好办法。参见上面有关自动循环展开的注意点。
· 移位和乘除法的比较
当需要乘以(或除以)2^n时始终用移位来代替(如J/128==J>>7),对于大多数CPU来说执行的速度更快。同样地,可以使用位运算AND来代替模运算(如J%128==J&0x7F)。
什么是大小端?
通常根据数据在存储器中的如何存储,可以将CPU分为若干类。除非是某些极特殊的情况,大多数CPU都可以归为以下两种:
· 大端 CPUu将一个字中的高字节存储在存储器的低地址。例如,CPU存储0x12345678,存储器如下:
0 1 2 3
+--+--+--+--+
|12|34|56|78|
+--+--+--+--+
· 小端 CPU将一个字的低字节存储在存储器的低地址中,同样的存储字在存储器的存储方式不同:
0 1 2 3
+--+--+--+--+
|78|56|34|12|
+--+--+--+--+
典型的大端CPU有6809,Motorola 680x0系列,PowerPC和Sun SPARC,小端CPU包括6502(它的后继者65816),Zilog Z80,大多数Intel处理器(包括8080和80x86)和DEC Alpha等。
编写模拟器时,要同时注意所模拟的CPU和宿主机CPU的大小端情况。比如说,想模拟一个小端CPU Z80,它将一个16位字的低字节存储在低地址。如果宿主机使用小端CPU(如Intel 80x86),那么一切都没有问题。如果宿主机是大端CPU(如PowerPC),那么将一个16位的Z80数据存储到存储器中就会出现问题。更糟糕的是,如果程序要同时在这两种架构上运行,你就需要对大小端情况进行识别。
下面是一种处理大小端问题的方法:
typedef union
{
short W; /* Word access */
struct /* Byte access... */
{
#ifdef LOW_ENDIAN
byte l,h; /* ...in low-endian architecture */
#else
byte h,l; /* ...in high-endian architecture */
#endif
} B;
} word;
你会发现,一个字可以直接用W来访问。如果模拟过程需要访问其中单独一个字节,可以使用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");
如何让程序可移植?
待写
如何让程序模块化?
大多数计算机系统由若干个大芯片组成,每个芯片实现一部分系统功能。这样就有了CPU、视频控制器和声音发生器等。这些芯片大都有自己的存储器和连接的其它硬件。
一个典型的模拟器要重现原有系统的设计,就要在单独的模块中实现各个子系统的功能。首先,可以方便调试,因为可以将bug定位到各个模块当中。其次,采用模块化架构可以让你在其它模拟器中重用某些模块。计算机硬件是高度标准化的,你可以在许多不同的计算机模型中找到相同的CPU和视频芯片。模拟出这个芯片一次,显然比在每个使用同种芯片的计算机中重复实现它要容易很多。
©1997-2000 Copyright by Marat Fayzullin