From Transistor to Browser

The Software · Chapter 7
How do programs run programs?

Updated Oct 12, 2026
Summarize
Transcript

How do programs run programs? Our CPU only runs eight-bit words. But you can type A plus B plus C, and get forty-two.

I figured there's one way: translate it into CPU words first. But a program can just read the sum and do the math itself, writing no words at all.

First, the parser turns the text into a tree: a plus at the top, A plus B on the left, C on the right. A is ten, B is twenty, C is twelve. Walk it: A plus B is thirty, plus C is forty-two. That walker is an interpreter. Or translate it: load A, add B, add C, and run those words on our CPU. That translator is a compiler. The CPU ends up holding forty-two too.

So programs run programs two ways, with one answer! An interpreter is a program that runs a program; a compiler is a program that writes one.

Type any sum below and race the two routes. Do they ever disagree?

The hosts' voices are synthetic, made with Breeze TTS 2 by BreezeBlue.

  1. The browser
  2. The network
  3. Software and languages
  4. The CPU
  5. Memory
  6. Arithmetic
  7. Logic gates
  8. The switch
Figure 7.1: Where we are in the climb from transistor to browser: on the software rung. This chapter invents a small language of arithmetic and runs it two ways: by walking its tree, and by compiling it down to the machine’s own instructions.

Chapter 6 built an assembler: a program that translates text into machine code, one line to one instruction. That is already a program operating on another program. Chapter 6.b then showed how any text is first cut into tokens; this chapter finally builds the machinery that consumes them. We climb one level and invent a small language of arithmetic, more abstract than assembly, and ask how a machine that only knows 8-bit words could possibly run it. There are two classic answers, and this chapter builds both.

The first answer is to interpret: write a program that reads the new language and computes its meaning directly, step by step. The second is to compile: write a program that translates the new language down into one the machine already runs, and then run that. An interpreter is a program that runs a program. A compiler is a program that writes one. Both end at the same number, and seeing why is the point of the chapter.

7.1 A tiny language, and its tree

The language is deliberately small: integer literals, variable names, addition, and parentheses. An expression like (a + b) + c is the whole of it. Small as it is, it has the one feature that makes a language more than a list of instructions: structure. The parentheses group sub-expressions, and groups can nest. Before anything can run such a program, the flat text has to be turned into that structure.

To match the CPU, this language works with unsigned bytes. Literals and input values must be integers from 0 to 255, and each addition keeps only its low eight bits: 255 + 1 is 0, and 200 + 100 is 44. In Python, % 256 expresses that wrap by taking the remainder after division by 256. Both execution routes use this rule. The compiled route also needs its code and data to fit the CPU’s sixteen words; the compiler rejects a larger program.

Turning text into a tree happens in two steps. A lexer like Chapter 6.b’s chops the characters into tokens: names, numbers, and the symbols +, (, ). A parser then assembles the tokens into a tree, the abstract syntax tree, which makes the grouping explicit. Figure 7.2 shows the tree for (a + b) + c: the outer addition sits at the root, with the inner a + b as its left child and c as its right.

astast
Figure 7.2: The abstract syntax tree of (a + b) + c. Each + node is an addition; each leaf is a variable. The parser builds this tree; the interpreter and the compiler each walk it.

The parser is the smallest kind that handles nesting: recursive descent. One method reads an expression as a sequence of terms joined by +; another reads a single term, which is a number, a name, or a parenthesized expression, and a parenthesized expression sends it back to the first method. The recursion in the grammar becomes recursion in the code.

Example 7-1. Recursive-descent parser: tokens into a tree

class _Parser:
    """A recursive-descent parser. expr = term ('+' term)*; term = number |
    name | '(' expr ')'."""

    def __init__(self, tokens: list[str]) -> None:
        self.tokens = tokens
        self.pos = 0

    def _peek(self) -> str | None:
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None

    def _advance(self) -> str:
        if self.pos >= len(self.tokens):
            raise ValueError("unexpected end of input")
        tok = self.tokens[self.pos]
        self.pos += 1
        return tok

    def expr(self) -> tuple:
        """Parse a sequence of terms joined by +, left to right."""
        node = self.term()
        while self._peek() == "+":
            self._advance()
            node = Add(node, self.term())1
        return node

    def term(self) -> tuple:
        """Parse a single number, name, or parenthesized expression."""
        tok = self._advance()
        if tok == "(":
            node = self.expr()2
            if self._advance() != ")":
                raise ValueError("missing closing parenthesis")
            return node
        # The tokenizer emits only all-digit numbers and alpha-started names, so
        # these two checks are collectively exhaustive for any real token.
        if tok.isdigit():
            return Num(int(tok))
        if tok.isalnum():
            return Var(tok)
        raise ValueError(f"unexpected token {tok!r}")

7.2 Interpreting: walk the tree

With the tree in hand, the first way to run the program is the most direct one: walk the tree and compute as you go. The interpreter is one recursive function. A number node evaluates to its value. A variable node evaluates to whatever the environment binds it to, with either value checked against the byte range. An addition node evaluates its two children, adds the results, and wraps at 256. The structure of the function mirrors the structure of the tree.

