Building a Computer From First Principles
Designed and simulated a 32-bit computer from NAND gates up through ALU, memory, and CPU, then wrote a C-like compiler and OS for it - capable of running a real-time Tetris game on the emulated hardware.
Below, in red, we will see the necessary mathematical concepts to build a computer.
A transistor - the tiny switch a computer is built from - only has two stable states: on or off. Everything a computer does, from counting to decision-making, has to be expressed in terms of just those two states, which we write as 1 and 0.
In binary arithmetic, numbers can only contain 1 & 0. This means we have to count like this:
\[ \begin{array}{c|c} \text{decimal} & \text{binary} \\ \hline 00 & 0000 \\ 01 & 0001 \\ 02 & 0010 \\ 03 & 0011 \\ 04 & 0100 \\ 05 & 0101 \\ 06 & 0110 \\ 07 & 0111 \\ 08 & 1000 \\ 09 & 1001 \\ 10 & 1010 \\ 11 & 1011 \\ \end{array} \]In a decimal system, we carry over the extra digit when the digit exceeds 9. In a binary system, we do it with 2.
Since 1 & 0 can also represent true and false, we can also use mathematical logic to reason with 1 & 0:
- Variable A: I depart on time.
- Variable B: There wasn't any traffic jam.
- Conclusion C: I arrive on time at my destination.
- Equation: A and B = C
We can write this simple example in a truth table:
\[ \begin{array}{cc|c} \text{A} & \text{B} & \text{A and B = C} \\ \hline \text{Late departure} & \text{Traffic jam} & \text{I'm late} \\ \text{Late departure} & \text{Smooth traffic} & \text{I'm late} \\ \text{I'm punctual} & \text{Traffic jam} & \text{I'm late} \\ \text{I'm punctual} & \text{Smooth traffic} & \text{I'm on time} \\ \end{array} \; \; \; \; \; \; \; \; \; \; \; \; \begin{array}{cc|c} \text{A} & \text{B} & \text{A and B = C} \\ \hline \text{false} & \text{false} & \text{false} \\ \text{false} & \text{true} & \text{false} \\ \text{true} & \text{false} & \text{false} \\ \text{true} & \text{true} & \text{true} \\ \end{array} \; \; \; \; \; \; \; \; \; \; \; \; \begin{array}{cc|c} \text{A} & \text{B} & \text{A} \times \text{B = C} \\ \hline 0 & 0 & 0 \times 0 = 0 \\ 0 & 1 & 0 \times 1 = 0 \\ 1 & 0 & 1 \times 0 = 0 \\ 1 & 1 & 1 \times 1 = 1 \\ \end{array} \]Here are the truth tables of some basic functions:
\[ \begin{array}{cc|c} \text{A} & \text{B} & \text{A and B} \\ \hline 0 & 0 & 0 \\ 0 & 1 & 0 \\ 1 & 0 & 0 \\ 1 & 1 & 1 \\ \end{array} \; \; \; \; \; \; \; \; \; \; \; \; \begin{array}{cc|c} \text{A} & \text{B} & \text{A or B} \\ \hline 0 & 0 & 0 \\ 0 & 1 & 1 \\ 1 & 0 & 1 \\ 1 & 1 & 1 \\ \end{array} \; \; \; \; \; \; \; \; \; \; \; \; \begin{array}{c|c} \text{A} & \text{not A} \\ \hline 0 & 1 \\ 1 & 0 \\ \end{array} \]We can also combine them:
- not (A and B) = A nand B
- not (A or B) = A nor B
- A + B = (A or B) and not (A and B) = A xor B
The previous section wrote logic as tables of 1s and 0s - but a real computer needs physical hardware that computes those operations automatically. That hardware is built from gates: small circuits made of transistors, each one implementing a single logic function.
Logic gates are the building blocks of digital circuits, implemented with transistors. A transistor is an electrical switch which can be activated by electrical current:
Notice that the output always has the opposite value of the input. This is a NOT gate.
If we place 2 transistors in series, we can obtain a NAND gate:
Since NAND gates are built with only 2 transistors, everything in the computer is built with combinations of NAND gates. Example with a triple NAND gate:
Other gates such as the AND, the OR, the NOR and the XOR gates can easily be built from combinations of NOT and NAND gates.
| Gate | Built from | Output is true when... |
|---|---|---|
| NOT | 1 transistor | the input is 0 |
| NAND | 2 transistors | not both inputs are 1 |
| AND | NAND + NOT | both inputs are 1 |
| OR | NOT + NAND | at least one input is 1 |
| NOR | OR + NOT | neither input is 1 |
| XOR | AND + OR + NAND | exactly one input is 1 |
Now that we have gates, we can combine them into circuits that do arithmetic - the same way we add and subtract digits by hand, carrying and borrowing, but with only two digits (0 and 1) instead of ten.
-
To add 2 bits together we need to build a half-adder:
\[
\begin{array}{ccccc}
A & + & B & = & XY \\
1 & + & 0 & = & 01 \\
1 & + & 1 & = & 10
\end{array}
\; \; \; \; \; \; \; \; \; \; \; \;
\begin{array}{cc|cc}
A & B & X & Y \\ \hline
0 & 0 & 0 & 0 \\
0 & 1 & 0 & 1 \\
1 & 0 & 0 & 1 \\
1 & 1 & 1 & 0 \\
\end{array}
\]
-
When we add number such as 011 + 011 = 110, we have to carry over an extra 1. This means we need 3 inputs in our circuit instead of 2, this is done with a full-adder:
\[
\begin{array}{ccccccc}
A & + & B & + & C & = & XY \\
1 & + & 0 & + & 0 & = & 01 \\
1 & + & 1 & + & 1 & = & 11
\end{array}
\; \; \; \; \; \; \; \; \; \; \; \;
\begin{array}{ccc|cc}
A & B & C & X & Y \\ \hline
0 & 0 & 0 & 0 & 0 \\
0 & 0 & 1 & 0 & 1 \\
0 & 1 & 0 & 0 & 1 \\
0 & 1 & 1 & 1 & 0 \\
1 & 0 & 0 & 0 & 1 \\
1 & 0 & 1 & 1 & 0 \\
1 & 1 & 0 & 1 & 0 \\
1 & 1 & 1 & 1 & 1 \\
\end{array}
\]
-
Using multiple full-adders, we can build a logical circuit to add two 4-bit numbers. Note that we use the "little endian" notation (if \(A = 10\), then \(A0 = 0\) and \(A1 = 1\)).
\[
\begin{array}{rrrrr}
A & + & B & = & X \\
0000 & + & 1010 & = & 0 \; 1010 \\
1111 & + & 1011 & = & 1 \; 1011 \\
\end{array}
\]
-
To subtract numbers, we need to build a half-subtractor:
\[
\begin{array}{ccccc}
A & - & B & = & XY \\
1 & - & 0 & = & 1 \\
1 & - & 1 & = & 0 \\
0 & - & 1 & = & -1 \\
\end{array}
\; \; \; \; \; \; \; \; \; \; \; \;
\begin{array}{cc|cc}
A & B & \text{diff} & \text{borrow} \\ \hline
0 & 0 & 0 & 0 \\
0 & 1 & 1 & 0 \\
1 & 0 & 1 & 1 \\
1 & 1 & 0 & 0 \\
\end{array}
\]
- Now, following the same logic as for the adder, we can build a full-subtractor from which we could build a 4-bit subtractor if we want to.
-
Multiplication can be reduced to repeated addition: \(A \times B\) means adding \(A\) to itself \(B\) times. A more efficient approach is shift-and-add, which mirrors long multiplication in decimal: for each bit \(B_i\) of \(B\), if \(B_i = 1\), add \(A\) shifted left by \(i\) positions to the running total.
\[
\begin{array}{r}
0101 \; (5) \\
\times \; 0011 \; (3) \\ \hline
0101 \; (B_0 = 1, \text{shift } 0) \\
+ \; 1010 \; (B_1 = 1, \text{shift } 1) \\
+ \; 0000 \; (B_2 = 0) \\
+ \; 0000 \; (B_3 = 0) \\ \hline
01111 \; (15)
\end{array}
\]
The subtractor above can produce a negative result, so we need a way to represent negative numbers with the same bit patterns our adder and subtractor circuits already understand - without building any new hardware.
There are multiple ways of going into negative numbers with binaries. However, the easiest way to do it is called two's complement:
- +3 = 0011
- +2 = 0010
- +1 = 0001
- +0 = 0000
- -1 = 1111
- -2 = 1110
- -3 = 1101
Using this method, we can use the same circuits we already built. For example, adding -1 and +1 will be: 1111 + 0001 = 0000. This saves us a lot of time.
Below, in blue, we build a basic unit to choose the operation we want to perform based on a predefined code.
With gates and arithmetic circuits in hand, we can now build the units that let a computer make decisions: comparing numbers, and choosing between signals based on a code rather than always taking the same path.
At some point, we will need the computer to compare numbers and change the course of action based on these comparisons. We'll create basic circuits for this purpose:
-
Check if two 2-bit numbers are equal:
-
Check if a 2-bit number is greater than another 2-bit number (\(A > B \implies A1>B1 \text{ or } (A1=B1 \text{ and } A0>B0)\)):
-
Select the input based on the selector value - this is called a selector or a multiplexer:
\[ \begin{array}{cc|cccc|c} S1 & S0 & I3 & I2 & I1 & I0 & O \\ \hline 0 & 0 & x & x & x & 1 & 1 \\ 0 & 1 & x & x & 1 & x & 1 \\ 1 & 0 & x & 1 & x & x & 1 \\ 1 & 1 & 1 & x & x & x & 1 \\ \end{array} \]
-
The opposite of a multiplexer is a demultiplexer, the selector helps us select the output branch:
The comparison and arithmetic circuits built so far are only useful if a program can actually invoke them. Machine language gives each operation a name (a mnemonic) and a fixed bit pattern (an opcode), so a sequence of instructions can select which circuit runs on which registers.
List of arithmetic operations:
| Mnemonic | Instruction | Type | Description | Example |
|---|---|---|---|---|
| ADD dest, src1, src2 | Add | Registry | dest ← src1 + src2 | r0 ← r1 + r2 |
| SUB dest, src1, src2 | Subtract | Registry | dest ← src1 - src2 | r0 ← r1 - r2 |
| SEQ dest, src1, src2 | Set equal | Registry | dest ← src1 == src2 | r0 ← r1 == r2 |
| SLT dest, src1, src2 | Set less than | Registry | dest ← src1 < src2 | r0 ← r1 < r2 |
| SGT dest, src1, src2 | Set greater than | Registry | dest ← src1 > src2 | r0 ← r1 > r2 |
List of logical operations:
| Mnemonic | Instruction | Type | Description |
|---|---|---|---|
| AND dest, src1, src2 | And | Registry | dest ← src1 & src2 |
| OR dest, src1, src2 | Or | Registry | dest ← src1 | src2 |
Here is how the operations are stored as numbers/bits:
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | instruction |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| padding (unused) | source registry | source registry | destination registry | operation code | operation |
|||||||||||||||||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 0 | 0 | add |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 0 | 1 | sub |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 1 | 0 | seq |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 1 | 1 | slt |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 0 | 0 | sgt |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 0 | 1 | and |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 1 | 0 | or |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 1 | 1 | mul |
||||||||||||
Every arithmetic and logic circuit built so far exists as a separate piece of hardware. An ALU (Arithmetic & Logic Unit) combines all of them into a single unit that picks one operation to run based on a code - which is exactly the opcode field the machine language table above assigns to each mnemonic.
Since we can now:
- Do multiple operations on numbers: addition, subtraction, comparison.
- Select what we want to do using a selector/multiplexer.
We can easily build a circuit which takes 2 numbers as input & an operation code and returns the result of the selected operation (e.g. as defined above, operation code 000000 = addition). Since there are exactly 8 operations, we need exactly 3 bits to choose the operation of the ALU.
Note: due to the limitations of the library used for logical circuit simulations, the equal and greater than gates only return 1 bit. So, we have to attach 15 bits to the result.
We can put the entire ALU in a subcircuit and add the registry:
Below, in green, we build the memory of the computer.
Every circuit built so far - gates, adders, the ALU - only computes: give it inputs, get an output, and the value is gone once the inputs change. A computer also needs to hold onto values between operations. That's what memory does.
Digital memory is somewhat simple. First, memorize 1-bit. Second, memorize a 16-bit register. Third, memorize multiple registers and read/write their value when necessary.
-
The latch circuit can store 1-bit of information. It knows which value to store thanks to the "store" input:
\[
\begin{array}{cc|c}
\text{input} & \text{store} & \text{output} \\ \hline
0 & 1 & 0 \\
1 & 1 & 1 \\
0 & 0 & \text{unchanged} \\
1 & 0 & \text{unchanged} \\
\end{array}
\]
As you can notice, the trick to memorizing information is a loop in the circuit.
-
Unfortunately, the use of latches may cause a synchronization issue. For example, you cannot do these two things at the same time:
- Use the previously stored value.
- Store a new value in the latch.
When the clock = 1, the first latch changes value then, when clock = 0, the second latch changes value. -
Now, we can easily put multiple flip-flops in parallel to create a register:
Using multiple registers, we can build a RAM (Random Access Memory). It needs an addressing system to know which register we want to access and a new input "store" to know whether we want to read the register value or overwrite the current value.
-
Add the input store to indicate whether we want to change the value of the register or not and the input select which returns 0 when the register is not selected and the stored value when it is:
-
Use a demultiplexer to indicate which register's value we want to access:
And we've finally built a RAM.
The RAM we just built can only be read from and written to if a program can tell it which address to use. Machine language exposes that as a small family of load/store instructions, encoded the same way as the arithmetic and logic instructions above.
List of memory operations (load/store operations):
| Mnemonic | Instruction | Type | Description |
|---|---|---|---|
| LD dest, src1, imm1 | Load | Memory | dest ← mem[src1 + imm1] |
| SD dest, src1, imm1 | Store | Memory | mem[dest + imm1] ← src1 |
| LDI dest, imm1 | Load immediate | Memory | dest ← mem[imm1] |
| SET dest, imm1 | Set registry | Memory | dest ← imm1 |
Here is how the operations are stored as numbers/bits:
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | instruction |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| immediate value | source registry | destination registry | 0 | 0 | 0 | 1 | 1 | 1 | ld |
|||||||||||||||||||||||
| immediate value | source registry | destination registry | 0 | 0 | 1 | 0 | 0 | 0 | sd |
|||||||||||||||||||||||
| immediate value | 0 | 0 | 0 | 0 | 0 | destination registry | 0 | 0 | 1 | 0 | 0 | 1 | ldi |
|||||||||||||||||||
| immediate value | 0 | 0 | 0 | 0 | 0 | destination registry | 0 | 0 | 1 | 0 | 1 | 0 | set |
|||||||||||||||||||
Let's implement the operation SET dest, imm1, i.e. dest ← imm1
The operation LDI dest, imm1, i.e. dest ← mem[imm1]:
The operation LD dest, src1, imm1, i.e. dest ← mem[src1 + imm1]:
The operation SD dest, src1, imm1, i.e. mem[dest + imm1] ← src1:
The RAM circuit and the load/store instructions above don't quite speak the same language yet - the RAM expects an address/data/enable trio, while the instructions carry registers and an immediate value. The memory unit is the adapter between the two.
The RAM only takes 3 inputs:
- The memory address we want to read/write from.
- The data to write at the selected memory address.
- The enable parameter which tells us whether we are reading or writing the data.
But the operations we defined have 3 very different inputs:
- A source registry which is used to tell which data we need to read/write.
- A destination registry which is used to tell where to write the data.
- An immediate value which can be used for both.
Since there are only 5 operations, we'll only need 3 bits to choose the operation we want to perform.
We can now put everything in a subcircuit, we'll also put data contained in the register dest in reg1 and the data from src1 in reg2.
Below, in black, we build the control unit. It serves to control the flow of data through the CPU.
So far every instruction runs in sequence, one after another. Real programs need to make decisions and repeat work, which means the CPU needs a way to jump to a different instruction instead of just continuing to the next one - that's what branching operations do.
List of branching operations:
| Mnemonic | Instruction | Type | Description |
|---|---|---|---|
| JAL dst1, imm1 | Jump and link | Branch | dst1 ← pc + 2, pc ← pc + imm1 |
| BEQ src1, src2, imm1 | Branch equal | Branch | pc ← pc + imm1, if src1 == src2 |
| BGT src1, src2, imm1 | Branch greater than | Branch | pc ← pc + imm1, if src1 > src2 |
| BLT src1, src2, imm1 | Branch lesser than | Branch | pc ← pc + imm1, if src1 < src2 |
Here is how the operations are stored as numbers/bits:
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | instruction |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| immediate value | 0 | 0 | 0 | 0 | 0 | destination registry | 0 | 0 | 1 | 1 | 0 | 0 | jal |
|||||||||||||||||||
| immediate value | 1-st source registry | 2-nd source registry | 0 | 0 | 1 | 1 | 0 | 1 | beq |
|||||||||||||||||||||||
| immediate value | 1-st source registry | 2-nd source registry | 0 | 0 | 1 | 1 | 1 | 0 | bgt |
|||||||||||||||||||||||
| immediate value | 1-st source registry | 2-nd source registry | 0 | 0 | 1 | 1 | 1 | 1 | blt |
|||||||||||||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | source registry | 0 | 0 | 1 | 1 | 1 | 1 | jr |
|||||||||||||||||||
All operations are done on these inputs:
- The
pc(program counter) registry which contains the address of the next instruction which is being executed. - The
src1registry whose value is used in a comparison. - The
src2registry whose value is used in a comparison. - An immediate value
imm1which is used to change the value of thepcregistry.
We can start by implementing the circuit when there is no branching happening (i.e. load the next instruction & add 2 to the current value of pc):
The branching circuit above only decides the next pc value from a comparison. The control unit is the piece that reads the actual instruction and drives all of it: deciding whether to branch, jump, or simply advance, based on what the instruction says to do.
Now, let's improve the previous circuit in order to handle instructions that change the flow of instructions directly.
We can, again, put most of it under a subcircuit to simplify things.
Below, in yellow, we will build the processor. This is the core of our computer.
This section pulls together every instruction built across the arithmetic, memory, and branching circuits above into a single reference: the full instruction set, its exact bit encoding, and a short program to show how instructions combine.
In order to support more complex operations, every number must be considered as a signed number:
- If a number starts with the bit 1, it is a negative number.
- If a number starts with the bit 0, it is a positive number.
List of arithmetic operations:
| Mnemonic | Instruction | Type | Description | Example |
|---|---|---|---|---|
| ADD dest, src1, src2 | Add | Registry | dest ← src1 + src2 | r0 ← r1 + r2 |
| SUB dest, src1, src2 | Subtract | Registry | dest ← src1 - src2 | r0 ← r1 - r2 |
| SEQ dest, src1, src2 | Set equal | Registry | dest ← src1 == src2 | r0 ← r1 == r2 |
| SLT dest, src1, src2 | Set less than | Registry | dest ← src1 < src2 | r0 ← r1 < r2 |
| SGT dest, src1, src2 | Set greater than | Registry | dest ← src1 > src2 | r0 ← r1 > r2 |
List of logical operations:
| Mnemonic | Instruction | Type | Description |
|---|---|---|---|
| AND dest, src1, src2 | And | Registry | dest ← src1 & src2 |
| OR dest, src1, src2 | Or | Registry | dest ← src1 | src2 |
List of load/store operations:
| Mnemonic | Instruction | Type | Description |
|---|---|---|---|
| LD dest, src1, imm1 | Load | Memory | dest ← mem[src1 + imm1] |
| SD dest, src1, imm1 | Store | Memory | mem[dest + imm1] ← src1 |
| LDI dest, imm1 | Load immediate | Memory | dest ← mem[imm1] |
| SET dest, imm1 | Set registry | Memory | dest ← imm1 |
List of branching operations:
| Mnemonic | Instruction | Type | Description |
|---|---|---|---|
| JAL dst1, imm1 | Jump and link | Branch | dst1 ← pc + 2, pc ← pc + imm1 |
| BEQ src1, src2, imm1 | Branch equal | Branch | pc ← pc + imm1, if src1 == src2 |
| BGT src1, src2, imm1 | Branch greater than | Branch | pc ← pc + imm1, if src1 > src2 |
| BLT src1, src2, imm1 | Branch lesser than | Branch | pc ← pc + imm1, if src1 < src2 |
Using these instructions, we can already build small programs such as a multiplication.
Example computing \(5 \times 4 \; (= 5 + 5 + 5 + 5)\):
0000 start: LDI r0, 5 -- number to multiply
0001 LDI r1, 4 -- how many times it is multiplied
0010 LDI r2, 0 -- result of the multiplication
0011 LDI r3, 1 -- to decrement the loop
0100 LDI r4, 0 -- compare to know if we added the number to multiply enough times
0101 loop: SUB r1, r1, r3
0110 ADD r2, r0, r2
0111 BEQ r1, r4, end -- loop until we have added the number to multiply enough times
1000 JAL r31, loop
1001 end:
Note that when we transform the assembly language to machine code, the assembler will automatically change the values of start, loop and end to the necessary immediate values.
Here is how the operations are stored as numbers/bits:
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | instruction |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 0 | 0 | add |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 0 | 1 | sub |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 1 | 0 | seq |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 0 | 1 | 1 | slt |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 0 | 0 | sgt |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 0 | 1 | and |
||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 0 | 0 | 1 | 1 | 0 | or |
||||||||||||
| immediate value | source registry | destination registry | 0 | 0 | 0 | 1 | 1 | 1 | ld |
|||||||||||||||||||||||
| immediate value | source registry | destination registry | 0 | 0 | 1 | 0 | 0 | 0 | sd |
|||||||||||||||||||||||
| immediate value | 0 | 0 | 0 | 0 | 0 | destination registry | 0 | 0 | 1 | 0 | 0 | 1 | ldi |
|||||||||||||||||||
| immediate value | 0 | 0 | 0 | 0 | 0 | destination registry | 0 | 0 | 1 | 0 | 1 | 0 | set |
|||||||||||||||||||
| immediate value | 0 | 0 | 0 | 0 | 0 | destination registry | 0 | 0 | 1 | 0 | 1 | 1 | jal |
|||||||||||||||||||
| immediate value | 2-nd source registry | 1-st source registry | 0 | 0 | 1 | 1 | 0 | 0 | beq |
|||||||||||||||||||||||
| immediate value | 2-nd source registry | 1-st source registry | 0 | 0 | 1 | 1 | 0 | 1 | bgt |
|||||||||||||||||||||||
| immediate value | 2-nd source registry | 1-st source registry | 0 | 0 | 1 | 1 | 1 | 0 | blt |
|||||||||||||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | source registry | 0 | 0 | 1 | 1 | 1 | 1 | jr |
|||||||||||||||||||
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2-nd source registry | 1-st source registry | destination registry | 0 | 1 | 0 | 0 | 0 | 0 | mul |
||||||||||||
Example on changing assembly code to byte code (note that addresses increase by 2 because instructions are encoded on 32 bits instead of 16):
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | address |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | LDI r0, 5 |
0000 - start |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | LDI r1, 4 |
0001 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | LDI r2, 0 |
0010 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | LDI r3, 1 |
0011 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | LDI r4, 0 |
0100 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | SUB r1, r1, r3 |
0101 - loop |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ADD r2, r0, r2 |
0110 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | BEQ r1, r4, end |
0111 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 1 | 0 | 1 | 1 | JAL r31, loop |
1000 |
The ALU, the memory unit, and the control unit have each been built and tested on their own. The processor (CPU) is what you get when you wire all three together into one circuit that fetches an instruction, decodes it, and executes it.
The goal now is to combine the idea of the ALU, the memory unit and the control unit to make a single object which is the processor.
Building the processor is one thing; trusting it is another. This section walks through a few small hand-encoded programs and traces exactly what each instruction does, to confirm the memory unit, ALU, and control unit all behave as designed.
Let's test the memory part of the processor:
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | SET r0, 0 |
r0 ← 0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | SET r1, 5 |
r1 ← 5 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | SD r0, r1, 0 |
mem[r0 + 0] ← r1 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | LD r2, r0, 0 |
r2 ← mem[r0 + 0] |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 1 | 0 | 0 | 1 | LDI r3, imm1 |
r3 ← mem[imm1] |
After executing this program we should see:
- r0 = 0
- r1 = 5
- mem[r0 + 0] = r1 \(\implies\) mem[0 + 0] = 5
- r2 = mem[r0 + 0] \(\implies\) r2 = mem[0 + 0] = 5
- r3 = mem[0] \(\implies\) r3 = mem[0] = 5
Let's test the ALU of the processor:
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | SET r0, 2 |
r0 ← 2 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | SET r1, 5 |
r1 ← 5 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ADD r2, r0, r1 |
r2 ← r0 + r1 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | SUB r3, r1, r0 |
r3 = r1 - r0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | SEQ r4, r0, r0 |
r4 ← r0 == r0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | SLT r5, r0, r1 |
r5 ← r0 < r1 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | SGT r6, r1, r0 |
r6 ← r1 > r0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | AND r7, r5, r6 |
r7 ← r5 & r6 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | OR r8, r5, r6 |
r8 ← r5 | r6 |
After executing this program we should see:
- r0 = 2
- r1 = 5
- r2 = 7
- r3 = 3
- r4 = 1
- r5 = 1
- r6 = 1
- r7 = 1
- r8 = 1
We can test the control unit of the processor by looping over and over (reminder that here, imm1 is a signed number):
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | SET r0, 0 |
r0 ← 0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | SET r1, 1 |
r1 ← 1 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ADD r0, r0, r1 |
r0 ← r0 + r1 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 1 | 1 | JAL r2, -2 |
dst1 ← pc + 2 pc ← pc - 2 |
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | SET r0, 9 |
r0 ← 9 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | SET r1, 9 |
r1 ← 9 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 0 | BEQ r0, r1, -4 |
if r0 == r1: pc ← pc - 4 |
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | SET r0, 0 |
r0 ← 0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | SET r1, 5 |
r1 ← 5 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 1 | 0 | 1 | BGT r1, r0, -4 |
if src1 > src2: pc ← pc + imm1 |
| 31 | 30 | 29 | 28 | 27 | 26 | 25 | 24 | 23 | 22 | 21 | 20 | 19 | 18 | 17 | 16 | 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | operation | comment |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | SET r0, 0 |
r0 ← 0 |
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 0 | SET r1, 5 |
r1 ← 5 |
| 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 0 | BLT r0, r1, -4 |
if src1 < src2: pc ← pc + imm1 |
Below in gray, we will define how our execution model works.
The execution model describes everything that happens between loading a program into memory and getting a result: how the CPU fetches instructions, what state it keeps, and what software conventions programs must follow so they work correctly together. The sub-sections below each cover one piece of this model.
The CPU runs a simple loop forever:
- Fetch - read the 32-bit word at the address stored in the program counter (PC). The PC always points to the next instruction to run.
- Decode - inspect the bits to determine the operation (opcode), which registers are involved, and any immediate value encoded in the instruction.
- Execute - carry out the operation: compute a result in the ALU, read or write memory, or change the PC for a branch or jump.
After each instruction the PC is automatically incremented by 2, so execution flows straight ahead unless a branch or jump instruction redirects it. It steps by 2 rather than 1 because memory is addressed in 16-bit words, while each instruction is encoded as a 32-bit value spanning two consecutive addresses.
| Component | Description |
|---|---|
| Program Counter (PC) | Holds the address of the instruction currently being fetched. Increments by 2 after every instruction (each 32-bit instruction spans two 16-bit-addressable words); jumps and branches overwrite it directly. |
| Registers | 32 general-purpose 16-bit storage slots inside the CPU. All arithmetic and logic operations read from and write to registers - not directly from RAM. A few have fixed roles (r0 = 0, r29 = sp, r30 = fp, r31 = ra). |
| ROM | Read-only memory that holds the program. The machine-code words produced by the assembler are written here before the CPU starts; the CPU fetches every instruction from ROM. |
| RAM | 65,536 words of read-write memory for data. The stack grows downward from the top; LD/SD instructions access RAM, not ROM. |
| ALU | Arithmetic & Logic Unit - performs addition, subtraction, comparisons, and bitwise operations on register values. |
- Memory Layout - how instructions live in ROM and data lives in RAM; how programs are stored and loaded.
- Stack - the memory region used to store local variables and intermediate values; how push/pop work.
- Function Calls - the calling convention: how arguments are passed, how the return address is saved, and how stack frames are created and torn down.
- Register Map - the agreed roles for each of the 32 registers.
- Program Startup & Halt - the preamble that runs before
main(), and how the CPU stops after it returns.
The program (instructions) is stored in ROM (Read-Only Memory), which is separate from RAM. The CPU always fetches the next instruction from ROM using the program counter as the address. Data - the stack - lives in RAM. The CPU cannot write to ROM, so the program is fixed for the duration of execution.
The RAM has 65,536 words (addresses 0x0000 to
0xFFFF), used exclusively for data.
RAM address Contents
─────────────────────────────────────────
0x0000 screen framebuffer (hardware-mapped, 0x0000-0x3FFF)
0x4000 I/O registers (hardware-mapped, 0x4000-0x4FFF)
0x5000 globals (user RAM)
...
0xFFFD local variable / stack data
0xFFFE local variable / stack data
0xFFFF ← sp and fp start here (top of RAM)
─────────────────────────────────────────
- Low addresses (
0x0000-0x4FFF) are hardware-mapped - the screen framebuffer and I/O registers. User code must not use them as general-purpose data. - User data starts at
0x5000- globals grow upward from there. - High addresses hold the stack.
spstarts at 65535 (0xFFFF) and moves downward as variables are pushed. - A stack overflow occurs if
spreaches the globals region at0x5000- there is no hardware guard.
After compilation the assembler produces a flat list of 32-bit machine-code words - one word per instruction. These words are what actually runs on the CPU; labels and comments exist only in the assembly source and are resolved away during assembly.
For example, the three-instruction program:
set r0, 0
set r1, 5
jal r0, 0
becomes three 32-bit instructions stored starting at address 0, each spanning two 16-bit-addressable words (so the address advances by 2 per instruction):
Address 0x0000: 00000000 00000101 00000000 00100101 (set r0, 0)
Address 0x0002: 00000000 00000101 00000010 00100101 (set r1, 5)
Address 0x0004: 00000000 00000000 00000000 00000001 (jal r0, 0)
Loading a program means writing the machine-code words into ROM before the CPU starts (or resets). There is no operating system - the CPU simply begins fetching instructions from ROM address 0 on power-on. In the simulator, machine code is pasted in and written into the ROM array; when the CPU is released from reset it begins executing from ROM address 0.
A stack is a region of memory that works like a pile of plates: you can only add (push) or remove (pop) items from the top. This Last-In, First-Out (LIFO) discipline makes it perfect for tracking local variables and function calls, because the most recently entered scope is always the first one to leave.
In our architecture, the stack lives in RAM and grows downward: high addresses are the bottom, low addresses are the top. Two registers manage it:
r29- stack pointer (sp): always points to the current top of the stack.r30- frame pointer (fp): anchors the base of the current function's local variables.
Push (add an item): store the value at mem[sp], then decrement sp by 1.
Pop (remove an item): increment sp by 1, then read mem[sp].
When you write int x = 5;, the compiler translates it into three steps:
set r1, 5 -- load immediate value into scratch register
sd r30, r1, 0 -- store at mem[fp + 0] (declare x)
sub r29, r29, r3 -- push slot onto stack (r3 holds constant 1)
Each new local variable gets the next slot below fp (offset 0, -1, -2, ...).
When the function returns, sp is restored to its value before the call,
which automatically reclaims all local variables at once.
Calling a function requires saving the caller's state so execution can resume correctly after the callee returns. This is done entirely on the stack, using a dedicated region called a stack frame (or activation record).
Every call follows four steps:
- Push arguments - the caller pushes each argument onto the stack, left-to-right.
-
Jump and link -
jal r31, fnstores the return address (the instruction after the call) intor31, then jumps to the function. -
Prologue - the callee saves
r31andr30on the stack, then setsr30 = r29to establish a new frame pointer. Local variables are allocated below this anchor as the function runs. -
Epilogue - the callee restores
r30(old fp) andr31(return address) from the stack, resetsr29 = r30to discard all locals, then jumps back to the caller viar31. The caller then pops the arguments it pushed in step 1.
The return value is passed back in r1; the caller reads it there
immediately after the call returns.
Stack frame layout (shown for int add(int a, int b)):
| Address | Contents | Note |
|---|---|---|
| fp + 4 | a | first argument (pushed first) |
| fp + 3 | b | second argument (pushed last) |
| fp + 2 | saved r31 | return address |
| fp + 1 | saved r30 | caller's frame pointer |
| fp + 0 | first local | ← r30 (fp) and r29 (sp) after prologue |
| fp - 1 | second local | sp moves down with each new local |
| ... | ... |
Because every function uses the same layout, calls can be nested or recursive: each call simply pushes a fresh frame on top of the previous one, and unwinding restores everything in reverse order.
fn_name:
sd r29, r31, 0 -- save return address
sub r29, r29, r3
sd r29, r30, 0 -- save caller's frame pointer
sub r29, r29, r3
add r30, r29, r0 -- set new frame pointer
[body]
fn_name_epilogue:
add r29, r30, r0 -- discard locals (sp = fp)
set r2, 1
add r29, r29, r2 -- step past saved fp slot
ld r30, r29, 0 -- restore caller's frame pointer
add r29, r29, r2 -- step past saved ra slot
ld r31, r29, 0 -- restore return address
add r29, r29, r2
jr r31 -- return to caller
Our architecture has 32 registers, but only a few of them have special meanings. The rest are general-purpose and can be used freely by programmers and compilers.
| Register | Name | Purpose |
|---|---|---|
| r0 | zero | always 0; set once at startup (SET r0, 0), writes are silently ignored |
| r1 | ret | return value from function calls; also used as a general-purpose scratch register |
| r2-r28 | - | general-purpose scratch registers; free for the compiler and programmer to use freely |
| r29 | sp | stack pointer - points to the current top of the stack (grows downward) |
| r30 | fp | frame pointer - anchors the base of the current function's stack frame |
| r31 | ra | return address - written by JAL, jumped to on return |
When the processor is powered on or reset, the following happens in order:
- All registers are in an undefined state - their values are whatever the flip-flops power up to (usually 0, but not guaranteed by the hardware).
-
The program counter is reset to
0x0000- this is the only guaranteed initial state. - The CPU immediately begins the fetch-decode-execute loop - it fetches the 32-bit word at address 0 and executes it.
- The first instructions encountered are the startup preamble - the compiler always places this preamble at the very start of the output, so address 0 is always the first preamble instruction.
This means user code never runs before the preamble. By the time execution reaches
main, every register with a defined role (r0,
r29, r30) has been initialized correctly.
The first instruction the CPU fetches is whatever the assembler placed at the very beginning of memory. This is the startup preamble - a short sequence emitted automatically by the compiler before any user code.
The preamble performs four tasks in order:
-
Initialize
r0- write 0 into the zero register once. Every other instruction that usesr0as a source relies on it being 0, so this must happen first. -
Initialize the stack pointer - set
r29to the highest usable RAM address (0xFFFF= 65535). The stack will grow downward from there. -
Initialize the frame pointer - copy
r29intor30to establish the base frame beforemainis entered. -
Call
main- jump to the user'smainfunction usingJAL r31, main, saving the return address inr31.
In assembly, the preamble looks like this:
; --- startup preamble (placed at address 0x0000) ---
set r0, 0 ; r0 = 0 (zero register, used as a constant throughout)
set r29, 65535 ; sp = top of RAM (0xFFFF)
add r30, r29, r0 ; fp = sp (empty frame before main is called)
jal r31, main ; call main(); return address saved in r31
There is no dedicated HALT instruction.
When main returns (its epilogue jumps back to the preamble via r31),
the CPU must be stopped by entering an infinite busy loop.
This is achieved with a self-jump: a JAL whose offset is 0 causes the
program counter to stay at the same instruction forever.
Writing the link address to r0 discards it harmlessly.
halt:
jal r0, 0 ; pc ← pc + 0 → loops on this instruction forever
; r0 is written but always reads back as 0 (ignored)
The full end of the preamble therefore looks like:
; --- startup preamble ---
set r0, 0
set r29, 65535
add r30, r29, r0
jal r31, main ; call main()
halt:
jal r0, 0 ; main returned - spin here forever
; --- user code follows ---
main:
...
Because the preamble is emitted automatically by the compiler, user programs never
need to set up r0, sp, or fp themselves -
they are guaranteed to be correct by the time main begins executing.
Below in cyan, we will create a high-level language, an assembler, and a compiler. They will be implemented in JavaScript and help us turn instructions into machine code.
Every machine-code example so far was hand-translated bit by bit - readable for a handful of instructions, but unworkable for a real program. An assembler automates that translation: it takes text written with mnemonics and labels and turns it into the 32-bit words the CPU actually runs.
To make it easier to work with the processor, a small program in javascript was written to turn assembly code to machine code:
Writing long/complex programs in assembly will be difficult and error prone. This is why we have to define a higher-level programming language which will handle a lot of the abstractions for us. For example, we will be able to loop more easily over the same code.
EBNF (Extended Backus-Naur Form) is a language to define a formal language. Let's use the EBNF grammar to define the language we want to create.
- Comments - the two comment syntaxes the parser ignores.
- Variable - declaring variables, scope, and the two basic types.
- Arrays - fixed-size collections and indexing.
- Char & Strings - character literals and string handling.
- Pointers - referencing and dereferencing memory addresses.
- Types - the full set of types the language supports.
- Operators - arithmetic, comparison, and logical operators.
- Control Flow -
if/elseand loops. - Functions - declaring and calling reusable blocks of code.
- Expressions & Statements - how the two combine into a program.
- EBNF - the complete formal grammar, all in one place.
Comments let a programmer leave notes in the source that the parser skips entirely - they exist for humans reading the code, not for the compiler.
- If we write
//, everything coming after this (on the same line) will be considered a comment. - If we write
/*, everything coming after this, until*/, will be considered a comment.
(* Comments *)
comment = single_line_comment | multi_line_comment ;
single_line_comment = "//" , *([^"\n"]) ;
multi_line_comment = "/*" , *([^\*] | "*" , [^\/]) , "*/" ;
A variable gives a name to a piece of memory so a program can read and change it by that name instead of tracking raw addresses by hand - the same role registers and stack slots played in assembly, but managed automatically by the compiler.
- A variable contains information.
-
Variables have a given scope. They only exist in the code block in which they were defined and the blocks contained by it.
Example :
{ int variable = 144; } // 'variable' cannot be used outside {} - A variable of type integer must be declared with the keyword
int. - The keyword
boolis used to declare a variable of type boolean which can have either the value true or false.
(* Variables Basics *)
variable = identifier ;
identifier = letter , * (letter | digit | underscore) ;
letter = "a" .. "z" | "A" .. "Z" ;
digit = "0" .. "9" ;
underscore = "_" ;
boolean_constant = "true" | "false" ;
number = digit , *digit ;
An array groups several values of the same type under one name, laid out contiguously in memory, so a program can refer to any one of them by an index instead of declaring a separate variable for each.
- An array is a list of values of the same type. Example:
int[5] numbers = {10, 20, 30, 40, 50}; - You can access an element of the array like this:
numbers[1] = 99; // numbers => {10, 99, 30, 40, 50} - Note that the index of the array starts with 0.
(* Arrays *)
array_declaration = type , identifier , "[" , number , "]" , ";" ;
array_access = identifier , "[" , expression , "]" ;
Text has to be represented with the same numeric building blocks as everything else - a character is just a small number under an ASCII encoding, and a string is simply an array of characters with a marker for where it ends.
- A
charcan hold any single character, such as a letter, digit, or symbol. For example: 'a', '1', '%'. - The encoding is ASCII but due to the architecture of the computer, we are using 16-bits.
- A string is a chain of characters. Example:
char[6] greeting = "Hello"; // Array of chars: {'H', 'e', 'l', 'l', 'o', '\0'} - A chain of characters ends with a
'\0', it means the last character is full of 0 instead of containing a real value. This means there is always 1 more character in the string.
(* Characters/Strings *)
character = "'" , char_content , "'" ;
char_content = letter | digit | special_char | escape_sequence ;
special_char = "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "-" | "_" | "=" | "+" | "{" | "}" | "[" | "]" | ";" | ":" | "'" | "\"" | "<" | ">" | "," | "." | "/" | "?" | "\\" | "|" ;
escape_sequence = "\\" , ("n" | "t" | "\\" | "'" | "\"" ) ;
string = '"' , { letter | digit | special_char | escape_sequence | " " } , '"' ;
A pointer makes the underlying memory address of a variable visible and usable directly, instead of hiding it behind a name - which is also exactly how an array is represented under the hood.
- A pointer points towards information (which can be a variable). It's basically a memory address.
- If you define the variable
int var = 50;, you can access the memory address where "50" is stored using this operation:&var. - You can define a pointer like this:
int* pointer = &var;you can then access the value from the pointer using:*pointer - A pointer is also useful when dealing with arrays. Actually, when defining an array, we are defining a pointer towards a list of values.
- For example, if you define the array
int[3] numbers = {10, 20, 30};you can access the different values of the array using: numbers[0]or*numbers, you can get the first value of the arraynumbers[1]or*(numbers + 1), you can get the second value of the arraynumbers[2]or*(numbers + 2), you can get the third value of the array
(* Pointers *)
pointer_declaration = pointer_types , identifier , [ "=" , expression ] , ";" ;
pointer = { "*" } , variable ;
Every value in the language - a plain variable, a pointer, or an array - is one of the same small set of underlying types, combined the same way. This section gathers them into a single reference.
We have seen all 3 types of variables:
- Regular variables like:
int variable = 5; - Pointers:
int* pointer = &variable;orint** pointer2 = &pointer; - Arrays:
int[3] array = {1, 2, 3};
(* Types *)
types = default_types | pointer_types | array_types ;
default_types = "bool" | "int" | "char" | "void" ;
pointer_types = default_types , { "*" } ; (* Supports multiple levels of pointers *)
array_types = (default_types | pointer_types) , "[" , number , "]" ;
(* Declaration & Assignment *)
variable_declaration = type , identifier , [ "=" , expression ] , ";" ;
assignment_statement = (variable | array_access | pointer) , "=" , expression , ";" ;
Operators are shorthand for the arithmetic and comparison circuits built earlier in the ALU - writing a + b here compiles down to the same ADD instruction as before, just without having to name registers by hand.
Arithmetic operators:
| Operator | Operation |
|---|---|
| + | Addition |
| - | Subtraction |
| - | Negation (e.g. -5, -7) |
| * | Multiplication |
| / | Division |
| % | Modulo (remainder) |
Note that you can also use parentheses like a regular mathematical computation.
Logical operators:
| Operator | Operation |
|---|---|
| && | And |
| || | Or |
| ! | Not |
Comparison operators:
| Operator | Operation |
|---|---|
| == | Equal |
| != | Not equal |
| < | Smaller than |
| > | Greater than |
| <= | Smaller or equal to |
| >= | Greater or equal to |
(* Arithmetic/Logical/Comparison Operators *)
arithmetic_operator = "+" | "-" | "*" | "/" | "%" ;
logical_operator = "&&" | "||" | "!" ;
comparison_operator = "==" | "!=" | "<" | ">" | "<=" | ">=" ;
unary_operator = "!" | "&" | "*" | "-" ;
So far every program runs top to bottom with no choices. Control flow lets a program skip code, choose between branches, or repeat a block - compiling down to the same BEQ/BGT/BLT/JAL instructions covered in Branching Operations, just expressed without manually computing jump targets.
- You can choose which part of the code to run using the if/else keywords:
if (condition) {
// if-code
} else {
// else-code
}
else if:if (condition 1) {
// code 1
} else if (condition 2) {
// code 2
} else {
// code 3
}
- Loop while the condition is true with the keyword
while:
while (condition) {
// code block
}
(* Control Flow *)
if_statement = "if" , "(" , expression , ")" , statement , { "else if" , "(" , expression , ")" , statement } , [ "else" , statement ] ;
while_statement = "while" , "(" , expression , ")" , statement ;
A function is a reusable piece of code which can be called from different parts of the program - the same role JAL/JR and the stack-frame calling convention played in assembly, but named and called by the compiler instead of managed by hand.
- A function definition is like this:
type function_name(parameters);, it starts with the type of the value it returns. If it returns nothing, the type should bevoid. - Below is an example of a function which compares 2 values and returns the maximum:
int max(int value1, int value2) {
int result;
if (value1 > value2) { result = value1; }
else { result = value2; }
return result;
}
(* Functions *)
function_definition = type , identifier , "(" , [ parameter_list ] , ")" , compound_statement ;
parameter_list = parameter , { "," , parameter } ;
parameter = type , identifier ;
The parser needs a precise definition of what counts as a value-producing expression versus an action-performing statement, and how operators nest within an expression - otherwise something like a + b * c would be ambiguous about which operation runs first.
The hierarchy expression > term > factor is designed to respect the precedence and associativity rules in arithmetic expressions:
- Factors are evaluated first because they are the most basic units.
- Terms are evaluated next, allowing multiplication and division to occur before addition and subtraction.
- Expressions combine terms, respecting the order of operations and making the final evaluation of the entire arithmetic or logical statement.
Example - consider the expression a + b * c - d / e:
- Factors: a, b, c, d, e
- Terms: b * c, d / e
- Expression: a + (b * c) - (d / e)
(* Expressions *)
expression = term , { (arithmetic_operator | comparison_operator | logical_operator) , term } ;
term = factor , { (arithmetic_operator) , factor } ;
factor = [unary_operator] ( number | variable | array_access | "(" , expression , ")" | function_call ) ;
function_call = identifier , "(" , [ expression , { "," , expression } ] , ")" ;
Distinction between expression and statement:
- Expression: Produces a value. For example,
x + 1,3 * 5, or even a function call likef(3)are expressions. - Statement: Performs an action. For example,
int x = 5;declares a variable and initializes it, which is an action, not something that evaluates to a value directly.
(* Statements *)
statement = expression_statement | compound_statement | if_statement | while_statement | function_definition | variable_declaration | assignment_statement ;
expression_statement = expression , ";" ;
compound_statement = "{" , { statement } , "}" ;
Every rule introduced piece by piece in the sections above - comments, variables, arrays, characters, pointers, types, operators, control flow, functions, expressions and statements - is a fragment of one single grammar. This is the complete picture, all in one place.
(* Comments - will be removed by the tokenizer *)
comment = single_line_comment | multi_line_comment ;
single_line_comment = "//" , *([^"\n"]) ;
multi_line_comment = "/*" , *([^\*] | "*" , [^\/]) , "*/" ;
(* Program *)
program = { statement }
statement = variable_declaration | variable_assignment | compound_statement | if_statement | while_statement | function_definition | return_statement ;
compound_statement = "{" , { statement } , "}" ;
if_statement = "if" , "(" , expression , ")" , statement , { "else if" , "(" , expression , ")" , statement } , [ "else" , statement ] ;
while_statement = "while" , "(" , expression , ")" , statement ;
function_definition = type , identifier , "(" , [ parameter_list ] , ")" , compound_statement ;
variable_declaration = type , identifier , [ "=" , expression ] , ";" ;
variable_assignment = (variable | array_access | pointer) , "=" , expression , ";" ;
(* Expression *)
expression = operand , { (arithmetic_operator | comparison_operator | logical_operator) , operand } ;
operand = number | boolean_constant | string | character | array_declaration | [unary_operator] (number | variable | array_access | function_call | "(" , expression , ")") ;
number = digit , *digit ;
function_call = identifier , "(" , [ expression , { "," , expression } ] , ")" ;
variable = identifier ;
identifier = * ("a" .. "z" | "A" .. "Z" | "0" .. "9" | "_") ;
(* Function *)
parameter_list = parameter , { "," , parameter } ;
parameter = type , identifier ;
return_statement = "return" , expression , ";" ;
(* Types *)
types = default_types | pointer_types | array_types ;
default_types = "bool" | "int" | "char" | "void" ;
pointer_types = default_types , { "*" } ; (* Supports multiple levels of pointers *)
array_types = (default_types | pointer_types) , "[" , number , "]" ;
(* Characters/Strings *)
character = "'" , char_content , "'" ;
char_content = letter | digit | special_char | escape_sequence ;
special_char = "!" | "@" | "#" | "$" | "%" | "^" | "&" | "*" | "(" | ")" | "-" | "_" | "=" | "+" | "{" | "}" | "[" | "]" | ";" | ":" | "'" | "\"" | "<" | ">" | "," | "." | "/" | "?" | "\\" | "|" ;
escape_sequence = "\\" , ("n" | "t" | "\\" | "'" | "\"" ) ;
string = '"' , { letter | digit | special_char | escape_sequence | " " } , '"' ;
(* Array *)
array_declaration = "{" , [ character | number | boolean_constant ] , "}"
array_access = identifier , "[" , expression , "]" ;
(* Arithmetic/Logical/Comparison Operators *)
logical_operator = "&&" | "||" ;
arithmetic_operator = "+" | "-" | "*" | "/" | "%" ;
comparison_operator = "==" | "!=" | "<" | ">" | "<=" | ">=" ;
unary_operator = "!" | "&" | "*" | "-" ;
(* Basics *)
letter = "a" .. "z" | "A" .. "Z" ;
digit = "0" .. "9" ;
underscore = "_" ;
number = digit , *digit ;
boolean_constant = "true" | "false" ;
The compiler turns source text into assembly in three successive phases. The first two are tokenization and parsing - handled here.
The tokenizer reads the raw source text character by character and groups characters into tokens - the smallest meaningful units of the language. Whitespace and comments are discarded at this stage; everything else is classified by type:
| Type | Examples |
|---|---|
keyword | int, if, while, return, true |
identifier | x, result, myFunc |
number | 0, 42, 1024 |
operator | +, -, ==, <=, && |
punctuation | {, }, (, ), ;, , |
string | "hello" |
character | 'a' |
For example, int x = 5 + y; produces the flat token list:
[keyword: int] [identifier: x] [operator: =] [number: 5] [operator: +] [identifier: y] [punctuation: ;]
The parser reads that flat token list from left to right and builds an
Abstract Syntax Tree (AST) - a tree whose shape mirrors the
grammatical structure of the program. Each node has a type field and
type-specific children. Unlike the raw token stream, the tree makes nesting and
precedence explicit: you can see at a glance that 5 + y is the
value being assigned to x.
The same example int x = 5 + y; produces this tree:
VariableDeclaration
├── variableType : int
├── variableName : x
└── value : expression
└── elements
├── operand { value: 5 }
├── operator { type: arithmeticOperator, value: "+" }
└── operand { value: variable { variableName: "y" } }
The parser is a hand-written recursive-descent parser: each grammar
rule (from the EBNF defined in the language section) corresponds directly to a
function - parse_statement, parse_expression,
parse_operand, and so on. These functions call each other recursively,
consuming tokens as they go, until the whole program has been turned into a tree.
The resulting AST is what the code generator (next section) walks to emit assembly instructions.
Try it yourself - write some code below, then click Tokenize to see the token stream, or To AST to see the full tree:
The code generator is the third and final compiler phase. It walks the AST produced by the parser and emits assembly instructions for the custom CPU, one AST node at a time.
The generated code follows a fixed register contract so that every piece of generated assembly can assume the same invariants:
| Register | Role |
|---|---|
r0 | Always 0 - set once at startup, never written again |
r1 | Primary scratch / expression result / return value |
r2 | Secondary scratch (right-hand side of binary ops) |
r3 | Constant 1 - used for stack push/pop arithmetic |
r29 | Stack pointer (sp) - points to the top of the stack; grows downward |
r30 | Frame pointer (fp) - base of the current function's local variables |
r31 | Return address - written by JAL |
Before any user code runs, the generator emits a fixed preamble that sets up the registers
above, then calls main().
After main returns the CPU spins in an infinite loop:
set r0, 0 -- zero register
set r29, 65535 -- stack pointer = top of RAM
set r30, 65535 -- frame pointer = sp
set r3, 1 -- constant 1 - used for stack push/pop
jal r31, main -- call main()
__halt:
jal r31, __halt -- spin forever after main returns
Each function call creates a new stack frame between the frame pointer and the stack pointer. Arguments are pushed by the caller before the call; local variables are pushed by the callee as they are declared.
high addresses ┌──────────────┐ │ arg N │ ← pushed by caller (left-to-right, so first arg is deepest) │ ... │ │ arg 1 │ │ return addr │ ← r31 saved here by callee prologue (fp + 2) │ saved fp │ ← old r30 saved here by callee (fp + 1) │ local var 1 │ ← r30 (frame pointer) points here (fp + 0) │ local var 2 │ (fp - 1) │ ... │ │ local var N │ ← r29 (stack pointer) points here └──────────────┘ low addresses
Variable declaration - int x = 5;
set r1, 5 -- evaluate initialiser into r1
sd r30, r1, 0 -- store at mem[fp + 0] (declare x)
sub r29, r29, r3 -- push slot onto stack
The compiler records that x lives at offset 0 from fp in its symbol table.
Every subsequent read or write of x uses that offset.
If / else - if (a > b) { ... } else { ... }
ld r1, r30, 0 -- read a
ld r2, r30, 1 -- read b into r2
bgt r1, r2, then_0 -- if a > b, jump to then-branch
[else body] -- fall-through: else body
jal r31, end_if_0 -- skip then-branch
then_0:
[then body]
end_if_0:
While loop - while (i < 10) { i = i + 1; }
while_start_0:
ld r1, r30, 0 -- read i
set r2, 10
blt r1, r2, while_body_0 -- if i < 10, enter body
jal r31, while_end_0 -- else exit
while_body_0:
[body]
jal r31, while_start_0 -- loop back
while_end_0:
Function call - max(x, y)
ld r1, r30, 0 -- evaluate arg 1 (x)
sd r29, r1, 0 -- push arg 1
sub r29, r29, r3
ld r1, r30, 1 -- evaluate arg 2 (y)
sd r29, r1, 0 -- push arg 2
sub r29, r29, r3
jal r31, max -- call max()
set r2, 2 -- pop 2 args
add r29, r29, r2
-- return value is now in r1
Function definition - prologue & epilogue
max:
sd r29, r31, 0 -- save return address
sub r29, r29, r3
sd r29, r30, 0 -- save caller's frame pointer
sub r29, r29, r3
add r30, r29, r0 -- set new frame pointer
[body]
max_epilogue:
add r29, r30, r0 -- discard locals (sp = fp)
set r2, 1
add r29, r29, r2 -- step past saved fp slot
ld r30, r29, 0 -- restore caller's frame pointer
add r29, r29, r2 -- step past saved ra slot
ld r31, r29, 0 -- restore return address
add r29, r29, r2
jr r31 -- return to caller
The CPU has a native MUL instruction, so * compiles directly to
MUL r1, r1, r2 - no helper call. There is still no divide
instruction, so the code generator always appends a software helper, __div,
at the end of the output. Whenever the source uses /, the generator emits a
JAL r31, __div call, which expects its two operands in
r1 and r2 and leaves the result in r1:
| Helper | Algorithm |
|---|---|
__div | Repeated subtraction: subtract r2 from r1 until it goes below zero, counting iterations |
Try it yourself - write some source code below, then click Generate Assembly to see the full assembly output. Click To Machine Code to convert it to binary machine code.
Below, in grey, we add hardware into the mix to help visualize the output of the computer and give it inputs.
So far the CPU can compute and store results in RAM - but it has no way to talk to the outside world. This section explains how a screen and a keyboard are wired in, and why no new instructions are needed to use them.
Instead of dedicated I/O instructions, the CPU reuses its existing SD and
LD instructions. Certain memory addresses are simply wired to hardware devices
rather than to RAM. Writing to one of those addresses sends data to a device; reading from one
returns device state.
A memory controller sits between the CPU and all memory. It looks at the address bus and activates the correct device:
| Address range | Device |
|---|---|
| 0x0000 - 0x3FFF | Screen framebuffer (16 384 words) |
| 0x4000 | Keyboard data register - ASCII code of last key pressed |
| 0x4001 | Keyboard status register - 1 = key available, 0 = none |
| 0x4002 - 0x4007 | Display registers - x, y, width/char, height, color, op (writing op fires the draw command) |
| 0x4008 - 0x4FFF | Reserved for future hardware |
| 0x5000 - 0xFFFF | User RAM (globals + stack) |
The controller is purely combinational - three comparators and three multiplexers:
screen_cs = (address < 0x4000) io_cs = (address >= 0x4000) AND (address < 0x5000) ram_cs = (address >= 0x5000)
The screen is built in two successive phases that share the same address region
(0x0000-0x3FFF) and the same SD instruction. Only the display
controller's interpretation of the memory contents changes.
Phase 1 - Character terminal
Each word in the buffer holds one ASCII code. The display controller reads the buffer continuously, looks up each code in a glyph ROM (pre-stored 8×16 bitmaps for every printable character), and renders the corresponding glyph pixel-by-pixel. The CPU never deals with pixels at all.
-- print 'H' at row 2, col 5 (address = 2×80 + 5 = 165) set r1, 72 -- ASCII 'H' set r2, 165 -- screen address sd r2, r1, 0 -- mem[165 + 0] ← 72
Phase 2 - Pixel framebuffer (upgrade for graphics / Tetris)
The same 16 384-word buffer is reinterpreted as a 128×128 pixel display in
RGB-565 (5 bits red, 6 bits green, 5 bits blue - one full 16-bit color per word,
no bit-packing). Rather than having the CPU compute pixel addresses and issue one
SD per pixel, drawing goes through a dedicated display
controller command interface: the CPU latches x, y,
width/char, height and color into
memory-mapped registers 0x4002-0x4006, then fires the command by writing
an op selector to 0x4007.
op = 0 -> DRECT (filled rectangle: x, y, width, height, color) op = 1 -> DCHAR (8x8 bitmap character: x, y, char code, color)
Each command draws the whole shape in a single clock tick using up to 128 parallel write ports, instead of one pixel at a time. See "Pixel Screen", "Display Controller" and "Draw from the CPU" below for the full circuit.
The simplest approach is polling: the keyboard hardware writes the ASCII code
of the last key pressed into 0x4000 and sets a status flag at
0x4001. The CPU loops on the status register and reads the key when it is ready.
wait: ld r1, r0, 0x4001 -- read status
beq r1, r0, wait -- loop until status != 0
ld r2, r0, 0x4000 -- read ASCII code
set r3, 0
sd r0, r3, 0x4001 -- clear the status flag
A more efficient alternative is interrupts: the keyboard hardware forces the CPU to jump to a handler the moment a key is pressed. This requires an interrupt line and a return-from-interrupt mechanism - recommended as a later extension.
- Memory controller - routes addresses to screen, I/O registers, or RAM
- Screen buffer - RAM at
0x0000-0x3FFF, writable by the CPU - Glyph ROM + character display controller - renders ASCII to screen (terminal)
- Standard library -
putchar,printemitted as a C preamble - Keyboard encoder + registers - key-to-ASCII + polling registers at
0x4000-0x4001 - Standard library -
getcharemitted as a C preamble - Pixel display controller - upgrades the screen to a 128×128 RGB-565 framebuffer
- Display registers - x/y/width-char/height/color latches + op trigger at
0x4002-0x4007(DRECT/DCHAR command interface) - Parallel write ports - draw a full rectangle or character in a single clock tick instead of one pixel at a time
The terminal is the simplest form of output: text only. Rather than adding dedicated I/O
instructions, the CPU reuses its existing SD instruction - certain memory
addresses are wired to the display instead of RAM. Writing an ASCII code to one of those
addresses puts the corresponding character on screen.
| Address range | Size | Purpose |
|---|---|---|
0x0000 - 0x3FFF | 16 384 words | Screen framebuffer (character terminal) |
0x4000 - 0x4FFF | 4 096 words | I/O registers (keyboard, display control) |
0x5000 - 0xFFFF | ≈ 44 K words | User RAM (globals + stack) |
The screen is a 80 × 25 character grid - 2 000 cells in total. Each cell holds one ASCII code. The address of the cell at column c, row r is:
address = 0x0000 + r * 80 + c
A normal SD dest, src, imm instruction is used. The hardware detects that the
computed address falls below 0x4000 and routes the write to the screen buffer
instead of user RAM - no special instruction needed.
-- print 'H' (ASCII 72) at row 2, col 5 → address = 2*80 + 5 = 165 set r1, 72 -- ASCII code for 'H' set r2, 165 -- target screen address sd r2, r1, 0x0000 -- screen[165 + 0] ← 'H'
Inside the Memory Unit subcircuit, an adder computes the final write address
(reg[dest] + imm). A comparator checks whether that address is below
0x4000:
- address < 0x4000 → write enable is routed to the Screen buffer; RAM is suppressed.
- address ≥ 0x4000 → write enable is routed to RAM; Screen is suppressed.
The three screen write signals (enable, address, data) are also fanned out to a top-level Terminal memory block visible in the diagram below. This mirror is what the JavaScript renderer reads every 100 ms to update the display.
The ROM is pre-loaded with the following program. It writes each ASCII code directly to a
screen-buffer address using SD r0, r1, offset (with r0 = 0 as the
base), then halts by jumping to itself forever.
set r0, 0 -- base address of the screen buffer
set r1, 72 -- 'H' (ASCII 72)
sd r0, r1, 0
set r1, 101 -- 'e'
sd r0, r1, 1
set r1, 108 -- 'l'
sd r0, r1, 2
set r1, 108 -- 'l'
sd r0, r1, 3
set r1, 111 -- 'o'
sd r0, r1, 4
set r1, 44 -- ','
sd r0, r1, 5
set r1, 32 -- ' '
sd r0, r1, 6
set r1, 87 -- 'W'
sd r0, r1, 7
set r1, 111 -- 'o'
sd r0, r1, 8
set r1, 114 -- 'r'
sd r0, r1, 9
set r1, 108 -- 'l'
sd r0, r1, 10
set r1, 100 -- 'd'
sd r0, r1, 11
set r1, 33 -- '!'
sd r0, r1, 12
halt: jal r0, 0 -- loop forever (target = PC + 0 = PC)
The box below is updated automatically as the circuit runs. Each cell maps directly to one word in the Terminal memory: address 0 is top-left, address 1999 is bottom-right. Non-printable values (including uninitialized memory) are shown as spaces.
Keyboard input uses the same memory-mapped I/O strategy as the screen. Two addresses in the I/O region are reserved:
0x4000 Data Register - ASCII code of the last key pressed (read) 0x4001 Status Register - 1 = new key available, 0 = none (read / write)
The CPU uses its existing LDI and LD instructions to read
those addresses and SD to clear the status register after consuming the
key. No new instructions are needed.
The Memory Unit subcircuit was extended in two places:
-
Read-side mux - A comparator checks whether the read address falls
in the I/O range
0x4000 - 0x4FFF. If so, data comes from the Keyboard register file instead of RAM. The low address bit selects the register: bit 0 = 0 → data register, bit 0 = 1 → status register. BothLD(address =reg[src1] + imm) andLDI(address =imm) have their own mux. -
Write-side three-way decoder - The existing two-way split
(screen vs. RAM) becomes a three-way split. An
SDto an address below0x4000writes to Screen; between0x4000and0x4FFFwrites to Keyboard (used to clear the status register);0x5000and above writes to RAM.
wait_key: ldi r1, 0x4001 -- read keyboard status beq r1, r0, wait_key -- loop while status == 0 (no key) ldi r2, 0x4000 -- read ASCII code set r3, 0 sd r0, r3, 0x4001 -- clear status (write 0 to 0x4001) -- r2 now holds the ASCII code
The Keyboard register panel below shows the live values as seen by
the circuit. Pressing a key updates the data register and sets the status
to 1; running the SD instruction clears it back to 0.
With keyboard I/O in place, the simplest useful program is an echo loop: poll the status register, consume each key, and append its ASCII code to the terminal buffer. This is the program currently loaded in the ROM above.
set r0, 0 -- zero register (permanent)
set r1, 0 -- r1 = cursor (next write position in screen buffer)
set r4, 1 -- r4 = 1 (cursor increment)
poll: ldi r2, 0x4001 -- r2 ← status register (1 = new key available)
beq r2, r0, poll -- spin while status == 0 (no new key)
ldi r3, 0x4000 -- r3 ← data register (ASCII code of the key)
sd r0, r0, 0x4001 -- mem[0x4001] ← 0 (clear status register)
sd r1, r3, 0 -- mem[r1] ← r3 (write character to terminal)
add r1, r1, r4 -- r1++ (advance cursor)
jal r5, poll -- unconditional jump back to poll
-
Preamble -
r0is pinned to 0 and used wherever a zero constant is needed.r1is the write cursor: it starts at address 0 (the first cell of the screen buffer, range 0x0000-0x3FFF) and walks forward with every keystroke.r4holds 1 so the cursor can be incremented with a singleadd. -
Polling loop -
ldi r2, 0x4001reads the memory-mapped status register via the new keyboard read-side mux: because 0x4001 ≥ 0x4000 and < 0x5000, the address falls in the keyboard I/O range and the data comes from the Keyboard register file instead of RAM. Thebeqre-executes theldias long as status is 0. -
Consume the key - once status is 1,
ldi r3, 0x4000reads the ASCII code from the data register. The status must be cleared immediately:sd r0, r0, 0x4001stores 0 at address 0x4001 via the keyboard write decoder; failing to clear it would cause the same key to be read again on the next iteration. -
Write to terminal -
sd r1, r3, 0stores the ASCII code at addressr1. Becauser1 < 0x4000the write-side decoder routes this to the Screen memory, and the terminal widget mirrors it in real time. -
Advance & repeat -
add r1, r1, r4increments the cursor;jal r5, pollis an unconditional PC-relative jump back topoll(offset -12 words from thejalinstruction). The return address is saved in the scratch registerr5rather thanr0so the zero register stays intact.
Below, in yellow, we will construct the Operating System. It serves as the interface between the hardware and the user's programs.
Every function listed here is emitted automatically by the compiler as a preamble before the user's code. User programs call them like ordinary functions - no special syntax is needed.
| Signature | Returns | Description |
|---|---|---|
getchar() |
int |
Blocks until a key is pressed, reads its ASCII code from 0x4000, clears the status register at 0x4001, and returns the code. Use in interactive programs. |
key_pressed() |
int |
Returns the ASCII code of the pending key and clears the status, or returns 0 immediately if no key is available. Does not block. Use in game loops. |
readline(int* buf, int max) |
int |
Reads up to max - 1 characters into buf, echoing each to the terminal. Handles backspace. Stops on Enter or when the buffer is full. Null-terminates the result. Returns the number of characters read. |
| Signature | Returns | Description |
|---|---|---|
putchar(int c) |
void |
Writes one ASCII character at cursor_pos and advances the cursor. A '\n' moves the cursor to the start of the next row without writing a character. Calls scroll() if the cursor goes past row 24. |
print(int* s) |
void |
Writes a null-terminated string by calling putchar for each character until the '\0' terminator is reached. |
scroll() |
void |
Shifts rows 1-24 up to rows 0-23, blanks row 24 with spaces, and sets cursor_pos to the start of row 24. Called automatically by putchar on overflow. |
clear_terminal() |
void |
Fills all 2 000 character cells with ' ' and resets cursor_pos to 0. |
| Signature | Returns | Description |
|---|---|---|
MUL (native instruction) |
int |
Returns a × b directly in hardware. Emitted automatically when the source uses * - no software helper involved. The old __mul repeated-addition helper still exists in compiled output but is no longer called. |
__div(int a, int b) |
int |
Returns the integer quotient a / b via repeated subtraction. Called automatically when the source uses /. |
__mod(int a, int b) |
int |
Returns a mod b as a - (a/b)×b. Called automatically when the source uses %. |
The three sub-sections below walk through how getchar/key_pressed/readline, the terminal-writing functions, and the arithmetic helpers are each actually implemented in the C-like language.
getchar() waits until a key is pressed (status register set to 1), then clears the status, and returns the character.
int getchar() {
while (*(int*)0x4001 == 0) {} // busy-wait until status == 1
int c = *(int*)0x4000; // read ASCII code
*(int*)0x4001 = 0; // clear status flag
return c;
}
key_pressed() returns the ASCII code if a key is available, or 0 if not.
int key_pressed() {
if (*(int*)0x4001 == 0) return 0; // no new key
int c = *(int*)0x4000;
*(int*)0x4001 = 0; // clear status
return c;
}
readline(buf, max) reads characters one by one into buf until
Enter is pressed or max - 1 characters have been collected.
It echoes each character to the terminal as it arrives and handles
backspace (ASCII 8) by erasing the last character from both the
buffer and the screen.
The buffer is always null-terminated on return.
int readline(int* buf, int max) {
int len = 0;
int limit = max - 1;
while (len < limit) {
int c = getchar();
if (c == '\n' || c == '\r') {
putchar('\n');
break;
}
if (c == 8) { // backspace (ASCII 8)
if (len > 0) {
len = len - 1;
cursor_pos = cursor_pos - 1;
*(int*)(0x0000 + cursor_pos) = ' '; // erase from screen
}
} else {
buf[len] = c;
len = len + 1;
putchar(c); // echo to terminal
}
}
buf[len] = 0; // null terminator
return len;
}
- Echo - every accepted character is immediately written to the terminal so the user can see what they are typing. Backspace is not echoed; instead the cursor position is decremented and the cell is overwritten with a space.
-
Buffer limit - the loop stops at
max - 1characters to leave room for the null terminator. The caller controls the maximum line length by choosingmax. -
Return value - the number of characters stored (not counting
the null terminator), so the caller can tell whether the line is empty without calling
strlen.
putchar - write one character at the cursor, then advance it.
void putchar(int c) {
if (c == '\n') { // newline
cursor_pos = cursor_pos + (80 - (cursor_pos % 80)); // jump to next row
} else {
*(int*)(0x0000 + cursor_pos) = c;
cursor_pos = cursor_pos + 1;
}
if (cursor_pos >= 2000) { scroll(); }
}
print - write a null-terminated string.
void print(int* s) {
while (*s != 0) {
putchar(*s);
s = s + 1;
}
}
scroll - shift all rows up by one, blank the bottom row.
void scroll() {
int i = 0;
while (i < 1920) { // copy rows 1-24 → rows 0-23
*(int*)(0x0000 + i) = *(int*)(0x0000 + i + 80);
i = i + 1;
}
while (i < 2000) { // blank row 24
*(int*)(0x0000 + i) = ' ';
i = i + 1;
}
cursor_pos = 1920; // cursor at start of row 24
}
The first loop copies 1 920 characters (rows 1-24) one position upward. The second loop fills the freed bottom row with spaces. After scrolling, the cursor is placed at the beginning of the now-empty last row.
Because the cursor is a flat index into a 1D array, converting between (row, col) and the index requires only addition and modulo:
| Goal | Formula |
|---|---|
| Index from (row, col) | row * 80 + col |
| Row from index | index / 80 |
| Column from index | index % 80 |
| Columns left on current row | 80 - (cursor_pos % 80) |
Fills every character cell with a space and resets the cursor to 0. This is the software
clear - the hardware clear (0x4002 ← 0x01) zeros the pixel
framebuffer and is used in graphics mode instead.
void clear_terminal() {
int i = 0;
while (i < 2000) {
*(int*)(0x0000 + i) = ' ';
i = i + 1;
}
cursor_pos = 0;
}
The CPU instruction set has a native MUL instruction (opcode 16); *
compiles directly to a single MUL r1, r1, r2 instruction, no
function call involved. DIV and MOD still have no hardware
instruction and remain implemented in software as ordinary functions. The OS emits them
as a fixed preamble appended after main - every compiled program gets them
automatically, and the compiler calls them whenever the source uses / or
%. The old __mul software helper (shown below) is still emitted
into that preamble for reference, but nothing calls it anymore.
All math helpers use r1 and r2 as inputs and leave their result in r1, following the same calling convention as every other function.
| Function | Signature | Algorithm |
|---|---|---|
MUL (native instruction) |
r1 ← r1 × r2 |
Computed directly by the ALU's hardware multiplier. O(1) - single instruction, no loop. |
__div |
r1 ← r1 ÷ r2 |
Repeated subtraction - subtract r2 from r1 until it goes below r2, counting iterations. O(r1/r2). |
__mod |
r1 ← r1 mod r2 |
Repeated subtraction - subtracts b from a until a < b; the residual is the remainder. O(a/b). |
Multiplication (unused, kept for reference) - __mul(a, b): the old software helper added a to an accumulator b times. Since * now compiles to a single MUL instruction, this helper is dead code - still emitted in the preamble, never called.
__mul: -- inputs: r1 = a, r2 = b
sd r29, r31, 0 -- save return address
sub r29, r29, r3
sd r29, r30, 0 -- save frame pointer
sub r29, r29, r3
add r30, r29, r0 -- new frame pointer
add r4, r0, r0 -- r4 = 0 (accumulator)
__mul_loop:
beq r2, r0, __mul_done -- if b == 0, done
add r4, r4, r1 -- acc += a
sub r2, r2, r3 -- b--
jal r31, __mul_loop
__mul_done:
add r1, r4, r0 -- return value in r1
add r29, r30, r0 -- epilogue
set r2, 1
add r29, r29, r2
ld r30, r29, 0
add r29, r29, r2
ld r31, r29, 0
add r29, r29, r2
jr r31 -- return
Integer division - __div(a, b): counts how many times b fits into a.
__div: -- inputs: r1 = a, r2 = b
[prologue]
add r4, r0, r0 -- r4 = 0 (quotient)
__div_loop:
blt r1, r2, __div_done -- if a < b, done
sub r1, r1, r2 -- a -= b
add r4, r4, r3 -- quotient++
jal r31, __div_loop
__div_done:
add r1, r4, r0 -- return quotient in r1
[epilogue]
Modulo - __mod(a, b): subtracts b from a repeatedly until a < b; the residual is the remainder.
__mod: -- inputs: r1 = a, r2 = b
sd r29, r31, 0 -- save return address
sub r29, r29, r3
beq r2, r0, __mod_end -- divide by zero: return a unchanged
__mod_loop:
blt r1, r2, __mod_end -- if a < b, remainder found
sub r1, r1, r2 -- a -= b
beq r0, r0, __mod_loop -- loop back (r0 == r0 is always true)
__mod_end:
add r29, r29, r3 -- restore sp
ld r31, r29, 0 -- restore return address
add r29, r29, r3
jr r31 -- return
The OS functions above only matter if they run correctly on the actual CPU circuit. This test loads a compiled program into the processor built earlier and drives it live - type on the on-screen keyboard, watch the instruction trace update, and see the output appear on the terminal.
OS Program - write C-like code below, then step through the pipeline.
Edit the assembly below if needed, then load it into the ROM.
This is a complete program, not just a test: it uses readline, print, and the arithmetic helpers from the OS above, compiled and run on the same CPU circuit built earlier. Below, you can play a guessing game - guess the number between 1 and 100:
Note that the circuit is hidden to make it faster by avoiding rendering overhead.
Below in red, we will build a pixel screen that will enable us to display graphics. This is the last step before building Tetris.
The terminal above can only display text. Real graphics - lines, shapes, images - need the same memory-mapped address range reinterpreted as individual colored pixels instead of character codes.
The screen buffer occupies addresses 0x0000-0x3FFF (16 384 words). Each word is one pixel.
Writing a value with SD to an address in that range puts a color on screen.
The resolution is 128 x 128 pixels, and each 16-bit value is decoded as
RGB-565: 5 bits red, 6 bits green, 5 bits blue.
The memory write port is 2048 bits wide (one full row), and we use a mask to tell on which pixels to write.
A range mask is built combinationally from X start and Length using two
right-shifts on an all-ones constant and a NOT+AND, so only pixels
[X, X+Length) are written. The write button gates the clock;
wr0data is Color broadcast to all 128 pixel slots.
For example, above, you could set the X and Y coordinates to 20, the length to 25, and the color to 0xFFFF to make a horizontal white line appear on the screen.
Writing one pixel at a time with the row-write circuit above would make even a small rectangle painfully slow. This circuit fills an entire rectangular region in a single clock tick by writing many rows in parallel.
The circuit is hidden (displaying it freezes the page due to the large number of components)
but it runs in the background. Use the controls below to draw a filled rectangle in one clock tick.
Constraint: Y + Height must be ≤ 127 (7-bit y-end).
Note that the rectangle is drawn in a single clock tick - it works by having 128 row write circuits write simultaneously.
Rectangles are the easy case: a solid block of one color. Rendering readable text on a pixel screen needs something more specific - a way to turn a character code into the exact pattern of pixels that draws its shape.
A bitmap character is a fixed-size image of a letter stored as a grid of
on/off pixels. Each character fits in an 8×8 cell. Each of the 8 rows is
stored as a single byte - 8 bits, one per pixel. Bit 7 (MSB) is the leftmost pixel, bit 0
(LSB) is the rightmost. A 1 bit means "draw a foreground pixel here";
a 0 means "leave the background alone."
For example, the letter 'A' decoded row by row:
Row 0: 0x38 = 0 0 1 1 1 0 0 0 → ··███··· Row 1: 0x6C = 0 1 1 0 1 1 0 0 → ·██·██·· Row 2: 0xC6 = 1 1 0 0 0 1 1 0 → ██···██· Row 3: 0xC6 = 1 1 0 0 0 1 1 0 → ██···██· Row 4: 0xFE = 1 1 1 1 1 1 1 0 → ███████· Row 5: 0xC6 = 1 1 0 0 0 1 1 0 → ██···██· Row 6: 0xC6 = 1 1 0 0 0 1 1 0 → ██···██· Row 7: 0x00 = 0 0 0 0 0 0 0 0 → ········
The full font is stored in a character ROM, indexed by
charCode × 8 + row. To draw a character, the circuit reads those 8 bytes and
uses each one as a write-enable mask - a pattern that tells the screen which pixels in that
row to light up with the chosen color.
The circuit is hidden (displaying it freezes the page due to the large number of components)
but it runs in the background. Use the controls below to draw a character in one clock tick.
All 8 row bitmaps are read from the character ROM simultaneously; 8 parallel write ports
write all rows at once - the same 1-tick approach used for rectangles.
Constraint: Y + 8 must be ≤ 127.
The character is drawn in a single clock tick using 8 simultaneous write ports - one per row of the 8×8 character cell. Each write port is driven by a char-row-ctrl subcircuit that reads one row bitmap from the character ROM and expands it into a 2048-bit write-enable mask.
Rectangles and characters were built as two separate circuits above, each with its own controls. A real program needs one interface it can drive uniformly - a single command format that says which of the two operations to run and with what parameters.
The display controller (circuit 050) unifies DRECT and DCHAR
behind a single interface. The op input selects the operation; the same pixel screen
memory is shared between both. When op = 0, the circuit draws a filled
rectangle using the same 128-port parallel approach as circuit 048. When op = 1,
it draws an 8×8 bitmap character using the same 8-port ROM approach as circuit 049.
Only one set of write ports fires per command - the clock is gated by
base AND NOT(op) for DRECT and base AND op for DCHAR.
| Field | Bits | DRECT | DCHAR |
|---|---|---|---|
op | 1 | 0 - draws rectangle | 1 - draws character |
x | 7 | X start (0-127) | |
y | 7 | Y start (0-127) | |
width / char | 7 | Width (0-127) | ASCII code |
height | 7 | Height (0-127) | unused |
color | 16 | RGB-565 color | |
The circuit is hidden (136 write ports would freeze the layout). Use the controls below to draw on the shared pixel screen.
Below, in yellow, we will finally put everything together. However, we cannot build Tetris because the simulation is running too slowly.
Circuit 051 connects the CPU from circuit 045 to the display controller from circuit 050. The CPU draws by writing each field as a raw value to its own memory-mapped register - no bit-packing, so the C code needs no multiplication:
| Address | Content | Effect |
|---|---|---|
0x4002 | x (0-127) | Latched |
0x4003 | y (0-127) | Latched |
0x4004 | width (DRECT) or char code (DCHAR) | Latched |
0x4005 | height (DRECT only) | Latched |
0x4006 | color[15:0] (RGB-565) | Latched |
0x4007 | op | Fires the command - op=0 DRECT, op=1 DCHAR |
Because r6 = 0x4002 and SD r6, r5, imm writes to r6 + imm,
the offsets 0-5 hit the six registers in order; writing op at offset 5
(0x4007) latches nothing new but fires the draw using the already-latched fields.
Each field latches into its own flip-flop inside the display controller, so the previous
bit-packing (and the slow multiplications it required) is gone.
The circuit is hidden; use the assembly editor below to write programs and load them into the ROM.
Choose an example or write your own, then click Run to compile, flash the ROM, and start execution.
In order to go faster, we have to add a new abstraction layer: an emulator.
A software emulator interprets the compiled assembly in pure JavaScript -
no DigitalJS gates, no clock ticks. Programs that take minutes in the circuit finish in
milliseconds here, letting you test logic, spot bugs, and watch animations play in real time.
The emulator implements the same machine model as circuit 051: 32 × 16-bit registers,
64 K × 16-bit word-addressed memory, the full 17-opcode ISA, and the six-register display
controller at 0x4002-0x4007. The same C programs that run on the circuit
run unchanged on the emulator.
Unlike the circuit, the emulator has no clock tick to drive it - each browser animation frame runs a bounded batch of instructions, then hands control back to the browser to render and check for input. The SPEED slider above sets that batch size directly: it is the same number as the loop's step budget below.
Scroll with Ctrl/Cmd held to zoom, drag to pan, or use the buttons above. The dashed green edges are the loop-backs: the inner one repeats while the frame's step budget remains, the outer one schedules the next animation frame once it runs out (or the program halts). The Store box is the one branch with further structure inside it - see the annotated code below for exactly how it picks between a keyboard write, a draw command, and a plain memory write.
function _emulatorRunBudget() {
var steps = 0;
while (steps < budget) { // budget = SPEED slider value
var t = code[pc >> 1].instr.split(/[ ,]+/);
var op = t[0], rd = ..., rs = ..., r3 = ...;
var nextpc = (pc + 2) & M; // default: advance one instruction
switch (op) {
case 'add': reg[rd] = (reg[rs] + reg[r3]) & M; break;
case 'sd':
var a = (reg[rd] + imm) & M, v = reg[rs] & M;
if (a === 0x4001) { /* consume key from queue */ }
else if (a === 0x4007) { if (v === 0) emulatorDrawRect(); else emulatorDrawChar(); }
else { mem[a] = v; } // ordinary RAM write
break;
case 'beq': if (reg[rd] === reg[rs]) nextpc = (pc + imm) & M; break;
// ... one case per opcode
}
if (nextpc === pc) { emulatorRunning = false; break; } // self-jump = halt
pc = nextpc;
steps++;
}
}
- No device dispatch - memory-mapped addresses like
0x4007(fire draw command) or0x4001(keyboard status) are checked inline inside theld/ldi/sdcases, rather than routed through a separate memory controller like the circuit's. - Halt is generic, not a special opcode - after computing
nextpc, the loop simply checks whether it equals the currentpc. Any instruction that jumps to itself (beq r0, r0, 0, or ajal/jrwith the same effect) is treated as a halt, exactly mirroring how the real ISA has no dedicatedHALTinstruction. - The step budget is the speed control -
_emulatorFrame()calls this function once perrequestAnimationFrame, then renders and reschedules itself. Programs that halt stop early within their budget; programs with useful infinite loops (animations, keyboard polling) simply run the full budget every frame, so raising the budget is what makes "Max" speed run ~100 000 steps per frame instead of 30.
Choose an example or write your own, then click Run.
The full Tetris game runs on the emulator - no circuit required.
Click Run to compile and start; click Capture
to direct keyboard input to the game.
Controls: a/← left, d/→ right,
w/↑ rotate, s/↓ soft-drop,
x hard-drop.
How the game loop works, at runtime. main() (§8 of
tetris.md) is really two nested loops: an outer loop that spawns a
piece and runs until it locks, and an inner loop that runs once per frame while
that piece is falling, polling the keyboard and, every 40 ticks, applying gravity. There is
no timer interrupt; the CPU just spins through this loop as fast as the emulator steps it.
Scroll with Ctrl/Cmd held to zoom, drag to pan, or use the buttons above.
The dashed green edges are the loop-backs. Whenever the piece is still falling
(it isn't time for gravity yet, or gravity just moved it down), control returns to
Poll input for the next frame. Once a piece locks and any full rows are cleared,
control returns to Spawn a new piece to start the next one. Both loops are
while(1)/while (falling == 1) in the source, so there's no scheduler,
the CPU is simply always inside one of these two loops (see tetris.md §6.8).