The Concept of Addressing Modes in Assembly Language

Explain the concept of addressing modes in Assembly language

Addressing modes are one of those topics that sound abstract until you actually start writing Assembly by hand — and then you realize they’re everywhere, quietly determining how every single instruction figures out where its data actually lives. Once I understood addressing modes properly, reading disassembled code stopped feeling like decoding a foreign language and started feeling like reading a slightly terse dialect of something familiar.

This post covers what addressing modes are, why they exist, the major categories you’ll encounter, and how they play out concretely in x86/x86-64 and ARM Assembly.

What Is an Addressing Mode?

An addressing mode defines how an instruction specifies the location of the data it operates on. That data might be a literal constant embedded in the instruction, a value sitting in a register, or a value living somewhere in memory that needs its address computed. Different addressing modes represent different strategies for expressing “where is this operand,” and each comes with different tradeoffs in flexibility, instruction size, and execution speed.

Without addressing modes, every single instruction would need a completely rigid, fixed way of referring to operands — which would make things like array indexing, pointer dereferencing, and struct field access either impossible or absurdly inefficient. Addressing modes give Assembly the flexibility to express these common patterns directly in hardware-supported instruction forms.

The Major Categories of Addressing Modes

1. Immediate Addressing

The operand is a constant value embedded directly in the instruction itself — no register or memory lookup required.

x86-64:

mov eax, 42        ; 42 is an immediate value

ARM64:

MOV X0, #42         ; #42 is an immediate value

This is the fastest possible form since the value is available the moment the instruction is decoded — no additional fetch is needed.

2. Register Addressing

The operand’s value lives directly in a register.

x86-64:

mov eax, ebx        ; EBX's value, directly

ARM64:

MOV X0, X1            ; X1's value, directly

Also very fast — accessing the register file has minimal latency and doesn’t touch memory at all.

3. Direct (Absolute) Addressing

The instruction specifies an actual, fixed memory address where the operand lives.

x86-64:

mov eax, [0x601040]     ; read from the fixed memory address 0x601040

This mode is less common in modern position-independent code (since absolute addresses break under ASLR and relocation), but still appears, particularly for accessing statically known global data in non-PIE binaries.

4. Register Indirect Addressing

The operand’s address is held inside a register — the instruction “indirects” through that register to find the actual data.

x86-64:

mov eax, [rbx]           ; RBX holds the address; read the value there

ARM64:

LDR X0, [X1]               ; X1 holds the address; read the value there

This is the foundation of pointer dereferencing — exactly what happens when you dereference a pointer variable in C.

5. Base + Displacement (Base-Offset) Addressing

The effective address is computed by adding a fixed displacement (offset) to a base register’s value. Commonly used for struct field access and stack-relative variable access.

x86-64:

mov eax, [rbx + 8]         ; read the value at (RBX + 8)

ARM64:

LDR X0, [X1, #8]             ; read the value at (X1 + 8)

6. Base + Index (with optional Scale) Addressing

The effective address is computed from a base register plus an index register, optionally multiplied by a scale factor — this is exactly how array element access works.

x86-64:

mov eax, [rbx + rcx*4]       ; base RBX, index RCX scaled by 4 (e.g., 4-byte ints)

ARM64:

LDR X0, [X1, X2, LSL #3]      ; base X1, index X2 shifted left by 3 (scaled by 8)

The formula:

effective_address = base + (index * scale) + displacement

7. PC-Relative (RIP-Relative) Addressing

The effective address is computed relative to the current instruction pointer / program counter, rather than any general-purpose register. This is heavily used in position-independent code, since it naturally adapts regardless of where the code is loaded in memory.

x86-64:

lea rax, [rip + some_label]     ; address of some_label, relative to RIP

ARM64:

ADR X0, some_label                 ; PC-relative address of some_label

8. Pre-Indexed and Post-Indexed Addressing (ARM-specific emphasis)

ARM places special emphasis on addressing modes that automatically update the base register as a side effect of the load/store — extremely useful for walking through arrays or stacks.

Pre-indexed (base register updated before the access, and the updated value is used for the access):

LDR X0, [X1, #8]!         ; X1 = X1 + 8, then load from new X1 into X0

Post-indexed (base register updated after the access, using the original value for the access):

LDR X0, [X1], #8           ; load from X1 into X0, then X1 = X1 + 8

x86 doesn’t have a direct instruction-level equivalent of this auto-incrementing pattern outside of string instructions (movsb etc., which auto-increment RSI/RDI based on the direction flag) and explicit push/pop (which implicitly adjust RSP).

Addressing Modes and Struct/Array Access in Practice

To really cement how addressing modes get used in generated code, it helps to look at a concrete C-to-Assembly translation involving both a struct and an array.

struct Point {
    int x;
    int y;
};

int get_y_at(struct Point *points, int index) {
    return points[index].y;
}

The y field sits at offset 4 within each 8-byte Point struct. On x86-64, this compiles down to a single base+index+displacement addressing calculation:

; RDI = points (base), ESI = index
mov eax, [rdi + rsi*8 + 4]   ; base + index*scale + displacement, all in one instruction

This single instruction does the work of what would otherwise require several separate steps: multiply the index by the struct size (8), add that to the base pointer, add the field offset (4), and finally dereference the result. That’s the entire value proposition of rich addressing modes — collapsing common access patterns into single, hardware-supported instructions.

On ARM64, since instructions are more uniform, the same access typically takes two instructions instead of one, since ARM doesn’t support a three-term (base + scaled-index + displacement) computation in a single load:

ADD X2, X0, X1, LSL #3     ; X2 = points + index*8 (base+index, no extra displacement)
LDR W0, [X2, #4]              ; then apply the field displacement separately

This tradeoff — one flexible but more complex instruction on x86 versus two simpler, more uniform instructions on ARM — is a recurring theme throughout CISC vs. RISC design philosophy, and addressing modes are one of the clearest places to see it play out directly.

Segment-Based Addressing (Legacy x86 Context)

Older x86 documentation and some specialized modern use cases (like thread-local storage) reference segment override prefixes, which add yet another layer to the addressing calculation by incorporating a segment register into the effective address computation:

mov eax, [fs:0x28]      ; access memory relative to the FS segment base — commonly used for thread-local storage on Windows and Linux

While general-purpose segmentation (base+limit protection per segment) is largely a relic of 16-bit and early 32-bit x86 programming, segment override prefixes remain very much alive today specifically for thread-local storage (TLS) access, where each thread has its own small data area addressed relative to the FS or GS segment base.

Comparison Table: Addressing Modes at a Glance

Addressing ModeWhat It Specifiesx86-64 ExampleARM64 Example
ImmediateLiteral constantmov eax, 42MOV X0, #42
RegisterValue in a registermov eax, ebxMOV X0, X1
Direct/AbsoluteFixed memory addressmov eax, [0x601040](less common directly)
Register IndirectAddress held in a registermov eax, [rbx]LDR X0, [X1]
Base + DisplacementBase register + fixed offsetmov eax, [rbx+8]LDR X0, [X1, #8]
Base + Index (Scaled)Base + index*scalemov eax, [rbx+rcx*4]LDR X0, [X1, X2, LSL #3]
PC-RelativeOffset from instruction pointerlea rax, [rip+label]ADR X0, label
Pre/Post-IndexedBase register auto-updates(limited support)LDR X0, [X1, #8]! / LDR X0, [X1], #8

Addressing Modes for Multi-Dimensional Arrays

A natural extension of base+index addressing shows up when working with multi-dimensional arrays, where a single element’s address depends on more than one index. Since hardware addressing modes only directly support a single base, a single scaled index, and a displacement, accessing a 2D array typically requires the compiler (or the Assembly programmer) to first compute a combined offset before issuing the actual load or store.

int grid[10][20];
int value = grid[row][col];
; x86-64: address = base + (row * 20 + col) * 4
imul eax, row_reg, 20
add eax, col_reg
mov edx, [rbx + rax*4]     ; base+index*scale still applies to the final combined index

This shows an important nuance: addressing modes handle the final step of address computation elegantly, but more complex indexing arithmetic (like multiplying a row index by a row stride) generally still needs to happen in separate arithmetic instructions before the addressing mode’s base+index+displacement formula takes over for the final memory access.

How the CPU Resolves an Addressing Mode Internally

flowchart TD
    A[Decode instruction operand] --> B{Addressing mode type}
    B -->|Immediate| C[Extract constant from instruction encoding]
    B -->|Register| D[Read directly from register file]
    B -->|Register Indirect / Base+Disp / Base+Index| E[Address Generation Unit computes effective address]
    E --> F[Access cache/memory hierarchy]
    B -->|PC-Relative| G[Add offset to current PC/RIP value]
    G --> F
    C --> H[Operand ready for execution]
    D --> H
    F --> H

The Address Generation Unit (AGU) is dedicated hardware specifically responsible for performing the base+index*scale+displacement arithmetic quickly, typically in parallel with other pipeline activity, so that memory-based addressing modes don’t become a major bottleneck despite requiring more computation than immediate or register addressing.

Practical Use Cases by Addressing Mode

Addressing Modes in Compiler-Generated vs. Hand-Written Assembly

One thing I’ve noticed comparing compiler output to hand-written Assembly is that compilers are often far more aggressive about exploiting complex addressing modes than a human writing code by hand would naturally be. GCC and Clang, for instance, will happily fold a multiplication by a power of two into a scale factor, combine a struct offset with an array index into a single instruction, and choose RIP-relative addressing automatically for anything referencing global symbols in position-independent executables — all without being asked. Reading compiler-generated Assembly is, in this sense, one of the best ways to actually learn the full expressive range of a target architecture’s addressing modes, since compilers are tuned to use every trick the encoding allows.

Performance Considerations

Common Mistakes

Best Practices

FAQs

Which addressing mode is fastest? Immediate and register addressing are the fastest since they don’t touch memory at all. Among memory-based modes, simple register-indirect and base+displacement are typically as fast as base+index once cached, since the AGU computes the effective address in a single step regardless of mode complexity.

Why does x86 support so many more addressing mode combinations than ARM? x86 is historically a CISC architecture designed to let a single instruction do more work (including flexible memory addressing combined with arithmetic), while ARM’s RISC philosophy favors simpler, more uniform instructions, generally requiring a separate load/store step before arithmetic.

Do addressing modes affect instruction size? Yes, especially on x86 — immediate and register addressing tend to produce shorter encodings, while base+index with a large displacement and other prefixes can significantly increase instruction byte length, which can matter for instruction cache efficiency in performance-critical code.

Why can x86 combine base, index, scale, and displacement in one instruction while ARM often can’t? This comes down to instruction encoding philosophy. x86’s variable-length encoding and dedicated ModRM/SIB bytes give it room to pack a richer addressing calculation into a single instruction. ARM’s fixed 32-bit instruction width leaves less room for encoding all four components at once, so more complex address calculations are typically split across two simpler instructions.

Is segment-based addressing still relevant on modern 64-bit systems? Its original purpose — providing memory protection and separate address spaces per segment — is obsolete on modern flat-memory-model systems. However, segment override prefixes (particularly FS and GS) remain actively used today specifically for accessing thread-local storage efficiently.

Summary and Key Takeaways

References

Exit mobile version