How Memory Addresses Are Represented in Assembly Language

How are memory addresses represented in Assembly language

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 StyleExampleNotes
Intel (NASM)mov eax, [ebx]Destination first, brackets denote memory reference
AT&T (GAS)movl (%ebx), %eaxSource 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 Modex86-64 ExampleARM ExampleTypical Use Case
Direct/Absolutemov eax, [0x400000](rare, usually via register)Fixed global variables (legacy code)
Register Indirectmov eax, [ebx]ldr x0, [x1]Pointer dereference
Base + Displacementmov 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-Relativemov eax, [rip+var]N/A (uses adr/adrp)Position-independent code
Pre/Post-IndexedN/A (less common)ldr x0, [x1], #8Loop-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.field or struct->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:

AddressContent
rbp - 4Parameter a
rbp - 8Parameter b
rbp - 12Local variable sum
rbp - 16Local variable doubled
rbp (base)Saved previous rbp value
rbp + 8Return 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) or adrp/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

  1. Confusing the value in a register with the address it representsmov eax, ebx copies a value, while mov eax, [ebx] dereferences an address.
  2. Off-by-one errors in scale factors — using the wrong scale (e.g., *2 instead of *4) when indexing into an int array leads to incorrect memory access.
  3. Forgetting alignment requirements — some architectures fault or slow down significantly on misaligned memory accesses.
  4. 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 x in 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/
Total
0
Shares

Leave a Reply

Previous Post
Explain the concept of registers in Assembly language

Understanding Registers in Assembly Language: The CPU’s Fastest Storage

Next Post
In assembly language programming, the **program counter** (PC) is a crucial register that plays a fundamental role in the control flow of a program. Also referred to as the instruction pointer (IP) in some architectures, the program counter keeps track of the memory address of the next instruction to be fetched and executed. Here are key aspects of the role of the program counter in assembly language: ## 1. **Instruction Fetch:** - **Responsibility:** The primary responsibility of the program counter is to point to the memory address of the next instruction to be executed by the CPU. - **Incrementing:** After each instruction is fetched, the program counter is typically incremented to point to the next sequential memory address. ```assembly ; Example: Incrementing the program counter MOV AX, 1 ; Instruction 1 ADD AX, 2 ; Instruction 2 ``` ## 2. **Control Flow:** - **Branching:** The program counter is crucial for implementing control flow structures, such as conditional and unconditional branches. - **Jumps and Calls:** Instructions like jump (JMP) and call (CALL) modify the program counter, causing it to point to a different memory address, enabling the execution of instructions at that location. ```assembly ; Example: Jump instruction CMP AX, BX ; Compare AX and BX JE label1 ; Jump to label1 if equal ``` ## 3. **Subroutine Calls:** - **Call Instructions:** When a subroutine or function is called using a call instruction, the current value of the program counter is typically pushed onto the stack. - **Return Instructions:** After the subroutine completes its execution, a return instruction (RET) pops the saved program counter value from the stack, restoring the flow of execution to the calling routine. ```assembly ; Example: Subroutine call and return CALL subroutine ; Call subroutine ; ... ; Subroutine instructions RET ; Return from subroutine ``` ## 4. **Exception Handling:** - **Interrupts and Exceptions:** In systems that handle interrupts or exceptions, the program counter may be saved and restored to maintain the flow of the program after handling the interrupt or exception. - **Interrupt Service Routines (ISRs):** When an interrupt occurs, the program counter is often saved on the stack before transferring control to the interrupt service routine. ## 5. **Conditional Execution:** - **Conditional Jumps:** Conditional jump instructions (e.g., JE, JNE) modify the program counter based on the outcome of a previous comparison or test operation. ```assembly ; Example: Conditional jump CMP AX, BX ; Compare AX and BX JE label1 ; Jump to label1 if equal ``` ## 6. **Looping:** - **Loop Instructions:** Looping constructs use the program counter to repeat a sequence of instructions until a certain condition is met. ```assembly ; Example: Loop instruction MOV CX, 5 ; Initialize loop counter label1: ; ... ; Loop body LOOP label1 ; Decrement CX and jump to label1 if CX is not zero ``` ## 7. **Program Termination:** - **Halt or End Instructions:** The program counter is involved in reaching the end of the program or executing a halt instruction, signaling the termination of the program. ```assembly ; Example: Halt instruction HLT ; Halt execution ``` ## Conclusion: The program counter is a critical component in assembly language programming, determining the sequence of instructions to be executed. Its role in control flow, subroutine calls, conditional execution, looping, and program termination makes it an indispensable part of the execution model of a computer program. Understanding and managing the program counter is essential for creating well-structured and functional assembly language programs.

The Role of the Program Counter in Assembly Language: A Deep Dive

Related Posts