Writing a 6502 emulator, part 2

In the previous part, we wrote the beginning of a simple 6502 emulator. The next part will be adding methods to read and write to memory to work with data, handle the stack, and fetch opcodes.

Reading and writing to memory

A processor needs to be able to read and write to memory. The 6502 has a 16-bit address space, so we need to be able to read and write to any address. Every memory location can store an 8-bit value and costs one clock cycle per read or write action.

class Processor:
    """MOT-6502 Processor."""
    ...

    def read_byte(self, address: int) -> int:
        """Read a byte from memory.

        :param address: The address to read from
        :return: int
        """
        data = self.memory[address]
        self.cycles += 1
        return data


    def write_byte(self, address: int, value: int) -> None:
        """Write a byte to memory.

        :param address: The address to write to
        :param value: The value to write
        :return: None
        """
        self.memory[address] = value
        self.cycles += 1

Now that we have a way to read and write to memory, we need to validate the functionality. We can use the read_byte() and write_byte() methods in the same test. In the repository, we have a test that does this per a separate function as well. The state of the CPU should not be changed and the number of cycles used should be 1 cycle each.

def test_cpu_read_write_byte() -> None:
    """Verify CPU can read and write a byte from memory.

    The cost of the read and write operation is 1 cycle each, and
    the state of the CPU is not changed.

    :return: None
    """
    memory = m6502.Memory()
    cpu = m6502.Processor(memory)
    cpu.reset()
    cpu.write_byte(0x0001, 0xA5)
    value = cpu.read_byte(0x0001)
    assert (
        cpu.program_counter,
        cpu.stack_pointer,
        cpu.cycles,
        cpu.flag_b,
        cpu.flag_d,
        cpu.flag_i,
        value,
    ) == (0xFCE2, 0x01FD, 2, True, False, True, 0xA5)

Working with words

Besides reading and writing bytes, the 6502 can also read and write words that are two bytes long. This is useful for reading and writing to memory locations that are not byte aligned. In processor design, two ways are different on how to read and write words and are called little and big-endian. The 6502 is a little-endian processor, so we need to make sure to use the little-endian layout when reading and writing to memory. Most processors are little-endian, but we need to make sure that the emulator is working with the same endianness.

The Theory

Endianness is the way that the bytes in a word are ordered when stored in memory. A big-endian word is stored as the most significant byte first, and a little-endian word is stored as the least significant byte first. If we visualize a word as a series of bits as seen in the diagram below, the most significant bit is on the left and the least significant bit is on the right. This is also how they are stored in memory and matches how they are stored in the processor.

High byte

Low byte

Bits

15

14

13

12

11

10

9

8

Bits

7

6

5

4

3

2

1

0

With little-endian, the first byte is the least significant byte and the second byte is the most significant byte stored in memory. We have to make sure that the emulator converts the bytes in the correct order when reading and writing to memory. Later on, we will see how to do this.

Low byte

High byte

Bits

7

6

5

4

3

2

1

0

Bits

15

14

13

12

11

10

9

8

The code

Python has via the sys module away to expose the endianness of a processor. The sys.byteorder variable will return little or big depending on the endianness of the machine. With this information, we can create a function that will return the correct endianness of the word. The endianness will determine on which memory address we need to apply a bitshift operation to split the word into two bytes or add two bytes together.

For reading a word on a little-endian machine we only have to read the low byte at the initial address and then read the high byte at the next address and combine them into a word by shifting the high byte to the left by 8 bits. For big-endian processors, we need to read the high byte first and then the low byte and combine them into a word by shifting the high byte to the left by 8 bits. For writing a word we can mostly reuse how we handled the reading of a word. The only difference is to shift the high byte to the right by 8 bits and make sure it is only 8 bits long.

class Processor:
    """MOT-6502 Processor."""
    ...

    def read_word(self, address: int) -> int:
        """Read a word from memory.

        :param address: The address to read from
        :return: int
        """
        if sys.byteorder == "little":
            data = self.read_byte(address) | (self.read_byte(address + 1) << 8)
        else:
            data = (self.read_byte(address) << 8) | self.read_byte(address + 1)
        return data


    def write_word(self, address: int, value: int) -> None:
        """Split a word to two bytes and write to memory.

        :param address: The address to write to
        :param value: The value to write
        :return: None
        """
        if sys.byteorder == "little":
            self.write_byte(address, value & 0xFF)
            self.write_byte(address + 1, (value >> 8) & 0xFF)
        else:
            self.write_byte(address, (value >> 8) & 0xFF)
            self.write_byte(address + 1, value & 0xFF)

