자바스크립트를 활성화 해주세요

NES CPU(6502) 에뮬레이션 - 2

 ·   ·  ☕ 8 min read  ·  ✍️ Yogo

이전 포스트에서 6502 프로세서의 간략한 특징과 명령어, 어드레싱 모드에 대해서 정리를 했습니다. 에뮬레이션을 위해서는 더 많은 내용의 정리가 필요하지만 이미 레퍼런스 자료가 많이 있으므로 상세한 정보는 생략하고 일부 코드 예제를 들어 어떻게 구현할 것인지에 대한 고찰과 짤막한 코드와 함께 구현하는 과정을 남겨 보겠습니다.

프로그래밍 언어의 선택

오픈소스로 공유된 NES 에뮬레이터들을 보면 알려진 거의 모든 언어로 포팅이 되어있었습니다. C/C++은 물론이고 Javascript 나 Go, Rust 등 언어로도 구현이 되어 공유되고 있었습니다. 처음 에뮬레이션 작업을 시작할 때 메모리나 하드웨어 제어라는 측면에서 볼 때 C언어가 적합하다고 판단하여 해당 언어로 opcode를 작성을 시작하였습니다.

하지만 롬 파일 제어나 간단하게나마 그래픽 표시나 사운드 출력이라도 하려면 적어도 GUI 플랫폼 기반 환경에서 작업하는 것이 나을 것이라는 판단이 들었습니다. 그래서 처음에는 Visual Studio의 C++ 이나 Qt1를 고려하였었습니다. 그런데 어차피 원리만 이해하면 언어는 크게 상관이 없을거란 생각이 들어 결론적으로는 윈폼(Win Forms) 환경에서 C#으로 개발을 시작 하게 되었습니다.

NES CPU 에뮬레이션 개발 절차

말이 좋아 에뮬레이션이지 평소 개발과는 전혀 생소한 것을 만들기 위해서 첫 코드를 작성하려니 어디서 부터 어떻게 시작을 해야할지 생각보다 막막했습니다. 목표와 해야할 일은 있었으나 어느 절차대로 해야할지 우선순위가 쉽게 파악되지 않았습니다. 그래서 일단은 공개된 코드 참조를 통해서 디테일 보다는 동작 흐름을 파악하여 에뮬레이션을 위한 지식의 마중물을 마련했습니다.

그렇게 머릿속으로 정리를 한번 하고 그 후에는 가장 선행되어야 할 항목부터 우선 순위를 두어 다음과 같은 순서로 개발을 진행 하였습니다.

  1. 레지스터 정의 및 제어
  2. 메모리 정의 및 제어
  3. 명령어 세트
  4. 어드레싱 모드
  5. 명령어 사이클(Instruction Cycles)
  6. 인터럽트 및 기타

레지스터 정의 및 제어

레지스터는 총 6개이고 8비트 또는 16비트(PC) 크기를 가지고 있습니다. 레지스터 대부분이 특별한 조건이나 동작이 필요없이 일반 메모리처럼 데이터를 읽고 저장할 수 있기만 하면 되므로 크게 고려 할 사항은 없었습니다.

다만, 상태 레지스터는 비트 단위 플래그 제어가 필요하므로 이에 따른 고려가 필요했습니다. 이때 하드웨어와 가장 유사하게 구현하는 방법은 byte 단위의 변수를 선언하고 비트 마스킹을 통해서 플래그 비트(bit)를 세트(set) 또는 클리어(clear) 하는 것입니다. 하지만 우리는 상태 레지스터 동작의 구현에 있어 반드시 자료형과 하드웨어 구성을 완전 동일해야 유지해야하는 것은 아니므로 각각의 플래그를 일반적인 자료형으로 선언하여 마스킹 대신 1과 0만 사용하는 방법을 고려했습니다.

