Capstone/Assembling a Complete CPU

Lesson 11.23,152 words

Assembling a Complete CPU

We bolt the parts the course built — PC, instruction memory and its fetch logic, register file, ALU, condition codes, data memory, and the control unit — into one complete CPU, name the lesson that built each, wire them in a deliberate order, and power the machine on from reset. Then we assemble a real test program (sum a four-element array through a call/ret procedure), give its exact bytes and memory layout, and trace it cycle by cycle to the answer 0xabcdabcdabcd.

╌╌╌╌

This is the lesson the whole course was building toward. We have every part: the ALU that adds and subtracts and sets flags, the register file with its two read ports and two clocked write ports (dstE, dstM), the addressed memory, the Y86-64 ISA that fixes the bytes, and the control logic that drives the muxes. We even traced a program through SEQ. What is left is the assembly itself, done the way an engineer would actually do it: draw the complete machine with every named part in its place, say which lesson built each, wire the parts in a deliberate order, define the reset state and watch the first fetch happen, then run a real compiled program (a procedure call, a loop, an array in memory) from power-on to halt. When the answer lands, we ask the two questions any real project ends with: how do you know it works, and what would it take to build two?

The complete CPU, part by part

A CPU is seven kinds of part, and the course built each one. Fetch needs a place to keep the address of the next instruction and a place to read instruction bytes from: the program counter (PC) and the instruction memory, whose current output — the fetched instruction word — the Split and Align logic of module 4's fetch stage breaks into fields combinationally (no register latches it; the machine's clocked state elements remain the four SEQ counted: PC, CC, register file, data memory). Decode and write-back need the register file. Execute needs the ALU and, beside it, the condition-code register (CC) that latches ZF, SF, and OF whenever an OPq instruction runs. Memory needs the data memory. And tying them together, deciding from the decoded opcode which mux selects what and which register gets written, is the control unit: the hardwired control logic of a Y86-64 machine (in a microprogrammed design the same job is done by a microsequencer stepping through microinstructions, but the role is identical).

The complete Y86-64 CPU with every named part placed and wired. The PC addresses instruction memory; the fetched instruction word splits combinationally into fields; the register file feeds the ALU, which sets the CC register; the ALU result addresses data memory; results write back down the inner right margin, and newPC loops down the outer one. The control unit (left) reads icode:ifun and drives a signal to every unit.

The discipline is the one SEQ taught: functional units stacked along a central spine, data handed straight up, and the two feedback wires — write-back to the register file, and newPC to the PC — routed in separate right-hand margins where they cross nothing. The control unit stands to one side, reads icode:ifun out of the fetched instruction word, and sends a signal to every unit. One wire is drawn small but matters enormously: the CC register's three flags flow back into the control unit (through the Cnd condition logic), because a jne cannot decide newPC without them. That single feedback is the machine's only means of decision; everything else is data routing.

This machine uses ideal one-cycle memories, as SEQ always did. On the full die, the previous lesson's block diagram wraps these two memory boxes in TLBs, caches, and DRAM; nothing in this lesson changes when it does, which is the point of the memory-hierarchy abstraction.

Here is the provenance, part by part: which lesson built each.

Which lesson built which part. Every functional unit of the complete CPU was constructed earlier in the course; this legend names the source. The capstone adds nothing new — it only wires the existing parts together.

Wiring order and bring-up

Assembly has an order, and the order is not arbitrary. First the data spine, bottom to top: PC into instruction memory, the fetched fields into the register file's read addresses, read ports into the ALU, ALU output into the data memory's address port. This much is purely feed-forward: signals flow one way, and each connection can be checked in isolation by driving the input and probing the output. Second, the two feedback paths: write-back to the register file and newPC to the PC. These close loops, so they come after the spine works; a loop wired around a broken spine oscillates or latches garbage, and you cannot tell which part failed. Third, the CC register beside the ALU and its Cnd wire to control. Last, the control fan: the decoder in the control unit that reads icode:ifun and drives every mux select and write-enable, one stub per unit. Control comes last because it is meaningless until the things it controls exist: every one of its output wires names a mux or a port that has to be there already. This is the same inside-out order the SEQ assembly lesson followed; the capstone only makes it explicit as a checklist.

With the machine wired, it has to start. A CPU has no operator; the only thing that distinguishes cycle 1 from cycle 1,000,000 is the state the machine wakes up in, so that state must be defined by hardware, not by luck.

