第 56 章 / 共 58 章 卷十 代码讲解

Chip-8 模拟器

Chip-8 是 1977 年 Joseph Weisbecker 设计的虚拟机——它不是真实硬件,而是一台"想象中的计算机",有 CPU、内存、显示器和键盘。实现一个 Chip-8 模拟器是理解计算机体系结构的最佳入门项目:取指→译码→执行的指令周期、寄存器文件、程序计数器、栈、中断定时器——所有真实 CPU 的核心概念都在这里以最简形式呈现。

本章导读

Chip-8 有 4KB 内存、16 个 8 位寄存器、64×32 单色显示、16 键键盘、2 个定时器,以及 35 条指令。它的指令是 16 位定长(真实 CPU 的简化版),用大端序存储。实现一个完整的 Chip-8 模拟器约 200 行代码,但涵盖了 CPU 设计的全部核心概念。

本章学习目标
  • 理解 CPU 的取指-译码-执行周期
  • 实现 16 个通用寄存器 + I/J 地址寄存器 + 程序计数器
  • 实现 35 条 Chip-8 指令
  • 实现 64×32 位图显示
  • 实现栈(子程序调用/返回)
  • 实现延迟定时器和声音定时器

56.1 Chip-8 架构

// Chip-8 虚拟机规格
// ┌─────────────────────────────────────┐
// │  内存: 4096 字节 (0x000 - 0xFFF)    │
// │  程序加载地址: 0x200                 │
// │  字体存储: 0x000 - 0x1FF            │
// ├─────────────────────────────────────┤
// │  寄存器: V0-VF (16 个 8 位)         │
// │  I: 12 位地址寄存器                  │
// │  PC: 12 位程序计数器                 │
// │  SP: 8 位栈指针                      │
// │  Stack: 16 层 (12 位地址)           │
// ├─────────────────────────────────────┤
// │  显示: 64×32 单色 (1=白, 0=黑)      │
// │  键盘: 16 键 (0x0 - 0xF)           │
// │  定时器: delay_timer, sound_timer   │
// └─────────────────────────────────────┘

56.2 状态定义

class Chip8
    array<int> memory      // 4096 字节内存
    array<int> V           // 16 个 8 位寄存器 V0-VF
    int I                   // 地址寄存器 (12 位)
    int pc                  // 程序计数器
    array<int> stack       // 16 层栈
    int sp                  // 栈指针
    int delayTimer          // 延迟定时器
    int soundTimer          // 声音定时器
    array<int> display     // 64×32 = 2048 像素
    array<int> keys        // 16 键状态 (0/1)
    bool drawFlag           // 屏幕需要刷新

    func init()
        memory = array<int>(4096)
        V = array<int>(16)
        stack = array<int>(16)
        display = array<int>(2048)
        keys = array<int>(16)
        I = 0
        pc = 0x200      // 程序从 0x200 开始
        sp = 0
        delayTimer = 0
        soundTimer = 0
        drawFlag = false
    end
end

56.3 取指与译码

Chip-8 指令是 16 位大端序:

func cycle()
    // 取指:从内存读取 16 位指令(大端序)
    int opcode = (memory[pc] * 256) + memory[pc + 1]
    pc += 2

    // 译码:提取各字段
    int nnn = opcode & 0x0FFF    // 地址
    int nn = opcode & 0x00FF     // 8 位立即数
    int n = opcode & 0x000F      // 4 位立即数
    int x = (opcode >> 8) & 0xF  // 寄存器 Vx
    int y = (opcode >> 4) & 0xF  // 寄存器 Vy

    // 执行
    execute(opcode, nnn, nn, n, x, y)
end

指令格式:NNNN(4 个十六进制位),高 4 位是操作码,其余是操作数。

56.4 指令执行

