Back to Blog

Designing a Tiny ISA: Instruction Formats for ADD, SUB, AND, OR, LW, SW, BEQ

August 18, 202623 min read
Computer Architecture ISA CPU Design Learning

Lesson 1 ended with a promise: take a tiny seven-instruction ISA — ADD SUB AND OR LW SW BEQ — and actually build the hardware that executes it, cycle by cycle, control signals and all. This is where that promise starts getting cashed in. Before we can wire up a single logic gate, though, we need to answer a more basic question: what do these seven instructions actually look like as bits? That's the whole subject of this post.

Why Every Instruction Is Exactly 32 Bits

The first design decision, and arguably the one that shapes everything downstream, is that every instruction in our ISA is exactly 32 bits wide — no more, no less, regardless of whether it's a three-register ADD or a BEQ carrying a branch offset. This is the classic RISC choice, and it's worth being explicit about why, because "fixed width" is not free — it's a tradeoff, and we're choosing one side of it deliberately.

Consider what the CPU has to do on every single instruction, every single cycle: fetch some bits from instruction memory at address PC, and then figure out what those bits mean. If every instruction is the same width, fetch is trivial — read exactly 32 bits (one word) starting at PC, full stop. The fetch unit never has to ask "how many bytes should I read this time?" There's no chicken-and-egg problem where you need to partially decode an instruction just to know how many more bytes to fetch before you can finish decoding it.

Contrast that with a variable-length encoding, the kind Lesson 1 touched on when discussing RISC-V's compressed extension and ARM's Thumb mode: there, a 16-bit instruction and a 32-bit instruction can sit back-to-back in memory, and the fetch unit has to inspect the first few bits of what it just read to know whether it grabbed a whole instruction or only half of one. That buys you real code-density wins — fewer bytes fetched from memory overall — but it pushes real complexity into the front end of the pipeline: variable-width PC increments, instructions that can straddle a cache-line or fetch-buffer boundary, and a decoder that has to be at least partially aware of instruction length before it's finished decoding. We are not building that CPU. We're building the simplest possible correct machine first, and fixed-width 32-bit encoding is what makes "simplest possible" actually achievable in hardware you can draw on a whiteboard.

There's a second reason 32 bits specifically, beyond "fixed is simpler than variable": it matches our datapath width. This ISA has 32-bit registers and a 32-bit ALU (we'll build both in Part 2), so a 32-bit instruction word means one instruction fetch is exactly one memory-word read — no alignment gymnastics, no stitching two reads together. The instruction memory, the register file, the ALU, and the instruction word itself are all the same width, and that uniformity is going to make almost every diagram in this lesson simpler than it would otherwise be.

One more consequence falls out of fixed-width encoding immediately: sequential control flow always advances the Program Counter by exactly 4 bytes (one 32-bit word), never anything else. We saw the PC as a register construct back in Lesson 1 — its job is to hold the address of the current instruction and feed the next one, either PC + 4 for straight-line code or a computed branch target. With every instruction exactly 4 bytes, that + 4 is a hardware constant, not a value that has to be derived from decoding. That's another decode-time question the fixed-width choice simply deletes.

Takeaway: Fixed-width 32-bit instructions trade code density for decode simplicity — one fetch is always exactly one word, and PC always advances by a hardware-constant 4 bytes on straight-line code. Variable-length ISAs (x86, Thumb, RISC-V "C") make the opposite trade, and pay for it with a fetch/decode front end that has to discover instruction length before it can fully decode.

By convention — the actual convention used by real MIPS toolchains and simulators — our program's instructions live starting at address 0x00400000, the traditional MIPS text-segment base. So the PC doesn't start at 0; it starts at 0x00400000, and every subsequent instruction sits 4 bytes after the last one: 0x00400004, 0x00400008, and so on. We'll use these exact addresses later in this post when we hand-encode a branch instruction, because branch target math is only concrete once you have real addresses to plug in.


Two Shapes for Seven Instructions: R-Type and I-Type

Here's the program we'll be tracing through this entire lesson — it's worth looking at once, in full, before we start slicing individual instructions into bit fields. We'll come back to this exact program in Part 4, when we trace it cycle-by-cycle through a finished datapath.

