C模拟CPU & 内存——《如何编写计算机模拟器》选节

一、如何模拟一个 CPU?

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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)

1
2
Counter=InterruptPeriod;
PC=InitialPC;

Counter 纪录了到下一次系统中断发生,还剩多少个 CPU 周期。注意当 Counter 过其实,系统中断不必然发生。你可以利用他来处理其事情:像是时钟同步,更新萤幕的扫瞄线等。等等,我们会讨论这些。PC 则纪录了CPU 会从那个内存位址,读取下次的执行的指令。

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

1
2
for (;;)
{

主循环也可以写成这样:

1
2
while (CPUIsRunning)
{

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

1
2
while (1)
{

因为这样写,编译器產生代码,去检查 1 为 “真” 或 “假”,你不会希望在主循环的每个循环,都去执行这多餘的动作。

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

1
OpCode=Memory[PC++];

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

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

1
Counter-=Cycles[OpCode];

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

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

1
2
switch (OpCode)
  {

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

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

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

1
2
3
4
5
6
7
if (Counter<=0)
  {
  /* Check for interrupts and do other hardware emulation here */
  ...
  Counter+=InterruptPeriod;
  if (ExitRequired) break ;
  }

有关周期性的工作,后面会提到。

注意,我们并非直接指定 Counter=InterruptPeriod,而是执行 Counter+=InterruptPeriod,这样会让周期的计算更精确,因为有时候,Counter 会变成负数。

同时,注意这

1
if (ExitRequired) break ;

这个语句如果在每个循环都执行,成本太高,所以只有在中断发生时才检查。这样就可以在 ExitRequired=1 时,停止模拟,但又不会花太大的成本。

 

二、如何存取被模拟的内存?

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

1
2
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 装置,映射到内存位址。存取这样的内存位址,会產生特殊效果,所以必须被追踪。

要成功处理上述问题,我们引进几个函式:

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

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

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

1
2
3
4
5
6
7
8
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 程序?

首先,很多执行效率的增进,只要选对编译器的编译选项,就有了。根据我的经验,下面的编译选项,可以给你的最佳的执行速度:

1
2
3
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)来的强。

●摊平小循环Unroll small loops

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

●算术移位 vs. 乘除法

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

所有的 CPU 通常都根据它们如何储存资料到内存,分为几个等级。除了非常特殊的种类,绝大多数的 CPU 分成两个等级:

High-endian CPU 先存放 higher byte of word。例如,在这样的 CPU 你存放 0×12345678,内存的内容会长像这样:

1
2
3
4
0  1  2  3
+--+--+--+--+
|12|34|56|78|
+--+--+--+--+

Low-endian CPU 先存放 lower byte of word。上述了例子,内存内容会看起来完全不一样。

1
2
3
4
0  1  2  3
+--+--+--+--+
|78|56|34|12|
+--+--+--+--+

典型 High-endian 的 CPU 有 6809,摩罗托拉 680×0 系列,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 都能跑,问题就更复杂了。

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

1
2
3
4
5
6
7
8
9
10
11
12
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 旗标。这裡有如何测试的程序码。

1
2
3
4
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" );

 

原文:http://blog.jobbole.com/32268/

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值