琵琶行论坛

 找回密码
 注册

模擬器學習日記 (8) - 模擬器原理

热度 2已有 2736 次阅读2010-2-18 18:59 |个人分类:模擬器研究|

早期在 PC 上写模拟器的牛人,Marat Fayzullin 是其中之一。1997 年,他就已经开发出 fMSX 模拟器,并且以这篇文章 how to write a computer emulator 分享他的知识。中文翻译的网页已经不存在了,可惜。

下面是阅读后的整理:

纲要:
  • 什么可以被模拟?
  • 什么是 emulation,它跟 simulation 有什么不同?
  • 模拟有专利的硬体,是合法的吗?
  • 什么是直译式的模拟器,跟编译式的模拟器有何不同?
  • 我想写一个模拟器,我该从何开始?
  • 我该用哪一种程式语言?
  • 我从哪里可以得到想模拟的硬体的资讯?
实做:
  • 如何模拟一个 CPU?
  • 如何存取被模拟的记忆体?
  • 周期性的运作有哪些?
程式技巧:
  • 如何最佳化 C 程式码?
  • 什么是高低字节顺序?
  • 如何让程式具可移植性?
  • 为何我要模组化我的程式?
-------------------------------------------------------------------------
什么可以被模拟?

基本上,任何东西有微处理器在里面,就可模拟。当然,只有那些可以跑程式装置,我们才有兴趣模拟。包括:
  • 电脑
  • 计算机
  • 游乐器
  • 大型电动
  • 其他......
必须特别注明,你可以模拟任何电脑系统,即是事非常复杂的系统(譬如 Amiga 电脑),但是执行效率可能很低。
-------------------------------------------------------------------------
什么是 Emulation,它跟 Simulation 有什么不同?

Emulation 模拟装置内部的硬体,Simulation 是模拟装置内部的功能。举例来说,一个程式模拟小精灵大型电动的硬体,然后执行小精灵的 ROM,就是个 emulator。一个小精灵的 PC 游戏,就是个 simulator。
-------------------------------------------------------------------------
模拟有专利的硬体,是合法的吗?

这是个灰色地带,只要你不是透过不合法的管道,拿到硬体的资讯,就应该不违法。但是很清楚知道,跟模拟器一起散布有著作权的系统 ROM(例如 BIOS),是违法的。
-------------------------------------------------------------------------
什么是直译式的模拟器,跟编译式的模拟器有何不同?

模拟器有三种设计的方式,这些设计也可以混用,来达到最好的效果。
  • 直译式
    模拟器一个位元又一个位元的,从记忆体读取代码,然后解码,执行对应的暂存器、记忆体、输出入的命令。通用的演算法如下:
    while (CPUIsRunning)
    {
    Fetch OpCode
    Interpret OpCode
    }

    这种设计的好处是,容易除错,容易移植,容易同步(你只需要计算过了多少 CPU 周期,然后让你模拟的其他部份,跟 CPU 同步)。

    这种设计明显的弱点,就是执行效率很差。执行直译会花很多 CPU 时间,你会需要很快的电脑,才能有不错的执行速度。

  • 静态编译式
    这种技术,就是把一支你要模拟的系统的代码,编译成你的电脑的的组合语言。编译的结果,通常是一支你的电脑的普通执行档,不需要额外的工具就可以执行。静态编译,听起来很美好,但通常不可行。例如,你就无法静态编译会自我修改的代码,因为这种代码只有执行时,才会知道内容是什么。为瞭解决上述的问题,或许需要混用直译器,或是动态编译编译器。

  • 动态编译式
    动态编译基本上跟静态编译一样,但动态编译发生在程式执行时。动态编译是在执行到 CALL 或 JUMP 时才编译,取代一开始就编译一整个程式。为了增加执行效率,这种技术常常结合静态编译。你可以读,动态编译式麦金塔模拟器的作者 Ardi,的这篇动态编译白皮书学到更多
-------------------------------------------------------------------------
我想写一个模拟器,我该从何开始?

想要写一个模拟器,你必须懂程式设计,以及数位电子。如果懂得组合语言,会更好。
  1. 选一种程式语言
  2. 找到所有被模拟硬体的所有资讯
  3. 写 CPU 模拟,或是选用一个现成的 CPU 模拟程式
  4. 写个粗略的其他周边硬体的模拟,至少要一部分
  5. 在这个时候,写个内建除错器,让你可以暂停模拟,检查程式执行的结果。你也会需要一个被模拟 CPU 的组合语言反组译器。如果找不到现成的,就自己写一个。
  6. 试着用你的模拟器执行程式
  7. 用除错程式跟反组译器,看看程式到底在干么,然后根据此修改你的模拟器
