禁止一切未授权的扩散,连接到这个文档,而不要拷贝它.
 

怎样写一个计算机模拟器

 

公告: 此文档翻译自Marat Fayzullin的主页,原文著作权为原作者所有.
中文翻译,则为译者 张 研[zhyan@usa.net]所有.欢迎传阅,但不可作为商业用途!

我写这个文档是由于收到了很多人的email,这些人想写一个或另一个计算机模 拟器又不知从那里开始.以下的文档中的任何意见和建议都是我的个人见解, 可能并不绝对正确.这个文档主要包括被称为"解释(interpreting)"的模拟器, 它恰 好和"编译(compiling)"相反.我在重编译技术上没有太多的经验.这个文 档中提供了一两个地方,在那里你可以找到关于这些技术的信息.

如果你认为这个文档遗漏了一些东西或者想要更正,有时间的时候可以把你的 见解email给我.但是,我不回答白痴和那些要求提供ROM映像的请求.在这个文 档的末尾我遗漏了一些重要的FTP/WWW地址,所以如果你知道一些有价值的 地址,请告诉我.同样,一些常见问题也没有包含在这个文档中.

这个文档由Bero, 翻译成日文 .


目录

你决定写一个软件模拟器吗? 很好,那么这个文档也许会给你一些帮助.它包含 了人们问及的模拟器的几个共同的技术问题. 它也提供了模拟器的内部"蓝图" ,你可以按以下的步骤理解.

什么能够被模拟?

基本上, 任何内部有微处理器的设备都可以被模拟. 当然,只有那些运行着多 少有些灵活性的程序的计算机我们有兴趣模拟它.它们包括: 非常有必要指出你可以模拟任何计算机系统, 即使它非常复杂(诸如 Commodore Amiga computer). 但是这种模拟器的性能可能非常低.


什么是"模拟(emulation)",它和"仿真(simulation)"有什么不同?

模拟(Emulation)是试图模仿一个设备的内部设计.仿真(Simulation)是试图模仿 一个设 备的功能.例如,一个程序模仿Pacman 街机硬件并且在这个模拟器执行真 Pacman ROM. 一个为你的计算机写的Pacman游戏但它的图案和真的街机类似, 这就是仿真器(simulator).


模拟一个有专利保护的硬件合法吗?

虽然这件事在"灰色"("gray"area)说谎,但很显然模拟一个有专利保护的硬件 是合法的,只要这些信息不是通过非法手段得到的.你应该知道和模拟器一起扩 散被版权保护的系统ROM (BIOS, .) 是违法的.


什么是"解释模拟器(interpreting emulator)" ,它和"重编译

模拟器(recompiling emulator)"有什么不同?

这里有三个可以模拟器方案,它们可以组合以达到最好的效果.
while(CPUIsRunning)
{
  Fetch OpCode
  Interpret OpCode
}
加上那些容易调试,可移植的和容易同步的代码(你可以简单地计算通 过的时钟周期并且结合你模拟的周期计数).

一个很明显的缺点是性能. 这种解释(interpretation)消耗很多的CPU 时间, 并且你可能需要相当快的计算机才能在满意的速度上运行你的 代码.


我想写一个模拟器,应该从那里开始?

为了写一个模拟器,你必须对计算机编程和数字电子有全面的知识.汇编语言的经 验也会带来很多方便.
  1. 选择使用一种编程语言.
  2. 找到所有关于被模拟硬件的可用信息.
  3. CPU模拟器或者从CPU模拟器取得现存的代码.
  4. 写一些非正式的代码去模拟硬件的其他部分,至少是一部分.
  5. 在这一点上,写一个内建的调试器是非常有用的,它可以允许你停下来并且观
  6. 察程序正在干什么. 你可能同样需要这个被模拟系统的汇编语言的反汇编程
  7. . 如果什么也没有,写一个自己的.
  8. 试着在你的模拟器上执行程序.
  9. 使用反汇编程序和调试器观察程序怎样使用硬件并适当调整你的代码.

