Datapath and Control

Every processor that has ever been built is organized around a single split. On one side is the datapath — the registers, adders, multiplexers, memories and wires through which operand bits physically travel and get transformed. On the other is the control — the logic that, for each instruction, decides which of those elements is active, which multiplexer selects which input, and which register or memory location is allowed to change. The datapath is where the bits go; control is what steers them. Nothing in a CPU is outside this split: an adder is datapath, the single wire that tells the adder’s result multiplexer to pass it through is control.

The split is not a teaching device. It survives into the source code of real cores: the Berkeley Sodor collection, used for years in the CS152 undergraduate architecture course, physically separates every one of its five RISC-V implementations into a dpath.scala and a cpath.scala, with a Chisel Bundle named CtlToDatIo that enumerates exactly the wires crossing from one to the other (riscv-sodor, source read from the master tarball on 2026-09-04).

The most useful idea in this note is that the datapath barely changes as a design matures, while control migrates. In a single-cycle machine control is one combinational function of the instruction word. In a multi-cycle machine it becomes a finite state machine. In a pipeline it becomes per-stage control carried forward in registers alongside the data. The decode table you write once is, in each case, the same table — it just gets stored somewhere different.

Scope. This note owns the organizing split — the elements, the signals, and where the control logic lives across microarchitectures. The Single-Cycle Processor owns the complete working machine assembled from these parts: the per-instruction traces, the cycle-time problem, and the reference-model argument. Read this one for the vocabulary and that one for the machine.

Mental Model — Two Kinds of Wire

Take any wire inside a CPU and ask one question: does the value on this wire ever get written into an architectural register or memory location? If yes, it is a datapath wire. If no — if its only job is to select, enable, or suppress — it is a control wire. That test partitions a processor cleanly, and it is worth applying by hand to a few signals until it becomes automatic.

The 32 bits coming out of the adder are datapath. The 4-bit code telling the adder to subtract instead of add is control. The 32 bits read from register x5 are datapath. The 5-bit address rs1 = 5 that selected x5 is a borderline case that is worth being precise about: those five bits come straight out of the instruction word and never become a result, so they are control in the strict sense — they steer a multiplexer inside the register file. In practice designers usually route register addresses with the datapath because they are bit-fields sliced directly out of the instruction, and only call the decoded one-bit and few-bit signals “control”. The strict test still tells you what they do.

The reason to insist on the distinction is that the two kinds of wire have completely different design pressures. Datapath is wide and regular: 32 bits of adder, 32 bits of multiplexer, 32 bits of register, replicated bit-for-bit. It is expensive in area and it is where your arithmetic delay lives, but it is boring — you build one bit slice and stamp it out. Control is narrow and irregular: a dozen individual bits, each a different Boolean function of the opcode, none of them repeating. It is cheap in area and infuriating in complexity, and it is where essentially all of your bugs will be.

flowchart TB
    subgraph CTL["Control plane — narrow, irregular, all your bugs"]
      DEC["decoder<br/>combinational function<br/>of instr[31:0]"]
      SIG["≈12 signals<br/>RegWrite · ALUSrc · MemWrite<br/>MemToReg · Branch · ALUOp"]
      DEC --> SIG
    end
    subgraph DP["Datapath — wide, regular, all your delay"]
      PC["PC"] --> IM["instruction<br/>memory"]
      IM --> RF["register file<br/>32 x 32"]
      RF --> ALU["ALU"]
      ALU --> DM["data<br/>memory"]
      DM --> WB["write-back<br/>mux"]
      WB --> RF
    end
    IM -.->|"the instruction word<br/>is the only input to control"| DEC
    SIG -.->|"enables"| RF
    SIG -.->|"selects operation"| ALU
    SIG -.->|"enables write"| DM
    SIG -.->|"selects source"| WB
    SIG -.->|"selects next address"| PC

The two planes and the single arrow between them. What it shows: the instruction word is the only thing that crosses from datapath into control, and everything crossing back is a select or an enable. The insight to take: control has exactly one input — the instruction — and in a single-cycle machine that makes it a pure combinational function with no state at all. Every complication that arrives later (stalls, flushes, multi-cycle sequencing) is a second input being added to that function, and each new input is what forces control to grow state.

Two consequences follow immediately, and both matter for the SoC project.

First, the datapath is reusable across microarchitectures and control mostly is not. When you pipeline the single-cycle core in Stage 3 of the project, the register file, the ALU, the sign-extend unit and the memories are copied over essentially unchanged. What changes is that the control signals now have to arrive at each element at the right time, not just with the right value. That is a control problem, and it is why Pipeline Hazards is a note about control logic even though its symptoms look like data corruption.

Second, the ISA designer can move work between the two planes. RISC-V does this deliberately. The specification’s own rationale for the instruction encoding says the ISA “keeps the source (rs1 and rs2) and destination (rd) registers at the same position in all formats to simplify decoding”, and that “the sign bit for all immediates is always in bit 31 of the instruction to allow sign extension to proceed in parallel with instruction decoding” (The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, §2.1.3, intermediate release 20260903, read 2026-09-04). Both statements are about making the control plane smaller and faster at zero cost to the datapath. The scrambled B-type and J-type immediates are the same trade taken further: the spec says that “by rotating bits in the instruction encoding of B and J immediates instead of using dynamic hardware multiplexers to multiply the immediate by 2, we reduce instruction signal fanout and immediate multiplexer costs by around a factor of 2”, and states the motive plainly — “we wanted to reduce the hardware cost of the simplest implementations.” A simple implementation is precisely the one you are about to build.

