CodeRigor

FREE LESSON

x86-64 registers and data movement

Read and write the instructions that move data: the sixteen registers and their sub-registers, zero- and sign-extension, lea as arithmetic, and every addressing mode the compiler uses for arrays, structs and globals.

Reading compiler output is a superpower: it tells you what your C++ actually costs, why a "harmless" cast added an instruction, and what a crash address means. This first x86 lesson covers the vocabulary every later one uses:

  • the sixteen general-purpose registers and their 8/16/32-bit pieces;
  • mov, and the rule that writing a 32-bit register zeroes the top half;
  • widening: movzx, movsx, movsxd;
  • memory operands: [base + index*scale + disp] and RIP-relative globals;
  • lea, the address calculator that compilers use for arithmetic.

Try it live: every snippet below can be pasted into the Playground with the Compiler explorer open. Hover any mnemonic for its description.

Listings use Intel syntax (mov dst, src), which the explorer shows by default. AT&T syntax (movq %rsi, %rdi, source first, % on registers) is the same instructions written backwards; you'll meet it in GDB and old docs.

For preparation, the optional number-representation course teaches binary and hex, signed encodings, byte order and sign extension.

1. The registers

x86-64 has sixteen 64-bit general-purpose registers. Eight have historical names, eight are numbered:

64-bit 32-bit 16-bit 8-bit System V role (next lesson)
rax eax ax al (ah) return value
rbx ebx bx bl (bh) callee-saved
rcx ecx cx cl (ch) 4th argument
rdx edx dx dl (dh) 3rd argument, 2nd return value
rsi esi si sil 2nd argument
rdi edi di dil 1st argument
rbp ebp bp bpl callee-saved (frame pointer)
rsp esp sp spl stack pointer
r8–r15 r8d… r8w… r8b… 5th/6th args (r8, r9), scratch, callee-saved (r12–r15)

Each name is a view of the same register. eax is the low 32 bits of rax, ax the low 16, al the low 8; ah is bits 8–15 (a legacy oddity that only the first four registers have). rip is the instruction pointer and rflags holds the condition flags; you can't mov to either directly.

One register, five names
eax, ax, ah and al aren't separate registers: they're windows onto bits of rax. Bit 0 is on the right.
One register, five names
eax, ax, ah and al aren't separate registers: they're windows onto bits of rax. Bit 0 is on the right.

The operand size of an instruction is the size of its register operands: add eax, ecx is a 32-bit add, add rax, rcx a 64-bit one. That's how an int and a long addition differ in the machine code.

2. The zero-extension rule

Here is the single most useful fact for reading x86-64 code:

Writing a 32-bit register zeroes bits 32–63 of the full register. Writing an 8- or 16-bit register leaves the other bits alone.

C++
#include <cstdio>

extern "C" unsigned long write32();
extern "C" unsigned long write16();
asm(R"(
    .text
    .intel_syntax noprefix
    .globl write32
write32:
    mov rax, -1          # all 64 bits set
    mov eax, 1           # 32-bit write: upper half cleared
    ret
    .globl write16
write16:
    mov rax, -1
    mov ax, 1            # 16-bit write: upper 48 bits untouched
    ret
    .att_syntax prefix
)");

int main() {
    std::printf("%016lx\n%016lx\n", write32(), write16());
}
A 32-bit write clears the top half; a 16-bit write merges
The same rax = -1 before each write, shown byte by byte (byte 0 on the right). write32 returns 0000000000000001, write16 returns ffffffffffff0001.
A 32-bit write clears the top half; a 16-bit write merges
The same rax = -1 before each write, shown byte by byte (byte 0 on the right). write32 returns 0000000000000001, write16 returns ffffffffffff0001.

Consequences you'll see constantly:

  • mov eax, edi is how the compiler converts unsigned to unsigned long: it costs one instruction and the zeroing is free.
  • xor eax, eax is the idiom for "rax = 0" (shorter than mov rax, 0, and the CPU recognises it as dependency-breaking).
  • mov esi, esi looks like a no-op but isn't: it clears the top of rsi. You'll see it when an unsigned index is used to address memory.
  • Partial writes (mov al, …) merge with the old value, which creates a dependency on whatever was there before. Compilers prefer movzx eax, ….