그래서 플래그를 byte로 단위로 정의하고 직접 1과 0만 사용하는 구조체를 작성하였습니다. C#에서는 구조체에 메서드 선언이 가능하므로 일부 조건에 의한 동작 기능 들을 메서드로 정의 하였습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
//////////////////////////////////////////////////////////
// 바이트로 선언, 비트 단위 제어 (미채용)
[Flags]
enum StateFlags
{
    Carry = 0b_0000_0001,
    Zero = 0b_0000_0010,
    InterruptDisable = 0b_0000_0100,
    DecimalMode = 0b_0000_1000,
    BreakCommand = 0b_0001_0000,
    Unused = 0b_0010_0000,
    Overflow = 0b_0100_0000,
    Negative = 0b_1000_0000,
}

byte statusRegister = 0;

void SetStatusFlag(StateFlags flag) {}
void ClearStatusFlag(StateFlags flag) {}

//////////////////////////////////////////////////////////
// 구조체로 선언, 직접 플래그 제어 또는 메서드 이용 (실질적으로 채용한 방법)
struct StatusRegister
{
    public byte CARRY;
    public byte ZERO;
    public byte INTERRUPT_DISABLE;
    public byte DECIMAL_MODE;
    public byte BREAK;
    public byte UNUSED;
    public byte OVERFLOW;
    public byte NEGATIVE;

    public void Reset()
    {
        SetByte(0x24);
    }

    public void SetByte(byte value)
    {
        this.CARRY = (byte)(value & 0x01);
        this.ZERO = (byte)((value >> 1) & 0x01);
        this.INTERRUPT_DISABLE = (byte)((value >> 2) & 0x01);
        this.DECIMAL_MODE = (byte)((value >> 3) & 0x01);
        this.BREAK = (byte)((value >> 4) & 0x01);
        this.UNUSED = (byte)((value >> 5) & 0x01);
        this.OVERFLOW = (byte)((value >> 6) & 0x01);
        this.NEGATIVE = (byte)((value >> 7) & 0x01);
    }
    public byte GetByte()
    {
        return (byte)((this.CARRY & 0x01)
            | ((this.ZERO << 1) & 0x02)
            | ((this.INTERRUPT_DISABLE << 2) & 0x04)
            | ((this.DECIMAL_MODE << 3) & 0x08)
            | ((this.BREAK << 4) & 0x10)
            | ((this.UNUSED << 5) & 0x20)
            | ((this.OVERFLOW << 6) & 0x40)
            | ((this.NEGATIVE << 7) & 0x80));
    }

    public void SetCarryFlag(ushort value)
    {
        this.CARRY = (byte)(value > 0xFF ? 1 : 0);
    }

    public void SetZeroFlag(byte value)
    {
        this.ZERO = (byte)(value == 0 ? 1 : 0);
    }

    public void SetNegativeFlag(byte value)
    {
        this.NEGATIVE = (byte)((value & 0x80) > 0 ? 1 : 0);
    }

    public void SetZeroNegativeFlags(byte value)
    {
        SetZeroFlag(value);
        SetNegativeFlag(value);
    }
}

////////////////////////////////////////////////////////
// CPU 레지스터
struct CpuRegisters
{
    public byte X;      // index X
    public byte Y;      // index y
    public byte A;      // accumulator
    public byte SP;     // stack pointer
    public ushort PC;   // program counter        
    //public byte SR;   // status register
    public StatusRegister SR;
}

CPU 메모리 인터페이스

레지스터가 마련되었으니 이제는 게임 카트리지(여기서는 롬파일)로부터 Opcode를 읽거나 램이나 레지스터에 접근하여 읽기/쓰기 작업을 수행할 수 있도록 메모리 인터페이스를 정의합니다.

CPU는 아래에 표시된 메모리 맵2에 접근 할 수 있습니다. 하지만 CPU의 메모리 맵이라고 해서 CPU 내에 메모리만 접근 할 수 있는 것은 아니고 롬 카트리지와 PPU 레지스터의 하드웨어 메모리가 버스(Bus) 형태로 공유되어 접근할 수 있도록 되었습니다.

일단 Mirrors 영역을 제외하면 Zero Page, Stack, RAM(CPU 내부 램)은 CPU 내에 존재하는 메모리 영역입니다. 그리고 그 외의 영역은 PPU(Picture Processing Unit)나 카트리지, 기타 하드웨어(APU, 조이스틱, 인터럽트 벡터)의 메모리나 레지스터 영역에 해당하고 버스 인터페이스를 통해 상호 접근이 가능합니다.