The Datapath Elements

An RV32I datapath needs exactly seven kinds of element. The list is short enough to memorize and it is worth doing so, because every diagram of every simple processor you will ever see is a rearrangement of these.

The program counter (PC) is a 32-bit register holding the address of the instruction currently executing. It is the only piece of architectural state outside the register file and memory: RV32I’s programmer-visible state is “the 32 x registers plus pc” (spec §2.1.1, Figure 1), where “Register x0 is hardwired with all bits equal to 0.”

Instruction memory is read-only during execution and is addressed by the PC. In a real system this is a boot ROM or an instruction cache; see Boot ROM and the Reset Vector and The SoC Memory Map.

The register file is the 32×32-bit array with two read ports and one write port. Two read ports because R-type instructions need rs1 and rs2 simultaneously; one write port because RV32I writes at most one rd per instruction. Detail in The Register File.

The arithmetic logic unit (ALU) computes the one operation each instruction needs — add, subtract, the three bitwise ops, three shifts, and the two set-less-than comparisons. Detail in The Arithmetic Logic Unit.

Data memory is separate from instruction memory in a single-cycle design, and this is not a stylistic choice — it is forced. See the memory discussion in The Single-Cycle Processor.

The immediate generator (sign-extend unit) turns the scattered immediate bits of an instruction into a full 32-bit signed value. It is pure wiring plus a fan-out of instr[31].

Multiplexers are the elements that make one datapath serve many instructions. Every place where two instructions want to feed different values into the same input, a mux appears — and every mux needs a select line, which is a control signal. Counting muxes is therefore the fastest way to predict how many control bits you need.

flowchart LR
    PC["PC"] --> IM["instruction<br/>memory"]
    PC --> A4["+4"]
    IM -->|"instr[19:15]"| RF["register file<br/>2 read · 1 write"]
    IM -->|"instr[24:20]"| RF
    IM -->|"instr[11:7]"| RF
    IM --> IG["immediate<br/>generator"]
    RF -->|"rs1 value"| MA{"mux A"}
    PC --> MA
    RF -->|"rs2 value"| MB{"mux B"}
    IG --> MB
    MA --> ALU["ALU"]
    MB --> ALU
    ALU -->|"address"| DM["data<br/>memory"]
    RF -->|"rs2 value = store data"| DM
    ALU --> MW{"write-back mux"}
    DM --> MW
    A4 --> MW
    MW -->|"rd value"| RF
    IG --> BA["branch adder<br/>PC + imm"]
    PC --> BA
    A4 --> MP{"next-PC mux"}
    BA --> MP
    ALU --> MP
    MP --> PC

The RV32I datapath with no control drawn at all. What it shows: every element and every wire that carries operand bits, plus the four multiplexers (mux A, mux B, write-back mux, next-PC mux) where instructions disagree about what to feed forward. The insight to take: this diagram is complete as a capability — it can compute every RV32I result — and completely useless as a machine, because nothing tells the muxes which way to point. Those four diamond-shaped nodes are the entire interface to the control unit, and everything in the next section exists to drive them.

Notice something about the count. Four muxes, one register-file write enable, one memory write enable, and one ALU operation selector is seven decisions. That is very close to the number of control bits an RV32I single-cycle core actually needs, and it is a good sanity check when reading someone else’s design: if their control bundle is much wider than their mux count, they are carrying signals that belong elsewhere.

One subtlety in the diagram deserves calling out, because it catches people building this for the first time. The rs2 output of the register file goes to two places: into mux B as a potential ALU operand, and directly into the data memory as the store data. This is not a mistake or a duplicated read port. sw rs2, imm(rs1) needs rs1 + imm as the address (so mux B must select the immediate) and the raw rs2 value as the data to write. The store-data path bypasses the ALU entirely. If you wire store data through the ALU result you will get a core that stores addresses instead of values, and the failure looks like memory corruption rather than a decode bug.

The Control Signals

A control signal is a named Boolean (or few-bit) function of the instruction word whose only purpose is to steer a datapath element. The classic RV32I set is small. The names below are the ones used in this note and in the core built in The Single-Cycle Processor; the literature is not standardized, so MemToReg, WBSel and wb_sel all mean the same thing.

SignalWidthDrivesMeaning when asserted
RegWrite1register file write enablecommit rd at the end of this cycle
ALUSrcA1mux AALU operand A is the PC, not rs1 (for auipc)
ALUSrcB1mux BALU operand B is the immediate, not rs2
MemRead1data memorythis instruction is a load
MemWrite1data memory write enablecommit a store at the end of this cycle
WBSel2write-back mux0 = ALU result, 1 = memory data, 2 = PC+4
Branch1next-PC muxthis is a conditional branch; take it if the comparator agrees
Jump1next-PC muxthis is an unconditional jump
ALUOp4ALUwhich of the eleven operations to perform

Two of these deserve a closer look because they are where designs differ.

ALUOp is drawn here as a single 4-bit field decoded directly from the instruction. Textbook treatments — including Patterson and Hennessy’s Computer Organization and Design, RISC-V Edition (Morgan Kaufmann; first edition 2017, second edition ISBN 9780128203316, bibliographic record confirmed via Open Library) — usually split this into a two-level decode: a small ALUOp[1:0] from the opcode saying “add”, “subtract” or “look at the funct fields”, plus a second ALU control block that combines ALUOp with funct3 and funct7[5]. The two-level version keeps the main decoder narrow. The flat version is one table and is easier to read. Both synthesize to roughly the same logic; pick the one you can debug.