; Initial state: R1 = 10, R2 = 20, R4 = 100 (base address), Mem[100] = 0
; Instructions live at 0x00400000, 0x00400004, 0x00400008, ...
 
0x00400000: ADD  R3, R1, R2        # R3 = 30
0x00400004: SW   R3, 0(R4)         # Mem[100] = 30
0x00400008: LW   R5, 0(R4)         # R5 = 30
0x0040000C: SUB  R6, R5, R1        # R6 = 20
0x00400010: BEQ  R6, R2, 1         # R6==R2 (20==20) -> taken, skip next
0x00400014: ADD  R7, R1, R1        # skipped
0x00400018: OR   R8, R1, R2        # R8 = 30  (branch target)

Look closely at what each instruction actually needs to specify, and a pattern jumps out immediately. ADD R3, R1, R2 needs to name three registers — two sources and a destination — and nothing else. SW R3, 0(R4) needs to name only two registers (a base address register and a data register) plus a numeric offset that isn't a register at all. BEQ R6, R2, 1 also needs two registers plus a number, but the number here means something completely different — a branch offset, not a memory offset.

This is exactly the design tension that produces two instruction formats instead of one. If we tried to force every instruction into a single rigid template — say, always reserving space for three register fields — we'd be wasting bits on every load, store, and branch instruction, which only ever need two registers. If we went the other way and gave every instruction a small immediate field "just in case," the register-only instructions (ADD, SUB, AND, OR) would be stuck with a useless, wasted immediate they never read. Real MIPS-style ISAs — and this is exactly the pedagogical design taught in Patterson and Hennessy's Computer Organization and Design and courses like UC Berkeley's CS61C and MIT's 6.004 — solve this with two coexisting instruction formats, each shaped for the operands its instructions actually need:

  • R-type ("register type"): for instructions whose every operand is a register. ADD, SUB, AND, OR all live here — three register fields, no immediate.
  • I-type ("immediate type"): for instructions that need a numeric constant baked directly into the instruction word. LW, SW, BEQ all live here — two register fields plus one wide immediate field.

Both formats are still exactly 32 bits — that constraint from the previous section doesn't bend. What differs is how those 32 bits get carved up internally. And critically, both formats agree on where the very first field lives: bits 31 down to 26 are always the opcode, in every single instruction, regardless of format. That's what lets the very first thing the CPU does after fetch — look at bits [31:26] — tell it unambiguously which format (and usually which specific instruction) it's looking at, before it has interpreted a single other bit.

Takeaway: Two formats exist because two different operand shapes exist in this ISA — three-register arithmetic versus two-register-plus-constant. Both formats keep the opcode in the identical bit position (31:26) so decode can always start the same way, regardless of which format follows.


Deriving Every Field Width From First Principles

It would be easy to just hand you a table of field widths and move on. But every width in this ISA is the minimum number of bits that does the job — nothing here is an arbitrary round number, and it's worth deriving each one so the bit-layout tables in the next section feel inevitable rather than memorized.

Why the Register Fields Are 5 Bits

This ISA has 32 general-purpose registers, R0 through R31. To name one specific register out of 32 possibilities, you need enough bits to represent 32 distinct patterns. With n bits you can represent 2^n distinct patterns, so we need the smallest n such that 2^n ≥ 32. Since 2^5 = 32 exactly, five bits is the precise answer — not four (2^4 = 16, not enough registers), not six (2^6 = 64, wasteful — you'd be able to address 32 registers that don't exist).

