
我从一个实际问题说起把一个 NES 模拟器从零开始写第一道槛不是画面也不是手柄而是让 CPU 动起来。我当初把 olcNES 这个开源项目翻出来一路看到 Part#2 也就是 6502 CPU 的模拟代码才算真正把“CPU 在整机里到底怎么工作”这件事弄明白。文章标题里的 Part#2 - CPU 指的是项目里专门讲 6502 内核的部分它把寄存器、标志位、寻址方式、操作码表、总线读写这些内容串成了一份非常完整的实现。这篇文章就当作我的学习记录把那段代码为什么这么组织、哪些地方最容易翻车、以及我验证自己有没有写对的方法一起写清楚。如果你正准备写一个 NES 模拟器或者已经写了点循环但是跑不出正确指令我应该能帮你省下几个晚上的排查时间。1. Part#2 在整机中的位置CPU 是“读指令、算结果、写内存”的中枢1.1 走进代码前先看清一台 NES 被拆成了哪几个部分模拟器本质上是在软件里重新搭一台虚拟游戏机。NES 的硬件可以分割成几块CPU2A036502 的定制版本负责执行卡带里的程序PPU 负责根据 CPU 的指令生成画面APU 集成在 CPU 里负责声音卡带通过总线接入系统提供程序和数据。olcNES 这个项目里也是按“总线 设备”的思路组织的卡带、 RAM、PPU 都是挂在总线上的设备CPU 不直接知道它们在哪个地址段只负责发出读写请求。我一开始有个误区以为 CPU 模拟这部分代码应该很独立结果看了 Part#2 才发现代码里 CPU 的一切访问都绕不开总线。所以学习的第一件事不是马上背操作码而是先认清角色分工。1.2 进入 CPU 代码前必须回答的三个问题读 Part#2 时我给自己定了三个必须搞清楚的问题后来看其他 CPU 模拟代码也发现万变不离其宗CPU 从哪里取指令答案是程序计数器 PC 指向的内存地址。CPU 怎么知道操作数在哪儿答案是寻址模式每种寻址方式都是一套地址计算方法。CPU 如何把结果写回去答案是总线写入接口通过地址映射落到具体设备。这三个问题贯穿整个 CPU 模拟代码。只要能把它们理清楚哪怕后面更换具体总线的实现CPU 内核也不需要大改。这其实正是 Part#2 这段代码的巧妙之处它把 CPU 的状态变化和外部设备的访问方式完全解耦。1.3 先写 CPU 而不是先写画面是有理由的模拟器项目很容易犯一个毛病上来就想看到画面于是先去搞 PPU 和像素渲染。但如果没有一个正确的 CPU画面不过是一堆随机字节的无意义变化。CPU 是验证卡带程序能不能跑起来的前提。Part#2 的代码即使没有接入图形输出也可以通过地址总线和调试接口去执行一段已知指令看到寄存器变化从而确认内核实现了。这种“先无头验证、再接显示器”的开发节奏我后来在接入 PPU 和 mapper 时感受到了极大的好处。2. 寄存器建模几个无符号数背后藏着的决定2.1 CPU 的核心字段其实只有 6 个打开 olcNES 的 CPU 类声明成员变量少得让人意外uint8_t a; // 累加器 uint8_t x; // 索引寄存器 X uint8_t y; // 索引寄存器 Y uint8_t stkp; // 堆栈指针 uint16_t pc; // 程序计数器 uint8_t status; // 状态寄存器 P6502 毕竟是 1970 年代的 8 位处理器寄存器本来就少。模拟器不需要还原芯片内部的晶体管和流水线只需要把程序员可见的状态保存下来再把取指、译码、执行的行为模拟出来。换句话说CPU 模拟的粒度是“指令级”而不是“周期内部级”。这里有个小细节堆栈指针 stkp 是 8 位的它永远指向内存第 1 页0x0100 - 0x01FF。代码里堆栈操作通常写成“先写内存[0x0100 stkp]再减 stkp”。很多初学者会把 stkp 本身当成完整地址结果压栈压到了第 0 页整个内存布局都乱了。2.2 状态标志位怎么“塞”进一个字节状态寄存器 P 用一个 uint8_t 表示。为了让标志位清晰代码里定义了一组位掩码常量enum FLAGS6502 { C (1 0), // 进位 Carry Z (1 1), // 零 Zero I (1 2), // 中断禁止 Interrupt disable D (1 3), // 十进制模式 Decimal B (1 4), // 中断来源 Break V (1 6), // 溢出 Overflow N (1 7), // 负数 Negative };注意 bit5 在真正芯片上一直是 1文档里通常标记为“未使用”。而 bit4 的 B 位含义比较复杂它在执行 BRK 指令时会被置位硬件中断如 NMI、IRQ时则表现为另一套语义。这些如果随手写死不处理等到跑测试 ROM 时第一条 BRK 指令产生的日志就会和真实机器对不上。建议所有对状态位的修改都封装成 SetFlag / GetFlag而不是到处直接写 status比如 status | 0x80。因为直接改位时很容易误碰其他标志等出了疑难 bug回头检查时眼睛里全是 0x80 和 0x40根本看不清意图。2.3 程序计数器的小端读取顺序PC 是 16 位寄存器模拟时可以放心用 uint16_t但读 16 位地址时一定要牢记“小端序”先读低 8 位再读高 8 位。我见过也亲身犯过的错误是把两个字节读取顺序写反导致所有绝对地址都跳到错误位置。建议在开发初期就写一个读 16 位的辅助函数uint16_t Read16(uint16_t addr) { uint16_t lo Read(addr); uint16_t hi Read(addr 1) 8; return lo | hi; }之后所有取绝对地址、取向量、JSR 返回地址的地方都统一用这个函数能少踩很多坑。3. 寻址模式地址怎么算比指令本身更容易让人栽跟头3.1 一张表把 13 种寻址方式串起来6502 的全部寻址方式本质都是“算出操作数地址”或者“取出操作数”的方法。我先列一张对照表后面代码里可以对着看寻址模式操作数地址/行为典型指令Implied 隐含不需要操作数行为隐含在指令中CLC、INXAccumulator 累加器操作数就是累加器本身ASL、LSRImmediate 立即数据紧跟在操作码之后LDA #immZero Page 零页8 位地址LDA zpZero Page,X(零页地址 X) 0xFFLDX zp,XZero Page,Y(零页地址 Y) 0xFFLDY zp,YAbsolute 绝对16 位地址LDA absAbsolute,X绝对地址 XSTA abs,XAbsolute,Y绝对地址 YSTA abs,YRelative 相对PC 带符号偏移BEQ、BCCIndirect 间接JMP 特有的指针跳转JMP (abs)Indirect,X(零页地址 X) 再读出的 16 位指针LDA (zp,X)Indirect,Y先读 16 位指针再加 YLDA (zp),Y理解这张表以后就会发现指令函数里真正要做的不过是“拿到一个地址读数据或者写数据”剩下千变万化的处理都集中在寻址模式这一步。3.2 零页和绝对寻址的实现差异零页寻址只读 8 位地址地址范围天然限制在 0x0000 - 0x00FF。好处是指令短、周期也短所以 6502 程序里访问 ram 和 IO 寄存器时特别爱用零页。代码实现起来也很直白void olc6502::AddrZP0() { addr Read(pc); // 读零页地址 }而绝对寻址是读两个字节void olc6502::AddrABS() { addr Read(pc); addr | Read(pc) 8; }零页变址时有一个“回绕”特性计算结果是 8 位的如果零页地址加上 X 超过 0xFF会回绕到 0x00而不是进位进到高字节。这一点也要在代码里体现出来。例如 Zero Page,X 的写法uint8_t zp Read(pc); addr (zp x) 0xFF;如果不做 0xFF地址就有可能跑到 0x0100 以上的第 1 页去行为就和真实芯片不一致。3.3 跨页读取要多花一个总线周期6502 的页是 256 字节从 0x00FF 到 0x0100 就算跨页。变址寻址Absolute,X、Absolute,Y、(Indirect),Y执行时如果变址后的地址跨过页边界真实芯片会额外多取一个总线周期因为低字节地址发生了回绕需要重新计算高字节。模拟时如果漏掉这个细节游戏不会立刻崩溃但累计周期会越来越偏。遇到对时间敏感的代码比如音乐播放、帧同步就会出现“一切逻辑正确但节奏不对”的问题。我在代码里用这样的判断uint16_t base Read16(...); uint16_t final base y; if ((base 0xFF00) ! (final 0xFF00)) cycles;用变址前后的高字节比较一下就能判断是否跨页。把这段逻辑放在寻址模式函数里统一处理指令函数就能完全不用关心周期增加的规则。3.4 间接寻址里的著名 BUGJMP 页边界回绕JMP (abs)这条指令有个众所周知的硬件 bug如果指针位于页边界比如地址 0x03FF芯片读取 16 位指针时会先从 0x03FF 读低字节然后从 0x0300 读高字节而不是从 0x0400 读。也就是说高字节地址的页号在指针跨页时并不会正常 1。那次学到这条我意识到模拟器不是把“理想指令集文档”翻译一遍而是要把“一颗真实芯片的行为”完整复刻出来。文档里写的是通用规则硬件里却充满了这类历史包袱。Part#2 里对这个细节同样做了处理模拟实现必须跟随硬件行为否则一些自制 ROM 的跳转表会全部乱套。4. 用查表代替 if/else指令译码的工程选择4.1 如果每个操作码都写 case代码会膨胀多少151 个基本操作码如果直接在 clock 里 switch每个 case 还要区分寻址模式同一指令可能对应 2 到 8 个操作码。比如 LDA 指令就有 8 种寻址方式对应 8 个操作码如果每个都写一遍“读取、更新标志”那文件会像面条一样长而且把重复逻辑复制粘贴迟早改漏一处。olcNES 项目采用的思路是建一张操作码表用 opcode 作为数组下标一次取索引就能得到该操作码对应的寻址函数、指令函数和周期数。这种“数据驱动”的方案在模拟器里相当流行。4.2 操作码表条目长什么样操作码表的每一项可以抽象成一个结构体struct INS { std::string name; // 助记符调试的时候很有用 void (olc6502::*operate)(); // 指向指令实现函数 void (olc6502::*addrmode)(); // 指向寻址模式函数 uint8_t cycles; // 基础周期数 };代码里 256 个槽位都要填满。即使 6502 有一些未定义的操作码也最好填上“默认处理”而不是留空否则一旦出现非法操作码程序就会越界。用查表以后取指和译码的主循环就短得惊人void olc6502::clock() { if (remaining_cycles 0) { remaining_cycles--; return; } opcode Read(pc); current_ins lookup[opcode]; (this-*current_ins-addrmode)(); (this-*current_ins-operate)(); remaining_cycles current_ins-cycles; }这个结构里寻址模式负责把操作数地址计算好指令函数负责真正干活。每次执行完后状态留在寄存器、内存里不需要额外返回结构。我当时第一次看懂这个循环时心里只有一个念头原来 CPU 模拟的主旋律就这么干净。4.3 为什么用普通数组而不是 std::map操作码正好是 0-255 的连续范围数组下标天然就是操作码。用数组查询是 O(1)并且不需要处理哈希冲突。相比之下std::map 或 unordered_map 虽然写起来“很现代”但每次取指令都要多一些查找开销。对模拟器这种追求周期级精确的场景数组是毫无疑问的默认选择。另外还有一点体会函数指针数组配合成员函数指针写起来需要一点 C 成员的语法基础但换来的是指令表和函数实现完全分离。想加一个指令只需要在表里填一行想查周期数直接读表。这种可读性对模拟器这种“细节极多”的项目来说是非常值得的。4.4 寻址模式和指令实现分离的好处我在自己重写时也刻意保持了这种分离。指令函数内部往往只有几行// LDA 实现把准备好的操作数/内存值装载到 A void olc6502::LDA() { a addr; SetFlag(Z, a 0); SetFlag(N, a 0x80); } // STA 实现把 A 写回内存 void olc6502::STA() { Write(addr, a); }因为寻址模式已经把所有“取操作数”的工作做完了指令函数只要关心 A、X、Y、内存、状态标志之间的搬运和计算。我当时调试时就靠助记符打印把“当前 PC、操作码、助记符、寻址模式函数名”打出来一条条和参考日志对照效率高得多。5. 指令执行与标志运算越是不起眼的细节越容易崩5.1 ADC 的进位计算不能只靠 uint8_tADC带进位加是最容易写错的指令之一。如果只用 uint8_t 相加进位和溢出的信息会直接丢失。正确做法是先把结果放到 uint16_t 里观察再截断回 8 位uint16_t result (uint16_t)a operand GetFlag(C); SetFlag(C, result 0xFF); SetFlag(Z, (result 0xFF) 0); SetFlag(N, result 0x80); SetFlag(V, ((~(uint16_t)a ^ operand) ((uint16_t)a ^ result)) 0x80); a result 0xFF;V 标志的判断是重灾区。V 表示“把操作数当有符号数时结果是否越界”也就是说两个正数相加变成负数或者两个负数相加变成正数都属于溢出。那段位运算看起来有点绕本质是判断“两个操作数符号相同而结果符号却和它们不同”。千万不要图省事忽略 V否则后面跑测试 ROM 时会发现 BVS/BVC 分支全错。5.2 CMP 系列是减法但不写回结果CMP、CPX、CPY 是把寄存器减操作数只更新标志位不保存差值。实现时可以用临时变量做减法然后Z (结果 0)N 结果的 bit7C 寄存器 操作数也就是没有发生借位时为 1很多文档会把这里写成“等于时不借位”导致新手把 C 的判定方向搞反。我的建议是专门为 CMP 写几组极小的测试A5 与 操作数5、4、6 分别验证 Z、C、N 的期望值一次跑过心里就踏实了。5.3 分支指令的跨页多周期分支指令BEQ、BNE、BCC、BCS、BPL、BMI、BVC、BVS使用相对寻址操作数是带符号的 8 位偏移。地址计算是int8_t offset (int8_t)Read(pc); uint16_t target pc offset;分支成立时本身就比不成立多一个周期如果跳转目标跨页还要再多一个周期。这个“成立与否 跨页与否”的组合是周期误差最常见的来源。调试的时候我会把每个分支的判定结果、目标地址、是否跨页打印出来手工核对一遍往往能捞到好几个隐藏问题。5.4 JSR、RTS 和中断的堆栈写序JSR 把返回地址的低字节和高字节依次压栈RTS 弹栈时先弹高字节再弹低字节并且给 PC 加 1。这个“加 1”很容易被忽略。RTI 则是先恢复状态寄存器再恢复 PC。中断流程里压栈的又是状态寄存器加 PC顺序和 BRK 有微妙差别。我自己记口诀JSR 压“低位、高位”RTS 弹“高位、低位再加 1”。如果你发现子程序返回之后 PC 总差一个字节多半是这里错了。可以把压栈前后的 SP 和内存值都打印出来一眼就能看到栈区是否错位。6. 让 CPU 不直接持有内存总线回调解耦是关键6.1 CPU 内部为什么不该放 64KB 数组如果图省事直接在 CPU 类里放一个 uint8_t ram[65536]前期看起来运行良好。但 NES 的地址空间根本不是一块连续的 RAM0x0000 - 0x07FF 是 SRAM0x2000 - 0x3FFF 会镜像到 PPU 寄存器0x4020 - 0xFFFF 由卡带决定。CPU 如果自己拿大数组就会把 CPU 内核和某一种特定的整机架构绑死。olcNES 的做法是 CPU 只持有总线指针通过 Read/Write 函数访问地址。这样 CPU 代码可以原封不动地套用到其他 6502 平台只要换一个总线实现即可。6.2 读写的“副作用”必须留出空间在真实硬件里读一个地址可能不仅是读数据。读 PPU 寄存器可能清除某个状态位读卡带 mapper 寄存器可能切换内存页。如果 Read/Write 一开始就做成数组下标后面加副作用时必须把所有调用点都改一遍。所以从这个阶段起建议就让 CPU 调用总线接口uint8_t olc6502::Read(uint16_t addr) { return bus-cpuRead(addr); } void olc6502::Write(uint16_t addr, uint8_t data) { bus-cpuWrite(addr, data); }总线类内部再根据地址段分流。这样 CPU 代码完全不知道外部有什么设备PPU 或者 mapper 想接入时只需要在总线的分发函数里加分支。6.3 中断和复位信号也要通过“引脚”表达CPU 类里通常会有几套控制接口对应真实芯片的引脚Reset、NMI、IRQ。Reset 时从复位向量读取 PC 初值NMI/IRQ 在当前指令执行结束后压栈并跳转到对应向量。Part#2 的代码虽然还没接上真正的 PPU但中断接口已经预留好了后面 PPU 每帧触发 NMI、手柄按键触发输入都会用到同一套设施。写中断时最容易犯的错是“打断当前指令”。真实 CPU 是在一条指令完成后才响应中断而不是取指中途被打断。模拟时如果有类似 pending_interrupt 的标记也必须在当前指令完整执行之后再处理否则会出现指令执行到一半跳走的状态调试起来格外痛苦。7. 怎么验证 CPU 实现是对的测试台、参考日志和“疑难周期”7.1 搭一个最小测试台不急着接画面的阶段我给 CPU 准备了一个最简陋的“总线”里面就一个 64KB 数组class TestBus { public: uint8_t ram[65536]; uint8_t cpuRead(uint16_t addr) { return ram[addr]; } void cpuWrite(uint16_t addr, uint8_t v) { ram[addr] v; } };然后在 ram 里按指令格式写入几行“人肉十六进制”0xA9 0x42LDA #$420x8D 0x00 0x02STA $02000x4C 0x00 0x80JMP $8000每次执行一小段检查 A 寄存器、目标内存地址、PC 的值是否和我用手推演的一致。这一套能快速确认最基本的三条指令路径没问题取立即数、写内存、绝对跳转。7.2 用参考机器日志逐条比对“看起来能跑”并不等于“模拟得对”。我后来使用社区流传的 6502 测试 ROM它会把每条指令执行前后的寄存器快照、操作数、周期数输出出来和真实机器日志逐条比对。重点观察三点助记符和操作数是否一致。指令执行后的寄存器值、内存值、状态标志是否一致。周期数是否一致。跑这类测试之前务必先把两件事处理干净栈指针的复位初值以及 BRK/中断时 B 位的语义。这两项不对往往从第一条指令的日志就开始产生全部偏移看起来像所有指令都错了实际根子只是状态寄存器的某一位处理有误。7.3 周期对不上时的固定排查套路周期误差通常不会立刻崩但它会累积成“音乐快几帧”“滚动速度不对”这样的诡异问题。我的排查路径一直是固定的四步先确认 PC 和操作码正确打印“PC opcode 助记符”。确认寻址模式算出的地址正确尤其是指令直接操作的内存地址。确认指令执行后的寄存器和状态标志正确。前面全对了再看周期计数。如果仍差一两个周期优先检查分支指令是否跨页、变址寻址是否跨页。跨页判断这段逻辑我建议统一放在地址模式函数里并且把是否跨页单独记录成一个字段方便在主循环尾部打印。这样遇到周期问题时调试目标非常明确能直接定位到某一条跨页指令。提示如果你在某条指令上找不到周期差先去查“页边界”而不是算法逻辑。6502 模拟里绝大多数周期误差不是因为算错而是因为漏了“跨页多一个周期”这个硬件行为。7.4 一个容易被忽视的栈指针初值问题真实 NES 的 2A03 在上电复位后堆栈指针通常会被初始化到 0xFD。如果你的模拟器在 Reset 时把 SP 随意设为 0xFF或者干脆不初始化那么程序一执行 PHA/PLA 或者 JSR/RTS 就会从错误的栈地址开始。测试 ROM 虽然能通过指令逻辑检查但和真实主机比对时还是会差出几行错误。我把 SP 初值写死成 0xFD 后很多测试从“看起来没问题”变成了“逐周期一致”。8. 我自己看完 Part#2 后的三个体会整个 CPU 部分啃下来最深的体会是模拟器的难点不在指令的数量而在“细节的精确度”。 151 个操作码并不是拦路虎真正花掉时间的全是 B 位、跨页周期、栈指针初值、JMP 间接寻址的回绕这类“文档角落”里的行为。olcNES 的这份代码把这些细节都做得非常清楚值得逐行去读。第二个体会是CPU 模拟一定要和总线解耦。前期多写一个 Read/Write 分发函数后期接 PPU、接 mapper 会省下无数力气。我自己的一个小建议是先把所有内存访问统一到总线接口再做任何性能优化否则将来替换总线时所有指令函数都要动一遍。第三个体会是测试要早做日志要可读。不需要等全部指令写完才开始验证每实现一条指令就手动构造最直接的测试跑一小段检查结果等指令集差不多齐了再用社区流通的测试 ROM 做完整验证。这种“边写边验”的方式会让整个模拟器项目进入稳定的正循环。如果让我重来一次我会在开始写任何指令之前先把“指令表 寻址函数 执行函数”这三层结构搭好然后把总线的 Read/Write 接好最后才逐个填充操作码。这样每一步都能立刻验证而且永远不会出现到后期几百行代码堆在一起、看不出 bug 在哪的窘境。这也是我在 olcNES 这份学习项目里收获最大的一点。