MemRead is the signal that catches people out. In a single-cycle core with an asynchronous (combinational-read) data memory, MemRead does nothing at all: the memory presents data on its output whenever an address is applied, and the write-back mux decides whether anyone cares. In the core built for this note it is decoded correctly and then left unconnected, and Verilator duly reports it as an unused signal. It becomes load-bearing the instant memory becomes synchronous — a real block RAM, a cache, or a bus transaction — because then something has to request the read a cycle ahead. Treat an unused MemRead as a marker for “this design has not yet met real memory”, not as dead code to delete.

Where the signals come from

flowchart TB
    I["instruction word<br/>instr[31:0]"]
    I -->|"instr[6:0]"| OP["opcode<br/>7 bits"]
    I -->|"instr[14:12]"| F3["funct3<br/>3 bits"]
    I -->|"instr[31:25]"| F7["funct7<br/>7 bits"]
    OP --> MAIN["main decode<br/>opcode → instruction class"]
    MAIN --> CLASS{"which class?"}
    CLASS -->|"0110011 R-type"| RT["RegWrite=1<br/>ALUSrcB=0"]
    CLASS -->|"0010011 I-ALU"| IT["RegWrite=1<br/>ALUSrcB=1"]
    CLASS -->|"0000011 LOAD"| LD["RegWrite=1 · ALUSrcB=1<br/>MemRead=1 · WBSel=1"]
    CLASS -->|"0100011 STORE"| ST["RegWrite=0 · ALUSrcB=1<br/>MemWrite=1"]
    CLASS -->|"1100011 BRANCH"| BR["RegWrite=0<br/>Branch=1"]
    CLASS -->|"1101111 / 1100111 JAL/JALR"| JP["RegWrite=1<br/>Jump=1 · WBSel=2"]
    CLASS -->|"0110111 / 0010111 LUI/AUIPC"| UT["RegWrite=1<br/>ALUSrcB=1"]
    F3 --> AOP["ALU operation select"]
    F7 -->|"bit 30 only"| AOP
    MAIN --> AOP
    RT --> OUT["control word"]
    IT --> OUT
    LD --> OUT
    ST --> OUT
    BR --> OUT
    JP --> OUT
    UT --> OUT
    AOP --> OUT

Control-signal generation from the instruction word. What it shows: the opcode alone picks the instruction class and therefore almost every enable and mux select; funct3 and a single bit of funct7 are consulted only to choose the ALU operation. The insight to take: RV32I needs just one bit of funct7instr[30] — in the whole base integer decoder. It distinguishes add from sub and srl from sra, and nothing else. That is why the seven-bit funct7 field looks wasteful and is not: it is reserved space that later extensions spend, while the base ISA leaves the decoder tiny.

The control table, dumped from real hardware

The following table was not written by hand. It is the control unit of the working RV32I core built for these notes, sampled at every instruction executed by a test program under Icarus Verilog 13.0, deduplicated, and annotated. The core and testbench are quoted in full in The Single-Cycle Processor; the raw command was ./sim_iv +hex=sw/t2.hex +ctrl.

instr      mnemonic       RegWrite ALUSrcA ALUSrcB MemRd MemWr WBsel Branch Jump ALUop
02a00093   addi x1,x0,42     1        0       1      0     0     0     0     0     ADD
00310133   add  x2,x2,x3     1        0       0      0     0     0     0     0     ADD
402303b3   sub  x7,x6,x2     1        0       0      0     0     0     0     0     SUB
00002303   lw   x6,0(x0)     1        0       1      1     0     1     0     0     ADD
00504703   lbu  x14,5(x0)    1        0       1      1     0     1     0     0     ADD
00202023   sw   x2,0(x0)     0        0       1      0     1     0     0     0     ADD
00d002a3   sb   x13,5(x0)    0        0       1      0     1     0     0     0     ADD
fe419ce3   bne  x3,x4,loop   0        0       0      0     0     0     1     0     SUB
02039063   bnez x7,fail      0        0       0      0     0     0     1     0     SUB
010005ef   jal  x11,leaf     1        0       0      0     0     2     0     1     ADD
00058067   jalr x0,0(x11)    1        0       1      0     0     2     0     1     ADD
dead04b7   lui  x9,0xdead0   1        0       1      0     0     0     0     0     PASSB
00000517   auipc x10,0       1        1       1      0     0     0     0     0     ADD
00100073   ebreak            0        0       0      0     0     0     0     0     ADD

The measured control table of a real core. What it shows: every instruction class of RV32I reduced to a nine-column row, with x-marked don’t-cares resolved to the safe defaults the decoder actually emits. The insight to take: read the columns, not the rows. RegWrite is 0 for exactly three classes — stores, branches, and ebreak. ALUSrcB is 1 for everything that uses an immediate, which is most of the ISA. ALUSrcA is 1 for exactly one instruction in the entire base set (auipc), which is why some designs omit that mux and special-case auipc in the immediate generator instead. Each column is one Boolean function of the opcode, and the whole control unit is nine such functions side by side.

The last row is worth a note. ebreak decodes to all-zeros here because the decoder’s default branch drives every enable low. That is a deliberate design rule: an unrecognised instruction must be inert. A decoder whose defaults are don’t-cares will, on an illegal opcode, sometimes write a garbage value to a register or corrupt memory, and the resulting bug appears thousands of cycles later somewhere unrelated. Set the defaults to “do nothing” and raise a separate illegal flag, which in a real core becomes an illegal-instruction trap (see RISC-V Trap Handling).