func execute(int opcode, int nnn, int nn, int n, int x, int y)
    int op = (opcode >> 12) & 0xF   // 高 4 位

    if op == 0x0 then
        if opcode == 0x00E0 then clearDisplay()      // CLS
        elif opcode == 0x00EE then ret()              // RET
        end
    elif op == 0x1 then pc = nnn                      // JP addr
    elif op == 0x2 then call(nnn)                     // CALL addr
    elif op == 0x3 then if V[x] == nn then pc += 2 end  // SE Vx, byte
    elif op == 0x4 then if V[x] != nn then pc += 2 end  // SNE Vx, byte
    elif op == 0x5 then if V[x] == V[y] then pc += 2 end // SE Vx, Vy
    elif op == 0x6 then V[x] = nn                     // LD Vx, byte
    elif op == 0x7 then V[x] = (V[x] + nn) & 0xFF    // ADD Vx, byte
    elif op == 0x8 then execArith(opcode, x, y)       // 算术/逻辑
    elif op == 0x9 then if V[x] != V[y] then pc += 2 end // SNE Vx, Vy
    elif op == 0xA then I = nnn                       // LD I, addr
    elif op == 0xB then pc = nnn + V[0]              // JP V0, addr
    elif op == 0xC then V[x] = (math.random(0,255)) & nn  // RND
    elif op == 0xD then draw(x, y, n)                 // DRW
    elif op == 0xE then execKey(opcode, x)            // 键盘
    elif op == 0xF then execMisc(opcode, x, nn)       // 杂项
    end
end

func execArith(int opcode, int x, int y)
    int sub = opcode & 0xF
    if sub == 0x0 then V[x] = V[y]                    // LD Vx, Vy
    elif sub == 0x1 then V[x] = V[x] | V[y]          // OR
    elif sub == 0x2 then V[x] = V[x] & V[y]          // AND
    elif sub == 0x3 then V[x] = V[x] ^ V[y]          // XOR
    elif sub == 0x4 then                              // ADD Vx, Vy
        int sum = V[x] + V[y]
        V[x] = sum & 0xFF
        if sum > 255 then V[0xF] = 1 else V[0xF] = 0 end
    elif sub == 0x5 then                              // SUB Vx, Vy
        if V[x] >= V[y] then V[0xF] = 1 else V[0xF] = 0 end
        V[x] = (V[x] - V[y]) & 0xFF
    elif sub == 0x6 then                              // SHR Vx
        V[0xF] = V[x] & 1
        V[x] = V[x] / 2
    elif sub == 0x7 then                              // SUBN Vx, Vy
        if V[y] >= V[x] then V[0xF] = 1 else V[0xF] = 0 end
        V[x] = (V[y] - V[x]) & 0xFF
    elif sub == 0xE then                              // SHL Vx
        V[0xF] = (V[x] >> 7) & 1
        V[x] = (V[x] * 2) & 0xFF
    end
end

56.5 显示系统

// DRW Vx, Vy, N:在 (Vx, Vy) 绘制 N 字节精灵
func draw(int x, int y, int height)
    V[0xF] = 0
    int row = 0
    while row < height
        int spriteByte = memory[I + row]
        int col = 0
        while col < 8
            if (spriteByte & (0x80 >> col)) != 0 then
                int px = (V[x] + col) % 64
                int py = (V[y] + row) % 32
                int idx = py * 64 + px
                if display[idx] == 1 then
                    V[0xF] = 1   // 碰撞检测
                end
                display[idx] = display[idx] ^ 1   // XOR 绘制
            end
            col += 1
        end
        row += 1
    end
    drawFlag = true
end

func clearDisplay()
    int i = 0
    while i < 2048
        display[i] = 0
        i += 1
    end
    drawFlag = true
end

// 文本渲染(调试用)
func renderText() as string
    string out = ""
    int y = 0
    while y < 32
        int x = 0
        while x < 64
            if display[y * 64 + x] == 1 then out += "#" else out += "." end
            x += 1
        end
        out += "\n"
        y += 1
    end
    return out
end

56.6 键盘输入

func execKey(int opcode, int x)
    int low = opcode & 0xFF
    if low == 0x9E then                    // SKP Vx:键按下则跳过
        if keys[V[x]] == 1 then pc += 2 end
    elif low == 0xA1 then                  // SKNP Vx:键未按下则跳过
        if keys[V[x]] == 0 then pc += 2 end
    end