// CPU 메모리 맵
+--------------------+ $10000       +--------------------+ $10000
|                    |              |                    |
|                    |              |     PPG-ROM        |
|                    |              |     Upper Bank     |
|                    |              |                    |
|      PPG-ROM       |              +--------------------+ $C000
|                    |              |                    |
|                    |              |     PPG-ROM        |
|                    |              |     Lower Bank     |
|                    |              |                    |
+--------------------+ $8000        +--------------------+ $8000
|        SRAM        |              |        SRAM        |
+--------------------+ $6000        +--------------------+ $6000
|   Expansion ROM    |              |   Expansion ROM    |
+--------------------+ $4020        +--------------------+ $4020
|                    |              |   I/O Registers    |
|                    |              +--------------------+ $4000
|                    |              |                    |
|   I/O Registers    |              |       Mirrors      |
|                    |              |     $2000-$2007    |
|                    |              |                    |
|                    |              +--------------------+ $2008   
|                    |              |   I/O Registers    |
+--------------------+ $2000        +--------------------+ $2000   
|                    |              |                    |
|                    |              |       Mirrors      |
|                    |              |     $0000-$07FF    |
|                    |              |                    |
|        RAM         |              +--------------------+ $0800
|                    |              |        RAM         |
|                    |              +--------------------+ $0200
|                    |              |       Stack        |
|                    |              +--------------------+ $0100
|                    |              |      Zero Page     |
+--------------------+ $0000        +--------------------+ $0000

하드웨어 컴포넌트를 CPU, PPU, APU, Cartridge와 같이 구분하여 구성한다고 가정한다면, 각 생성된 인스턴스를 Bus 인스턴스를 생성하고 연결하는 과정을 통해 컴포넌트간 메모리 영역을 상호 공유할 수 있도록 합니다.

참고로 지금은 CPU만 고려하고 있고 PPU와 APU를 비롯한 기타 하드웨어 컴포넌트 들은 미구현 상태이므로 이들 접근을 위한 인터페이스나 버스 연결은 생략해도 됩니다. 다만 카트리지 영역은 접근이 가능해야, 롬 데이터를 읽어들여 실제 명령어 수행 테스트를 할 수 있으므로 나중에라도 구현에 대한 고려를 할 수 있도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// memory read/write in CPU instance
// +--------------------+ $0800
// |        RAM         |
// +--------------------+ $0200
// |       Stack        |
// +--------------------+ $0100
// |      Zero Page     |
// +--------------------+ $0000
private byte[] RAM = new byte[0x800];

private byte ReadByte(ushort address) 
{
    if (address < 0x2000)
    {
        return RAM[address & 0x07FF];
    }
    ... 
    else if (address >= 0x6000)
    {
        return bus.ReadMapperByte(address);
    }
    
    Debug.WriteLine(String.Format("Invalid Memory Address : {0:X}", address));    
}

private void WriteByte(ushort address, byte value) 
{
    if (address < 0x2000)
    {
        RAM[address & 0x07FF] = value;
    }
    ... 
    else if (address >= 0x6000)
    {
        bus.WriteMapperByte(address, value);
    }
    else {
        Debug.WriteLine(String.Format("Invalid Memory Address : {0:X}", address));
    }    
}

private ushort ReadShort(ushort address) { ... }
private void WriteShort(ushort address, ushort value) { ... }

public ushort ReadShortAbnormally(ushort address) {
    byte high;
    byte low = ReadByte(address);

    if ((address & 0xFF) == 0xFF)
    {
        high = ReadByte((ushort)(address & 0xFF00));
    }
    else
    {
        high = ReadByte((ushort)(address + 1));
    }

    return (ushort)((high << 8) | low);
}