Where Control Lives: Combinational, Sequential, Distributed

This is the section the rest of the note exists to set up. The datapath elements listed above appear, essentially unchanged, in a single-cycle core, a multi-cycle core and a five-stage pipeline. What differs between the three is where the control logic sits and what kind of circuit it is.

flowchart TB
    subgraph SC["Single-cycle — control is combinational"]
      SC1["instr[31:0]"] --> SC2["decode<br/>pure combinational<br/>no clk, no reset, no state"]
      SC2 --> SC3["all signals valid<br/>for the whole cycle"]
    end
    subgraph MC["Multi-cycle — control is a state machine"]
      MC1["instr[31:0]"] --> MC2["next-state logic"]
      MC2 --> MC3["state register<br/>FETCH · DECODE · EXEC<br/>MEM · WB"]
      MC3 --> MC2
      MC3 --> MC4["signals valid for<br/>ONE step of many"]
    end
    subgraph PP["Pipelined — control is distributed per stage"]
      PP1["instr[31:0]"] --> PP2["decode once<br/>in ID"]
      PP2 --> PP3["ID/EX control<br/>register"]
      PP3 --> PP4["EX/MEM control<br/>register"]
      PP4 --> PP5["MEM/WB control<br/>register"]
      PP6["hazard unit<br/>stall · flush · forward"] -.->|"can override"| PP3
      PP6 -.-> PP4
    end

The same decode logic in three microarchitectures. What it shows: single-cycle control is a function; multi-cycle control is a Moore/Mealy machine whose state says which step of this instruction we are on; pipelined control decodes once and then physically carries the resulting control word forward through registers, one per stage boundary. The insight to take: in all three the decode table is the same table. The single-cycle version consumes it immediately, the multi-cycle version consumes one row of it per state, and the pipelined version stores it and consumes it four cycles later. That is why writing the control table carefully in Stage 2 pays off in Stage 3 — you are writing the pipeline’s stage-register contents a project phase early.

Combinational control, in a single-cycle machine

Look at the module header of the control unit in the core built for this note:

module control (
    input  wire [31:0] instr,
    output reg         reg_write,
    output reg         alu_src_b,    // 0 = rs2,  1 = immediate
    ...
);

There is no clk port and no reset port. That absence is the whole definition of combinational control: the module is a truth table with 2³² rows (of which only a few dozen patterns matter), evaluated afresh every cycle from scratch. It cannot remember anything, cannot sequence anything, and cannot stall anything. Everything it needs to know is in the 32 bits in front of it.

This is only possible because the instruction is available for the entire cycle. In a single-cycle machine it is, because the PC does not change until the clock edge. The moment the PC advances while an instruction is still in flight — which is what pipelining means — the control unit’s input disappears out from under it, and the design has to store the control word instead.

Sequential control, in a multi-cycle machine

A multi-cycle core splits one instruction into several clock cycles and reuses hardware between them: one memory serving both fetch and data access, one ALU serving address arithmetic and the actual operation, one bus rather than three. Because the hardware is being reused, something must remember which use is happening now. That something is a state register, and control becomes a finite state machine.

stateDiagram-v2
    [*] --> FETCH
    FETCH: FETCH<br/>MA ← PC, IR ← Mem[MA]
    DECODE: DECODE<br/>A ← Reg[rs1], B ← Reg[rs2]
    EXEC: EXEC<br/>ALU computes
    MEMADDR: MEM ADDRESS<br/>MA ← A + imm
    MEMRD: MEM READ<br/>data ← Mem[MA]
    MEMWR: MEM WRITE<br/>Mem[MA] ← B
    WB: WRITE-BACK<br/>Reg[rd] ← result
    BR: BRANCH<br/>PC ← PC + imm if taken
    FETCH --> DECODE
    DECODE --> EXEC: R-type / I-type
    DECODE --> MEMADDR: load / store
    DECODE --> BR: branch
    EXEC --> WB
    MEMADDR --> MEMRD: load
    MEMADDR --> MEMWR: store
    MEMRD --> WB
    MEMWR --> FETCH
    WB --> FETCH
    BR --> FETCH

The control finite state machine of a classic multi-cycle datapath. What it shows: an instruction is now a path through states, and different instruction classes take paths of different length — an R-type takes four states, a load takes five, a store takes four. The insight to take: this is the origin of variable cycles per instruction. Cycles Per Instruction stops being 1 exactly here, and it stops being 1 because control gained a state register. See Cycles Per Instruction.

The Sodor collection contains a working example of this in rv32_ucode, whose datapath declares exactly the registers the diagram implies — an instruction register ir, two operand latches reg_a and reg_b, and a memory-address register reg_ma — none of which exist in the single-cycle rv32_1stage datapath. Those four registers are the multi-cycle microarchitecture; everything else is the same datapath.

Distributed control, in a pipeline

In a pipeline nothing is reused across cycles — instead, five different instructions occupy five stages at once. Decode happens once, in ID, and the resulting control word then has to travel alongside its instruction for the remaining stages. The signals that the EX stage needs are consumed one cycle later; the ones MEM needs, two cycles later; the ones write-back needs, three.

You can see this directly in Sodor’s five-stage control path, which registers the decoded control bits into stage registers:

val exe_reg_wbaddr      = Reg(UInt())
val exe_reg_ctrl_rf_wen = RegInit(false.B)
val exe_reg_illegal     = RegInit(false.B)
val exe_reg_is_csr      = RegInit(false.B)

