olcNES 6502 CPU模拟器实现与代码拆解
把 olcNES 项目读到 CPU 这块时我最大的感受是模拟器最核心的部分其实不是“还原一台机器”而是“用软件的方式重写一套硬件行为”。6502 CPU 模拟代码就是这套行为的骨架。这篇 Part#2 学习记录我会从 6502 的寄存器、标志位、寻址模式这些地基讲起再重点拆解 olcNES 的 CPU 模拟代码是如何组织取指、解码、执行的最后分享一些我在读代码和验证过程中踩过的坑。这篇内容适合两类读者。一类是想入门模拟器开发、一直停留在“看原理”阶段的开发者另一类是对 6502 指令流水线感兴趣、想搞懂 CPU 微架构的嵌入式或逆向爱好者。读完你至少能明白操作码表为什么要用表驱动、周期计数到底怎么算、寻址模式在代码里长什么样、以及为什么有人说 6502 的 JMP 间接跳转有“硬件缺陷”。1. 先看清 6502 的家底寄存器、标志位与寻址模式1.1 寄存器少得可怜但够用6502 是一颗 8 位 CPU数据总线 8 位地址总线 16 位。它的可见寄存器比我见过的绝大多数 MCU 都要少累加器 A、索引寄存器 X、索引寄存器 Y、栈指针 SP、程序计数器 PC以及一个 8 位的状态寄存器 P。这里的 A、X、Y 都是 8 位PC 是 16 位SP 是 8 位但栈固定在 0x0100 到 0x01FF 这一页所以 SP 只存低 8 位偏移。寄存器少意味着指令集设计必须很克制。LDA、LDX、LDY 负责从内存加载数据到 A、X、YSTA、STX、STY 负责把寄存器写回内存再加上 TAX、TAY、TXA、TYA、TSX、TXS 这类寄存器互传指令就构成了数据传输的全部家当。没有复杂的寄存器窗口没有流水线重命名也没有乱序执行这对模拟器作者来说非常友好。我第一次看这部分源码时心里想的是“就这么点东西也能跑出一台游戏机”但后来发现6502 真正厉害的地方不在寄存器数量而在寻址模式。同一个 LDA 指令可以搭配十几种取数方式一条指令在不同寻址模式下会表现出完全不同的行为这才是代码设计的复杂度来源。1.2 状态寄存器里的 8 个开关状态寄存器 P 有 8 个标志位按位从低到高分别是 C进位、Z零、I中断禁用、D十进制模式、B断点、U未使用、V溢出、N负数。这些标志位直接影响分支指令的走向所以 CPU 模拟代码里必须对它们做精细的写入操作。这里有个新手最容易忽略的点B 标志在真实硬件上并不存在于状态寄存器内部它是在数据压栈时由 BRK 指令或硬件中断“合成”到栈上某一位的。也就是说你在代码里看到的 status 字节和压栈时的 status 字节可能不完全一样。olcNES 的实现里特意区分了“内部状态”和“压栈状态”读代码时要留意这个细节否则后面做中断处理时栈内容很容易对不上。D 标志在 NES 主机上实际上是被忽略的因为 NES 的 CPU 工作模式中十进制运算并不启用。但 6502 指令集是有 ADC 十进制模式的所以模拟代码里仍然要留出处理 D 标志的余量。很多模拟器为了兼容性直接让 ADC/SBC 无视 D 标志这虽然能跑大部分游戏严格来说并不符合 6502 芯片的真实行为。1.3 指令集虽小操作码组合却很多6502 官方指令大约 56 条但每条指令配合不同寻址模式会形成 151 个有效操作码。比如 LDA 就有立即、零页、零页 X、绝对、绝对 X、绝对 Y、索引间接、间接索引这 8 种形态。模拟器代码里最忌讳的做法是写一长串 if-else 来判断操作码因为 256 个操作码大部分都有对应行为用表驱动是最自然的方案。操作码表里还会出现“未定义指令”也就是 0x02、0x44、0xC2 这类官方没有定义的字节。真实 CPU 遇到未定义指令时行为非常混乱但很多模拟器直接把它们当作 NOP 处理或者填充一个占位条目。olcNES 的做法也是在表中给一个固定条目保证取指循环不会崩掉同时在调试时能看出程序跑飞到了什么地址。1.4 寻址模式同一个指令不同的取数姿势寻址模式是理解 6502 模拟代码的重中之重。常见的有立即数操作数就在指令后面、零页地址高字节为 0x00访问速度最快、绝对16 位完整地址、相对分支用8 位偏移量、间接JMP 专用从指定内存地址读取 16 位跳转目标、索引间接和间接索引这两种“指针式”寻址。索引间接和间接索引是最绕的两种模式。索引间接是先将 X 加到操作数地址上再从该地址读出 16 位指针最后访问指针指向的内存间接索引则是先从操作数地址读出 16 位指针再把 Y 加到指针上。这两个模式在代码实现上只差一行加法顺序但一旦搞反整个思想就乱了。我建议读代码时把这两种模式的地址计算函数并排对照亲手推演一遍。2. 模拟精度与架构取舍为什么 CPU 代码要这样设计2.1 指令级、周期级、总线级你到底在模拟什么写 CPU 模拟器前要先想明白一个问题你追求的是“能跑”还是“严格复现”。指令级模拟只关心每条指令执行后的寄存器状态周期级模拟还要保证每个 CPU 时钟周期的耗时和总线访问行为和真实硬件一致总线级模拟则要连 CPU 和内存之间的每一个读写的时序细节都还原。对于 NES 模拟器来说周期级模拟几乎是必须的。因为 NES 的 PPU图像处理单元和 CPU 是同步工作的CPU 访问 PPU 寄存器的时间点会直接影响画面生成。如果 CPU 的周期数算不准游戏画面会出现各种奇怪的撕裂或错位。olcNES 的 CPU 代码采用的是“指令级逻辑 周期级计数”的混合方案每条指令会先根据操作码表拿到标准周期数再根据实际寻址情况增加额外周期。读代码时可以特别关注一个叫 page_cycles 或者“跨页周期”的字段。6502 的某些指令在跨越内存页边界时需要多花一个周期比如 LDA abs,X 如果地址低字节进位到高字节就会多一个周期。这个细节看似微小但对游戏时序影响不小olcNES 的操作码表里专门为此预留了额外周期字段。2.2 表驱动设计用一张表替代一百次判断用表驱动的最直接好处有两个一是代码量小二是查错容易。你在 olcNES 的 CPU 代码里会看到一个由 256 个条目组成的大数组每个条目对应一个操作码包含助记符、寻址模式、字节数、标准周期数、额外周期数。这样的结构让新增指令或修正周期变得非常方便。但表驱动也有代价它把“指令行为”和“操作码索引”完全拆开了。查表只能告诉你这条操作码属于哪个助记符、什么寻址模式指令真正干了什么还是要由执行函数来响应。所以 olcNES 里会有一个很大的执行函数通过操作码的助记符 ID 来分发到具体实现。这相当于把“解码”和“执行”分开读代码时要分清这两个阶段。我的经验是看表驱动代码时先不要逐行读执行函数而是先画一张小表操作码、寻址模式、字节数、周期数、行为接口。表格列完代码结构基本就清晰了。如果一上来就去读那个几百行的执行函数很容易被各种标志位操作绕晕。2.3 与总线解耦CPU 不直接碰硬件6502 模拟代码不能直接操作内存数组也不能直接访问 PPU 寄存器。它应该通过一个统一的读写接口来访问外部设备。olcNES 的做法是让 CPU 通过一个总线对象来执行 read 和 write这个总线对象再根据地址范围决定访问内容可能是 RAM、可能是 PPU 寄存器也可能是卡带里的游戏 ROM。这种设计对后续扩展特别重要。当你把总线换成更真实的实现时CPU 模拟代码完全不用改。反过来如果你在 CPU 代码里直接写死了内存数组后续加总线逻辑时就得翻工。我见过好几个模拟器项目就是在这个问题上踩了坑前期图省事后期重构成本极高。3. 逐步拆解 CPU 模拟代码从数据结构到执行循环3.1 操作码表的结构体与初始化olcNES 的 CPU 模拟代码里操作码表是一个结构体数组。结构体大概长这样struct OLCOpcode { std::string name; // 助记符比如 LDA uint8_t mode; // 寻址模式枚举 uint8_t bytes; // 这条操作码占据几个字节 uint8_t cycles; // 标准周期数 uint8_t pageCycles; // 跨页时的额外周期数 };初始化这个表是件非常枯燥但重要的工作。每个操作码的字节数和周期数必须严格对照 6502 官方文档或经社区验证的周期表。一旦某个周期数填错后面所有依赖周期的功能都会跟着出错而且这种错误极难定位因为画面只是“稍微不正常”。我建议读源码时先对照官方周期表把整个操作码表重新检查一遍。虽然很枯燥但这是建立对指令集整体认知的最好方式。检查完你会发现大多数指令的周期数集中在 2 到 7 之间只有少数分支指令会因跳转与否产生不同周期这类特殊情况通常会在执行函数里单独处理。3.2 时钟驱动的取指循环clock() 函数CPU 模拟的“心脏”是一个 clock() 函数。它每次被调用就代表一个 CPU 时钟周期过去。olcNES 的思路是当当前指令没有剩余周期时就取新的操作码、完成解码和地址计算如果还有剩余周期说明当前指令正在执行中只需要把计数减一。void cpu::clock() { if (cycles 0) { // 取指 opcode read(pc); // 根据操作码查表得到寻址模式和周期 // 执行寻址模式对应的地址计算 // 执行指令逻辑 cycles instruction.cycles; } cycles--; }这个状态机的精妙之处在于它天然支持“一条指令横跨多个时钟周期”的模拟。比如零页寻址只需要一次内存访问绝对寻址需要两次读取地址字节加一次读取数据这些都会反映在周期计数上。如果你想把模拟精度提到总线级只需要把每个周期里读写总线的具体信号记录下来但 olcNES 暂时不需要做到那一步。一个容易忽略的细节是取指后 PC 的增加时机。对大多数指令来说PC 应该在读取操作码后立即加一然后再去读取操作数。如果读操作数前不加一地址直接错一字节整个程序基本废了。我在自己调试时就犯过这个错现象特别诡异程序第一遍执行正常循环几次后突然跳到随机地址。3.3 寻址模式的地址计算多种模式几条分支寻址模式在代码里通常是一个 switch。每种模式返回一个 16 位地址有些模式还会把取到的操作数放到统一的 fetched 变量里。比如立即数寻址直接返回 PC 当前地址然后 PC 加一这样执行函数读到的就是操作数本身零页寻址只读取一个字节作为低 8 位地址高 8 位补 0。这里面最值得反复推敲的是间接寻址。JMP 指令的间接模式需要从指定地址读取两个连续字节组成 16 位跳转目标。6502 的著名缺陷就在这里如果这两个字节所在的地址跨越了内存页边界也就是低字节是 0xFF 时高字节不会像你想象的那样从下一个地址读取而是从当前页的开始位置读取。模拟器为了真实还原主机行为必须把这个缺陷也模拟出来。我看代码时最开始以为这是个 bug差点提交一个“修复”。后来查阅资料才明白这是真实 6502 芯片的硬件行为。这个教训非常深刻模拟器的目标不是写出“更正确”的 CPU而是复现真实硬件的所有行为包括臭名昭著的 bug。3.4 指令执行标志位是这里的重头戏指令执行函数是 CPU 模拟代码里最长的部分。每条指令的操作逻辑并不复杂真正复杂的是标志位的更新。以 ADC 为例它要做加法并更新 C、V、Z、N 四个标志位。C 是进位输出Z 是结果为零N 是结果的最高位V 是有符号溢出判断条件是被加数和结果符号位不一致时置位。uint16_t sum a fetched (status C_FLAG ? 1 : 0); if (sum 0xFF) setFlag(C, true); setFlag(Z, (sum 0xFF) 0); setFlag(N, sum 0x80); setFlag(V, ((a ^ sum) (fetched ^ sum) 0x80)); a sum 0xFF;这段代码里最容易写错的是 V 标志位的判定。如果对二进制的补码运算理解不够深很容易把 V 进位和 C 进位混为一谈。记住一句话C 位是无符号溢出V 位是有符号溢出两者完全不同。我在读代码时专门写了一个验证脚本用已知的测试向量去检查每个标志位结果还真抓到一个不一致的地方。4. 实操演练跟着 LDA、ADC、JMP 走一遍代码4.1 LDA 立即寻址最简单的入口建议从 LDA 立即数开始跟着代码走。它的操作码是 0xA9寻址模式是立即数总字节数 2标准周期 2。在 clock() 里第一周期读取 0xA9第二周期读取数据 0x42 并放入 A 寄存器然后根据结果更新 Z 和 N 标志位。读源码时重点看它是怎么处理“取指后 PC 加一”和“读取操作数后 PC 再加一”的。这两个加一动作如果写错位置指令读取的数据就会漂移一字节。跟着断点走一遍你会发现整个过程非常机械先算地址再读数据最后写结果和标志位。这条最简单的指令能帮你建立对整个执行框架的直觉。4.2 ADC 与 SBC算术细节决定成败ADC 的进位处理我上面已经给过代码示例。SBC 的实现一般不会直接用减法而是用“加补码”的方式把借位转成进位把减数按位取反再做加法。这样 ADC 和 SBC 可以共用同一套加法逻辑代码更简洁。// SBCa a - fetched - (1 - C) uint16_t sum a (fetched ^ 0xFF) (status C_FLAG ? 1 : 0);这里有个细节执行 SBC 之前必须保证 C 标志位代表的是“无借位”。如果你在 SBC 前手动清零 C行为就和真实 6502 不符。我调试总线测试程序时遇到过这种问题最后逐条指令对比参考实现才找到根因。4.3 JMP 间接寻址的硬件缺陷JMP 操作码是 0x4C绝对和 0x6C间接。间接模式下的硬件缺陷我在前面提到了当指针低字节是 0xFF 时高字节要从当前页的起始位置读。比如 JMP ($12FF)真实 CPU 读取的是 $12FF 的低字节和 $1200 的高字节而不是你预想中的 $1300。olcNES 的代码里会刻意保留这个行为侧边注释里甚至会提醒你“这是真实硬件行为不要修”。我第一次读到这里时忍不住笑了一下这大概是模拟器开发者之间心照不宣的默契。如果你在测试程序里发现 JMP 间接跳转地址诡异先检查一下是不是撞上了这个缺陷。4.4 栈指令与 BRK 处理PHA、PHP、PLA、PLP 这四条栈操作指令会直接操作 SP 指向的栈内存。PHA 先把 SP 减一再把 A 写入栈PLA 先把栈内容读回 A再把 SP 加一。顺序写反的话栈内容和真实硬件就不一致。BRK 指令的处理有一个特别容易忽略的点BRK 会把“当前 PC 1”压栈而不是当前 PC。这意味着中断返回时必须额外考虑这个偏移。如果模拟器忽略了这个细节BRK 后的 RTI 就会跳回错误的地址。我在调试中断处理时被这个问题卡了一个晚上。5. 调试与验证CPU 模拟代码怎么测才靠谱5.1 最小测试程序思路对着代码看一百遍不如让 CPU 跑一条指令验证一遍。我建议先准备一个最简单的测试环境一块 64KB 的空白内存把程序从 0x8000 开始放好重置向量指向 0x8000然后手动循环调用 clock()。测试程序可以从 LDA #0x01 开始跑完检查 A 寄存器是否等于 0x01、Z 标志是否清零、N 标志是否清零。跑通这条指令之后再逐步加入 STA、TAX、ADC 等指令直到能完整执行一小段求和程序。每步都做寄存器状态断言出错时能立刻定位到具体指令。5.2 借助操作码周期表核对周期计数是另一个容易出错的点。我建议每实现或阅读一条指令都对照标准的 6502 周期表核对一遍。特别是分支指令条件成立与不成立时周期数不同跨页时还要再加一周期。很多“画面轻微微妙不对”的问题最后都指向周期数少了或多了一两个。我自己总结了一个土办法在 clock() 里记录累计周期数跑一段已知时长的测试程序然后看 CPU 的时钟计数是否符合预期。比如一段循环 10 次的简单程序总周期数应该是一个可精确计算的数值如果对不上就逐步缩小范围定位是哪条指令的周期错了。5.3 常见错误速查表症状可能原因排查建议程序跑到随机地址PC 加一时机错误检查取指后和读操作数后的 PC 更新分支跳转方向反了标志位判定逻辑写反检查 CMP 后的 Z/C 标志位设置加法结果多 1 或少 1ADC/SBC 的进位处理有误用固定测试向量验证 C 标志栈内容错乱PHA/PLA 的 SP 增减顺序不对逐字节打印栈区变化JMP 间接跳转异常没有模拟硬件缺陷确认是否按真实硬件行为处理跨页周期数与参考实现不一致操作码表周期填错逐项核对标准周期表5.4 提高排查效率的几个技巧给 CPU 模拟代码加上断点单步功能是我用过性价比最高的调试手段。你不需要独立模拟器那种复杂的调试器只要在 clock() 里加入一个“是否暂停”的开关并在每条指令执行前打印 PC、操作码、寄存器状态就够了。打印日志配合操作码表能快速定位绝大多数问题。还有一个技巧是准备一份经过验证的 CPU 测试程序集。网上有很多社区维护的 6502 功能测试 ROM它们会逐条指令检查寄存器和标志位任何不一致都会触发报告。把这些测试程序集成到你的最小环境里跑一遍就能覆盖大部分指令。这个方法比手工验证高效得多。6. 读到这里我的一点个人体会读完 olcNES 的 CPU 模拟代码我最大的收获不是学会了某个数据结构而是理解了“精确复现”和“看起来正确”之间的差距。模拟器开发的乐趣也正在于此你写下的每一行代码都是在跟一台几十年前的硬件对话。如果你正在模仿这样的项目我的建议是别急着把整个 CPU 一次写完。先把读取操作码、解码、寻址模式、执行这四步框架搭出来跑通一条 LDA再逐步扩展指令集。每扩展一条指令就用测试程序验证一下寄存器、标志位和周期数。这个过程虽然慢但每一步都走得很扎实。接下来我打算继续研究 NES 的总线交互和 PPU 周期对齐因为 CPU 模拟真正“活”起来一定要等它跟其他硬件模块产生联动之后。希望这篇记录能给你在啃模拟器源码的路上提供一张还算清晰的地图。