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.
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.
#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());
}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, ediis how the compiler convertsunsignedtounsigned long: it costs one instruction and the zeroing is free.xor eax, eaxis the idiom for "rax = 0" (shorter thanmov rax, 0, and the CPU recognises it as dependency-breaking).mov esi, esilooks like a no-op but isn't: it clears the top ofrsi. You'll see it when anunsignedindex is used to address memory.- Partial writes (
mov al, …) merge with the old value, which creates a dependency on whatever was there before. Compilers prefermovzx 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.
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
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:
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 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:
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):
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:
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 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
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.GCC -O2:
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
retRead 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:
extern "C" unsigned long pick(const unsigned short* a, unsigned long i) {
return a[i] * 3UL;
}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/alare views ofrax. - 32-bit writes zero the upper half; 8/16-bit writes merge.
movzxfor unsigned,movsx/movsxdfor signed widening;mov r32, r32is the 32→64 zero-extension.- Memory operand = base + index×{1,2,4,8} + disp; globals are RIP-relative.
leadoes arithmetic on addresses (and plain integers) without touching memory or flags.