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

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

 ·   ·  ☕ 8 min read  ·  ✍️ Yogo

이전 포스트에서 레지스터와 메모리 인터페이스를 정의 하였고 이번 포스트에서는 명령어 세트와 어드레싱 모드 구현에 대해 다루어 보고자 합니다.

명령어 세트 (Instruction Sets)

공식 명령어 개수는 56개 정도이지만 어드레싱 모드가 다른 경우의 수를 고려하면 실제 실행 가능한 명령어 세트 수는 더 많습니다. 그리고 비공식 명령어와 더불어 중복 명령어까지 포함하면 1바이트 opcode 어느 값을 참조하더라도 특정 명령어 세트와 매핑이 가능합니다.

  • 명령어 테이블1
    Instruction Table

명령어 사이클 (Instruction Cycles)2

명령어를 처리하는 단계는 설명마다 조금씩 차이가 있지만 보통 3 ~ 4단계로 구분할 수 있습니다. 여기서는 3단계로 명령어를 가져오는 단계(Fetch Stage), 그리고 명령어를 해석하는 디코딩 단계(Decode State), 마지막으로 실행 단계(Excute Stage)로 구분하여 진행합니다.

Fetch 단계에서는 프로그램 카운터(PC)를 참조하여 메모리 주소로 부터 1바이트 opcode를 가져온 후 다음 PC를 가리킵니다. Decode 단계에서는 해석에 따라 명령어나 어드레싱 모드에 의해 다른 크기의 operand 정보를 가져옵니다. 마지막으로 디코딩 된 정보를 바탕으로 실제 연산을 수행합니다.

그리고 이 단계들은 PC가 중단될 때까지 계속 반복하며, 이 순차적인 과정을 만드는 것이 CPU 에뮬레이션의 기본적인 목표가 되겠습니다.

명령어 사이클 중에서 Fetch 단계는 이미 메모리 인터페이스가 마련되어 있으므로 특별한 것이 없습니다. PC가 가리키는 주소를 참조하여 opcode만 읽어오면 됩니다.

1
2
// fetch
byte opcode = ReadByte(registers.PC)

명령어 세트 디코딩 (Decoding)

PC를 통해 opcode를 읽어들였다면 디코딩 과정을 통해 실행할 명령어(instruction)와 어드레싱 모드를 통한 피연산자 위치와 크기를 결정합니다.

The 6502/65C02/65C816 Instruction Set Decoded3를 참고하면 명령어 세트는 일부 규칙이 있어서 1바이트 opcode를 aaabbbcc 포맷이라고 할 경우 aaa와 cc를 통해 명령어 종류, bbb를 통해 어드레싱 모드를 디코딩 할 수 있습니다. 하지만 아쉽게도 모든 명령어가 이 규칙을 따르지 않기 때문에 이 포맷의 규칙을 따르는 일부 명령어를 제외하면 나머지는 예외 처리가 요구됩니다.

opcode의 크기가 1바이트이고 어떤 값을 참조하던지 특정 명령어 세트를 참조할 수 있기 때문에 포맷을 이용한 디코딩 방법 대신 좀 더 쉽고 단순한 방법을 사용할 수도 있습니다. 명령어 종류, 어드레싱 모드, 명령어 소요 사이클 등 명령어와 연관된 정보들을 256 크기를 가지는 룩업 테이블로 만들어 opcode를 인덱스로 사용하여 각 정보를 참조하는 방법입니다.

이 방법은 명령어 세트 수가 많지 않기에 가능한 방법이고 특별한 규칙이나 연산 등이 요구되지 않으므로 포맷과 예외 처리를 동시에 하는 방법보다 직관적이고 단순한 방법으로 디코딩을 수행할 수 있습니다. 그리고 공개된 많은 에뮬레이터가 이와 같은 방법을 사용하고 있습니다.

 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
enum Instruction
{
    ADC, AND, ASL, BCC, BCS, BEQ, BIT, BMI, BNE, BPL, BRK, BVC, BVS, CLC, CLD, CLI,
    CLV, CMP, CPX, CPY, DEC, DEX, DEY, EOR, INC, INX, INY, JMP, JSR, LDA, LDX, LDY,
    LSR, NOP, ORA, PHA, PHP, PLA, PLP, ROL, ROR, RTI, RTS, SBC, SEC, SED, SEI, STA,
    STX, STY, TAX, TAY, TSX, TXA, TXS, TYA, ALR, ANC, ARR, AXS, LAX, SAX, DCP, ISC,
    RLA, RRA, SLO, SRE, SKB, IGN,
    // illegal
    AHX, KIL, LAS, SHX, SHY, TAS, XAA
};

