One Register, Four Names

In the last lesson you met rax, but in Chapter 1 you mostly saw eax. And in this chapter you have already glimpsed r8d, dx, and al. These are not different registers. They are different names for parts of the same register, depending on how many bits you want to touch.

This is one of the most confusing things about reading x86-64 assembly for the first time, and it has a tidy explanation. The architecture grew over decades: from a 16-bit chip, to 32-bit, to today's 64-bit. Each generation kept the old register names working and just added a way to address the larger whole. So a single 64-bit register has up to four names, one per size.

The rax Family

Take rax. The same physical register can be referred to as:

Name Bits Bytes Which part
rax 64 8 the whole register
eax 32 4 the low 32 bits
ax 16 2 the low 16 bits
al 8 1 the lowest byte

They are nested, like the rings of a tree. al is the bottom byte of ax, which is the bottom half of eax, which is the bottom half of rax. Write to al and you change only the lowest 8 bits; the rest of rax is untouched.

This nesting is why an int (32 bits) shows up as eax while a pointer (64 bits) shows up as rax, even though they may be the same register. The compiler picks the name whose size matches the C++ type it is operating on.

The Naming Pattern Across All Sixteen

The naming rules differ slightly between the historically-named registers and the numbered ones.

For the named registers, the 32-bit form starts with e and the 64-bit form starts with r:

64-bit 32-bit 16-bit 8-bit (low)
rax eax ax al
rbx ebx bx bl
rcx ecx cx cl
rdx edx dx dl
rsi esi si sil
rdi edi di dil
rbp ebp bp bpl
rsp esp sp spl

For the numbered registers r8r15, the pattern is much more regular. You just bolt a size suffix onto the number:

64-bit 32-bit 16-bit 8-bit
r8 r8d r8w r8b
r9 r9d r9w r9b

The suffixes are d (dword, 32-bit), w (word, 16-bit), and b (byte, 8-bit). That is why the six-argument function from the last lesson stored its 5th and 6th int arguments from r8d and r9d: the arguments are 32-bit ints, so the 32-bit name of r8/r9 is used.

Seeing the Sizes in Action

Here is a function that works entirely with short (16-bit) values, so the compiler reaches for the 16-bit names:

short addShort(short a, short b)
{
    return static_cast<short>(a + b);
}

At -O0:

addShort(short, short):
	push	rbp
	mov	rbp, rsp
	mov	edx, edi
	mov	eax, esi
	mov	WORD PTR -4[rbp], dx
	mov	WORD PTR -8[rbp], ax
	movzx	edx, WORD PTR -4[rbp]
	movzx	eax, WORD PTR -8[rbp]
	add	eax, edx
	pop	rbp
	ret

Look at the differences from the int listings you are used to. The stores use WORD PTR (2 bytes) rather than DWORD PTR (4 bytes), and the values are written from the 16-bit names dx and ax. The size keyword and the register name agree with the C++ type, just as they always do.

Why Writing eax Zeroes the Top of rax

There is one rule about sub-registers that surprises everyone, and it is important enough to learn deliberately:

Writing to a 32-bit register zeroes out the upper 32 bits of the full 64-bit register.

The 8-bit and 16-bit names do not do this: writing al or ax leaves the rest of the register alone, as you would expect from the nesting picture. But the 32-bit names are special. When the architecture went 64-bit, the designers decided that any write to a 32-bit register would clear the high half to zero, because it avoids a class of slow partial-register dependencies in the hardware.

You can watch the compiler exploit this. Converting a 32-bit unsigned int to a 64-bit unsigned long should fill the top 32 bits with zero:

unsigned long widen(unsigned int n)
{
    return n;
}

At -O2:

widen(unsigned int):
	mov	eax, edi
	ret

That is the whole function. The argument arrived in edi (the low 32 bits), and mov eax, edi copies it. Because the destination is the 32-bit eax, the upper 32 bits of rax are automatically set to zero, which is exactly what zero-extending an unsigned value to 64 bits means. No extra instruction needed. The compiler is relying on the zeroing rule.

Compare that with a signed widening, where the top bits must be filled with the sign bit instead of zero:

long widen(int n)
{
    return n;
}
widen(int):
	movsx	rax, edi
	ret

Here the free zeroing is no help, so the compiler emits movsx ("move with sign-extend") to copy edi into the full rax while propagating the sign. The next lesson looks at the mov family in detail, including movzx and movsx; for now just notice that the choice of instruction follows directly from the sub-register rules.

A Reading Habit

When you read a listing, let the register name tell you the size of the data:

  • An r-prefixed name (rax, rdi, r8) means a 64-bit value: usually a pointer or a long.
  • An e-prefixed name (eax, edi) or a d-suffixed name (r8d) means a 32-bit value: usually an int.
  • A bare two-letter name (ax, dx) or w suffix means 16-bit: a short.
  • An l-suffixed byte name (al, dl, r8b) means a single byte: a char or bool.

This single habit removes most of the mystery from a first read. The same register doing different jobs at different sizes is normal; the name is your size label.

Key Takeaways

  • A 64-bit register has up to four names, one per access size: e.g. rax (64), eax (32), ax (16), al (8).
  • These names are nested: the smaller names address the low bits of the same physical register.
  • Named registers use r/e prefixes; numbered registers r8r15 use d/w/b suffixes for 32/16/8 bits.
  • The compiler picks the register name whose size matches the C++ type, alongside size keywords like WORD PTR and DWORD PTR.
  • Writing a 32-bit register zeroes the upper 32 bits of the full register; writing the 8- or 16-bit names does not.
  • The compiler uses that zeroing for free unsigned widening (mov eax, edi) and uses movsx for signed widening.