Computer Architecture
MIPS16 ISA study tool
The Toolchain Demystified
Compiler vs Assembler vs Linker. Where the MIPS16 reference tool fits. Why these are completely different beasts.
1.1 — THE CENTRAL QUESTION
You write text. The CPU executes bits. Something in the middle does the translation. But what? Most people collapse this entire process into one word — "compile" — and that's where the confusion starts.
There are actually four separate tools in the classic pipeline, each doing something fundamentally different. Let's name them and understand them before we look at the MIPS16 reference tool specifically.
1.2 — THE FOUR TOOLS
The compiler: the smart one
The compiler is the only tool that understands what your program means. It parses grammar, enforces types, detects logical errors (int x = "hello" fails here), and applies optimizations. Its output is not an executable — it's assembly text. On its own, nothing runs.
The assembler: the mechanical translator
The assembler does zero semantic analysis. It reads assembly mnemonics and applies a lookup table. ADD is always opcode 0b0000. Period. It cannot catch logic errors. It cannot optimize. Its only "intelligence" is resolving label addresses — figuring out that when you write BEQ $r4, LOOP, the address of LOOP is word address 6. That single task requires the two-pass algorithm we'll study in Module 3.
The linker: the glue
When you call printf() in C, the assembler produces a placeholder — it doesn't know where printf lives. The linker's job is to stitch together all the object files (your code + standard library) and resolve these cross-file references. The MIPS16 reference assembler skips this step entirely because it is single-file — everything is resolved by the assembler itself.
1.3 — YOUR MIPS16 TOOL IN CONTEXT
The reference MIPS16 tool combines two things: an assembler (the assemble() function) and an emulator (the stepCPU() function). You type assembly text → the assembler converts it to an array of 16-bit words → the emulator fetches and executes those words one by one.
This pattern — self-contained assembler-emulator — is how educational processors work (LC-3, MARIE), and how embedded microcontroller tools worked in the 1980s-90s.
CALL external_function pointing to another file. This is a deliberate simplification for learning.
1.4 — IS AN ASSEMBLER A COMPILER?
Technically, an assembler is a compiler in the broad Computer Science sense (any program that translates from one language to another). But in practice, when engineers say "compiler" they mean a tool that understands semantics and transforms across abstraction levels. An assembler is categorically simpler — it's closer to a structured find-and-replace than a real compiler.
The clearest test: can the tool catch a semantic error? A compiler catches if (x = 5) (assignment where comparison was intended). An assembler cannot — if your logic is wrong, the assembler happily encodes your bug into bits.
MODULE 1 QUIZ
Question 1 of 12The MIPS16 ISA
16 registers. 16-bit words. 16 opcodes. Every bit of every instruction format decoded from the source.
2.1 — WHAT IS AN ISA?
An Instruction Set Architecture (ISA) is the contract between software and hardware. It defines exactly: which operations the CPU can perform, how instructions are encoded as bits, how many registers exist, how memory is addressed, and how control flow works.
The MIPS16 ISA defined in the reference tool is a pedagogical 16-bit architecture. Every design decision is visible and motivated. Let's read it from the source code.
2.2 — REGISTERS
MIPS16 has 16 registers, each 16 bits wide. They are numbered r0–r15 and have conventional aliases:
| Number | Alias | Purpose | Special rule |
|---|---|---|---|
| r0 | $zero | Always zero | Writes are silently discarded |
| r1–r4 | $t0–$t3 | Temporaries | Caller-saved |
| r5–r8 | $s0–$s3 | Saved vars | Callee-saved |
| r9–r12 | $a0–$a3 | Arguments / return values | |
| r13 | $fp | Frame / base pointer | Used by LDW/STW for addressing |
| r14 | $sp | Stack pointer | Grows downward |
| r15 | $ra | Return address | JLR writes PC+1 here |
stepCPU(), line: if(werf && wd!==null && Rd!==0){newRegs[Rd]=...} — the write-enable check includes Rd!==0. Writing to r0 simply does nothing. This is a classic RISC trick: you get a "free" zero operand without needing a special instruction, and MOV Rd, Rs is just ADD Rd, Rs, $zero.
2.3 — THE TWO INSTRUCTION FORMATS
Every MIPS16 instruction is exactly 16 bits. The top 4 bits are always the opcode. The remaining 12 bits depend on the format:
R-Format (Register)
Used by arithmetic/logic instructions that operate on registers.
| bits 15–12 | bits 11–8 | bits 7–4 | bits 3–0 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| OP (4 bits) | Rd (4 bits) | Rs (4 bits) | Rt (4 bits) | ||||||||||||
| opcode | destination | source 1 | source 2 |
Example: ADD $r2, $r1, $r3 → opcode=0000, Rd=0010, Rs=0001, Rt=0011 → 0x0213
I-Format (Immediate)
Used by memory, branch, and load-immediate instructions. The second source is a signed 8-bit constant baked into the instruction itself.
| bits 15–12 | bits 11–8 | bits 7–0 | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| OP (4 bits) | Rd (4 bits) | Im8 (8 bits) | |||||||||||||
| opcode | register | signed immediate [−128, 127] (unsigned [0,255] for LUI) | |||||||||||||
Example: ADI $r2, 5 → opcode=1101, Rd=0010, Im8=00000101 → 0xD205
2.4 — ALL 16 INSTRUCTIONS
The ISA is defined in the const ISA = {...} object in the source. Here is every instruction, its opcode, format, and exact operation:
| Mnemonic | Opcode | Fmt | Operation | Notes |
|---|---|---|---|---|
| ADD | 0000 | R | Rd = Rs + Rt (16-bit wrap) | |
| SUB | 0001 | R | Rd = Rs − Rt | |
| AND | 0010 | R | Rd = Rs & Rt | bitwise |
| OR | 0011 | R | Rd = Rs | Rt | bitwise |
| XOR | 0100 | R | Rd = Rs ^ Rt | bitwise |
| SLT | 0101 | R | Rd = (Rs < Rt) ? 1 : 0 | signed compare |
| SLL | 0110 | R | Rd = Rs << (Rt & 0xF) | shift left logical |
| MUL | 0111 | R | Rd = Rs × Rt (low 16 bits) | |
| LDW | 1000 | I | Rd = MEM[$fp + Im8] | base=$fp always |
| STW | 1001 | I | MEM[$fp + Im8] = Rd | no write to regfile |
| BEQ | 1010 | I | if Rd==0: PC = PC+1+Im8 | PC-relative offset |
| BNE | 1011 | I | if Rd≠0: PC = PC+1+Im8 | PC-relative offset |
| LUI | 1100 | I | Rd = Im8 << 8 | Im8 unsigned [0,255] |
| ADI | 1101 | I | Rd = Rd + Im8 (signed) | adds to self |
| SRL | 1110 | R* | Rd = Rs >>> (Rt & 0xF) | logical (zero-fill) |
| JLR | 1111 | R* | Rd = PC+1; PC = Rs | jump+link; Rt unused |
* SRL and JLR use R-format encoding but have special behavior. JLR ignores Rt.
2.5 — MEMORY MODEL
MIPS16 uses word addressing — each address refers to one 16-bit word, not one byte. So address 0x0001 is the second word (bytes 2–3). This is different from byte-addressed architectures like x86. The emulator displays addresses in byte terms (×2) for compatibility, but internally everything is word-addressed.
2.6 — BRANCH ADDRESSING
BEQ and BNE use PC-relative addressing. The Im8 field is a signed offset added to PC+1. So BEQ $r4, -3 jumps to 3 words before the next instruction — which is a backwards loop.
In the assembler: when you write BEQ $r4, LOOP, the assembler computes Im8 = address(LOOP) − (current_word_address + 1). This is done in Pass 2 using the labelMap built in Pass 1.
2.7 — THE SPECIAL CASES: LUI + ADI
Since Im8 is only 8 bits, you cannot load an arbitrary 16-bit value in one instruction. The solution is two instructions working together:
; Load 0xBEEF into $r1
LUI $r1, 0xBE ; $r1 = 0xBE00 (shift left 8)
ADI $r1, -17 ; $r1 = 0xBE00 + (-17) = 0xBDEF... wait
There's a subtlety: if the low byte is ≥ 128, it will sign-extend to negative when used as Im8. So the assembler's LI pseudo-instruction adjusts the high byte upward by 1 to compensate. This is exactly what the expandPseudo function does for LI.
MODULE 2 QUIZ
Question 1 of 15How the Assembler Works
Two-pass algorithm. Label resolution. The forward-reference problem. Every function in assemble() explained.
3.1 — WHY TWO PASSES?
Here's the fundamental problem. You're writing this program:
BEQ $r4, DONE ; jump to DONE — but where IS Done?
ADD $r1, $r2, $r3
DONE: JLR $r0, $r15
When the assembler reads line 1, it hasn't seen DONE: yet. It doesn't know the address. This is the forward reference problem — you're referencing a label that's defined later in the file.
The solution is to read the file twice:
3.2 — PASS 1: TOKENISATION AND LABEL COLLECTION
Pass 1 iterates over every line, doing three things:
- Strip comments —
stripComment()removes everything after;or#, respecting quoted strings. - Extract labels — anything before a
:gets added tolabelMapwith the current word address. - Count words — figure out how many 16-bit words each line emits, and advance the address counter. This is where
expandPseudo()is called to check if a pseudo-instruction expands to 1 or 2 real instructions.
labelMap — maps label names (uppercase) to word addressesequMap — maps .equ constants to valuestokList — tokenized version of every source line with its word address
3.3 — PASS 2: ENCODING
Pass 2 iterates over tokList (not the raw source again). Now it has the complete labelMap, so every forward reference can be resolved. For each instruction:
- Look up the mnemonic in
ISAto get the opcode and format. - If R-format: parse three register operands, pack as
(op<<12)|(Rd<<8)|(Rs<<4)|Rt. - If I-format: parse register + immediate, resolve labels, pack as
(op<<12)|(Rd<<8)|Im8. - Push the result word into the
program[]array.
Encoding R-format — from the source
// From assemble(), Pass 2, R-format branch:
const Rd = parseReg(a[0]), Rs = parseReg(a[1]);
const Rt = m==="JLR" ? 0 : parseReg(a[2]||"$r0");
word = ((op & 0xF) << 12) | ((Rd & 0xF) << 8)
| ((Rs & 0xF) << 4) | (Rt & 0xF);
Encoding I-format branch offset
// BEQ/BNE: offset = target_address − (current_word_address + 1)
imm = combined[lbl] - (wa + 1);
// wa = current word address, combined = labelMap merged with equMap
3.4 — THE COMPLETE ASSEMBLY PIPELINE
MODULE 3 QUIZ
Question 1 of 14Pseudo-Instructions & Directives
NOP, MOV, LI, BEQ/BNE expansion. The .equ, .org, .word, .asciiz directives. How the assembler rewrites these before encoding.
4.1 — WHAT IS A PSEUDO-INSTRUCTION?
A pseudo-instruction is a mnemonic that looks like a real instruction but isn't — the CPU has no opcode for it. The assembler expands it into one or more real instructions before encoding. This is pure syntactic sugar: makes code more readable, generates real bits.
4.2 — THE FIVE PSEUDO-INSTRUCTIONS
| Pseudo | Syntax | Expands to | Why |
|---|---|---|---|
NOP | NOP | ADD $r0, $r0, $r0 |
No-op: add zero to zero, discard result. Zero cycles wasted conceptually. |
JR | JR Rs | JLR $r0, Rs |
Jump to Rs, but discard return address (write to $r0 which ignores writes) |
MOV | MOV Rd, Rs | ADD Rd, Rs, $r0 |
Rd = Rs + 0 = Rs. Register copy using $zero as addend. |
LI | LI Rd, imm16 | LUI Rd, hi8 + ADI Rd, lo8 |
Load full 16-bit value. Two instructions because Im8 is only 8 bits. |
BEQ/BNE(3-arg) | BEQ Rs, Rt, label | SUB $r1, Rs, Rt + BEQ $r1, label |
Hardware BEQ tests Rd==0. To compare two regs, subtract first, then test zero. |
0xBEEF: hi=0xBE, lo=0xEF=239. As signed Im8, 239 → -17. So ADI would subtract 17 instead of adding 239. The assembler compensates: if lo ≥ 128, it increments hi by 1 (so LUI loads 0xBF00), then ADI adds the negative offset (0xBF00 + (-17) = 0xBEEF). This is exactly the code in expandPseudo('LI').
4.3 — ASSEMBLER DIRECTIVES
Directives are not instructions at all — they're commands to the assembler itself about how to organize the output. They produce no CPU opcodes (except .word/.space/.asciiz which emit data words).
| Directive | Syntax | Effect |
|---|---|---|
.equ | NAME .equ VALUE | Define a symbolic constant. Used like a number anywhere in the source. Handled in Pass 1. |
.org | .org ADDR | Set the current word address counter. Next instruction/data goes at ADDR. |
.word | .word VALUE | Emit one 16-bit data word at the current address. |
.space | .space N | Emit N zero-words. Used to allocate uninitialized data. |
.ascii | .ascii "str" | Emit each character as one word (ASCII code). No null terminator. |
.asciiz | .asciiz "str" | Like .ascii but appends a zero word at the end (C-string style). |
Example: placing data at a specific address
.org 0x0000 ; code starts at word 0
_start:
LI $fp, 0x0020 ; point $fp at our buffer
LDW $t0, 0 ; load first word of buffer
JLR $r0, $r15 ; halt
.org 0x0020 ; data section at word 0x20
msg: .asciiz "Hi!" ; 4 words: 'H','i','!',0x00
val: .word 0xBEEF ; 1 word: 0xBEEF
if (mn===".ORG") { addr = parseImm(args[0],...); continue; } — it simply resets the word-address counter. Everything after it is placed starting at the new address. This is how you put code at 0x0000 and data at 0x0020 in the same file.
MODULE 4 QUIZ
Question 1 of 12CPU & Emulator Internals
Fetch. Decode. Execute. How stepCPU() works line by line. MMIO. Why the datapath is unified.
5.1 — THE FETCH-DECODE-EXECUTE CYCLE
Every CPU, from your laptop to a microcontroller, runs the same basic loop indefinitely:
- Fetch — read the instruction word at address PC from memory
- Decode — split the 16-bit word into fields (op, Rd, Rs, Rt, Im8)
- Execute — perform the operation, update registers/memory/PC
In stepCPU() this happens in one JavaScript function call per instruction. The state object ({regs, mem, pc}) is immutable — each call returns a new state object. This is clean functional-style emulation.
5.2 — DECODING A WORD
// From stepCPU() — first thing after fetching:
const word = imem[pc];
const op = (word >> 12) & 0xF; // top 4 bits
const Rd = (word >> 8) & 0xF; // bits 11–8
const Rs = (word >> 4) & 0xF; // bits 7–4
const Rt = word & 0xF; // bits 3–0
const im8b = word & 0xFF; // bits 7–0 (unsigned)
const Im8s = sx8(im8b); // sign-extended
sx8(v) converts an 8-bit unsigned value to signed: if v ≥ 128, return v−256. So 0xFF → −1. This is how ADI $r1, -1 encodes 0xFF in the Im8 field and the CPU correctly subtracts 1.
5.3 — THE UNIFIED DATAPATH
Rather than a giant switch on opcode, stepCPU() uses a clever unified datapath: it computes a set of intermediate signals that are then muxed (selected) based on the opcode. This mirrors real hardware:
5.4 — KEY CONTROL SIGNALS
// alufnOvr — overrides ALU function for non-ALU ops:
// LDW/STW/ADI → use ADD (0000) to compute address/value
// BEQ/BNE → use SUB (0001) to test zero
// LUI/JLR → null (ALU not used)
const alufnOvr =
[0b1000,0b1001,0b1101].includes(op) ? 0b0000 // ADD
: [0b1010,0b1011].includes(op) ? 0b0001 // SUB
: [0b1100,0b1111].includes(op) ? null
: op; // use op directly
// werf — write enable to register file
// STW, BEQ, BNE don't write to registers
const werf = ![0b1001,0b1010,0b1011].includes(op);
5.5 — MMIO: MEMORY-MAPPED I/O
Instead of special I/O instructions, MIPS16 puts I/O devices at specific memory addresses. Read/write those addresses and you talk to the device. The emulator intercepts LDW/STW at four magic word addresses:
| Word Address | Name | Direction | Meaning |
|---|---|---|---|
0x7FF8 | IN_STATUS | Read | 1 if a character is waiting in the input queue; 0 if empty |
0x7FF9 | IN_DATA | Read | Read next character from queue (consumes it) |
0x7FFA | OUT_STATUS | Read | Always returns 1 (output always ready) |
0x7FFB | OUT_DATA | Write | Write a character to the terminal |
pc_poll: LDW $t1, 0 ; read OUT_STATUS via $fp=0x7FFA BEQ $t1, pc_poll ; loop until status=1 STW $a0, 0 ; write char to OUT_DATA
MODULE 5 QUIZ
Question 1 of 13Writing Real Programs
Calling conventions. Stack frames. Loops and conditionals. I/O. Building factorial, string output, and a calculator.
6.1 — CALLING CONVENTIONS
When you call a function in MIPS16 assembly, both caller and callee must agree on how to pass arguments, return values, and preserve registers. This informal agreement is the calling convention.
| Role | Registers | Who saves |
|---|---|---|
| Arguments in | $a0–$a3 (r9–r12) | Caller sets them before call |
| Return value | $a0 (r9) | Callee sets before returning |
| Return address | $ra (r15) | JLR writes it; callee must save if it calls others |
| Temporaries | $t0–$t3 (r1–r4) | Caller-saved: assume trashed after any call |
| Saved vars | $s0–$s3 (r5–r8) | Callee-saved: must restore before returning |
| Stack pointer | $sp (r14) | Must be same on return as on entry |
| Frame pointer | $fp (r13) | Flexible — often set to $sp at frame entry |
6.2 — THE STACK FRAME
The stack grows downward (lower addresses). ADI $sp, -1 allocates one word on the stack. ADI $sp, 1 frees it. This is word-addressed, so each "slot" is one 16-bit word.
6.3 — ANNOTATED EXAMPLE: SUM 1..N
; Sum integers 1..N, result in $a0
; Uses: $t0=counter, $t1=sum, $t2=N
LUI $r1, 0 ; $t0 = 0 (counter)
ADI $r1, 0
LUI $r2, 0 ; $t1 = 0 (sum)
ADI $r2, 0
LUI $r3, 0 ; $t2 = 8 (N)
ADI $r3, 8
LOOP: ADI $r1, 1 ; counter++
ADD $r2, $r2, $r1 ; sum += counter
SUB $r4, $r3, $r1 ; temp = N - counter
BNE $r4, LOOP ; if temp≠0: loop
; (BNE branches if Rd ≠ 0)
ADD $r9, $r2, $r0 ; $a0 = sum
JLR $r0, $r15 ; return / halt
6.4 — BUILDING A FUNCTION WITH STACK DISCIPLINE
; putchar($a0) — write one character to terminal
putchar:
ADI $sp, -1 ; allocate 1 stack slot
MOV $fp, $sp ; $fp points to frame
STW $ra, 0 ; save return address
pc_poll:
LI $t0, 0x7FFA ; $t0 = OUT_STATUS address
MOV $fp, $t0 ; use $fp for LDW base
LDW $t1, 0 ; read OUT_STATUS
MOV $fp, $sp ; restore $fp
BEQ $t1, pc_poll ; loop while not ready
LI $t0, 0x7FFB ; $t0 = OUT_DATA address
MOV $fp, $t0
STW $a0, 0 ; write char
MOV $fp, $sp
LDW $ra, 0 ; restore $ra
ADI $sp, 1 ; free stack slot
MOV $fp, $sp
JR $ra ; return
MODULE 6 QUIZ
Question 1 of 14MIPS16 ISA Reference Card
All instructions, registers, formats, and MMIO addresses at a glance.
REGISTERS
| r# | Alias | Role | r# | Alias | Role |
|---|---|---|---|---|---|
| r0 | $zero | Always 0 (writes ignored) | r8 | $s3 | Saved var (callee-save) |
| r1 | $t0 | Temporary | r9 | $a0 | Arg0 / return value |
| r2 | $t1 | Temporary | r10 | $a1 | Arg1 |
| r3 | $t2 | Temporary | r11 | $a2 | Arg2 |
| r4 | $t3 | Temporary | r12 | $a3 | Arg3 |
| r5 | $s0 | Saved var | r13 | $fp | Frame/base ptr (LDW/STW base) |
| r6 | $s1 | Saved var | r14 | $sp | Stack pointer |
| r7 | $s2 | Saved var | r15 | $ra | Return address |
R-FORMAT INSTRUCTIONS (op bits 15–12)
| Op | Mnemonic | Operation |
|---|---|---|
| 0000 | ADD | Rd = Rs + Rt |
| 0001 | SUB | Rd = Rs − Rt |
| 0010 | AND | Rd = Rs & Rt |
| 0011 | OR | Rd = Rs | Rt |
| 0100 | XOR | Rd = Rs ^ Rt |
| 0101 | SLT | Rd = (signed(Rs) < signed(Rt)) ? 1 : 0 |
| 0110 | SLL | Rd = Rs << (Rt & 0xF) |
| 0111 | MUL | Rd = Rs × Rt (low 16 bits) |
| 1110 | SRL | Rd = Rs >>> (Rt & 0xF) logical right shift |
| 1111 | JLR | Rd = PC+1 (word addr); PC = Rs |
I-FORMAT INSTRUCTIONS
| Op | Mnemonic | Operation |
|---|---|---|
| 1000 | LDW | Rd = MEM[$fp + Im8s] |
| 1001 | STW | MEM[$fp + Im8s] = Rd (no regfile write) |
| 1010 | BEQ | if Rd == 0: PC = PC+1+Im8s |
| 1011 | BNE | if Rd != 0: PC = PC+1+Im8s |
| 1100 | LUI | Rd = Im8 << 8 (Im8 unsigned 0–255) |
| 1101 | ADI | Rd = Rd + Im8s (signed −128 to 127) |
PSEUDO-INSTRUCTIONS
| Pseudo | Expands to |
|---|---|
| NOP | ADD $r0, $r0, $r0 |
| JR Rs | JLR $r0, Rs |
| MOV Rd, Rs | ADD Rd, Rs, $r0 |
| LI Rd, imm16 | LUI Rd, adj_hi + ADI Rd, adj_lo |
| BEQ Rs, Rt, lbl | SUB $r1, Rs, Rt + BEQ $r1, lbl |
| BNE Rs, Rt, lbl | SUB $r1, Rs, Rt + BNE $r1, lbl |
MMIO ADDRESSES (word addresses)
| Addr | Name | R/W | Meaning |
|---|---|---|---|
| 0x7FF8 | IN_STATUS | R | 1 if char ready, 0 otherwise |
| 0x7FF9 | IN_DATA | R | Read next char from input queue |
| 0x7FFA | OUT_STATUS | R | Always 1 (always ready) |
| 0x7FFB | OUT_DATA | W | Write char to terminal |
DIRECTIVES
| Directive | Effect |
|---|---|
| .equ NAME V | Define constant NAME = V (handled Pass 1, no words emitted) |
| .org ADDR | Set word address counter to ADDR |
| .word V | Emit one 16-bit word with value V |
| .space N | Emit N zero words |
| .ascii "s" | Emit each char as one word (no null) |
| .asciiz "s" | Emit each char + null word at end |
Legal Notice & Attribution
Please read before using this tool.
Independent Project
This study tool is an independent project developed for pedagogic purposes in the context of Computer Architecture at . It is provided AS IS, without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement.
No Endorsement
No institution or individual has endorsed, reviewed, sponsored, or approved this tool in any way.
No Guarantees
The content of this tool — including quiz questions, module text, assembler behavior, and emulator output — may contain errors or omissions. It is not a substitute for official course materials, lectures, or instructor guidance. Always refer to official sources for authoritative information.
Limitation of Liability
The author assumes no liability for any direct, indirect, incidental, or consequential damages arising from the use or inability to use this tool, including but not limited to errors in educational content, loss of progress data, or any reliance placed on this material in an academic context.
Intellectual Property
The MIPS16 ISA definition, assembler specification, and emulator design referenced in this tool are based on standard computer-architecture course materials. All rights to those materials remain with their respective owners. This tool does not claim ownership of any third-party intellectual property.
Open Use
This tool is shared freely for educational use. If you find it useful, please use it responsibly and do not misrepresent it as an official course resource.