Lesson 3.31,892 words

Addressing Modes

Once an operand field exists, it needs a rule for turning its bits into the data it names. That rule is the addressing mode.

╌╌╌╌

An operand field is just a handful of bits. The addressing mode is the rule the hardware applies to those bits to find the actual operand: sometimes the bits are the value, sometimes they name a register holding it, and sometimes they describe a calculation that produces a memory address. That calculation's result, the address the instruction ultimately reaches, is the effective address. Addressing modes exist because the shapes of data in real programs are varied: a loop constant, a local variable, a struct field, an array element, a jump target. Each mode is the cheap encoding of one of those shapes.

We use AT&T/x86 syntax throughout, since it names every mode compactly and the machine-level module already uses it.

Modes that need no memory

The two simplest modes never touch memory at all.

Immediate. The operand bits are the value. A constant is baked directly into the instruction. In AT&T syntax a $ marks it: $0x100 is the literal 0x100. No address is computed; the value travels inside the instruction word. Its use is any literal constant: loop bounds, initial values, masks.

Register. The operand names a register, and the value is that register's contents. %rax is the operand. Again no memory access: the value is already in the datapath. This is the mode nearly every operand uses, since register machines keep hot values in registers.

Immediate and register modes need no memory. An immediate operand carries the value inside the instruction; a register operand names a slot in the register file whose contents are the value. Neither computes an address.

Modes that reach into memory

The remaining modes all compute an effective address and then read or write the bytes there. They differ only in how the address is built.

Direct (absolute). The operand carries a full address outright. The effective address is the constant in the instruction; the operand is the bytes at that fixed location. This names a single, statically known object — a global variable at a link-time address. It is encodable but rare, because a full address is a wide field and most accesses are relative to something.

Register-indirect. A register holds the address. Written (%rax), the effective address is , and the operand is the bytes there. This is pointer dereference: load the pointer into a register, then dereference with (%reg). The address is computed, but trivially — just read the register.

Register-indirect mode. The register holds an address; the effective address is its contents, and the operand is the memory at that address. This is pointer dereference — (%rax) reaches the bytes %rax points to.

Displacement (base + offset). A register plus a constant. Written D(%rb), the effective address is . This is the single most useful memory mode, because it names a fixed offset from a moving base: a field inside a struct (8(%rax) for the field 8 bytes into the object %rax points to), or a local variable at a known offset from the stack or frame pointer (-16(%rbp)). Register-indirect is just the case.

Scaled-indexed (base + index × scale). The full form, D(%rb, %ri, s), with effective address

This mode exists for array indexing. To reach array[i] with 8-byte elements, put the base in %rb, the index i in %ri, and use scale 8: one instruction, base(%rb, %ri, 8), computes base + i*8. The scale set matches the sizes of char, short, int, and long.

PC-relative. The operand is an offset added to the program counter. The effective address is , where the PC holds the address of the next instruction. Jumps and calls use it: a branch encodes go this many bytes forward or back from here, not an absolute target. In real x86-64 bytes, the conditional branch je with encoding 74 0e sitting at address 0x100 occupies two bytes, so the next instruction is at 0x102, and the branch target is . The payoff is position independence: because every internal reference is relative to the code itself, the whole block can be loaded at any address and every branch still lands correctly. Modern executables and shared libraries depend on this.

PC-relative addressing in real bytes. The two-byte branch je (74 0e) at address 0x100 ends at 0x102, and the target is next-PC plus the offset: 0x102 + 0x0e = 0x110. Move the whole block anywhere in memory and the branch still lands correctly, which is what makes code position-independent.

One machine state, every mode

To fix the arithmetic, run every mode against the same state. Suppose an array of 8-byte elements starts at address 0x2000, with a[0] = 0x11, a[3] = 0x44, and a[7] = 0x88; the registers hold %rbx = 0x2000 (the base) and %rsi = 3 (an index). Element a[i] lives at , so a[3] is at 0x2018 and a[7] at 0x2038. Now read each row and check the arithmetic.

