Addressing Modes Revisited

You have met addressing modes before as a menu of ways an instruction can name its operand. Now we look at them the way an architect does: as a deliberate, minimal part of the ISA contract, chosen to cover exactly the memory-access patterns real programs use — arrays, structs, pointers, and jumps — and no more. The graduate question is not "what modes exist?" but "why does a RISC ISA keep so few, and how does the compiler build everything else out of them?"

An addressing mode is an effective-address recipe

An addressing mode is a rule for computing the effective address (EA) — the actual memory location an instruction reads or writes — from the bits in the instruction plus the current register values. RISC keeps essentially four, and a load or store uses only the base+displacement one:

ModeOperand is…Effective addressRISC-V example
Registera value in a register— (no memory)add x3, x1, x2
Immediatea constant in the instruction— (no memory)addi x3, x1, 42
Base + displacementmemory at register + constant\text{EA} = R[\text{rs1}] + \text{imm}lw x3, 8(x1)
PC-relativea target near the current instruction\text{EA} = \text{PC} + \text{imm}beq x1, x2, label

That's essentially the whole set for RISC-V. Notice base+displacement is the only mode that reaches data memory — and even PC-relative is really just base+displacement with the PC as the base. CISC ISAs like x86 pile on more — indexed, scaled-indexed [base + index*scale + disp], indirect through memory — but RISC bets that the compiler can synthesise those from the primitives, and it can.

How base + displacement maps to real code

The single mode \text{EA} = R[\text{rs1}] + \text{imm} is astonishingly versatile because it matches how data is laid out. The base register holds a starting address; the small constant displacement picks an offset from it. That is exactly the shape of the three most common access patterns in all of programming:

One mode, three pillars of real programs. That coverage is precisely why RISC designers felt safe throwing the fancier modes away.

Computing effective addresses

Let's make the arithmetic concrete. Say an int array of 4-byte elements starts at address 1000, held in a base register, and a struct with a 4-byte x then an 8-byte y starts at 2000. The code walks the array with a scaled index and then reaches a struct field, printing the effective address the hardware would form for each access.

// Effective-address arithmetic for the common access patterns. // Base + displacement: EA = base + disp. Scaled index: disp = i * elementSize. function ea(base: number, disp: number): number { return base + disp; // what the load/store unit computes } // 1) Array a[] of 4-byte ints at address 1000; access a[i] for several i. const arrayBase = 1000; const elemSize = 4; console.log("int array a[] at base 1000 (4-byte elements):"); for (const i of [0, 1, 2, 5, 10]) { console.log(` a[${i}] -> EA = 1000 + ${i}*4 = ${ea(arrayBase, i * elemSize)}`); } // 2) struct Point { int x; double y; } at 2000. x at +0, y at +8 (aligned). const structBase = 2000; console.log("struct Point { int x; double y; } at base 2000:"); console.log(` p->x -> EA = ${ea(structBase, 0)}`); console.log(` p->y -> EA = ${ea(structBase, 8)}`); // 3) 2D array m[row][col], ROW-major, 3 columns of 4-byte ints, base 4000. // EA = base + (row*ncols + col) * elemSize const mBase = 4000, ncols = 3; const row = 2, col = 1; const flat = row * ncols + col; console.log(`m[${row}][${col}] (row-major, ${ncols} cols) -> EA = 4000 + (${row}*${ncols}+${col})*4 = ${ea(mBase, flat * elemSize)}`);

PC-relative addressing and why branches use it

Branches and jumps use PC-relative addressing: the target is \text{PC} + \text{imm}, where imm is a signed offset stored in the instruction. Two big wins fall out of this choice. First, most branches go somewhere nearby (the top or bottom of a loop), so a small signed immediate reaches them — no need to spend 32 bits on an absolute target. Second, and more deeply, PC-relative code is position-independent: because every branch target is expressed as a distance from the current instruction, the whole block of code can be loaded at any address in memory and still works untouched. That is exactly what a modern OS needs to load shared libraries and to randomise addresses for security (ASLR).

Old CISC machines (and the DEC VAX especially) had glorious modes like auto-increment: a single instruction would load through a pointer and bump the pointer to the next element, perfect for *p++. RISC deleted it. So how does a RISC loop stride through an array? It just spends one extra, dirt-cheap instruction: lw x5, 0(x10) to load, then addi x10, x10, 4 to advance the pointer. Two simple, fixed-cost instructions instead of one clever, variable-cost one — the same RISC bargain you keep meeting. And because they pipeline so well, the "extra" instruction is nearly free, while the decoder stays blissfully simple. The compiler even hoists the increment out of the way with loop optimisations.

A classic bug: writing lw x5, 3(x10) expecting the fourth int of an array. Memory is byte-addressed, so the displacement counts bytes. The fourth 4-byte int is at displacement 3 \times 4 = 12, so you want lw x5, 12(x10). Loading at offset 3 would read straddling bytes of the wrong (and possibly misaligned) element. Always multiply the index by the element size to get the byte displacement — that multiply is where the "scale" in x86's scaled-index mode comes from, and it's the step you must do by hand on RISC.

The design lesson

Addressing modes are a microcosm of the whole RISC/CISC argument. CISC offered a rich menu, hoping to match every access pattern with a bespoke mode; RISC offered a tiny kit and trusted the compiler to assemble the rest from base+displacement plus ordinary arithmetic. Fewer modes means a simpler, faster decoder and a shorter effective address computation — one add — that fits neatly in a pipeline stage. The patterns that matter (arrays, structs, stack locals, nearby branches) are all covered, and the ISA stays lean. Next we use base+displacement in earnest: building the stack frames that make procedure calls work.