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.


πŸ“š Prerequisites
Read Lesson 2 (the Fetch-Decode-Execute cycle) and Lesson 1 (the ALU β€” this lesson's datapath calls aluExecute() directly).

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.

rv32i_bit_layout.txt (bit field layout, MSB→LSB)
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.

assemble_rv32i.js (excerpt from the shared cpu-core.js engine)
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.

decode_rv32i.js (excerpt from the shared cpu-core.js engine)
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).
⚠️ Pitfall: absolute addresses vs PC-relative addresses
Lesson 2's Toy CPU used 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).

⚠️ Pitfall: assuming every instruction costs the same time on real silicon
Because a single-cycle design forces ALL instructions to complete within one clock period of FIXED length, that period has to be as long as the SLOWEST instruction (LW: fetch + decode + ALU address computation + memory access) β€” so even a simple 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.

datapath_verified_program.js (the program verified in the self-test)
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:

πŸ’‘ Try these three steps first
Every register starts at 0, so pressing "Execute" straight away on the default 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):
  1. ADDI x1, x0, 5 β†’ Assemble β†’ Execute. The register panel shows x1=5.
  2. ADDI x2, x0, 3 β†’ Assemble β†’ Execute. Shows x1=5 x2=3.
  3. ADD x3, x1, x2 β†’ Assemble β†’ Execute. Shows x1=5 x2=3 x3=8.
Those are the first 3 instructions of the program you just saw in Section 3, this time typed by hand.
πŸ“Ÿ RISC-V single-cycle datapath β€” Assemble & Execute

32-bit machine code (bit breakdown):

β€”

Registers (non-zero):

(every register is 0)
Initialising…
RAM (code) Fetch/Decode Register File ALU (Lesson 1) Data memory (LW/SW touch memory β€” ADD/SUB stop at the ALU)

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):

Download cpu-core.js

πŸ“– References

Related lessons in this series

Lesson 2: The Von Neumann Architecture & the ISA Lesson 4: CPU Pipelining & Data Hazards Back to the Computer Architecture roadmap

Comments