Planning a CPU: the RV32I Subset

Five modules ago you wrote your first assign; last module you followed a design all the way to packaged silicon. Time to spend that skill on the classic rite of passage: building a CPU. Over the next six lessons we will design, in RTL and in a growing TypeScript model, a processor that fetches real 32-bit RISC-V instructions, executes them, and — by the end — runs them five at a time in a pipeline and catches its own bugs. But no wires yet. Every successful chip project starts the same way: nail down the contract first. Ours is a clean ten-instruction subset of RV32I, the RISC-V base integer instruction set.

The contract: ten instructions

A real RV32I core implements 47 instructions; ten are enough to compute anything and to expose every interesting design problem — arithmetic, memory, and control flow:

InstructionFormatMeaningExample
ADDRrd = rs1 + rs2add x3, x1, x2
SUBRrd = rs1 − rs2sub x4, x1, x2
ANDRrd = rs1 & rs2and x5, x1, x2
ORRrd = rs1 | rs2or x6, x1, x2
ADDIIrd = rs1 + immaddi x1, x0, 5
LWIrd = mem[rs1 + imm]lw x5, 8(x2)
SWSmem[rs1 + imm] = rs2sw x5, 12(x2)
BEQBif rs1 == rs2, pc += immbeq x1, x3, loop
JALJrd = pc + 4; pc += immjal x1, func
LUIUrd = imm << 12lui x7, 0x12345

Why RISC-V and not something homemade? Three reasons. It is open — no licence, no permission, which is why it has become the lingua franca of computer architecture courses and a thousand startups. Its encodings are ruthlessly regular, which will make our decoder almost embarrassingly small. And it has real toolchains: the moment our CPU works, an off-the-shelf compiler can generate programs for it. A homemade ISA gets you none of that — and teaches you less, because its warts are yours alone.

The registers — and the strange case of x0

RV32I gives software 32 registers, x0 through x31, each 32 bits wide. Thirty-one of them are ordinary storage. The first is not: x0 is hardwired to zero. Reads always return 0; writes disappear without a trace. This one odd register is a quiet masterstroke — it gives the ISA a free constant zero, and it turns missing instructions into pseudo-instructions: addi x5, x0, 7 is "load constant 7", add x5, x1, x0 is "copy x1", jal x0, target is "jump and discard the return address", and addi x0, x0, 0 is the official NOP. We will meet x0 again in every single lesson of this module, usually as a bug it either prevents or causes.

Six formats, fixed field positions

Every RV32I instruction is exactly 32 bits, built from the same few fields. Reading the columns right to left (bit 0 is on the right):

FormatBits 31 … 2524 … 2019 … 1514 … 1211 … 76 … 0Used by
Rfunct7rs2rs1funct3rdopcodeADD SUB AND OR
Iimm[11:0]rs1funct3rdopcodeADDI LW
Simm[11:5]rs2rs1funct3imm[4:0]opcodeSW
Bimm[12|10:5]rs2rs1funct3imm[4:1|11]opcodeBEQ
Uimm[31:12]rdopcodeLUI
Jimm[20|10:1|11|19:12]rdopcodeJAL

Stare at the columns, not the rows: rs1 is always bits 19:15, rs2 always 24:20, rd always 11:7, opcode always 6:0. No format ever moves them. That is the regularity our decoder will cash in: the register-file read addresses can be wired straight out of the instruction word before we even know which instruction it is. The price is paid by the immediates, which get scrambled (S splits its immediate around rs2; B and J interleave bits) precisely so the register fields never move. B and J immediates are also in multiples of 2 — the low bit is implicit — buying double the branch reach for free.

Because hardware pays for muxes, not for weird-looking documentation. The RISC-V architects chose the scrambling so that each immediate bit comes from as few different instruction-word positions as possible across all formats — the sign bit, for instance, is always bit 31, so sign extension never waits for format decoding. A human assembler finds imm[4:1|11] ugly; a decoder finds it nearly free. It is a beautiful example of an ISA being co-designed with the datapaths that will implement it — the spec looks strange on paper and elegant in gates. The scrambling costs software nothing: assemblers do the shuffling, and no human ever encodes a branch by hand. Well — except us, in the code block below.

The shape of the machine to come

Here is the whole project on one napkin. Every instruction, whatever its format, will take the same walk: the pc fetches a word from instruction memory, decode splits it into fields, the register file supplies rs1/rs2, the ALU computes, data memory is touched if needed, and the result loops back — into rd and into the next pc:

The module plan follows that drawing. Next lesson builds the register file; then we close the whole loop as a single-cycle CPU, give it a proper control unit, pipeline it five deep, fix the hazards that pipelining unleashes, and finally verify the whole thing the way real CPU teams do.

The commonest beginner mistake in this project is to start drawing hardware while the instruction list is still wobbling. Resist it. An ISA says what must happen — "after ADD, rd holds the sum" — and says nothing about how. The same ten-instruction contract we just signed will be implemented three different ways in this very module: as a functional TypeScript model, as a single-cycle datapath, and as a five-stage pipeline. A phone core, a laptop core and a supercomputer core implementing RV32I share not one wire of design — yet run the same binaries. That separation is the whole economic miracle of the ISA: software outlives any particular silicon. Nail the contract; then design. (It is also why the contract will be our referee in the final lesson: any implementation can be checked against a simpler one.)

