用JavaScript 编写8位机仿真器

What is Chip-8? 什么是8位机



在开始这个项目之前,我从未听说过8位机,所以我想大多数人也没有听说过,除非他们已经进入了模拟器。Chip-8是一种非常简单的解释型编程语言,于20世纪70年代为业余计算机开发。人们编写了基本的Chip-8程序,模仿当时流行的游戏,如Pong,俄罗斯方块,太空侵略者,可能还有其他独特的游戏,失去了时间的歼灭。

玩这些游戏的虚拟机实际上是Chip-8解释器,而不是技术上的模拟器,因为模拟器是模拟特定机器硬件的软件,而Chip-8程序不与任何特定硬件相关联。通常,Chip-8解释器用于图形计算器。

尽管如此,它已经足够接近于成为模拟器,对于任何想要学习如何构建模拟器的人来说,它通常是开始的项目,因为它比创建NES模拟器或除此之外的任何东西都要简单得多。对于许多CPU概念来说,这也是一个很好的起点,比如内存、堆栈和I/O,这些都是我在JavaScript运行时无限复杂的世界中每天要处理的事情。

What Goes Into a Chip-8 Interpreter? 8位机解释器

我必须做很多预习才能开始理解我正在做什么,因为我以前从未学习过计算机科学的基础知识。所以我写了理解位,字节,基,并在JavaScript中编写十六进制转储,其中大部分内容。

总而言之,该文章有两个主要要点:

CPU是执行程序指令的计算机的主处理器。在这种情况下,它由下面描述的各种状态位以及包含获取,解码和执行步骤的指令周期组成。

Memory 内存

