Logic Gates

Gate-Level CPU Simulator

Every operation the CPU performs eventually reduces to boolean gates on individual bits: AND, OR, XOR, NAND, NOR, XNOR, NOT. Adders are XORs and ANDs glued together. Multiplexers are ANDs and ORs glued together. Memory cells are gates wired in feedback loops. So before we build anything, we need a clean way to represent a gate — something we can instantiate, compose, and simulate without redundant code.


Piece 1 — What a gate is, formally

Two inputs, each 0 or 1. That's four possible input combinations: (0,0), (0,1), (1,0), (1,1). The gate produces one bit of output for each combination. A gate is therefore nothing more than an assignment of a single output bit to each of the four input rows. That mapping is the gate's truth table.

Aside: I noticed while learning this that the truth table is structurally the same problem as enumerating spin configurations of an H2 molecule. Each row of the table is like a basis state of two spins (↑↑, ↑↓, ↓↑, ↓↓), and the "gate" is just a labelling of those four basis states with output values. So if you ever get asked "what's the similarity between the H2 atom and logic gates," now you have an answer.

The six famous 2-input gates

AND OR XOR NAND NOR XNOR
00000111
01011100
10011100
11110001

Notice that every column on the right is just a length-4 string of 0s and 1s. AND is 0001. OR is 0111. XOR is 0110. The gate is the column. Hold onto that — it's the whole insight of this page.

Click the a and b toggles on any gate to flip them between 0 and 1 — all six gates share the same inputs so you can compare them side by side. Or hit Cycle through 4 configs to step through every combination automatically. Wires light green when carrying a 1; the bulb at the right shows the gate's output, and the highlighted character of tt = ... is the row of the truth table currently selected by .

Gates compose into circuits

The six named gates aren't the whole picture. A circuit almost never uses just one of them — it wires several together, with the output of one feeding the input of the next. Composition is what turns these primitives into useful behavior.