First tool: an encoder and decoder

Our first deliverable is not hardware but tooling — a tiny assembler and disassembler we will reuse in every lesson of this module. Encoding is just fields shifted into place and OR'd together; decoding is the reverse. Run it, then try encoding your own instruction — change a register or an immediate and check the hex word moves the way the format table predicts:

// ── Encoders: shift each field into its slot and OR them together. ── 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 S(f3: number, rs2: number, rs1: number, imm: number): number { return ((((imm >> 5) & 0x7f) << 25) | (rs2 << 20) | (rs1 << 15) | (f3 << 12) | ((imm & 0x1f) << 7) | 0x23) | 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 U(op: number, rd: number, imm20: number): number { return ((imm20 << 12) | (rd << 7) | op) | 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; } // ── Our ten-instruction subset, as a tiny assembler. ── 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), AND: (rd: number, rs1: number, rs2: number) => R(0x00, 7, rd, rs1, rs2), OR: (rd: number, rs1: number, rs2: number) => R(0x00, 6, rd, rs1, rs2), ADDI: (rd: number, rs1: number, imm: number) => I(0x13, 0, rd, rs1, imm), LW: (rd: number, rs1: number, imm: number) => I(0x03, 2, rd, rs1, imm), SW: (rs2: number, rs1: number, imm: number) => S(2, rs2, rs1, imm), BEQ: (rs1: number, rs2: number, imm: number) => B(0, rs1, rs2, imm), JAL: (rd: number, imm: number) => J(rd, imm), LUI: (rd: number, imm20: number) => U(0x37, rd, imm20), }; // ── Decoder: pull the fixed-position fields first, THEN ask the opcode what they mean. ── interface Decoded { name: string; fmt: string; opcode: number; rd: number; f3: number; rs1: number; rs2: number; f7: 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; // >> sign-extends bit 31 down 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 immU = (w >> 12) << 12; const immJ = ((w >> 31) << 20) | (((w >> 12) & 0xff) << 12) | (((w >> 20) & 1) << 11) | (((w >> 21) & 0x3ff) << 1); let name = "??", fmt = "?", imm = 0; if (opcode === 0x33) { fmt = "R"; name = f3 === 0 ? (f7 === 0x20 ? "SUB" : "ADD") : f3 === 7 ? "AND" : "OR"; } else if (opcode === 0x13) { fmt = "I"; name = "ADDI"; imm = immI; } else if (opcode === 0x03) { fmt = "I"; name = "LW"; imm = immI; } else if (opcode === 0x23) { fmt = "S"; name = "SW"; imm = immS; } else if (opcode === 0x63) { fmt = "B"; name = "BEQ"; imm = immB; } else if (opcode === 0x6f) { fmt = "J"; name = "JAL"; imm = immJ; } else if (opcode === 0x37) { fmt = "U"; name = "LUI"; imm = immU; } return { name, fmt, opcode, rd, f3, rs1, rs2, f7, imm }; } // ── Round trip: assemble, print the word, take it apart again. ── const hex = (w: number) => "0x" + (w >>> 0).toString(16).padStart(8, "0"); const program: [string, number][] = [ ["addi x1, x0, 5", asm.ADDI(1, 0, 5)], ["addi x2, x0, -3", asm.ADDI(2, 0, -3)], ["add x3, x1, x2", asm.ADD(3, 1, 2)], ["sub x4, x1, x2", asm.SUB(4, 1, 2)], ["lw x5, 8(x1)", asm.LW(5, 1, 8)], ["sw x5, 12(x1)", asm.SW(5, 1, 12)], ["beq x1, x2, -8", asm.BEQ(1, 2, -8)], ["jal x1, 2048", asm.JAL(1, 2048)], ["lui x6, 0x12345", asm.LUI(6, 0x12345)], ]; console.log("assembly | word | name fmt | rd rs1 rs2 | imm"); console.log("--------------------+------------+----------+------------+------"); for (const [src, w] of program) { const d = decode(w); console.log( `${src.padEnd(19)} | ${hex(w)} | ${d.name.padEnd(4)} ${d.fmt} | ` + `${String(d.rd).padStart(2)} ${String(d.rs1).padStart(2)} ${String(d.rs2).padStart(2)} | ${d.imm}`, ); }

Two details worth savouring in the output. The word for addi x1, x0, 5, 0x00500093, is the exact bit pattern a real RISC-V compiler emits — our toy speaks the genuine language. And look at the raw rd/rs2 columns for the store and branch rows: the decoder pulls fields from fixed positions first and only then asks the opcode what they mean — for SW the "rd" bits are really immediate bits. Fixed positions, format-dependent meaning: that is the RISC-V decode idiom, and it is exactly how our hardware decoder will work in two lessons' time.