InstructionModeEffective address%rax gets
movq $0x2038, %raximmediate0x2038
movq %rbx, %raxregister0x2000
movq 0x2038, %raxdirect0x2038a[7] = 0x88
movq (%rbx), %raxregister-indirect0x2000a[0] = 0x11
movq 0x18(%rbx), %raxdisplacement0x2018a[3] = 0x44
movq (%rbx,%rsi,8), %raxscaled-indexed0x2018a[3] = 0x44
movq 0x10(%rbx,%rsi,8), %raxscaled-indexed0x2038a[7] = 0x88

Two rows deserve a second look. The displacement row and the first scaled-indexed row reach the same cell two different ways: 0x18(%rbx) bakes the offset into the instruction as a constant, which works only because the index is known at compile time, while (%rbx,%rsi,8) computes it from %rsi at run time and keeps working when i changes. That is the whole reason scaled-indexed exists: it is displacement mode with the offset promoted from a constant to a variable. The last row exercises every field at once.

The full effective-address computation for 0x10(%rbx,%rsi,8) with %rbx = 0x2000 and %rsi = 3. The index is scaled (3 x 8 = 0x18), added to the base and displacement (0x10 + 0x2000 + 0x18 = 0x2038), and the sum is the address sent to memory, which returns a[7] = 0x88.

A mode per data shape: one struct, walked

Each mode matches a data shape, which shows when a compiler picks one per access in ordinary C. Take an array of structs:

record.cc
struct rec { long id; long score; };
struct rec table[100];

long lookup(long i) {
  return table[i].score;
}

Reaching table[i].score is one effective-address computation using the richest mode. Each struct rec is 16 bytes; score is the second field, at offset 8. So

which is 8(%rbx, %rsi, 16) with the base in %rbx and the index i in %rsi. One instruction, movq 8(%rbx,%rsi,16), %rax, loads the field. Each field of the mode does a named job: scale = struct stride, index = subscript, displacement = field offset. This is why the general form has all four components — real data is exactly this shape, an array of fixed-size records with fields at known offsets.

table[i].score compiled to one scaled-indexed access. The struct stride (16 bytes) is the scale, the subscript i is the index, and the field offset (8 bytes, score is the second long) is the displacement, so 8(%rbx,%rsi,16) with %rbx = &table, %rsi = i reaches base + 16i + 8.

The richer the mode, the more the single instruction carries. In Y86-64, which has only displacement, that same table[i].score load cannot be one instruction — the i*16 has to be built by hand first, then the field offset folded into the displacement:

record.ysassembly
rrmovq %rsi, %rax       # i
addq %rax, %rax         # 2i
addq %rax, %rax         # 4i
addq %rax, %rax         # 8i
addq %rax, %rax         # 16i
addq %rbx, %rax         # &table + 16i  (base of table[i])
mrmovq 8(%rax), %rdx    # load .score: displacement 8 does the field offset

Y86-64's single memory mode still helps on the last line: the field offset +8 is absorbed into the displacement, because that part is a constant. It is only the variable i*16 that costs the four doublings and an add. The pattern generalizes to the rule from the previous lesson — constant offsets are cheap in any displacement mode, but a runtime-scaled index needs the scaled-indexed mode that Y86-64 lacks.

The set at a glance

The modes form a tidy progression: from operands that need no address, through one register, to a register and a constant, to the full base-plus-scaled-index, with PC-relative as the special case anchored on the program counter.

ModeSyntaxEffective addressUsed for
Immediate$Imm— (value is Imm)literal constants
Register%ra— (value is R[ra])values in registers
DirectImmImma fixed global
Register-indirect(%rb)R[rb]pointer dereference
DisplacementD(%rb)D + R[rb]struct field, local var
Scaled-indexedD(%rb,%ri,s)D + R[rb] + R[ri]·sarray element
PC-relativelabelPC + Djumps, calls, PIC

What Y86-64 drops, and the bill