我应该用哪种编程语言?

最明显的是在C语言和汇编语言中二者选其一.下面是二者的优缺点:
+ 一般来说, 允许处理更快的代码.
+ CPU寄存器可以被直接用于存储被模拟的CPU的寄存器.
+ 很多被模拟CPU的操作码(opcodes)可以被模拟成CPU类似的操作码(opcodes)
- 代码不能被移植,例如它不能在结构不同的计算机上运行.
- 调试和维护代码很困难.
+ 代码可以被移植,这样就可以工作在不同的计算机和操作系统上.
+ 调试和维护代码相对容易.
+ 硬件是怎样真正工作的不同假设可以很快的被测试.
- C 一般来说比纯汇编代码慢.
熟练掌握选择的语言对写一个工作的模拟器是绝对必要的,因为它是一个复杂的项目,并且你的代码应该优化得尽可能的快. 计算机模拟器肯定不是学习一门语言的项目.


从那里取得有关模拟器的信息?

下面是一些地方的列表,你应该看一看.

新闻组

comp.sys.* 层次包含特定计算机的新闻组. 你可以通过阅读这些新闻组获得许多有用的技术 信息.

典型的例子:

comp.sys.msx       MSX/MSX2/MSX2+/TurboR 计算机
comp.sys.sinclair  Sinclair ZX80/ZX81/ZXSpectrum/QL计算机
comp.sys.apple2    Apple ][ 计算机
etc.
在给新闻组发信之前请阅读相应的FAQs.  

FTP

Console and Game Programming site in Oulu, Finland Arcade Videogame Hardware archive at ftp.spies.com Computer History and Emulation archive at KOMKON

WWW

comp.emulators.misc FAQ My Homepage Arcade Emulation Programming Repository Emulation Programmer's Resource


怎样模拟一个CPU?

首先,如果你只想模拟一个标准的Z80或者6502 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;
Counter 包含留给下一个估计的中断处理的CPU周期数. 注意当这个计数器到指定值时中断 不必发生: 你能把它用于很多其他目的, 诸如同步时钟, 或者更新屏幕上的扫描线. 更多 的会在后面提到. PC包含我们模拟的CPU将要读取的下一条操作码的存储器地址.

初始化值定义以后,我们开始主循环:

for(;;)
{
注意这循环也可以这样执行
while(CPUIsRunning)
{
这里CPUIsRunning 是一个布尔变量. 这有很多优点,因为你可以通过设置CPUIsRunning=0 任何时候终止这个循环. 不幸的是每一遍检查这个变量时会消耗一些CPU 时间,如果可能的 话应该避免.同样, 不要这样执行这个循环
while(1)
{
因为在这种情况下, 一些编译器会生成检查1是真是假的代码. 你当然不想编译器在每次循 环都做这些无用的工作.

现在,当我们在循环里的时候,第一件事就是读下一个操作码, 并且修改程序计数器:

OpCode=Memory[PC++];
注意这是最简单最快速地从存储器读操作码的方法, 但这不总是切实可行的.一个更普遍的 访问存储器的方法包含在后面的文档中.

操作码被读取之后,我们要根据这个操作码需要的周期,减少CPU周期计数值:

Counter-=Cycles[OpCode];
Cycles[] 数组应该包含每条操作码的CPU周期数. 注意一些操作码(诸如条件跳转或者 子程序调用)消耗的周期数依赖于它们的参数. 但这以后可以在代码上被调整.

现在到解释操作码并执行它的时候了:

switch(OpCode)
{
switch()结构通常被误解为效率很低, 因为它编译进入一个if() ... else if() ... 句链内. 对于少量的case语句结构来说确实是这样的, 对于数量多的 (100-200 或者更多) cases 通常编译成跳转表, 显然这使得它们效率很高.

有两个替换的方法来解释操作码. 第一种方法是产生一个函数表并且调用合适的函数. 种方法显然比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: 这是周期计数更精确, 因为在Counter中的周期计数可能是负数.

同样, 看看这行.

if(ExitRequired) break;
由于在每个循环通过时检查是否退出太浪费CPU时间了, 所以我们只在Counter到指定值时检查: 当你设置ExitRequired=1, 将退出模拟器.但着不会消耗太多的CPU时间.


怎样处理访问被模拟的存储器?

最简单的访问被模拟的存储器方法是把它视为一个无格式的字节(字等等)的数组. 于是访问它们很容易:
  Data=Memory[Address1]; /* Read from Address1 */
  Memory[Address2]=Data; /* Write to Address2  */
但是由于下面的原因这种简单的访问存储器的方法并不总是可能的: 为了处理这些问题,我们介绍两个函数:
  Data=ReadMemory(Address1);  /* Read from Address1 */
  WriteMemory(Address2,Data); /* Write to Address2  */
所有的特殊处理诸如页访问,镜像, I/O 处理, 等等., 在这些函数内部处理.

ReadMemory() WriteMemory() 通常在模拟器上带来间接的开销,因为它们被频繁地调用. 所以, 它们必须被作成尽可能的t高效. 这是一个这些函数访问分页地址空间的例子:

static inline byte ReadMemory(register word Address)
{
  return(MemoryPage[Address13][Address&0x1FFF]);
}

static inline void WriteMemory(register word Address,register byte Value)
{
  MemoryPage[Address13][Address&0x1FFF]=Value;
}
注意inline 关键字. 它告诉编译器把函数嵌入到代码当中去, 而不是产生对它的调用. 如果编 译器不支持inline 或者 _inline, 试着产生static型函数: 一些编译器 (例如WatcomC)会通过 内嵌优化short static函数.

同样, 记住在大多数情形下ReadMemory()被调用的频率比WriteMemory()被调用的频率更高. , WriteMemory()执行大多数代码值得的, 留给ReadMemory()的代码尽可能的短小.


周期任务: 它们是什么?

周期任务实在被模拟的机器上周期性地发生的事件,诸如: 为了模拟这些任务, 让它们依靠CPU周期数. 例如, 假定CPU 2.5MHz时钟下工作并且显示器 使用50Hz刷新频率(标准的PAL视频), VBlank 中断将在每次都发生.
       2500000/50 = 50000 CPU 周期
现在, 如果我们假定整个屏幕(包括VBlank) 256条扫描线高并且它们中的212条确实显示在 屏幕上(例如: 其他44条落入VBlank), 我们得到,你的模拟器必须每次刷新一条扫描线
       50000/256 ~= 195 CPU 周期
然后, 你应该生成一个VBlank中断,什么也不做直到完成了VBlank, 例如:
       (256-212)*50000/256 = 44*50000/256 ~= 8594 CPU 周期
小心的计算每个任务需要的CPU周期, 然后在InterruptPeriod里使用最小数并且让其他任务 依赖它(它们不必在每次Counter到指定值的时候都执行).


怎样优化C代码?

首先, 大量的额外代码的性能可以通过为编译器选择正确的优化选项来提高. 基于我的经验, 以下的标志组合会带给你最快的执行速度:
Watcom C++      -oneatx -zp4 -5r -fp3
GNU C++         -O3 -fomit-frame-pointer
Borland C++
如果你发现了其中的一个编译器或不同的编译器的更好的选项组合,请让我知道.. 优化C代码本身比选择编译器选项更复杂,并且一般都依赖编译这个代码的CPU. 几个一般的规 则可以用于所有的CPU. 但是不要把它们当作绝对真理, as your mileage may vary: 如果你碰巧有一个小的循环,它就执行几次, 手工把循环展开成一个线性的 代码片段是一个好主意. 参阅上面关于自动展开循环的文档.

1997-1998 Copyright by Marat Fayzullin [fms@cs.umd.edu]

LU LinkUnion Free Advertising Network
Member of LinkUnion - Click Here to Join