The Machine's View
The instruction set architecture is the contract a compiler writes against: the program counter, sixteen integer registers with their sub-register widths, and the condition codes. We follow one C function down through gcc to assembly, learn to read an instruction as operation plus operands, and fix the vocabulary the rest of the module uses.
╌╌╌╌
C presents typed variables, scopes, and structured control. The processor sees only a stream of instructions, a handful of registers, and a flat array of bytes called memory. The seam between those two views — the precise set of things the hardware promises to do and the state it exposes — is the instruction set architecture, the ISA. This module reads machine-level x86-64, and this first lesson lays out the state the machine makes visible and the pipeline that turns your C into it.
Why read assembly at all
You write in C, and almost never in assembly. So why learn to read the assembly the compiler emits? Four reasons recur through this module, and each involves information the C source does not show.
- To see what the compiler actually produced. Two source programs that look equally fast can compile to very different instructions; the only way to know which loop got vectorized, which call got inlined, or which bounds check survived is to read the output.
- To reason about performance. When a hot loop is slower than it should be, the answer lives in the instructions — an extra memory load, a mispredicted branch, a division the compiler could not strength-reduce. Assembly is where those costs become visible.
- To understand security. Buffer overflows, return-address hijacking, and the defenses against them are all phenomena of the machine level, invisible in the source. The final lesson is unreadable without this one.
- To debug at the metal. When a debugger drops you into a crash with no source — an optimized library, a stripped binary, a corrupted stack — the instructions are all you have.
Assembly is the machine code, written in names instead of bytes. That near one-to-one correspondence is what makes it worth reading: every line names an operation the hardware performs.
How to read one line
First, fix the shape of a single instruction. In the AT&T syntax this module uses (the default in GAS, the GNU assembler), one line is a mnemonic naming the operation, followed by its operands, and operands run source first, destination last — the reverse of Intel syntax. Three sigils tell you what each operand is.
Read that line as add the constant 8 into the register
The three sigils
are the whole alphabet of operands: %rax.$ marks an immediate (a literal number,
like $8), % marks a register (%rax), and parentheses mark a memory
operand — (%rax) means the bytes at the address held in
not the
register itself. A bare name with none of these, like a jump target, is a label.%rax,
A glossary of the core instructions
The entire module uses about fifteen instructions; the lessons introduce each in turn, with its semantics, before using it. The table below anchors the vocabulary and serves as a reference to glance back at.
| Mnemonic | What it does (in words) |
|---|---|
mov | copy a value from source to destination |
lea | compute an address (or a sum) without touching memory |
add / sub | add / subtract source into destination |
imul | multiply destination by source |
and / or / xor | bitwise AND / OR / XOR into destination |
sal / shr / sar | shift bits left / right (logical) / right (sign-keeping) |
cmp | subtract, set flags, discard the result |
test | bitwise-AND, set flags, discard the result |
jmp / jXX | jump always / jump if a condition holds |
push / pop | push a value onto / pop one off the stack |
call / ret | call a procedure / return from one |
Each row is one lesson's worth of detail compressed to a line. With the reading rules and this map in hand, the rest of the lesson can name the state these instructions act on.
The ISA is a contract
The ISA is the boundary at which hardware and software agree on a vocabulary. It fixes the instructions, their byte encodings, the register set, the addressing modes, and the effect each instruction has on machine state. Everything above it, your compiler and your program, is written against that contract; everything below it — the pipeline, the caches, the out-of-order machinery — is unconstrained so long as the contract's behavior is preserved.1
The programmer-visible state of x86-64 is small enough to name on one page: a program counter, sixteen general-purpose integer registers, a set of single-bit condition codes, and memory. Fix those four and you have fixed everything an instruction can touch.
- The program counter
%ripholds the address of the next instruction. Almost every instruction advances it to the following instruction; jumps and calls overwrite it outright. - The integer registers are sixteen 64-bit slots holding values and addresses.
- The condition codes record information about the most recent arithmetic result, and feed the conditional jumps of a later lesson on control flow.
- Memory is the byte-addressable array from the first module, accessed by address.
The register file
x86-64 exposes sixteen general-purpose integer registers, each 64 bits wide. The
names are historical baggage made permanent: %rax through %rsp carry forward
the 8086's accumulator, base, count, and data registers, while %r8 through
%r15 were added with the 64-bit extension and have plainly numbered names.
Two of these registers carry a fixed duty. %rsp is the stack pointer, always
holding the address of the top of the run-time stack, and is touched by push,
pop, call, and ret. %rbp is conventionally the frame pointer, though
modern compilers often free it for general use. The remaining fourteen are
assigned roles by the calling convention rather than the hardware, a point we make
precise in procedures.
Sub-register widths
Each register can be accessed at four widths, and the name changes with the width. The low byte, low 16 bits, and low 32 bits of a register all have their own names, a direct consequence of x86-64's evolution from a 16-bit then 32-bit machine.
Another way to picture it: the smaller names are nested inside the larger one,
each naming a prefix of the low bytes. %al is the lowest byte, %ax the lowest
two, %eax the lowest four, and %rax the whole 64-bit slot. Naming a width
selects how deep into that nesting an instruction reaches.
The four widths line up with the four assembly size suffixes from the foundations module: byte, word, double word, quad word. The table fixes the naming for the legacy registers and the numbered ones.
| 64-bit | 32-bit | 16-bit | 8-bit | Width suffix |
|---|---|---|---|---|
%rax | %eax | %ax | %al | b/w/l/q |
%rbx | %ebx | %bx | %bl | |
%rdi | %edi | %di | %dil | |
%rsi | %esi | %si | %sil | |
%r8 | %r8d | %r8w | %r8b |
One rule with real consequences: an instruction that writes a register's low 4 bytes
(an l-suffixed operation into %eax) zeroes the upper 4 bytes of the full
register. Writing the low 1 or 2 bytes (%al, %ax) leaves the rest unchanged.
This asymmetry is a frequent source of surprise when reading compiled code.
For example, start with
%rax holding 0x0011223344556677, then apply a write at each of the three narrow
widths and compare which upper bytes are preserved.
Read the rows top to bottom. movb touched only the last byte pair, movw the
last two, and both left everything to their left untouched. The movl is the odd
one out: it wrote the low four bytes FFFFFFFF and silently cleared the high
four to zero, so the result is not 0011223344... with a new low half but a clean
0x00000000FFFFFFFF. The reason is worth internalizing now, because it recurs in
every later lesson: 64-bit x86 chose upper-zeroing for 32-bit writes so that
int-to-long promotions need no extra instruction, and so that the processor
need not carry a false dependency on a register's stale upper half. The cost is that
%al/%ax writes, kept for backward compatibility, leave a partial-register hazard
the 32-bit form does not.
From C to instructions
A compiled program is produced in several stages, each transforming one representation into the next. The chain from source to running process is worth knowing as a whole, because every later lesson works at one stage of it.
In practice gcc drives all of these for you, but we will stop at the assembly
stage on purpose. We compile with -Og, which limits optimization
so the assembly still tracks the source, and -S, which stops after
generating assembly so we can read it.2
long mult2(long, long);
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}
Compiling with gcc -Og -S mstore.c produces a .s file. Stripped to the body of
multstore, the assembly is short, and every line is one instruction. Later
lessons define these mnemonics formally; in brief:
pushq saves a register's value on the stack; movq copies eight bytes;
call invokes a function; popq restores a saved value; ret returns to the
caller. That is enough to follow the trace.
multstore:
pushq %rbx # save callee-saved %rbx
movq %rdx, %rbx # stash dest pointer in %rbx
call mult2 # t = mult2(x, y), result in %rax
movq %rax, (%rbx) # *dest = t
popq %rbx # restore %rbx
ret # return to caller
Reading this needs only a little vocabulary, all of which the next lessons make
precise. The first two arguments x and y arrived in %rdi and %rsi, the
pointer dest in %rdx; call invokes mult2, which leaves its result in
%rax; and movq %rax, (%rbx) stores that result through the pointer. The
pushq/popq pair preserves %rbx across the call, a calling-convention
obligation from procedures.
Assembly is bytes, one step down
The claim that assembly is the machine code is not a figure of speech. Assemble
the file (gcc -c mstore.c) and disassemble the object (objdump -d mstore.o),
and each mnemonic reappears next to the exact bytes it encodes.
0000000000000000 <multstore>:
0: 53 push %rbx
1: 48 89 d3 mov %rdx,%rbx
4: e8 00 00 00 00 call 9 <multstore+0x9>
9: 48 89 03 mov %rax,(%rbx)
c: 5b pop %rbx
d: c3 ret
The left column is the byte offset of each instruction inside the function; the
middle column is the raw bytes; the right column is the same mnemonics you already
read. Notice three things a byte view makes plain. First, instructions have
different lengths: push %rbx is one byte (53), while mov %rdx,%rbx needs
three (48 89 d3) — x86-64 is a variable-length encoding, so you cannot find the
next instruction without decoding the current one. Second, the offsets are not
uniform for the same reason: the call at offset 4 occupies five bytes, so the
mov after it lands at offset 9, not 8. Third, the call's four target bytes
are all 00: the linker has not yet resolved where mult2 lives, so those bytes
are a placeholder the linker will patch. Reading assembly is reading these bytes
with the mnemonics restored; the two are the same program at two zoom levels.
The anatomy of an instruction
Every assembly instruction is an operation together with zero or more operands. In the AT&T syntax that GAS and this course use, the operation is a mnemonic, an optional size suffix picks the operand width, and operands are written source first, destination last — the opposite of Intel syntax.3
Read aloud, movq %rax, (%rbx) is move the quad word in
The parentheses mark a memory operand — %rax into the memory at the address held in %rbx.the bytes at this address
— versus a bare register, which means the register's own
contents. That distinction, and the richer addressing forms it opens up, is the
subject of the next lesson on
data movement.
One ISA among several
CS:APP teaches x86-64 because it is the machine most readers actually run, but its sixteen named registers and variable-length encoding are one point in a wide design space, and the contrast sharpens what the ISA is. The sixteen general registers are already generous by the standards of the 8086's eight; the register count is a deliberate trade, since more registers cut memory traffic but cost bits in every instruction that names one and cost saves and restores at every call.
The instructive contrast is RISC-V, the open ISA that grew out of Berkeley's
decades of reduced-instruction-set work and is now ratified by RISC-International.
Where x86-64 has a variable-length encoding (1 to 15 bytes) and instructions that
read one operand from memory, the RISC-V base integer ISA fixes every instruction at
32 bits, exposes 32 general registers, and touches memory only through explicit
load and store instructions — the same load-store discipline ARM's AArch64
follows with its own 31 general registers. The multstore above would compile on
RISC-V to a load, an explicit call, and a store, never a mov with a memory operand,
because no RISC-V arithmetic or move instruction has one. That the same C program can
target both, unchanged, is the whole point of the ISA-as-contract abstraction: the
compiler retargets, the source does not.4
The other lesson from the wider literature is that the visible ISA and the hardware underneath it diverged long ago. Since the Pentium Pro (1995), Intel and AMD processors have decoded each x86 instruction into one or more internal micro-ops and executed those out of order, so the sixteen architectural registers are renamed onto a much larger physical register file the programmer never sees. The ISA is the behavior the hardware guarantees; the microarchitecture underneath may be arbitrarily elaborate, and that separation is the one Hennessy and Patterson build their whole treatment of computer architecture around.5
Footnotes
- Bryant & O'Hallaron, CS:APP, §3.1 — A Historical Perspective: the ISA as the model the compiler targets, distinct from the microarchitecture that implements it. ↩
- Bryant & O'Hallaron, CS:APP, §3.2 — Program Encodings:
gcc -Og -Sgenerates assembly at an optimization level that keeps the output readable against the source. ↩ - Bryant & O'Hallaron, CS:APP, §3.3 — Data Formats and §3.4: the ATT assembly convention, size suffixes
b/w/l/q, and source-before-destination operand order. ↩ - Waterman & Asanović (eds.), The RISC-V Instruction Set Manual, Volume I: Unprivileged ISA (2019) — the base RV64I integer ISA: fixed 32-bit encoding, 32 registers, and a load-store architecture with no memory operands on arithmetic instructions. ↩
- Hennessy & Patterson, Computer Architecture: A Quantitative Approach, 6th ed. (2017), §3 — the separation of the architecturally visible ISA from the out-of-order microarchitecture that renames registers and executes internal micro-ops. ↩
╌╌ END ╌╌