模拟器架构 ============== [Chip8简介](01_chip8.md) 介绍了 Chip8 的历史以及其基本组成。本文在此基础上,针对 Chip8 的各个组件的开始编码,构建 Chip8 模拟器的基本骨架。 接下来再根据 SPEC,把各个组件的功能实现完成。Chip8 模拟器就大功告成了。 ## 组成结构 ```{image} ../img/chip8-emulator-design-overview.png :alt: chip8-emulator-design-overview :align: center ``` 从结构图看到 Chip8 的组件有 CPU,Memory,Display 和 Keyboard 四个主要的模块。使用 Python 定义这些组件为 class。模拟器的主要程序结构如下 src/tutorial/emulator/1_machine.py ```python class Memory: pass class Display: def render(self): pass class Keyboard: def poll_event(self): pass class CPU: def __init__(self): self.memory = Memory() def cycle(self): pass class Machine: def __init__(self): self.display = Display() self.keyboard = Keyboard() self.cpu = CPU() def run(self): while True: # cpu 指令循环 self.cpu.cycle() # 监听处理键盘事件 self.keyboard.poll_event() # 渲染屏幕 self.display.render() def main(): machine = Machine() machine.run() ``` 上述的代码是 Chip8 的基本骨架。与 pygame 的主框架非常相似。Machine 有处理器`CPU`,外设键盘`Keyboard`和外设显示器`Display`。 三个类组成。Machine 运行的时候,模拟器处于一个无限循环中,然后执行 CPU的指令循环,随后响应键盘事件和绘制屏幕。此时一个时钟流程完成,进入下一个周而复始的流程。 在 CPU 内部,内存 Memory 抽象为 CPU 的一个内部属性。这样方便 CPU 读写内存的操作。真实的硬件系统,CPU 通过内存总线和 Memory 进行通信。模拟器就忽略内存总线,直接把内存作为CPU的一个内部属性。 ### Memory ``` +---------------+= 0x000 (0) Start of Chip-8 RAM | 0x000 to 0x1FF| | Reserved for | | interpreter | +---------------+= 0x200 (512) Start of most Chip-8 programs | | | | | | +- - - - - - - -+= 0x600 (1536) Start of ETI 660 Chip-8 programs | | | | | | | | | | | 0x200 to 0xFFF| | Chip-8 | | Program / Data| | Space | | | | | | | +---------------+= 0xFFF (4095) End of Chip-8 RAM ``` 如上图所示,Chip8 的内存是**4-KB**的大小。每一个内存单元大小为**8-bit**,即**1-byte**。编号从`0x0000`开始,最后一个是`0x0FFF`,从上往下,编号依次变大。一共可以分为`4096(4 * 1024)` 个内存单元。内存的地址大小可以使用`12-bit`大小。 地址的描述通常使用三位的十六进制表示。即 0x200,0xFFF 等。 [^1] * 0x000 ~ 0x1FF: chip8 系统内存空间,模拟器则用这部分内容存储 Chip8 的`Font`数据。 [^2] * font是`0~f`的的数据数字数据,每一个数字有 5-bytes 大小,8x5 个像素。如: ```text +---------------------+-----------------+--------------------+-------------------+ | Symbol | Address | Sprite | Binary | Hex | +=====================+=================+====================+===================+ | 0 | 0x050 | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | * * | 10010000 | 0x90 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 1 | 0x055 | * | 00100000 | 0x20 | | | | ** | 01100000 | 0x60 | | | | * | 00100000 | 0x20 | | | | * | 00100000 | 0x20 | | | | *** | 01110000 | 0x70 | +---------+-----------+-----------------+--------------------+-------------------+ | 2 | 0x05A | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 3 | 0x05F | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 4 | 0x064 | * * | 10010000 | 0x90 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | * | 00010000 | 0x10 | +---------+-----------+-----------------+--------------------+-------------------+ | 5 | 0x069 | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 6 | 0x06E | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 7 | 0x073 | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | * | 00100000 | 0x20 | | | | * | 01000000 | 0x40 | | | | * | 01000000 | 0x40 | +---------+-----------+-----------------+--------------------+-------------------+ | 8 | 0x078 | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 9 | 0x07D | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | | | | * | 00010000 | 0x10 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 10 | 0x082 | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | **** | 11110000 | 0xF0 | | | | * * | 10010000 | 0x90 | | | | * * | 10010000 | 0x90 | +---------+-----------+-----------------+--------------------+-------------------+ | 11 | 0x087 | *** | 11100000 | 0xE0 | | | | * * | 10010000 | 0x90 | | | | *** | 11100000 | 0xE0 | | | | * * | 10010000 | 0x90 | | | | *** | 11100000 | 0xE0 | +---------+-----------+-----------------+--------------------+-------------------+ | 12 | 0x08C | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | * | 10000000 | 0x80 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 13 | 0x091 | *** | 11100000 | 0xE0 | | | | * * | 10010000 | 0x90 | | | | * * | 10010000 | 0x90 | | | | * * | 10010000 | 0x90 | | | | *** | 11100000 | 0xE0 | +---------+-----------+-----------------+--------------------+-------------------+ | 14 | 0x096 | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | +---------+-----------+-----------------+--------------------+-------------------+ | 15 | 0x09B | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | **** | 11110000 | 0xF0 | | | | * | 10000000 | 0x80 | | | | * | 10000000 | 0x80 | +---------+-----------+-----------------+--------------------+-------------------+ ``` * 0x200 ~ 0xE9F: chip8 程序可以随意使用的空间。实际上,0x200~0xFFF 都可以作为程序使用,现在的程序也很少有用到比0xE9F 还大的地址。 * 0xEA0 ~ 0xEFF: 保留给栈以及其他内部应用。实现模拟器的时候,可以独立使用一个数据结构来作为栈,因此这一块内存可以给程序使用。 * 0xF00 ~ 0xFFF: 保留给屏幕显示使用的 buffer。Chip8 的分辨率是 64 * 32,每一个 bit 一个像素。一共 256 个字节,对应内存地址为0xF00 ~ 0xFFF。与栈一样,可以独立使用一个数据结构来作为屏幕显示用到buffer。不需要使用这一部分内存。 下面是内存的代码实现: ```{eval-rst} .. autoclass:: src.tutorial.03.2_memory_define.Memory ``` ```python # 字体数据 _FONTSET = [0xF0, 0x90, 0x90, 0x90, 0xF0, # 0 0x20, 0x60, 0x20, 0x20, 0x70, # 1 0xF0, 0x10, 0xF0, 0x80, 0xF0, # 2 0xF0, 0x10, 0xF0, 0x10, 0xF0, # 3 0x90, 0x90, 0xF0, 0x10, 0x10, # 4 0xF0, 0x80, 0xF0, 0x10, 0xF0, # 5 0xF0, 0x80, 0xF0, 0x90, 0xF0, # 6 0xF0, 0x10, 0x20, 0x40, 0x40, # 7 0xF0, 0x90, 0xF0, 0x90, 0xF0, # 8 0xF0, 0x90, 0xF0, 0x10, 0xF0, # 9 0xF0, 0x90, 0xF0, 0x90, 0x90, # A 0xE0, 0x90, 0xE0, 0x90, 0xE0, # B 0xF0, 0x80, 0x80, 0x80, 0xF0, # C 0xE0, 0x90, 0x90, 0x90, 0xE0, # D 0xF0, 0x80, 0xF0, 0x80, 0xF0, # E 0xF0, 0x80, 0xF0, 0x80, 0x80] # F class Memory: def __init__(self): # 4KB memory self._ram = [0] * 4 * 1024 # stack self._stack = [] for i in range(len(_FONTSET)): self._ram[i] = _FONTSET[i] def stack_pop(self): return self._stack.pop() def stack_push(self, x: int): return self._stack.append(x) ``` ### CPU Chip8 的CPU主要用寄存器(register), 定时器(timer),按键缓存(key_pressed_buf),屏幕缓存(screen_buf)组成。 #### 寄存器 * 通用寄存器:16个 8-bit 的通用寄存器,记为`V0 ~ VF`,其中`VF`用来表示 flag 标记。可以用一个 16 长度数组表示,数组的每一个元素是一个寄存器,每个元素的类型是 uint8,当然 python 可以统一使用 int 表示 * 程序计数器:一个 16-bit 的程序计数器,即PC寄存器。初始值为0x200。Chip8 的内存是4k,实际上PC的最大值是 12-bit。 * 索引寄存器:一个 16-bit 的地址索引寄存器。 * 栈顶寄存器:8-bit 的栈顶寄存器,指向当前栈顶。因为 Memory 实现使用了python的list接口,直接使用 append 入栈和 pop 出栈,这个寄存器可以忽略。 #### 计时器 两个 8-bit 的定时器,一个 delay timer, 一个 sound timer,都按照 60hz 的频率递减直到 0。 #### 按键缓存 使用一个大小 16 的列表作为按键缓存,映射了 Chip8 的按键,按下的键其值为 1,未按的键是 0。键盘按键的映射留在 Keyboard 组件实现的时候再介绍。 #### 屏幕缓存 传统的 Chip8 使用内存的 0xF00 ~ 0xFFF 用作屏幕缓存。这里使用一个二维列表,作为 CPU 一个属性字段。 下面是 CPU 模块的定义代码实现: ```{eval-rst} .. autoclass:: src.tutorial.03.3_cpu_define.CPU ``` ```python class CPU: def __init__(self): self.memory = Memory() # 8 bits register self._reg_V = [0] * 16 # 16 bits register self._reg_PC = 0x200 self._reg_I = 0 self.draw_flag = False self._screen_buf = [[0 for _ in range(640)] for _ in range(320)] self._keys_pressed_buf = [0] * 16 self._delay_timer = 0 self._sound_timer = 0 def cycle(self): pass ``` ### Display 用来显示的 Display 模块相对简单,它本身需要初始化 pygame 和 canvas 对象。然后提供绘图和渲染的方法。具体的方法实现留到下一节再介绍。当前只需要知道 Display 的初始化即可。 ```{eval-rst} .. autoclass:: src.tutorial.03.4_display_define.Display ``` ```python class Display: def __init__(self): pygame.init() pygame.display.set_caption(TITLE) self.canvas = pygame.display.set_mode((SCREEN_WIDTH * ZOOM, SCREEN_HEIGHT * ZOOM)) self.canvas.fill(BG_COLOR) pygame.display.update() ``` Display 类进行 pygame 的初始化,设置canvas。 ### Keyboard Keyboard 模块和 Display 模块类似,自身的没有属性,提供了pygame键盘处理的绑定方法而已。 键盘映射,左边是 Chip8 的按键,右边是键盘的按键。 ```text 1 2 3 C 1 2 3 4 4 5 6 D -> Q W E R 7 8 9 E A S D F A 0 B F Z X C V ``` ```{eval-rst} .. autoclass:: src.tutorial.03.5_keyboard_define.Keyboard ``` ```python _KEYS = { pygame.K_1: 0x1, pygame.K_2: 0x2, pygame.K_3: 0x3, pygame.K_4: 0xC, pygame.K_q: 0x4, pygame.K_w: 0x5, pygame.K_e: 0x6, pygame.K_r: 0xD, pygame.K_a: 0x7, pygame.K_s: 0x8, pygame.K_d: 0x9, pygame.K_f: 0xE, pygame.K_z: 0xA, pygame.K_x: 0x0, pygame.K_c: 0xB, pygame.K_v: 0xF } class Keyboard: def poll_event(self): pass ``` ## 总结 CHIP8 模拟器定义为 Machine 类,它由 CPU,Memory,Keyboard 和 Display 几个部分组成。这也是冯诺依曼结构的基本组成。 模拟器的CPU由内存和寄存器组成,根据 Chip8 的 SPEC 定义了一些寄存器。 栈和视频缓存并没有使用内存对象,而是 CPU 内部独立的数据结构,这样实现比较方便,原理一致。 整个模拟器的工作流和 pygame 绘制图像的方式类似。也就是在主循环中进行 CPU 的 cycle 执行,处理键盘事件和绘制刷新屏幕。 至此,一个简单的模拟器(虚拟机)的工作原理介绍完毕。接下来就是针对 Chip8 的具体 SPEC,one step by step 实现完整的功能。 [^1]: Chip8 的地址范围是`0~4KB`,使用`12-bit`的大小即可覆盖,可是编程语言没有`12-bit`大小数据结构,通常地址类型是一个`2-byte`大小的结构。如 golang 的 uint16 或 rust 中的 u16。 [^2]: 更多的font数据可以参考[font](http://devernay.free.fr/hacks/chip8/C8TECH10.HTM#font)