The first time I truly understood pointers in C, it was because I had already spent time writing raw Assembly and using register indirect addressing. Once you see how a register can hold not a value, but the address of a value, higher-level pointer concepts stop feeling like magic. In this post, I want to break down register indirect addressing from the very basics all the way to how compilers and operating systems lean on it constantly, with real x86, x86-64, and ARM examples along the way.
What Is Register Indirect Addressing?
In Assembly language, an addressing mode defines how the CPU figures out where an operand actually lives. Register indirect addressing means: instead of a register holding the data you want to operate on, it holds the memory address where that data is stored. The CPU has to dereference the register — follow the address it contains — to get to the actual value.
Compare these two ideas:
- Register addressing:
MOV AX, BX— copy the value sitting inside BX into AX. - Register indirect addressing:
MOV AX, [BX]— treat the value inside BX as a memory address, go to that address, and copy whatever is stored there into AX.
That single set of square brackets [ ] (or parentheses in ARM/AT&T-style syntax) changes everything. It’s the difference between “use this number” and “use whatever is stored at this number.”
Why This Addressing Mode Matters
Register indirect addressing is the foundation of:
- Traversing arrays and buffers
- Implementing linked lists, trees, and other pointer-based data structures
- Passing large structures efficiently between functions (by passing an address instead of copying data)
- Implementing dynamic memory access where the address isn’t known until runtime
Without it, every memory access would need a hardcoded, fixed address baked into the instruction — which would make dynamic programs (loops over arrays, recursive structures, function calls with pointer arguments) practically impossible.
A Memory Diagram to Visualize It
Let’s say register BX contains the value 0x2000, and memory address 0x2000 contains the value 42.
Register BX: 0x2000
|
v
Memory Address 0x2000: [ 42 ]
When you execute MOV AX, [BX], the CPU does not put 0x2000 into AX. It follows the arrow, goes to address 0x2000, reads the value 42, and that’s what ends up in AX.
x86 / x86-64 Assembly Examples
Basic Register Indirect Addressing (NASM syntax)
section .data
value dd 42 ; a 32-bit value stored in memory
section .text
mov ebx, value ; EBX now holds the ADDRESS of 'value'
mov eax, [ebx] ; EAX now holds the VALUE at that address (42)
Register Indirect Addressing With Displacement (Base + Offset)
x86 also allows combining register indirect addressing with a constant offset, which is extremely useful for accessing struct fields or array elements:
; Suppose ESI points to the base of a structure
mov eax, [esi] ; first field
mov ebx, [esi + 4] ; second field, 4 bytes later
mov ecx, [esi + 8] ; third field, 8 bytes later
x86-64: 64-bit Registers as Pointers
section .data
numbers dq 10, 20, 30, 40
section .text
lea rsi, [numbers] ; RSI holds the address of the array
mov rax, [rsi] ; RAX = 10 (first element)
mov rax, [rsi + 8] ; RAX = 20 (second element, 8 bytes per qword)
mov rax, [rsi + 16] ; RAX = 30
Indexed Register Indirect Addressing (Scaled Index)
x86 supports a powerful form: [base + index*scale + displacement], ideal for array traversal:
; Traverse an array of 4-byte integers
mov ecx, 0 ; index = 0
loop_start:
mov eax, [esi + ecx*4] ; access array[ecx]
; ... process eax ...
inc ecx
cmp ecx, 10
jl loop_start
ARM Assembly Examples
ARM uses a very similar concept but with its own syntax conventions.
Basic Register Indirect Addressing
LDR R1, =value ; R1 = address of 'value'
LDR R0, [R1] ; R0 = value stored at that address
With Offset (Struct-like Access)
LDR R0, [R1, #0] ; load first field
LDR R2, [R1, #4] ; load second field, 4 bytes later
LDR R3, [R1, #8] ; load third field
Pre-Indexed and Post-Indexed Addressing (ARM-specific power)
ARM extends register indirect addressing with auto-increment/decrement variants that x86 doesn’t have natively:
; Post-indexed: use R1 as address, THEN increment R1
LDR R0, [R1], #4
; Pre-indexed: increment R1 FIRST, then use as address
LDR R0, [R1, #4]!
This is incredibly handy for array traversal loops, since the pointer update happens as part of the same instruction, saving a separate ADD instruction.
How the CPU Handles This Internally
flowchart TD
A[Instruction: MOV AX, register-BX] --> B[CPU reads value inside register]
B --> C{Is this register-indirect mode?}
C -->|Yes| D[Treat register value as a memory address]
D --> E[Send address to Memory Management Unit]
E --> F[MMU translates address, fetches data from RAM/cache]
F --> G[Data returned to destination register]
C -->|No| H[Use register value directly, no memory access]
The key difference from direct register addressing is step E–F: an actual memory bus transaction (or cache lookup) happens, which is slower than simply reading a register, but far more flexible.
Comparing Addressing Modes
| Addressing Mode | Example (x86) | What It Means | Speed |
|---|---|---|---|
| Immediate | MOV AX, 5 | Use the literal value 5 | Fastest |
| Register | MOV AX, BX | Use the value in BX | Fastest |
| Direct (memory) | MOV AX, [0x1000] | Use value at fixed address 0x1000 | Slower (memory access) |
| Register Indirect | MOV AX, [BX] | Use value at the address stored in BX | Slower (memory access) |
| Base + Displacement | MOV AX, [BX+4] | Use value at (BX + 4) | Slower (memory access) |
| Indexed/Scaled | MOV AX, [BX+SI*2] | Use value at (BX + SI*scale) | Slower (memory access) |
Register indirect addressing sits right in the middle in terms of complexity: more flexible than direct addressing (because the address can change at runtime), but requiring an actual memory access, unlike pure register-to-register operations.
Practical Use Cases
1. Array Traversal
Almost every loop over an array in compiled C code becomes register indirect addressing under the hood. When you write arr[i] in C, the compiler emits something like [base_register + i*element_size].
2. Function Parameter Passing by Pointer
When you pass a struct or array to a function “by reference,” the function receives an address in a register (per calling convention, e.g. RDI in System V x86-64), and every access to that data uses register indirect addressing.
3. Linked Data Structures
Linked lists, trees, and graphs are built entirely on the idea of “a memory location containing the address of the next memory location” — which is precisely register indirect addressing at the machine level.
4. Operating System Interaction
Kernel code frequently receives pointers to user-space buffers (for system calls like read() or write()), and dereferencing these pointers safely (checking they’re valid, not kernel memory, etc.) is central to OS security — this is literally register indirect addressing combined with validation logic.
Debugging and Performance Considerations
- Segmentation faults: The most common bug tied to register indirect addressing is dereferencing an invalid or uninitialized address — this is exactly what causes a segmentation fault in higher-level languages.
- Cache locality: Because register indirect addressing triggers an actual memory access, sequential access patterns (like array traversal) benefit heavily from CPU cache prefetching, while random pointer-chasing (like traversing a linked list) tends to cause cache misses and hurts performance.
- Debugging with GDB: When debugging, examining a register with
info registersshows you the address, but you often needx/4xb $rbx(examine memory) to see what’s actually stored at that address — a very literal illustration of the direct-vs-indirect distinction. - Null pointer checks: Good Assembly and low-level C code always validates that a register isn’t zero (or otherwise invalid) before using it in register indirect addressing, to avoid crashing on a dereference of address 0.
Common Mistakes
- Confusing the register’s value with the address it points to — forgetting the brackets (
MOV AX, BXvsMOV AX, [BX]) is a classic beginner error with very different outcomes. - Using an uninitialized register as a pointer — leads to unpredictable crashes or memory corruption.
- Off-by-one errors in scaled indexing — forgetting that the scale factor must match the element size (e.g.,
*4for 32-bit integers,*8for 64-bit values). - Ignoring alignment requirements — some architectures penalize or outright fault on misaligned memory accesses through register indirect addressing.
- Not accounting for endianness — the byte order in memory can trip up manual pointer arithmetic when working across architectures.
Best Practices
- Always initialize a register before using it as a pointer.
- Prefer indexed/scaled addressing modes for array loops instead of manually recomputing addresses each iteration.
- Use ARM’s pre/post-indexed addressing to combine pointer updates with data access when writing tight loops.
- Comment your Assembly clearly when a register is being used as a pointer versus a plain value — it’s not always obvious at a glance.
- When debugging, always double-check whether you’re inspecting the register (address) or the memory it points to (value).
Register Indirect Addressing in Higher-Level Language Constructs
It helps to connect this all the way back up to the languages most of us write day to day, because it makes the abstraction click permanently.
| High-Level Construct | Underlying Assembly Concept |
|---|---|
int *p; *p = 5; in C | Register indirect addressing: MOV [register], 5 |
arr[i] in C/C++ | Indexed register indirect: MOV reg, [base + i*scale] |
| Passing a struct “by reference” | A pointer (address) loaded into a register, dereferenced inside the function |
this pointer in C++ methods | Typically held in a register (e.g., RCX on MSVC x64, RDI on System V) and dereferenced for member access |
| Java/Python object references | Conceptually a pointer under the hood, though the runtime adds a layer of indirection through object headers and garbage collection metadata |
Seeing this table made me realize that “pointers are just addresses stored somewhere” isn’t a metaphor — it’s a literal, mechanical description of what the CPU is doing every time a dereference happens.
Combining Register Indirect Addressing With Loops: A Full Walkthrough
Let’s trace through a complete, realistic example: summing an array of 32-bit integers using register indirect addressing on x86-64.
section .data
arr dd 1, 2, 3, 4, 5
arr_len equ 5
section .text
global _start
_start:
lea rsi, [arr] ; RSI = address of first element
xor rcx, rcx ; RCX = index counter, start at 0
xor eax, eax ; EAX = running sum, start at 0
sum_loop:
cmp rcx, arr_len
jge sum_done
add eax, [rsi + rcx*4] ; register indirect + scaled index addressing
inc rcx
jmp sum_loop
sum_done:
; EAX now holds the sum of all elements
Every single iteration of this loop performs register indirect addressing with a scaled index — the CPU computes rsi + rcx*4 as the effective address, then dereferences it to fetch the actual integer value. This is, byte for byte, what a compiled for loop over an int[] array looks like in optimized C code.
How the CPU Encodes Register Indirect Addressing (ModRM Byte)
For anyone curious about what’s actually happening at the bit level, x86 instructions that use register indirect addressing encode this information in a structure called the ModRM byte, which follows the opcode in the instruction encoding.
| Field | Bits | Purpose |
|---|---|---|
| Mod | 2 bits | Addressing mode: 00 = register indirect (no displacement), 01 = 8-bit displacement, 10 = 32-bit displacement, 11 = register direct |
| Reg/Opcode | 3 bits | Identifies a register operand or extends the opcode |
| R/M | 3 bits | Identifies the base register used for the memory reference |
When Mod = 00 and R/M points to a general-purpose register like EBX, the CPU’s decoder knows to treat the instruction as pure register indirect addressing — dereference the address in that register with no added offset. Add a displacement byte or four, and Mod shifts to 01 or 10 to indicate that a constant offset follows the instruction, exactly like the [BX+4] examples shown earlier. ARM encodes its equivalent information directly in the instruction’s addressing mode fields rather than a separate ModRM-style byte, but the underlying decision tree — “is this a plain address, or an address plus an offset, and does the base register get updated afterward?” — is conceptually identical across both architectures.
Understanding this encoding layer isn’t strictly necessary to write Assembly, but it explains why certain addressing mode combinations are faster to decode than others, and why some assemblers produce shorter machine code for simple [reg] forms compared to [reg + large_displacement] forms.
Frequently Asked Questions
What’s the difference between register indirect and indexed addressing? Register indirect addressing uses a single register as the address ([BX]), while indexed addressing combines a base register with an index register and optional scale ([BX + SI*2]) for more flexible array-like access.
Is register indirect addressing slower than direct register addressing? Yes, because it requires an actual memory access (subject to cache hits/misses), whereas operating on a register directly happens within the CPU with no bus transaction.
Does ARM support the same addressing mode as x86? Conceptually yes, but ARM also offers pre-indexed and post-indexed variants that automatically update the base register, which x86 doesn’t provide as a single instruction.
Why do compilers use register indirect addressing so heavily? Because it’s the only efficient way to implement pointers, arrays, and pass-by-reference semantics found in virtually every higher-level programming language.
Can two registers be combined for indirect addressing at once? Yes — this is exactly what base+index addressing does, combining a base register (often a struct or array pointer) with an index register (often a loop counter), optionally multiplied by a scale factor matching the element size.
Does register indirect addressing work the same way for reading and writing memory? Yes, the same addressing mode applies symmetrically — MOV [BX], AX writes the value in AX to the address held in BX, exactly mirroring how MOV AX, [BX] reads from that same address.
Summary and Key Takeaways
Register indirect addressing is the mechanism that turns a register into a pointer — the value it holds is treated as an address, and the CPU dereferences it to fetch or store data. It’s foundational to arrays, structs, linked data structures, and function parameter passing across every major architecture. x86 and x86-64 support it with flexible displacement and scaled-index variants, while ARM adds convenient pre/post-indexed forms for tighter loops. Understanding this addressing mode is really understanding how pointers work at the hardware level — everything else in high-level pointer semantics builds on top of this one idea.
References
- Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 1: Basic Architecture — Addressing Modes
- AMD64 Architecture Programmer’s Manual, Volume 1: Application Programming — Addressing
- ARM Architecture Reference Manual — Addressing Modes for Load/Store Instructions
- GNU Assembler (GAS) Manual — Operand addressing syntax