Machine-Level Programming/The Machine's View

Lesson 2.12,332 words

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.

One line of AT&T assembly. The mnemonic names the operation; the sigils classify each operand: $ for a literal constant, % for a register, and parentheses for the memory at an address. Operands read source then dest.

Read that line as add the constant 8 into the register %rax. The three sigils are the whole alphabet of operands: $ 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 %rax, not the register itself. A bare name with none of these, like a jump target, is a label.

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.

MnemonicWhat it does (in words)
movcopy a value from source to destination
leacompute an address (or a sum) without touching memory
add / subadd / subtract source into destination
imulmultiply destination by source
and / or / xorbitwise AND / OR / XOR into destination
sal / shr / sarshift bits left / right (logical) / right (sign-keeping)
cmpsubtract, set flags, discard the result
testbitwise-AND, set flags, discard the result
jmp / jXXjump always / jump if a condition holds
push / poppush a value onto / pop one off the stack
call / retcall 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 %rip holds 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 whole programmer-visible state of x86-64 on one page: a program counter, sixteen integer registers, four condition-code flags, and a flat byte-addressed memory. Every instruction reads and writes only these.

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.

The sixteen 64-bit integer registers. Two carry dedicated roles — %rsp is the stack pointer, %rbp the optional frame pointer — and the rest are general-purpose under the calling convention.

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.

The four access widths of %rax. The same 64-bit slot is named %rax, %eax, %ax, or %al depending on how many low bytes the instruction touches.

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 sub-register names as nested boxes over one 64-bit slot. %al is the low byte, %ax the low 2, %eax the low 4, %rax all 8; each name selects a prefix of the low bytes, and a write to %eax also clears the upper 4.

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-bit32-bit16-bit8-bitWidth suffix
%rax%eax%ax%alb/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.

Three narrow writes into a register that starts at 0x0011223344556677. Writing %al (movb 0xFF) or %ax (movw 0xFFFF) leaves the upper bytes intact (shown plain); writing %eax (movl) zeroes the whole upper half (shown accented).

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.

The compile pipeline. The compiler proper turns C into assembly text; the assembler encodes it into object-file bytes; the linker resolves references across objects and libraries into a single executable.

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

mstore.cc
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.

mstore.sassembly
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.

mstore.o disassemblyassembly
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

One instruction dissected. The mnemonic plus its size suffix names the operation; operands follow source-then-destination, so this copies the 8-byte value in %rax into the memory location addressed by %rbx.

Read aloud, movq %rax, (%rbx) is move the quad word in %rax into the memory at the address held in %rbx. The parentheses mark a memory operand — 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

  1. 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.
  2. Bryant & O'Hallaron, CS:APP, §3.2 — Program Encodings: gcc -Og -S generates assembly at an optimization level that keeps the output readable against the source.
  3. 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.
  4. 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.
  5. 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 ╌╌