Every choice here is a convention, and each has a reason. PC = 0x000 means the machine's first act is to fetch M[0x000], so whoever loads memory knows exactly where to put the entry point. Registers start at zero so no computation depends on power-on noise. ZF = 1 matches what the flags would show after computing zero, which is the honest description of a machine that has computed nothing. And Stat = AOK arms the machine to run. That is the entire boot process for this CPU: reset forces a known state, memory holds bytes at address 0x000, and the clock does the rest. Real x86-64 machines are no different in kind (the PC resets to a fixed address that points into firmware ROM instead of a loaded program), and everything a modern boot does is layered on this one mechanism.

Bring-up. While reset is held, every clock edge reloads the defined reset state: PC = 0x000, registers zero, ZF = 1. After release, cycle 1 fetches M[0x000] (the bytes 30 f4 ..), computes valE = 0x200, and the next rising edge clocks the first real state change: rsp = 0x200 and PC = 0x00a. From here the machine is simply running.

The first fetch is not special. Cycle 1 runs the same fetch–decode–execute sequence as every later cycle; the machine cannot tell boot from steady state. Everything rests on the two facts the hardware guarantees: a defined PC and defined bytes at that address.

The test program: an array sum with call and ret

The countdown loops we traced in module 4 exercised arithmetic and a branch, but a real program does more: it keeps data in memory, walks a pointer across it, and calls procedures. So the capstone's test program is chosen to touch every instruction class the ISA defines: immediate moves (irmovq), a memory read (mrmovq), register arithmetic that sets flags (addq, subq, xorq), a conditional branch (jne), the stack pair (call/ret), and halt. It sums a four-element array through a procedure, the same shape as CS:APP's asum: main sets up the stack and the arguments, then calls sum(array, 4).

asum.ysassembly
# main: set up the stack, then sum(array, 4)
irmovq stack, %rsp    # rsp = 0x200      (stack pointer)
irmovq array, %rdi    # arg 1: base of array
irmovq $4, %rsi       # arg 2: element count
call sum              # push return addr, jump to sum
halt

# long sum(long *start, long count)
sum:
irmovq $8, %r8        # r8 = 8   (element stride)
irmovq $1, %r9        # r9 = 1   (decrement constant)
xorq %rax, %rax       # sum = 0  (and sets ZF = 1)
loop:
mrmovq (%rdi), %r10   # r10 = *start
addq %r10, %rax       # sum += *start
addq %r8, %rdi        # start++
subq %r9, %rsi        # count--
jne loop              # repeat while count != 0
ret                   # pop return addr into PC

.align 8
array:
.quad 0x000d000d000d
.quad 0x00c000c000c0
.quad 0x0b000b000b00
.quad 0xa000a000a000

.pos 0x200
stack:                # stack grows down from 0x200

The array values are chosen so the answer proves itself. Each quad fills a different nibble of every 16-bit group, so the running sum assembles the digits a, b, c, d in place, and the correct total is unmistakable: . A single transposed byte anywhere in the machine and the pattern breaks visibly.

The assembler lays the code out from address 0x000. Each irmovq is 10 bytes, each OPq is 2, call and jne are 9 (opcode byte plus 8-byte destination), ret and halt are 1. That fixes every address, and fixing the addresses resolves every label: sum lands at 0x028, loop at 0x03e, array at 0x058 (already 8-aligned), and stack is pinned to 0x200 by the .pos directive. The bytes follow the ISA encodings: register nibbles %rax=0, %rsp=4, %rsi=6, %rdi=7, %r8=8, %r9=9, %r10=a, and every constant little-endian.

The assembled bytes of asum.ys from address 0x000. Each irmovq is 30, then f:rB, then the 8-byte little-endian value; call is 80 plus the destination 0x028; the OPq instructions are two bytes; jne is 74 plus the destination 0x03e; ret is 90 and halt is 00. The label addresses (sum = 0x028, loop = 0x03e) appear literally inside the call and jne.

The program is only part of the memory image. The array sits right after the code, and the stack, empty at reset, hangs below 0x200, growing toward lower addresses when call pushes.

The memory image at reset. Code occupies 0x000-0x057, the four array quads follow at 0x058-0x077, and the stack region is empty with rsp initialized to 0x200. The one cell the program will write is 0x1f8: call pushes the return address 0x027 there, and ret pops it back.

These bytes are the whole input. The machine starts with PC = 0x000, the image above sitting in memory, and from there it runs itself.

Tracing it, cycle by cycle