-------------------------------------------------------------------------
我该用哪一种程式语言?

最常被用到是 C 跟组合语言,各有优缺点。
  • 组合 语言
    + 通用,可以产生速度快的程式码
    + 可以直接使用暂存器,来映射被模拟的暂存器
    + 很多组合语言指令,可以对应到被模拟的组合语言指令
    - 程式是不可移植的,换句话说,你的模拟器,不能在别种 CPU 上跑
    - 很难除错跟维护

  • C 语言
    + 可移植性,所以可以在不同的作业系统上跑
    + 相对容易除错跟维护
    + 对硬体的不同假设,可以很快的测试
    - 通常 C 语言的程式比组合语言的程式慢
要写模拟器,对所选择的语言,瞭解得很透彻,是绝对必要的。因为模拟器的程式很复杂,你要最佳化你的模拟器,让它跑得越快越好。电脑模拟器程式,绝对不是你越来学习程式语言的专案。
-------------------------------------------------------------------------
我从哪里可以得到想模拟的硬体的资讯?

下列地方,你会想去看一看:

网路新闻群组
  • comp.emulators.misc
    这个新闻群组,讨论模拟器一般的问题。许多模拟器作者会订阅,虽然里面杂音很多。如果要贴问题到这个新闻群组,记得先看 c.e.m FAQ 常见问题。
  • comp.emulators.game-consoles
    跟 comp.emulators.misc 一样,不过这个新闻群组,专攻电视游乐器的模拟器。如果要贴问题到这个新闻群组,记得先看 c.e.m FAQ 常见问题。
  • comp.sys./emulated-system/
    comp.sys.* 新闻群组阶层,专攻特定的电脑系统。你阅读这些新闻群组,可以得到有用的技术资料。典型的例子:

    com.sys.msx                           MSX / MSX2 /  MSX2+ / TurboR 电脑
    comp.sys.sinclair                   Sinclair ZX80/ZX81/ZXSpectrum/QL
    comp.sys.apple2                   Apple ][

    如果要发问题到这个新闻群组,记得先看 FAQ
  • alt.folklore.computers
  • rec.games.video.classic
FTP
WWW
-------------------------------------------------------------------------
如何模拟一个 CPU?

首先,如果你需要模拟一个标准的 Z80 或 6502 CPU,你可以使用 Marat Fayzullin 所写的 CPU 模拟器 当然有些限制。

对那些想要自己写 CPU 模拟核心,或是对其中的运作原理感性趣的人,我提供一个用 C 写的范例架构如下,在真正的实做,你或许会考虑略过其中部份,或添加新的部份。

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 周期。注意当 Counter 过其实,系统中断不必然发生。你可以利用他来处理其事情:像是时钟同步,更新萤幕的扫瞄线等。等等,我们会讨论这些。PC 则纪录了CPU 会从那个记忆体位址,读取下次的执行的指令。

在我们给这些设定初始值之后,然后开始进入主回圈:

for(;;)
{

主回圈也可以写成这样:

while(CPUIsRunning)
{

CPUIsRunning 是个布林值,这样写有个好处,你可以在任何时候,设 CPUIsRunning=0,来终止主回圈。然而在每个回圈检查这个变数,会花不少的 CPU,而我们应该尽量减少花费 CPU。同时,不要写成下面这样子:

while(1)
{

因为这样写,编译器产生代码,去检查 1 为 "真" 或 "假",你不会希望在主回圈的每个回圈,都去执行这多余的动作。

现在我们在主回圈内,第一件事,就是去读下一个执行码,然后修改程式位址记数器。

OpCode=Memory[PC++];

注意,这是最简易的方式,来模拟读取记忆体,但并非永远可行。更通用的方式,来存取记忆体,稍后会提到。

在提取操作码后,会从 CPU 周期计数器,扣掉这个指令所需的周期数。

Counter-=Cycles[OpCode];

Cycles[] 表内放的是每个操作码,所需要的周期数。要特别注意,有些指令(例如条件式跳跃,或是呼叫副程式),需要的周期数,是跟操作后面紧接的参数而变动。这个可以在执行指令码时调整。

现在该是解译操作码,然后跟着执行的时候了:

switch(OpCode)
{

有一个错误的观念,认为 switch 叙述事没有效率的,因为会被编译成 if () ...... else if () ........ 叙述。这只有在 case 数量很少的 switch 叙述,才会被这样编译。当有 100 到 200 个 case 的时候,switch 叙述通常会被翻译成 jump 表格,jump 表格,其实蛮有效率的。

有其他两种替代方案,可以用来解译操作码。第一种方法,是建一个函式表,然后呼叫对应的函式。这种方式,比用 switch() 没效率,因为呼叫函式,有额外的开销。第二种方式,是建一个位址的表格,然后使用 goto 叙述。这种方式,稍比用 switch() 有效率一点,但这种方式,只适合用在编译器支援未预定位址表格。其他的编译器,不会允许你这样定义表格。

在成功解译并执行一个操作码后,这时候该去检查有没有任何系统中断发生。这时候,你也可以执行任何需要跟系统时钟同步的工作。

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;

这个叙述如果在每个回圈都执行,成本太高,所以只有在中断发生时才检查。这样就可以在 ExitRequired=1 时,停止模拟,但又不会花太大的成本。
-------------------------------------------------------------------------
如何存取被模拟的记忆体?

模拟记忆体存取最简单的方式,就是把它当成一个摊平的位元组或字元组阵列。如此,存取记忆体,就是一件微不足道的事情:

  Data=Memory[Address1]; /* Read from Address1 */
  Memory[Address2]=Data; /* Write to Address2  */

这种简易的作法,并非永远可行,原因如下:
  • 分页式的记忆体
    记忆体空间,可能被切成小块,变成可以切换的页,就是所谓的 banks。例如常见的,小记忆体位址空间( 64 KB),所使用的扩充记忆体。

  • 映射的记忆体
    这块记忆体空间,可以用数个不同的位址来存取。例如你写资料到位址 $4000,然后你在位址$6000,及位址 $8000,你也可以读到。

  • ROM 的读取保护
    有些存到卡夹的软体(例如 MSX 的游戏),就算你写到 ROM,回传成功,事实上 ROM 上的资料也不会改变。这么做,是为了做软体保护。为了让这样的软体,可以在你的模拟器运行,你需要把 ROM 设成唯读。

  • 记忆体映射到 I/O
    系统可能有 I/O 装置,映射到记忆体位址。存取这样的记忆体位址,会产生特殊效果,所以必须被追踪。
要成功处理上述问题,我们引进几个函式:

  Data=ReadMemory(Address1);  /* Read from Address1 */
  WriteMemory(Address2,Data); /* Write to Address2  */

所有特殊的处理,包括记忆体分页,记忆体映射,I/O 的处理,等等,都在函式内处理。

ReadMemory() 跟 WriteMemory() 对模拟器造成很大的 CPU 负担,因为它们执行的非常频繁。因此
这些函式必须写得越有效率越好。这里有一个存取分页式记忆体的例子:

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,有些编译器(例如 Watcom C)最佳化时,会把短的函式,变成 inline 函式。

同时要记住,通常 ReadMemory() 的呼叫次数,是 WriteMemory() 的好几倍。所以尽量把程式码放到 WriteMemory(),让 ReadMemory() 保持简单。

关于记忆体映射的一个小注记:

之前说过,被映射的记忆体,写入一个位址,可以在其他位址读取。这个功能,可以实做在 ReadMemory(),但是通常我们不这样做,因为 ReadMemory() 比 WriteMemory() 更频繁被呼叫。更有效率的方式,是实做记忆体映射到 WriteMemory()函式。

-------------------------------------------------------------------------
周期性的运作有哪些?

周期性的运作,是被模拟的机器,固定一段时间,就会执行的工作,例如:
  • 萤幕更新
  • VBlank 跟 HBlank 系统中断
  • 更新时钟
  • 更新声音参数
  • 更新键盘跟摇杆状态
  • 其他
为了要模拟这样的运作,你要替它们绑上固定的周期。例如 CPU 假设以 2.5 MHz,并且以 50 Hz 更新显示(PAL 系统),所以 VBlank 系统中断,就会每 5000 CPU 周期,发生一次。

    2500000/50 = 50000 CPU cycles

现在,假设整个萤幕是(包含 VBlank)是 256 条扫瞄线,实际上只有 212 条显示(44 条在 VBlank),我们得到一条扫瞄线 195 个 CPU 周期,更新一次。

     50000/256 ~= 195 CPU cyles

然后,我们应该产生一个 VBlank 系统中断,然后在 VBlank 期间不做任何事情。

     (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。但别把它们当成真理。

  • 使用分析程式
    用分析工具来执行你的程式(第一个就想到 GPROF),或许可以发现你从没怀疑的神奇事情。你会发现毫不起眼的程式,频繁的被执行,拖慢整个程式。最佳化这些程式码,或是用组合语言改写,可以让你的程式执行效率飞耀。

  • 不要用 C++
    不要用任何非用 C++ 不可的架构。C++ 跟纯 C 比起来,额外的开销比较大。

  • 整数的型别
    尽量用你的 CPU 支持的整数型别。举例 int 对比 short 或 long,这会减少编译器产生不同整数行别的转换。

  • 暂存器配置
    尽量减少在程式区块配置太多变数,并且宣告他们为 register (大部分的编译器已经会自动把变数变成 register)。特别是有很多通用暂存器的 CPU (PowerPC)这个优势,就比有专属暂存器(Intel 8086)来的强。

  • 摊平小回圈
    如果你刚好有小回圈会执行好几次,把小回圈摊平成线性执行的程式,是好主意。对照前面提到的编译器自动摊平选项。

  • 算术移位 vs. 乘除法
    尽量用算术移位,如果你乘或除一个数是 2 的 n 次方(J/128==J>>7),算术移位在大多数的 CPU 都比较快。另外用位元的 & 来求余数(J%128==J&0x7F)。
-------------------------------------------------------------------------
什么是高低字节顺序?

所有的 CPU 通常都根据它们如何储存资料到记忆体,分为几个等级。除了非常特殊的种类,绝大多数的 CPU 分成两个等级:
  • High-endian CPU 先存放 higher byte of word。例如,在这样的 CPU 你存放 0x12345678,记忆体的内容会长像这样:
  • Low-endian CPU 先存放 lower byte of word。上述了例子,记忆体内容会看起来完全不一样。
典型 High-endian 的 CPU 有 6809,摩罗托拉 680x0 系列,PowerPC,及升阳的 SPARC。Low-Endian 的 CPU 有 6502,及其后代 65816,及 zilog Z80,绝大多数 Intel CPU (8086,8088),DEC alpha 等。

当我们写模拟器时,必须注意到,你模拟的 CPU,及执行你的模拟器的 CPU 的高低字节。举例,我们想要模拟 low-endian 的 Z80,Z80 会先存 lower byte of word。如果你用的也是 low-endian 的 CPU,例如 intel 8006,那么完全不需要特别处理。但是如果你用的是 high-endian 的 CPU,例如 PowerPC,这时候,要存放 16 bit 的 Z80 资料到记忆体,就会有问题。如果你的程式,必须两种高低字节顺序的 CPU 都能跑,问题就更复杂了。

一种解节高低字节顺序的作法如下:

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 存取整个字节。而每次如果你需要存取个别 byte,用 B.l 及 B.w,来对应高低位元组。

如果你的程式,要在跨平台编译,在程式开始执行前,你也许会想要测试,是否编译有设定正确的 endian 旗标。这里有如何测试的程式码。

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,显示控制器,声音产生器,及其他。有些晶片,有自己的记忆体,及周边的硬体。

一个典型的模拟器,应该重现原有的系统设计,并实做每个子系统的功能,在不同的模组。这样做,首先除错会比较容易,因为问题会被独立在各自的模组里。其次模组化,可以让你在别的模拟器,重复使用你的模组。电脑的硬体,其实标准化成度很高,你可以在不同型号的电脑,发现相同的 CPU,相同的显示控制器。为某个晶片,模组化写一次模拟的程式,会比你每次都重写来的容易。

翻译完毕

评论 (0 个评论)

小黑屋|手机版|Archiver|琵琶行论坛 ( 赣ICP备12000882号 )

GMT+8, 2024-4-20 12:55 , Processed in 0.024745 second(s), 6 queries , Gzip On, MemCached On.

Powered by Discuz! X3.4

Copyright © 2001-2021, Tencent Cloud.

返回顶部