The Y86-64 ISA of the next lesson keeps immediate, register, and displacement — and nothing else. Its only memory form is D(%rb), with register-indirect falling out free as the case. Direct mode is gone (a global's address must first be loaded into a register with irmovq), scaled-indexed is gone, and even jumps give up PC-relative: a Y86-64 jXX carries its full 8-byte absolute destination, so a conditional branch costs 9 bytes where x86-64's je above cost 2, and Y86-64 code is not position-independent.

The scaled-index cut costs the most. Reaching a[%rcx] from a base in %rdx is one instruction in x86-64, but Y86-64 must build by hand — and since its arithmetic family has no multiply and no shift, the only way to scale is repeated doubling.

indexing.sassembly
movq (%rdx,%rcx,8), %rax    # x86-64: a[i] in one instruction
indexing.ysassembly
rrmovq %rcx, %rax           # y86-64: i
addq %rax, %rax             # 2i
addq %rax, %rax             # 4i
addq %rax, %rax             # 8i
addq %rdx, %rax             # base + 8i
mrmovq (%rax), %rax         # load a[i]

Six instructions and a scratch register against one instruction. In a loop the honest fix is not to compute 8i at all: keep a pointer to the current element and add 8 each iteration, so every access is plain (%reg). Compilers call this strength reduction, and the array-sum program in this module's last lesson is built around exactly that pattern. The trade is the same one this module keeps meeting: x86-64 spends encoder richness so common patterns cost one instruction; Y86-64 spends instructions to keep its formats few enough to implement by hand.

lea, RIP-relative, and the RISC minimum

CS:APP introduces addressing modes on the way to x86-64 machine code. Three points about how they play out in modern practice round out the picture.

lea turns the address unit into an adder. x86-64's leaq (load effective address) computes the effective address but does not dereference it — it returns the address itself. Since the scaled-indexed mode computes in one instruction, compilers use leaq as a three-input arithmetic instruction that never touches memory:

The address-generation hardware, built for pointer math, is reused for integer math the ISA has no cheaper way to express — an ISA feature's designed use and its actual use diverging.

RIP-relative addressing made PC-relative general. Classic x86 used PC-relative only for branches. x86-64 added RIP-relative data addressing — movq sym(%rip), %rax — so that a global variable is reached as an offset from the instruction pointer rather than an absolute address.1 The payoff is the position-independence this lesson framed for branches, now extended to data: a shared library's code and its global references can be loaded at any address, which is what makes modern position-independent executables (the default hardening on current systems) possible. The mode this lesson called a special case for jumps turned out to be the foundation of how every shared library is addressed.

The RISC minimum. Where x86 accumulated modes, RISC ISAs deliberately keep the smallest set that still compiles well: essentially register, immediate, and base-plus-displacement — the same three Y86-64 keeps. ARM64 and RISC-V have no memory-to-memory anything and no direct absolute mode; RISC-V's only memory form is disp(reg), exactly Y86-64's, and it too builds array indices with explicit arithmetic or a walking pointer.2 The lesson's account of what Y86-64 gives up is not a teaching simplification that real machines avoid — it is the mainstream RISC design, and the strength-reduced walking pointer is what those compilers emit in practice. Y86-64's addressing austerity is closer to ARM64 than x86-64 is.

Footnotes

  1. RIP-relative addressing is x86-64's disp(%rip) mode, in which a data reference is computed as an offset from the address of the next instruction; it is the mechanism behind position-independent code and executables. See Intel, Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 1, §3.7.5.1, and the System V x86-64 ABI on PIC. The general idea — PC-relative addressing for position independence — is CS:APP §3.6/§7.
  2. A. Waterman and K. Asanović, eds., The RISC-V Instruction Set Manual, Volume I. RISC-V's load/store instructions use a single base-plus-12-bit- displacement addressing mode; there is no scaled-index or absolute mode, so array indexing is done with explicit slli/add or a pointer walked across the array — the strength-reduction pattern this module builds its array-sum program around.

╌╌ END ╌╌