The test case test_cpu_read_write_word() is similar to test_cpu_read_write_byte() as described in the previous section, but it uses the read_word() and write_word() methods instead of read_byte() and write_byte(). Secondly, the values used are 0xA5 (1010 0101) and 0x5A (0101 1010) so we can test if the values are stored and retrieved correctly. The only drawback is that the tests only validate the working for the processor architecture that is running the tests on.

def test_cpu_read_write_word() -> None:
    """Verify CPU can read and write a byte from memory.

    The cost of the read and write operation is 2 cycles each, and
    the state of the CPU is not changed.

    :return: None
    """
    memory = m6502.Memory()
    cpu = m6502.Processor(memory)
    cpu.reset()
    cpu.write_word(0x0001, 0x5AA5)
    value = cpu.read_word(0x0001)
    assert (
        cpu.program_counter,
        cpu.stack_pointer,
        cpu.cycles,
        cpu.flag_b,
        cpu.flag_d,
        cpu.flag_i,
        value,
    ) == (0xFCE2, 0x01FD, 4, True, False, True, 0x5AA5)

Fetching opcodes

We now can implement the fetching of instructions or more precisely the opcode which is the numeric value of the instruction and also any arguments that the instruction might need. The processor has a register for the program counter which is used to keep track of the current address in memory. The opcode is then read from memory at the current address and the program counter is incremented by one. The same goes for the arguments which can be both a byte or a word.

We have to implement a function to fetch a byte or word from memory. The function will take the current address from the program_counter register and return the byte or word at that address. Here we can reuse the read_byte() and read_word() functions from the previous section and only increase the program_counter.

class Processor:
    """MOT-6502 Processor."""
    ...

    def fetch_byte(self) -> int:
        """Fetch a byte from memory.

        :param address: The address to read from
        :return: int
        """
        data = self.read_byte(self.program_counter)
        self.program_counter += 1
        return data


    def fetch_word(self) -> int:
        """Fetch a word from memory.

        :param address: The address to read from
        :return: int
        """
        data = self.read_word(self.program_counter)
        self.program_counter += 2
        return data

Validating these methods is very simlar as the one we used for reading a byte or word. We can reuse the same tests and make sure the program counter is incremented by the correct amount. For a byte we need an increment of one and for a word we need an increment of two.

def test_cpu_fetch_byte() -> None:
    """Verify CPU can fetch a byte from memory.

    The cost of the fetch operation is 1 cycle, and increases the
    program counter by 1. The state of the CPU is not changed
    further.

    :return: None
    """
    memory = m6502.Memory()
    cpu = m6502.Processor(memory)
    cpu.reset()
    memory[0xFCE2] = 0xA5
    value = cpu.fetch_byte()
    assert (
        cpu.program_counter,
        cpu.stack_pointer,
        cpu.cycles,
        cpu.flag_b,
        cpu.flag_d,
        cpu.flag_i,
        value,
    ) == (0xFCE3, 0x01FD, 1, True, False, True, 0xA5)

def test_cpu_fetch_word() -> None:
    """Verify CPU can fetch a word from memory.

    The cost of the fetch operation is 2 cycle, and increases the
    program counter by 2. The state of the CPU is not changed
    further.

    :return: None
    """
    memory = m6502.Memory()
    cpu = m6502.Processor(memory)
    cpu.reset()
    memory[0xFCE2] = 0xA5
    memory[0xFCE3] = 0x5A
    value = cpu.fetch_word()
    assert (
        cpu.program_counter,
        cpu.stack_pointer,
        cpu.cycles,
        cpu.flag_b,
        cpu.flag_d,
        cpu.flag_i,
        value,
    ) == (0xFCE4, 0x01FD, 2, True, False, True, 0x5AA5)

Next step

As we have a processor and memory that has working registers, can read and write both a byte or word and can fetch a byte or word from memory we can now implement the actual instructions.

Note

  • The repository contains more information about the project.

  • All parts 1, 2