The assembled CPU executes one instruction per cycle: in a single tick it fetches at the PC, decodes the fields, reads registers, runs the ALU, touches memory if needed, writes back, and computes newPC; the new register, condition-code, memory, and PC values clock in at the rising edge. The whole run is 29 cycles: three setup moves, the call, three cycles of sum prologue, four loop iterations of five cycles each, ret, and halt. Here are the cycles where something new happens.

Key cycles of the 29-cycle run. Cycles 1-4 set up the stack and arguments and execute the call, whose memory write is the pushed return address; cycles 5-7 are the sum prologue (xorq zeroing rax sets ZF = 1); cycles 8-12 are the first loop iteration in full, including the mrmovq that reads the first array element; cycles 27-29 exit the loop, pop the return address, and halt with the answer in rax.

Three cycles deserve a closer look, because each one runs a mechanism the countdown trace never touched.

Cycle 4, the call. This is the only instruction in the program that both writes memory and takes a non-sequential newPC. The ALU computes valE = R[%rsp] - 8 = 0x1f8; the Memory stage writes valP = 0x027 (the address of the instruction after the call, the halt) into M[0x1f8]; write-back puts valE into %rsp; and newPC = valC = 0x028. One cycle, three effects — and the return address is now data, sitting in memory like any other quad, exactly as the procedures lesson promised.

Cycle 7, the xorq. Zeroing a register by xoring it with itself is the idiom from the machine-level module, and it has a side effect the trace makes visible: the result is zero, so ZF becomes 1. If the loop guard were checked here, it would fall through. The program is correct only because subq reruns the flags every iteration before jne reads them; flag liveness is part of the program's logic.

Cycle 8, the mrmovq. The first true memory read: valE = R[%rdi] + 0 = 0x058, and the Memory stage returns valM = M[0x058] = 0x000d000d000d, the little-endian bytes 0d 00 0d 00 0d 00 00 00 reassembled into a quad. On the full machine this is the access that would traverse the d-TLB and cache; here the ideal memory answers in-cycle.

From there the loop turns four times, and the sum builds its answer nibble by nibble. Each element contributes a different hex digit to every 16-bit group, so you can watch correctness accumulate:

The running sum in rax after xorq and after each of the four loop iterations. Each array element fills a different nibble of every 16-bit group, so the digits a, b, c, d assemble in place and the final value 0xabcdabcdabcd is visibly correct - any wrong byte anywhere breaks the pattern.

Meanwhile %rdi walks 0x058, 0x060, 0x068, 0x070 and %rsi counts 4, 3, 2, 1, 0. The iteration-4 subq (cycle 26) produces zero and sets ZF = 1, so the cycle-27 jne falls through to 0x057. Cycle 28, the ret, undoes the call symmetrically: Memory reads valM = M[0x1f8] = 0x027, the ALU computes valE = R[%rsp] + 8 = 0x200 for write-back into %rsp, and newPC = valM — control returns to main not because the machine remembers the call, but because the address was parked in memory and fetched back. Cycle 29 fetches halt at 0x027, Stat becomes HLT, and the machine stops with .

Trace any one of those cycles into the block diagram and every value is derived, not asserted. In cycle 9 the PC holds 0x048; the control unit, reading icode:ifun = 6:0 out of the fetched instruction word, drives the register file to read %r10 and %rax as valA and valB, steers both into the ALU with alufun = add, asserts set_cc, routes the ALU output past the idle data memory to the register-file write port with dstE = %rax, and — seeing icode is neither call, jump, nor ret — selects newPC = valP = 0x04a. Not one wire was set by hand. The opcode configured the fixed datapath, and the datapath did the rest.

Validating the machine

A trace that ends in the right answer is evidence, not proof. Real hardware teams spend more effort on verification than on design, and the structure of that effort follows the structure of the machine: test the parts, then the contracts between them, then the whole.

Unit tests, one part at a time. Each functional unit has a small, closed specification, so test it against that spec in isolation. The ALU is combinational: drive operand pairs and check outputs: exhaustively at narrow widths (every 8-bit pair is only 65,536 cases per function), then structured 64-bit cases targeting the carry chain: 0 + 0, 1 + (-1), alternating patterns, and the overflow boundaries from integer arithmetic. The register file is stateful, so its test is temporal: write a distinctive value to each register, read it back on both ports, and check the corner the clocking lesson worried about: a read of the register being written this cycle must return the old value, because the write lands at the edge. Memories get address-in-data patterns (write 0x58 at 0x58) so any addressing error shows as a mismatch.

