x86-64 Assembly Basics
x86-64 assembly is the raw language your CPU actually speaks — master it and the entire stack from C down to silicon becomes transparent.
Overview
Every program you have ever run — whether written in Python, Rust, Java, or C — eventually becomes a stream of x86-64 instructions that the processor fetches, decodes, and executes one by one. Assembly is that stream made human-readable. Learning it is not about writing entire applications in assembly (almost nobody does). It is about gaining an honest, unfiltered view of what computation actually is at the level of electrons and transistors.
When you write a + b in C, you are asking the compiler to translate your intent into something like addq %rbx, %rax — a 3-byte sequence of bits the CPU understands directly. When a security researcher finds a buffer overflow, they think in terms of stack frames, return addresses, and register contents. When a kernel developer tunes a hot path, they read the disassembly to verify the compiler did what they expected. Assembly is the lingua franca of systems work.
x86-64 (also called AMD64 or Intel 64) is the instruction set architecture (ISA) of virtually every laptop, desktop, and server you will encounter. It evolved from Intel's 8086 (1978) through the 286, 386, Pentium, and Pentium 4 eras, and was extended to 64-bit by AMD in 2003. This history means it carries decades of backward compatibility baggage — but it also means every x86-64 chip can still execute code from 1978 in the right mode. Understanding this heritage explains why some things look odd.
This module builds a solid foundation: the register file, the two dominant syntax flavors, the essential instructions, addressing modes, and the beautiful (and slightly arcane) idea of how instructions are encoded as bytes. You will write a real program, assemble it, and run it. By the end, you will be able to read compiler output, understand what a disassembler shows you, and see clearly what happens "under the hood" every time code runs.
Linux kernel development demands reading and sometimes writing assembly. The kernel's entry points for system calls, interrupt handlers, context-switch routines, and CPU-feature-detection stubs are all written in raw assembly — not because the kernel authors enjoy pain, but because C cannot express the precise, ordered, side-effect-heavy operations those paths require. arch/x86/entry/entry_64.S is one of the most important files in the entire kernel, and it is pure assembly. If you cannot read it, a large fraction of the kernel is a black box.
Beyond the kernel, assembly literacy is the prerequisite for understanding compilers (what does -O2 actually do?), debuggers (what does GDB show in the disassembly pane?), security research (how does a ROP chain work?), performance optimization (why did this loop miss the L1 cache?), and binary analysis (how does a stripped binary behave?). Every one of these fields rewards the person who can look at a hex dump and read a story.
x86-64 Instruction Execution Pipeline Overview
Simplified view of how one x86-64 instruction flows through fetch, decode, address generation, execution, flag update, and write-back before RIP advances to the next instruction.
The concepts
01The CPU Register File — Your Workspace
A register is a tiny, blazingly fast storage location inside the CPU die itself. Unlike RAM, which sits on a separate chip and requires bus transactions to access, registers are part of the processor's execution units. An x86-64 core has 16 general-purpose integer registers, each 64 bits (8 bytes) wide.
The 16 general-purpose registers:
| 64-bit | 32-bit | 16-bit | 8-bit (low) | Notes |
|---|---|---|---|---|
rax | eax | ax | al | Accumulator; syscall return value; function return value |
rbx | ebx | bx | bl | Base; callee-saved |
rcx | ecx | cx | cl | Counter (loop/shift); 4th argument |
rdx | edx | dx | dl | Data; 3rd argument |
rsi | esi | si | sil | Source index; 2nd argument |
rdi | edi | di | dil | Destination index; 1st argument |
rsp | esp | sp | spl | Stack pointer — always points to top of stack |
rbp | ebp | bp | bpl | Base pointer — optional frame pointer |
r8–r15 | r8d–r15d | r8w–r15w | r8b–r15b | Extra registers added in AMD64 |
The naming looks cryptic at first. rax = register accumulator extended (the 'r' prefix and 64-bit width were added in AMD64; 'e' = 32-bit extended from 16-bit; the original 16-bit was just ax). You do not need to memorize the etymology — just know the names.
Sub-register aliasing is a key x86 quirk: writing to eax (the 32-bit view) zero-extends into rax, clearing the upper 32 bits. Writing to ax (the 16-bit view) or al (the 8-bit view) does not zero-extend — it only modifies those bits, leaving the rest intact. This is a historical quirk that the AMD64 architects deliberately chose for performance and backward compatibility. It bites people in subtle ways: if you compute a 32-bit result into eax, the upper 32 bits of rax are zero, so reading rax afterward is safe. But if you write only to al, the upper 56 bits of rax are whatever they were before.
Special-purpose registers:
rsp(stack pointer): Always points to the most recently pushed item on the call stack. The x86-64 stack grows downward — pushing decrementsrsp.rbp(base/frame pointer): Conventionally points to the base of the current stack frame, making it easy for debuggers to walk the call chain. Modern compilers often omit the frame pointer (-fomit-frame-pointer) as an optimization.rip(instruction pointer): Contains the address of the next instruction to execute. You cannot move a value intoripdirectly (that would be a jump), but you can read it vialea rax, [rip].rflags(flags register): A 64-bit register where individual bits encode CPU state after arithmetic: Zero Flag (ZF), Sign Flag (SF), Carry Flag (CF), Overflow Flag (OF), Direction Flag (DF), etc. Conditional jumps test these flags.
# See sub-register aliasing live:
# Compile with: gcc -O0 -S alias_demo.c -o alias_demo.s
# Or just read the concept:
# movl $0xDEADBEEF, %eax -> rax = 0x00000000DEADBEEF (zero-extended!)
# movb $0xFF, %al -> rax = 0x00000000DEADBEFF (only low byte changed)
# movl $1, %eax -> rax = 0x0000000000000001 (zero-extended again)
# Demonstrate with inline asm:
#include <stdint.h>
#include <stdio.h>
int main(void) {
uint64_t val = 0;
__asm__ volatile (
"movq $0xCAFEBABEDEADBEEF, %%rax\n"
"movl $0x12345678, %%eax\n" /* zero-extends: rax = 0x0000000012345678 */
"movq %%rax, %0"
: "=r"(val)
:
: "rax"
);
printf("rax = 0x%016lx\n", val); /* prints 0x0000000012345678 */
return 0;
}
02AT&T vs Intel Syntax — Two Ways to Write the Same Thing
Assembly language is a textual representation of machine instructions. The problem is that there are two mainstream syntaxes for x86 assembly, and they look significantly different. You will encounter both in the wild.
Intel syntax (used by NASM, MASM, Intel's own documentation, and MSVC):
mov rax, 42 ; destination FIRST, source second
add rax, rbx ; rax = rax + rbx
mov rax, [rbp-8] ; load from memory at address rbp-8
AT&T syntax (used by GAS, the GNU Assembler, and therefore gcc -S output on Linux/macOS):
movq $42, %rax ; source FIRST, destination second
addq %rbx, %rax ; rax = rax + rbx
movq -8(%rbp), %rax ; load from memory at address rbp-8
The differences in a nutshell:
| Feature | AT&T | Intel |
|---|---|---|
| Operand order | src, dst | dst, src |
| Register prefix | %rax | rax |
| Immediate prefix | $42 | 42 |
| Memory reference | -8(%rbp) | [rbp-8] |
| Size suffix | movq (quad), movl (long), movw (word), movb (byte) | mov (size inferred from operands) |
The operand order reversal is the most disorienting at first. AT&T reads like "move the value 42 from constant, to rax" — source-to-destination like an arrow flowing left to right. Intel reads like an assignment: rax = 42. Neither is objectively better; they are just conventions from different traditions.
The size suffixes in AT&T (q = 64-bit quad-word, l = 32-bit long, w = 16-bit word, b = 8-bit byte) look redundant when register names already encode the size (%rax is 64-bit, %eax is 32-bit), but they matter when the operand involves memory, where the size is ambiguous.
Which to learn? Learn both. The Linux kernel uses AT&T syntax (GAS). NASM/Intel syntax is more readable for learning and is used in many textbooks and CTF contexts. Compiler Explorer (godbolt.org) can show you either flavor with a toggle. Once you understand both, your brain will switch automatically.
# Same operation, both syntaxes:
# AT&T (GAS) — what gcc -S produces on Linux:
movq $10, %rdi # 1st arg = 10
movq $20, %rsi # 2nd arg = 20
addq %rsi, %rdi # rdi = rdi + rsi = 30
movq %rdi, %rax # return value
ret
# Intel (NASM) — same logic:
mov rdi, 10
mov rsi, 20
add rdi, rsi ; rdi = 30
mov rax, rdi
ret
# To get AT&T from gcc:
# gcc -O1 -S -o output.s myfile.c
# To get Intel syntax from gcc:
# gcc -O1 -S -masm=intel -o output.s myfile.c
# To disassemble a binary in Intel syntax with objdump:
# objdump -d -M intel ./a.out
03The RFLAGS Register and Condition Codes
After most arithmetic and logical instructions execute, the CPU automatically updates individual bits in the RFLAGS register to record properties of the result. These bits are called flags or condition codes. They are the CPU's way of remembering "what just happened" so that a subsequent branch instruction can act on it.
The key flags for beginners:
- ZF (Zero Flag, bit 6): Set to 1 if the result was zero.
cmp a, binternally computesa - band sets flags based on that; ifa == b, the result is zero, so ZF=1. - SF (Sign Flag, bit 7): Set to 1 if the result's most significant bit is 1 (i.e., the result is negative in two's complement).
- CF (Carry Flag, bit 0): Set to 1 if there was an unsigned carry out of (or borrow into) the most significant bit. Used for multi-precision arithmetic and unsigned comparisons.
- OF (Overflow Flag, bit 11): Set to 1 if there was a signed overflow — the result's sign is wrong for the operation performed (e.g., adding two large positive numbers yielded a negative result).
- PF (Parity Flag, bit 2): Set if the low byte of the result has an even number of 1-bits. Rarely used in modern code.
- DF (Direction Flag, bit 10): Controls the direction of string operations (
movs,scas, etc.) — 0 = forward (increment), 1 = backward (decrement). Set withstd, cleared withcld.
The flags are tested by conditional jump instructions like je (jump if equal / ZF=1), jne (ZF=0), jl (less than, signed: SF≠OF), jb (below, unsigned: CF=1), and so on. There are about 16 conditional jump mnemonics, but they all just test combinations of these few flags.
An important distinction: cmp and test are non-destructive flag-setters:
cmp a, bcomputesa - band sets flags, but discards the result. It is purely for comparing.test a, bcomputesa AND band sets flags (SF, ZF, PF), discarding the result. The idiomtest rax, raxis the idiomatic way to check ifraxis zero (if rax AND rax == 0, then rax must be 0), and it is one byte shorter thancmp rax, 0.
# Demonstrating flags with a simple loop (AT&T syntax):
# Counts from 5 down to 0, exits when zero.
.text
.globl _start
_start:
movq $5, %rcx # rcx = 5 (loop counter)
loop:
decq %rcx # rcx -= 1; sets ZF if rcx becomes 0
jnz loop # jump back if ZF == 0 (not zero yet)
# rcx is now 0; we fall through
movq $60, %rax # syscall: exit
xorq %rdi, %rdi # exit code 0 (xor reg,reg is idiomatic zero)
syscall
# The 'test' idiom for NULL check:
movq some_pointer(%rip), %rax
testq %rax, %rax # sets ZF=1 if rax==0
jz handle_null # jump if pointer is NULL
04Core Instructions — mov, add, sub, cmp, jmp and Friends
x86-64 has hundreds of instructions, but a surprisingly small set covers the vast majority of real code. Here are the essential ones with precise semantics:
Data movement:
mov dst, src— Copysrcintodst. Neither operand is modified in-place (unlike some RISC architectures). The most common instruction in any binary — often 25-40% of all instructions.lea dst, [expr]— Load Effective Address. Computes the address of the memory expression and stores it indst, without actually accessing memory. Compilers love this for fast arithmetic:lea rax, [rdi + rdi*4]computesrax = rdi * 5with a single instruction.xchg dst, src— Atomically swap two values. Has an implicitLOCKprefix when one operand is memory.push src— Decrementsrspby 8, then storessrcat the new[rsp].pop dst— Loads the value at[rsp]intodst, then incrementsrspby 8.
Arithmetic:
add dst, src—dst += src. Sets ZF, SF, CF, OF.sub dst, src—dst -= src. Sets flags.inc dst/dec dst— Increment or decrement by 1. Note: these do not modify CF (a quirk that matters in multi-precision arithmetic). Preferadd dst, 1when you need CF.neg dst— Two's complement negate:dst = 0 - dst.imul dst, src— Signed multiply.imul rax, rbxcomputesrax = rax * rbx.idiv src— Signed divide. Divides the 128-bit value inrdx:raxbysrc. Quotient →rax, remainder →rdx. You must sign-extendraxintordx:raxusingcqobefore dividing.
Logical/Bitwise:
and dst, src,or dst, src,xor dst, src,not dst— Bitwise operations.xor rax, raxis the canonical zero-a-register idiom (it is shorter and sometimes faster thanmov rax, 0).shl dst, cl/shr dst, cl— Shift left/right (logical; fills with zeros). Count comes fromclor an immediate.sar dst, cl— Shift arithmetic right (fills with sign bit, preserving sign for signed integers).
Control flow:
jmp target— Unconditional jump. Target can be a label, register, or memory.je/jne/jl/jle/jg/jge/jb/jbe/ja/jae— Conditional jumps (signed and unsigned variants).call target— Push return address (next instruction'srip) onto stack, then jump totarget.ret— Pop return address from stack intorip; control returns to caller.syscall— Transitions to kernel mode to execute a system call. On Linux/x86-64: syscall number inrax, args inrdi,rsi,rdx,r10,r8,r9.
# Practical snippet: compute max(a, b) in AT&T syntax
# Arguments: rdi=a, rsi=b
# Returns: rax = max(a, b)
.text
.globl max_ab
max_ab:
movq %rdi, %rax # rax = a (tentative result)
cmpq %rsi, %rdi # compute a - b, set flags
jge .done # if a >= b (signed), we already have the answer
movq %rsi, %rax # else rax = b
.done:
ret
# Compiler-idiomatic version uses cmov (conditional move):
# cmovl %rsi, %rax — move rsi->rax if less (no branch!)
# This avoids branch misprediction on modern CPUs.
05Addressing Modes — How You Talk to Memory
An addressing mode is the way an instruction specifies a memory location (or an immediate value or register). x86-64 has a rich set of addressing modes — richer than most modern RISC architectures — inherited from its CISC heritage.
The general form of an x86-64 memory reference is:
[base + index * scale + displacement]
where:
- base: any 64-bit register (e.g.,
rbp,rsp,rdi) - index: any 64-bit register except
rsp - scale: 1, 2, 4, or 8 (powers of two; maps to byte/short/int/long-sized array element access)
- displacement: a signed 8-bit or 32-bit constant offset
Any combination of these four components can be present or absent. Examples:
| Mode | Example (Intel) | Description |
|---|---|---|
| Immediate | mov rax, 42 | rax = 42 (no memory access) |
| Register | mov rax, rbx | rax = rbx |
| Direct | mov rax, [0x401000] | Load from absolute address |
| Register indirect | mov rax, [rbx] | Load from address in rbx |
| Register + offset | mov rax, [rbp - 8] | Load from rbp minus 8 |
| Base + index | mov rax, [rbx + rcx] | Load from rbx + rcx |
| Base + scaled index | mov rax, [rbx + rcx*4] | Load 32-bit array element |
| Full SIB + disp | mov rax, [rbx + rcx*8 + 16] | Struct-in-array access |
| RIP-relative | mov rax, [rip + offset] | Position-independent global |
The SIB byte (Scale-Index-Base) is the encoding mechanism that makes the base + index * scale + displacement form possible within a single instruction. We cover this in the encoding section.
RIP-relative addressing deserves special mention. In 64-bit mode, direct absolute addressing is rarely used. Instead, [rip + displacement] lets code reference global variables at a known offset from the instruction, without knowing the absolute address at compile time. This is what makes position-independent code (PIC) and shared libraries work: the binary can be loaded anywhere in memory, and all memory references still work because they are relative to the current instruction pointer.
The lea instruction computes these expressions without accessing memory — it is essentially a free arithmetic unit:
lea rax, [rdi + rdi*2] ; rax = rdi * 3 (no multiply instruction needed!)
lea rax, [rsi + 7] ; rax = rsi + 7 (pointer arithmetic)
/* C code that demonstrates several addressing modes */
#include <stdint.h>
struct Point { int64_t x, y; };
int64_t sum_array_element(int64_t *arr, int64_t i, struct Point *p) {
/* arr[i] -> base + index*scale addressing */
/* p->y -> base + displacement addressing */
return arr[i] + p->y;
}
/* gcc -O1 -S produces approximately:
movq (%rdi,%rsi,8), %rax # arr[i]: rdi=arr, rsi=i, scale=8 (int64_t)
addq 8(%rdx), %rax # p->y: rdx=p, displacement=8 (after x)
ret
*/
06The System V AMD64 ABI — How Functions Talk to Each Other
An Application Binary Interface (ABI) is a contract between separately compiled pieces of code. It answers: "where do I put arguments? where do I find the return value? which registers can I trash, and which must I preserve?"
The System V AMD64 ABI is used by Linux, macOS, and all Unix-like systems on x86-64. (Windows uses a different ABI, which is why Windows DLLs cannot be linked directly with Linux code.)
Integer/pointer argument passing (in order):
rdi— 1st argumentrsi— 2nd argumentrdx— 3rd argumentrcx— 4th argumentr8— 5th argumentr9— 6th argument- Additional arguments go on the stack, pushed right-to-left.
Return values: Integer/pointer return values ≤ 64 bits go in rax. A 128-bit return uses rdx:rax.
Caller-saved (volatile) registers: rax, rcx, rdx, rsi, rdi, r8–r11. The caller must save these before a call if it needs them after; the callee is free to clobber them.
Callee-saved (non-volatile) registers: rbx, rbp, r12–r15, rsp. If a callee wants to use these, it must save them (push onto stack) at the start and restore them (pop from stack) before returning.
Understanding the ABI is what lets you:
- Write assembly functions callable from C (and vice versa)
- Read disassembly of any C program and know what every
callinstruction expects - Understand why a function that uses
rbxalways starts withpush rbxand ends withpop rbx - Understand the stack frame layout that GDB and debuggers display
/* C function and its faithful hand-written assembly equivalent */
/* C: */
long add3(long a, long b, long c) {
return a + b + c;
}
/* Hand-written AT&T assembly equivalent: */
/*
.text
.globl add3
add3:
# Arguments: rdi=a, rsi=b, rdx=c
# We are allowed to clobber rdi, rsi, rdx (caller-saved)
# Return value must go in rax
movq %rdi, %rax # rax = a
addq %rsi, %rax # rax += b
addq %rdx, %rax # rax += c
ret # return rax
*/
/* A function that must preserve rbx: */
long compute(long *arr, long n) {
long sum = 0;
for (long i = 0; i < n; i++) sum += arr[i];
return sum;
}
/* The compiler will use rbx or r12-r15 for loop variables
that survive across inner calls, and will push/pop them. */
07Writing a Complete Hand-Assembled Program
Theory becomes real when you assemble and run actual code. This section walks through a complete, minimal Linux x86-64 program that writes "Hello, world!" to stdout and exits cleanly — with no C library. We use only Linux system calls directly.
On Linux x86-64, the syscall instruction invokes the kernel. The calling convention for Linux syscalls (not the C ABI!) is:
rax= syscall numberrdi,rsi,rdx,r10,r8,r9= arguments 1–6- After
syscall,raxholds the return value (negative = error)
Key syscall numbers:
1=write(fd, buf, count)60=exit(status)
The source below uses AT&T/GAS syntax because that is what the GNU toolchain (the Linux kernel's build system) uses. To assemble:
as hello.s -o hello.o
ld hello.o -o hello
./hello
Or with NASM (Intel syntax version shown in the code comment):
nasm -f elf64 hello.nasm -o hello.o
ld hello.o -o hello
./hello
Every line is explained in the code example. Pay attention to how .text and .data sections separate code from data, and how msg is referenced via %rip-relative addressing for position-independent code.
# hello.s — minimal Linux x86-64 "Hello, world!" (AT&T/GAS syntax)
# Assemble: as hello.s -o hello.o && ld hello.o -o hello && ./hello
.section .data
msg:
.ascii "Hello, world!\n" # the string (no null terminator needed)
msg_len = . - msg # assembler arithmetic: current addr minus msg addr = 14
.section .text
.globl _start # _start is the entry point; linker requires this
_start:
# syscall: write(fd=1, buf=msg, count=msg_len)
movq $1, %rax # syscall number 1 = write
movq $1, %rdi # fd = 1 (stdout)
leaq msg(%rip), %rsi # rsi = address of msg (RIP-relative)
movq $msg_len, %rdx # rdx = length
syscall # enter kernel
# syscall: exit(status=0)
movq $60, %rax # syscall number 60 = exit
xorq %rdi, %rdi # exit status = 0
syscall
# To inspect the result:
# objdump -d ./hello # disassemble
# strace ./hello # trace system calls
# readelf -h ./hello # ELF header info
08Instruction Encoding — How Bytes Become Instructions
Every assembly instruction is ultimately a sequence of bytes. The CPU's decoder reads these bytes and figures out what operation to perform, what operands to use, and how long the instruction is. This encoding is one of the more complex aspects of x86-64 — but understanding the basic structure is enormously valuable: it explains why some instructions are 1 byte and others are 15 bytes, why disassemblers work the way they do, and how exploits like shellcode or instruction-stream confusion attacks work.
An x86-64 instruction is laid out as:
[Prefixes] [REX] [Opcode] [ModRM] [SIB] [Displacement] [Immediate]
Prefixes (0–4 bytes): Optional bytes that modify the instruction. Examples:
0x66— Operand size override (16-bit instead of 32-bit)0x67— Address size override0xF3,0xF2— REP/REPNE prefixes for string instructions0xF0— LOCK prefix (atomic operation)
REX prefix (0 or 1 byte): Introduced in AMD64. The byte has the form 0100WRXB where:
W=1→ 64-bit operand sizeR→ extends the ModRMregfieldX→ extends the SIBindexfieldB→ extends the ModRMr/mfield or the register in the opcode byte WithoutREX.W, most instructions default to 32-bit operation (which zero-extends into 64-bit).
Opcode (1–3 bytes): The core instruction identifier. 0x89 is MOV r/m, r. 0x8B is MOV r, r/m. Single-byte opcodes like 0x90 (NOP) are the simplest.
ModRM byte: When present, encodes the addressing mode and two operand designators:
[ mod (2 bits) | reg (3 bits) | r/m (3 bits) ]
mod=11: both operands are registersmod=00: register indirect (no displacement)mod=01: 8-bit displacement followsmod=10: 32-bit displacement follows- When
r/m=100andmod≠11: a SIB byte follows.
SIB byte (Scale-Index-Base): Encodes [base + index * scale] when needed.
[ scale (2 bits) | index (3 bits) | base (3 bits) ]
Scale encoding: 00=1, 01=2, 10=4, 11=8.
A concrete example: movq %rbx, %rax in AT&T (move rbx into rax):
- REX.W prefix:
0x48(0100 1000: W=1, R=0, X=0, B=0) - Opcode:
0x89(MOV r/m64, r64) - ModRM:
0xC3(11 000 011: mod=11 registers, reg=000=rax, r/m=011=rbx) - Total: 3 bytes:
48 89 C3
You can verify this on Compiler Explorer (godbolt.org) by hovering over any instruction — it shows the encoding.
# Verifying instruction encodings with objdump and Python
# Assemble a tiny snippet:
# echo -e '.globl f\nf: movq %rbx, %rax\n ret' | as -o t.o -
# objdump -d t.o
# Output:
# 0000000000000000 <f>:
# 0: 48 89 d8 mov %rbx,%rax
# 3: c3 ret
# Decode 48 89 d8 manually:
# 0x48 = REX prefix = 0100 1000
# W=1 (64-bit), R=0, X=0, B=0
# 0x89 = opcode: MOV r/m64, r64
# 0xd8 = ModRM = 1101 1000
# mod=11 (reg-to-reg), reg=011 (rbx), r/m=000 (rax)
# Result: move rbx into rax — correct!
# Python snippet to decode a REX prefix:
def decode_rex(byte):
assert 0x40 <= byte <= 0x4F, "Not a REX byte"
return {
'W': (byte >> 3) & 1, # 64-bit operand size
'R': (byte >> 2) & 1, # extend ModRM.reg
'X': (byte >> 1) & 1, # extend SIB.index
'B': (byte >> 0) & 1, # extend ModRM.r/m or opcode reg
}
print(decode_rex(0x48)) # {'W': 1, 'R': 0, 'X': 0, 'B': 0}
09Using Compiler Explorer and objdump — Your Learning Tools
The fastest way to learn assembly is to generate it from C you understand, then read what the compiler produced. Two tools are indispensable:
Compiler Explorer (godbolt.org) is a browser-based tool where you type C (or C++, Rust, Go, ...) in the left pane and see the corresponding assembly in the right pane, in real time. You can:
- Choose any GCC/Clang/MSVC version and see how optimization level affects output
- Toggle between AT&T and Intel syntax
- Hover over assembly lines to see the instruction encoding
- Color-code lines to see which C source line produced which assembly
- Add multiple compilers side-by-side to compare
This is how experts read compiler output: write a tiny, focused C function, look at the asm, understand it, modify the C, and watch the asm change.
objdump is a command-line tool for dissecting compiled binaries:
objdump -d ./binary # disassemble all code sections
objdump -d -M intel ./binary # Intel syntax
objdump -S ./binary # interleave source and asm (requires -g)
objdump -x ./binary # show all section headers
readelf gives you ELF file structure:
readelf -h ./binary # ELF header (architecture, entry point, ...)
readelf -S ./binary # all sections (.text, .data, .rodata, ...)
readelf -l ./binary # program (loadable) segments
strace shows what system calls your program makes:
strace ./hello # trace every syscall with args and return values
Get comfortable with these four tools. You will use them for the rest of your systems career. A workflow: write C, compile with gcc -O1 -g, disassemble with objdump -S, and cross-reference with godbolt.org.
# Practical workflow: see what the compiler does with a loop
# 1. Write a simple function: sum.c
# long sum(long *a, long n) {
# long s = 0;
# for (long i = 0; i < n; i++) s += a[i];
# return s;
# }
# 2. Compile to assembly with different optimization levels:
# gcc -O0 -S sum.c -o sum_O0.s # unoptimized — lots of stack spills
# gcc -O1 -S sum.c -o sum_O1.s # basic opts — clean register use
# gcc -O3 -S sum.c -o sum_O3.s # aggressive — may auto-vectorize with SSE2
# 3. Compare with objdump (after compiling to ELF):
# gcc -O1 -g -o sum sum.c
# objdump -d -M intel sum
# 4. On Compiler Explorer:
# URL: https://godbolt.org/z/... (paste your code, share link)
# Tip: add volatile to prevent the optimizer from eliminating the loop
# 5. See the auto-vectorized version at -O3:
# Look for vmovdqu / vpcmpeqq / vpaddq instructions — SIMD!
# The compiler turned your scalar loop into a vector loop automatically.
Key insights
- ✦Writing to a 32-bit register (e.g., eax) zero-extends into the full 64-bit register (rax) — this is a deliberate AMD64 design choice and differs from writing to the 8-bit or 16-bit sub-registers.
- ✦The RFLAGS register is the CPU's memory of 'what just happened' — virtually every conditional branch is just asking 'what do the flags say?', not doing any new computation.
- ✦AT&T and Intel syntax describe identical machine code — choosing a syntax is just choosing a notation, not a different instruction set.
- ✦RIP-relative addressing is what makes shared libraries and position-independent executables possible; virtually all global variable accesses in modern Linux binaries use it.
- ✦The SIB (Scale-Index-Base) byte is the hardware mechanism that makes array indexing like [rdi + rsi*8] possible in a single instruction — the CPU computes the address in the address-generation unit without a separate multiply.
- ✦The System V AMD64 ABI is a contract: knowing that rdi/rsi/rdx/rcx/r8/r9 carry arguments and rax carries the return value lets you read ANY function's disassembly without source code.
- ✦Instruction length in x86-64 varies from 1 to 15 bytes — this variability is why disassembly must start from a known valid instruction boundary; starting mid-instruction produces nonsense.
- ✦The 'xor rax, rax' idiom is shorter and faster than 'mov rax, 0' because there is a special 2-byte encoding; compilers always emit the xor form.
Common pitfalls
- ⚠Confusing AT&T and Intel operand order: 'movq $1, %rax' (AT&T: src then dst) looks reversed compared to 'mov rax, 1' (Intel: dst then src). Mixing them up in your head leads to wrong mental models of what every instruction does.
- ⚠Forgetting that 'inc' and 'dec' do NOT modify the Carry Flag — this breaks multi-precision arithmetic where you rely on CF for carry propagation. Use 'add dst, 1' instead.
- ⚠Using 'div'/'idiv' without setting up rdx first — the division uses rdx:rax as the dividend; if rdx is not zeroed (for unsigned) or sign-extended via 'cqo' (for signed), you get a divide-by-incorrect-value or a #DE fault.
- ⚠Misreading sub-register write behavior: writing to al or ax does NOT zero the upper bits of rax. Only writing to eax zero-extends. This causes mysterious 'garbage in high bits' bugs.
- ⚠Forgetting stack alignment: the System V ABI requires rsp to be 16-byte aligned before a 'call' instruction. The 'call' itself pushes 8 bytes, making rsp 8-byte aligned inside the function; if you call another function, you must adjust rsp.
- ⚠Treating memory addresses as values: 'mov rax, [rbp-8]' loads the VALUE at that address; 'lea rax, [rbp-8]' loads the ADDRESS itself. Confusing these is one of the most common beginner mistakes.
- ⚠Jumping to the middle of an instruction: because x86-64 instructions are variable-length, manually computing jump targets is treacherous. Always use assembler labels; never hand-compute offsets.
Practice
- 01easy
Write an AT&T-syntax assembly function called 'strlen_asm' that takes a null-terminated string pointer in rdi and returns the length (not counting the null byte) in rax. Do not use any C library functions or string instructions (no 'scas'). Use only 'cmpb', 'jne', 'inc', and a loop label. Assemble it, write a tiny C driver that calls it and prints the result, and verify it matches strlen() from <string.h>.
- 02medium
Use Compiler Explorer (godbolt.org) to compare what GCC produces for a simple bubble sort at -O0 vs -O2 vs -O3. Write a one-page annotated analysis: identify where the inner loop lives, which registers hold the array pointer and loop indices, and explain any vectorization you see at -O3.
- 03medium
Write a position-independent x86-64 assembly program (no C library) that reads an integer from stdin using the 'read' syscall (syscall 0), adds 1 to it, and writes the result back to stdout using the 'write' syscall (syscall 1). Handle the number as ASCII — read one character, subtract '0', increment, add '0', write back. Assemble with 'as', link with 'ld', and test with 'echo 5 | ./increment'.
- 04hard
Manually decode the bytes '48 83 EC 28' using the Intel Software Developer's Manual (Volume 2, instruction reference). Identify the REX prefix, opcode, ModRM byte if present, and the immediate. Confirm by assembling 'subq $40, %rsp' and inspecting the bytes with 'objdump -d' to see if they match.
- 05hard
Write a complete x86-64 assembly program that implements FizzBuzz for numbers 1 to 20, printing 'Fizz', 'Buzz', 'FizzBuzz', or the number (as ASCII) to stdout using only syscalls. No C library. This requires implementing a modulo operation (use 'idiv'), converting integers to ASCII strings, and conditional branching.
Resources
The single most useful tool for learning assembly — type C, see live assembly output from any compiler at any optimization level.
The authoritative reference for every x86-64 instruction — encoding, flags affected, exceptions. Volume 2 is the instruction set reference; Volume 1 is the architecture overview.
Free, thorough, uses YASM (Intel syntax) with Ubuntu — excellent for beginners who want a structured textbook.
The best textbook treatment of x86-64 assembly for systems programmers; Chapter 3 alone is worth the price.
Beej's accessible, witty writing style covers inline assembly and calling conventions in a beginner-friendly way.
A beautifully formatted, searchable HTML version of the Intel SDM instruction reference — faster to browse than the PDF.
The definitive document for argument passing, register conventions, stack layout, and ELF details on Linux x86-64.
One of the best modern books that takes assembly seriously as a foundation for systems programming, not just a curiosity.
A clean, concise hands-on tutorial for NASM (Intel syntax) that gets you writing real programs quickly.
Checkpoints — you can now…
- ✓You can name all 16 general-purpose registers, explain what rsp/rbp/rip/rflags are for, and describe the sub-register aliasing rule for eax vs ax vs al.
- ✓You can read and write simple AT&T and Intel syntax assembly — including translating between them — and explain the operand-order and prefix differences without looking them up.
- ✓You can explain what the ZF, SF, CF, and OF flags are, what instruction sets them, and which conditional jumps test each.
- ✓You can write a complete Linux x86-64 assembly program that uses 'write' and 'exit' syscalls directly, assemble it with 'as', link with 'ld', and run it.
- ✓You can decode the addressing mode in an expression like [rbp + rcx*8 - 16] and identify the base, index, scale, and displacement components.
- ✓You can use objdump and Compiler Explorer to read the assembly output of a C function and identify which registers hold arguments, the return value, and local variables.
- ✓You can explain the first 6 integer argument registers of the System V AMD64 ABI from memory, and identify which registers are caller-saved vs callee-saved.
- ✓You can manually decode the REX prefix byte of a 3-byte instruction and identify the W, R, X, B fields and what each means.