- 读写内存的指令(译文:Instructions for Reading and Writting to Memory)
- 原文链接:https://blog.ryanlevick.com/DMG-01/public/book/
- 译文出处:https://github.com/suhanyujie/emulate-game-boy-translation
- 译者:suhanyujie
- tags: Rust,cpu emulator
- ps:水平有限,翻译不当之处,还请指正,谢谢!
现在我们已经了解指令是如何执行的,以及从内存中获取要读取的指令的基本知识,接下来我们要研究从内存的不同地方读写指令。
首先,当我们谈论内存的读写时,我们通常使用“加载”这个词。我们把数据从一个地方加载到另一个地方 —— 例如,将寄存器 A 的内容加载到地址为 0xFF0A 的内存中,或者从地址为 0x0040 的内存中加载数据到寄存器 C 中。“加载”不一定是在寄存器和某块内存之间发生,它也可以发生在两个寄存器之间甚至内存的两个区域之间。
我们要查找的所有指令都称为 LD
指令。我们将使用 LoadType
的枚举类型来区分加载的类型。枚举用于描述我们正在使用的加载类型。
我们来看看 LD
指令的实现,它的 LoadType
是 Byte
,它将“一个字节”的内容从一个地方加载到另一个地方。
fn write_byte(&self, addr: u16, byte: u8) {}
enum LoadByteTarget {
A, B, C, D, E, H, L, HLI
}
enum LoadByteSource {
A, B, C, D, E, H, L, D8, HLI
}
enum LoadType {
Byte(LoadByteTarget, LoadByteSource),
}
enum Instruction {
LD(LoadType),
}
impl CPU {
fn execute(&mut self, instruction: Instruction) -> u16 {
match instruction {
Instruction::LD(load_type) => {
match load_type {
LoadType::Byte(target, source) => {
let source_value = match source {
LoadByteSource::A => self.registers.a,
LoadByteSource::D8 => self.read_next_byte(),
LoadByteSource::HLI => self.bus.read_byte(self.registers.get_hl()),
_ => { panic!("TODO: implement other sources") }
};
match target {
LoadByteTarget::A => self.registers.a = source_value,
LoadByteTarget::HLI => self.bus.write_byte(self.registers.get_hl(), source_value),
_ => { panic!("TODO: implement other targets") }
};
match source {
LoadByteSource::D8 => self.pc.wrapping_add(2),
_ => self.pc.wrapping_add(1),
}
}
_ => { panic!("TODO: implement other load types") }
}
}
_ => { panic!("TODO: support more instructions") }
}
}
}
当把寄存器作为源加载时,我们只需读取寄存器的值。而如果源是 D8
(意味着“直接读取 8 个位的值”),那么该值在调用指令后将直接存储起来,因此我们可以简单地调用 read_next_byte
,它直接读取位于程序计数器当前所指向位置的后一个字节。最后,如果源是 HLI
,我们使用 HL
寄存器内的值作为地址,从内存中读取 8 位值。
目标仅仅是源的反向(除非我们不能将 D8
作为目标)。如果目标是一个寄存器,我们将源值写入该寄存器,如果目标是 HLI
,我们将写入存储在 HL
寄存器内的地址。
使用 16 位寄存器 BC
、DE
和 HL
来存储地址是非常常见的。
我们看看其他类型的加载:
Word
: 就像Byte
类型一样,只是有 16 位的值AFromIndirect
: 把 A 寄存器的值从内存的某个位置的地址中加载出来IndirectFromA
: 加载一个内存位置,其地址与寄存器的内容一起存储在某个位置AFromByteAddress
: 就像AFromIndirect
,只不过内存地址是内存的最后一个字节的某个地址ByteAddressFromA
: 类似于IndirectFromA
,只不过内存地址是内存的最后一个字节的某个地址。
有关这些说明的更详细内容,可以参考说明指南。
这些指令用于写入和写入内存中的任意位置,但是有一组指令是专门处理栈的内存段。我们下面看看什么是栈,以及操作栈的指令有哪些。
在查看 Game Boy 中被称为栈的内存区域之前,我们要更好的理解堆栈是什么。简单来讲,堆栈是一个简单的数据结构,你可以向其中添加值(例如将值”push”进去),然后把这些值取出(例如将值“pop”出来)。记住堆栈的关键点是出栈和入栈的顺序是相反的,例如,如果降三个项目“A”,“B”,“C”按顺序送入栈中,取出时的顺序则是“C”,“B”,“A”。
Game Boy 的 CPU 已经支持内存中栈数据结构。该堆栈在内存中的某个位置中(我们只需一分钟就能清楚它在内存中设置的值),并且它的值是 16 位的。那么是如何建立支持的呢?
首先 CPU 上有一个额外的 16 位寄存器,它指向栈的顶部。这个寄存器叫做 SP
或者栈指针,因为它指向栈顶部位置。我们先将这个寄存器加到 CPU 中:
struct CPU {
registers: Registers,
pc: u16,
sp: u16,
bus: MemoryBus,
}
现在我们有了栈指针,也就知道了栈所在的位置,但我们如何从这个栈中 push 和 pop 数据呢?
Game Boy 的 CPU 拥有这两个指令的功能。PUSH
会把 16 位寄存器的内容写入栈中,POP
会将数据从栈顶写入 16 位寄存器中。
下面是 PUSH
数据的过程:
- 栈指针减 1。
- 将 16 位值中高位有效字节写到当前栈指针指向的内存中。
- 栈指针再次 减 1.
- 将 16 位数据的低位字节写入到当前栈指针指向的内存中
注意,栈指针减 1 而不是加 1。这是因为栈地址在内存中是向下增长的。这一点很重要,因为栈的正常位置位于内存的末端。在后面的章节中,我们将看到 Game Boy 的引导 ROM 将栈指针设到内存的末尾。因此,当栈递增时,它会从内存的末端增长到内存的起始端。
实现一下 PUSH
:
impl CPU {
fn execute(&mut self, instruction: Instruction) -> u16 {
match instruction {
Instruction::PUSH(target) => {
let value = match target {
StackTarget::BC => self.registers.get_bc(),
_ => { panic!("TODO: support more targets") }
};
self.push(value);
self.pc.wrapping_add(1)
}
_ => { panic!("TODO: support more instructions") }
}
}
fn push(&mut self, value: u16) {
self.sp = self.sp.wrapping_sub(1);
self.bus.write_byte(self.sp, ((value & 0xFF00) >> 8) as u8);
self.sp = self.sp.wrapping_sub(1);
self.bus.write_byte(self.sp, (value & 0xFF) as u8);
}
}
我们现在可以将值 push 到栈中。下面是 PUSH
操作发生时的流程:
- 从栈指针指向的内存中的 16 位值中读取低有效位的字节。
- 栈指针 加 1
- 从栈指针指向的内存中的 16 位值中读取高有效位的字节。
- 栈指针再次 加 1
- 返回高有效位和低有效位组合在一起的值。
我们实现一下 POP
:
impl CPU {
fn execute(&mut self, instruction: Instruction) -> u16 {
match instruction {
Instruction::POP(target) => {
let result = self.pop();
match target {
StackTarget::BC => self.registers.set_bc(result),
_ => { panic!("TODO: support more targets") }
};
self.pc.wrapping_add(1)
}
_ => { panic!("TODO: support more instructions") }
}
}
fn pop(&mut self) -> u16 {
let lsb = self.bus.read_byte(self.sp) as u16;
self.sp = self.sp.wrapping_add(1);
let msb = self.bus.read_byte(self.sp) as u16;
self.sp = self.sp.wrapping_add(1);
(msb << 8) | lsb
}
}
好了!我们现在有了可以使用的栈了。但是它到底用来做什么呢?一个内建的栈是用于创建一个“调用”栈,它可以让游戏“调用”函数并从中返回一些值。我们看看它是如何工作的。
在大多数编程语言中,当你调用一个函数时,调用函数的状态会被保存在某个地方,以让对应的函数执行,然后当被调用函数返回时,被调用函数的状态会恢复。所以 Game Boy 也内置了对这种机制的支持,其中保存的状态就是调用函数时程序计数器的状态。这意味着“调用一个函数”中的函数还可以继续调用其他函数,当所有这些调用执行完成后,将会回到一开始调用函数时的地方。
这个功能是由两种类型的指令来处理的:CALL
和 RET
(即“返回”)。其中,CALL
的工作方式是使用我们已知的 PUSH
和 JP
(即 jump)指令。要执行 CALL
指令,我们必须执行以下操作:
- 将下一个程序计数器(例如没有跳转,我们就会得到该程序计数器)
PUSH
到栈中 JP
(即“跳转”)到下一个内存字节指向的地址上(即函数)
就是这样!我们调用了函数。但是当我们在调用的函数中执行 RET
(即返回)指令时会发生什么呢?
- 从栈中
POP
出下一个程序计数器,并跳转到它指向的位置。
这很简单!我们用代码实现它:
impl CPU {
fn execute(&mut self, instruction: Instruction) -> u16 {
match instruction {
Instruction::CALL(test) => {
let jump_condition = match test {
JumpTest::NotZero => !self.registers.f.zero,
_ => { panic!("TODO: support more conditions") }
};
self.call(jump_condition)
}
Instruction::RET(test) => {
let jump_condition = match test {
JumpTest::NotZero => !self.registers.f.zero,
_ => { panic!("TODO: support more conditions") }
};
self.return_(jump_condition)
}
_ => { panic!("TODO: support more instructions") }
}
}
fn call(&mut self, should_jump: bool) -> u16 {
let next_pc = self.pc.wrapping_add(3);
if should_jump {
self.push(next_pc);
self.read_next_word()
} else {
next_pc
}
}
fn return_(&mut self, should_jump: bool) -> u16 {
if should_jump {
self.pop()
} else {
self.pc.wrapping_add(1)
}
}
}
现在我们可以很容易地调用函数并从中返回数据了。至此,我们已经完成绝大部分的 CPU 指令了!