The control table. The control unit is a pure function from icode:ifun (and Cnd) to a bundle of mux selects and write-enables, and the control-logic lesson wrote that function down as a table. So the test is table-against-table: for each of the ISA's opcodes, apply the opcode, read every control output, and compare with the specified row. This catches the classic assembly bug (a mux wired to the right control signal but with its inputs swapped) before any program runs.

Per-instruction programs against the ISA model. The ISA is the machine's specification, and it is executable: an ISA-level simulator (CS:APP's yis) applies each instruction's defined effect to an architectural state with no hardware in sight. So write one tiny program per instruction (a mrmovq and a halt, a call and a halt), run each on the hardware and on the model, and compare the complete architectural state (registers, CC, memory, PC, Stat) after every instruction. Any divergence identifies the faulty instruction directly. Then add the behavioral pairs: jne taken and not taken, call followed by ret, flags set then read.

Integration. Only now run asum.ys, and the cycle table above is what the comparison looks like: the hardware's state trace laid against the model's, cycle by cycle, with 0xabcdabcdabcd as the visible checksum at the end. A pipelined build adds one more layer, the hazard suite from module 5: load-use pairs, ret followed immediately by work, a mispredicted branch with in-flight wrong-path instructions, each written to force one forwarding or stall path and checked the same way, against the ISA model that never pipelines anything.

The validation ladder. Unit tests check each part against its own spec; the control table and per-instruction programs check the contracts, with the ISA-level simulator as the golden model; integration runs whole programs and compares complete state traces.

Scaling up: the same machine, faster

What we built is the simplest correct CPU: one instruction per cycle, every stage finishing before the next instruction begins. The rest of computer architecture is this exact machine made faster, with the datapath unchanged in spirit.

  • Pipelined (PIPE). Insert registers between the stages and let several instructions occupy the datapath at once, as the complete PIPE does. The functional units are the very same PC, register file, ALU, and data memory; the additions are pipeline registers, forwarding paths, and a control unit that stalls or bubbles each register to handle hazards. On asum.ys the effect is concrete: the mrmovq/addq pair in the loop body is a load-use hazard costing one bubble per iteration, and each ret injects three — the previous lesson's swimlane is this program's inner loop.
  • Real x86-64 silicon. A modern core is the same idea scaled hard: a deep pipeline, several ALUs, out-of-order issue, aggressive branch prediction, and a multi-level cache feeding it: the mechanisms the previous lesson's honesty section flagged as beyond scope. The instruction set is larger and the encodings messier, but underneath, an opcode still configures a fixed datapath of adders, register files, and memories, cycle by cycle.

Two of them on one die

There is a second axis of scaling, and after module 9 we can be precise about it. Duplicating the core is the easy part: the block diagram above, stamped twice. What the duplication demands is three new mechanisms, none of which exists anywhere in the single-core machine:

  1. Coherence for the caches. Each core needs private caches to run at speed, and the moment two caches can hold the same line, the machine needs a coherence protocol: every line carries a MESI state, and a write in one core invalidates the copy in the other before it may proceed. Without this, our asum array could be summed from stale bytes.
  2. An interconnect. The single wire from CPU to memory becomes a shared fabric: a snooping bus at two cores, a ring or mesh beyond that, carrying both memory traffic and the coherence messages, with the shared last-level cache hanging off it.
  3. Atomic instructions. Software on two cores must be able to build locks, and ordinary load–modify–store sequences interleave. The ISA grows an atomic read-modify-write family (lock-prefixed ops, xchg, cmpxchg) and fences, the machinery of memory consistency and synchronization. The coherence protocol is what makes them enforceable, by letting a core hold a line exclusive for the duration of one read-modify-write.
The dual-core extension of the assembled CPU. The core is duplicated verbatim; what is new is everything between the cores: MESI coherence across the private caches, a shared LLC on an interconnect, and atomic read-modify-write support so software can build locks.

And once the hardware exists, something must feed it two instruction streams: threads, scheduled by the kernel our interrupt machinery already lets in every millisecond, or even two thread contexts inside one core, sharing the pipeline we just assembled. The capstone machine is the unit cell; module 9 is the crystal.

So the machine in the block diagram is the real thing in its clearest form. Everything faster is this datapath plus mechanisms to overlap and feed it; everything bigger is this datapath duplicated plus mechanisms to keep the copies consistent; everything below is the gates we built it from. That is the whole course in one picture: a switch became a gate, gates became an ALU and a register file, an ISA fixed the bytes, control wired the parts, and the assembled machine ran a program to the right answer, with nothing directing it but its own PC and the bytes it pointed at.

╌╌ END ╌╌