exe_reg_ctrl_rf_wen is the single-cycle core’s RegWrite bit — decoded in ID, stored, and used three stages later. The Reg wrapper is the entire difference. This is the concrete form of the claim made at the top of this note: the control table becomes the pipeline’s stage registers.

The other half of pipelined control is new, though, and it is not decode at all. Comparing the two Sodor cores’ control-to-datapath interfaces makes the split visible:

1-stage CtlToDatIo5-stage CtlToDatIo
decode outputspc_sel, op1_sel, op2_sel, alu_fun, wb_sel, rf_wen, csr_cmdexe_pc_sel, br_type, op1_sel, op2_sel, alu_fun, wb_sel, rf_wen, mem_val, mem_fcn, mem_typ, csr_cmd
hazard/flush outputsstall, dmissdec_stall, full_stall, if_kill, dec_kill, pipeline_kill, fencei
exception outputsexception, exception_causemem_exception, mem_exception_cause
control path source size189 lines286 lines
datapath source size296 lines543 lines

Control interfaces of two Sodor cores compared, line counts from the master tarball read 2026-09-04. What it shows: the decode signals are almost identical between a one-stage and a five-stage RV32I core — op1_sel, op2_sel, alu_fun, wb_sel, rf_wen appear verbatim in both. Everything the five-stage core adds is either hazard machinery (dec_stall, if_kill, dec_kill, pipeline_kill) or a signal that had to be named per-stage because there are now five instructions in flight (exe_pc_sel). The insight to take: pipelining does not make decoding harder. It adds an entirely separate second control problem — when is this signal valid and which instruction does it belong to — on top of an unchanged first one.

Hardwired versus Microcoded Control

There are two ways to build the control unit, and which one is viable is a direct consequence of the ISA you chose.

Hardwired control is what this note has described so far: a block of combinational logic (single-cycle) or a hand-written state machine (multi-cycle) that computes control signals from the opcode. It is fast, because it is a couple of levels of gates, and it is rigid, because changing the instruction set means re-synthesizing the logic.

Microcoded control replaces that logic with a ROM. Each machine instruction becomes an address into the ROM, and the ROM’s output word is the control signal bundle — possibly for several consecutive micro-steps, with a micro-program counter walking through them. Maurice Wilkes proposed this in 1951 as a way of imposing order on the ad-hoc control circuits of early machines; the paper is “The best way to design an automatic calculating machine”, presented at the Manchester University Computer Inaugural Conference and reprinted in Microprocessing and Microprogramming pp. 141–144 with doi:10.1016/0165-6074(81)90018-1 (bibliographic record confirmed via dblp, 2026-09-04).

Uncertain

Verify: the 1951 original venue and pagination of Wilkes’s microprogramming paper. Reason: dblp indexes the 1981 Microprocessing and Microprogramming reprint (pp. 141–144), and the 1951 Manchester conference proceedings are not available online to this fetch. To resolve: consult a library copy of the Report of the Manchester University Computer Inaugural Conference, July 1951, pp. 16–18, or the ACM/IEEE reprint in The Origins of Digital Computers. The attribution of microprogramming to Wilkes in 1951 is not in doubt; only the exact citation is.

Sodor’s rv32_ucode core is a readable working example, and its microcode source states the model exactly: “Micro-code that controls the processor is found in here. The Micro-code Compiler takes the micro-code in here and generates a ROM for use by the control path.” A single add becomes three micro-operations:

/* ADD              */
/* A  <- Reg[rs1]   */ Label("ADD"), Signals(Cat(..., RS_RS1, REN_1, LDA_1, ..., UBR_N), "X")
/* B  <- Reg[rs2]   */               Signals(Cat(..., RS_RS2, REN_1, LDB_1, ..., UBR_N), "X")
/* Reg[rd] <- A + B */               Signals(Cat(..., RS_RD,  RWR_1, ALU_ADD, AEN_1, UBR_J), "FETCH")

Read that as three rows of the control table executed in sequence, with the last row micro-branching (UBR_J) back to the FETCH micro-routine. The Cat(...) is literally the concatenation of control bits into one ROM word.

flowchart LR
    subgraph HW["Hardwired"]
      H1["opcode"] --> H2["combinational<br/>gates / LUTs"]
      H2 --> H3["control word"]
    end
    subgraph MC["Microcoded"]
      M1["opcode"] --> M2["dispatch table<br/>opcode → µaddr"]
      M2 --> M3["µPC"]
      M3 --> M4["control ROM"]
      M4 --> M5["control word"]
      M4 -->|"next-µaddr field"| M3
    end

The two control implementations. What it shows: hardwired control turns an opcode into signals with gates; microcoded control turns it into a ROM address and reads the signals out, with the ROM word also carrying the address of the next micro-instruction. The insight to take: the microcoded version is a tiny interpreter running inside the CPU, and its cost is one ROM access per micro-step — which is why microcoded machines have CPI in the high single digits and hardwired RISC machines aim at 1.

Why RISC made hardwired control viable again

Microcode won the 1970s for a specific reason: memory was expensive and slow relative to logic, so ISAs grew dense, complex, variable-length instructions that did a lot per fetch — VAX’s POLY evaluates a polynomial; x86’s REP MOVSB copies a string. No sane person builds a combinational decoder for that. A ROM is the only tractable way to sequence a hundred-cycle instruction, and it has the enormous practical advantage that a control-store bug can be patched by rewriting the ROM rather than respinning the silicon.