3. Widening: movzx, movsx, movsxd

Converting a narrow value to a wide one needs to know signedness:

C++ instruction meaning
unsigned char → int movzx eax, BYTE PTR [rdi] zero-extend
signed char → int movsx eax, BYTE PTR [rdi] sign-extend (copy bit 7 up)
short → long movsx rax, si sign-extend 16 → 64
int → long movsxd rax, edi sign-extend 32 → 64
unsigned → unsigned long mov eax, edi zero-extend (the 32-bit rule)

There's no movzx from 32 to 64 bits: the plain 32-bit mov already does it.

C++
extern "C" long widen(int x) { return x; }             // movsxd rax, edi
extern "C" unsigned long uwiden(unsigned x) { return x; } // mov eax, edi
extern "C" int sbyte(const signed char* p) { return *p; }   // movsx eax, BYTE PTR [rdi]
extern "C" unsigned ubyte(const unsigned char* p) { return *p; } // movzx eax, BYTE PTR [rdi]

A wrong choice here is a classic bug in hand-written assembly: mov eax, edi for an int argument turns −1 into 4 294 967 295.

4. Memory operands

Any instruction can read (or write) memory through one operand of the form

One memory operand, four optional parts
The address-generation unit adds them up in one go; the result is the address the instruction reads or writes.
One memory operand, four optional parts
The address-generation unit adds them up in one go; the result is the address the instruction reads or writes.

Every part is optional. The size comes from the other operand, or from an explicit BYTE/WORD/DWORD/QWORD PTR. This covers almost every C++ access:

C++
extern "C" long at(const long* a, long i) { return a[i]; }
//   mov rax, QWORD PTR [rdi+rsi*8]          base = a, index = i, scale = sizeof(long)

struct P { long x, y; };
extern "C" long y_of(const P* p, long i) { return p[i].y; }
//   sal rsi, 4                               scale 16 doesn't exist: shift first
//   mov rax, QWORD PTR 8[rdi+rsi]            + offsetof(P, y)
a[i] is [rdi + rsi*8]
With a in rdi and i = 3 in rsi, the element sits 3 × 8 = 24 bytes past the base. The scale is sizeof(long).
a[i] is [rdi + rsi*8]
With a in rdi and i = 3 in rsi, the element sits 3 × 8 = 24 bytes past the base. The scale is sizeof(long).

GCC writes the displacement before the bracket (8[rdi+rsi]); Clang writes [rdi + rsi + 8]. Same thing.

Scale can only be 1, 2, 4 or 8, which is exactly the sizes of scalar types. For bigger strides (a 16-byte struct) the compiler multiplies the index first.

Signed vs unsigned indices. An int index has to be sign-extended before it can take part in a 64-bit address:

C++
extern "C" int at_int(const int* a, int i) { return a[i]; }
//   movsxd rsi, esi
//   mov eax, DWORD PTR [rdi+rsi*4]
extern "C" int at_unsigned(const int* a, unsigned i) { return a[i]; }
//   mov esi, esi                             zero-extend
//   mov eax, DWORD PTR [rdi+rsi*4]

Using std::size_t (or std::ptrdiff_t) for indices saves that instruction.

Globals are addressed relative to the instruction pointer, so the code works wherever the program is loaded (position-independent code):

C++
int counter;
extern "C" int get() { return counter; }
//   mov eax, DWORD PTR counter[rip]

5. lea: the address calculator

lea dst, [expression] ("load effective address") computes the address expression without touching memory and stores the number. Compilers love it for arithmetic, because one lea can add two registers, scale one by 2/4/8 and add a constant, in one cycle, without changing the flags:

