A Single-Cycle CPU in RTL
Today the machine runs. We have a contract (ten RV32I instructions), a
register file,
and an encoder that speaks real machine code. This lesson wires everything into the simplest CPU
organisation there is: the single-cycle datapath, where every instruction
completes in exactly one clock cycle. One edge, one instruction — fetch, decode, execute,
memory, write-back, all in a single combinational sweep between two ticks. It is the
datapath
idea taken to its purest form: beautiful, easy to reason about, and — as we will
measure at the end — magnificently slow.
The whole loop, on one diagram
This figure is the module's signature — we will redraw it, sliced and annotated, for the next three
lessons. Step through it left to right; every wire we name here
(pc, instr, imm, alu_out, wb_data)
keeps its name for the rest of the project:
Notice what is not in the picture: pipeline registers, stall logic, caches — nothing but
the loop. The only flip-flops are the pc, the register file, and the memories. All the
rest is one huge combinational cloud, and the cycle rhythm you learned with
always_ff — settle, then snap — runs the entire machine: during the cycle the cloud
settles through all five stations; at the edge, the pc, the register file and the data
memory snap their new values in, and the next instruction begins.
The RTL skeleton
In SystemVerilog the top level is mostly instantiation and two muxes — the blocks are the modules
we have been building all course:
module cpu (input logic clk, rst_n);
logic [31:0] pc, pc_next, instr, imm;
logic [31:0] rdata1, rdata2, alu_b, alu_out, mem_rdata, wb_data;
// The only flip-flops in the whole CPU (besides the RF and memories): the PC.
always_ff @(posedge clk)
if (!rst_n) pc <= 32'd0;
else pc <= pc_next;
imem u_imem (.addr(pc), .instr(instr)); // fetch (a ROM)
decoder u_dec (.instr, .rs1, .rs2, .rd, .imm, .ctl); // fields + immediate + controls
regfile u_rf (.clk, .we(ctl.reg_write), .rs1, .rs2, .rd,
.wdata(wb_data), .rdata1, .rdata2); // last lesson's module!
assign alu_b = ctl.alu_src ? imm : rdata2; // the ALU-B mux
alu u_alu (.a(rdata1), .b(alu_b), .op(ctl.alu_op), .y(alu_out));
dmem u_dmem (.clk, .we(ctl.mem_write), .addr(alu_out),
.wdata(rdata2), .rdata(mem_rdata)); // loads and stores
// Write-back mux, then the next-PC mux closes the loop.
assign wb_data = ctl.mem_to_reg ? mem_rdata
: ctl.jump ? pc + 32'd4 // JAL's link value
: alu_out;
assign take = ctl.jump | (ctl.branch & (rdata1 == rdata2));
assign pc_next = take ? pc + imm : pc + 32'd4;
endmodule
Three details deserve a pause. First, the ALU pulls double duty: for ADD it adds
values, for LW/SW it adds rs1 + imm to make an
address — one adder, two jobs, selected by the alu_src mux. Second,
wb_data has three sources (ALU, memory, pc + 4 for JAL's
return address) — that mux is the last decision every instruction makes. Third, pc_next
is itself a mux: pc + 4 by default, pc + imm for a taken branch or jump.
Where do all the little select signals come from? For now, imagine a decoder box that
just knows; giving that box its proper name and internals is the whole of the next lesson.
The payoff: it runs a real program
Now the moment the module has been building toward. Below is the entire CPU as a two-phase
simulator — the exact pattern from the flip-flop lesson, with a richer State:
the architectural state is {pc, regs, mem}, and next() is the whole
combinational cloud — fetch, decode, register read, ALU, memory, write-back, next-PC — committed
atomically, one call per clock edge. The instruction memory holds genuine RV32I words assembled by
lesson 34's encoder, forming a loop that sums 1+2+3+4+5:
// ── The assembler + decoder from the planning lesson (compact recap). ──
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),
SUB: (rd: number, rs1: number, rs2: number) => R(0x20, 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 program: sum 1+2+3+4+5 into x1, with a real BEQ loop. ──
const imem = [
asm.ADDI(1, 0, 0), // 0: addi x1, x0, 0 sum = 0
asm.ADDI(2, 0, 1), // 4: addi x2, x0, 1 i = 1
asm.ADDI(3, 0, 6), // 8: addi x3, x0, 6 stop = 6
asm.BEQ(2, 3, 16), // 12: beq x2, x3, +16 if i == stop goto 28 (done)
asm.ADD(1, 1, 2), // 16: add x1, x1, x2 sum += i
asm.ADDI(2, 2, 1), // 20: addi x2, x2, 1 i += 1
asm.JAL(0, -12), // 24: jal x0, -12 back to the beq
]; // 28: (falls off the end: done)
// ── The state: everything that lives in flip-flops or RAM. ──
interface State { pc: number; regs: number[]; mem: number[] }
// ── next(): ONE combinational sweep — fetch, decode, execute, write back. ──
function next(s: State): State {
const instr = decode(imem[s.pc >> 2]); // fetch + decode
const regs = s.regs.slice(), mem = s.mem.slice();
const a = s.regs[instr.rs1], b = s.regs[instr.rs2]; // register-file read
const wr = (rd: number, v: number) => { if (rd !== 0) regs[rd] = v | 0; }; // x0 stays 0
let pc = s.pc + 4; // default next-PC
switch (instr.name) {
case "ADD": wr(instr.rd, a + b); break;
case "SUB": wr(instr.rd, a - b); break;
case "AND": wr(instr.rd, a & b); break;
case "OR": wr(instr.rd, a | b); break;
case "ADDI": wr(instr.rd, a + instr.imm); break;
case "LUI": wr(instr.rd, instr.imm); break;
case "LW": wr(instr.rd, mem[(a + instr.imm) >> 2]); break;
case "SW": mem[(a + instr.imm) >> 2] = b; break;
case "BEQ": if (a === b) pc = s.pc + instr.imm; break;
case "JAL": wr(instr.rd, s.pc + 4); pc = s.pc + instr.imm; break;
}
return { pc, regs, mem };
}
// ── The clock: settle, then snap — until the PC falls off the program. ──
let state: State = { pc: 0, regs: new Array(32).fill(0), mem: new Array(16).fill(0) };
console.log("cycle | pc | instr | x1 x2 x3");
console.log("------+-----+-----------------+----------");
let cycle = 0;
while (state.pc >> 2 < imem.length) {
const d = decode(imem[state.pc >> 2]);
const args = d.name === "JAL" ? `x${d.rd}, ${d.imm}`
: d.name === "BEQ" ? `x${d.rs1}, x${d.rs2}, ${d.imm}`
: d.name === "ADDI" ? `x${d.rd}, x${d.rs1}, ${d.imm}`
: `x${d.rd}, x${d.rs1}, x${d.rs2}`;
state = next(state);
console.log(
` ${String(cycle).padStart(2)} | ${String(state.pc).padStart(3)} | ` +
`${(d.name.toLowerCase() + " " + args).padEnd(15)} | ${state.regs[1]} ${state.regs[2]} ${state.regs[3]}`,
);
cycle++;
}
console.log(`\nDone in ${cycle} cycles: x1 = ${state.regs[1]} (1+2+3+4+5 = 15?)`);
Twenty-four cycles, twenty-four instructions, and x1 lands on 15. Watch the
pc column: it marches in 4s, snaps back to 12 at every jal x0, -12, and
finally skips to 28 when beq sees x2 == x3. That register-by-register
trace is worth keeping — in the final lesson this same little model becomes the golden
reference our pipelined hardware is checked against, retirement by retirement.
The bill: one clock to fit the worst walk
Now the accounting. The clock can only tick when the longest combinational path has
settled — and different instructions take very different walks. Rough, plausible numbers for our
five stations:
| Station | Delay | ADD uses | BEQ uses | LW uses |
| Instruction memory | 250 ps | ✓ | ✓ | ✓ |
| RF read + decode | 150 ps | ✓ | ✓ | ✓ |
| ALU | 200 ps | ✓ | ✓ | ✓ |
| Data memory | 250 ps | — | — | ✓ |
| WB mux + RF setup | 50 ps | ✓ | — | ✓ |
ADD finishes its walk in 650 ps; BEQ in 600. But LW strolls
through every station: 250 + 150 + 200 + 250 + 50 = 900 ps. The clock
must accommodate it:
T_{clk} \ge 900\ \text{ps} \quad\Rightarrow\quad f_{max} \approx 1.1\ \text{GHz}
Every ADD now spends 250 ps of its cycle doing nothing — finished, waiting for a clock
edge sized for someone else's journey. That idle time, multiplied by billions of instructions, is
the price of the single-cycle design's simplicity. Cutting the walk into stations that tick
independently is the pipelining idea, two lessons from now — and this table is exactly the
arithmetic that will justify it.
"Every instruction completes in one cycle" sounds like a performance boast — surely CPI
= 1 is ideal? The catch is that not all cycles are equal: the single-cycle machine achieves CPI
= 1 by stretching the cycle to fit its worst instruction's full path, so every
instruction pays LW's 900 ps toll. A pipelined design with the same silicon runs its
clock ~4–5× faster; even paying occasional multi-cycle penalties, it wins by miles. When you
evaluate any processor, multiply all three factors — instructions × cycles-per-instruction ×
seconds-per-cycle — never the first two alone. Single-cycle optimises the middle factor
by sacrificing the third.
Almost — the very first microprocessors came close out of necessity, and small FPGA softcores and
teaching chips still do. But the organisation's true home is as a reference. RISC
pioneers used the single-cycle machine as the baseline against which the pipelined MIPS and SPARC
designs were argued in the 1980s, and textbooks have taught it first ever since — not because you
would ship it, but because it is the only CPU organisation simple enough to hold complete in your
head. That is also why our verification story (lesson 40) trusts it: a design simple enough to be
obviously right is the perfect referee for one that is fast but hairy. Simplicity is not a
stepping stone; it is an asset.