Zero Page, Stack, RAM을 통합하여 0x800 크기의 배열 형태로 정의하였습니다. 해당 영역은 CPU 메모리 맵 기준에서 0x2000 이하의 주소에서 접근할 수 있습니다. 하지만 실질적 메모리 크기는 0x800 이므로 접근 가능한 실제 주소와 인덱스 크기가 일치하지 않습니다. 실제 하드웨어 동작에서는 $800 이상 주소부터는 Mirrors 되어 참조 메모리 위치가 반복되므로($800을 참조하면 $00을 참조하는 것과 동일), 0x7FF로 AND 마스킹을 하면 인덱스를 벗어나지 않고 미러링 되는 효과를 나타낼 수 있습니다.

ReadMapperByte의 경우는 Cartridge(여기서는 ROM 파일)의 메모리에 접근하기 위한 메서드 입니다. 코드에는 생략되어 있지만 cartridge와 mapper 인스턴스를 생성하고 bus에 연결하여 CPU에서 참조 할 수 있도록 하였습니다. 이와 관련된 부분은 차후 테스트 관련 포스트에서 다룰 예정입니다.

ReadShort, WriteShort는 16비트 크기의 데이터를 읽고/쓰기는 메서드를 별도로 정의한 것 입니다. value를 바이트 단위로 쪼개거나 합치는 과정과 ReadByte, WriteByte를 두번 씩 호출하는 방식으로 되어 있습니다. 그리고 ReadShortAbnormally 라는 이름의 말 그대로 비정상적인 16비트 읽기를 수행하는 메서드를 정의가 되어있는데요 이는 6502의 하드웨어 버그로 인해서 일부 어드레싱 모드에서 비정상적인 주소를 참조하는 상황을 에뮬레이션 하기 위해서 별도로 선언한 것 입니다.

이 부분은 나중에 어드레싱 모드에서 설명하도록 하겠습니다.

스택 포인터

Zero Page 페이지와 Stack, CPU RAM 영역중에서 Zero Page와 RAM 부분은 위에 작성된 메모리 인터페이스를 통하여 직접 엑세스하여 읽고 쓰기를 수행하면 됩니다. 하지만 Stack은 별도의 동작 방식이 있으므로 SP 레지스터를 활용하여 Push, Pull(or Pop) 동작 정의가 필요합니다.

6502는 스택 포인터가 오버플로우나 언더플로우가 발생하면, 다른 메모리 영역을 가리키지 않고 스택 메모리 영역안에서 순환하여 가리켜야 하므로 이 부분을 유의하여 정의를 하도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private readonly ushort STACK_OFFSET = 0x100;

private void PushStackByte(byte value)
{
    WriteByte((ushort)(STACK_OFFSET | registers.SP), value);
    registers.SP -= 1;
}

private byte PullStackByte()
{
    registers.SP += 1;
    return ReadByte((ushort)(STACK_OFFSET | registers.SP));
}

private void PushStackShort(ushort value)
{
    byte high = (byte)((value >> 8) & 0xFF);
    byte low = (byte)(value & 0xFF);
    PushStackByte(high);
    PushStackByte(low);
}

private ushort PullStackShort()
{
    byte low = PullStackByte();
    byte high = PullStackByte();
    return (ushort)((high << 8) | low);
}

스택 포인더는 CPU가 리셋이 되면 기본적으로 0xFD(253)의 값으로 초기화 됩니다. 스택 메모리 시작 오프셋이 0x100이므로 리셋이 되면 실제 스택 포인터는 $01FD 가리키게 됩니다. 스택이 하단으로 쌓이는 구조로 되어 있으므로 Push를 하면 현재 포인터에 값을 저장하고 하단으로 포인터를 변경하고, Pull을 하면 포인터를 증가시킨 후 값을 가져옵니다. SP 값이 8비트로서 0xFF(255)에서 오버플로우 되거나 0x00에서 언더플로우 되더라도 0 ~ 255를 벗어나지 않고 순환하므로 스택 메모리 영역 벗어나서 가리키지 않게 됩니다.

이렇게 스택까지 완료가 되었으니 CPU내 메모리나 레지스터의 기본적인 제어는 완료가 되었습니다. 명령어를 가져오거나 실행할 수 있는 기반이 마련되었으므로 명령어와 명령어 사이클, 어드레싱 모드 동작을 정의하고 구현하도록 하겠습니다.

.. 다음 포스트에서 ..