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.

SPEED

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
\[ \begin{array}{cc|c|c} \text{A} & \text{B} & \text{A and B} & \text{A nand B} \\ \hline 0 & 0 & 0 & 1 \\ 0 & 1 & 0 & 1 \\ 1 & 0 & 0 & 1 \\ 1 & 1 & 1 & 0 \\ \end{array} \; \; \; \; \; \; \; \; \; \; \; \; \begin{array}{cc|c|c} \text{A} & \text{B} & \text{A or B} & \text{A nor B} \\ \hline 0 & 0 & 0 & 1 \\ 0 & 1 & 1 & 0 \\ 1 & 0 & 1 & 0 \\ 1 & 1 & 1 & 0 \\ \end{array} \; \; \; \; \; \; \; \; \; \; \; \; \begin{array}{cc|c|c} \text{A} & \text{B} & \text{A + B} & \text{A xor B} \\ \hline 0 & 0 & 0 & 0 \\ 0 & 1 & 1 & 1 \\ 1 & 0 & 1 & 1 \\ 1 & 1 & 0 & 0 \\ \end{array} \]

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:

+5 V +0 V Output Input +5 V +0 V Output Input

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:

+5 V +0 V Output Input A Input B

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.

GateBuilt fromOutput is true when...
NOT1 transistorthe input is 0
NAND2 transistorsnot both inputs are 1
ANDNAND + NOTboth inputs are 1
ORNOT + NANDat least one input is 1
NOROR + NOTneither input is 1
XORAND + OR + NANDexactly 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} \] I0 I1 I2 I3 S1=1 S0=0 Out
  • 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.
    To overcome this, we need to build a flip-flop, it will introduce a delay before the stored value changes.
    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 src1 registry whose value is used in a comparison.
  • The src2 registry whose value is used in a comparison.
  • An immediate value imm1 which is used to change the value of the pc registry.

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:

  1. 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.
  2. Decode - inspect the bits to determine the operation (opcode), which registers are involved, and any immediate value encoded in the instruction.
  3. 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.

ComponentDescription
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. sp starts at 65535 (0xFFFF) and moves downward as variables are pushed.
  • A stack overflow occurs if sp reaches the globals region at 0x5000 - 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].

A B C A B C Push Pop C C

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:

  1. Push arguments - the caller pushes each argument onto the stack, left-to-right.
  2. Jump and link - jal r31, fn stores the return address (the instruction after the call) into r31, then jumps to the function.
  3. Prologue - the callee saves r31 and r30 on the stack, then sets r30 = r29 to establish a new frame pointer. Local variables are allocated below this anchor as the function runs.
  4. Epilogue - the callee restores r30 (old fp) and r31 (return address) from the stack, resets r29 = r30 to discard all locals, then jumps back to the caller via r31. 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)):

AddressContentsNote
fp + 4afirst argument (pushed first)
fp + 3bsecond argument (pushed last)
fp + 2saved r31return address
fp + 1saved r30caller's frame pointer
fp + 0first local← r30 (fp) and r29 (sp) after prologue
fp - 1second localsp 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.

RegisterNamePurpose
r0zeroalways 0; set once at startup (SET r0, 0), writes are silently ignored
r1retreturn 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
r29spstack pointer - points to the current top of the stack (grows downward)
r30fpframe pointer - anchors the base of the current function's stack frame
r31rareturn address - written by JAL, jumped to on return

When the processor is powered on or reset, the following happens in order:

  1. 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).
  2. The program counter is reset to 0x0000 - this is the only guaranteed initial state.
  3. The CPU immediately begins the fetch-decode-execute loop - it fetches the 32-bit word at address 0 and executes it.
  4. 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:

  1. Initialize r0 - write 0 into the zero register once. Every other instruction that uses r0 as a source relies on it being 0, so this must happen first.
  2. Initialize the stack pointer - set r29 to the highest usable RAM address (0xFFFF = 65535). The stack will grow downward from there.
  3. Initialize the frame pointer - copy r29 into r30 to establish the base frame before main is entered.
  4. Call main - jump to the user's main function using JAL r31, main, saving the return address in r31.

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/else and 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 bool is 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 char can 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 array
    • numbers[1] or *(numbers + 1), you can get the second value of the array
    • numbers[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; or int** 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
    }
  • You can also add multiple conditions using else if:
  • if (condition 1) {
       // code 1
    } else if (condition 2) {
       // code 2
    } else {
       // code 3
    }
if-else-condition-c-lang
  • Loop while the condition is true with the keyword while:
  • while (condition) {
      // code block
    }