The RISC argument, made by Patterson and Ditzel in “The case for the reduced instruction set computer” (SIGARCH Computer Architecture News 8(6), 25–33, 1980, doi:10.1145/641914.641917, verified via dblp), was that this had become a bad trade. If instructions are fixed-length, have fields in fixed positions, and each do one thing, then the decoder collapses to two levels of gates — and you can spend the transistors the control store was consuming on cache and registers instead. Their case was contested at the time in the same journal issue by Clark and Strecker’s “Comments on ‘The case for the reduced instruction set computer’” (SIGARCH CAN 8(6), 34–38, doi:10.1145/641914.641918), which is worth knowing about: the debate was live, not a foregone conclusion.

RISC-V inherits the conclusion and hard-codes it into the encoding. Fixed 32-bit instructions (16-bit with the C extension), rs1/rs2/rd in the same bit positions in every format, immediates always sign-extended from bit 31. The result is measurable: the control unit built for these notes decodes all of RV32I in roughly 100 lines of Verilog, with a single bit of funct7 consulted. There is nothing left for a ROM to do.

The honest caveat is that microcode never disappeared — it moved. Modern x86 implementations decode common instructions with hardwired logic and fall back to a microcode sequencer for the rare complex ones, and the patchable control store is now a security feature: microcode updates are how Spectre and Meltdown mitigations were shipped to already-deployed silicon. “Hardwired won” is true for embedded RISC cores and false as a general statement about processors.

Building It — Verilog for a Real Decoder

The following is the complete control unit of a working RV32I core — not a sketch. It elaborates under Verilator 5.046 with -Wall clean and simulates under Icarus Verilog 13.0; the surrounding core is quoted and exercised in The Single-Cycle Processor.

module control (
    input  wire [31:0] instr,
    output reg         reg_write,
    output reg         alu_src_b,    // 0 = rs2,  1 = immediate
    output reg         alu_src_a,    // 0 = rs1,  1 = PC   (auipc, jal)
    output reg         mem_read,
    output reg         mem_write,
    output reg  [1:0]  wb_sel,       // 0 = ALU, 1 = memory, 2 = PC+4
    output reg         branch,
    output reg         jump,         // unconditional (jal, jalr)
    output reg         jalr,
    output reg  [3:0]  alu_op,
    output reg  [2:0]  funct3_out,
    output reg         illegal
);
    wire [6:0] opcode = instr[6:0];
    wire [2:0] funct3 = instr[14:12];
    wire [6:0] funct7 = instr[31:25];
 
    always @* begin
        // Defaults chosen so that an unrecognised opcode is inert: nothing is
        // written to the register file and nothing is written to memory.
        reg_write  = 1'b0;
        alu_src_b  = 1'b0;
        alu_src_a  = 1'b0;
        mem_read   = 1'b0;
        mem_write  = 1'b0;
        wb_sel     = 2'd0;
        branch     = 1'b0;
        jump       = 1'b0;
        jalr       = 1'b0;
        alu_op     = `ALU_ADD;
        funct3_out = funct3;
        illegal    = 1'b0;
 
        case (opcode)
        7'b0110011: begin                       // R-type: add/sub/sll/slt/...
            reg_write = 1'b1;
            case ({funct7[5], funct3})
                4'b0_000: alu_op = `ALU_ADD;
                4'b1_000: alu_op = `ALU_SUB;
                4'b0_001: alu_op = `ALU_SLL;
                4'b0_010: alu_op = `ALU_SLT;
                4'b0_011: alu_op = `ALU_SLTU;
                4'b0_100: alu_op = `ALU_XOR;
                4'b0_101: alu_op = `ALU_SRL;
                4'b1_101: alu_op = `ALU_SRA;
                4'b0_110: alu_op = `ALU_OR;
                4'b0_111: alu_op = `ALU_AND;
                default : illegal = 1'b1;
            endcase
        end
        7'b0010011: begin                       // I-type ALU: addi/andi/slli/...
            reg_write = 1'b1;
            alu_src_b = 1'b1;
            case (funct3)
                3'b000: alu_op = `ALU_ADD;
                3'b010: alu_op = `ALU_SLT;
                3'b011: alu_op = `ALU_SLTU;
                3'b100: alu_op = `ALU_XOR;
                3'b110: alu_op = `ALU_OR;
                3'b111: alu_op = `ALU_AND;
                3'b001: alu_op = `ALU_SLL;
                3'b101: alu_op = funct7[5] ? `ALU_SRA : `ALU_SRL;
                default: illegal = 1'b1;
            endcase
        end
        7'b0000011: begin                       // LOAD: lw/lh/lb/lhu/lbu
            reg_write = 1'b1;
            alu_src_b = 1'b1;                   // address = rs1 + imm
            alu_op    = `ALU_ADD;
            mem_read  = 1'b1;
            wb_sel    = 2'd1;
        end
        7'b0100011: begin                       // STORE: sw/sh/sb
            alu_src_b = 1'b1;                   // address = rs1 + imm
            alu_op    = `ALU_ADD;
            mem_write = 1'b1;
        end
        7'b1100011: begin                       // BRANCH: beq/bne/blt/bge/...
            branch = 1'b1;
            alu_op = `ALU_SUB;                  // comparison via subtract
        end
        7'b1101111: begin                       // JAL
            reg_write = 1'b1;
            jump      = 1'b1;
            wb_sel    = 2'd2;                   // rd <- PC+4
        end
        7'b1100111: begin                       // JALR
            reg_write = 1'b1;
            jump      = 1'b1;
            jalr      = 1'b1;
            alu_src_b = 1'b1;
            alu_op    = `ALU_ADD;
            wb_sel    = 2'd2;
        end
        7'b0110111: begin                       // LUI
            reg_write = 1'b1;
            alu_src_b = 1'b1;
            alu_op    = `ALU_PASSB;
        end
        7'b0010111: begin                       // AUIPC
            reg_write = 1'b1;
            alu_src_a = 1'b1;                   // PC + imm
            alu_src_b = 1'b1;
            alu_op    = `ALU_ADD;
        end
        default: illegal = 1'b1;
        endcase
    end
