Nearly everything a program does eventually boils down to moving data from one place to another — from memory into a register, from one register to another, or from a register back out to memory. In this post, I’ll walk through exactly how data movement works in assembly language, covering instructions, addressing modes, and practical examples across x86, x86-64, and ARM.
Why Data Movement Is Foundational
Before a CPU can add, compare, or branch on a value, that value has to actually be somewhere the CPU’s execution units can reach — almost always a register. Data movement instructions are responsible for getting values:
- From memory into registers
- From registers into memory
- Between registers directly
- As immediate constants directly into registers or memory
Without reliable, well-understood data movement, none of the other operations (arithmetic, comparisons, branching) would have anything to work with.
The Core Instruction: MOV
The most fundamental data movement instruction across nearly every architecture is some form of MOV.
x86-64 Examples
mov eax, 10 ; immediate-to-register: EAX = 10
mov ebx, eax ; register-to-register: EBX = EAX
mov [rdi], eax ; register-to-memory: store EAX at address in RDI
mov eax, [rdi] ; memory-to-register: load value at address in RDI into EAX
ARM64 Examples
mov x0, #10 ; immediate-to-register: X0 = 10
mov x1, x0 ; register-to-register: X1 = X0
str w0, [x1] ; register-to-memory: store W0 at address in X1
ldr w0, [x1] ; memory-to-register: load value at address in X1 into W0
Notice an important architectural difference here: x86 uses a single MOV mnemonic for both register and memory operations, while ARM uses separate, explicit LDR (load) and STR (store) instructions, reflecting its load/store architecture philosophy — arithmetic instructions in ARM never touch memory directly, only registers.
Internal Working Process: How a MOV Actually Executes
flowchart TD
A[Fetch MOV instruction] --> B[Decode: determine source and destination]
B --> C{Source type?}
C -->|Immediate| D[Value embedded directly in instruction encoding]
C -->|Register| E[Read value from source register]
C -->|Memory| F[Calculate effective address, read from memory/cache]
D --> G[Write value to destination]
E --> G
F --> G
G --> H{Destination type?}
H -->|Register| I[Value stored in destination register]
H -->|Memory| J[Value written to memory/cache at calculated address]
Addressing Modes: The Different Ways to Specify Data Location
Addressing modes define how an instruction determines where its data actually lives. This is one of the richest parts of understanding data movement.
| Addressing Mode | x86-64 Example | ARM64 Example | Description |
|---|---|---|---|
| Immediate | mov eax, 5 | mov x0, #5 | Constant value embedded in the instruction itself |
| Register direct | mov eax, ebx | mov x0, x1 | Value comes directly from another register |
| Register indirect | mov eax, [ebx] | ldr x0, [x1] | Address to read/write comes from a register |
| Base + displacement | mov eax, [ebx+8] | ldr x0, [x1, #8] | Register plus a constant offset |
| Base + index + scale | mov eax, [ebx+ecx*4] | ldr x0, [x1, x2, lsl #2] | Common pattern for array indexing |
| RIP-relative (x86-64 only) | lea rax, [rip+label] | N/A | Address relative to current instruction pointer, used for position-independent code |
| Pre/post-indexed (ARM specific) | N/A | ldr x0, [x1, #8]! (pre-indexed) / ldr x0, [x1], #8 (post-indexed) | Automatically updates the base register as part of the memory access |
ARM’s pre- and post-indexed addressing modes are particularly powerful for iterating through arrays, since the base register update and the memory access happen together in a single instruction:
; Post-indexed: load value at [x1], then increment x1 by 8 afterward
ldr x0, [x1], #8
Moving Data Between Registers of Different Sizes
Data movement often needs to handle size mismatches — moving an 8-bit or 16-bit value into a larger register while controlling what happens to the extra bits.
; x86-64: sign-extend a 32-bit value into a 64-bit register
movsxd rax, ebx ; sign-extend EBX into RAX
; x86-64: zero-extend a 32-bit value into a 64-bit register
; (Note: on x86-64, writing to a 32-bit register automatically zero-extends the upper 32 bits)
mov eax, ebx ; implicitly zero-extends into RAX's upper half
; zero-extend an 8-bit value
movzx eax, bl ; zero-extend BL (8-bit) into EAX (32-bit)
ARM64 has equivalent behavior:
sxtw x0, w1 ; sign-extend W1 (32-bit) into X0 (64-bit)
uxtb w0, w1 ; zero-extend the lowest byte of W1 into W0
Getting sign-extension versus zero-extension wrong is a very common source of subtle bugs, especially when working with negative numbers stored in smaller registers.
Loading Effective Addresses vs. Loading Values
A frequently confused pair of instructions:
mov rax, [my_variable] ; loads the VALUE stored at my_variable's address
lea rax, [my_variable] ; loads the ADDRESS of my_variable itself (no memory read)
LEA (Load Effective Address) doesn’t actually access memory at all — it just performs the address calculation and stores the result. This makes it useful not only for getting pointers, but also as a fast arithmetic trick:
lea eax, [ebx + ecx*2 + 4] ; computes ebx + ecx*2 + 4 without touching memory
Compilers frequently use LEA for simple multiplication and addition combinations because it can execute faster than equivalent MUL/ADD instruction sequences.
Moving Blocks of Data: String Instructions (x86)
x86 provides dedicated instructions for moving blocks of memory efficiently:
mov rsi, source_address
mov rdi, dest_address
mov rcx, block_length
rep movsb ; repeat MOVSB (move byte) RCX times, advancing RSI/RDI automatically
REP MOVSB is essentially a hardware-accelerated memcpy at the instruction level, historically important for bulk data movement, though modern compilers and CPUs often prefer vectorized (SIMD) copy loops for large blocks due to better throughput.
Vectorized Data Movement (SIMD)
Modern CPUs support moving multiple data elements simultaneously using SIMD registers:
; x86-64: move 128 bits (four 32-bit floats) at once
movaps xmm0, [array_a] ; requires 16-byte aligned memory
movups xmm0, [array_a] ; works with unaligned memory, slightly slower historically
ARM’s equivalent uses NEON registers:
ld1 {v0.4s}, [x0] ; load four 32-bit values into a NEON register
These instructions matter enormously for performance in multimedia processing, scientific computing, and machine learning workloads, since moving and processing multiple values per instruction dramatically reduces total instruction count.
Practical Use Cases
- Function argument passing: moving values into the correct registers (or stack locations) according to the calling convention before a
CALL/BL. - Array and struct access: using base+offset and scaled-index addressing modes to navigate structured memory layouts.
- String and buffer processing: bulk data movement instructions or SIMD loads/stores for efficient copying and transformation.
- Pointer arithmetic:
LEA(x86) is commonly used to compute addresses for linked lists, arrays, and dynamically allocated structures without an actual memory access.
Performance Considerations
- Memory access is far slower than register access — minimizing unnecessary loads/stores by keeping values in registers as long as possible is one of the most impactful manual optimizations.
- Alignment matters: aligned memory accesses (especially for SIMD instructions like
MOVAPS) are typically faster than unaligned ones, and some older instructions outright fault on misaligned access. - Cache locality: sequential memory access patterns (as in array traversal) benefit heavily from CPU cache prefetching, while scattered/random access patterns suffer significant performance penalties.
- Avoid redundant loads: reloading the same memory location repeatedly instead of caching it in a register wastes cycles and can prevent certain compiler/CPU optimizations.
Comparison: Register-Memory Model vs. Load-Store Model
| Aspect | x86 (Register-Memory Model) | ARM (Load-Store Model) |
|---|---|---|
| Arithmetic directly on memory | Yes (ADD [mem], eax) | No — must load into register first |
| Instruction count for memory-involved math | Often fewer instructions | Often more instructions, but more uniform/predictable |
| Complexity of instruction decoding | Higher (variable-length, many addressing modes) | Lower (fixed-length, simpler decode logic) |
Best Practices
- Prefer
LEAover separate arithmetic instructions when just computing an address, to save an instruction and avoid unnecessary flag updates. - Be explicit and deliberate about sign-extension versus zero-extension when moving smaller values into larger registers.
- Align data structures appropriately when working with SIMD instructions, to avoid alignment faults or performance penalties.
- Minimize memory traffic by keeping frequently used values in registers rather than repeatedly reloading them.
Common Mistakes and Troubleshooting
- Confusing
MOVandLEA: accidentally loading a value instead of an address, or vice versa. - Sign/zero-extension errors: sign-extending an unsigned value (or the reverse) leads to incorrect results, especially visible with negative numbers.
- Misaligned SIMD memory access: using
MOVAPSon unaligned memory causes a fault;MOVUPSshould be used instead when alignment can’t be guaranteed. - Forgetting register size implications on x86-64: writing to a 32-bit register alias automatically zero-extends into the full 64-bit register, which can surprise programmers expecting the upper bits to remain untouched.
FAQs
What’s the difference between MOV and LEA in x86? MOV with a memory operand reads or writes the actual value stored at that address. LEA only calculates the address itself and stores that calculated address — it never touches the memory being referenced.
Why does ARM use separate LDR/STR instructions instead of a universal MOV like x86? Because ARM follows a load/store architecture philosophy, where arithmetic and logic instructions only ever operate on registers, never directly on memory. Data must be explicitly loaded into a register before any computation, and explicitly stored back afterward.
What happens if I move a negative 8-bit value into a 32-bit register without sign-extending it? Using a plain zero-extending move (MOVZX) on a negative signed byte will produce an incorrect large positive value in the destination register, since the sign bit isn’t propagated. You need MOVSX (sign-extend) for correct signed behavior.
Is REP MOVSB still used in modern code? It’s less common in hand-written performance-critical code today, since modern SIMD-based copy routines are often faster for large blocks, but some highly optimized memcpy implementations still selectively use variants of it for specific size ranges.
Summary and Key Takeaways
- Data movement is the foundation of all assembly programming — nearly every other instruction depends on data first being in the right register or memory location.
- x86 allows a broad range of addressing modes combined directly with a general
MOV, reflecting its register-memory architecture, while ARM enforces a strict load/store model with dedicatedLDR/STRinstructions. LEAcomputes addresses without touching memory, making it useful both for pointer arithmetic and as a fast general-purpose calculation trick.- Sign-extension and zero-extension must be handled explicitly and correctly when moving data between differently sized registers.
- SIMD instructions allow moving and processing multiple data elements per instruction, which is critical for performance in multimedia, scientific, and machine learning workloads.
References
- Intel® 64 and IA-32 Architectures Software Developer’s Manuals — Intel Corporation
- AMD64 Architecture Programmer’s Manual — AMD
- ARM Architecture Reference Manual (ARMv7-A and ARMv8-A) — ARM Ltd.
- ARM NEON Programmer’s Guide — ARM Ltd.
- GNU Assembler (GAS) Documentation — Free Software Foundation
