Assembly (x86-64, AArch64, RISC-V) Module 4 – Moving data: registers, memory and addresses
Addressing modes for arrays and structs
Walk arrays and structs with base, index, scale and displacement on x86-64, pre- and post-index on AArch64, and base-plus-offset on RISC-V.
What you will learn
- Use base, index, scale and displacement addressing to walk arrays and structs
- Use AArch64 pre- and post-indexed addressing and RISC-V base-plus-offset loads
- Map textbook addressing modes onto the real instructions of three ISAs
Before you start
What you’ll learn · Before you start
What you will learn
- Use base, index, scale and displacement addressing to walk arrays and structs
- Use AArch64 pre- and post-indexed addressing and RISC-V base-plus-offset loads
- Map textbook addressing modes onto the real instructions of three ISAs
Before you start
On this page
An array index or a struct field is an address computed from a few parts: where the array starts, which element you
want, how big each element is, and how far into the element the field sits. An addressing mode: The rule an instruction uses to compute the memory address of an operand, such as a register plus a constant offset, or a base plus an index times a scale plus a displacement. Each ISA offers its own set of modes. is the rule an instruction uses to build that address. x86-64 can fold all four parts into one operand;
AArch64 and RISC-V split the work differently, and the difference is why the same a[i].y compiles to one memory
operand on one ISA and three instructions on another.
This lesson walks an array of struct point { int x; int y; }, summing the y fields, on all three ISAs. The AArch64
version is built and run, and returns 60; the x86-64 and RISC-V versions are disassembled, so you can compare the
operands instruction by instruction. By the end you will read a [rdi + rcx*8 + 4] operand at sight and map the
textbook addressing modes onto the instructions that implement them.
x86-64: base, index, scale and displacement
x86-64’s memory operand is the most capable of the three. One operand can be
[base + index*scale + displacement], where the scale is 1, 2, 4 or 8 and the displacement is a signed constant. That
is exactly the shape of an array-of-structs access: the base is the array, the index times the scale reaches element
i, and the displacement picks the field.
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The base register. rdi holds the address of the array's first element. Every element's address is measured from here.
- The scaled index. rcx is the element number i; times the scale 8 (the struct size) it is the byte offset to point[i].
- The displacement. The constant 4 is the offset of the y field inside a point. It is fixed, so it rides in the instruction.
- The effective address. base + index times scale + displacement is &points[i].y. x86-64 computes it inside one operand; the add reads from there.
The loop below sums a[i].y for 8-byte points. The whole address computation lives in the one operand
[rdi + rcx*8 + 4], so the add reads a[i].y directly. The same arithmetic in lea writes the address to a
register without touching memory, which is why compilers reach for lea as a general “multiply-and-add” instruction.
// x86-64 addressing: [base + index*scale + displacement] reaches point[i].y in one
// operand. lea computes such an address without touching memory. Build with
// clang -c -target x86_64-linux-gnu sum_y.s and link the output on an x86-64 Linux host.
.intel_syntax noprefix
.text
// long sum_y(struct point *a, long n): sum the y field of each 8-byte point.
.globl sum_y
sum_y:
xor eax, eax // sum = 0
xor rcx, rcx // i = 0
.Lloop:
add eax, [rdi + rcx*8 + 4] // sum += a[i].y: base + index*scale + displacement
inc rcx
cmp rcx, rsi
jne .Lloop
ret
// lea computes an address with the same arithmetic but writes it to a register.
.globl addr_of_y
addr_of_y:
lea rax, [rdi + rsi*8 + 4] // rax = &a[rsi].y, no memory touched
ret """Assemble the x86-64 struct-array code and disassemble the scaled-index operand and lea.
The source is an x86-64 Linux file: clang -c -target x86_64-linux-gnu turns it into an ELF object, and
llvm-objdump shows the bytes of each instruction beside it. Link the object on an x86-64 Linux machine to run it.
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run("""
clang -c -target x86_64-linux-gnu x86_64/sum_y.s -o x86_64/sum_y.o
/opt/homebrew/opt/llvm/bin/llvm-objdump -d x86_64/sum_y.o
""") Output
$ clang -c -target x86_64-linux-gnu x86_64/sum_y.s -o x86_64/sum_y.o
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d x86_64/sum_y.o
x86_64/sum_y.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <sum_y>:
0: 31 c0 xorl %eax, %eax
2: 48 31 c9 xorq %rcx, %rcx
5: 03 44 cf 04 addl 0x4(%rdi,%rcx,8), %eax
9: 48 ff c1 incq %rcx
c: 48 39 f1 cmpq %rsi, %rcx
f: 75 f4 jne 0x5 <sum_y+0x5>
11: c3 retq
0000000000000012 <addr_of_y>:
12: 48 8d 44 f7 04 leaq 0x4(%rdi,%rsi,8), %rax
17: c3 retq
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 x86_64_addr_session.py
The disassembly shows addl 0x4(%rdi,%rcx,8), %eax: displacement 0x4, base %rdi, index %rcx, scale 8. The
addr_of_y function shows leaq 0x4(%rdi,%rsi,8), %rax — the identical address arithmetic, but the result goes into a
register instead of being dereferenced.
AArch64: offset, pre-index and post-index
AArch64 has no four-part operand, but it has something x86-64 lacks: addressing modes that update the base register as
a side effect. The forms are [xn, #imm] (base plus a fixed offset), [xn, xm, lsl #3] (base plus a scaled register),
pre-index [xn, #8]! (add 8 to xn, then access) and post-index [xn], #8 (access, then add 8 to xn).
| Form | Meaning | Side effect |
|---|---|---|
[x1, #4] |
address is x1 + 4 |
none |
[x1, x2, lsl #3] |
address is x1 + x2*8 |
none |
[x1, #8]! (pre-index) |
address is x1 + 8 |
x1 = x1 + 8 first |
[x1], #8 (post-index) |
address is x1 |
x1 = x1 + 8 after |
Post-index is the natural fit for walking an array: load the current element and advance the pointer in one
instruction. The AArch64 program sums the y fields with exactly that, ldr w3, [x1], #8, and returns 60.
The two families differ in how far they reach, which decides which one a compiler can use. The plain offset form
encodes a 12-bit unsigned immediate that is scaled by the access width, so a 64-bit ldr x0, [x1, #imm] reaches 0 to
32760 bytes in steps of 8 — far enough for any field of a normal structure. The pre- and post-index forms encode an
unscaled 9-bit signed immediate instead, −256 to 255, because the instruction must also say that the base register is
written back. A stride larger than that cannot be folded into the load, and the pointer has to be advanced by a
separate add.
// An array of struct point { int x; int y; } (8 bytes each). Sum the y fields and
// return the total from _main. y values 10 + 20 + 30 = 60 becomes the exit status.
.section __DATA,__data
.p2align 2
points:
.word 1, 10 // point[0]: x=1, y=10
.word 2, 20 // point[1]: x=2, y=20
.word 3, 30 // point[2]: x=3, y=30
.text
.globl _main
.p2align 2
_main:
adrp x1, points@PAGE
add x1, x1, points@PAGEOFF
add x1, x1, #4 // point x1 at the first y field (offset 4)
mov w0, #0 // running sum
mov x2, #3 // points left
Lloop:
ldr w3, [x1], #8 // load y, then advance x1 by the 8-byte struct (post-index)
add w0, w0, w3
subs x2, x2, #1
b.ne Lloop
ret // exit status = low byte of sum = 60 """Build and run the AArch64 struct-array sum, then read its post-indexed load.
sum_y.s walks an array of 8-byte points with ldr w3, [x1], #8, summing the y fields; the total 60 is the exit status.
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run("""
clang -o aarch64/sum_y aarch64/sum_y.s
./aarch64/sum_y; echo "exit=$?"
/opt/homebrew/opt/llvm/bin/llvm-objdump -d aarch64/sum_y | sed -n '/<_main>:/,/ret/p'
""") Output
$ clang -o aarch64/sum_y aarch64/sum_y.s $ ./aarch64/sum_y; echo "exit=$?" exit=60 $ /opt/homebrew/opt/llvm/bin/llvm-objdump -d aarch64/sum_y | sed -n '/<_main>:/,/ret/p' 0000000100000370 <_main>: 100000370: 90000021 adrp x1, 0x100004000 <points> 100000374: 91000021 add x1, x1, #0x0 100000378: 91001021 add x1, x1, #0x4 10000037c: 52800000 mov w0, #0x0 ; =0 100000380: d2800062 mov x2, #0x3 ; =3 100000384: b8408423 ldr w3, [x1], #0x8 100000388: 0b030000 add w0, w0, w3 10000038c: f1000442 subs x2, x2, #0x1 100000390: 54ffffa1 b.ne 0x100000384 <_main+0x14> 100000394: d65f03c0 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 aarch64_addr_session.py
Checkpoint
Check: x86-64 base, index, scale, displacement · AArch64 pre- and post-index
RISC-V: one mode, and the shNadd shortcut
RISC-V keeps its addressing deliberately small: the only mode is a base register plus a 12-bit signed offset,
offset(rs1). There is no index, no scale, no auto-increment. Everything else is computed with ordinary
instructions, so a[i].y needs a shift to scale the index, an add to reach the element, and then the base-plus-offset
load for the field.
The standard extension Zba (address generation) adds sh1add, sh2add and sh3add, which shift one operand
left by 1, 2 or 3 and add in a single instruction. The disassembly shows the plain loop using slli then add, and
the Zba loop folding both into one sh3add.
Two limits follow from that single mode. The offset is a 12-bit signed field, −2048 to 2047, so a field further than
2 KiB into a structure or an object needs its own add before the load; and the shortcut only exists where the
hardware has Zba, so the assembler has to be told the extension is allowed — the Zba variant here is assembled for
rv64gc_zba, while the same source assembled for plain rv64gc is rejected, as the session’s first command shows.
That is the RISC-V trade-off in one
line: a small, regular instruction set, with the arithmetic that other ISAs hide inside an operand written out where
you can see it.
# RISC-V has one addressing mode: a base register plus a 12-bit signed offset. Scaling an
# index by the struct size is a separate instruction (slli), or a single sh3add with the
# Zba extension. Build with clang -c -target riscv64-linux-gnu -march=rv64gc_zba sum_y.s.
.text
# long sum_y(struct point *a, long n): base plus offset, index scaled with slli.
.globl sum_y
sum_y:
li a2, 0 # sum = 0
li a3, 0 # i = 0
.Lloop:
slli a4, a3, 3 # a4 = i * 8 (no scaled addressing mode, so compute it)
add a4, a0, a4 # a4 = base + i*8
lw a5, 4(a4) # a[i].y: the only mode is base + offset
addw a2, a2, a5
addi a3, a3, 1
bne a3, a1, .Lloop
mv a0, a2
ret
# The Zba extension folds the shift-and-add into one sh3add (a4 = a0 + a3*8).
.globl sum_y_zba
sum_y_zba:
li a2, 0
li a3, 0
.Lz:
sh3add a4, a3, a0 # a4 = base + i*8 in one instruction (Zba)
lw a5, 4(a4)
addw a2, a2, a5
addi a3, a3, 1
bne a3, a1, .Lz
mv a0, a2
ret """Assemble the RISC-V struct-array code twice, then disassemble the Zba variant.
A plain rv64gc assembler rejects sh3add with a Zba-required error; adding the Zba extension
(-march=rv64gc_zba) lets both loops assemble. The disassembly contrasts slli+add with sh3add.
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
session.run("""
# A plain rv64gc assembler refuses sh3add: the extension is not in the base.
/opt/homebrew/opt/llvm/bin/clang -c -target riscv64-linux-gnu -march=rv64gc riscv/sum_y.s -o riscv/sum_y.o
# Adding the Zba extension lets both loops assemble.
/opt/homebrew/opt/llvm/bin/clang -c -target riscv64-linux-gnu -march=rv64gc_zba riscv/sum_y.s -o riscv/sum_y.o
/opt/homebrew/opt/llvm/bin/llvm-objdump -d riscv/sum_y.o
""") Output
# A plain rv64gc assembler refuses sh3add: the extension is not in the base.
$ /opt/homebrew/opt/llvm/bin/clang -c -target riscv64-linux-gnu -march=rv64gc riscv/sum_y.s -o riscv/sum_y.o
riscv/sum_y.s:26:5: error: instruction requires the following: 'Zba' (Address Generation Instructions)
sh3add a4, a3, a0 # a4 = base + i*8 in one instruction (Zba)
^
[exit status 1]
# Adding the Zba extension lets both loops assemble.
$ /opt/homebrew/opt/llvm/bin/clang -c -target riscv64-linux-gnu -march=rv64gc_zba riscv/sum_y.s -o riscv/sum_y.o
$ /opt/homebrew/opt/llvm/bin/llvm-objdump -d riscv/sum_y.o
riscv/sum_y.o: file format elf64-littleriscv
Disassembly of section .text:
0000000000000000 <sum_y>:
0: 4601 li a2, 0x0
2: 4681 li a3, 0x0
4: 00369713 slli a4, a3, 0x3
8: 972a add a4, a4, a0
a: 435c lw a5, 0x4(a4)
c: 9e3d addw a2, a2, a5
e: 0685 addi a3, a3, 0x1
10: feb69ae3 bne a3, a1, 0x4 <sum_y+0x4>
14: 8532 mv a0, a2
16: 8082 ret
0000000000000018 <sum_y_zba>:
18: 4601 li a2, 0x0
1a: 4681 li a3, 0x0
1c: 20a6e733 sh3add a4, a3, a0
20: 435c lw a5, 0x4(a4)
22: 9e3d addw a2, a2, a5
24: 0685 addi a3, a3, 0x1
26: feb69be3 bne a3, a1, 0x1c <sum_y_zba+0x4>
2a: 8532 mv a0, a2
2c: 8082 ret
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 riscv_addr_session.py
Key idea
Textbook addressing modes, mapped to real forms
Textbooks and course syllabi list addressing modes as a vocabulary — immediate, register, direct, register indirect, indexed, base-plus-displacement, PC-relative, auto-increment. The table maps each name onto a real form you have now seen; the one exception, direct, follows it.
| Textbook mode | What it means | Real example |
|---|---|---|
| Immediate | operand is a constant in the instruction | mov x0, #42, addi a0, a0, 4 |
| Register | operand is a register | add x0, x1, x2 |
| Register indirect | address is in a register | ldr x0, [x1], lw a0, 0(a1) |
| Base plus displacement | register plus a constant offset | ldr w3, [x1, #4], lw a5, 4(a4) |
| Indexed (scaled) | base plus index times scale | [rdi + rcx*8], [x1, x2, lsl #3] |
| Base, index, scale, disp | all four at once | [rdi + rcx*8 + 4] (x86-64 only) |
| PC-relative | address measured from the program counter | lea [rip + msg], adrp/add, auipc/addi |
| Auto-increment | base updates as a side effect | [x1], #8 (post-index), [x1, #8]! (pre-index) |
The effective address: The final memory address an instruction computes from the parts of its addressing mode (base, index, scale, displacement or PC-relative offset) just before it reads or writes memory. is whatever the mode finally computes before the memory access. “Direct” (the operand names a fixed absolute address) barely appears in modern 64-bit code, because position-independent executables form every address relative to the PC instead; the previous lesson showed why.
GATE CS 2027
IIT Madras runs GATE 2027. In its Computer Science paper, Section 3 (Computer Organization and Architecture) opens with the topic “Instruction set and addressing modes”. The table above maps those textbook names onto the real x86-64, AArch64 and RISC-V instructions this lesson traced.
Checkpoint
Check: RISC-V base-plus-offset and shNadd · Textbook addressing modes
Practise
Each question gives its reason, right or wrong, from the modes this lesson named: the four-part x86-64 operand, the auto-indexing AArch64 forms, and RISC-V’s single base-plus-offset.
Practice that adapts to you
Practise this lesson
8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is an addressing mode, and why does a[i].y take one x86-64 operand but three RISC-V instructions?
An addressing mode is the rule an instruction uses to compute the memory address of an operand — for example “a
register plus a constant offset”, or “a base plus an index times a scale plus a displacement”. The address it produces
is the effective address. x86-64 offers a rich mode, [base + index*scale + displacement], which maps directly onto
a[i].y: the base is the array, index*scale reaches element i (scale 8 for an 8-byte struct), and the
displacement 4 is the offset of the y field. So one memory operand computes the whole address and the load or add
reads from it. RISC-V deliberately provides only offset(base), so the same access is a shift (slli to scale the
index), an add (to reach the element), and a base-plus-offset load for the field — three instructions, unless the Zba
extension folds the shift-and-add into one sh3add. The trade-off is x86-64’s decoding complexity against RISC-V’s
regularity.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Reading optimised x86-64, you see
leaused where no memory is accessed, sometimes instead of a multiply or a chain of adds. Why do compilers do this, and what are the limits? -
Mid to senior
On RISC-V an array-of-structs loop has extra instructions per iteration compared with x86-64. A teammate asks you to justify or remove them. What do you say?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is an addressing mode, and what is the effective address?
An addressing mode is the rule an instruction uses to compute the memory address of an operand, such as a register plus an offset. The effective address is the address that rule finally produces, just before the access.
-
How does one x86-64 operand reach a[i].y in an array of 8-byte structs?
[base + index*8 + 4]: the base register holds the array, the index scaled by 8 steps to element i, and the displacement 4 is the offset of field y. One memory operand computes the whole address.
-
What does x86-64 lea do with a memory operand?
It computes the effective address and puts it in a register without touching memory, so it is also used for quick arithmetic such as a + b*4 + 8.
-
What is the difference between AArch64 pre-index and post-index addressing?
Pre-index, written with an exclamation mark after the brackets, adds the offset to the base register before the access and keeps the new value. Post-index, with the offset after the brackets, accesses the old address and then updates the base, which makes it the natural way to walk an array.
-
RISC-V has only offset(base) addressing. How does it index an array of 8-byte elements?
It shifts the index left by 3 with slli, adds the base, then loads at offset 0; with the Zba extension one sh3add does the shift and the add together.
-
Which textbook addressing modes map onto real instructions of the three ISAs?
Immediate, register, register indirect, indexed, base plus displacement, PC-relative and auto-increment all appear: auto-increment as AArch64 post-index, PC-relative as rip, adr and auipc forms.
Key takeaways
- x86-64’s
[base + index*scale + displacement]operand computes an array-of-structs address in one step, andlearuns the same arithmetic to produce an address without a memory access. - AArch64 adds pre-index (
[xn, #8]!) and post-index ([xn], #8) forms that update the base register, which makesldr w3, [x1], #8the natural way to walk an array. - RISC-V has a single
offset(base)mode and computes scaling withslliplusadd, or with onesh3addwhen the Zba extension is present. - The textbook addressing modes — immediate, register, indirect, indexed, base-plus-displacement, PC-relative, auto-increment — each map onto real instructions across the three ISAs, and the effective address is what the mode finally computes.
Terms in this lesson
- addressing mode
- The rule an instruction uses to compute the memory address of an operand, such as a register plus a constant offset, or a base plus an index times a scale plus a displacement. Each ISA offers its own set of modes.
- effective address
- The final memory address an instruction computes from the parts of its addressing mode (base, index, scale, displacement or PC-relative offset) just before it reads or writes memory.
References
- Intel 64 and IA-32 Architectures Software Developer Manuals (Intel Corporation)
- Arm A64 Instruction Set Architecture (learn the architecture: memory access) (Arm Limited)
- The RISC-V Instruction Set Manual, Volume I: Unprivileged ISA (Zba extension) (RISC-V International)
- GATE 2027 Computer Science and Information Technology syllabus (Indian Institute of Technology Madras (GATE 2027 organising institute))
Report a problem with this lesson
Kept only in this browser. Your Learn progress