2^4 = 16   -> too few (can't name R16..R31)
2^5 = 32   -> exactly enough (R0..R31, no waste)
2^6 = 64   -> more than needed (32 unused encodings per field)

Every register-naming field in this ISA is therefore exactly 5 bits: rs, rt, and rd. R-type instructions have three such fields (three register operands); I-type instructions have two (rs and rt — I-type instructions never need an rd field, which turns out to matter a lot once we get to control signals below).

Why the Immediate Field Is 16 Bits

The immediate field is where MIPS-style design makes a real tradeoff, and it's worth seeing the arithmetic. LW, SW, and BEQ all need to embed a signed numeric constant directly in the instruction word — a memory offset for loads and stores, a branch offset for BEQ. Lesson 1 Part 1 derived the two's-complement range formula for an n-bit signed integer:

-2^(n-1) ≤ x ≤ 2^(n-1) - 1

For our 8-bit examples back then, that gave -128 ≤ x ≤ 127. Apply the exact same formula with n = 16:

-2^15 ≤ x ≤ 2^15 - 1
-32768 ≤ x ≤ 32767

So a 16-bit signed immediate can represent any offset from -32768 up to 32767 — plenty of range for "how many bytes past this base register" or "how many instructions to jump" in ordinary code, while still leaving room in the 32-bit instruction word for a 6-bit opcode and two 5-bit register fields (6 + 5 + 5 + 16 = 32, using every bit exactly once, no waste, no overflow). This is a genuine design tradeoff, not a free lunch: 16 bits is enough for the vast majority of load/store offsets and nearby branches, but a jump further than about 32 KB away (in either direction) simply cannot be expressed in a single instruction's immediate field. Real MIPS toolchains solve that overflow case with extra instructions (loading a larger constant piecewise, or using the dedicated jump format's wider 26-bit field) — a preview of the kind of "we ran out of bits" problem that recurs constantly in ISA design.

It's also worth flagging why the immediate is sign-extended rather than zero-extended when it's consumed by the ALU or address logic: SW R3, -4(R4) (storing at an address slightly below the base register) has to be expressible, and only a signed interpretation of those 16 bits makes negative offsets meaningful. We'll formalize sign-extension as an actual hardware block (a set of wires, not a computation) when we build the datapath in Part 2.

Why the Opcode Is 6 Bits (and Why R-Type Also Needs funct)

The opcode field is 6 bits in both formats, which gives 2^6 = 64 distinct opcode values — comfortably more than the seven instructions this tiny ISA needs, leaving room for growth (and matching the real MIPS32 opcode space, which uses far more of those 64 slots than we do here). But there's a subtlety specific to R-type instructions that's worth calling out explicitly: all four R-type instructions in this ISA — ADD, SUB, AND, OR — share the exact same opcode, 000000. The opcode alone cannot tell them apart.

That's not an oversight; it's a deliberate use of the R-type format's extra bits. Since R-type instructions don't need an immediate field (all three operands are registers), those 16 bits that I-type spends on an immediate are instead spent, in R-type, on shamt (5 bits — a shift amount, unused and set to zero for our four ALU instructions, but present in the format for future shift instructions like SLL/SRL) and funct (6 bits — the field that actually distinguishes ADD from SUB from AND from OR). So for R-type instructions, decoding is genuinely two-stage: the opcode says "this is some register-register ALU operation," and the funct field says which one. I-type instructions skip this entirely — their opcode alone is unique per instruction, because there's no shared "I-type opcode" the way there's a shared R-type opcode of 000000.

Takeaway: Every field width here is the tightest fit for its job: 5 bits names exactly 32 registers with zero waste, 16 bits gives two's-complement range ±32K (derived from the same formula as Lesson 1's two's-complement math), and 6-bit opcode plus 6-bit funct together disambiguate the four R-type instructions that all share opcode 000000.


The Complete Bit-Layout Tables

With every field width now justified rather than asserted, here are the two complete instruction formats, bit by bit.

R-Type Layout — ADD, SUB, AND, OR

31 26 25 21 20 16 15 11 10 6 5 0 opcode rs rt rd shamt funct (6 bits) (5 bits) (5 bits) (5 bits) (5 bits) (6 bits) 000000 source source destination unused which always operand1 operand2 (=00000) ALU op
FieldBitsWidthMeaning
opcode31:266Always 000000 for R-type
rs25:215First source register
rt20:165Second source register
rd15:115Destination register
shamt10:65Shift amount (unused here — always 00000)
funct5:06Distinguishes ADD/SUB/AND/OR

Semantics for all four: rd ← rs OP rt.

I-Type Layout — LW, SW, BEQ

31 26 25 21 20 16 15 0 opcode rs rt immediate (6 bits) (5 bits) (5 bits) (16 bits) identifies base dest./ sign-extended constant: instruction register source memory offset (LW/SW) or uniquely register branch offset (BEQ)
FieldBitsWidthMeaning
opcode31:266Uniquely identifies LW / SW / BEQ
rs25:215Base register (LW/SW) or first compare register (BEQ)
rt20:165Destination (LW) / source (SW) / second compare register (BEQ)
immediate15:016Sign-extended offset