enum AddressingMode
{
    ABSOLUTE,
    ABSOLUTE_X,
    ABSOLUTE_Y,
    ACCUMULATOR,
    IMMEDIATE,
    IMPLIED,
    INDEXED_INDIRECT,
    INDIRECT,
    INDIRECT_INDEXED,
    RELATIVE,
    ZERO_PAGE,
    ZERO_PAGE_X,
    ZERO_PAGE_Y
}

private readonly static AddressingMode[] addressModes =
{
    AddressingMode.IMPLIED, AddressingMode.INDEXED_INDIRECT, AddressingMode.IMPLIED, 
    AddressingMode.INDEXED_INDIRECT, AddressingMode.ZERO_PAGE, AddressingMode.ZERO_PAGE, 
    AddressingMode.ZERO_PAGE, AddressingMode.ZERO_PAGE, AddressingMode.IMPLIED,     
    ...
};

private readonly static Instruction[] instructions = {
    Instruction.BRK, Instruction.ORA, Instruction.KIL, Instruction.SLO, Instruction.NOP, Instruction.ORA, 
    Instruction.ASL, Instruction.SLO, Instruction.PHP, Instruction.ORA, Instruction.ASL, Instruction.ANC, 
    Instruction.NOP, Instruction.ORA, Instruction.ASL, Instruction.SLO, Instruction.BPL, Instruction.ORA,
    ...
};

명령어 세트 실행 (Execution)

디코딩에 의해 명령어와 어드레싱 모드가 결정 되었으면 이제 어드레싱 모드에 의해 피연산자 위치를 찾아내서 명령을 수행하면 됩니다. 그리고 당연히 실행을 위해서는 그 전에 명령어 프로세스와 피연산자 참조를 위한 어드레싱 모드 정의가 필요한데요 6502 Instruction Set1나 Ultimate Commodore 64 Reference4 등을 참조하면 명령어와 어드레싱 모드에 대한 세부 설명과 동작 프로세스를 확인 할 수 있습니다.

  • 명령어 세트 구현

먼저 명령어 정의를 위해 Ultimate Commodore 64 Reference를 참고 하겠습니다. 아래 카드는 ADC 명령어에 대한 설명을 담고 있습니다.

NV-BDIZC
----
ADC - Add Memory to Accumulator with Carry

Operation: A + M + C → A, C

This instruction adds the value of memory and carry from the previous operation to the value of the accumulator and stores the result in the accumulator.

This instruction affects the accumulator; sets the carry flag when the sum of a binary add exceeds 255 or when the sum of a decimal add exceeds 99, otherwise carry is reset. The overflow flag is set when the sign or bit 7 is changed due to the result exceeding +127 or -128, otherwise overflow is reset. The negative flag is set if the accumulator result contains bit 7 on, otherwise the negative flag is reset. The zero flag is set if the accumulator result is 0, otherwise the zero flag is reset.

..어드레싱 모드 테이블 생략..

먼저 Operation 부분을 보면 A + M + C –> A, C 라고 되어 있는데요 A(ccumlator) 와 M(emory) 그리고 C(arry) 비트 값을 더한 값을 A, C에 반영 하라는 의미입니다. 굳이 이 부분을 참조하지 않아도 중간에 설명이 되어 있습니다.

내용을 더 참조하면 sets the carry flag when the sum of a binary add exceeds 255, otherwise carry is reset라는 문구를 확인 할 수 있는데 A + M + C 연산 후 255를 초과하면 C를 set 하고 아니면 reset(clear) 하라는 의미입니다.

그리고 카드 우측 상단 표를 보면 해당 연산 시 영향을 받는 상태 레지스터의 플래그들을 표시하고 있습니다. 내용을 더 참조하면 연산 결과의 부호 비트 즉, 7번째 비트에 변화에 따라 overflow flag와 negative flag를 설정하고, 결과가 0인지 여부에 따라 zero flag를 설정하라는 것을 알 수 있습니다.

