本章导读
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 测试通过!")
深入细节
0x000-0x1FF 保留给解释器和字体。真实 Chip-8 系统中,这段内存存放 COSMAC VIP 的 BIOS。模拟器中我们用它存放字体数据(80 字节)和系统变量。PC 初始化为 0x200 是 Chip-8 的硬性规定。
DRW 指令用 XOR 绘制:像素 1 XOR 1 = 0(擦除),0 XOR 1 = 1(绘制)。这意味着画两次同一精灵会擦除它——无需单独的"清除精灵"指令。碰撞检测(VF=1)在擦除时触发,用于游戏碰撞判定。
Chip-8:16 位定长指令、16 个寄存器、无流水线、无缓存、无中断控制器。现代 CPU(x86/ARM):变长指令、数百个内部寄存器、超标量流水线、多级缓存、复杂中断。但核心周期相同:取指→译码→执行→写回。理解 Chip-8 就是理解 CPU 的灵魂。
⚠️ 常见错误
Chip-8 是大端序:高字节在低地址。opcode = memory[pc]*256 + memory[pc+1],不是反过来。搞反会导致所有指令译码错误。
V 寄存器是 8 位(0-255)。加法结果必须 & 0xFF 截断。不截断会导致后续逻辑错误(如比较、索引)。
CALL 压栈、RET 弹栈。如果程序 CALL 超过 16 层或 RET 多于 CALL,栈会溢出/下溢。需要检查 sp 边界。
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 指令定位 |
复习题
- Chip-8 的指令周期包含哪几步?每步做什么?
- 为什么程序从 0x200 而不是 0x000 开始?
- DRW 指令为什么用 XOR 而不是直接赋值?
- VF 寄存器在 ADD 和 DRW 中分别表示什么?
- CALL 和 JP 的区别是什么?为什么 CALL 需要栈?
- 大端序和小端序的区别是什么?Chip-8 用哪种?
- 定时器以什么频率递减?如何模拟?
- 如果要实现 Super Chip-8(128×64 显示),需要改什么?
编程练习
实现从文件加载 Chip-8 ROM:file.readBytes 读取 .ch8 文件,写入 memory[0x200..]。下载一个开源 Chip-8 游戏 ROM(如 Pong),运行模拟器验证。
参考思路:Buffer rom = file.readBytes(path);循环写入 memory。
实现单步调试:每执行一条指令打印 PC、opcode、V0-VF、I、SP。支持断点(指定 PC 地址暂停)。支持查看内存(dump 指定范围)。
参考思路:cycle() 后打印状态;维护断点 list;io.readLine() 交互。
用 Chip-8 汇编手写一个简单 Pong:两个挡板(DRW 绘制)、一个球(XOR 移动)、碰撞检测(VF)、得分显示(字体)。编译为 ROM 并用模拟器运行。
参考思路:游戏循环:清屏→更新位置→绘制→检测碰撞→延时。