end

// 模拟按键
func pressKey(int key)
    keys[key] = 1
end

func releaseKey(int key)
    keys[key] = 0
end

56.7 定时器

func execMisc(int opcode, int x, int nn)
    int low = opcode & 0xFF
    if low == 0x07 then V[x] = delayTimer          // LD Vx, DT
    elif low == 0x15 then delayTimer = V[x]        // LD DT, Vx
    elif low == 0x18 then soundTimer = V[x]        // LD ST, Vx
    elif low == 0x1E then I = (I + V[x]) & 0xFFF  // ADD I, Vx
    elif low == 0x29 then I = V[x] * 5             // LD F, Vx (字体地址)
    elif low == 0x33 then                           // BCD
        memory[I] = V[x] / 100
        memory[I + 1] = (V[x] / 10) % 10
        memory[I + 2] = V[x] % 10
    elif low == 0x55 then                           // LD [I], V0..Vx
        int i = 0
        while i <= x
            memory[I + i] = V[i]
            i += 1
        end
    elif low == 0x65 then                           // LD V0..Vx, [I]
        int i = 0
        while i <= x
            V[i] = memory[I + i]
            i += 1
        end
    end
end

// 每帧调用(60Hz)
func tick()
    if delayTimer > 0 then delayTimer -= 1 end
    if soundTimer > 0 then soundTimer -= 1 end
end

func call(int addr)
    stack[sp] = pc
    sp += 1
    pc = addr
end

func ret()
    sp -= 1
    pc = stack[sp]
end

56.8 字体加载

Chip-8 内置 0-F 的 5 字节字体:

func loadFont()
    // 0-F 的 5×8 像素字体,每个字符 5 字节
    array<int> font = [
        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
    ]
    int i = 0
    while i < 80
        memory[i] = font[i]
        i += 1
    end
end

56.9 完整模拟器

// chip8.xl — 主循环
Chip8 cpu = Chip8()
cpu.init()
cpu.loadFont()

// 加载 ROM(示例:手动写入测试程序)
// 真实使用时从文件读取:
// Buffer rom = file.readBytes("game.ch8")
// int i = 0
// while i < rom.len()
//     cpu.memory[0x200 + i] = rom[i]
//     i += 1
// end

// 测试程序:在屏幕上画一条线
// 0xA200: LD I, 0x200 (指向字体 '0')
// 0x6005: LD V0, 5
// 0x6105: LD V1, 5
// 0xD015: DRW V0, V1, 5 (画字符 '0')
// 0x1206: JP 0x206 (死循环)
cpu.memory[0x200] = 0xA2
cpu.memory[0x201] = 0x00
cpu.memory[0x202] = 0x60
cpu.memory[0x203] = 0x05
cpu.memory[0x204] = 0x61
cpu.memory[0x205] = 0x05
cpu.memory[0x206] = 0xD0
cpu.memory[0x207] = 0x15
cpu.memory[0x208] = 0x12
cpu.memory[0x209] = 0x06

// 执行 10 个周期
int cycles = 0
while cycles < 10
    cpu.cycle()
    cpu.tick()
    cycles += 1
end

// 渲染输出
println(cpu.renderText())
println("PC=0x" + cpu.pc.toString())
println("V0=" + cpu.V[0].toString() + " V1=" + cpu.V[1].toString())

56.10 测试程序

// 验证基本指令
Chip8 test = Chip8()
test.init()
test.loadFont()

// LD V0, 0x42 → 6042
test.memory[0x200] = 0x60
test.memory[0x201] = 0x42
test.cycle()
assert test.V[0] == 0x42, "LD Vx, byte failed"

// ADD V0, 0x10 → 7010
test.memory[0x202] = 0x70
test.memory[0x203] = 0x10
test.cycle()
assert test.V[0] == 0x52, "ADD Vx, byte failed"

// JP 0x300 → 1300
test.memory[0x204] = 0x13
test.memory[0x205] = 0x00
test.cycle()
assert test.pc == 0x300, "JP failed"