Semantics:

  • LW: rt ← Mem[rs + signext(imm)]
  • SW: Mem[rs + signext(imm)] ← rt
  • BEQ: if rs == rt then PC ← PC + 4 + (signext(imm) << 2), else PC ← PC + 4

Notice the << 2 in the BEQ semantics — that's not a typo, and it deserves a sentence of its own. Since every instruction is 4 bytes and instructions are always word-aligned, a branch never needs to target an address that isn't a multiple of 4. So instead of storing the raw byte offset in the 16-bit immediate (which would waste the low 2 bits, since they're always 00 for a valid target), MIPS-style branches store the offset measured in instructions, and the hardware shifts it left by 2 (multiplying by 4) to convert back to a byte offset before adding it to the PC. That one shift, done for free in wiring rather than in a full adder, effectively quadruples the reachable branch range for the same 16 stored bits — ±32768 instructions becomes ±131072 bytes of reach. We'll build the actual shift-left-by-2 hardware (it's just a fixed wire relabeling, not a real "shifter" circuit) when we assemble the datapath in Part 2.


R0: A Register That's Always Zero

Here's a small piece of hardware that pays for itself constantly: R0 is not an ordinary general-purpose register. It is hardwired to the constant value 0 — any read of R0 always returns 0, and any attempted write to R0 is silently discarded. This is real MIPS convention (RISC-V does the same thing with its x0), and it's worth understanding why it's genuinely useful rather than just a curiosity.

The most direct payoff is a free move instruction. Suppose you need to copy the value in R1 into R3 — a completely ordinary thing to want to do. Without a dedicated "move" instruction in the ISA (and this tiny seven-instruction ISA doesn't have one), you might expect you'd need new hardware just to support copying a register. You don't. Because R0 always reads as zero:

ADD R3, R1, R0     ; R3 = R1 + 0 = R1  — a "move" for free

This costs nothing extra in hardware — it's literally the same ADD circuit, the same R-type decode path, the same control signals as any other ADD. The "move" behavior falls directly out of the fact that adding zero is the identity operation, combined with the fact that zero is always sitting there, guaranteed, in R0, with no instruction needed to put it there. This is a genuinely load-bearing technique in real RISC ISAs, not a toy example — both MIPS assemblers and RISC-V assemblers define pseudo-instructions (move, mv) that the assembler silently expands into exactly this trick under the hood.

The same idea generalizes past just moves. R0 gives you a free comparison-against-zero (BEQ R6, R0, target branches if R6 == 0, with no dedicated "branch if zero" opcode needed), and a free way to zero out a register (ADD R5, R0, R0 sets R5 = 0). None of these need new instructions or new hardware — they're all the existing ADD/BEQ circuitry, exploiting one guaranteed constant.

Making this work does require one piece of real hardware, though — it isn't automatically true just because you decided register 5 "means" zero. Somewhere in the register file's write-port logic, there has to be a check: if the write address equals 0, suppress the write entirely (or equivalently, gate WriteEnable low whenever WriteAddr == 0). Without that guard, some other instruction that happened to target R0 as its destination register would silently overwrite the constant, and every "free" trick above would quietly break the next time it ran. It's a small circuit — a single 5-input NOR or a comparator against 00000 — but it's the one piece of the register file (built back in Lesson 1) that isn't just "N flip-flops with an address decoder." We'll draw that specific piece of logic when we build the register file into the datapath in Part 2.

Takeaway: R0 hardwired to zero turns "copy a register," "zero a register," and "compare against zero" into free byproducts of instructions that already exist — at the cost of exactly one small guard circuit in the register file's write port that discards any write targeting address 0.


From Format to Control Signals: A Preview

We are not building the datapath in this post — that's Part 2's job. But it's worth pausing here, right after finishing the instruction formats, to notice something: the format split we just designed directly determines what decisions the control hardware has to make. Every one of the canonical control signals from the single-cycle datapath — RegDst, ALUSrc, MemToReg, RegWrite, MemRead, MemWrite, Branch, and the 2-bit ALUOp — exists because some instruction in this ISA needs the datapath to behave differently than some other instruction, and the format is exactly where that difference first becomes visible.

A few concrete examples, so this isn't abstract:

  • RegDst exists because R-type instructions write their result to the register named in the rd field, while I-type's LW writes to the register named in the rt field — same physical wire position in the instruction word, different field, so something has to choose which one feeds the register file's WriteAddr input.
  • ALUSrc exists because R-type instructions want the ALU's second operand to come from the register file (rt's value), while LW/SW want it to come from the sign-extended immediate instead (for address computation). Same ALU, two different places its second input could come from.
  • ALUOp exists because the opcode alone doesn't tell the ALU what to do for R-type instructions — remember, all four share opcode 000000. ALUOp is a coarse 2-bit hint ("this is some R-type ALU op, go check funct" versus "this is a fixed operation like address-add or equality-compare") that feeds a secondary decoder, which also reads the funct field directly, to produce the ALU's real operation selector.

The full truth table connecting instruction bits to each of these eight signals, and the logic that implements it, is exactly what Part 2 builds. What matters right now is seeing why those particular eight signals are the right ones to need: they map one-to-one onto the specific decision points the R-type/I-type split just created. Nothing in this list is arbitrary — each signal exists because two instructions in our seven-instruction ISA disagree about something, and the disagreement always traces back to a formatting choice we made in this post.


Hand-Encoding Three Instructions From the Program

Everything so far has been rules and field widths. Now let's actually apply them — take three real instructions from the worked program and encode each one down to its literal 32-bit pattern, the way an assembler would. We'll do the register-field arithmetic explicitly rather than waving at it.

Register numbers we need, in 5-bit binary:

R1 = 00001     R2 = 00010     R3 = 00011
R4 = 00100     R5 = 00101     R6 = 00110

Instruction 0 — ADD R3, R1, R2

This is R-type. rs = R1, rt = R2, rd = R3, shamt = 0 (unused), funct = 100000 (ADD, 0x20), opcode = 000000 always for R-type.

opcode  rs     rt     rd     shamt  funct
000000  00001  00010  00011  00000  100000

Concatenating all six fields gives the full 32-bit instruction word:

00000000001000100001100000100000

To get the hex form, it's easiest to reconstruct the word from the field values as shifted integers — exactly what a real assembler does internally — rather than eyeballing binary digits:

word = (opcode << 26) | (rs << 21) | (rt << 16) | (rd << 11) | (shamt << 6) | funct
     = (0 << 26) | (1 << 21) | (2 << 16) | (3 << 11) | (0 << 6) | 32
     = 0x00200000 + 0x00020000 + 0x00001800 + 0x00000000 + 0x00000020
     = 0x00221820

ADD R3, R1, R2 encodes to 0x00221820.

Instruction 1 — SW R3, 0(R4)

This is I-type. The syntax SW R3, 0(R4) means "store the value in R3 to the address computed from base register R4 plus offset 0" — so in the encoding, rs = R4 (the base register) and rt = R3 (the register whose value gets stored), immediate = 0, opcode = 101011 (0x2B).

opcode  rs     rt     immediate
101011  00100  00011  0000000000000000

Same shift-and-OR reconstruction:

word = (opcode << 26) | (rs << 21) | (rt << 16) | imm
     = (0x2B << 26) | (4 << 21) | (3 << 16) | 0
     = 0xAC000000 + 0x00800000 + 0x00030000 + 0x00000000
     = 0xAC830000

SW R3, 0(R4) encodes to 0xAC830000.

Notice rs and rt are not interchangeable here even though both are just "5-bit register fields" — rs always means "base/address register" for LW/SW, and rt always means "the data register." Swap them by mistake and you'd be storing R4's value to an address computed from R3 — a completely different (and wrong) instruction that still parses as valid I-type.

Instruction 4 — BEQ R6, R2, 1

This is I-type. rs = R6, rt = R2, immediate = 1 (meaning "branch 1 instruction past the next sequential one"), opcode = 000100 (0x04).

opcode  rs     rt     immediate
000100  00110  00010  0000000000000001
word = (opcode << 26) | (rs << 21) | (rt << 16) | imm
     = (4 << 26) | (6 << 21) | (2 << 16) | 1
     = 0x10000000 + 0x00C00000 + 0x00020000 + 0x00000001
     = 0x10C20001

BEQ R6, R2, 1 encodes to 0x10C20001.

Now let's actually verify the branch target arithmetic using the real addresses from the program, since that's the whole point of encoding a branch correctly. This instruction sits at 0x00400010 (it's the fifth instruction, index 4, and 0x00400000 + 4×4 = 0x00400010). Applying the BEQ semantics field-by-field:

PC              = 0x00400010     (address of this BEQ instruction)
signext(imm)    = 0x00000001     (imm=1, positive, sign-extension is a no-op here)
signext(imm)<<2 = 0x00000004     (shift left 2: convert instruction-offset to byte-offset)
 
target = PC + 4 + (signext(imm) << 2)
       = 0x00400010 + 0x00000004 + 0x00000004
       = 0x00400018

0x00400018 is exactly the address of instruction 6 (OR R8, R1, R2) — the branch target the program comment promised, skipping over instruction 5 (ADD R7, R1, R1, at 0x00400014) entirely. The bits check out.

Takeaway: Encoding an instruction is nothing more than placing each field's numeric value into its assigned bit position — shift-and-OR, exactly as a real assembler does it. There's no hidden magic between assembly syntax and the 32-bit word the CPU actually fetches; it's arithmetic you can do by hand, as we just did three times.


The Instruction Format Cheat Sheet

Every instruction this ISA supports, in one table — this is the reference we'll keep coming back to for the rest of Lesson 2.

MnemonicFormatOpcodeFunctSemantics
ADDR-type000000 (0x00)100000 (0x20)rd ← rs + rt
SUBR-type000000 (0x00)100010 (0x22)rd ← rs − rt
ANDR-type000000 (0x00)100100 (0x24)rd ← rs AND rt
ORR-type000000 (0x00)100101 (0x25)rd ← rs OR rt
LWI-type100011 (0x23)rt ← Mem[rs + signext(imm)]
SWI-type101011 (0x2B)Mem[rs + signext(imm)] ← rt
BEQI-type000100 (0x04)if rs == rt: PC ← PC+4+(signext(imm)<<2), else PC ← PC+4

Seven instructions, two formats, one shared opcode field position. Everything the control unit needs to know to drive the datapath in Part 2 is derivable from this table plus the bit layouts above it.


A Modern Point of Comparison: RISC-V's Encoding

It's worth a brief look sideways at RISC-V, since it's the ISA most systems engineers will actually touch day-to-day, and it makes almost exactly the same design choices for almost exactly the same reasons — with one interesting refinement. RISC-V's R-type format is funct7(7) | rs2(5) | rs1(5) | funct3(3) | rd(5) | opcode(7), and its I-type format is imm(12) | rs1(5) | funct3(3) | rd(5) | opcode(7).

The core idea is identical to what we just built: a small opcode field picks a broad instruction family, register fields are exactly wide enough for the register count (RISC-V also has 32 integer registers, so 5-bit fields, same 2^5 = 32 derivation as ours), and R-type instructions get extra "which-operation" bits (funct3/funct7, split into two pieces rather than MIPS's single funct) beyond what I-type needs, because I-type spends that space on an immediate instead. RISC-V's x0 is also hardwired to zero, for exactly the same free-move-and-free-compare reasons we derived for R0 above.

The one structural difference worth noting: RISC-V keeps the opcode, rd, and funct3 fields in the same bit positions across almost every format (R, I, S, B), specifically so a partial decoder can start extracting rd before it even knows the full instruction type. That's a refinement in service of the exact same goal that motivated our fixed-width choice at the very top of this post — minimizing what decode has to figure out, and how much of it has to happen sequentially, before the datapath can start moving.


Further Reading

We now have every instruction in this ISA reduced to exact bits — opcodes, register fields, funct codes, immediates, all of it hand-verified. Lesson 2, Part 2 picks up exactly where this leaves off: Building the Single-Cycle Datapath: ALU, Register File, Memory, and Buses — wiring the actual hardware that reads these bit patterns and turns them into register writes, memory accesses, and PC updates.