endmodule

Line by line, the things worth understanding:

  • always @* with output reg, not always @(posedge clk). In Verilog reg does not mean “flip-flop”; it means “assignable in a procedural block”. Because the sensitivity list is @* and every output is assigned on every path, this synthesizes to pure combinational logic. If you forget to assign one output on one path, the synthesizer infers a latch to hold the old value — and you have accidentally given your combinational control unit state. The block of defaults at the top exists specifically to make that impossible.
  • The defaults are a safety property, not tidiness. With reg_write, mem_write, branch and jump defaulting to 0, the worst an unrecognised instruction can do is compute a value nobody uses.
  • {funct7[5], funct3} is the whole R-type secondary decode. funct7[5] is instr[30] — the only bit of funct7 the base ISA reads. It selects sub over add and sra over srl.
  • Branch uses ALU_SUB but the core does not use the ALU result to decide. The alu_op is set for symmetry and for designs that derive the branch condition from the ALU’s zero flag; the core built here uses a separate comparator so that the ALU-result mux and the next-PC mux do not both sit on the same delay chain. This is a real timing decision, discussed in The Single-Cycle Processor and in Timing Closure and Fmax.
  • ALU_PASSB for lui. lui needs rd = imm, with no arithmetic. Rather than add a fifth path into the write-back mux, the ALU gains a “pass operand B” operation. This is a recurring pattern: adding an ALU opcode is nearly free, adding a mux input is not.
  • jalr gets its own bit because its target is rs1 + imm, not PC + imm. The specification requires the result’s least-significant bit be cleared — the core implements (rs1_data + imm) & ~32'd1 — and the spec’s rationale notes this “both simplifies the hardware slightly and allows the low bit of function pointers to be used to store auxiliary information.”

Verifying a decoder without a CPU around it

A control unit is one of the few CPU modules that can be tested completely on its own, because it has no state and one input. Two techniques are worth using before the core exists at all — see Testbenches and RTL Verification:

  1. Exhaustive sweep against a reference. Iterate all 2⁷ opcodes crossed with all funct3/funct7[5] combinations, and compare the control word against a table you wrote independently (in C, in Python, or read from a disassembler). This finds transposed columns, which are otherwise invisible until a specific instruction misbehaves.
  2. Assert the inertness property. For every instruction word that sets illegal, assert reg_write == 0 && mem_write == 0. This is a one-line SystemVerilog assertion and it catches the entire class of “garbage opcode corrupted state” bugs.

The measured control table earlier in this note came from the third technique: instrumenting the running core and dumping the signals per instruction. That one is the least rigorous and by far the most useful during bring-up, because it tells you what the hardware is actually doing rather than what you believe it does.

Failure Modes and Gotchas

These are the specific ways a control unit goes wrong, with the symptom you will actually observe rather than the abstract description.

Inferred latches from an incomplete always @*. Symptom: the core works for a while and then an instruction behaves as though it were the previous instruction — a nop writes a register, or an add uses the immediate. Cause: a signal not assigned on some path through the case statement retains its previous value, which means the control unit remembers the last instruction that set it. Diagnosis: Verilator reports LATCH warnings; verilator --lint-only -Wall finds these in seconds and is worth running on every save.

A don’t-care that was not. Symptom: stores corrupt a register, or branches write to rd. Cause: treating RegWrite as a don’t-care for stores because “the value is garbage anyway”. It is not a don’t-care — rd for an S-type instruction is not a rd field at all, it is the low five bits of the immediate, and writing to whatever register that names will destroy state. The rd/immediate field overlap is exactly why the defaults-are-inert rule matters.

Store data routed through the ALU. Symptom: sw x2, 0(x0) writes the address into memory instead of the value of x2. Cause: the store-data path must come straight from the register file’s rs2 port, bypassing the ALU, while the ALU computes the address.

Comparing with the ALU’s zero flag for the wrong branch. Symptom: beq works, blt does not. Cause: a - b == 0 correctly implements beq, but signed less-than is not the sign bit of a - b in general — that formulation overflows. Either use a dedicated comparator (as this core does) or use the ALU’s SLT/SLTU results, which are defined to handle it.

Sign-extension applied to the wrong format. Symptom: short forward branches work, backward branches jump to nonsense; or sw with a negative offset writes the wrong place. Cause: the S-type immediate is {instr[31:25], instr[11:7]} and the B-type is the same bits rotated, with instr[7] supplying bit 11 and an implicit zero in bit 0. Getting the B-type wrong produces branch targets off by a power of two, which looks like a completely unrelated bug.

x0 not enforced on write. Symptom: a program that uses x0 as a scratch destination — which compilers emit freely, e.g. addi x0, x0, 0 as a nop, or jalr x0, 0(x1) as a return — starts corrupting the constant zero. Cause: the register file must suppress writes to index 0. RV32I requires x0 be “hardwired with all bits equal to 0” (spec §2.1.1). Note this is a datapath fix, not a control one: do it inside the register file, not by masking RegWrite in the decoder, or you will have to remember it again in the pipelined version.

