Every variable, instruction, and piece of data your program touches lives somewhere in memory — and Assembly language is where you finally see exactly how that “somewhere” is expressed. If you’ve ever wondered what’s really happening behind a high-level array index or pointer dereference, this is where it all becomes concrete. Let’s walk through how memory addresses are represented in Assembly, from basic numbering systems to full addressing modes on x86-64 and ARM.
What Is a Memory Address?
A memory address is a unique numerical identifier for a specific byte location in a computer’s memory (RAM). Think of memory as a giant array of bytes, each with its own index — that index is the address. On a 64-bit system, addresses are typically represented as 64-bit values, though the actual usable address space is often smaller (commonly 48 bits are used in practice, with the rest reserved).
Memory addresses are almost universally represented in hexadecimal notation in Assembly language and debuggers, because hex maps cleanly onto binary (each hex digit = 4 bits) and is far more compact than binary while still being easy for programmers to reason about.
Binary: 0000000001000000000000000000000000000000000000
Hexadecimal: 0x400000
Basic Memory Layout Diagram
Before diving into addressing modes, it helps to visualize how a typical process’s memory is organized:
graph TB
A["High Addresses"] --> B["Stack (grows downward)"]
B --> C["Memory-Mapped Region / Shared Libraries"]
C --> D["Heap (grows upward)"]
D --> E["BSS Segment (uninitialized data)"]
E --> F["Data Segment (initialized global/static data)"]
F --> G["Text/Code Segment (program instructions)"]
G --> H["Low Addresses"]
Each of these segments occupies a distinct range of memory addresses, and Assembly instructions reference specific addresses within them — whether that’s fetching an instruction from the text segment or reading a global variable from the data segment.
Representing Addresses in Assembly Syntax
Different assemblers use different syntax conventions, but the core ideas are the same. The two dominant syntax styles for x86/x86-64 are AT&T syntax (used by GNU Assembler/GAS) and Intel syntax (used by NASM and MASM).
| Syntax Style | Example | Notes |
|---|---|---|
| Intel (NASM) | mov eax, [ebx] | Destination first, brackets denote memory reference |
| AT&T (GAS) | movl (%ebx), %eax | Source first, parentheses denote memory reference, registers prefixed with % |
This post primarily uses Intel syntax (NASM-style) for clarity, since it’s more common in modern tutorials, but ARM syntax is shown in its standard form as well.
Direct (Absolute) Addressing
The simplest form: the instruction directly specifies a fixed memory address.
mov eax, [0x400000] ; load the value stored at address 0x400000 into eax
This is straightforward but inflexible — hardcoding a numeric address is rare in real programs since it breaks portability and works against techniques like ASLR (Address Space Layout Randomization).
Register Indirect Addressing
Here, a register holds the memory address, and the instruction accesses the memory location that register points to.
mov eax, [ebx] ; ebx holds an address; load the value at that address into eax
This is extremely common — it’s how pointers work at the Assembly level. If ebx contains 0x7ffeead2, then [ebx] means “the value stored at address 0x7ffeead2.”
Base + Displacement (Offset) Addressing
This mode adds a constant offset to a base register’s value, which is essential for accessing structure fields or array elements at known fixed offsets.
mov eax, [ebx + 4] ; load value at address (ebx + 4)
If ebx points to the start of a structure, [ebx + 4] might represent the second 4-byte field of that structure.
Indexed Addressing (Base + Index)
Used heavily for array traversal, this mode combines a base register (array start) with an index register (current position).
mov eax, [ebx + ecx] ; base ebx, index ecx
mov eax, [ebx + ecx*4] ; base ebx, index ecx scaled by 4 (common for int arrays)
The *4 scale factor is especially useful because a 4-byte int array means element i sits at base + i*4. x86 supports scale factors of 1, 2, 4, and 8, matching common data type sizes (byte, word, dword, qword).
Full x86-64 Addressing Mode Syntax
mov eax, [base + index*scale + displacement]
Example:
mov eax, [rbx + rcx*4 + 8]
This computes the effective address as: rbx + (rcx * 4) + 8.
RIP-Relative Addressing (x86-64 Specific)
x86-64 introduced RIP-relative addressing, which computes an address relative to the current instruction pointer (RIP). This is crucial for position-independent code, since it allows referencing data without needing an absolute address baked into the instruction.
mov eax, [rip + some_variable] ; address = current RIP + offset to some_variable
This is why modern 64-bit Linux binaries (compiled with -fPIC) rely so heavily on RIP-relative addressing — it lets the OS load the binary at any base address (thanks to ASLR) without needing to patch every single memory reference.
ARM Addressing Modes
ARM (both 32-bit and AArch64) uses a load/store architecture, meaning only specific ldr (load) and str (store) instructions can access memory — arithmetic instructions only work on registers.
Immediate offset:
ldr x0, [x1, #8] ; load from address (x1 + 8) into x0
Register offset:
ldr x0, [x1, x2] ; load from address (x1 + x2) into x0
Pre-indexed (updates base register before use):
ldr x0, [x1, #8]! ; x1 = x1 + 8, then load from new x1 into x0
Post-indexed (updates base register after use):
ldr x0, [x1], #8 ; load from x1 into x0, THEN x1 = x1 + 8
Pre- and post-indexed addressing are especially useful for looping through arrays, since they combine the memory access and the pointer increment into a single instruction.
Comparison Table: Addressing Modes
| Addressing Mode | x86-64 Example | ARM Example | Typical Use Case |
|---|---|---|---|
| Direct/Absolute | mov eax, [0x400000] | (rare, usually via register) | Fixed global variables (legacy code) |
| Register Indirect | mov eax, [ebx] | ldr x0, [x1] | Pointer dereference |
| Base + Displacement | mov eax, [ebx+4] | ldr x0, [x1, #4] | Struct field access |
| Indexed (Base+Index*Scale) | mov eax, [ebx+ecx*4] | ldr x0, [x1, x2, lsl #2] | Array traversal |
| RIP-Relative | mov eax, [rip+var] | N/A (uses adr/adrp) | Position-independent code |
| Pre/Post-Indexed | N/A (less common) | ldr x0, [x1], #8 | Loop-based pointer walking |
How the CPU Computes Effective Addresses
Internally, the CPU’s Address Generation Unit (AGU) computes the final “effective address” from these components before the memory access actually happens.
flowchart LR
A[Base Register Value] --> D[Address Generation Unit]
B["Index Register × Scale"] --> D
C[Displacement/Offset] --> D
D --> E[Effective Memory Address]
E --> F[Memory / Cache Access]
This calculation happens extremely fast — often in a single clock cycle — because it’s a dedicated hardware unit separate from the main ALU.
Virtual Addresses vs Physical Addresses
Everything discussed so far describes how addresses look and behave from the perspective of a running program — but there’s an important layer of translation happening underneath that every Assembly programmer should understand: virtual memory.
The addresses your Assembly instructions reference ([rbx], [rip+var], and so on) are almost always virtual addresses, not the actual physical location in RAM chips. The operating system, working with the CPU’s Memory Management Unit (MMU), translates virtual addresses into physical addresses transparently, using page tables.
graph LR
A["Virtual Address (used in Assembly instructions)"] --> B["MMU - Memory Management Unit"]
B --> C["Page Table Lookup"]
C --> D["Physical Address (actual RAM location)"]
D --> E["Physical Memory Access"]
This translation happens for essentially every memory access, though it’s cached in a hardware structure called the Translation Lookaside Buffer (TLB) to avoid the overhead of a full page table walk on every single access. From your Assembly code’s point of view, none of this is visible — you simply write mov eax, [rbx], and the hardware and OS handle the virtual-to-physical translation behind the scenes.
This abstraction is what makes several important OS features possible:
- Process isolation: each process gets its own virtual address space, so a program can’t accidentally (or maliciously) read or write another process’s memory, even though they may be using overlapping virtual address ranges.
- ASLR (Address Space Layout Randomization): since programs deal only in virtual addresses, the OS can randomize where segments (stack, heap, libraries) are actually placed in physical memory (or more precisely, randomize the virtual base addresses) each time a program runs, making certain memory-corruption exploits harder to pull off reliably.
- Paging and swapping: the OS can move rarely-used memory pages out to disk and back without the running program ever noticing, since the program only ever deals with stable virtual addresses.
It’s worth noting that a small category of Assembly programming — writing OS kernels, bootloaders, or device drivers — does need to work directly with physical addresses, particularly during early boot before the MMU and page tables are even set up. In that context, understanding the distinction between virtual and physical addressing isn’t just academic; it’s essential to getting the system to boot at all.
Practical Use Cases
- Array indexing: indexed addressing modes directly implement
array[i]from high-level languages. - Struct/object field access: base + displacement addressing implements
object.fieldorstruct->field. - Pointer arithmetic: register indirect and indexed modes are exactly how C pointers work under the hood.
- Position-independent executables (PIE) and shared libraries: RIP-relative addressing (x86-64) or
adrp/adr(ARM) make code relocatable in memory, essential for ASLR-based security. - Stack frame access: local variables are typically accessed via base-pointer-relative addressing (e.g.,
[rbp - 8]).
A Closer Look: Addressing Within a Stack Frame
One of the most common real-world uses of base+displacement addressing is accessing local variables and function parameters within a stack frame. Walking through a concrete example ties together everything covered above.
Consider this simple C function:
int compute(int a, int b) {
int sum = a + b;
int doubled = sum * 2;
return doubled;
}
When compiled (unoptimized) for x86-64, the function typically sets up a stack frame like this:
compute:
push rbp
mov rbp, rsp ; rbp now points to the base of this stack frame
sub rsp, 16 ; allocate 16 bytes for local variables
mov [rbp-4], edi ; store parameter a at rbp-4
mov [rbp-8], esi ; store parameter b at rbp-8
mov eax, [rbp-4] ; load a
add eax, [rbp-8] ; add b
mov [rbp-12], eax ; store sum at rbp-12
mov eax, [rbp-12] ; load sum
add eax, eax ; double it
mov [rbp-16], eax ; store doubled at rbp-16
mov eax, [rbp-16] ; return value goes in eax
leave ; restore rsp/rbp
ret
Here’s a visual of how these variables map onto actual stack memory addresses relative to rbp:
| Address | Content |
|---|---|
rbp - 4 | Parameter a |
rbp - 8 | Parameter b |
rbp - 12 | Local variable sum |
rbp - 16 | Local variable doubled |
rbp (base) | Saved previous rbp value |
rbp + 8 | Return address (pushed by call) |
This pattern — using negative offsets from rbp for locals/parameters, and positive offsets for the return address and any stack-passed arguments beyond what fits in registers — is nearly universal across x86-64 compiled code, and understanding it is essential for reading stack traces, using a debugger effectively, or manually writing Assembly functions that need to interoperate with C. ARM uses an analogous pattern with its frame pointer (X29) and stack pointer (SP), though the exact offsets and calling convention details differ per the AAPCS64 standard.
Debugging Memory Addresses
In GDB, examining memory addresses is one of the most common debugging tasks:
(gdb) x/4xw 0x7ffeead2 # examine 4 words in hex at address 0x7ffeead2
(gdb) print &myVariable # get address of a variable
(gdb) print *(int*)0x400000 # dereference an address as an int
Understanding addressing modes makes it far easier to read disassembled code (via objdump -d or Ghidra) and understand what a compiled program is actually doing with memory.
Troubleshooting Address-Related Bugs
A few recurring troubleshooting patterns come up repeatedly when working with memory addresses in Assembly, and recognizing them quickly can save hours of debugging:
- Segmentation faults from invalid addressing: if a register used in an addressing expression contains garbage (e.g., an uninitialized register, or a value corrupted by a previous bug), dereferencing it with
[reg]will typically crash the program immediately with a segmentation fault. Check the register’s value with a debugger right before the faulting instruction to confirm whether it holds a sane, expected address. - Off-by-a-few-bytes errors in struct/array access: if data looks “shifted” when you read it back — for example, you expect an integer but get a value that looks like it’s combined with part of the next field — double-check your displacement offsets and scale factors against the actual size of each field, remembering that compilers may also insert padding bytes for alignment purposes that aren’t obvious from the struct definition alone.
- Position-independent code failures: if code that worked fine as a standalone executable breaks when built as a shared library (
.so/.dll), check whether you’re using absolute addressing where RIP-relative (x86-64) oradrp/adr-based (ARM) addressing is required instead. - Stack addresses that look unexpectedly large or “random”: this is usually just ASLR at work, not a bug — stack, heap, and library base addresses are intentionally randomized by the OS on each run for security, so don’t assume a specific hardcoded address will remain stable across executions when debugging.
Common Mistakes
- Confusing the value in a register with the address it represents —
mov eax, ebxcopies a value, whilemov eax, [ebx]dereferences an address. - Off-by-one errors in scale factors — using the wrong scale (e.g.,
*2instead of*4) when indexing into anintarray leads to incorrect memory access. - Forgetting alignment requirements — some architectures fault or slow down significantly on misaligned memory accesses.
- Hardcoding absolute addresses — breaks with ASLR and non-PIE vs PIE binary differences; almost always avoided in modern code.
Best Practices
- Prefer indexed and RIP-relative addressing over hardcoded absolute addresses for portability and ASLR compatibility.
- Always double check scale factors match your data type size.
- Use a disassembler alongside source code to see how high-level array/struct access maps to real addressing modes.
- When debugging, get comfortable reading raw hex addresses and using tools like
xin GDB to inspect memory directly.
FAQs
Q: Why are memory addresses shown in hexadecimal instead of decimal? Hex maps cleanly to binary (4 bits per digit), making it far easier to reason about memory layout, bit patterns, and alignment than decimal would be.
Q: What’s the difference between a memory address and a pointer? A pointer, in high-level languages, is a variable that stores a memory address. In Assembly, there’s no distinct “pointer type” — it’s just a register or memory location holding a numeric address.
Q: Why does x86-64 use RIP-relative addressing? It enables position-independent code, which is required for ASLR and shared libraries to work efficiently without patching every address reference at load time.
Q: Are addressing modes the same across all CPU architectures? No — while the general concepts (direct, indirect, indexed) are common, the exact syntax and available combinations differ significantly between architectures like x86-64 and ARM.
Summary and Key Takeaways
- Memory addresses represent specific byte locations in RAM and are almost universally written in hexadecimal in Assembly and debugging tools.
- Assembly languages support several addressing modes: direct, register indirect, base+displacement, indexed, and RIP-relative (x86-64) or pre/post-indexed (ARM).
- These addressing modes are the low-level mechanism behind high-level concepts like arrays, structs, and pointers.
- Understanding addressing modes is essential for reading disassembled code, debugging, and writing efficient, secure low-level programs.
References
- Intel® 64 and IA-32 Architectures Software Developer’s Manual, Vol. 1, Chapter 3 (Addressing Modes) — https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- AMD64 Architecture Programmer’s Manual, Volume 1: Application Programming — https://www.amd.com/en/support/tech-docs
- ARM Architecture Reference Manual for A-profile architecture (Addressing Modes) — https://developer.arm.com/documentation
- GNU Assembler (GAS) Documentation — https://sourceware.org/binutils/docs/as/