SV

Addressing Modes in the NES 6502By Steven VaughtJanuary 12, 2021

Flappy Bird - SNES Edition

What the hell does Flappy Bird have to do with Addressing Modes in the NES? It was probably about a year ago when I learned that it was possible to inject code into the SNES by manipulating CPU registers with complex actions in Super Mario World. It is insane that someone was able to recreate Flappy Bird in SMW reusing assets from the the original game. You read that right. Go watch the video, it’s awesome. I had to learn more about this stuff. Who wouldn’t want to know how Flappy Bird was successfully injected into a 1990 title with nothing but a controller? That stuff is interesting, but SNES is a huge undertaking for a first emulator, so I opted to build the much friendlier father: the NES. I plan on writing a few posts to detail areas I found confusing. Maybe some more documentation on this stuff out there will prevent the all-too common questions of “Why is flag 4 always set?” and “Why does the stack pointer start at 0xFD” from popping up on r/EmuDev. Questions like these actually have incredibly straightforward answers, but the absolute mass of confusing documentation makes it hard to pin down something tangible. My goal here is to create a documentation akin to the 6502 Documentation at obelisk.me.uk strictly for areas I found confusing. And it’s not like the documentation was bad! It just seems like most of it is meant as a reference for writing 6502 assembly, not for writing something that will read 6502 bytecode.

Addressing Modes in the 6502

Ahh, now for the meat and potatoes of it all: Addressing modes! Well, what is an addressing mode? The 6502 has actually has a fairly minimal number of instructions, and most are very simple operations (left/right bitshift, set/clear flags, load to accumulator, etc…). In order to get the most out of this relatively small instruction set, the 6502 provides multiple addressing modes to target different sections of memory. Some modes use less cycles than others and were preferable since speed was (and to some degree still is) a big deal. But before we get into the addressing modes, maybe we should consider how the 6502 works at a high level. Essentially, the CPU does the following things over and over again:

  1. fetch byte from address pointed to by program counter
  2. decode and execute that byte. I mean, it’s actually more complicated than that, but this is a distilled truth of the CPU. Since the instructions (or Opcodes) are a single byte, that gives us 256 different instructions. But some of those bytes are unused, so we only have 151 opcodes, 56 of which are unique. After taking out the opcodes with Implied addressing (which is tantamount to no addressing), you get something like 32 instructions that can operate according to various addressing modes.

Implied

This is probably the simplest addressing mode to talk about. You know how CPU’s have flags, and some instructions simply set/clear a particular flag? Those instructions don’t target memory addresses, but rather a specific component of the CPU, and use Implied Mode Addressing. Some opcodes using this mode are INX, PHA, and SEC. No additional data is read upon execution of opcodes with this addressing mode; the program counter is incremented by 1 and the next opcode is fetched.

OpcodeTarget Address
0x18 (CLC)&carryFlag

Accumulator

This is another easy addressing mode to talk about! Basically, any opcode using this addressing mode does all of its work on the accumulator. ROR, an opcode that uses this addressing mode to rotate the accumulator to the right (i.e., bitshifting 1 place to the right, and wrapping bit 0 around to fill the void left by bit 7). Similar to Implied, this mode doesn’t read any extra data after the opcode, and you can make the argument that the target address is the “address” of the accumulator.

OpcodeTarget Address
0x0A (ASL)&accumulator

Immediate

Ok, this bad boy is where we start reading in data! Basically, the byte that immediately follows the opcode in memory is our “target”. So the target address is PC + 1, where PC (Program Counter) is the location of the executing opcode. The following table shows 0xFF being loaded into the accumulator.

OpcodeNext ByteTarget Address
0xA9 (LDA)0xFFPC + 1

Relative

Relative addressing is used predominantly (entirely?) in branching instructions to skip over segments of code conditionally. These instructions take the byte following the opcode, and add it to the program counter to produce a target address. The only catch is that signed arithmetic is used, so the program counter can also jump backwards depending on the value of the next byte.

OpcodeNext ByteTarget Address
0x90 (BCC)0x02PC + 0x02

Zero Page

Zero Page Addressing always accesses the 0x00 page of the address space, meaning it never undergoes a page break and only requires a single byte to specify. This makes it really performant, but also very limited. The byte immediately following the opcode makes up the lower byte of the “target address”.

OpcodeNext ByteTarget Address
0x84 (STY)0xC20x00C2

Zero Page X & Zero Page Y

Zero Page, X & Zero Page, Y are both just the regular Zero Page addressing mode with the contents of register X or Y being added to the “Target Address”. If the addition causes the “Target Address” to cross a page boundary, the upper byte is discarded such that the final address is still on the zero page.

OpcodeNext ByteY RegisterTarget Address
0xB6 (LDX)0xEA0x600x004A

Absolute

Absolute addressing takes the two bytes after the opcode and use them to construct a target address. The first byte is the lower byte, and the second byte is the higher byte.