아래의 코드는 ADC 연산 과정을 보여줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Add Memory to Accumulator with Carry
private void adc(ushort address)
{
    byte value = ReadByte(address);    
    ushort result = (ushort)(registers.ACC + value + registers.SR.CARRY);   // A + M + C

    registers.SR.SetZeroNegativeFlags((byte)result);                        // zero and negative flags
    registers.SR.SetCarryFlag(result);                                      // carry flag
    registers.SR.OVERFLOW = (byte)((((registers.ACC ^ value) & 0x80) == 0)  // overflow flag
            && (((registers.ACC ^ result) & 0x80) != 0) ? 1 : 0);
    registers.ACC = (byte)(result & 0xff);                                  // store result to A
}

코드를 참조하면 피연산자 위치에서 값을 읽어와서 A + M + C 연산을 수행합니다. 그리고 연산 결과에 따라 상태 레지스터 플래그를 설정하고, 결과를 A에 저장합니다.

여기서 유의할 것은 캐리 비트 설정 즉, 255 초과 여부 체크를 쉽게 하기위해서 ushort 형으로 연산결과를 저장하였고, 나머지는 플래그나 A는 8비트 기준에서 연산을 해야하므로 byte로 타입 캐스팅하여 사용한 것 입니다. 그리고 SetCarryFlag나 SetZeroNegativeFlags 같은 메서드는 여러 명령어 수행에 있어서 흔하게 사용되는 상태 레지스터 반영을 위해 구조체 내에 미리 선언된 메서드 입니다.

이와 같은 절차로 공식 명령어를 정의하면 됩니다. 그리고 비공식 명령어도 필요에 따라 정의를 하는데 사이트마다 약간씩 설명이 다른 경우가 있으므로 상호 참조를 통해 진행을 하는데 저의 경우 테스트 ROM으로 실행 시 로그가 이상하게 나오는 경우 다른 사이트를 참조하여 예상 값이 나올 때까지 수정하는 방식으로 오류를 수정하였습니다.

  • 어드레싱 모드 구현

어드레싱 모드는 피연산자 주소나 값을 찾아가는 방법을 정의하는 것입니다. 어드레싱 모드는 룩업 테이블로 어떤 모드인지 가져올 수 있으므로 해당 어드레싱 모드의 주소 참조 방식을 구현 하면 됩니다.

 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
private ushort GetOperandAddress(byte opcode)
{
    ushort address = 0;

    switch (Opcode.GetAddresingMode(opcode))
    {
        case AddressingMode.ABSOLUTE:
            address = ReadShort((ushort)(registers.PC + 1));
            break;
        case AddressingMode.ABSOLUTE_X:
            address = (ushort)(
                ReadShort((ushort)(registers.PC + 1))
                + registers.X
            );
            cpuCycles += PageCrossed(address, (ushort)(address - registers.X)) 
                            ? Opcode.GetPageCycle(opcode) : 0;
            break;
        case AddressingMode.ABSOLUTE_Y:
            address = (ushort)(
                ReadShort((ushort)(registers.PC + 1))
                + registers.Y
            );
            cpuCycles += PageCrossed(address, (ushort)(address - registers.Y)) 
                            ? Opcode.GetPageCycle(opcode) : 0;
            break;
        case AddressingMode.IMMEDIATE:
            address = (ushort)(registers.PC + 1);
            break;
        case AddressingMode.INDEXED_INDIRECT:
            {
                ushort jmpAddress = 
                    (ushort)((ReadByte((ushort)(registers.PC + 1)) + registers.X) & 0xFF);
                address = ReadShortAbnormally(jmpAddress);
            }
            break;
        case AddressingMode.INDIRECT:
            address = ReadShortAbnormally(
                ReadShort((ushort)(registers.PC + 1))
            );
            break;
        case AddressingMode.INDIRECT_INDEXED:
            address = ReadShortAbnormally(
                ReadByte((ushort)(registers.PC + 1))
            );
            address += registers.Y;
            cpuCycles += PageCrossed(address, (ushort)(address - registers.Y)) 
                            ? Opcode.GetPageCycle(opcode) : 0;
            break;
        case AddressingMode.RELATIVE:
            {
                // The range of the offset is -128 to +127 bytes
                byte offset = ReadByte((ushort)(registers.PC + 1));
                address = (ushort)((registers.PC + 2 + offset) - ((offset < 0x80) ? 0 : 0x100));
            }
            break;
        case AddressingMode.ZERO_PAGE:
            address = ReadByte((ushort)(registers.PC + 1));
            break;
        case AddressingMode.ZERO_PAGE_X:
            address = (ushort)((ReadByte((ushort)(registers.PC + 1)) + registers.X) & 0xFF);
            break;
        case AddressingMode.ZERO_PAGE_Y:
            address = (ushort)((ReadByte((ushort)(registers.PC + 1)) + registers.Y) & 0xFF);
            break;
        default:
        case AddressingMode.ACCUMULATOR: // operate directly on the contents of the accumulator
        case AddressingMode.IMPLIED:     // do not require access to operands stored in memory
        case AddressingMode.INVALID:
            break;
    }

    return address;
}

