264. CPU 的 NMI 中断

实现 CPU 端的 NMI 中断机制。

264 / 356 · tests/chapter_04_ppu_timing_and_vblank/test_264_cpu_interrupt_nmi.py

参考资料

https://www.nesdev.org/wiki/CPU_interrupts
https://www.nesdev.org/wiki/PPU_registers#Vblank_NMI

需要更新的文件

emulator/cpu/cpu.py

此步骤存在的原因

PPU 已经可以产生 nmi_requested 信号。在通过系统/主机协调器把这个信号接入 CPU 之前,CPU 本身必须先知道如何独立完成 NMI 序列。

什么是 NMI?NMI 意为不可屏蔽中断(Non-Maskable Interrupt)。在 NES 上,PPU 可以在 VBlank 时请求 NMI,从而让游戏代码运行其垂直消隐处理程序。

直观模型

NMI 就像一次硬件级的紧急跳转。CPU 暂停当前的执行路径,保存足够的状态以便之后返回,然后跳转到 NMI 向量中保存的地址。

机制模型

当 NMI 被接受时,CPU 会执行以下序列

1. Push PC high byte
2. Push PC low byte
3. Push status with:
       ONE_FLAG set
       B_FLAG clear
4. Set INTERRUPT_FLAG in CPU status
5. Read low byte from $FFFA
6. Read high byte from $FFFB
7. Set PC = high << 8 | low

重要区别

NMI 不会写入 $FFFA/$FFFB。CPU 只是读取这两个地址。在真实的 ROM 中,向量字节本来就存在于 PRG ROM 中。在这些测试里,FakeROM 让我们可以把这些字节作为测试搭建的一部分预先准备好。

实现示例

NMI_VECTOR_LOW = 0xFFFA
NMI_VECTOR_HIGH = 0xFFFB

class CPU:
    ...

    def interrupt_nmi(self) -> None:
        # Save the current PC so RTI can restore it later.
        pc_high = (self.pc >> 8) & 0xFF
        pc_low = self.pc & 0xFF
        self.push_stack(pc_high)
        self.push_stack(pc_low)

        # Hardware interrupts push status with B clear and bit 5 set.
        status_to_push = self.p | ONE_FLAG
        status_to_push &= ~B_FLAG
        self.push_stack(status_to_push)

        # After accepting an interrupt, set the interrupt-disable flag.
        self.p |= INTERRUPT_FLAG

        # NMI vector bytes are read from PRG space. NMI does not write them.
        low = self.bus.read(NMI_VECTOR_LOW)
        high = self.bus.read(NMI_VECTOR_HIGH)
        self.pc = low | (high << 8)

具体的运行时示例

FakeROM setup:
    $FFFA = $00
    $FFFB = $C0

CPU before NMI:
    PC = $8123
    S  = $FD

CPU.interrupt_nmi()

CPU after NMI:
    stack contains return PC/status
    PC = $C000

以 S = $FD、PC = $8123 为例的栈示例:

write $81 to $01FD, S becomes $FC
write $23 to $01FC, S becomes $FB
write status to $01FB, S becomes $FA

常见误解

硬件中断不会设置 B 标志位。BRK/PHP 压栈时状态字节会设置 B,但 NMI 压栈时状态字节的 B 是清零的。压入状态字节中的第 5 位 ONE_FLAG 仍然会被置位。

不在本步骤范围内

  • 由 PPU 调用 CPU.interrupt_nmi()
  • 清除 ppu.nmi_requested
  • 精确的中断延迟/周期数
  • IRQ/APU/映射器中断
  • RTI 行为,这在 CPU 章节前面已经测试过

运行本课

uv run pytest tests/chapter_04_ppu_timing_and_vblank/test_264_cpu_interrupt_nmi.py -v