OpcodeLower ByteUpper ByteTarget Address
0xED (SBC)0xB30x210x21B3

Absolute, X & Absolute Y

These two addressing modes are so similar they might as well be the same. Both of them find the Absolute Target Address (detailed just above) and add either register X or Y to it.

OpcodeLower ByteUpper ByteX RegisterABS AddressTarget Address
0xFD (SBC)0x130x4B0x200x4B130x4B33

Indirect

So far, none of the addressing modes have been very complicated: that starts change with Indirect Addressing. You know how a lot of beginner programmers get hung up on pointers? Well, Indirect addressing uses pointers. A first address is obtained similarly to Absolute, and is used as a pointer to a second space in memory, where the “Target Address” is read from. The 6502 - an 8 bit machine with 16 bit address space - has no way of supporting 16 bit arithmetic, so crossing page boundaries (e.g. reading 0x01FF and then reading 0x0200 takes an additional cycle. Be it intentional or a bug, this extra cycle isn’t taken when loading the “target address”, so the address wraps around to the same page. Clear as mud? Maybe the following table will be easier to understand.

OpcodeLower ByteUpper ByteABS AddressTarget Lower ByteTarget Upper Byte
0x6C (JMP)0xFF0x340x34FFread from 0x34FFread from 0x3400

Indirect, X

This addressing mode is kind of like a combination of Zero Page X & Indirect. First, the Zero Page X address is read. Afterwards, this address is used to find the “Target Address”. And in the spirit of the original Indirect bug/feature, if the Zero Page X address is 0x00FF, the second byte is loaded from 0x0000 instead of 0x0100

OpcodeNext ByteX RegisterZero Page XTarget Lower ByteTarget Upper Byte
0x81 (STA)0x550x350x0090read from 0x0090read from 0x0091

Indirect, Y

This addressing mode is kind of like a combination of Zero Page & Indirect. With an extra Y Register Addition. After a Zero Page address is read, it is used to find the “Target Address”. Finally, the contents of the Y Register are added to this “Target Address”.

OpcodeNext ByteZero PageTarget Lower ByteTarget Upper Byte
0x11 (ORA)0x760x0076read from 0x0076read from 0x0077

Sample Code

Below are my implementations of these addressing modes in C++. I chose to treat each mode as a function that returns the target address and have handling of reading/writing local to the opcode function.

// Immediate
u16 CPU::IMM(){
    u16 temp = PC + 1;
    PC += 2;
    return temp;
}

// Accumulator
u16 CPU::ACC(){
    PC += 1;
    return ACCUMULATOR_ADDRESS;
}

// Relative
u16 CPU::REL(){
    s16 address = PC;
    s8 offset = read(PC + 1);
    address += offset + 2;
    return (u16) address;
}

// Zero Page
u16 CPU::ZPG(){
    u8 address = read(PC + 1);
    PC += 2;
    return (u16) address;
}

// Zero Page X
u16 CPU::ZPX(){
    u16 address = read(PC + 1);
    address = (address + X) & 0xFF;
    PC += 2;
    return address;
}

// Zero Page Y
u16 CPU::ZPY(){
    u16 address = read(PC + 1);
    address = (address + Y) & 0xFF;
    PC += 2;
    return address;
}

// Absolute
u16 CPU::ABS(){
    u16 LSN = read(PC + 1);
    u16 MSN = read(PC + 2);
    u32 address = LSN + (MSN << 8);
    PC += 3;
    return address;
}

// Absolute X
u16 CPU::ABX(){
    u32 address = ABS();
    return address + X;
}

// Absolute Y
u16 CPU::ABY(){
    u32 address = ABS();
    return address + Y;
}

// Indirect
u16 CPU::IND(){
    u16 ABS_LSN = read(PC + 1);
    u16 ABS_MSN = read(PC + 2);
    u16 ABS_address = (ABS_MSN << 8) + ABS_LSN;

    // AN INDIRECT JUMP MUST NEVER USE A VECTOR BEGINNING ON THE LAST BYTE OF A PAGE
    u16 address, LSN, MSN;
    if ((ABS_address & 0xFF) == 0xFF){
        LSN = read(ABS_address);
        MSN = read(ABS_address & 0xFF00);
        address = (MSN << 8) + LSN;
    } else {
        LSN = read(ABS_address);
        MSN = read(ABS_address + 1);
        address = (MSN << 8) + LSN;
    }
    PC += 2;
    return address;
}

// Indirect X
u16 CPU::IDX(){
    u16 address = (read(PC + 1) + X) & 0xFF;
    u16 LSN = read(address);
    u16 MSN = read((address + 1) & 0xFF);
    address = (MSN << 8) + LSN;
    PC += 2;
    return address;
}

// Indirect Y
u16 CPU::IDY(){
    u16 temp = read(PC + 1);
    u16 LSN = read(temp);
    u16 MSN = read((temp + 1) & 0xFF);
    u16 address = LSN + (MSN << 8) + Y;
    PC += 2;
    return address;
}