Decoding jal’s immediate as an I-type. Symptom: function calls jump to plausible-but-wrong addresses. Cause: jal’s J-type immediate is 20 bits with an implicit zero LSB; the decoder must select the J shape on opcode 1101111. The jalr immediate, confusingly, is a plain I-type and is not multiplied by two — the spec calls this out explicitly: “the JALR instruction does not treat the 12-bit immediate as multiples of 2 bytes, unlike the conditional branch instructions. This avoids one more immediate format in hardware.”

Believing an unused signal is a bug. As discussed above, MemRead genuinely has nothing to drive in a design with asynchronous memory. Suppressing the lint warning is correct; deleting the signal is not, because you will need it in Stage 5 when the memory becomes a real bus transaction.

Alternatives and When to Choose Them

There is no serious alternative to having a datapath/control split — it is a description of what processors are, not a design option. The real choices are how control is implemented and how it is described in source.

ApproachCostSpeedFlexibilityWhen to choose it
Hardwired combinationalsmallest; a few LUT levelsfastestfixed at synthesisAny fixed-length RISC ISA. The default for RV32I.
Hardwired FSM (multi-cycle)small; adds a state registerslower per instruction, higher clockfixedArea-critical designs, or when one memory port must serve fetch and data
Microcoded ROMcontrol store dominates areaslowest; a ROM read per micro-steppatchable after tape-outComplex/variable-length ISAs, or when field-updatable behaviour is required
Distributed pipelined controldecode logic plus one control register per stage boundaryhighest throughputfixedOnce CPI 1 at a usable clock is the goal — see Classic Five-Stage Pipeline

How to pick a control implementation. What it shows: the four options ordered by how much state the control unit carries. The insight to take: the column that actually decides it is “flexibility”. Microcode’s enduring advantage was never speed or area — it was that a control store can be rewritten. Once an ISA is simple enough that hardwired decode is a hundred lines and once FPGAs made re-synthesis cheap, that advantage evaporated for small cores.

Within hardwired control, there is a second choice about how the table is written, and it matters more than it looks.

Nested case statements (the style used above) read like the ISA manual and are easy to extend one instruction at a time — which is exactly the Stage 2 workflow the definitely-not-esp32 MOC prescribes: “a single-cycle RV32I core that executes addi x1, x0, 42 and halts. Then grow the decoder one instruction at a time.”

A flat lookup table — Sodor’s ListLookup with one row per instruction and one column per signal — makes the control word visible as a table you can read down the columns, which is how you spot a signal that is wrong for a whole class of instructions. Its cost is that adding a signal means editing every row.

Bit-pattern matching on a decoded instruction list — the approach used by larger cores, where a set of casez patterns or a generated matcher classifies the instruction first, and the control word is assigned per class — scales best past a few dozen instructions, which is where you land after adding M, C and Zicsr.

For a first core, write the nested case. For a core you intend to keep, migrate to the flat table once the instruction count passes about thirty, because that is roughly where reading down a column starts to be the fastest way to find a bug.

Production Notes

Three shipping open-source RV32 cores, read on 2026-09-04, show how the datapath/control balance is actually struck under different constraints.

PicoRV32 (YosysHQ) is optimized for area and clock frequency rather than instruction throughput, and says so: “This core is optimized for size and fmax, not performance.” Its control is a state machine, and the consequence is visible in its own published numbers — average CPI approximately 4, measured at 4.100 on Dhrystone, with per-instruction costs of 3 cycles for an ALU operation, 5 for a load or a taken branch, and 6 for jalr. In exchange it fits in 761–2019 slice LUTs on Xilinx 7-series and closes timing between 416 MHz and 769 MHz depending on device and speed grade (place-and-routed with Vivado 2017.3, per its README). That is the multi-cycle trade taken deliberately: four times the cycles, but a clock you can drop into an existing design without a clock-domain crossing.

SERV (Olof Kindgren) is the extreme. It is bit-serial — the datapath is one bit wide — and it claims to be the world’s smallest RISC-V CPU at 198 LUT / 239 LUT / 125 LUT across three FPGA families and 2.1 kGE in gates. Its README also contains a warning that is a perfect illustration of the control-plane-is-where-the-bugs-live principle: “Don’t feed serv any illegal instructions after midnight. Many logic expressions are hand-optimized using the old-fashioned method with Karnaugh maps on paper, and shamelessly take advantage of the fact that some opcodes aren’t supposed to appear.” That is the inertness property being deliberately traded away for LUTs.

Ibex (lowRISC) sits at the other end of the embedded range with a two-stage pipeline, and its documentation is precise about the resulting control cost: “All instructions require two cycles minimum to pass down the pipeline. One cycle in the IF stage and one in the ID/EX stage… This means the maximum IPC (Instructions per Cycle) Ibex can achieve is 1 when multi-cycle instructions aren’t used.” Loads and stores “stall for at least one cycle to await a response”. Every one of those stalls is a control signal that did not exist in the single-cycle design.

The pattern across all three is the same: the datapath elements are the ones in the diagram above in every case. What distinguishes a 125-LUT bit-serial core from a two-stage pipelined one is entirely the control structure wrapped around them.

For the definitely-not-esp32 project specifically, the practical consequence is a working discipline. Keep control.v a separate file from the datapath from day one, even though it is tempting to inline a dozen assign statements into the core. When Stage 3 arrives and the control word has to be registered into ID/EX, EX/MEM and MEM/WB, having the control word already exist as a named bundle of wires turns that from a rewrite into an insertion. Sodor’s CtlToDatIo bundle is the shape to copy: one struct, one place to add a signal, one place to look when a signal is wrong.

See Also