What is the purpose of the index register in Assembly language

What is the purpose of the index register in Assembly language

I remember the exact moment index registers made sense to me: I was trying to loop through an array in Assembly without a for loop to lean on, and I kept writing clunky code that recalculated an address from scratch on every single iteration. Then someone showed me SI/DI on x86 and I realized the whole point of an index register is to make exactly that kind of repetitive, offset-based memory access fast and natural. This post is the explanation I wish someone had given me that day.

What Is an Index Register?

An index register is a general-purpose (or semi-specialized) CPU register specifically used to hold an offset or index value that gets combined with a base address to compute the final effective memory address of an operand. Instead of hardcoding a fixed address for every access, the CPU can add the contents of an index register to a base address, letting the same instruction access different memory locations just by changing the register’s value.

This is the fundamental mechanism that makes array traversal, string processing, and table lookups efficient at the machine level.

The Classic x86 Index Registers

In the original 16-bit x86 architecture, two registers were specifically designated for this role:

In 32-bit mode these became ESI/EDI, and in 64-bit mode, RSI/RDI. While modern x86-64 code often uses these registers as general-purpose registers for anything (including passing function arguments per the System V calling convention), their historical and still-relevant role is indexed memory addressing, especially with string instructions like MOVS, LODS, STOS, CMPS, and SCAS.

Indexed Addressing Mode Explained

The general form of indexed addressing looks like:

effective_address = base + (index * scale) + displacement

Where:

Here’s a concrete NASM example accessing an array of 4-byte integers:

section .data
    numbers dd 10, 20, 30, 40, 50

section .text
    global _start

_start:
    mov rbx, numbers    ; rbx = base address of array
    mov rsi, 2          ; rsi = index (we want element 2, which is 30)
    mov eax, [rbx + rsi*4]   ; eax = numbers[2] = 30

Notice the rsi*4 scaling factor — since each dd element is 4 bytes wide, multiplying the index by 4 gives the correct byte offset into the array. This scaled-index addressing mode is a direct hardware feature of x86, not something the assembler fakes with extra instructions.

Looping Through an Array Using an Index Register

Here’s a complete loop that sums all five elements of that array using RSI purely as an index:

section .data
    numbers dd 10, 20, 30, 40, 50
    count   equ 5

section .text
    global _start

_start:
    xor rax, rax        ; rax = running sum
    xor rsi, rsi        ; rsi = index, starts at 0

sum_loop:
    mov ebx, [numbers + rsi*4]   ; load numbers[rsi]
    add eax, ebx                 ; add it to running sum
    inc rsi                       ; move to next index
    cmp rsi, count
    jl sum_loop

    ; rax now holds the sum of all elements
    mov rax, 60
    xor rdi, rdi
    syscall

This is exactly the kind of pattern the index register was designed for: increment a single register, and let the addressing hardware handle recomputing the target address on each pass, with zero extra arithmetic instructions needed.

String Instructions: Where SI/DI Truly Shine

x86’s dedicated string instructions are built entirely around RSI (source) and RDI (destination), combined with the direction flag (DF) in the FLAGS register to determine whether the index registers auto-increment or auto-decrement after each operation:

; Copy a string from source to destination using MOVSB
mov rsi, source_string
mov rdi, dest_buffer
mov rcx, string_length
cld               ; clear direction flag -> increment mode
rep movsb         ; repeat: copy byte at [rsi] to [rdi], increment both, decrement rcx

REP MOVSB is a single instruction that, combined with the index registers, performs an entire loop’s worth of copying in hardware — the CPU automatically increments both RSI and RDI after each byte and decrements RCX until it hits zero. This is a beautiful example of index registers doing exactly the job they were designed for: tracking position within a sequential memory operation without extra bookkeeping instructions.

Index Registers on ARM

ARM doesn’t have dedicated “SI/DI” registers by name — its uniform register file (X0X30 on AArch64) means any general-purpose register can serve as an index. However, ARM’s addressing modes explicitly support the same base + scaled-index concept through its powerful load/store instruction encodings:

.data
numbers:
    .word 10, 20, 30, 40, 50

