Last lesson's
decoder box that "just knows" which mux
selects what. Time to open the box. The datapath is the machine's muscles — adders, muxes,
memories, all willing but entirely undirected. The control unit is its nervous
system: a fistful of one-bit (and two-bit) signals that tell every mux which input to pass and
every write port whether tonight is the night. The wonderful surprise of this lesson is how small
it is: for our ten instructions, the entire "brain" is one combinational case
statement — a lookup from opcode to a bundle of bits.
First, meet the signals. Each one answers exactly one question that some mux or write-enable in the datapath is asking:
| Signal | The question it answers | Who listens |
|---|---|---|
RegWrite | Does rd get written this cycle? | Register-file write port |
ALUSrc | Is the ALU's B input rs2, or the immediate? | ALU-B mux |
ALUOp | Add, subtract, AND, OR — or pass B through? | The ALU |
MemRead | Should data memory perform a read? | Data memory |
MemWrite | Should data memory store rs2? | Data-memory write port |
MemToReg | Does rd receive memory data, or the ALU result? | Write-back mux |
Branch | May a zero ALU result redirect the pc? | Next-PC logic |
Jump | Redirect the pc unconditionally (and link pc+4)? | Next-PC logic, WB mux |
And here is the whole brain, laid flat — every instruction's settings for every signal. This table is the control unit; the RTL below and the code at the bottom are just two ways of writing it down:
| Instr | RegWrite | ALUSrc | ALUOp | MemRead | MemWrite | MemToReg | Branch | Jump |
|---|---|---|---|---|---|---|---|---|
ADD | 1 | 0 | ADD | 0 | 0 | 0 | 0 | 0 |
SUB | 1 | 0 | SUB | 0 | 0 | 0 | 0 | 0 |
ADDI | 1 | 1 | ADD | 0 | 0 | 0 | 0 | 0 |
AND | 1 | 0 | AND | 0 | 0 | 0 | 0 | 0 |
OR | 1 | 0 | OR | 0 | 0 | 0 | 0 | 0 |
LW | 1 | 1 | ADD | 1 | 0 | 1 | 0 | 0 |
SW | 0 | 1 | ADD | 0 | 1 | 0 | 0 | 0 |
BEQ | 0 | 0 | SUB | 0 | 0 | 0 | 1 | 0 |
JAL | 1 | 0 | ADD | 0 | 0 | 0 | 0 | 1 |
LUI | 1 | 1 | PASSB | 0 | 0 | 0 | 0 | 0 |
Little elegances hide in the rows. BEQ has no subtract-and-compare hardware of its
own: its ALUOp is SUB, and the next-PC logic just watches the ALU's
zero flag — equality testing by subtraction. LW and SW both set
ALUSrc because the ALU is moonlighting as their address adder. And LUI
asks the ALU to do nothing at all — PASSB waves the shifted immediate straight
through to write-back.
The style here is load-bearing, not cosmetic. Every signal gets a safe default
before the case — the "nothing happens" bundle — and each arm switches on
only what its instruction needs. This has two payoffs: no arm can forget a signal (the
default already set it), and an undecodable opcode falls through to a guaranteed-harmless NOP
rather than to whatever bits the synthesizer felt like. Recall from
always_comb infers a latch; here an incomplete
case arm would infer something worse — wrong behaviour that only shows on the instruction
you forgot.
The classic control-unit disaster is a "don't-care" that wasn't. Suppose you reason: "SW writes
no register, so RegWrite during SW is a don't-care — I'll leave whatever the
optimizer likes." If it settles to 1, then every sw x5, 12(x2) also writes a
register — which one? Whatever bits 11:7 happen to hold, and for S-format those bits are
immediate bits. So sw x5, 12(x2) quietly clobbers x12.
The store itself works perfectly; some unrelated register dies; the program fails minutes later
in code nowhere near the bug. Every cell of the control table is load-bearing.
When a signal seems not to matter, set it to the safe value (writes and redirects OFF) — never
leave it to chance.
Take lw x5, 8(x2) and follow its control row through the datapath, mux by mux:
ALUSrc = 1: the ALU-B mux picks the immediate, so the ALU computes
x2 + 8 — the address.MemRead = 1: data memory reads at alu_out and produces
mem_rdata.MemToReg = 1: the write-back mux passes the memory data, not the ALU's
address.RegWrite = 1: the register file commits mem_rdata into
x5 at the edge.MemWrite = Branch = Jump = 0: nothing stores, nothing redirects; the pc takes its
quiet pc + 4.
Do this walk yourself for BEQ (whose result is thrown away — only the zero
flag matters) and for JAL (whose write-back value ignores the ALU entirely). If you
can narrate a row, you understand the machine.
What we just built is hardwired control: pure combinational logic from
opcode/funct3/funct7 to the signal bundle, settling in a
fraction of a nanosecond. The historical alternative you met in
Not remotely — you almost certainly ran microcode today. x86 processors still translate their crustiest instructions into internal micro-op sequences from an on-chip ROM, and patchable microcode is how vendors ship fixes for hardware bugs — the Spectre and Meltdown mitigations of 2018 arrived partly as microcode updates, decades after microcode was declared obsolete. The modern arrangement is a hybrid: the common fast path is hardwired (RISC won that argument), with a microcoded trapdoor for the rare, the complex, and the regrettable. Our little CPU takes the pure hardwired road because its ISA — like all of RISC-V — was shaped so it could.
In the running TypeScript model, the control unit is exactly what the RTL is: a pure function from
instruction to signal bundle. Here it is, printing the control table straight from the code — and
then steering the same sum-to-15 program as last lesson, with every mux decision now made by the
bundle instead of a hand-written switch:
Compare next() with last lesson's: the arithmetic is unchanged, but every decision
point now reads a control bit — c.ALUSrc steers the B mux, c.RegWrite
gates the write, c.Branch && zero arbitrates the pc. The switch
on instruction names is gone from the datapath and lives only inside control() —
exactly the separation the hardware has. Same answer, 15 in 24 cycles; better anatomy.