Game boy模拟器:CPU

Posted 妇男主人

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Game boy模拟器:CPU相关的知识,希望对你有一定的参考价值。

本文通过为模拟物理机的每个部分奠定基础,着手实现 GameBoy 模拟的基础。起点是CPU。

模型

计算机的传统模型是一个处理单元,由指令程序告诉它该做什么;程序可能会使用自己的特殊内存进行访问,也可能与普通内存位于同一区域,具体取决于计算机。每条指令都需要很短的时间来运行,并且它们都被一条一条地运行。从 CPU 的角度来看,计算机一打开就会启动一个循环,从内存中获取一条指令,计算出它所说的内容并执行它。

为了跟踪 CPU 在程序中的位置,CPU 保存了一个数字,称为程序计数器 (PC)。从内存中取出一条指令后,PC 会前进由构成该指令的字节数。

原版GameBoy中的CPU是改装的Zilog Z80,所以有以下几点是相关的:

  • Z80 是一个 8 位芯片,所以所有的内部工作都是一次一个字节的;
  • 存储器接口最多可寻址 65,536 字节(16 位地址总线);
  • 程序通过与普通内存相同的地址总线进行访问;
  • 一条指令可以是一到三个字节之间的任何地方。

除了PC之外,CPU内部还有其他一些可以用来计算的数字,它们被称为寄存器:A、B、C、D、E、H和L。每个都是一个字节,所以每一个都可以保存一个从 0 到 255 的值。Z80 中的大多数指令都是用来处理这些寄存器中的值:从内存中加载一个值到一个寄存器中,加减值等等。

如果指令的第一个字节中有 256 个可能的值,那么基本表中就有 256 个可能的指令。该表在Gameboy Z80操作码图中有详细说明。这些中的每一个都可以由 javascript 函数模拟,该函数对寄存器的内部模型进行操作,并对内存接口的内部模型产生影响。

Z80 中还有其他寄存器处理保持状态:标志寄存器 (F),其操作将在下面讨论;以及与 PUSH 和 POP 指令一起使用的堆栈指针 (SP),用于值的基本 LIFO 处理。因此,Z80 仿真的基本模型需要以下组件:

  • 内部状态:
  •  用于保持寄存器当前状态的结构;
    
  •  执行最后一条指令所用的时间;
    
  •  CPU 运行的总时间;
    
  • 模拟每条指令的功能;
  • 将所述函数映射到操作码映射上的表;
  • 与模拟内存对话的已知接口。

内部状态可以保持如下:

内部状态值

Z80 = {
    // Time clock: The Z80 holds two types of clock (m and t)
    _clock: {m:0, t:0},

    // Register set
    _r: {
        a:0, b:0, c:0, d:0, e:0, h:0, l:0, f:0,    // 8-bit registers
        pc:0, sp:0,                                // 16-bit registers
        m:0, t:0                                   // Clock for last instr
    }
};

标志寄存器 (F) 对处理器的功能很重要:它根据上次操作的结果自动计算某些位或标志。 Gameboy Z80 中有四个标志:

  • 零 (0x80):如果上次操作产生的结果为 0,则设置;
  • 操作 (0x40):设置最后一个操作是否为减法;
  • 半进位 (0x20):设置是否在上次操作的结果中,字节的下半部分溢出超过 15;
  • 进位 (0x10):如果最后一次操作产生的结果大于 255(加法)或小于 0(减法),则设置。

由于基本计算寄存器是 8 位的,进位标志允许软件计算出如果计算结果溢出寄存器的值发生了什么变化。考虑到这些标志处理问题,下面显示了一些指令模拟示例。这些例子是简化的,不计算半进位标志。

操作模拟

Z80 = {
    // Internal state
    _clock: {m:0, t:0},
    _r: {a:0, b:0, c:0, d:0, e:0, h:0, l:0, f:0, pc:0, sp:0, m:0, t:0},

    // Add E to A, leaving result in A (ADD A, E)
    ADDr_e: function() {
        Z80._r.a += Z80._r.e;                      // Perform addition
        Z80._r.f = 0;                              // Clear flags
        if(!(Z80._r.a & 255)) Z80._r.f |= 0x80;    // Check for zero
        if(Z80._r.a > 255) Z80._r.f |= 0x10;       // Check for carry
        Z80._r.a &= 255;                           // Mask to 8-bits
        Z80._r.m = 1; Z80._r.t = 4;                // 1 M-time taken
    }

    // Compare B to A, setting flags (CP A, B)
    CPr_b: function() {
        var i = Z80._r.a;                          // Temp copy of A
        i -= Z80._r.b;                             // Subtract B
        Z80._r.f |= 0x40;                          // Set subtraction flag
        if(!(i & 255)) Z80._r.f |= 0x80;           // Check for zero
        if(i < 0) Z80._r.f |= 0x10;                // Check for underflow
        Z80._r.m = 1; Z80._r.t = 4;                // 1 M-time taken
    }

    // No-operation (NOP)
    NOP: function() {
        Z80._r.m = 1; Z80._r.t = 4;                // 1 M-time taken
    }
};