Example 7-2. Interpreter: evaluate the tree directly

def interpret(node: tuple, env: dict[str, int]) -> int:
    """Evaluate byte operands, wrapping each sum at eight bits like the CPU."""
    if isinstance(node, Add):
        return (interpret(node.left, env) + interpret(node.right, env)) % 2562
    value = node.value if isinstance(node, Num) else env[node.name]1
    if type(value) is not int or not 0 <= value <= 255:
        raise ValueError("operands must be integers from 0 to 255")
    return value

Nothing here produces machine code. The interpreter is the machine: it holds the result in an ordinary Python variable and never mentions an opcode. This is close to how many real languages run, from shell scripts to early BASIC interpreters that walked the source directly. Python only resembles this in style; it compiles the source to bytecode first and runs that on a virtual machine rather than walking the tree itself.

7.3 Compiling: write a program

The second way is indirect. Instead of computing the answer, the compiler walks the same tree and emits a second program, written in the assembly of Chapter 6, that will compute the answer when run. The machine of Part I can only load, add, and store, so the compiler must express the whole expression in those terms.

One observation makes this easy. Addition associates, including when each sum wraps at 256: grouping changes neither the final low eight bits nor the answer. So a tree built only from additions is just the sum of its leaves, in order. The compiler flattens the tree to that list of leaves, gives each distinct operand a memory cell, then emits code that loads the first operand and adds the rest.

Example 7-3. Compiler: emit Chapter 6 assembly

def compile_expr(node: tuple, env: dict[str, int]) -> str:
    """Compile an expression into assembly that computes it.

    Each distinct operand becomes a DATA cell; the code loads the first leaf and
    adds the rest, then stores the result. A leading JUMP skips the data cells.
    The compiler's own labels begin with an underscore, a character the
    tokenizer never allows in a variable name, so they can never clash with the
    program's own variables. Reject non-byte operands and images over 16 words.
    """
    leaves = _leaves(node)1
    cells = {}   # label -> value, one entry per distinct operand
    order = []   # operand labels in the order they are loaded and added
    for leaf in leaves:
        value = interpret(leaf, env)  # validate the same byte domain on both paths
        if isinstance(leaf, Var):
            label = leaf.name
        else:
            label = f"_lit{leaf.value}"2
        cells.setdefault(label, value)
        order.append(label)
    lines = ["        JUMP _start"]
    for label, value in cells.items():
        lines.append(f"{label}: DATA {value}")
    lines.append("_result: DATA 0")
    lines.append(f"_start:  LOAD {order[0]}")3
    for label in order[1:]:
        lines.append(f"        ADD {label}")
    lines.append("        STORE _result")
    lines.append("        HALT")
    if len(lines) > 16:
        raise ValueError(f"compiled program needs {len(lines)} words; CPU holds 16")
    return "\n".join(lines) + "\n"

The output is ordinary assembly text. Feeding the expression (a + b) + c to the compiler produces the program that loads a, adds b, adds c, and stores the result: the same shape Chapter 6 wrote by hand, differing only in the compiler-chosen label names. The compiler did the writing this time.

7.4 Both ways agree

Now the two paths can be run side by side. The interpreter walks the tree and returns a number. The compiler emits assembly; the Chapter 6 assembler turns it into machine code; the Chapter 5 CPU runs it. Figure 7.3 traces both routes from the same tree to the same answer.

two-waystwo-ways
Figure 7.3: Two ways to run (a + b) + c. Left: the interpreter walks the tree and evaluates it directly. Right: the compiler emits assembly, which the Chapter 6 assembler turns into machine code, which the Chapter 5 CPU runs. Both reach the same 42.

The interactive version needs the in-browser runtime.

Running ftb.language does exactly this: it parses the expression, shows the tree, interprets it, compiles it, runs the compiled program on the CPU, and reports whether the two answers match.

output · ftb.language
source:      (a + b) + c
environment: {'a': 10, 'b': 20, 'c': 12}
parsed tree: Add(left=Add(left=Var(name='a'), right=Var(name='b')), right=Var(name='c'))

interpreted directly: 42

compiled to assembly:
        JUMP _start
a: DATA 10
b: DATA 20
c: DATA 12
_result: DATA 0
_start:  LOAD a
        ADD b
        ADD c
        STORE _result
        HALT

compiled, assembled, and run on the CPU: 42

both ways agree: True

The interpreted result and the compiled-and-run result are both 42, and they are the same 42 as Chapters 5 and 6. The compiled assembly is recognisably their example program, generated now from a higher-level expression rather than written out by hand. That agreement is not a coincidence: both routes compute the meaning of the same tree, one by walking it, the other by translating it into a machine that walks instructions instead. That meaning includes the byte rule: 255 + 1 reaches 0 on both paths. The comparison applies to expressions whose generated program fits memory.

This language only computes expressions; the next chapter adds the state and loops that make a language.

Exercises