println("所有 CPU 测试通过!")

深入细节

深入 1:为什么程序从 0x200 开始

0x000-0x1FF 保留给解释器和字体。真实 Chip-8 系统中,这段内存存放 COSMAC VIP 的 BIOS。模拟器中我们用它存放字体数据(80 字节)和系统变量。PC 初始化为 0x200 是 Chip-8 的硬性规定。

深入 2:XOR 绘制的妙处

DRW 指令用 XOR 绘制:像素 1 XOR 1 = 0(擦除),0 XOR 1 = 1(绘制)。这意味着画两次同一精灵会擦除它——无需单独的"清除精灵"指令。碰撞检测(VF=1)在擦除时触发,用于游戏碰撞判定。

深入 3:Chip-8 与现代 CPU 的对比

Chip-8:16 位定长指令、16 个寄存器、无流水线、无缓存、无中断控制器。现代 CPU(x86/ARM):变长指令、数百个内部寄存器、超标量流水线、多级缓存、复杂中断。但核心周期相同:取指→译码→执行→写回。理解 Chip-8 就是理解 CPU 的灵魂。

⚠️ 常见错误

❌ 错误 1:大端序读取错误

Chip-8 是大端序:高字节在低地址。opcode = memory[pc]*256 + memory[pc+1],不是反过来。搞反会导致所有指令译码错误。

❌ 错误 2:寄存器溢出未截断

V 寄存器是 8 位(0-255)。加法结果必须 & 0xFF 截断。不截断会导致后续逻辑错误(如比较、索引)。

❌ 错误 3:栈溢出/下溢

CALL 压栈、RET 弹栈。如果程序 CALL 超过 16 层或 RET 多于 CALL,栈会溢出/下溢。需要检查 sp 边界。

❌ 错误 4:显示坐标未取模

DRW 的坐标需要 % 64(x)和 % 32(y)实现环绕。不取模会导致数组越界。

本章小结

主题核心要点
架构4KB 内存、16 寄存器、64×32 显示、16 键、2 定时器
指令周期取指(2字节大端) → 译码(高4位) → 执行
指令集35 条:跳转/调用/算术/逻辑/绘制/键盘/定时器
显示XOR 绘制,碰撞检测设 VF,坐标取模环绕
栈CALL 压 PC,RET 弹 PC,最多 16 层
定时器60Hz 递减,delay 用于延时,sound 用于蜂鸣
字体0x000 起 80 字节,每字符 5 字节,F 指令定位

复习题

  1. Chip-8 的指令周期包含哪几步?每步做什么?
  2. 为什么程序从 0x200 而不是 0x000 开始?
  3. DRW 指令为什么用 XOR 而不是直接赋值?
  4. VF 寄存器在 ADD 和 DRW 中分别表示什么?
  5. CALL 和 JP 的区别是什么?为什么 CALL 需要栈?
  6. 大端序和小端序的区别是什么?Chip-8 用哪种?
  7. 定时器以什么频率递减?如何模拟?
  8. 如果要实现 Super Chip-8(128×64 显示),需要改什么?

编程练习

练习 1:ROM 加载器

实现从文件加载 Chip-8 ROM:file.readBytes 读取 .ch8 文件,写入 memory[0x200..]。下载一个开源 Chip-8 游戏 ROM(如 Pong),运行模拟器验证。

参考思路:Buffer rom = file.readBytes(path);循环写入 memory。

练习 2:调试器

实现单步调试:每执行一条指令打印 PC、opcode、V0-VF、I、SP。支持断点(指定 PC 地址暂停)。支持查看内存(dump 指定范围)。

参考思路:cycle() 后打印状态;维护断点 list;io.readLine() 交互。

练习 3:Pong 游戏

用 Chip-8 汇编手写一个简单 Pong:两个挡板(DRW 绘制)、一个球(XOR 移动)、碰撞检测(VF)、得分显示(字体)。编译为 ROM 并用模拟器运行。

参考思路:游戏循环:清屏→更新位置→绘制→检测碰撞→延时。