内存接口

可以操作自身内部寄存器的处理器很好,但它必须能够将结果放入内存才能有用。同理,上面的CPU仿真需要一个接口来仿真内存;这可以由内存管理单元 (MMU) 提供。由于 Gameboy 本身不包含复杂的 MMU,因此模拟单元可以非常简单。

此时,CPU 只需要知道存在一个接口即可;Gameboy 如何将内存和硬件组映射到地址总线的细节与处理器的操作无关。CPU 需要进行四项操作:

MMU.js:内存接口

MMU = { rb: function (addr) { /* 从给定地址读取 8 位字节 */ }, rw: function (addr) { /* 从给定地址读取 16 位字 */ }, wb: function (addr, val) { /* 将 8 位字节写入给定地址 */ }, ww: function (addr, val) { /* 将 16 位字写入给定地址 */ } };

有了这些,就可以模拟其余的 CPU 指令。其他几个例子如下所示:

Z80.js:内存处理指令

    // 将寄存器 B 和 C 压入堆栈 (PUSH BC) 
    PUSHBC: function () { Z80._r.sp--;                               // 删除堆栈
	MMU.wb(Z80._r.sp, Z80._r.b);               //写B 
	Z80._r.sp--;                               // 删除堆栈
	MMU.wb(Z80._r.sp, Z80._r.c);               // 写 C 
	Z80._r.m = 3 ; Z80._r.t = 12 ;               // 进行了 3 M 次
    },

     // 从堆栈中弹出寄存器 H 和 L (POP HL) 
    POPHL: function () { Z80._r.l = MMU.rb(Z80._r.sp);              // 读取 L
	Z80._r.sp++;                               // 向上移动堆栈
	Z80._r.h = MMU.rb(Z80._r.sp);              // 读取 H 
	Z80._r.sp++;                               // 向上移动堆栈
	Z80._r.m = 3 ; Z80._r.t = 12 ;               // 3 M 次使用
    }

     // 从绝对位置读取一个字节到 A (LD A, addr) 
    LDAmm: function () {
         var addr = MMU.rw(Z80._r.pc);              // 从
	指令中获取地址Z80._r.pc += 2 ;                            //前进PC 
	Z80._r.a = MMU.rb(addr);                   // 从地址
	Z80._r.m = 4读取;Z80._r.t= 16 ;                 // 4 M 次使用
    }

调度和重置

指令就位后,CPU 剩下的难题就是在 CPU 启动时重置 CPU,并向仿真例程提供指令。具有复位例程允许 CPU 停止并“倒带”到执行开始;一个例子如下所示。

重置


    reset: function() {
		Z80._r.a = 0; Z80._r.b = 0; Z80._r.c = 0; Z80._r.d = 0;
		Z80._r.e = 0; Z80._r.h = 0; Z80._r.l = 0; Z80._r.f = 0;
		Z80._r.sp = 0;
		Z80._r.pc = 0;      // Start execution at 0
	
		Z80._clock.m = 0; Z80._clock.t = 0;
    }

为了运行仿真,它必须仿真前面详述的获取-解码-执行序列。“执行”由指令仿真功能负责,但提取和解码需要一段专门的代码,称为“调度循环”。这个循环接受每条指令,解码它必须发送到哪里执行,然后将它分派给相关函数。

调度

while(true)
{
    var op = MMU.rb(Z80._r.pc++);              // Fetch instruction
    Z80._map[op]();                            // Dispatch
    Z80._r.pc &= 65535;                        // Mask PC to 16 bits
    Z80._clock.m += Z80._r.m;                  // Add time to CPU clock
    Z80._clock.t += Z80._r.t;
}

Z80._map = [
    Z80._ops.NOP,
    Z80._ops.LDBCnn,
    Z80._ops.LDBCmA,
    Z80._ops.INCBC,
    Z80._ops.INCr_b,
    ...
];

如果没有模拟器来运行它,实现 Z80 仿真核心是没有用的。在本系列的下一部分中,模拟 Gameboy 的工作将开始:我将查看 Gameboy 的内存映射,以及如何通过 Web 将游戏图像加载到模拟器中。

以上是关于Game boy模拟器:CPU的主要内容,如果未能解决你的问题,请参考以下文章

Game boy模拟器:图形

Game boy模拟器:中断

Game boy模拟器:GPU的时序

Game boy模拟器:精灵

Game boy模拟器:运行内存

Game boy模拟器:内存池