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.
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.
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.
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.
| Instruction | Mode | Effective address | %rax gets |
|---|---|---|---|
movq $0x2038, %rax | immediate | — | 0x2038 |
movq %rbx, %rax | register | — | 0x2000 |
movq 0x2038, %rax | direct | 0x2038 | a[7] = 0x88 |
movq (%rbx), %rax | register-indirect | 0x2000 | a[0] = 0x11 |
movq 0x18(%rbx), %rax | displacement | 0x2018 | a[3] = 0x44 |
movq (%rbx,%rsi,8), %rax | scaled-indexed | 0x2018 | a[3] = 0x44 |
movq 0x10(%rbx,%rsi,8), %rax | scaled-indexed | 0x2038 | a[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.
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:
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.
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:
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.
| Mode | Syntax | Effective address | Used for |
|---|---|---|---|
| Immediate | $Imm | — (value is Imm) | literal constants |
| Register | %ra | — (value is R[ra]) | values in registers |
| Direct | Imm | Imm | a fixed global |
| Register-indirect | (%rb) | R[rb] | pointer dereference |
| Displacement | D(%rb) | D + R[rb] | struct field, local var |
| Scaled-indexed | D(%rb,%ri,s) | D + R[rb] + R[ri]·s | array element |
| PC-relative | label | PC + D | jumps, 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.
movq (%rdx,%rcx,8), %rax # x86-64: a[i] in one instruction
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
- 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. ↩ - 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/addor a pointer walked across the array — the strength-reduction pattern this module builds its array-sum program around. ↩
╌╌ END ╌╌