.text
.global _start
_start:
    ldr x0, =numbers    // x0 = base address
    mov x1, #2          // x1 = index (element 2)
    ldr w2, [x0, x1, lsl #2]   // w2 = numbers[2], scaled by 4 (lsl #2 = *4)

The lsl #2 (logical shift left by 2, equivalent to multiplying by 4) inside the addressing expression is ARM’s version of x86’s scaled-index addressing — the shift amount corresponds to the element size, exactly mirroring the *4 scale factor in the x86 example above.

ARM also supports pre-indexed and post-indexed addressing, which combine the index calculation with automatic register update:

ldr w2, [x0, #4]!      // pre-indexed: x0 += 4, then load from new x0
ldr w2, [x0], #4       // post-indexed: load from x0, then x0 += 4

Post-indexed addressing in particular is extremely useful for loop constructs, since it lets you load a value and advance your pointer in a single instruction — conceptually very close to what LODSB does on x86 with RSI.

Index Register Usage: x86 vs ARM Comparison

Featurex86/x86-64ARM (AArch64)
Dedicated index registersSI/DI (historical), any GPR in 64-bit modeNone dedicated; any GPR can serve as index
Scaled-index addressing[base + index*scale + disp][base, index, lsl #shift]
Auto-increment/decrementBuilt into string instructions (MOVS, LODS, STOS) via direction flagExplicit pre/post-indexed addressing modes
String-processing instructionsDedicated (MOVSB, CMPSB, SCASB, etc.)Not dedicated; achieved via normal load/store loops

Internal Working: How the CPU Computes the Effective Address

flowchart TD
    A[Instruction Decoded] --> B[Fetch Base Register Value]
    A --> C[Fetch Index Register Value]
    C --> D[Multiply Index by Scale Factor]
    B --> E[Add Base + Scaled Index]
    D --> E
    E --> F[Add Displacement, if any]
    F --> G[Effective Address Computed]
    G --> H[Access Memory: Load or Store]

This entire computation happens inside the CPU’s Address Generation Unit (AGU) in a single cycle on most modern implementations — one of the reasons indexed addressing is so efficient compared to manually computing addresses with separate ADD/SHL instructions.

Practical Use Cases

Performance Considerations

Modern out-of-order x86 CPUs handle complex addressing modes like [base + index*scale + disp] in a single cycle in the vast majority of cases, but there are a few caveats worth knowing:

Debugging Index-Register-Based Code

In GDB, watching an index register update through a loop is one of the most useful debugging techniques for verifying loop correctness:

break sum_loop
run
watch $rsi
continue

This lets you step through each iteration and confirm the index is advancing as expected and that the computed effective address matches what you intend.

Common Mistakes

  1. Forgetting the scale factor — indexing into a 4-byte array using rsi directly instead of rsi*4 accesses completely wrong memory.
  2. Off-by-one index errors, especially when mixing zero-based indexing (standard in Assembly) with one-based mental models from other contexts.
  3. Not clearing the direction flag (DF) before using string instructions, causing RSI/RDI to decrement instead of increment unexpectedly.
  4. Overwriting RSI/RDI in x86-64 calling-convention code without realizing they’re also used to pass the first two function arguments, causing subtle bugs when index registers double as argument registers.

Best Practices

Indexing Two-Dimensional Arrays

Real programs rarely stop at one-dimensional arrays, so it’s worth walking through how index registers extend naturally to 2D arrays. For a row-major array matrix[rows][cols] of 4-byte integers, the address of matrix[i][j] is base + (i * cols + j) * 4. In Assembly, this becomes a small combination of one multiply and one scaled-index computation:

section .data
    cols equ 4
    matrix dd 1,2,3,4,  5,6,7,8,  9,10,11,12

section .text
    global _start
_start:
    mov rax, 2          ; i = 2 (row index)
    mov rbx, 1          ; j = 1 (column index)
    imul rax, cols      ; rax = i * cols
    add rax, rbx        ; rax = i*cols + j
    mov ecx, [matrix + rax*4]   ; ecx = matrix[2][1] = 10

Here rax first accumulates the “flattened” linear index, and only then does it serve as the scaled index register in the final addressing expression — a pattern you’ll see constantly in compiler-generated code for multi-dimensional array access.

Jump Tables: Index Registers Driving Control Flow

One of my favorite uses of index registers isn’t for data access at all — it’s for computed jumps, the Assembly-level equivalent of a switch statement. A jump table is simply an array of code addresses, and an index register selects which one to jump to:

section .data
    jump_table dq case0, case1, case2, case3

section .text
    global _start
_start:
    mov rsi, 2                       ; selector value, e.g. from user input
    jmp [jump_table + rsi*8]         ; jump to case2 directly

case0:
    ; ... handle case 0 ...
    jmp end
case1:
    ; ... handle case 1 ...
    jmp end
case2:
    ; ... handle case 2 ...
    jmp end
case3:
    ; ... handle case 3 ...
end:

This is exactly the mechanism most compilers generate under the hood for a switch statement with densely packed case values — it turns an O(n) chain of comparisons into a single O(1) indexed jump, and it’s a beautiful illustration of just how general-purpose the “index register + scaled addressing” concept really is: it works identically whether you’re indexing into data or into code.

Index Registers and Pointer Arithmetic in Structures

Combining a base register, a scaled index, and a displacement lets you reach into an array of structures in a single instruction. Given a struct Point { int x; int y; } array:

section .data
    ; each Point is 8 bytes: x at offset 0, y at offset 4
    points dd 1,2,  3,4,  5,6

section .text
    global _start
_start:
    mov rsi, 1                    ; index: points[1]
    mov eax, [points + rsi*8 + 4]  ; points[1].y = 4

The displacement (+4) selects the field, the scale (*8) selects the struct size, and the index register (rsi) selects which array element — three independent pieces of addressing logic resolved in one instruction, entirely in hardware.

A Bit of History: Why 8086 Restricted Index Register Usage

It’s worth appreciating why SI and DI became “the” index registers historically rather than an arbitrary naming choice. The original 8086 had a genuinely restrictive addressing model: only four registers could ever participate in memory addressing at all — BX, BP, SI, and DI — and only in specific combinations ([BX+SI], [BX+DI], [BP+SI], [BP+DI], each optionally with a displacement). AX, CX, and DX couldn’t be used for addressing at all in that era; they were purely “accumulator-style” registers for arithmetic. This hardware restriction is precisely why SI and DI carry their historical “index register” identity so strongly — they were quite literally two of only four registers physically capable of that role. When the architecture expanded to 32-bit (with the 80386) and later 64-bit, this restriction was lifted almost entirely, and any general-purpose register can now participate in addressing expressions — but the naming (and the dedicated string-instruction behavior) persisted as a legacy of that original design.

Index Registers and the Calling Convention: A Practical Conflict

Something worth flagging explicitly because it causes real bugs: in the System V AMD64 calling convention used on Linux/macOS x86-64, RSI and RDI do double duty as the second and first integer/pointer function arguments, respectively. This means if you’re deep inside a loop using RSI purely as an array index and then call a function (like a libc routine) that follows this convention, you risk your index being silently clobbered by the callee unless you explicitly save it first:

loop_body:
    mov ebx, [numbers + rsi*4]   ; use rsi as index
    push rsi                       ; save index before the call
    call some_function             ; some_function may freely modify rsi as an argument register
    pop rsi                        ; restore index after the call
    inc rsi
    cmp rsi, count
    jl loop_body

Windows x64 calling convention uses RCX/RDX/R8/R9 for the first four arguments instead, so this particular conflict doesn’t arise on Windows in quite the same way — but it’s a perfect illustration of why understanding both a register’s addressing role and its calling-convention role matters simultaneously when writing real, function-call-heavy Assembly code, rather than just isolated loop snippets.

Scaled Index Limits: What Scale Factors Are Actually Legal

On x86, the scale factor in [base + index*scale] is restricted in hardware to exactly four values: 1, 2, 4, or 8, corresponding to byte, word, doubleword, and quadword element sizes. If your array elements are some other size — a 3-byte or 12-byte structure, for instance — you cannot express that directly in the addressing mode; you must compute the offset manually with a multiply instruction first:

; struct is 12 bytes; scale of 12 isn't a legal addressing-mode multiplier
mov rax, rsi
imul rax, 12          ; manually compute the byte offset
mov ecx, [struct_array + rax]

ARM’s lsl shift-based scaling is even more restrictive in a different way — since it’s a left shift, it can only express power-of-two scale factors (1, 2, 4, 8, 16, and so on), meaning any non-power-of-two element size requires the same kind of manual multiplication shown above.

Frequently Asked Questions

Q: Is an index register different from a base register? Conceptually yes — a base register typically holds the starting address of a structure or array, while the index register holds the variable offset within it. In practice, on modern architectures, any general-purpose register can serve either role; the distinction is about usage, not a hardware restriction (with the historical exception of SI/DI’s dedicated string-instruction role).

Q: Why did older 16-bit x86 restrict which registers could be used as index registers? The original 8086 addressing modes were more restrictive by hardware design — only BX, BP, SI, and DI could participate in memory addressing at all, and only certain combinations were legal. 32-bit and 64-bit modes relaxed these restrictions significantly.

Q: Does using an index register cost extra CPU cycles compared to a fixed address? Generally no, on modern hardware — the Address Generation Unit computes indexed addresses in the same cycle as fixed addresses in the vast majority of cases.

Summary and Key Takeaways

The index register exists to make position-dependent memory access — arrays, strings, tables — efficient without recalculating addresses from scratch on every access. On x86, SI/DI (and their extended ESI/EDI/RSI/RDI forms) carry this role historically and are deeply tied into scaled-index addressing modes and dedicated string instructions. ARM achieves the same effect through flexible, uniform general-purpose registers combined with shift-scaled and pre/post-indexed addressing modes.

Key points to remember:

References

Exit mobile version