Opening: from an invented mini instruction set to a REAL one
Lesson 2 built a Toy CPU running an INVENTED mini instruction set β each
instruction a convenient JavaScript object like { op: 'ADD', rd: 2, rs1: 0, rs2: 1 }. Easy to
read, but NO real CPU runs JavaScript objects β every real CPU understands nothing but BINARY NUMBERS.
This lesson replaces that mini set with RV32I β the real 32-bit RISC-V instruction set,
running today on billions of embedded chips and laptops β and builds a
single-cycle datapath to execute it.
1. The RISC-V instruction set (RV32I)
Every RV32I instruction is exactly 32 bits, fixed (a RISC hallmark β Lesson 2 compared this against CISC's variable length). But those 32 bits get DIVIDED into fields in different ways depending on the instruction format β the RISC-V specification defines 6 formats, named R, I, S, B, U and J. This lesson uses the three most common:
| Format | Bit structure (31β0) | Used for |
|---|---|---|
| R-type | funct7[7] rs2[5] rs1[5] funct3[3] rd[5] opcode[7] | ADD, SUB, AND, OR, XOR β both operands are registers |
| I-type | imm[12] rs1[5] funct3[3] rd[5] opcode[7] | ADDI, ANDI, ORI, XORI, LW β the second operand is a constant (an immediate) |
| S-type | imm[7] rs2[5] rs1[5] funct3[3] imm[5] opcode[7] | SW β needs an ADDRESS (rs1+imm) and a value to store (rs2), so there is no room for rd |
Why do ADD and SUB share an opcode without getting confused? They use the same R-type
opcode (0110011) but different funct7 β a 7-bit
"sub-code" acting as a switch that separates instructions in the same family. The top bit of
funct7 is effectively the "subtract flag": 0000000 for ADD,
0100000 for SUB.
R-type: funct7[31:25] rs2[24:20] rs1[19:15] funct3[14:12] rd[11:7] opcode[6:0]
I-type: imm[31:20] rs1[19:15] funct3[14:12] rd[11:7] opcode[6:0]
S-type: imm[31:25] rs2[24:20] rs1[19:15] funct3[14:12] imm[11:7] opcode[6:0]
# Real example: ADD x3, x1, x2 -> 0000000 00010 00001 000 00011 0110011
# funct7 rs2 rs1 f3 rd opcode
# (=0) (=2) (=1) (0) (=3) (R-type)
2. Assembling Assembly into machine code
One word will appear constantly from here on, so let us pin it down:
mnemonic is the NAME humans give an instruction so they never have to read the number.
ADD is a mnemonic; the number 0x2081b3 is what the CPU actually receives. The
relationship is like a person's name and their ID number: both identify the same thing, but one is for
people and one is for machine lookup.
Two examples to fix the boundary. ADD is a mnemonic β it does not exist inside the chip at
all, only in the assembly file you write and in the assembler's lookup table. But
funct7 = 0100000 is not a mnemonic β those are 7 real bits inside the instruction,
the thing the circuitry actually sees. Everything in this section is just converting the first into the
second.
Assembling is a MECHANICAL mapping: look up mnemonic β opcode/funct3/funct7, then pack the fields into
their bit positions. Verified with the engine itself (not derived by hand):
ADD x3, x1, x2 assembles to exactly 0x2081b3; changing ONLY funct7 (keeping the
same operands) for SUB x3, x1, x2 gives 0x402081b3 β the sole difference sits in
the top 7 bits, exactly as expected.
function assembleRV32I(mnemonic, args) {
const info = RV32I_MNEMONIC[mnemonic]; // table lookup: {type, funct3, funct7}
if (info.type === 'R') {
return ((info.funct7 << 25) | (args.rs2 << 20) | (args.rs1 << 15)
| (info.funct3 << 12) | (args.rd << 7) | RV32I_OPCODE.R) >>> 0;
}
// ... I-type and S-type do the same, packing fields at their own bit offsets ...
}
// Verified: assembleRV32I('ADD', {rd:3,rs1:1,rs2:2}) === 0x2081b3
// Verified: assembleRV32I('SUB', {rd:3,rs1:1,rs2:2}) === 0x402081b3 (only funct7 differs)
Decoding is the REVERSE: split the bits at their known positions, then look (funct3, funct7) back up to recover the mnemonic. Verified by a round trip: assembling and then re-decoding all 11 mnemonics the engine supports (ADD/SUB/AND/OR/XOR/ADDI/ANDI/ORI/XORI/LW/SW) returns EXACTLY the original mnemonic every time β no instruction goes missing through the assemble-decode loop.
function decodeRV32I(word) {
const opcode = word & 0x7f; // the lowest 7 bits are ALWAYS the opcode
const rd = (word >>> 7) & 0x1f;
const funct3 = (word >>> 12) & 0x7;
const rs1 = (word >>> 15) & 0x1f;
const rs2 = (word >>> 20) & 0x1f;
const funct7 = (word >>> 25) & 0x7f;
if (opcode === RV32I_OPCODE.R) {
// Reverse lookup: which R-type entry matches BOTH funct3 and funct7?
const mnemonic = Object.keys(RV32I_MNEMONIC).find(
(m) => RV32I_MNEMONIC[m].type === 'R'
&& RV32I_MNEMONIC[m].funct3 === funct3
&& RV32I_MNEMONIC[m].funct7 === funct7
);
return { mnemonic, type: 'R', rd, rs1, rs2 };
}
// ... I-type, ILOAD and S-type follow the same shape ...
}
// Verified: 11/11 mnemonics round-trip correctly (assemble, then decode back to the
// SAME mnemonic).
JMP addr where
addr is an ABSOLUTE address (e.g. "jump to memory cell 4"). REAL RISC-V encodes its jump
and branch instructions (B-type and J-type, outside this lesson's R/I/S encoder) as an offset
RELATIVE to the PC (e.g. "jump back 8 bytes from here"). The reason: a program can be
loaded into ANY region of memory (relocatable code) without having to rewrite the jump addresses inside
it. Confusing the two is a classic mistake when reading real machine code β the same offset number can
mean something completely different depending on the instruction format.
3. Designing a single-cycle datapath
This section introduces a new hardware block, so let us name it first: the
Register File is the whole cabinet holding the CPU's 32 registers, sitting INSIDE the
chip. In Lesson 2 it was the state.regs array; on real silicon it is a circuit block with 2
read ports and 1 write port, which is why an instruction like ADD x3, x1, x2 can read
x1 and x2 SIMULTANEOUSLY and write x3 within the same cycle.
Do not confuse the Register File with memory (RAM). The Register File is tiny (32 slots) but sits right
inside the core with instant access; RAM is billions of times larger but sits outside, and only two
instructions β LW and SW β are allowed to touch it. That is exactly why every
RISC-V arithmetic operation works ON registers, rather than directly on a memory cell the way x86 did in
Lesson 2.
A single-cycle datapath is the "physical route" data takes through the CPU: the instruction is fetched
from RAM β decoded to find which registers to read β flows through the ALU (Lesson 1, reused directly rather than rewritten) to compute a result or an address β (for LW/SW) touches data
memory β and the result is written back to the Register File. The ENTIRE journey happens within EXACTLY
ONE clock cycle β which is why the CPU clock period must be long enough to contain the SLOWEST instruction
(usually LW, which must pass through both the ALU for address computation AND memory).
ADD (which only needs fetch + decode + ALU)
is "held" for that same long interval, despite being able to finish much sooner. This is precisely the
motivation for pipelining in Lesson 4 (coming soon) β break the work into stages so
each stage can have a shorter clock, instead of forcing every instruction to the pace of the slowest
one.
Verified with a REAL RV32I program (not Lesson 2's mini set): computing $(5+3)-2$ with exactly 5
ADDI/ADD/ADDI/SUB instructions, storing the result into mem[100] with SW, then reading it
back with LW β running through executeRV32I() gives exactly $x_3=8$, $x_5=6$,
mem[100]=6, and reads back exactly $x_6=6$. The ADD/SUB inside call Lesson 1's
aluExecute() DIRECTLY β addition and subtraction are not written a second time.
const program = [
assembleRV32I('ADDI', { rd: 1, rs1: 0, imm: 5 }), // x1 = 5
assembleRV32I('ADDI', { rd: 2, rs1: 0, imm: 3 }), // x2 = 3
assembleRV32I('ADD', { rd: 3, rs1: 1, rs2: 2 }), // x3 = x1+x2 = 8
assembleRV32I('ADDI', { rd: 4, rs1: 0, imm: 2 }), // x4 = 2
assembleRV32I('SUB', { rd: 5, rs1: 3, rs2: 4 }), // x5 = x3-x4 = 6
assembleRV32I('SW', { rs1: 0, rs2: 5, imm: 100 }), // mem[100] = x5
assembleRV32I('LW', { rd: 6, rs1: 0, imm: 100 }), // x6 = mem[100]
];
const { regs, mem } = runRV32IProgram(program);
// Verified: regs[3]=8, regs[5]=6, mem[100]=6, regs[6]=6
4. Hands-on: visualising the RISC-V single-cycle datapath
Type an assembly instruction (e.g. ADD x3, x1, x2 or LW x5, 8(x2)), press
"Assemble" to see the REAL 32-bit breakdown, then press "Execute" to run it through the datapath β the
active block lights up orange:
ADD x3, x1, x2 computes $0 + 0 = 0$ β the register panel still reports "every register is
0" and it looks as though the demo is not working. It IS working: the blocks still light up. To see
numbers change you first have to load values into registers, and the only instruction that can do that
is ADDI (add a constant to x0, the register that is permanently zero by RISC-V
specification):
ADDI x1, x0, 5β Assemble β Execute. The register panel showsx1=5.ADDI x2, x0, 3β Assemble β Execute. Showsx1=5 x2=3.ADD x3, x1, x2β Assemble β Execute. Showsx1=5 x2=3 x3=8.
32-bit machine code (bit breakdown):
Registers (non-zero):
Summary
- β RV32I: every instruction is a fixed 32 bits, split into fields by format β R/I/S-type here.
- β Verified: ADD x3,x1,x2 = 0x2081b3, SUB x3,x1,x2 = 0x402081b3 β differing only in the 7 funct7 bits.
- β Verified: the assembleβdecode round trip returns the right mnemonic for all 11 instructions the engine supports.
- β Pitfall: absolute addresses (Lesson 2's Toy CPU) are nothing like the PC-relative addressing real RISC-V jumps use.
- β Single-cycle datapath: EVERY instruction completes in 1 cycle, so the clock is stretched to the SLOWEST instruction (LW) β the direct motivation for pipelining in Lesson 4.
- β Verified: a real RV32I program computing (5+3)-2 runs correctly through the datapath, reusing Lesson 1's ALU directly.
Review quiz
Question 1
Verified: ADD x3,x1,x2 = 0x2081b3 and SUB x3,x1,x2 = 0x402081b3 (same operands). Why do two different instructions differ only in their top bits?
Question 2
Why do real RISC-V jump and branch instructions encode their target RELATIVE to the PC, rather than absolutely the way Lesson 2's Toy CPU did?
Question 3
In a single-cycle datapath design, why must the CPU clock period be as long as the SLOWEST instruction (usually LW)?
Download the practice code for this lesson
The CPUJS JavaScript file β a mini computer-architecture library used across all 12
lessons. Lesson 3 adds assembleRV32I(), decodeRV32I(),
executeRV32I() and runRV32IProgram() β a real RV32I encoder/decoder plus a
single-cycle executor, with a self-test that checks every number quoted in this lesson (run
node cpu-core.js; nothing to install):
π References
- Official specification: RISC-V International β Technical Specifications β the source document defining the RV32I R/I/S/B/U/J-type encodings.
- Core textbook: Computer Organization and Design, RISC-V Edition (Patterson & Hennessy) β Chapters 2β4 cover instruction encoding and the single-cycle datapath in detail.
- Quick reference: WikiChip β RISC-V Instruction Set β a complete opcode/funct3/funct7 lookup table.
Comments