Quick worked example: build XOR from AND, OR, and NOT. (Pretend for a moment that XOR isn't already in your toolbox.)

Look at when XOR fires. Its column is 0110 — output 1 only on rows (0,1) and (1,0). So you want a circuit that's true exactly when " and " OR " and ". Each clause is itself a small circuit:

XOR is true when either clause is true. So OR them together:

Five gates total — 2 NOTs, 2 ANDs, 1 OR. Walk the truth table to confirm:

¬a ¬b ¬a ∧ b a ∧ ¬b OR (= XOR)
0011000
0110101
1001011
1100000

Every row in the rightmost column matches XOR's 0110. We didn't need a new gate type — XOR is just AND, OR, and NOT in a different shape. (On real chips XOR usually is a primitive because the composition costs five transistors more than a dedicated XOR cell. The composition is still what defines it.)

There's a deeper version of the same trick. Any 2-input gate, all sixteen of them, can be built from AND, OR, and NOT alone — and every one of those, in turn, can be built from just NAND. (One-line proofs: NOT(a) = NAND(a, a); AND(a, b) = NOT(NAND(a, b)); OR follows from De Morgan.) That's why CMOS chips lean on NAND so heavily — one universal building block, four transistors per cell, replicate by the million. The next page wires AND and XOR together to build an adder; every layer above that is more of the same.


Piece 2 — The naive way: one function per gate

The most direct implementation is what you'd write the first time through. Each gate is its own function, body full of if statements that hard-code the truth table.

def AND(a, b):
    if a == 1 and b == 1:
        return 1
    return 0

def OR(a, b):
    if a == 1 or b == 1:
        return 1
    return 0

def XOR(a, b):
    if a != b:
        return 1
    return 0

def NAND(a, b):
    if a == 1 and b == 1:
        return 0
    return 1

def NOR(a, b):
    if a == 0 and b == 0:
        return 1
    return 0

def XNOR(a, b):
    if a == b:
        return 1
    return 0

def NOT(a):
    if a == 1:
        return 0
    return 1

This works. You can build adders out of AND and XOR calls, multiplexers out of AND and OR, and so on. But look at the pattern: each function body is structurally identical — read two bits, decide an output bit. The only thing that varies is which input combinations get mapped to 1. We're writing the same shape of code seven times.

There's also a problem of scope. There are exactly possible 2-input gates (one for each 4-bit truth table). The named six are common, but if a circuit needs output = a AND NOT b as a single gate, we'd have to write a new function for it. The "function-per-gate" approach silently caps the design space at "gates we bothered to name."


Piece 3 — Each gate is the same object with a different setting

The realization: every 2-input gate has the same interface (read two bits, produce one bit) and the same shape of behavior (lookup in a 4-row table). What differs is the contents of the table. So instead of seven functions, write one — a Gate class — and parameterize it by the truth table itself.

The truth table is a 4-bit string. Index (a,b) by packing a and b into a 2-bit integer (2a + b) and looking up that position in the string. AND is the gate constructed with "0001". OR with "0111". And so on.

class Gate:
    """A 2-input boolean gate, parameterized by its 4-bit truth table.

    The bit at position (2*a + b) is the gate's output for inputs (a, b).
    Read the string left-to-right as the outputs for inputs
    (0,0), (0,1), (1,0), (1,1).
    """
    def __init__(self, truth_table, name=""):
        assert len(truth_table) == 4, "2-input gate needs a 4-bit table"
        self.tt = truth_table
        self.name = name

    def __call__(self, a, b):
        return int(self.tt[(a << 1) | b])

    def __repr__(self):
        return f"Gate({self.name or self.tt!r})"


# Every 2-input gate is now the same object, with a different setting.
AND  = Gate("0001", "AND")
OR   = Gate("0111", "OR")
XOR  = Gate("0110", "XOR")
NAND = Gate("1110", "NAND")
NOR  = Gate("1000", "NOR")
XNOR = Gate("1001", "XNOR")

# Some gates that don't have famous names:
A_AND_NOT_B = Gate("0010")     # output = a AND (NOT b)
ALWAYS_ONE  = Gate("1111")     # tautology
NOT_A       = Gate("1100")     # ignores b, inverts a

Now the seven gates collapse to seven one-liners — and we get the other nine 2-input gates for free, just by writing different bit strings. Gate("1100") is "ignore b, return NOT a". Gate("1111") is the constant-1 gate. We didn't have to invent any new code; we just set a different value of the same parameter.


Piece 4 — Generalizing to any number of inputs

Once you see gates as truth tables, the 2-input restriction is arbitrary. An -input gate has input rows and therefore needs a -bit truth table. The same Gate class handles any : just check that the table length is a power of two, deduce from of that, and pack the inputs into an integer index of that many bits.

class Gate:
    """A boolean gate with any number of inputs.
    Truth table is a 2**n-bit string for an n-input gate.
    """
    def __init__(self, truth_table, name=""):
        n_bits = len(truth_table)
        assert n_bits & (n_bits - 1) == 0 and n_bits >= 1, \
            "table length must be a power of two"
        self.tt = truth_table
        self.n_inputs = n_bits.bit_length() - 1
        self.name = name

    def __call__(self, *inputs):
        assert len(inputs) == self.n_inputs
        # Pack inputs as the bits of an integer index.
        index = 0
        for bit in inputs:
            index = (index << 1) | bit
        return int(self.tt[index])


NOT_GATE = Gate("10",       "NOT")     # 1 input  → 2 rows
AND      = Gate("0001",     "AND")     # 2 inputs → 4 rows
MAJ3     = Gate("00010111", "MAJORITY") # 3 inputs → 8 rows

A 1-input gate has a 2-bit table — there are only four of them (constant-0, identity, NOT, constant-1). A 3-input gate has an 8-bit table — 256 possible 3-input gates, of which the majority gate (00010111) is the famous one. A 4-input gate has a 16-bit table — 65536 possible gates, most of which have no name.


The necessarily-true facts

Short list of claims this representation rests on.

  1. An -input gate is fully described by its -bit truth table. Two gates with the same truth table are interchangeable in any circuit; two gates with different truth tables differ in their behavior for at least one input combination.
  2. There are exactly distinct -input gates. 4 one-input, 16 two-input, 256 three-input, 65536 four-input. The named ones (AND, OR, XOR, …) are a small handful of the total.
  3. The input combination encoded as indexes the truth-table string consistently. Read the string from left to right as the outputs for inputs in order (0,0), (0,1), (1,0), (1,1). The same convention generalizes to inputs as a binary integer.
  4. Every named gate is reproducible from its bit string. AND ↔ 0001; OR ↔ 0111; XOR ↔ 0110; NAND ↔ 1110; NOR ↔ 1000; XNOR ↔ 1001; majority ↔ 00010111. No information is lost in the representation.
  5. Evaluation is . Pack the inputs into an index in bit operations, then string-index in . No branching, no special-cases.

Why this abstraction is the right one

Three reasons to like this representation:

Next we'll wire several of these gates together to build the one-bit and multi-bit adders, the arithmetic backbone of the CPU.