8位计算机,可以访问高达4千字节的内存(RAM)。(这是软盘上存储空间的 0.002%。CPU中的绝大多数数据都存储在内存中。

4kb是4096字节,JavaScript有一些有用的类型化数组,比如Uint8Array,它是某个元素的固定大小的数组 - 在这种情况下是8位。

let memory = new Uint8Array(4096)

您可以像访问和使用此数组一样访问和使用此数组,从内存[0]到内存[4095],并将每个元素设置为最大255的值。任何高于此值的内容都将回退到该值(例如,内存[0] = 300 将导致内存[0] === 255)。


Program counter 程序计数器

程序计数器将当前指令的地址存储为 16 位整数。Chip-8中的每条指令都将在完成后更新程序计数器(PC),以进入下一条指令,方法是访问以PC为索引的存储器。

在8位机内存布局中,内存中0x1FF 0x000是保留的,因此它从0x200开始。

let PC = 0x200 // memory[PC] will access the address of  the current instruvtion

*您会注意到内存阵列是8位的,而PC是16位整数,因此两个程序代码将被组合成一个大的字节序操作码。

Registers 寄存器

存储器通常用于长期存储和程序数据,因此寄存器作为一种“短期存储器”存在,用于即时数据和计算。8位机有 16 个 8 位寄存器。它们被称为 V0 到 VF。

let registers = new Uint8Array(16)

Index register 索引寄存器

有一个特殊的16位寄存器,用于访问内存中的特定点,称为I。I寄存器通常用于读取和写入内存,因为可寻址内存也是16位的。

let I = 0

Stack 栈

8位机能够进入子例程,以及一个用于跟踪返回位置的堆栈。堆栈是 16 个 16 位值,这意味着程序在经历“堆栈溢出”之前可以进入 16 个嵌套的子例程。

let stack = new Uint16Array(16)

Stack pointer 栈指针

堆栈指针 (SP) 是一个 8 位整数,指向堆栈中的某个位置。即使堆栈是 16 位,它也只需要是 8 位,因为它只引用堆栈的索引,因此只需要 0 彻底 15。

let SP = -1

// stack[SP] will access the current return address in the stack

Timers 定时器

8位机能够发出光荣的一声蜂鸣声。说实话,我没有费心为“音乐”实现实际输出,尽管CPU本身都设置为与它正确接口。有两个计时器,都是8位寄存器 - 一个声音计时器(ST)用于决定何时发出蜂鸣声,一个延迟计时器(DT)用于在整个游戏中对某些事件进行计时。它们在 60 Hz 下倒计时。

let DT = 0
let ST = 0

Key input 输入

8位机的设置是为了与惊人的十六进制键盘接口。它看起来像这样:

┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ C │
│ 4 │ 5 │ 6 │ D │
│ 7 │ 8 │ 9 │ E │
│ A │ 0 │ B │ F │
└───┴───┴───┴───┘

在实践中,似乎只使用了几个键,你可以将它们映射到你想要的任何4x4网格,但它们在游戏之间非常不一致。

Graphical output 图形输出

8位机 使用单色 64x32 分辨率显示器。每个像素要么打开,要么关闭。

可以保存在内存中的精灵为 8x15 - 8 个像素宽 x 15 个像素高。Chip-8还附带了字体集,但它只包含十六进制键盘中的字符,因此总体上不是最有用的字体集。

CPU

将它们放在一起,您将获得CPU状态。

CPU

class CPU {
  constructor() {
    this.memory = new Uint8Array(4096)
    this.registers = new Uint8Array(16)
    this.stack = new Uint16Array(16)
    this.ST = 0
    this.DT = 0
    this.I = 0
    this.SP = -1
    this.PC = 0x200
  }
}

Decoding Chip-8 Instructions 指令解码

8位机有36条指令。此处列出了所有说明。所有指令的长度均为 2 个字节(16 位)。每条指令都由操作码(操作码)和操作数(操作码)编码,操作数据作。

如两个操作

x = 1
y = 2

ADD x, y

其中 ADD 是操作码,x、y 是操作数。这种类型的语言称为汇编语言。此指令将映射到:

x = x + y

使用此指令集,我必须将此数据存储在16位中,因此每个指令最终都是从0x0000到0xffff的数字。这些集合中的每个数字位置都是一个半字节(4 位)。

那么我怎么能从nnnn到像ADD x,y这样的东西,这更容易理解呢?好吧,我将首先查看Chip-8中的一条指令,它与上面的例子基本相同:

Instruction

Description

8xy4

ADD Vx, Vy

那么,我们在这里处理的是什么呢?有一个关键字 ADD 和两个参数 Vx 和 Vy,我们在上面建立的它们是寄存器。

如操作指令:

数据类型:

下一步是找到一种方法将16位操作码解释为这些更易于理解的指令。

Bit Masking 位操作

const opcode = 0x8124
const mask = 0xf00f
const pattern = 0x8004

const isMatch = (opcode & mask) === pattern // true
const x = (0x8124 & 0x0f00) >> 8 // 1

// (0x8124 & 0x0f00) is 100000000 in binary
// right shifting by 8 (>> 8) will remove 8 zeroes from the right
// This leaves us with 1

const y = (0x8124 & 0x00f0) >> 4 // 2
// (0x8124 & 0x00f0) is 100000 in binary
// right shifting by 4 (>> 4) will remove 4 zeroes from the right
// This leaves us with 10, the binary equivalent of 2
const instruction = {
  id: 'ADD_VX_VY',
  name: 'ADD',
  mask: 0xf00f,
  pattern: 0x8004,
  arguments: [
    { mask: 0x0f00, shift: 8, type: 'R' },
    { mask: 0x00f0, shift: 4, type: 'R' },
  ],
}

Disassembler

function disassemble(opcode) {
  // Find the instruction from the opcode
  const instruction = INSTRUCTION_SET.find(
    (instruction) => (opcode & instruction.mask) === instruction.pattern
  )
  // Find the argument(s)
  const args = instruction.arguments.map((arg) => (opcode & arg.mask) >> arg.shift)

  // Return an object containing the instruction data and arguments
  return { instruction, args }
}

Reading the ROM

由于我们将此项目视为仿真器,因此每个8位程序文件都可以被视为一个ROM。ROM只是二进制数据,我们正在编写程序来解释它。我们可以把Chip8 CPU想象成一个虚拟游戏机,而一个8位ROM是一个虚拟游戏卡带。

RomBuffer.js

class RomBuffer {
  /**
   * @param {binary} fileContents ROM binary
   */
  constructor(fileContents) {
    this.data = []

    // Read the raw data buffer from the file
    const buffer = fileContents

    // Create 16-bit big endian opcodes from the buffer
    for (let i = 0; i < buffer.length; i += 2) {
      this.data.push((buffer[i] << 8) | (buffer[i + 1] << 0))
    }
  }
}

指令周期 - 获取、解码、执行

现在,我已经准备好解释指令集和游戏数据。CPU只需要对它做点什么。指令周期包括三个步骤 - 获取、解码和执行。

Fetch

// Get address value from memory
function fetch() {
  return memory[PC]
}

Decode

// Decode instruction
function decode(opcode) {
  return disassemble(opcode)
}

Execute

// Execute instruction
function execute(instruction) {
  const { id, args } = instruction

  switch (id) {
    case 'ADD_VX_VY':
      // Perform the instruction operation
      registers[args[0]] += registers[args[1]]

      // Update program counter to next instruction
      PC = PC + 2
      break
    case 'SUB_VX_VY':
    // etc...
  }
}

CPU.js

class CPU {
  constructor() {
    this.memory = new Uint8Array(4096)
    this.registers = new Uint8Array(16)
    this.stack = new Uint16Array(16)
    this.ST = 0
    this.DT = 0
    this.I = 0
    this.SP = -1
    this.PC = 0x200
  }

  // Load buffer into memory
  load(romBuffer) {
    this.reset()

    romBuffer.forEach((opcode, i) => {
      this.memory[i] = opcode
    })
  }

  // Step through each instruction
  step() {
    const opcode = this._fetch()
    const instruction = this._decode(opcode)

    this._execute(instruction)
  }

  _fetch() {
    return this.memory[this.PC]
  }

  _decode(opcode) {
    return disassemble(opcode)
  }

  _execute(instruction) {
    const { id, args } = instruction

    switch (id) {
      case 'ADD_VX_VY':
        this.registers[args[0]] += this.registers[args[1]]
        this.PC = this.PC + 2
        break
    }
  }
}

Creating a CPU Interface for I/O 输入输出

所以现在我有了这个CPU,它正在解释和执行指令并更新它自己的所有状态,但我现在还不能用它做任何事情。为了玩游戏,你必须看到它并能够与之互动。

这就是输入/输出或 I/O 的用武之地。I/O 是 CPU 与外部世界之间的通信。

CpuInterface.js

// Abstract CPU interface class
class CpuInterface {
  constructor() {
    if (new.target === CpuInterface) {
      throw new TypeError('Cannot instantiate abstract class')
    }
  }

  clearDisplay() {
    throw new TypeError('Must be implemented on the inherited class.')
  }

  waitKey() {
    throw new TypeError('Must be implemented on the inherited class.')
  }

  getKeys() {
    throw new TypeError('Must be implemented on the inherited class.')
  }

  drawPixel() {
    throw new TypeError('Must be implemented on the inherited class.')
  }

  enableSound() {
    throw new TypeError('Must be implemented on the inherited class.')
  }

  disableSound() {
    throw new TypeError('Must be implemented on the inherited class.')
  }
}
class CPU {
  // Initialize the interface
  constructor(cpuInterface) {
    this.interface = cpuInterface
  }

  _execute(instruction) {
    const { id, args } = instruction

    switch (id) {
      case 'CLS':
        // Use the interface while executing an instruction
        this.interface.clearDisplay()
  }
}

Screen 显示

屏幕的分辨率为 64 像素宽 x 32 像素高。因此,就CPU和接口而言,它是一个64x32的位网格,这些位要么打开要么关闭。要设置一个空屏幕,我可以制作一个零的3D数组来表示所有关闭的像素。帧缓冲区是内存的一部分,其中包含将呈现到显示器上的位图图像。

MockCpuInterface.js

// Interface for testing
class MockCpuInterface extends CpuInterface {
  constructor() {
    super()

    // Store the screen data in the frame buffer
    this.frameBuffer = this.createFrameBuffer()
  }

  // Create 3D array of zeroes
  createFrameBuffer() {
    let frameBuffer = []

    for (let i = 0; i < 32; i++) {
      frameBuffer.push([])
      for (let j = 0; j < 64; j++) {
        frameBuffer[i].push(0)
      }
    }

    return frameBuffer
  }

  // Update a single pixel with a value (0 or 1)
  drawPixel(x, y, value) {
    this.frameBuffer[y][x] ^= value
  }
}

在 DRW 函数中,CPU 将循环遍历它从内存中提取的子画面,并更新子画面中的每个像素(为简洁起见,省略了一些细节)。

case 'DRW_VX_VY_N':
  // The interpreter reads n bytes from memory, starting at the address stored in I
  for (let i = 0; i < args[2]; i++) {
    let line = this.memory[this.I + i]
      // Each byte is a line of eight pixels
      for (let position = 0; position < 8; position++) {
        // ...Get value, x, and y...
        this.interface.drawPixel(x, y, value)
      }
    }

clearDisplay() 函数是将用于与屏幕交互的唯一其他方法。这是与屏幕交互所需的所有CPU接口。

Keys 按键

┌───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │
│ Q │ W │ E │ R │
│ A │ S │ D │ F │
│ Z │ X │ C │ V │
└───┴───┴───┴───┘
// prettier-ignore
const keyMap = [
  '1', '2', '3', '4',
  'q', 'w', 'e', 'r', 
  'a', 's', 'd', 'f', 
  'z', 'x', 'c', 'v'
]

按键按下状态.

this.keys = 0
0b1000000000000000 // V is pressed (keyMap[15], or index 15)
0b0000000000000011 // 1 and 2 are pressed (index 0, 1)
0b0000000000110000 // Q and W are pressed (index 4, 5)
case 'SKP_VX':
  // Skip next instruction if key with the value of Vx is pressed
  if (this.interface.getKeys() & (1 << this.registers[args[0]])) {
   // Skip instruction
  } else {
    // Go to next instruction
  }

Screen 显示器

对于所有实现,包含屏幕数据位图的帧缓冲区都是相同的,但屏幕与每个环境的接口方式将不同。

带着祝福,我只是定义了一个屏幕对象:

this.screen = blessed.screen({ smartCSR: true })

并在像素上使用fillRegion或clearRegion,并使用完整的 unicode 块进行填充,使用帧缓冲区作为数据源。

drawPixel(x, y, value) {
  this.frameBuffer[y][x] ^= value

  if (this.frameBuffer[y][x]) {
    this.screen.fillRegion(this.color, '█', x, x + 1, y, y + 1)
  } else {
    this.screen.clearRegion(x, x + 1, y, y + 1)
  }

  this.screen.render()
}

Keys 按键

按键处理程序与我对DOM的期望没有太大区别。如果按下某个键,处理程序将传递该键,然后我可以使用该键查找索引并使用已按下的任何其他新键更新 keys 对象。

this.screen.on('keypress', (_, key) => {
  const keyIndex = keyMap.indexOf(key.full)

  if (keyIndex) {
    this._setKeys(keyIndex)
  }
})
setInterval(() => {
  // Emulate a keyup event to clear all pressed keys
  this._resetKeys()
}, 100)

Entrypoint 入口点

terminal.js

terminal.js
const fs = require('fs')
const { CPU } = require('../classes/CPU')
const { RomBuffer } = require('../classes/RomBuffer')
const { TerminalCpuInterface } = require('../classes/interfaces/TerminalCpuInterface')

// Retrieve the ROM file
const fileContents = fs.readFileSync(process.argv.slice(2)[0])

// Initialize the terminal interface
const cpuInterface = new TerminalCpuInterface()

// Initialize the CPU with the interface
const cpu = new CPU(cpuInterface)

// Convert the binary code into opcodes
const romBuffer = new RomBuffer(fileContents)

// Load the game
cpu.load(romBuffer)

function cycle() {
  cpu.step()

  setTimeout(cycle, 3)
}

cycle()
展开阅读全文

页面更新:2024-03-29

标签:仿真器   寄存器   堆栈   数组   模拟器   字节   指令   像素   内存   操作   程序

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top