while-loop-c-lang
(* 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 be void.
  • 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:

  1. Factors are evaluated first because they are the most basic units.
  2. Terms are evaluated next, allowing multiplication and division to occur before addition and subtraction.
  3. 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 like f(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:

TypeExamples
keywordint, if, while, return, true
identifierx, result, myFunc
number0, 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:

RegisterRole
r0Always 0 - set once at startup, never written again
r1Primary scratch / expression result / return value
r2Secondary scratch (right-hand side of binary ops)
r3Constant 1 - used for stack push/pop arithmetic
r29Stack pointer (sp) - points to the top of the stack; grows downward
r30Frame pointer (fp) - base of the current function's local variables
r31Return 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:

HelperAlgorithm
__divRepeated 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 rangeDevice
0x0000 - 0x3FFFScreen framebuffer (16 384 words)
0x4000Keyboard data register - ASCII code of last key pressed
0x4001Keyboard status register - 1 = key available, 0 = none
0x4002 - 0x4007Display registers - x, y, width/char, height, color, op (writing op fires the draw command)
0x4008 - 0x4FFFReserved for future hardware
0x5000 - 0xFFFFUser 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.

  1. Memory controller - routes addresses to screen, I/O registers, or RAM
  2. Screen buffer - RAM at 0x0000-0x3FFF, writable by the CPU
  3. Glyph ROM + character display controller - renders ASCII to screen (terminal)
  4. Standard library - putchar, print emitted as a C preamble
  5. Keyboard encoder + registers - key-to-ASCII + polling registers at 0x4000-0x4001
  6. Standard library - getchar emitted as a C preamble
  7. Pixel display controller - upgrades the screen to a 128×128 RGB-565 framebuffer
  8. Display registers - x/y/width-char/height/color latches + op trigger at 0x4002-0x4007 (DRECT/DCHAR command interface)
  9. 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 rangeSizePurpose
0x0000 - 0x3FFF16 384 wordsScreen framebuffer (character terminal)
0x4000 - 0x4FFF4 096 wordsI/O registers (keyboard, display control)
0x5000 - 0xFFFF≈ 44 K wordsUser 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.

TERMINAL - 80×25

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. Both LD (address = reg[src1] + imm) and LDI (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 SD to an address below 0x4000 writes to Screen; between 0x4000 and 0x4FFF writes to Keyboard (used to clear the status register); 0x5000 and 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.

TERMINAL - 80×25
KEYBOARD - Memory-Mapped I/O
0x4000 Data Register 0000000000000000 0x0000
0x4001 Status Register 0000000000000000 0x0000 0
QWERTY LAYOUT
` 1 2 3 4 5 6 7 8 9 0 - =
Tab Q W E R T Y U I O P [ ] \
Caps A S D F G H J K L ; ' Enter
Shift Z X C V B N M , . / Shift
Ctrl Alt SPACE Alt Ctrl

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 - r0 is pinned to 0 and used wherever a zero constant is needed. r1 is the write cursor: it starts at address 0 (the first cell of the screen buffer, range 0x0000-0x3FFF) and walks forward with every keystroke. r4 holds 1 so the cursor can be incremented with a single add.
  • Polling loop - ldi r2, 0x4001 reads 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. The beq re-executes the ldi as long as status is 0.
  • Consume the key - once status is 1, ldi r3, 0x4000 reads the ASCII code from the data register. The status must be cleared immediately: sd r0, r0, 0x4001 stores 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, 0 stores the ASCII code at address r1. Because r1 < 0x4000 the write-side decoder routes this to the Screen memory, and the terminal widget mirrors it in real time.
  • Advance & repeat - add r1, r1, r4 increments the cursor; jal r5, poll is an unconditional PC-relative jump back to poll (offset -12 words from the jal instruction). The return address is saved in the scratch register r5 rather than r0 so 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.

SignatureReturnsDescription
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.
SignatureReturnsDescription
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.
SignatureReturnsDescription
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 - 1 characters to leave room for the null terminator. The caller controls the maximum line length by choosing max.
  • 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:

GoalFormula
Index from (row, col)row * 80 + col
Row from indexindex / 80
Column from indexindex % 80
Columns left on current row80 - (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.

FunctionSignatureAlgorithm
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.

INSTRUCTION TRACE
PC  | not started
TERMINAL - 80×25
QWERTY LAYOUT
` 1 2 3 4 5 6 7 8 9 0 - =
Tab Q W E R T Y U I O P [ ] \
Caps A S D F G H J K L ; ' Enter
Shift Z X C V B N M , . / Shift
Ctrl Alt SPACE Alt Ctrl

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:

TERMINAL - 80×25
QWERTY LAYOUT
` 1 2 3 4 5 6 7 8 9 0 - =
Tab Q W E R T Y U I O P [ ] \
Caps A S D F G H J K L ; ' Enter
Shift Z X C V B N M , . / Shift
Ctrl Alt SPACE Alt Ctrl

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 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.

FieldBitsDRECTDCHAR
op10 - draws rectangle1 - draws character
x7X start (0-127)
y7Y start (0-127)
width / char7Width (0-127)ASCII code
height7Height (0-127)unused
color16RGB-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:

AddressContentEffect
0x4002x (0-127)Latched
0x4003y (0-127)Latched
0x4004width (DRECT) or char code (DCHAR)Latched
0x4005height (DRECT only)Latched
0x4006color[15:0] (RGB-565)Latched
0x4007opFires 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.

QWERTY LAYOUT
` 1 2 3 4 5 6 7 8 9 0 - =
Tab Q W E R T Y U I O P [ ] \
Caps A S D F G H J K L ; ' Enter
Shift Z X C V B N M , . / Shift
Ctrl Alt SPACE Alt Ctrl

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) or 0x4001 (keyboard status) are checked inline inside the ld/ldi/sd cases, 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 current pc. Any instruction that jumps to itself (beq r0, r0, 0, or a jal/jr with the same effect) is treated as a halt, exactly mirroring how the real ISA has no dedicated HALT instruction.
  • The step budget is the speed control - _emulatorFrame() calls this function once per requestAnimationFrame, 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.
SPEED
QWERTY LAYOUT
` 1 2 3 4 5 6 7 8 9 0 - =
Tab Q W E R T Y U I O P [ ] \
Caps A S D F G H J K L ; ' Enter
Shift Z X C V B N M , . / Shift
Ctrl Alt SPACE Alt Ctrl

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