The Control Unit and Instruction Decode

Last lesson's single-cycle CPU had a confession buried in it: a magic 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.

The signal bundle

First, meet the signals. Each one answers exactly one question that some mux or write-enable in the datapath is asking:

SignalThe question it answersWho listens
RegWriteDoes rd get written this cycle?Register-file write port
ALUSrcIs the ALU's B input rs2, or the immediate?ALU-B mux
ALUOpAdd, subtract, AND, OR — or pass B through?The ALU
MemReadShould data memory perform a read?Data memory
MemWriteShould data memory store rs2?Data-memory write port
MemToRegDoes rd receive memory data, or the ALU result?Write-back mux
BranchMay a zero ALU result redirect the pc?Next-PC logic
JumpRedirect 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:

InstrRegWriteALUSrcALUOpMemReadMemWriteMemToRegBranchJump
ADD10ADD00000
SUB10SUB00000
ADDI11ADD00000
AND10AND00000
OR10OR00000
LW11ADD10100
SW01ADD01000
BEQ00SUB00010
JAL10ADD00001
LUI11PASSB00000

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 RTL: one always_comb, safe defaults first

always_comb begin // Safe defaults FIRST: nothing writes, nothing branches. Every case // below only switches ON what its instruction actually needs. ctl = '{reg_write: 0, alu_src: 0, alu_op: ALU_ADD, mem_read: 0, mem_write: 0, mem_to_reg: 0, branch: 0, jump: 0}; unique case (opcode) 7'b0110011: begin ctl.reg_write = 1; // ADD SUB AND OR ctl.alu_op = alu_from_funct(funct3, funct7); end 7'b0010011: begin ctl.reg_write = 1; ctl.alu_src = 1; end // ADDI 7'b0000011: begin ctl.reg_write = 1; ctl.alu_src = 1; // LW ctl.mem_read = 1; ctl.mem_to_reg = 1; end 7'b0100011: begin ctl.alu_src = 1; ctl.mem_write = 1; end // SW 7'b1100011: begin ctl.branch = 1; ctl.alu_op = ALU_SUB; end // BEQ 7'b1101111: begin ctl.reg_write = 1; ctl.jump = 1; end // JAL 7'b0110111: begin ctl.reg_write = 1; ctl.alu_src = 1; // LUI ctl.alu_op = ALU_PASSB; end default: ; // unknown opcode: the safe defaults hold, and nothing happens endcase end

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 combinational RTL that an incomplete 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.

Worked example: LW's row, wire by wire

Take lw x5, 8(x2) and follow its control row through the datapath, mux by mux:

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.

Hardwired — and the road not taken

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 hardwired vs microprogrammed control stores the bundles as microcode words in a little ROM and steps through them — flexible, patchable, and the right call in the 1970s when instructions were baroque and gates were precious. RISC-V's regularity is precisely what makes the hardwired choice so cheap here: a handful of opcodes, fields that never move, and the whole decision fits in a page of gates. The ISA and the control style were designed for each other.

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.

The decoder as a pure function

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:

// ── Assembler + decoder (lesson 34 recap, compact). ── function R(f7: number, f3: number, rd: number, rs1: number, rs2: number): number { return ((f7 << 25) | (rs2 << 20) | (rs1 << 15) | (f3 << 12) | (rd << 7) | 0x33) | 0; } function I(op: number, f3: number, rd: number, rs1: number, imm: number): number { return (((imm & 0xfff) << 20) | (rs1 << 15) | (f3 << 12) | (rd << 7) | op) | 0; } function B(f3: number, rs1: number, rs2: number, imm: number): number { return ((((imm >> 12) & 1) << 31) | (((imm >> 5) & 0x3f) << 25) | (rs2 << 20) | (rs1 << 15) | (f3 << 12) | (((imm >> 1) & 0xf) << 8) | (((imm >> 11) & 1) << 7) | 0x63) | 0; } function J(rd: number, imm: number): number { return ((((imm >> 20) & 1) << 31) | (((imm >> 1) & 0x3ff) << 21) | (((imm >> 11) & 1) << 20) | (((imm >> 12) & 0xff) << 12) | (rd << 7) | 0x6f) | 0; } const asm = { ADD: (rd: number, rs1: number, rs2: number) => R(0x00, 0, rd, rs1, rs2), ADDI: (rd: number, rs1: number, imm: number) => I(0x13, 0, rd, rs1, imm), BEQ: (rs1: number, rs2: number, imm: number) => B(0, rs1, rs2, imm), JAL: (rd: number, imm: number) => J(rd, imm), }; interface Decoded { name: string; rd: number; rs1: number; rs2: number; imm: number } function decode(w: number): Decoded { const opcode = w & 0x7f, rd = (w >> 7) & 0x1f, f3 = (w >> 12) & 7; const rs1 = (w >> 15) & 0x1f, rs2 = (w >> 20) & 0x1f, f7 = (w >>> 25) & 0x7f; const immI = w >> 20; const immS = ((w >> 25) << 5) | ((w >> 7) & 0x1f); const immB = ((w >> 31) << 12) | (((w >> 7) & 1) << 11) | (((w >> 25) & 0x3f) << 5) | (((w >> 8) & 0xf) << 1); const immJ = ((w >> 31) << 20) | (((w >> 12) & 0xff) << 12) | (((w >> 20) & 1) << 11) | (((w >> 21) & 0x3ff) << 1); if (opcode === 0x33) return { name: f3 === 0 ? (f7 === 0x20 ? "SUB" : "ADD") : f3 === 7 ? "AND" : "OR", rd, rs1, rs2, imm: 0 }; if (opcode === 0x13) return { name: "ADDI", rd, rs1, rs2, imm: immI }; if (opcode === 0x03) return { name: "LW", rd, rs1, rs2, imm: immI }; if (opcode === 0x23) return { name: "SW", rd, rs1, rs2, imm: immS }; if (opcode === 0x63) return { name: "BEQ", rd, rs1, rs2, imm: immB }; if (opcode === 0x6f) return { name: "JAL", rd, rs1, rs2, imm: immJ }; return { name: "LUI", rd, rs1, rs2, imm: (w >> 12) << 12 }; } // ── The control unit: one pure function, instruction name → signal bundle. ── interface Ctl { RegWrite: number; ALUSrc: number; ALUOp: string; MemRead: number; MemWrite: number; MemToReg: number; Branch: number; Jump: number; } function control(name: string): Ctl { // Start from the SAFE default: nothing writes, nothing branches. const c: Ctl = { RegWrite: 0, ALUSrc: 0, ALUOp: "ADD", MemRead: 0, MemWrite: 0, MemToReg: 0, Branch: 0, Jump: 0 }; switch (name) { case "ADD": case "AND": case "OR": c.RegWrite = 1; c.ALUOp = name; break; case "SUB": c.RegWrite = 1; c.ALUOp = "SUB"; break; case "ADDI": c.RegWrite = 1; c.ALUSrc = 1; break; case "LW": c.RegWrite = 1; c.ALUSrc = 1; c.MemRead = 1; c.MemToReg = 1; break; case "SW": c.ALUSrc = 1; c.MemWrite = 1; break; case "BEQ": c.ALUOp = "SUB"; c.Branch = 1; break; case "JAL": c.RegWrite = 1; c.Jump = 1; break; case "LUI": c.RegWrite = 1; c.ALUSrc = 1; c.ALUOp = "PASSB"; break; } return c; } // ── Print the control table. ── const names = ["ADD", "SUB", "ADDI", "AND", "OR", "LW", "SW", "BEQ", "JAL", "LUI"]; console.log("instr | RegWrite ALUSrc ALUOp MemRead MemWrite MemToReg Branch Jump"); console.log("------+---------------------------------------------------------------"); for (const n of names) { const c = control(n); console.log( `${n.padEnd(5)} | ${c.RegWrite} ${c.ALUSrc} ${c.ALUOp.padEnd(6)} ${c.MemRead} ${c.MemWrite} ${c.MemToReg} ${c.Branch} ${c.Jump}`, ); } // ── The same CPU as last lesson — but now the CONTROLS steer everything. ── interface State { pc: number; regs: number[]; mem: number[] } const imem = [ asm.ADDI(1, 0, 0), asm.ADDI(2, 0, 1), asm.ADDI(3, 0, 6), asm.BEQ(2, 3, 16), asm.ADD(1, 1, 2), asm.ADDI(2, 2, 1), asm.JAL(0, -12), ]; function next(s: State): State { const d = decode(imem[s.pc >> 2]); const c = control(d.name); // ← the new star of the show const regs = s.regs.slice(), mem = s.mem.slice(); const a = s.regs[d.rs1]; const b = c.ALUSrc ? d.imm : s.regs[d.rs2]; // ALUSrc steers the B mux const alu_out = c.ALUOp === "SUB" ? (a - b) | 0 : c.ALUOp === "AND" ? a & b : c.ALUOp === "OR" ? a | b : c.ALUOp === "PASSB" ? b : (a + b) | 0; const zero = alu_out === 0; if (c.MemWrite) mem[alu_out >> 2] = s.regs[d.rs2]; // MemWrite gates the store const loaded = c.MemRead ? mem[alu_out >> 2] | 0 : 0; // MemRead gates the load const wb = c.MemToReg ? loaded : c.Jump ? s.pc + 4 : alu_out; // MemToReg/Jump steer write-back if (c.RegWrite && d.rd !== 0) regs[d.rd] = wb; // RegWrite gates the RF port const pc = c.Jump || (c.Branch && zero) ? s.pc + d.imm : s.pc + 4; // the PC-source mux return { pc, regs, mem }; } let state: State = { pc: 0, regs: new Array(32).fill(0), mem: new Array(16).fill(0) }; let cycles = 0; while (state.pc >> 2 < imem.length) { state = next(state); cycles++; } console.log(`\nsum-to-5 program, control-driven: x1 = ${state.regs[1]} after ${cycles} cycles (15 in 24?)`);

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.