RISC-V: Registers and Encoding

Enough philosophy — let's open the hood of a real, modern, gloriously clean ISA. RISC-V (pronounced "risk-five") is an open, royalty-free RISC instruction set designed at Berkeley in 2010 and now used everywhere from tiny microcontrollers to Google's data centres. Because it was designed from scratch, decades after the RISC/CISC dust settled, it is the tidiest ISA to learn — every decision is deliberate and every instruction is exactly 32 bits wide. In this lesson we count its registers and then take a single instruction apart, bit by bit.

Thirty-two registers, and a very special zero

The base RISC-V integer ISA gives you 32 general-purpose registers, named x0x31, each 32 bits wide on RV32 (64 on RV64). Because there are 32 of them, a register needs exactly \log_2 32 = 5 bits to name — a number worth memorising, because those 5-bit fields are all over the encoding.

The standout is x0. It is hardwired to zero: reads always return 0, and writes are silently discarded. This one trick eliminates a surprising amount of special-case hardware. Want to copy a register? add x5, x6, x0. Want a no-op? add x0, x0, x0. Want to load a constant? Add it to x0. A single hardwired zero turns dozens of "missing" instructions into ordinary arithmetic.

Six instruction formats, one width

Every RISC-V instruction is 32 bits, but those bits are carved up in one of six formats, chosen so that the same field almost always lands in the same place — which keeps the decoder trivial.

FormatUsed forExample
R (register)register–register arithmeticadd x3, x1, x2
I (immediate)ops with a 12-bit constant; loadsaddi x3, x1, 42, lw x3, 8(x1)
S (store)stores (split immediate)sw x2, 8(x1)
B (branch)conditional branches (PC-relative)beq x1, x2, label
U (upper)20-bit upper immediateslui x3, 0x12345
J (jump)jump-and-linkjal x1, label

The genius is the shared layout: the destination rd is always in bits [11:7] when it exists; rs1 is always in [19:15]; rs2 is always in [24:20]; the opcode is always in [6:0]. The decoder can pull those fields out before it even knows the format, then use the opcode to decide what the remaining bits mean. That regularity is exactly the RISC payoff.

Anatomy of an R-type word

Let's dissect the workhorse format, R-type, used for register-to-register ops like add, sub, and, or, sll. Its 32 bits split into six fields:

So add and sub differ by a single bit up in funct7 — bit 30, to be exact. Elegant.

Encode and decode it yourself

An R-type instruction is nothing but its six fields shifted into position and OR-ed together:

\text{word} = (\text{funct7} \ll 25)\,|\,(\text{rs2} \ll 20)\,|\,(\text{rs1} \ll 15)\,|\,(\text{funct3} \ll 12)\,|\,(\text{rd} \ll 7)\,|\,\text{opcode}.

Decoding just reverses it — shift each field down and mask off the right number of bits. The code below encodes add x3, x1, x2 into its 32-bit word, prints it in hex and binary, then decodes it straight back to prove the round-trip.

// RISC-V R-type: funct7[31:25] rs2[24:20] rs1[19:15] funct3[14:12] rd[11:7] opcode[6:0] const OPCODE_R = 0b0110011; // 51, all register-register ops const F3_ADDSUB = 0b000; const F7_ADD = 0b0000000; const F7_SUB = 0b0100000; // 32; the one bit that flips add -> sub function encodeR(funct7: number, rs2: number, rs1: number, funct3: number, rd: number, opcode: number): number { // >>> 0 forces an unsigned 32-bit result. return ((funct7 << 25) | (rs2 << 20) | (rs1 << 15) | (funct3 << 12) | (rd << 7) | opcode) >>> 0; } function decodeR(word: number) { return { opcode: word & 0x7f, // bits [6:0] rd: (word >>> 7) & 0x1f, // bits [11:7] funct3: (word >>> 12) & 0x7, // bits [14:12] rs1: (word >>> 15) & 0x1f, // bits [19:15] rs2: (word >>> 20) & 0x1f, // bits [24:20] funct7: (word >>> 25) & 0x7f, // bits [31:25] }; } // Encode: add x3, x1, x2 const word = encodeR(F7_ADD, 2, 1, F3_ADDSUB, 3, OPCODE_R); console.log("add x3, x1, x2"); console.log("hex : 0x" + word.toString(16).toUpperCase().padStart(8, "0")); console.log("bin : " + word.toString(2).padStart(32, "0")); // Decode it back and confirm every field. const f = decodeR(word); console.log(`decoded -> rd=x${f.rd}, rs1=x${f.rs1}, rs2=x${f.rs2}, funct3=${f.funct3}, funct7=${f.funct7}, opcode=${f.opcode}`); // Flip one field: sub x3, x1, x2 differs only in funct7. const subWord = encodeR(F7_SUB, 2, 1, F3_ADDSUB, 3, OPCODE_R); console.log("sub differs from add only in funct7 -> 0x" + subWord.toString(16).toUpperCase().padStart(8, "0"));

RISC-V deliberately does not use every possible opcode. The base integer set is small, and vast regions of the encoding are reserved. That looks wasteful until you remember what an ISA is: a contract meant to last decades. The reserved space is where the standard extensions live — M (multiply/divide), F/D (floating point), A (atomics), V (vectors) — plus room for custom accelerator instructions a company can add without breaking anyone. A too-dense encoding is a prison; RISC-V left itself room to grow. That foresight is a big part of why it spread so fast.

Beginners often say "the opcode of add is add's whole bit pattern". Not in RISC-V. The opcode is only bits [6:0], and it is the same (51) for add, sub, and, or, xor, and every other R-type op. What actually picks the operation is the combination opcode + funct3 + funct7. Think of opcode as the family surname and funct3/funct7 as the first name. Treating funct3/funct7 as "part of the opcode" will make your decoder — and your exam answer — wrong.

Why fixed 32-bit encoding pays off

Because every instruction is one aligned 32-bit word, the fetch unit always knows the next instruction sits at \text{PC} + 4 — no need to decode the current one first (contrast x86, where you must parse an instruction just to find where the next one starts). Fields live in fixed places, so the decoder can read register names in parallel with figuring out the opcode. This is the regularity RISC promised, cashed out as a short, fast, easily-widened pipeline front end. With the encoding understood, the next lessons build on it: how instructions name memory, and how they call procedures.