C++
extern "C" long f(long x) { return x * 5 + 3; }   // lea rax, 3[rdi+rdi*4]
extern "C" long g(long x) { return x * 9; }       // lea rax, [rdi+rdi*8]
extern "C" long h(long x) { return x * 7; }       // lea rax, 0[0+rdi*8] ; sub rax, rdi
extern "C" long s(long a, long b) { return a + b; } // lea rax, [rdi+rsi]
lea computes the address and stops; mov goes on to memory
Same bracket, same adder. lea writes the number to the register; mov uses it to load. That's why lea doubles as a fast a + b·k + c.
lea computes the address and stops; mov goes on to memory
Same bracket, same adder. lea writes the number to the register; mov uses it to load. That's why lea doubles as a fast a + b·k + c.

lea is also why return a + b; isn't mov rax, rdi; add rax, rsi: lea writes the sum to a third register in one instruction.

Multiplication by constants that aren't 3, 5 or 9 becomes a short lea/shl/ sub sequence or an imul; the compiler picks whichever is cheapest for the target CPU.

6. Flags, briefly

Arithmetic instructions (add, sub, and, xor, inc, shl…) set the condition flags in rflags: ZF (result was zero), SF (sign bit set), CF (unsigned carry/borrow), OF (signed overflow). mov and lea do not change the flags. The next lesson is all about reading them with cmp, jcc, setcc and cmov.

7. Worked example: reading a function

C++
struct Item { int qty; short code; unsigned char flags; };   // 8 bytes
extern "C" long total(const Item* items, unsigned long i) {
    return items[i].qty * 2L + items[i].flags;
}

The element is 8 bytes, with one byte of tail padding:

qty at offset 0, code at 4, flags at 6, then 1 byte of padding so the next Item starts 4-aligned. sizeof(Item) == 8 — exactly a legal scale.
qty at offset 0, code at 4, flags at 6, then 1 byte of padding so the next Item starts 4-aligned. sizeof(Item) == 8 — exactly a legal scale.

GCC -O2:

Assembly
total:
    lea     rax, [rdi+rsi*8]          ; &items[i]   (sizeof(Item) == 8)
    movsxd  rdx, DWORD PTR [rax]      ; qty: int, widened to long (signed)
    movzx   eax, BYTE PTR 6[rax]      ; flags at offset 6, zero-extended
    lea     rax, [rax+rdx*2]          ; flags + qty*2
    ret

Read it the way you'd read a sentence: compute the element's address, load qty sign-extended, load flags zero-extended, compute flags + 2·qty with lea, return in rax. Every piece comes straight from the types in the source.

8. Faded example

Fill in the gaps mentally before checking in the explorer:

C++
extern "C" unsigned long pick(const unsigned short* a, unsigned long i) {
    return a[i] * 3UL;
}
Assembly
pick:
    movzx   eax, WORD PTR [rdi+rsi*?]   ; scale = sizeof(unsigned short)
    lea     rax, [rax+rax*?]            ; ×3
    ret

(Answers: 2 and 2.)

Key points

  • 16 GPRs; eax/ax/al are views of rax.
  • 32-bit writes zero the upper half; 8/16-bit writes merge.
  • movzx for unsigned, movsx/movsxd for signed widening; mov r32, r32 is the 32→64 zero-extension.
  • Memory operand = base + index×{1,2,4,8} + disp; globals are RIP-relative.
  • lea does arithmetic on addresses (and plain integers) without touching memory or flags.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

The fastest route to reading compiler output: registers, mov, lea and the x86-64 calling convention, explained on real Compiler Explorer listings.

Short visual tour of rax/eax/ax/al and r8–r15 with their sub-register names — exactly the aliasing this lesson drills.

Rigorous lecture opening with an x86-64 assembly overview: registers, instruction format, operand types and addressing modes.

Four minutes on why the compiler turns a + b into lea instead of add — lea as arithmetic in action.

Godbolt narrates real compiler output instruction by instruction; good for building fluency in mov/lea/addressing modes.

lea as address arithmetic and as a general add/shift/multiply instruction in compiler output.