:把 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、Y,STA、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 可以共用同一套加法逻辑,代码更简洁。
// SBC:a = 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 或少 1 | ADC/SBC 的进位处理有误 | 用固定测试向量验证 C 标志 |
| 栈内容错乱 | PHA/PLA 的 SP 增减顺序不对 | 逐字节打印栈区变化 |
| JMP 间接跳转异常 | 没有模拟硬件缺陷 | 确认是否按真实硬件行为处理跨页 |
| 周期数与参考实现不一致 | 操作码表周期填错 | 逐项核对标准周期表 |
5.4 提高排查效率的几个技巧
:给 CPU 模拟代码加上断点单步功能是我用过性价比最高的调试手段。你不需要独立模拟器那种复杂的调试器,只要在 clock() 里加入一个“是否暂停”的开关,并在每条指令执行前打印 PC、操作码、寄存器状态就够了。打印日志配合操作码表,能快速定位绝大多数问题。
:还有一个技巧是准备一份经过验证的 CPU 测试程序集。网上有很多社区维护的 6502 功能测试 ROM,它们会逐条指令检查寄存器和标志位,任何不一致都会触发报告。把这些测试程序集成到你的最小环境里,跑一遍就能覆盖大部分指令。这个方法比手工验证高效得多。
6. 读到这里,我的一点个人体会
:读完 olcNES 的 CPU 模拟代码,我最大的收获不是学会了某个数据结构,而是理解了“精确复现”和“看起来正确”之间的差距。模拟器开发的乐趣也正在于此:你写下的每一行代码,都是在跟一台几十年前的硬件对话。
:如果你正在模仿这样的项目,我的建议是别急着把整个 CPU 一次写完。先把读取操作码、解码、寻址模式、执行这四步框架搭出来,跑通一条 LDA,再逐步扩展指令集。每扩展一条指令,就用测试程序验证一下寄存器、标志位和周期数。这个过程虽然慢,但每一步都走得很扎实。
:接下来我打算继续研究 NES 的总线交互和 PPU 周期对齐,因为 CPU 模拟真正“活”起来,一定要等它跟其他硬件模块产生联动之后。希望这篇记录能给你在啃模拟器源码的路上,提供一张还算清晰的地图。