Homemade 8-bit Computer
This post is about a functional 8-bit computer I built using breadboards, jump cables, and logic gates. Specifically, I built this using AND, OR, NOT, NAND and XOR logic gates from 7400-series integrated circuits. Building a computer from these low-level components has been a longstanding goal of mine, and I'm thrilled to have brought it to fruition.
Linked above is a YouTube video I made to showcase the basic design. In that video, I demonstrate how my computer is broken up into many modules. While that video just explains the basic functionality of each module, this article will be diving into excruciating details.
Emulator
Before I begin, here's an accurate emulator of the computer.
Read the comment above the program to see what it does, and then hit "Start" to see the computer in action. You can load and run my programs, or write your own using assembly language. If you're interested, the GitHub page linked above has more information about the assembly language itself.
Background
Before diving in, it's worth noting my background. I'm a Software Engineer who decided to get into electronics about 5 months ago. While I have a grasp of basic computer architecture, I have very little depth in my electrical engineering abilities. Thus, this machine has many flaws, and is not a model example of how to do this kind of project. In this post, I will try to share both the good and the bad.
Specs
Max clock speed: | 0.5MHz |
# of breadboards: | 36 |
# of jumper cables: | ~3500 |
Number of logic gates: | ~800 |
Hours spent: | >100 |
The Clock
The computer's clock dictates the behavior of the other modules. To facilitate this, a single clock cycle is broken into 4 stages. The table below shows which modules are activated during each stage. After these 4 stages occur, the computer will have performed a single instruction.
This module caused me a lot of headaches. Originally, I had built an asynchronous 2-bit binary counter and hooked it up to a decoder. The problem with this arrangement was that during the transition from stage 4 (11) to stage 0 (00) there would be an instantaneous moment where stage 3 (10) was registered (since the least significant bit changes first). The computer wasn't designed to go between stages in this order, and its registers would become corrupted.
I considered switching to a synchronous clock, but those are usually built with JK flip-flops, which require many logic gates. Plus, I would have still needed a 2-to-4 decoder. Instead, I just built a shift register out of three D flip-flops. If every bit of the shift register was off, then the computer is in stage 1. Otherwise, each bit of the shift register represents one of the remaining 3 stages.
Even with this change, I still struggled with noise. Sticking random capacitors into the power rails helped smooth out noise from my power supply, but didn't completely solve the problem. For example, I discovered late into the project that my clock would go haywire if I unplugged my oscilloscope from the wall. It could be completely disconnected from everything else, but somehow it was cleaning enough noise (probably in my apartments AC power line) to make a difference.
The Program
Without the ability reprogram it, it would hardly be a computer. This machine has 256 reprogrammable lines of 24-bit instructions. In case you're wondering, the "8-bit" designation comes from the width of the data busses, so there's nothing contradictory about my program being 3x wider. I could have designed a 16-bit (or even 8-bit) instruction set, but I chose 24-bit because it vastly simplified the instruction decoding logic.
The programs are originally written in the same assembly language as the above emulator. Of course, my computer doesn't actually understand that assembly language. Instead, it gets compiled into a machine language (a binary encoding as specified in the table below) by a simple assembler I wrote in Golang. After that, the machine language gets bundled into a C application that I can flash onto an ESP32 microcontroller. Finally, I use GPIO pins on the ESP32 (all of them) to flash my computer. This process could certainly have been streamlined, but it only took around 30 seconds as-is.
Interestingly, I managed to avoid building a reset button into my computer. Normally, there would be some way of resetting the program counter back to the first instruction. However, that requires wiring up more logic gates. Instead, I just flash a reset program onto the computer. This program is comprised of 256 lines of "JMP $i 0" instructions. Thus, no matter what line the computer is currently on, it will always jump to and stay at the first line. Then I can just pause the clock and flash my intended program.
The program is stored on three SST39SF010A-70-4C-PHE flash memory chips. Each one holds 8 bits of the 24-bit instruction. I bought these without properly scrutinizing the datasheet and ended up being very frustrated by the sequence of commands required to store data on them. The chip designer considers this a feature, because it prevents unintentional writes. For me, that was the least of my worries. The main problem was that command sequence required setting 24 of its pins (the 8 data pins, 15 of the address pins, and a write enable pin) to specific values, whereas the ESP32 I used only has 21 digital output pins. Plus, I needed a few of those pins for my own purposes. Thankfully, I was able to exploit a pattern in the commands to reduce my total usage down to just 20 of the ESP32's pins. Still, it took many hours of frustratingly opaque debugging to finally write a full program.
The Program Counter
Closely related to program module is the program counter. This is a register that dictates which line of code is currently being processed. Unlike the other registers, you can only write to this one with two special purpose instructions, "JMP" (jump to a specific line) and "JMPNZ" (conditionally jump to a specific line if a number is not zero). In all other cases, the program counter is simply incremented by one at the end of a clock cycle (i.e. it moves to the next line of code).
Binary Operations
The binary operations module is very simple. It has circuitry for performing either an 8-bit binary AND or an 8-bit binary OR computation on two numbers. In addition to this, I found this to be a convenient spot for some of the control circuitry for the full adder and the comparator modules. Basically, this module tells those two when they should be turned on.
Full Adder
As you might have guessed, this module contains an 8-bit full adder. I actually built this module first with the thought, "If I can get this to work then I can build the rest of the computer." It was true, but I also ended up scrapping that version and rebuilding it with the logic for doing subtractions.
If you're not familiar, it's not difficult to make an adder perform subtraction. We do this by using a special number representation called two's complement. If you have the two's complement of a number (which is easy to compute) then adding it to another number is the same as subtracting the original number.
Comparator
Moving along with another module that does exactly what the name implies. This module compares numbers. Specifically, it can perform less than (<), less than or equal (<=), equal (==), and not equal (!=). Also, it can do all of these operations for both signed (-128 to 127) and unsigned (0-255) numbers. It outputs a 1 for true comparisons and a 0 for false comparisons. By combining these instructions with "JMPNZ" you can perform control flow such as loops and conditionals.
Allow me to vent and say that this module was rather difficult to build. Unlike most of the other modules, there were very little patterns in the wiring. On top of that, there were several different modes of behavior it could be in. Debugging involved a lot of reverse engineering because I couldn't keep it all in my head.
Unary Module
This module can perform four different operations on a single 8-bit number, not, shift left, shift right, and no-op. Both shift instructions only support shifting by one bit at a time, whereas a real computer would have a barrel shifter that can shift by any number of bits. Thankfully, I can replicate this behavior by building a loop and performing the shift multiple times. It's inefficient, but so is the rest of the computer.
By the way, you might not realize that the no-op instruction is among the most useful. The actual name for this instruction is "MV", because it allows you to move a value from one register to another without changing it. This is also the instruction I use to load constants into the computer. Hence, every program starts with at least a few of these to get the initial data loaded in.
Memory
This module is the only form of persistence the computer has. Like the program memory, this module uses an off the shelf chip (71256SA12TPG) to do most of the heavy lifting. These are volatile RAM chips, so turning the computer off results in the data being lost, but that hardly matters.
By far, this was the easiest module to build. I wired it in an hour, and it worked on the first try. After having so much trouble with the program memory I initially thought I had just botched the testing. In retrospect, I should have just used the same chip for the program memory. Non-volatility wasn't a requirement for it either.
Bus
This isn't really a module. Actually, it's just the place where all the other modules connect together. There are three lanes in this bus. Two for inputs, and one for output. I put an LED display on all of these to make debugging easier.
Register File
Now this was a large module. So large, in fact, that I had to break it up into 3 different submodules. As a whole, it makes a register file that is capable of holding four different 8-bit numbers at a time. Registers are used constantly in every program. Most computations use values from the registers, and all results must be stored in a register before you can perform further actions with them. Because of this, I decided to build four of them even though I could have gotten away with less.
Each register is built with an 8-bit D latch. You might realize that there's a problem with that design. Latches are level-triggered (i.e. when activated they take on new values instantly). If I was to perform "ADD $i $1 $1 10" (add 10 to register 1 and put the result in register 1) I would enter an infinite loop. Register 1 would take on a value that is 10 greater, and then immediately it would take on a value 10 greater than that, then a value 10 greater than that, etc. (the reality is more complicated than this, but either way there's a problem). The solution is to build an edge triggered flip-flop. Flip-flops remember the new value, but only switch to it at a different point in the clock cycle; thus, eliminating the infinite loop. You can build a D flip-flop by connecting two D latches together (in what's called a master-slave arrangement), but that requires a lot more logic gates. Instead, I built only one master D latch (called the register buffer) that all the registers shared. This is the second submodule.
The final submodule just handles some basic control logic. It uses the current clock stage to determine when the other two modules should be active. I also stuck a few ICs for loading immediate values onto this board. This made sense because if you are loading an immediate value then you consequently aren't loading a value from a register.
Summary
If you understood everything that I wrote above, then congratulations! You know how my entire computer works. Now, there's only about a hundred hours between you and one of your own.
In all sincerity, I recommend this type of project to anyone who is interested in computers. I don't say this thinking you'll gain much depth in understanding from it - I didn't - but simply because it allowed me to connect with what a computer truly is.