어드레싱 모드가 어떻게 피연산자 위치를 결정하는지는 이미 이전 포스트에서 설명을 하였으므로 이에 대한 설명은 하지 않겠습니다. 다만 코드 중간에 PageCrossed 라는 메서드를 호출 하는데 이는 주소 참조에 있어서 페이지 단위가 바뀌면 사이클이 더 소요되기 때문에 이러한 사유로 정확한 CPU 사이클 추정을 위해서 호출을 합니다.

참고로 사이클을 정확하게 측정하는 이유는 PPU(Picture Process Unit)과 동기화를 하기 위함입니다. 이 작업이 왜 필요한지는 나중에 PPU를 구현하고 관련 내용을 포스팅 할 때 설명하겠습니다.

INDIRECT, INDEXED_INDIRECT, INDIRECT_INDEXED의 경우 이전 포스트에서 한번 언급했던 ReadShortAbnormally라는 메서드를 호출 합니다. 6502 버그로 인해서 비정상적으로 16비트 값을 읽어오는 메서드 입니다. 관련 버그는 개발 포럼5에서도 논의가 되는 것을 확인 할 수 있는데요 이 버그는 다음과 같이 설명6하고 있습니다.

The indirect jump instruction does not increment the page address when the indirect pointer crosses a page boundary. JMP ($xxFF) will fetch the address from $xxFF and $xx00.

말하자면 페이지 바운더리 즉, 페이지가 바뀌는 영역($xxFF)에서 16비트(8bit 두번 읽기) 값을 읽기 위해 하위 바이트을 읽고, 다음 상위 바이트를 읽기 위해서 주소를 1 증가 시 주소 상위 바이트가 증가하지 않고, 주소의 하위 바이트만 wrap-around 되어 페이지가 증가되지 못하고 다시 해당 페이지 0번 위치를 참조하는 버그입니다.

예를 들어 $00FF 에서 16비트 값을 가져오러면 $00FF와 $0100을 연속으로 읽어야 하는데, 실제로는 $00FF와 $0000이 읽혀지게 되는 문제입니다. 만약 $00FE 였다면 $00FE, $00FF로 정상적으로 값을 읽을 수 있습니다.

당연히 버그니깐 고쳐줘야(?) 싶겠지만 사실 이 버그 조차도 반영된 것이 실제 하드웨어이므로 오히려 정상적인 에뮬레이션을 위해서는 이 하드웨어 버그도 구현을 해줘야합니다. 만약 하지 않는 경우 나중에 NES CPU 전용 ROM 테스트 시 실패하게 됩니다.

위 버그 외에도 몇 가지 버그7가 더 있지만 일부는 NES에서 사용하지 않는 decimal 모드에서만 유효한 것이고 그 외의 버그는 반영을 하지 않더라도 ROM 파일 테스트에서는 문제가 없어서 일단은 고려하지 않았습니다.

자 그럼 실행할 준비도 다 된거 같으니 코드를 통해 명령어 사이클을 정리 해보겠습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// fetch
byte opcode = ReadByte(registers.PC);

// decode
Instruction inst = Opcode.GetInstruction(opcode);
ushort address = GetOperandAddress(opcode);

// excute
switch (inst)
{
    case Instruction.ADC:
        adc(address);
        break;
    case Instruction.AND:
        and(address);
        break;
    ...
}

코드를 보면 PC에서 opcode를 읽어오는 단계, opcode를 인덱스로 사용하여 명령어와 어드레싱 모드를 통한 피연산자 주소를 가져와서 명령어를 수행하는 단계를 확인 할 수 있습니다. 이 절차를 ROM 파일의 코드를 읽어 들여 계속 반복해서 수행하면 CPU의 에뮬레이션의 대부분은 완료되었다고 볼 수 있겠습니다.

물론 여기에는 인터럽트나 CPU 사이클, PC를 증가시키는 부분도 고려가 되어야 합니다.

그럼 이제 마지막으로 실제 롬파일을 CPU를 테스트 하는 과정을 설명하겠습니다.

.. 다음 포스트에서 ..