“Computers are all made out of logic gates”. We’ve heard that saying before. We also have a sense that logic gates are very simple machines, analogous to light switches even. This raises the question: how exactly do kind-of-light-switches come together to form computers? How does “storing a variable” or “calling a function” translate into logic gates going on or off?
On a journey to answer that question, I discovered J Clark Scott’s excellent book “How do It Know?”. He starts with NAND gates and takes you on a journey to build a computer using them.
I liked his book so much that I took his schematic for RAM, and simulated it in Clojure. In this essay, I’ll guide you through doing just that: we’ll simulate NAND gates, and use about 14 thousand of them to build 256 bytes of RAM.
Going through this simulation ingrained an “aha” feeling in me: watching 14 thousand little machines chug away makes you feel that whoever uses a computer is a wizard. A wizard with an army of millions of machine servants doing billions of little jobs for them every second. I hope it gives you the same feeling. 🙂
Pre-requisites
To grok this essay, you need to understand this picture:
This describes a NAND gate. A NAND gate is a machine that has two input wires. If both input wires have a “high” charge (represented as 1), the output charge is “low” (represented as zero). With any other combination of input charges, the output charge is high.
Notice that the wires carry a charge, but we choose to interpret meaning in the charge. “high charge” means 1, and “low charge” means 0. Nothing changes in the machine, this is just something we decided as humans (1).
On the left you see a circuit diagram. You can read it as input wires a and b carrying charges into the NAND gate. The NAND gate has a wire c, carrying the output charge. For all the circuit diagrams we’ll draw, you can read them as electricity “flowing” from left to right, or top to bottom.
On the right is a “truth” table for a NAND gate. This is just a fancy name for summarizing every state a NAND gate can be, based on the input wires.
Now, we can start even lower than a NAND gate, but this machine is simple enough. It can’t be so hard to build something that turns off when two inputs are turned on. You don’t have to take my word for it though, you can search up “building a NAND gate with transistors”, and come back when you’re convinced.
Time to code! 🙂
0: State
First things first, we need some way to represent the state of our circuit. We know that our RAM will be built completely from NAND gates, so let’s take inspiration from one:
If we look at this example we can see that:
We have wires.
Wires have charges.
We hook wires together with NAND Gates
Here’s one way we can map that to a data structure in Clojure:
We can use keywords to represent our wires. We can also keep a map that tells us the charges of our wires. Finally, we can keep a list of NAND gates, which tell us how these wires connect.
Fine enough way to represent our circuit for now! Let’s create a few functions that can help us manage this representation:
; update state v0
; ---------------
(def empty-state {:charge-map {} :nand-gates []})
(defn charge [state wire]
(get-in state [:charge-map wire]))
(defn charges [state wires]
(map (partial charge state) wires))
(defn set-charge [state wire charge]
(assoc-in state [:charge-map wire] charge))
(defn wire-nand-gate [state a b o]
(update state :nand-gates conj {:ins [a b] :out o}))
These are all the basic tools we need to “connect” a NAND gate into our circuit. Let’s try them out in the REPL:
Nice! We can now “wire” up a circuit. Let’s run some electricity through it.
1: Trigger
To figure out how to simulate electricity into our circuit, let’s remember our diagram again:
One way we can model this is to imagine that electricity is like water: It “flows” from sources into wires, and “triggers” all the devices that are connected to those wires.
With a model like that, here’s what would happen if a charge was “triggered” on a:
First, a‘s charge would update.
After that a ‘s charge would transfer to all the NAND gates that are connected to it. In this case, it would be our one NAND gate above.
Each NAND gate would then recompute its charge, and if it changed, trigger its output wire in turn. In our case that’s c
If c was connected to other NAND gates, those gates would trigger, and the process would continue.
Now, this is a very naive view of how electricity works (2), but it’s good enough for us to model RAM!
Let’s translate this into code.
To do that, we need a way to model what a NAND gate does:
(defn nand-output [a b]
(if (= a b 1) 0 1))
(nand-output 0 0)
; => 1
(nand-output 1 1)
; => 0
Our nand-output function takes two input charges, and produces the output charge that a NAND gate would produce.
Next, we need a function to find all the NAND gates that are connected to a specific wire:
We’ll want to do this so much that it’s good to have around.
2: Simulate NAND
We have what we need to simulate a simple charge flowing through a NAND gate. Let’s write a test for that:
(deftest test-nand-gate
(let [s1 (-> empty-state
(wire-nand-gate :a :b :o)
(trigger-many [:a :b] [1 0]))
s2 (-> s1
(trigger :b 1))]
(testing "just a is on"
(is (= '(1 0 1) (charges s1 [:a :b :o]))))
(testing "both a and b are on"
(is (= '(1 1 0) (charges s2 [:a :b :o]))))))
; Ran 1 test containing 2 assertions.
; No failures.
Works like a charm!
3: Simulate NOT
What would happen, if we took a NAND gate, and fed the same wire in both inputs?
Well, the output would end up being the opposite of its input. When a is zero, c is 1, when a is 1, c is 0. Boom, that happens to be a NOT gate. Here’s how that looks:
To implement our NOT gate, we can do exactly as our diagram described: Feed the same wire to both inputs of a NAND gate:
(defn wire-not-gate
([state a o]
(wire-nand-gate state a a o)))
; Ran 1 test containing 2 assertions.
; No failures
It works! Onwards.
4: Simulate AND
What if we plugged the output of one NAND as the input of a NOT gate?
Well, it would be opposite of a NAND gate: d would only be 1 when botha and b are 1. That’s the AND gate:
To implement AND, we can follow just that schematic:
(defn wire-and-gate [state a b o]
(let [nand-o :c]
(-> state
(wire-nand-gate a b nand-o)
(wire-not-gate nand-o o))))
This would work…almost. The tricky thing here is that inside the function we have an “intermediary” wire c, which connects the NAND gate and NOT gate. If we made twoAND gates for example, then they would share the same wire :c!
To fix this, let’s write some helper functions to create unique wires:
; Ran 1 test containing 2 assertions.
; No failures.
Works like a charm!
5: Simulate Bits
Now comes one of the hardest and most important circuits we’ll need to understand. Let’s start by describing our goal:
Notice the interesting thing here. When the s wire is “1”, the value of i is transferred to o. When it is “0”, the value of o is no longer affected by i. o's charge is whatever it was before.
If we can make something like this, that would mean that the charge on “o” is stored.Since it can be either 1 or 0, we can in effect “store” 1 bit of data.
Here’s how we can do this:
The trick with this circuit is the way o and c are connected together. This intertwining thing is called a “latch”, because once a charge gets set in a certain way, these gates will find an equilibrium that causes o to be stored. Pretty cool!
This circuit is pretty complicated and a bit hard to understand (3), but we’ve got the power of simulation at our fingertips! Let’s try building it and test it out:
(defn wire-memory-bit
"To understand the variables in this circuit,
follow along with the diagram in the tutorial"
([state s i o]
(let [a (wire :a)
b (wire :b)
c (wire :c)]
(-> state
(wire-nand-gate i s a)
(wire-nand-gate a s b)
(wire-nand-gate a c o)
(wire-nand-gate b o c)))))
Looks so pretty for such a complicated machine. Now the ultimate question…will it work?!
(deftest test-memory-bit
(let [s1 (-> empty-state
(wire-memory-bit :s :i :o)
(trigger-many [:i :s] [1 0]))
s2 (-> s1
(trigger :s 1))
s3 (-> s2
(trigger-many [:s :i] [0 0]))]
(testing "turning i on does not affect the rest"
(is (= '(0 1 0) (charges s1 [:s :i :o]))))
(testing "enabling set transfers i to o"
(is (= '(1 1 1)
(charges s2 [:s :i :o]))))
(testing "disabling set, removes further effects on o"
(is (= '(0 0 1)
(charges s3 [:s :i :o]))))))
; Ran 1 test containing 3 assertions.
; No failures.
Oh ma gad…it works!
6: Simulate Bytes
Now that we have a bit, we can take one s wire and tie 8 memory bits together with it. That would let us set 8 bits together, which means we can “store” 8 bits of data…which gives us a byte! (5). Here’s how that would look:
Note that in our diagram now “two wires together” is short-hand for writing 8 wires.
Let’s write up a byte in our simulation:
(defn wire-byte [state s ins outs]
(reduce (fn [acc-state [i o]]
(wire-memory-bit acc-state s i o))
state
(map vector ins outs)))
Easy peasy!
To test this out though, we’re going to need a way to “create” a bunch of names for wires. Let’s write a few helper functions that make this easy:
(deftest test-byte
(let [ii (wires :i 8)
os (wires :o 8)
s1 (-> empty-state
(wire-byte :s ii os)
(trigger-many ii [1 1 1 0 0 0 0 0])
(trigger :s 0))
s2 (-> s1
(trigger :s 1))
s3 (-> s2
(trigger :s 0)
(trigger-many ii [0 0 0 0 0 0 0 1]))]
(testing "disabling set, removes further effects on o"
(is (= '(1 1 1 0 0 0 0 0)
(charges s1 ii)))
(is (= '(0 0 0 0 0 0 0 0)
(charges s1 os))))
(testing "set s, so os become 1 1 1"
(is (= '(1 1 1 0 0 0 0 0)
(charges s2 ii)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s2 os))))
(testing "freeze by disabling s. see that further changes to i do nothing to o"
(is (= '(0 0 0 0 0 0 0 1)
(charges s3 ii)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s2 os))))))
Phoof…does it work?
; Ran 1 test containing 6 assertions.
; No failures.
Yes!
7: Simulate Enabler
Now, imagine we had two bytes, connected like this:
Notice how the output wires are shared between B1 and B2. If B1 had a charge of “11110000”, and B2 had a charge of “0001111”, what would happen to the output wires? It would carry a charge of “1111111”! Say we wanted to make sure only one of the bytes sent their output charge into output wires. How could we do that?
We’ll need a new machine. Let’s consider what would happen if we took a bunch of AND gates, and connected them together like this:
Now, if the “e” wire is “on”, the output wires are charged with whatever the input wires are. Buut, if the “e” wire is “off”, the output zeroes out. This machine is called the “enabler”. If we put this together right, we could control what charge gets sent to output wires!
Let’s set it up first, it should be easy peasy:
(defn wire-enabler
[state e ins outs]
(reduce
(fn [acc-state [in out]]
(wire-and-gate acc-state e in out))
state
(map vector ins outs)))
; Ran 1 test containing 6 assertions.
; No failures.
🔥
8: Simulate Register
Here’s how we could “fix” our problem with B1 and B2:
If we stuck an “enabler” in front of each byte, we could control what gets sent to output wires. If we wanted to have B1's charge, we would “enable E1”, and make sure E2 was disabled, and vice versa.
This combination of byte and enabler is so common that we can build a machine for that:
It’s called a register! Registers let us both control what bytes are stored, and when these bytes are exposed as output.
To set this up, all we need to do is to tie together a byte and an enabler:
(defn wire-register [state s e ins bits outs]
(-> state
(wire-byte s ins bits)
(wire-enabler e bits outs)))
Badabing, badaboom, it should work. This is a pretty important machine, so let’s make darn sure it works:
(deftest test-register
(let [ii (wires :i 8)
bs (wires :b 8)
os (wires :o 8)
s1 (-> empty-state
(wire-register :s :e ii bs os)
(trigger-many ii [1 1 1 0 0 0 0 0])
(trigger :s 0)
(trigger :e 0))
s2 (trigger s1 :s 1)
s3 (trigger s2 :e 1)
s4 (-> s3
(trigger :s 0)
(trigger-many ii [0 0 0 0 0 0 0 1]))
s5 (trigger s4 :e 0)]
(testing "is should be set, but bs and os should be 0, b/c s & e are 0"
(is (= '(1 1 1 0 0 0 0 0)
(charges s1 ii)))
(is (= '(0 0 0 0 0 0 0 0)
(charges s1 bs)))
(is (= '(0 0 0 0 0 0 0 0)
(charges s1 os))))
(testing "is & bs should be set, as s is on. but os should be 0, b/c e is off"
(is (= '(1 1 1 0 0 0 0 0)
(charges s2 ii)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s2 bs)))
(is (= '(0 0 0 0 0 0 0 0)
(charges s2 os))))
(testing "is & bs should be set, as s is on. but os should be 0, b/c e is off"
(is (= '(1 1 1 0 0 0 0 0)
(charges s3 ii)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s3 bs)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s3 os))))
(testing "is should be new v, but bs and os should be the old value"
(is (= '(0 0 0 0 0 0 0 1)
(charges s4 ii)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s4 bs)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s4 os))))
(testing "os should 0 out again"
(is (= '(0 0 0 0 0 0 0 1)
(charges s5 ii)))
(is (= '(1 1 1 0 0 0 0 0)
(charges s5 bs)))
(is (= '(0 0 0 0 0 0 0 0)
(charges s5 os))))))
; Ran 1 test containing 15 assertions.
; No failures.
Very cool!
9: Simulate Bus
Okay, let’s continue our experiment, to an astounding result: what if we connected the inputs and outputs of a bunch of registers to the same wires?
Now, remember that s allows us to decide what gets “stored” into a register, and “e” lets us “pass” the charge of a register’s byte through to the output.
What would happen in the following scenario. Say R1’s byte contains “111”, all s and e wires are 0.
“Charge R1’s e to 1”.
Now R1 would enable, and the bus wires would carry the same charge as R1
“Charge R3’s s to 1, then 0”.
This would set the value of R3, to the current charge flowing in bus. This happens to be the output of R1!
“Set R1's e to 0” Now the current in bus would disappear again
The result? The byte in R1 would have been “copied” to R3. We’ve just created a bus.
To create a bus, all we need to do is to connect a register’s input and output to the same “bus” wires:
(defn wire-bus [state bus-wires s e bits]
(wire-register state s e bus-wires bits bus-wires))
This is only a single line, but it’s pretty important to get right. It’s what lets us “copy” registers after all. Let’s see if it works:
; Ran 1 test containing 12 assertions.
; 4 failures, 0 errors.
🙀 uh oh, it doesn’t work…
10: Evolve Trigger
Let’s look at this diagram again:
Remember that the bus wires can “receive” a charge now from the outputs of R1 , R2, or R3. Here’s how our set-charge looked:
(defn set-charge [state wire charge]
(assoc-in state [:charge-map wire] charge))
Imagine if R1 was enabled and a wire was charged to 1. What would happen if R3 got triggered?
It would overwrite our charge!
Time to to evolve our model. One way to think about it, is that bus wires now have multiple “sources” that can provide a charge. Let’s update our charge functions to keep track of this “source”
(defn trigger-nand-gate
[state {:keys [ins out]}]
(let [new-charge (apply nand-output (charges state ins))]
(trigger (apply kw (conj ins out)) state out new-charge)))
Now each NAND gate triggers with a specific name as a source. This would make it so R1's output trigger wouldn’t interfere with R2 output trigger! Let’s test out the bus again:
; Ran 1 test containing 12 assertions.
; No failures.
We’re back.
11: Simulate and-n
Right now, our and gate only tests whether two inputs are both 1. What if we wanted to test that three inputs are all 1? We could just wire up a bunch of AND gates together:
Let’s call this the AND-N gate. It can take N number of inputs, and produces one output. We can wire this up using some nice recursion:
(defn wire-and-n [state ins out]
(let [[a b] (take 2 ins)
rem (drop 2 ins)]
(if-not (seq rem)
(wire-and-gate state a b out)
(let [w (wire (kw a b :-and))]
(wire-and-n
(wire-and-gate state a b w)
(list* w rem)
out)))))
We keep recursing wire up the inputs as AND gates, until we’re only left with two. Let’s see how it works:
(deftest test-and-n
(let [ii [:a :b :c :d :e]
s1 (-> empty-state
(wire-and-n ii :o)
(trigger-many ii [1 1 1 1 0]))
s2 (trigger-many s1 ii [1 1 1 1 1])]
(testing "if only some are charged, o is off"
(is (= '(1 1 1 1 0)
(charges s1 ii)))
(is (= 0 (charge s1 :o))))
(testing "if all are on, we are on"
(is (= '(1 1 1 1 1)
(charges s2 ii)))
(is (= 1 (charge s2 :o))))))
; Ran 1 test containing 4 assertions.
; No failures.
12: Simulate decoder
Now, Say we have two inputs (ab), and we want to know how they are charged: (a0 b0) (a0 b1) (a1 b0) (a1 b1).
We could make a machine that figures that out:
It’s called a decoder. It guarantees that only one wire will turn on, and each wire will represent a specific state of a and b. Here’s how we could do it:
This looks like a lot, but in essence it’s pretty simple. Each input is wired into a NOT gate. Now let’s take an example: what would happen If we wire the NOT outputs of a & b to an AND gate?
If that AND output turned “1”, it would mean that botha and bhad to be 0! This would mean the output wire of that AND gate, when “1”, represents the selection (a0 b0). We can keep wiring AND gates like this, to represent all different selections.
It can even be generalized: for N inputs, we create N NOT outputs. Then we can wire them up to and-n gates in such a way that the output represents the a special selection.
Let’s do that. First, we’ll need to install Clojure’s math combinatorics library in our deps.edn:
…Pretty elegant. Thank you Clojure. We first wire up a bunch of NOT wires. Then we generate a bunch of selections, and for each one wire up and and-n gate. We wire either from ins or in-nots, based on what the selection tells us.
The ultimate question…will it work?!
(deftest test-decoder
(let [ii (wires :i 4)
os (wires :o 16)
sels (wire-mapping (count ii))
sel (nth sels 5)
o (nth os 5)
s1 (-> empty-state
(wire-decoder ii os)
(trigger-many ii sel))]
(testing "only 1 output is on"
(is (= 1 (charge s1 o)))
(is (every? zero? (charges s1 (remove #{o} os)))))))
; Ran 1 test containing 2 assertions.
; No failures.
😦
13: Simulate Lookup
With all of this, we can wire together the first part of RAM. We need a way to “look up” a byte.
What if we took one byte — let’s call it mar — and wired the first 4 outputs to one decoder, and the last 4 outputs to another decoder?
Well, this would produce 256 output wires. For each value in the mar byte, two output wires would be on: one from the first decoder, and the other from the second decoder.
We can think of this then, like a 16x16 grid. Each “byte” in mar represents a unique “intersection” in the grid. Now, in the future we can tie some registers to those grid wires, and when an “intersection” is on, we can make that register active! Pretty cool.
Let’s build it:
(defn wire-mar
[state s is os first-4-outs last-4-outs]
(-> state
(wire-byte s is os)
(wire-decoder (take 4 os) first-4-outs)
(wire-decoder (drop 4 os) last-4-outs)))
Will it work?
(deftest test-mar
(let [ii (wires :i 8)
os (wires :o 8)
first-4-decoders (wires :fd 16)
last-4-decoders (wires :ld 16)
s1 (-> empty-state
(wire-mar :s ii os first-4-decoders last-4-decoders)
(trigger-many ii [0 0 0 0 0 0 0 0])
(trigger :s 0))
sel (nth (wire-mapping 4) 5)
s2 (trigger-many s1 ii (concat sel sel))
s3 (-> s2
(trigger :s 1)
(trigger :s 0))
test-idx (fn [state idx]
(let [fd (nth first-4-decoders idx)
ld (nth last-4-decoders idx)]
(is (= 1 (charge state fd)))
(is (every? zero? (charges state (remove #{fd} first-4-decoders))))
(is (= 1 (charge state ld)))
(is (every? zero? (charges state (remove #{ld} last-4-decoders))))))]
(testing
"by default only one wire is on, and it's the correct mapping"
(test-idx s1 0))
(testing
"even if ii changes, sel doesn't change b/c s is 0"
(test-idx s2 0))
(testing
"once s triggers, the sel does change"
(test-idx s3 5))))
; Ran 1 test containing 12 assertions.
; No failures.
Nice, we can look something up!
14: Simulate IO
Time to complete the picture:
We’re going to add an io bus at the bottom, a set wire an enable wire. For each “intersection” from our decoders, what would happen if we did this?
The top decoder wire and left decoder wire are fed into an AND gate that outputs x. x will only be “on” when both decoder wires are “on”. That means x would represent whether our intersection is active!
x and io-s are fed into an AND gate, which produces s.s will only turn on, when both io-s is charged and the intersection is active.
x and io-e are fed an AND gate, producing e. This will only turn on, when both io-e is charged, and the intersection is active.
After that, we take se and the io wires, and connect them to a register.
If we follow that logic, it means that this register can be enabled and set, only when the intersection is “on”! If hooked this up to each intersection…all of a sudden we have 256 bytes of memory!
Let’s write this to work with just one intersection:
(defn wire-io
[state io-s io-e ios decoder-o-1 decoder-o-2 register-bits]
(let [x (wire (kw decoder-o-1 decoder-o-2 :x))
s (wire (kw decoder-o-1 decoder-o-2 :s))
e (wire (kw decoder-o-1 decoder-o-2 :e))]
(-> state
(wire-and-gate decoder-o-1 decoder-o-2 x)
(wire-and-gate x io-s s)
(wire-and-gate x io-e e)
(wire-bus ios s e register-bits))))
We first wire-mar. Then, we use cartesian-product to create a list of decoder intersections. For each of those intersections, we wire-io. Congratulations, you’ve wired together RAM!
16: Evolve state
Now, let’s take a look at how many NAND gates we have when we set our ram up:
Here, we make accessing dependent NAND gates fast. Now it’s a matter of a dictionary lookup. If we try it out, it’ll be muuch faster!
17: Testing it out!
Okay, we built it, we made it fast, but will it work? Let’s imagine we built a machine like this:
We wire up mar switches to the mar byte, and add a switch to the set wire
Depending on which switches we turn on, we can set a specific byte inmar,and access a specific “register” in our grid!
We wire up io lights
Whenever io has a charge, those lights will go on. Whichever light is on means 1, whichever light is off means 0. This will tell us the byte that is on the io bus!
We tie the io-s wire to the io set switch
When that switch is on, the io-s wire will be on, and as a result the currently active register’s “set” wire will be on. This will let us “save” whatever charge is in io!
We tie the io-e wire to an io enable switch
When that switch is on, the io-e wire will be on, and as a result the currently active register’s “enable” wire will be on. This will let us “pass” the active register’s charge intoio.That will causeio lightsto turnon and let us read the value in the register!
We wire up io switches
These switches will allow us to set a specific charge intoio.If we combine this by toggling our io “set” switch, we can save a specific charge into the active register!
With a machine like that, we can now use our RAM! Here’s how.
(I recommend following along on the diagram above)
To access a specific register:
Use the MAR switches, to select the exact intersection we want, and toggle the mar set switch
Toggle the io enable switch . This will send the active register’s data into io
Look at the io lights: The combination of lights that are on represent the value of the register!
When done, toggle the io enable switch off.
To set data to a specific register:
Use the MAR switches, to select the exact intersection we want
Use the IO switches, to set the charge in IO that represents the data we want to save.
Toggle the io set switch on. Now the charge of io will be saved to that specific register!
Now toggle the io set switch off, and toggle all the io switches off.
Badabing, badaboom. you’ve saved your data!
Let’s go ahead and create that machine.
First, let’s create a quick helper function to initialize our RAM:
Wow. we did it. 14056 NAND gates. 256 bytes of RAM. I hope you had a blast 🙂 — If you want to see how the rest of the computer is made, I definitely suggest checking out Scott’s book. He also has a great site with more example projects. To see the code all together, here’s the repo.
Thanks to Daniel Woelfel, Julien Odent, Sean Grove, Paul Vorobyev, Alexander Kotliarskyi, Alex Reichert, Poornamidam for reviewing drafts of this essay.
(1) I say this pretty lightly, but treating the charge on circuits as code was a pretty phenomenal innovation. I could only imagine what it might have been like, when Shannon showed a full-adder.
(2) To really model circuits, we’d need to learn about Maxwell’s equations. Just ordered a textbook for this, so maybe next time 🙂.
(4) That stack exchange question helped me a lot. If you’re even more curious, start with this youtube video, and follow along until the gentleman gets to the “D” latch. He uses NOR gates, but the essence is the same.
(5) There were a lot of different kind of architectures that were tried.
“Computers are all made out of logic gates”. We’ve heard that saying before. We also have a sense that logic gates are very simple machines, analogous to light switches even. This raises the question: how exactly do kind-of-light-switches come together to form computers? How does “storing a variable” or “calling a function” translate into logic gates going on or off?
On a journey to answer that question, I discovered J Clark Scott’s excellent book “How do It Know?”. He starts with NAND gates and takes you on a journey to build a computer using them.
I liked his book so much that I took his schematic for RAM, and simulated it in Clojure. In this essay, I’ll guide you through doing just that: we’ll simulate NAND gates, and use about 14 thousand of them to build 256 bytes of RAM.
Going through this simulation ingrained an “aha” feeling in me: watching 14 thousand little machines chug away makes you feel that whoever uses a computer is a wizard. A wizard with an army of millions of machine servants doing billions of little jobs for them every second. I hope it gives you the same feeling. 🙂
Pre-requisites
To grok this essay, you need to understand this picture:
This describes a NAND gate. A NAND gate is a machine that has two input wires. If both input wires have a “high” charge (represented as 1), the output charge is “low” (represented as zero). With any other combination of input charges, the output charge is high.
Notice that the wires carry a charge, but we choose to interpret meaning in the charge. “high charge” means 1, and “low charge” means 0. Nothing changes in the machine, this is just something we decided as humans (1).
On the left you see a circuit diagram. You can read it as input wires
a
andb
carrying charges into theNAND
gate. TheNAND
gate has a wirec
, carrying the output charge. For all the circuit diagrams we’ll draw, you can read them as electricity “flowing” from left to right, or top to bottom.On the right is a “truth” table for a NAND gate. This is just a fancy name for summarizing every state a
NAND
gate can be, based on the input wires.Now, we can start even lower than a
NAND gate
, but this machine is simple enough. It can’t be so hard to build something that turns off when two inputs are turned on. You don’t have to take my word for it though, you can search up “building a NAND gate with transistors”, and come back when you’re convinced.Time to code! 🙂
0: State
First things first, we need some way to represent the state of our circuit. We know that our RAM will be built completely from
NAND
gates, so let’s take inspiration from one:If we look at this example we can see that:
Here’s one way we can map that to a data structure in Clojure:
We can use keywords to represent our wires. We can also keep a map that tells us the charges of our wires. Finally, we can keep a list of NAND gates, which tell us how these wires connect.
Fine enough way to represent our circuit for now! Let’s create a few functions that can help us manage this representation:
These are all the basic tools we need to “connect” a NAND gate into our circuit. Let’s try them out in the REPL:
Nice! We can now “wire” up a circuit. Let’s run some electricity through it.
1: Trigger
To figure out how to simulate electricity into our circuit, let’s remember our diagram again:
One way we can model this is to imagine that electricity is like water: It “flows” from sources into wires, and “triggers” all the devices that are connected to those wires.
With a model like that, here’s what would happen if a charge was “triggered” on
a
:a
‘s charge would update.a
‘s charge would transfer to all the NAND gates that are connected to it. In this case, it would be our one NAND gate above.c
c
was connected to otherNAND
gates, those gates would trigger, and the process would continue.Now, this is a very naive view of how electricity works (2), but it’s good enough for us to model RAM!
Let’s translate this into code.
To do that, we need a way to model what a
NAND
gate does:Our
nand-output
function takes two input charges, and produces the output charge that aNAND
gate would produce.Next, we need a function to find all the
NAND
gates that are connected to a specific wire:This searches all of our
NAND
gates in our circuit, and finds the ones which are connected to a specific wire.With that, we have what we need to implement
trigger
:This follows exactly the model we described:
NAND
gates that the wire was connected tooNAND
gates if needed.What’s left is to implement what a
NAND
gate does when it is triggered:This calculates the new charge of a
NAND
gate, and triggers theoutput
wire with that charge.Great, we have a way to simulate charges flowing through NAND gates!
One final helper function: let’s create something that will will let us “trigger” many wires:
We’ll want to do this so much that it’s good to have around.
2: Simulate NAND
We have what we need to simulate a simple charge flowing through a NAND gate. Let’s write a test for that:
Works like a charm!
3: Simulate NOT
What would happen, if we took a NAND gate, and fed the same wire in both inputs?
Well, the output would end up being the opposite of its input. When
a
is zero,c
is 1, whena
is 1,c
is 0. Boom, that happens to be aNOT
gate. Here’s how that looks:To implement our
NOT
gate, we can do exactly as our diagram described: Feed the same wire to both inputs of aNAND
gate:🤯 1 line of code. If we test that out…
It works! Onwards.
4: Simulate AND
What if we plugged the output of one
NAND
as the input of aNOT
gate?Well, it would be opposite of a
NAND
gate:d
would only be 1 when botha
andb
are 1. That’s theAND
gate:To implement
AND
, we can follow just that schematic:This would work…almost. The tricky thing here is that inside the function we have an “intermediary” wire
c
, which connects theNAND
gate andNOT
gate. If we made twoAND
gates for example, then they would share the same wire:c
!To fix this, let’s write some helper functions to create unique wires:
Let’s see how it looks:
Now if we create a wire with a name that already exists, it’ll add a nice little “#2” beside it.
Nice! Let’s use it in
wire-and-gate
If we test this out…
Works like a charm!
5: Simulate Bits
Now comes one of the hardest and most important circuits we’ll need to understand. Let’s start by describing our goal:
Notice the interesting thing here. When the
s
wire is “1”, the value ofi
is transferred too
. When it is “0”, the value ofo
is no longer affected by i.o
's charge is whatever it was before.If we can make something like this, that would mean that the charge on “o” is stored. Since it can be either 1 or 0, we can in effect “store” 1 bit of data.
Here’s how we can do this:
The trick with this circuit is the way
o
andc
are connected together. This intertwining thing is called a “latch”, because once a charge gets set in a certain way, these gates will find an equilibrium that causeso
to be stored. Pretty cool!This circuit is pretty complicated and a bit hard to understand (3), but we’ve got the power of simulation at our fingertips! Let’s try building it and test it out:
Looks so pretty for such a complicated machine. Now the ultimate question…will it work?!
Oh ma gad…it works!
6: Simulate Bytes
Now that we have a bit, we can take one
s
wire and tie 8 memory bits together with it. That would let usset
8 bits together, which means we can “store” 8 bits of data…which gives us a byte! (5). Here’s how that would look:Note that in our diagram now “two wires together” is short-hand for writing
8
wires.Let’s write up a byte in our simulation:
Easy peasy!
To test this out though, we’re going to need a way to “create” a bunch of names for wires. Let’s write a few helper functions that make this easy:
Here’s how we could make 8 wires with the name
:i
Nice! Now to write our test for the byte:
Phoof…does it work?
Yes!
7: Simulate Enabler
Now, imagine we had two bytes, connected like this:
Notice how the output wires are shared between
B1
andB2
. IfB1
had a charge of “11110000”, andB2
had a charge of “0001111”, what would happen to the output wires? It would carry a charge of “1111111”! Say we wanted to make sure only one of the bytes sent their output charge intooutput wires
. How could we do that?We’ll need a new machine. Let’s consider what would happen if we took a bunch of
AND
gates, and connected them together like this:Now, if the “e” wire is “on”, the output wires are charged with whatever the input wires are. Buut, if the “e” wire is “off”, the output zeroes out. This machine is called the “enabler”. If we put this together right, we could control what charge gets sent to
output wires
!Let’s set it up first, it should be easy peasy:
Looks nice! Let’s see the test:
🔥
8: Simulate Register
Here’s how we could “fix” our problem with
B1
andB2
:If we stuck an “enabler” in front of each byte, we could control what gets sent to
output wires
. If we wanted to haveB1
's charge, we would “enableE1
”, and make sureE2
was disabled, and vice versa.This combination of byte and enabler is so common that we can build a machine for that:
It’s called a register! Registers let us both control what bytes are stored, and when these bytes are exposed as output.
To set this up, all we need to do is to tie together a byte and an enabler:
Badabing, badaboom, it should work. This is a pretty important machine, so let’s make darn sure it works:
Very cool!
9: Simulate Bus
Okay, let’s continue our experiment, to an astounding result: what if we connected the inputs and outputs of a bunch of registers to the same wires?
Now, remember that
s
allows us to decide what gets “stored” into a register, and “e” lets us “pass” the charge of a register’s byte through to the output.What would happen in the following scenario. Say R1’s byte contains “111”, all
s
ande
wires are 0.R1
’se
to 1”.R1
would enable, and thebus
wires would carry the same charge asR1
R3
’ss
to 1, then 0”.R3
, to the current charge flowing inbus
. This happens to be the output ofR1
!R1
'se
to 0” Now the current inbus
would disappear againThe result? The byte in R1 would have been “copied” to R3. We’ve just created a
bus
.To create a bus, all we need to do is to connect a register’s input and output to the same “bus” wires:
This is only a single line, but it’s pretty important to get right. It’s what lets us “copy” registers after all. Let’s see if it works:
🙀 uh oh, it doesn’t work…
10: Evolve Trigger
Let’s look at this diagram again:
Remember that the
bus
wires can “receive” a charge now from the outputs ofR1
,R2
, orR3
. Here’s how ourset-charge
looked:Imagine if
R1
was enabled and a wire was charged to1
. What would happen ifR3
got triggered?It would overwrite our charge!
Time to to evolve our model. One way to think about it, is that
bus wires
now have multiple “sources” that can provide a charge. Let’s update our charge functions to keep track of this “source”Nice. Now, whenever we set a charge, we are aware of waht source it comes from.
Let’s update
trigger
to include asource
too:And let’s make
trigger-nand-gate
use a source:Now each
NAND
gate triggers with a specific name as a source. This would make it soR1
's output trigger wouldn’t interfere withR2
output trigger! Let’s test out the bus again:We’re back.
11: Simulate and-n
Right now, our
and
gate only tests whether two inputs are both 1. What if we wanted to test that three inputs are all 1? We could just wire up a bunch ofAND
gates together:Let’s call this the
AND-N
gate. It can takeN
number of inputs, and produces one output. We can wire this up using some nice recursion:We keep recursing wire up the inputs as
AND
gates, until we’re only left with two. Let’s see how it works:12: Simulate decoder
Now, Say we have two inputs (
a
b
), and we want to know how they are charged: (a0 b0) (a0 b1) (a1 b0) (a1 b1).We could make a machine that figures that out:
It’s called a decoder. It guarantees that only one wire will turn on, and each wire will represent a specific state of
a
andb
. Here’s how we could do it:This looks like a lot, but in essence it’s pretty simple. Each input is wired into a
NOT
gate. Now let’s take an example: what would happen If we wire theNOT
outputs ofa
&b
to anAND
gate?If that
AND
output turned “1”, it would mean that botha
andb
had to be 0! This would mean the output wire of thatAND
gate, when “1”, represents the selection (a0 b0). We can keep wiringAND
gates like this, to represent all different selections.It can even be generalized: for N inputs, we create N
NOT
outputs. Then we can wire them up toand-n
gates in such a way that the output represents the a special selection.Let’s do that. First, we’ll need to install Clojure’s math combinatorics library in our
deps.edn
:Now let’s require it:
With this we can create a quick function that produces our “wire” selection mapping:
Perfect, this gives us a “selection”. We can use these “selections”, to decide what the combination of wires should be for an
and-n
gate.Here’s how the decoder could look:
…Pretty elegant. Thank you Clojure. We first wire up a bunch of
NOT
wires. Then we generate a bunch of selections, and for each one wire up andand-n
gate. We wire either fromins
orin-nots
, based on what the selection tells us.The ultimate question…will it work?!
😦
13: Simulate Lookup
With all of this, we can wire together the first part of RAM. We need a way to “look up” a byte.
What if we took one byte — let’s call it
mar
— and wired the first 4 outputs to one decoder, and the last 4 outputs to another decoder?Well, this would produce 256 output wires. For each value in the
mar
byte, two output wires would be on: one from the first decoder, and the other from the second decoder.We can think of this then, like a 16x16 grid. Each “byte” in
mar
represents a unique “intersection” in the grid. Now, in the future we can tie some registers to those grid wires, and when an “intersection” is on, we can make that register active! Pretty cool.Let’s build it:
Will it work?
Nice, we can look something up!
14: Simulate IO
Time to complete the picture:
We’re going to add an
io
bus at the bottom, aset
wire anenable
wire. For each “intersection” from our decoders, what would happen if we did this?The
top decoder wire
andleft decoder wire
are fed into anAND
gate that outputsx
.x
will only be “on” when both decoder wires are “on”. That meansx
would represent whether our intersection is active!x
andio-s
are fed into anAND
gate, which producess.
s
will only turn on, when bothio-s
is charged and the intersection is active.x
andio-e
are fed anAND
gate, producinge
. This will only turn on, when bothio-e
is charged, and the intersection is active.After that, we take
s
e
and theio
wires, and connect them to a register.If we follow that logic, it means that this register can be enabled and set, only when the intersection is “on”! If hooked this up to each intersection…all of a sudden we have 256 bytes of memory!
Let’s write this to work with just one intersection:
Does it work?
Great!
15: Wire RAM
Now, all we need to do is to
wire-mar
, and for each intersection,wire-io
. Let’s do that:We first
wire-mar
. Then, we usecartesian-product
to create a list of decoder intersections. For each of those intersections, wewire-io
. Congratulations, you’ve wired together RAM!16: Evolve state
Now, let’s take a look at how many NAND gates we have when we set our ram up:
That’s a lot. Let’s try triggering something. How long does it take?
Oi. Veery long. Why? Well, let’s remember our
dependent-nand-gates
:Every time a wire is triggered, we go through all 14056
NAND
gates! That’s a lot of iteration for finding what is probably a few NAND gates.Let’s make that faster:
Here, we make accessing dependent
NAND
gates fast. Now it’s a matter of a dictionary lookup. If we try it out, it’ll be muuch faster!17: Testing it out!
Okay, we built it, we made it fast, but will it work? Let’s imagine we built a machine like this:
mar switches
to themar
byte, and add a switch to theset
wiremar
, and access a specific “register” in our grid!io lights
io
has a charge, those lights will go on. Whichever light is on means 1, whichever light is off means 0. This will tell us the byte that is on theio
bus!io-s
wire to theio set
switchio-s
wire will be on, and as a result the currently active register’s “set” wire will be on. This will let us “save” whatever charge is inio
!io-e
wire to anio enable
switchio-e
wire will be on, and as a result the currently active register’s “enable” wire will be on. This will let us “pass” the active register’s charge intoio
. That will causeio lights
to turn on and let us read the value in the register!io switches
io
. If we combine this by toggling our io “set” switch, we can save a specific charge into the active register!With a machine like that, we can now use our RAM! Here’s how.
(I recommend following along on the diagram above)
To access a specific register:
MAR switches
, to select the exact intersection we want, and toggle themar set
switchio enable
switch . This will send the active register’s data intoio
io lights
: The combination of lights that are on represent the value of the register!io enable
switch off.To set data to a specific register:
MAR switches
, to select the exact intersection we wantIO switches
, to set the charge in IO that represents the data we want to save.io set
switch on. Now the charge ofio
will be saved to that specific register!io set
switch off, and toggle all theio switches
off.Let’s go ahead and create that machine.
First, let’s create a quick helper function to initialize our RAM:
Next, here’s a quick helper function to “set” data into our
mar
byte:Here’s the function that follows our recipe to “access a specific register”
Here’s the function that follows our recipe to “set data to a specific register”
With these two things, we can create a REPL loop:
And see how it goes:
Let’s take a moment to bask in our circuit:
Fin
Wow. we did it. 14056 NAND gates. 256 bytes of RAM. I hope you had a blast 🙂 — If you want to see how the rest of the computer is made, I definitely suggest checking out Scott’s book. He also has a great site with more example projects. To see the code all together, here’s the repo.
Thanks to Daniel Woelfel, Julien Odent, Sean Grove, Paul Vorobyev, Alexander Kotliarskyi, Alex Reichert, Poornamidam for reviewing drafts of this essay.
(1) I say this pretty lightly, but treating the charge on circuits as code was a pretty phenomenal innovation. I could only imagine what it might have been like, when Shannon showed a full-adder.
(2) To really model circuits, we’d need to learn about Maxwell’s equations. Just ordered a textbook for this, so maybe next time 🙂.
(4) That stack exchange question helped me a lot. If you’re even more curious, start with this youtube video, and follow along until the gentleman gets to the “D” latch. He uses NOR gates, but the essence is the same.
(5) There were a lot of different kind of architectures that were tried.