ARM64 vs Intel x86–64 Memory Addressing: When Clean Design Meets Backward Compatibility
More Than Just Memory Access

ARM64 vs Intel x86–64 Memory Addressing: When Clean Design Meets Backward Compatibility
More Than Just Memory Access
Most programmers assume that Intel x86–64 (a.k.a. AMD64), with its variable-length instructions, offers more expressive memory addressing than ARM64 which is known e.g. from Apple Silicon. After all, if an instruction may occupy anywhere between one and fifteen bytes, surely it has room for more sophisticated address calculations than a fixed 32-bit instruction. Edit: I replaced technically 100% correct AMD64 with the colloquial (Intel) x86–64 throughout the text.
Surprisingly, that assumption is only partly true.
Consider these two instructions:
mov rax, [rsp + rbx*8 + 32] // x86–64
ldr x0, [sp, x1, lsl #3] // ARM64
At first glance, they look remarkably similar. Both compute the memory address from a base register, an index register, and a scaling factor before loading a value.
Yet they are the product of two fundamentally different design philosophies.
x86–64 is the result of more than four decades of continuous evolution. Each processor generation added new capabilities while preserving compatibility with software written years — or even decades — earlier. ARM64, by contrast, was designed much later as a new 64-bit architecture, allowing many historical compromises to be left behind.
Memory addressing reveals this difference better than almost any other part of the instruction set.
In the previous article of this series, we explored how ARM64 and x86–64 load constants into registers. Now we take the next step. Once an address is known, how much computation can each architecture perform while accessing memory?
As we will see, the answer is more surprising than it first appears. Despite using fixed-length 32-bit instructions, ARM64 frequently performs remarkably rich address arithmetic directly within its load and store instructions. x86–64, on the other hand, often relies either on a highly flexible memory operand or on dedicated instructions for common pointer-manipulation tasks.
Rather than presenting a catalog of addressing modes, this article gradually builds increasingly complex address expressions — from a simple base register to scaled indices, automatic pointer updates, and pair loads — to show what memory addressing reveals about the design philosophy of each instruction set.
It Looks Direct. It Isn’t.
In the previous article, we compared how ARM64 and x86–64 place constants into registers. At first glance, that topic may seem unrelated to memory addressing. In reality, it is the first step of the same journey.
Before a processor can read or write memory, it must first determine where that memory is located, i.e. perform memory addressing.
Sometimes the address is available in a register such as x0 on ARM64. It can be for example, a pointer returned by malloc(), the address of a local stack object, or a function argument.
Global variables are different. Their final address depends on where the executable or shared library is placed in memory. Both x86–64 and ARM64 architectures take a similar approach. They access global data relative to the current position of the program, i.e. relative to RIP on x86–64 or PC on ARM64.
On x86–64, the assembler often allows code simply mov rax, [global] which looks like an absolute address but in fact is a RIP-relative memory access:
mov rax, [rip + disp32] // program counter + offset
The displacement is stored directly in the instruction encoding. In other words, the effective address is computed from the program counter and a signed displacement stored in the instruction. Its binary encoding is:
48 8B 05 xx xx xx xx // 4 bytes offset
ARM64 offers a very similar mechanism — the LDR (literal) instruction behaves as if it executed:
ldr x0, [pc,#imm19]
Internally, the processor computes the target address as address = PC + SignExtend(imm19 << 2). The immediate field occupies only 19 bits, but before being added to the program counter it is implicitly shifted left by two bits. Since every ARM64 instruction is 32 bits (4 bytes) long and instruction addresses are therefore always 4-byte aligned, the two least significant address bits are known to be zero and need not be encoded. This yields an effective addressing range of approximately ±1 MiB around the current instruction.
Finally, this brings us back to the section title. What looks like direct addressing in the source code is usually not direct at all — the machine normally reaches that location by adding an offset to the current instruction address!
- On x86–64, this happens directly inside the memory operand:
RIP + disp32. - On ARM64, the same idea appears as a PC-relative literal load:
PC + imm19 << 2.
In both cases, the apparent direct memory reference is actually implemented as a program-counter-relative address calculation.
That is why global memory access is a useful bridge from constants to addressing. The programmer writes a named location, the assembler and linker turn it into a relative displacement, and the processor finally computes the real address at run time.
This is only one special case: the base address is derived from the current instruction address itself.
Most memory accesses are different.
They start from an address already available in a register, and the interesting question becomes what kind of offset or index arithmetic the load or store instruction can add to that base address.
That is where the real comparison between ARM64 and x86–64 begins.
The Address Is Already There
The simplest possible memory access uses nothing more than an address already stored in a register.
Both x86–64 and ARM64 support this fundamental addressing mode.
mov rax, [rbx] // x86–64
ldr x0, [x1] // ARM64
Semantically, these instructions perform exactly the same operation: value = Memory[address] where address is in the given register, i.e. rbx or x1 in the previous code.
At this level, there is virtually no difference between the two architectures. Both assume that the register already contains a valid address, and both simply dereference it.
Just Add an Offset
Real programs often need a field inside a structure or an element located at a fixed offset. Both architectures therefore allow a constant offset to be added to the base register during the memory access itself.
mov rax, [rbx + 32] // x86–64
ldr x0, [x1, #32] // ARM64
Again, the semantics are identical: address = base register + offset.
The effective address is calculated as part of the load instruction in both architectures. No separate arithmetic instruction is required.
For example, consider a simple sensor record in C:
typedef struct {
float temperature; // offset 0
float pressure; // offset 4
double altitude; // offset 8
} SensorData;
SensorData *p = ...;
float pressure = p->pressure;
double altitude = p->altitude;
The compiler knows the layout of the structure at compile time:
0 +-----------------------+
| temperature (float) |
4 +-----------------------+
| pressure (float) | <--- offset 4
8 +-----------------------+
| altitude (double) | <--- offset 8
16 +-----------------------+
Accessing pressure uses offset 4; accessing altitude uses offset 8:
movss xmm0, [rbx + 4] // AMD64
movsd xmm1, [rbx + 8]
ldr s0, [x1, #4] // ARM64
ldr d1, [x1, #8]
In both architectures, the displacement is encoded directly in the instruction. The processor computes base register + offset as part of the memory access, eliminating the need for a separate arithmetic instruction.
Now Add an Index
A constant offset is sufficient only when the required displacement is known at compile time. Arrays and buffers are different: the offset depends on a position in the array: the index. The simplest solution is to use another register directly as the index:
mov al, [rbx + rcx] // x86-
ldrb w0, [x1, x2] // ARM64
Both instructions compute address = base register + index register before accessing memory. For example:
char text[] = "Hello";
char c = text[i];
The compiler places:
- the address of 'text' array into the base register and
- the variable 'i' into the index register.
No additional arithmetic is necessary.
At this point, x86–64 and ARM64 remain almost identical.
One More Trick: Scale the Index
In real programs, most arrays do not consist of bytes. Elements often occupy four bytes (e.g., int32 or float32) or eight bytes (long, double), meaning the array index must first be multiplied by the array’s element size. We call the multiplication as scaling.
Consider an array of double-precision floating-point values:
double samples[1024];
double value = samples[index];
Each element occupies eight bytes, so the effective address is address = base address of ‘samples’ array + index × 8.
movsd xmm0, [rbx + rcx*8] // x86-64
ldr d0, [x1, x2, lsl #3] // ARM64
Although the syntax differs, the underlying idea is the same x86–64 provides a hardware scaling factor of 1, 2, 4, or 8, while ARM64 expresses the same multiplication as a left shift. Shifting by 3 bits multiplies the index by 8.
For arrays, both approaches are equally expressive. Neither architecture requires a separate multiplication instruction just to compute the address. So far, the two instruction sets remain remarkably similar.
When 32 Bits Meet a 64-Bit Address
So far, the address calculation has been straightforward. The index register was the same size (64 bit) as the base register. Real address required just adding those two — with possible scaling.
Real programs are often less convenient.
Array indices are frequently stored as 32-bit integers because arrays with 32-bit index can occupy a pretty large portion of memory anyway. Before such a value can participate in a 64-bit address calculation, it must first be converted to the appropriate width.
On x86–64, the compiler may emit a separate instruction to extend the index bit size such as MOVSXD before the load as it extends the value into a larger register:
mov ecx, -1 ; ECX = 0xFFFFFFFF
movsxd rcx, ecx
On the contrary, ARM64 allows the transformation of the index register to become part of the memory access itself. Consider an array of sensor records:
SensorData sensors[1024];
SensorData sample = sensors[i];
The variable i is naturally represented as a 32-bit integer (unsigned int), while addresses are 64 bits wide. ARM64 can extend the index register and scale it within the load instruction:
ldr x0, [x1, w2, uxtw #4]
Semantically, this performs address = base register + ZeroExtend(index32 register) × 16 in a single instruction.
Likewise, signed indices are handled using sign extension:
ldr x0, [x1, w2, sxtw #4] // address = x1 + SignExtend(w32) × 16
On x86–64, no equivalent addressing mode exists.
The index register must already contain the correct 64-bit value before it can participate in address calculation. If necessary, the compiler first emits an instruction such as MOVSXD mentioned earlier.
This is the first point where ARM64 goes beyond the addressing capabilities of x86–64 not by introducing a new instruction, but by enriching an existing one.
So far, both architectures have followed a similar path. The address expression has gradually evolved:
address = direct(actuallyPC + direct)address = base registeraddress = base register + offsetaddress = base register + index register × scaleaddress = base register + extension(index register)
The next step is even more interesting. Instead of merely computing an address, ARM64 can also modify the pointer itself as part of the same memory-access instruction.
Reading Memory While Moving the Pointer
So far, every example left the base register unchanged.
In many algorithms, however, the pointer itself moves after every memory access. Typical examples include traversing an array, copying memory, or even pushing values onto the stack.
Without special hardware support, such code consists of two separate operations:
- access memory
- update the pointer
ARM64 can combine both into a single instruction.
while (*dst++ = *src++); // copy & update pointers
A straightforward ARM64 loop (ommiting the while-condition for brevity) can use post-incremented index addressing for both the source and destination pointers:
ldrb w2, [x0], #1 // ARM64
strb w2, [x1], #1 // also copy & update pointers
The first instruction loads one byte from the address in x0 and then increments x0 by one. The second instruction stores the byte to the address in x1 and then increments x1 by one.
ldrb w2, [x0], #1
// means approximately:
w2 = Memory[x0]
x0 = x0 + 1
Post-indexing updates the pointer after the memory access.
Pre-indexing performs the update before the access. This is especially useful for stack-like operations:
str x0, [sp, #-16]! // ARM64
Semantically, this means:
sp = sp - 16
Memory[sp] = x0
If this looks familiar, it should. x86–64 provides the same high-level operation through a dedicated instruction:
push rax // x86-64
which corresponds to:
rsp = rsp - 8
Memory[rsp] = rax
The opposite operation is equally symmetrical:
pop rax // x86–64
ldr x0, [sp], #16 // ARM64
The x86–64 POP instruction loads a value from the stack and then increments RSP. ARM64 performs the same idea using an ordinary load instruction with post-index addressing.
rax = Memory[rsp] // x86–64 POP
rsp = rsp + 8
x0 = Memory[sp] // ARM64 post-index load
sp = sp + 16
The difference is not the operation itself, but where the ISA places the complexity.
Design takeaway
x86–64 introduces dedicated instructions such as
PUSHandPOPbecause stack manipulation is extremely common.
ARM64 solves the same problem differently. Instead of adding a special stack instruction, it extends ordinary
LDRandSTRinstructions with reusable pre-index and post-index addressing modes.
The same mechanism is not limited to the stack. It can be used for traversing arrays, copying bytes, walking through buffers, or implementing compact pointer-based loops.
This is one of the clearest examples of the broader design contrast between the two architectures:
x86–64: common pattern -> dedicated instruction
ARM64: common pattern -> reusable addressing mode
Two Philosophies of ISA Design
Memory addressing is more than just a way to access data. It reveals how an instruction set evolves.
x86–64 often solves common programming tasks by introducing dedicated instructions. Memory operands are highly expressive, and some frequently used operations receive their own specialized instructions. In this article, PUSH and POPare the clearest examples: each combines a memory access with an automatic stack-pointer update.
ARM64 follows a different philosophy. Rather than introducing many specialized instructions, it enriches a much smaller set of general-purpose load and store instructions with reusable addressing modes. Register extension, scaling, pre-indexing, and post-indexing all follow the same idea: make the addressing mode more capable instead of creating another instruction.

Neither approach is inherently superior. x86–64 demonstrates how an architecture can evolve for decades while preserving compatibility. ARM64 demonstrates the benefits of designing a modern ISA around regularity and orthogonality.
Memory addressing is one of the clearest places where these two design philosophies become visible. x86–64 tends to encode common programming idioms as dedicated instructions, whereas ARM64 more often encodes them as reusable addressing modes.
Further Reading
Previous Stories in this Series:
- How ARM64 Instructions Are Really Encoded
- ARM64 vs x86–64: Why Loading Constants Is Surprisingly Different
This article is adapted from the FREE upcoming book:
The complete open-access edition, including instruction encoding, Apple Silicon ABI conventions, Mach-O internals, optimization techniques, SIMD, security features, and practical macOS ARM64 programming, is available on Zenodo:
https://doi.org/10.5281/zenodo.20802832
Project repository and companion code:
메타데이터
- post_id
- a31ae750864b
- slug
- arm64-vs-amd64-memory-addressing-when-clean-design-meets-backward-compatibility-a31ae750864b
- url
- https://blog.stackademic.com/arm64-vs-amd64-memory-addressing-when-clean-design-meets-backward-compatibility-a31ae750864b
- canonical_url
- https://blog.stackademic.com/arm64-vs-amd64-memory-addressing-when-clean-design-meets-backward-compatibility-a31ae750864b
- author_url
- https://medium.com/@tomas.pitner
- status
- ok
- fetched_at
- 2026-07-15 20:09:32