My Digital Garden

JPNAS.NET / Digital Garden

🌳 EvergreenLast updated:

From Source Code to Silicon

What really happens when you run a program: from NAND gates and a processor's fetch-decode-execute cycle to bytecode, assembly and machine code, who compiled the compiler, Ken Thompson's Trusting Trust, bootstrapping from a 357-byte seed, firmware, Windows and whether a country could build everything itself – with six examples, including a program written byte by byte.

Open in the interactive Digital Garden

What You Need
Curiosity – no programming knowledge is needed to follow the text; the examples are there for those who want to try
For the examples: a Linux computer (an old PC is fine, see Reviving an Old Computer) or WSL on Windows, with Python 3, GCC and the binutils tools (sudo apt install build-essential on Debian and Ubuntu)
Optional: an Arduino Uno or a bare ATmega328P chip for Example 6
Background: the ladder of layers in Communication with the Machines and the backup program from Constructing a Program

Jump to Examples

In Constructing a Program I wrote a small Python program that packs a folder into an archive every night and keeps the newest seven copies. I type python3 backup.py, press Enter, and a second later the archive is there. This note follows that one command all the way down: from the text I wrote, through the programs that translate and run it, to the processor that executes it as a stream of numbers – and finally to the uncomfortable question underneath it all: how do we know that the tools in between are honest?

The short version: everything a computer does is built from a few very simple operations, repeated billions of times per second. Every layer above them exists to make life easier for people, and every layer is a program that someone else wrote and that we trust.

A Bit of History: From Switches to Compilers That Compile Themselves

The first electronic computers of the 1940s had no programming languages at all. Programs were set with switches and cables or entered as numbers – pure machine code. Writing the numbers for each instruction by hand was slow and error-prone, so programmers soon wrote programs that write programs. The first assemblers appeared in the late 1940s, turning short names such as ADD into the right numbers, and in 1952 Grace Hopper's A-0 system showed that a program could translate something closer to human language. FORTRAN (1957) made the compiler a normal tool. The full story of languages is in Constructing a Program.

Soon a strange loop appeared: a compiler is itself a program, so it can be written in the language it compiles. Assemblers were the first tools to translate themselves, and in 1962 Tim Hart and Mike Levin at MIT wrote a LISP compiler in LISP – one of the first widely used self-hosting compilers. C and Unix followed the same path in the 1970s, and the GNU C compiler (GCC, 1987) is built by an older version of GCC to this day. Meanwhile the hardware shrank: Intel's 4004 (1971) had 2,300 transistors, the MOS 6502 (1975) about 3,500 – a close relative of it ran the Commodore 64 on which I started – while a processor today has billions.

In 1983, in his Turing Award lecture "Reflections on Trusting Trust" (published in 1984), Ken Thompson showed what this loop means for security: a compiler can be changed so that it plants a backdoor into the programs it compiles and into every new version of itself, while its source code looks perfectly clean. The answers came slowly. In 2009 David A. Wheeler described diverse double-compiling, a way to check a compiler with a second, independent one. The Reproducible Builds and Bootstrappable Builds projects made it possible to verify that a program file really comes from its source code. In 2023 the GNU Guix distribution announced a full-source bootstrap: its whole system is built from source code, starting with a single 357-byte program that a person can check by hand.

A short timeline:

1940s – programs as switch settings and numbers (machine code)
late 1940s – the first assemblers; 1952 – Hopper's A-0; 1957 – FORTRAN
1962 – a LISP compiler written in LISP (self-hosting)
1971 – Intel 4004 (2,300 transistors); 1975 – MOS 6502 (about 3,500)
1970s – C and Unix compile themselves; 1987 – GCC
1983/1984 – Ken Thompson's "Reflections on Trusting Trust"
2009 – diverse double-compiling (Wheeler)
2010s – Reproducible Builds, Bootstrappable Builds, stage0
2023 – Guix builds its system from a 357-byte seed
2026 – open-source security chip OpenTitan ships in Chromebooks

Some resources for history:

What the Hardware Actually Does

A processor understands only numbers, and it does only a handful of things with them. The table goes from the bottom up; Examples 1 and 2 build the first steps yourself.

From transistors to instructions:

Transistors: tiny electrical switches. A modern processor has billions of them, switching billions of times per second. A switch that is on or off is one bit: 1 or 0.
Logic gates: a few transistors together form a gate – AND, OR, NOT, or NAND ("not both"). Remarkably, NAND alone is enough to build every other gate, and from gates you can build anything a computer does (Example 1).
Arithmetic: an adder made of gates adds two bits and a carry, exactly as you add one column on paper. Eight of them in a row add two 8-bit numbers, 64 of them two 64-bit numbers. Subtraction, comparison and multiplication are built from the same idea.
Registers and memory: gates connected in a loop can remember a bit. A few dozen fast memory slots inside the processor are the registers; everything else lives in RAM, further away and slower.
Instructions: a processor has a fixed list of operations it can do – its instruction set: load a number from memory, add, compare, jump to another place, store. Each instruction is encoded as a few bytes. On x86-64, push r14 ("save register 14") is the two bytes 41 56.
The cycle: the processor fetches the next instruction from memory, decodes it, executes it, and repeats – forever. A modern x86 processor first breaks complex instructions into simpler internal micro-operations and runs many of them at once, but the principle is the one in Example 2.
The kernel: a normal program is not allowed to touch the disk or the screen directly. When it wants to, it executes a special instruction, syscall, and the operating system's kernel does the work for it.

The Journey of One Program

Now back to backup.py. Python is not translated into machine code. It is translated into bytecode: simple instructions for an imaginary "Python machine", such as LOAD_GLOBAL (fetch a name), CALL (call a function) and STORE_FAST (save a value). The command python3 -m dis backup.py shows them – 234 lines for this 30-line program. For comparison, I wrote the same backup job in C, a language that is translated into machine code (Example 4). It is an equivalent program written by hand, not a translation of the Python one.

The path of the C program, from text to running process:

1. Compiler (gcc): translates C into assembly language – the processor's instructions with readable names. For my program, 388 lines.
2. Assembler (as): translates each assembly line into the bytes of machine code: push r14 becomes 41 56. From here on the code is binary.
3. Linker (ld): adds the start-up code and the references to shared libraries such as the C library (libc), and produces an executable file in the ELF format – 17 KB here.
4. Loader: when you start the program, the kernel (with the execve system call) loads the file into memory, and the dynamic linker attaches libc.
5. Processor: fetches, decodes and executes the bytes. For the disk and the screen it calls the kernel: my C program made 56 system calls, and 339 together with the tar and gzip programs it starts.

Only two of these steps are real translations from one language into another: C into assembly, and assembly into machine code. The others add pieces and move the code into place.

And Python? The program python3 is itself a C program, compiled into machine code once, by the people who build your Linux distribution or by python.org. It reads your bytecode as data. At its heart is a big loop with a switch: for each bytecode instruction it jumps to a piece of already compiled machine code that does that job. Think of a cook (the interpreter) reading a recipe (the bytecode): the recipe never becomes the cook's movements, it only tells the cook which of the movements he already knows to make. That is why Python is slower than C, and why the same Python run made 1,064 system calls – most of them for loading the interpreter and its modules. The exception is a JIT compiler, which translates frequently used parts of the bytecode into machine code while the program runs. PyPy has done this for years; CPython has an experimental JIT since version 3.13, and Python 3.15 (October 2026) reports it 7–8% faster than the normal interpreter on x86-64 Linux. It is still marked experimental, and in June 2026 the Python Steering Council paused new JIT development until a proposal (PEP 836) for making it a supported part of Python is accepted.

Who Compiled the Compiler?

GCC is compiled by an older GCC, which was compiled by an even older one – a chain that leads back through decades of compilers to the 1950s, when the first programs were entered by hand in machine code. Almost nobody writes machine code today. The exceptions are the authors of compilers and assemblers, people writing code where every cycle counts (cryptography, video codecs such as FFmpeg, memory-copy routines), the code that starts an operating system, programmers of small microcontrollers, and security researchers who take apart programs without source code (reverse engineering).

Trusting Trust. Thompson's thought experiment goes like this. Change the compiler so that it recognises two programs. When it compiles login, it quietly adds a secret password that always works. When it compiles a new version of itself, it adds both of these tricks again. Then remove the changes from the source code. The source code is now clean, but every compiler built with the infected one is infected too, and so is every login. Reading the source code reveals nothing, because the infection lives only in the binary file. Thompson's conclusion: you can't trust code that you did not totally create yourself – and that includes the tools.

How the chain can be checked today:

Reproducible builds: if the same source code always gives bit-for-bit the same program file, independent people can build it and compare. A different result means something in between is different. Debian has made this a rule: since May 2026, packages that cannot be reproduced no longer enter its testing branch, and over 98% of its packages can already be rebuilt bit for bit.
Diverse double-compiling: compile the compiler's source with a second, unrelated compiler, then use the result to compile the source again. If the final file matches the official one, both compilers would have to be infected in the same way – very unlikely. Wheeler used it to check GCC.
Bootstrapping from a tiny seed (stage0): start from a program small enough to check by reading every byte – a few hundred bytes. The stage0 project describes a 256-byte seed; the one used by Guix for x86 is 357 bytes. Each next tool is written in the language of the previous one: hex0 (hex digits into bytes) → hex1 (adds simple labels) → hex2 (named labels and addresses) → M0 (a macro assembler) → cc_x86 (a C compiler for a small part of C) → M2-Planet (more C) → GNU Mes (MesCC) → TinyCC → an old GCC → a modern GCC.
Whole systems: the live-bootstrap project builds a working Linux toolchain from such seeds, and Guix uses this chain for its own system. Example 5 shows what the very first step looks like.

Windows, Firmware and the Limits of Checking

Windows works on the same principles, only the names and formats differ: the compiler is usually Microsoft's MSVC, compiled pieces are .obj files (in the COFF format), the linker is link.exe, and a program is a PE file whose first two bytes are the letters MZ. One difference is instructive: the numbers of Windows system calls change between versions, so programs never call the kernel directly. They call the system library ntdll.dll, which knows the right numbers for that version.

Because the source code is closed, nobody outside Microsoft can repeat the chain. Trust is based on independent analyses by security researchers, on Microsoft's business interest in not being caught, and on the Government Security Program (since 2003), which lets qualifying government agencies read selected source code, for example of Windows 11 and Office, through a secure online portal or in transparency centres.

Linux users should not feel too superior. Most of us install ready-made binary packages and trust the distribution, and under every operating system runs code that almost nobody can check: the UEFI firmware that starts the computer, the processor's microcode, and small separate computers inside the main chip – Intel's Management Engine and AMD's Platform Security Processor. One hopeful step: OpenTitan, an open-source security chip whose whole design is public, has been shipping in Chromebooks since 2026.

The honest perspective: a hidden trojan in a compiler is possible, but it is not how ordinary people and companies get hurt. Real attacks use known vulnerabilities that were not patched, stolen or weak passwords and poisoned software updates, as described in Security Considerations. The trust chain matters most for those who are worth a very expensive attack.

Could a Country Build Everything Itself?

If nobody can check everything, should an army or a state build its whole stack – chips, operating system, compilers – itself? Some try:

Examples (my own overview of public reports; the situation changes quickly):

China: Loongson processors with their own LoongArch instruction set, and operating systems such as Kylin. In independent tests in early 2026, the 12-core Loongson 3B6000 reached on average about a third of the performance of a current AMD desktop processor (Ryzen 5 9600X).
Russia: Elbrus processors from MCST and the Astra Linux operating system. The Elbrus chips were made by TSMC in Taiwan until the sanctions of 2022 cut that off. Reports in June 2026 say that deliveries have resumed from an unnamed "friendly" country, but MCST has not confirmed this.
United States: the Department of Defense's Trusted Foundry programme. It does not build everything itself, but accredits selected companies (the first was IBM) for the design and manufacture of chips for sensitive uses.

The obstacles are enormous. A modern chip factory costs well over ten billion euros, a single machine for the most advanced chips (EUV lithography) costs a few hundred million dollars and comes from only one company in the world, ASML in the Netherlands, and home-grown chips lag years behind in performance. A smaller, closed circle of developers also means fewer eyes on the code and so more undiscovered errors – and an insider can be bribed or blackmailed in any organisation. In practice, states build their own only the most critical, small parts, such as encryption devices, and protect everything else with layers: separated networks, strict update procedures and monitoring. Interest in open hardware is growing for exactly this reason: an open instruction set such as RISC-V and open chip designs such as OpenTitan can be checked by anyone, which turns trust into something closer to verification.

Examples: From Gates to a Program You Can Read Byte by Byte

The examples go from the bottom up. I ran them on Linux (x86-64) with Python 3.13 and GCC 13; the outputs are from those runs, apart from Example 6, which I could not test here. Save each Python example as a .py file and run it with python3 name.py.

Example 1 – Adding numbers with nothing but NAND gates

A computer adds numbers with switches. Here every switch is one NAND gate, and everything else – NOT, AND, OR, XOR, an adder for one column, and an adder for eight columns – is built only from it.

# A computer adds numbers with nothing but tiny switches. Here every switch is one NAND gate.
gates = 0

def nand(a, b):
    """The only building block: 0 only if both inputs are 1."""
    global gates
    gates += 1
    return 0 if (a and b) else 1

# Everything else is built from NAND
def NOT(a):    return nand(a, a)
def AND(a, b): return NOT(nand(a, b))
def OR(a, b):  return nand(NOT(a), NOT(b))
def XOR(a, b): return AND(OR(a, b), nand(a, b))

def full_adder(a, b, carry):
    """Adds three bits, like adding one column on paper: gives a digit and a carry."""
    s = XOR(XOR(a, b), carry)
    carry_out = OR(AND(a, b), AND(carry, XOR(a, b)))
    return s, carry_out

def add8(x, y):
    """Adds two 8-bit numbers column by column, from the right, passing the carry on."""
    result, carry = 0, 0
    for i in range(8):
        bit, carry = full_adder((x >> i) & 1, (y >> i) & 1, carry)
        result |= bit << i
    return result

print(f"{23:08b}   (23)")
print(f"{19:08b} + (19)")
total = add8(23, 19)
print(f"{total:08b} = ({total})   using {gates} NAND gates")

Output:

00010111   (23)
00010011 + (19)
00101010 = (42)   using 200 NAND gates

This is exactly how an adder in a chip works, only there it is made of transistors and all eight columns work at the same time. My version counts 200 NAND operations because it calculates some parts twice for readability; a careful circuit needs 9 NAND gates per column, 72 for eight bits. The same adder built from real switches and relays that you can flip by hand is in Computing with Switches.

Example 2 – A processor in thirty lines

An imaginary processor with six instructions. Its memory holds only numbers – the program and the data are the same kind of thing. The loop at the bottom is the fetch–decode–execute cycle of every real processor. The program adds the numbers from 1 to 10.

# A tiny imaginary processor. Its memory holds only numbers: some are instructions, some are data.
# Each instruction is two numbers: an operation code and an address.
#   1 = LOAD   copy memory[address] into the accumulator A
#   2 = ADD    A = A + memory[address]
#   3 = SUB    A = A - memory[address]
#   4 = STORE  memory[address] = A
#   5 = JNZ    jump to address if A is not zero
#   0 = HALT   stop

memory = [
    1, 16,   #  0: LOAD  total
    2, 17,   #  2: ADD   counter
    4, 16,   #  4: STORE total      total = total + counter
    1, 17,   #  6: LOAD  counter
    3, 18,   #  8: SUB   one
    4, 17,   # 10: STORE counter    counter = counter - 1
    5, 0,    # 12: JNZ   0          repeat while counter is not zero
    0, 0,    # 14: HALT
    0,       # 16: total
    10,      # 17: counter
    1,       # 18: the number one
]

pc, a, steps = 0, 0, 0                  # program counter, accumulator, instructions done
while True:
    op, addr = memory[pc], memory[pc + 1]   # 1. FETCH
    pc += 2
    if op == 0:                             # 2. DECODE and 3. EXECUTE
        break
    elif op == 1: a = memory[addr]
    elif op == 2: a = a + memory[addr]
    elif op == 3: a = a - memory[addr]
    elif op == 4: memory[addr] = a
    elif op == 5 and a != 0: pc = addr
    steps += 1

print("program:", memory[:16])
print("1 + 2 + ... + 10 =", memory[16], f"({steps} instructions executed)")

Output:

program: [1, 16, 2, 17, 4, 16, 1, 17, 3, 18, 4, 17, 5, 0, 0, 0]
1 + 2 + ... + 10 = 55 (70 instructions executed)

The line program: [1, 16, 2, 17, ...] is the program: machine code for this imaginary processor. A real processor does the same with a few thousand kinds of instructions instead of six – and billions of times per second.

Example 3 – One function on every level

The smallest possible example: a function that adds two numbers. In Python (add.py):

def add(a, b):
    return a + b

The bytecode that Python runs (python3 -c "import dis; from add import add; dis.dis(add)"):

  1           RESUME                   0

  2           LOAD_FAST_LOAD_FAST      1 (a, b)
              BINARY_OP                0 (+)
              RETURN_VALUE

The same function in C (add.c):

int add(int a, int b)
{
    return a + b;
}

Its assembly code (gcc -O2 -S -masm=intel -fcf-protection=none add.c, shortened to the essential lines):

add:
    lea    eax, [rdi+rsi]
    ret

And the machine code (gcc -O2 -fcf-protection=none -c add.c and objdump -d -M intel add.o) – the address, the bytes, and what they mean:

   0:    8d 04 37                 lea    eax,[rdi+rsi*1]
   3:    c3                       ret

The whole function is four bytes: 8d 04 37 adds the two numbers that arrive in the registers rdi and rsi and puts the result in eax, where the caller expects it, and c3 returns. The Python version does the same job with four bytecode instructions, but each of them runs dozens or hundreds of machine instructions inside the interpreter – because Python's + must first find out whether it is adding numbers, text or lists. (The option -fcf-protection=none leaves out an extra security instruction that Ubuntu adds at the start of every function by default.)

Example 4 – The backup program through the whole chain

Now the real program: backup.py from Constructing a Program, and backup.c, an equivalent C program that I wrote for comparison (it uses the system's tar program to pack the folder, while the Python version uses Python's tarfile module). You can follow each step with these commands:

python3 -m dis backup.py | wc -l        # bytecode: 234 lines
gcc -O2 -S -masm=intel backup.c         # compile: backup.s, 388 lines of assembly
as backup.s -o backup.o                 # assemble: machine code, 5,464 bytes
gcc backup.o -o backup                  # link: an ELF program, 17,016 bytes
file backup                             # ELF 64-bit LSB pie executable, x86-64, dynamically linked
ldd backup                              # the libraries it needs: libc.so.6, ld-linux-x86-64.so.2
head -c 4 backup | od -c                # the first bytes: 177 E L F
strace -c ./backup data backups         # count the system calls

A part of the bytecode – the line if not SOURCE.is_dir(): and the start of the next one:

  13   LOAD_GLOBAL      0 (SOURCE)
       LOAD_ATTR        3 (is_dir + NULL|self)
       CALL             0
       TO_BOOL
       POP_JUMP_IF_TRUE 29 (to L1)

  14   LOAD_GLOBAL      4 (sys)
       LOAD_ATTR        6 (exit)

The start of main in the C program, as machine code (objdump -d -M intel backup):

00000000000012f0 <main>:
    12f0:  f3 0f 1e fa     endbr64
    12f4:  41 57           push   r15
    12f6:  41 56           push   r14
    12f8:  41 55           push   r13
    12fa:  41 54           push   r12
    12fc:  55              push   rbp
    12fd:  53              push   rbx

The two versions in numbers (my runs; yours will differ a little):

Source code: Python 30 lines, C 70 lines
What the processor gets: Python – 234 lines of bytecode, run by the 3.13 interpreter; C – 388 lines of assembly, turned into a 17 KB machine-code program
System calls for one run: Python 1,064 (mostly starting the interpreter and loading modules); C 56 in its own process, 339 including tar and gzip

Example 5 – A program written directly in bytes, without any compiler

This is the idea of the first stage of a bootstrap: a program small enough to check every byte. Below is a complete Linux program in 165 bytes, written as hex numbers with a comment for each group. The first 64 bytes tell Linux "this is a program", the next 56 say where to put it in memory, then 33 bytes of instructions and 12 bytes of text. Save it as hello.hex0:

# hello.hex0 - a complete Linux x86-64 program, written byte by byte.
# Everything after # or ; is a comment. 165 bytes in total.

# --- ELF header (64 bytes): "this is a program, start it like this"
7F 45 4C 46             # magic number: 0x7F 'E' 'L' 'F'
02 01 01 00             # 64-bit, little-endian, ELF version 1, Linux/System V
00 00 00 00 00 00 00 00 # padding
02 00                   # type: executable
3E 00                   # machine: x86-64
01 00 00 00             # version 1
78 00 40 00 00 00 00 00 # entry point: start at address 0x400078
40 00 00 00 00 00 00 00 # the program header starts at byte 64
00 00 00 00 00 00 00 00 # no section headers
00 00 00 00             # flags
40 00 38 00 01 00       # header size 64, program header size 56, one program header
00 00 00 00 00 00       # no sections

# --- Program header (56 bytes): "load the file into memory at 0x400000"
01 00 00 00             # type: load into memory
05 00 00 00             # permissions: read + execute
00 00 00 00 00 00 00 00 # from the start of the file
00 00 40 00 00 00 00 00 # to virtual address 0x400000
00 00 40 00 00 00 00 00 # (physical address, ignored)
A5 00 00 00 00 00 00 00 # 165 bytes from the file
A5 00 00 00 00 00 00 00 # 165 bytes in memory
00 10 00 00 00 00 00 00 # aligned to 4096-byte pages

# --- The code (33 bytes), at address 0x400078
B8 01 00 00 00          # mov eax, 1        system call number 1: write
BF 01 00 00 00          # mov edi, 1        to file 1: the screen (standard output)
48 8D 35 10 00 00 00    # lea rsi, [rip+16] address of the text, 16 bytes ahead
BA 0C 00 00 00          # mov edx, 12       12 bytes long
0F 05                   # syscall           ask the kernel to do it
B8 3C 00 00 00          # mov eax, 60       system call number 60: exit
31 FF                   # xor edi, edi      exit code 0
0F 05                   # syscall

# --- The data (12 bytes): "Hi from hex" and a new line
48 69 20 66 72 6F 6D 20 68 65 78 0A

To turn the text into a file of bytes we need a tool – the simplest "compiler" possible, which only reads pairs of hex digits and ignores comments. That is all that the 357-byte seed of a real bootstrap does. Here it is in Python (hex0.py):

# hex0.py - the simplest possible "compiler": turns pairs of hex digits into bytes
# and ignores everything after # or ; on a line.
import sys

out = bytearray()
for line in open(sys.argv[1], encoding="ascii"):
    line = line.split("#")[0].split(";")[0]
    out += bytes.fromhex("".join(line.split()))
open(sys.argv[2], "wb").write(out)
print(len(out), "bytes written to", sys.argv[2])
python3 hex0.py hello.hex0 hello
chmod +x hello
./hello
165 bytes written to hello
Hi from hex

No compiler, no assembler, no linker and no library – only the kernel, which the program asks twice for a service: write (system call 1) and exit (system call 60). Compare it with the assembly version in Communication with the Machines, Example 4: the instructions are the same, but here you are the assembler and the linker. In a real bootstrap the converter itself is written this way too, so that nothing is left that you have to take on trust.

Example 6 – Small systems you can still understand completely

A modern PC is too big for any one person to know completely. Small systems are not – and they are the best way to learn, and the places where full control is still possible:

Systems small enough to understand (my own selection):

The 6502 and the Commodore 64: about 3,500 transistors, 64 KB of memory, and documentation that fits into one book. People still write new programs for it, and the whole chip has been photographed and simulated transistor by transistor at Visual 6502.
An 8-bit computer on breadboards: Ben Eater's video series builds a working computer from simple logic chips, wire by wire.
Nand to Tetris: a course and book by Noam Nisan and Shimon Schocken in which you build, in a simulator, a whole computer from NAND gates up – Example 1 is its first step – and then an assembler, a compiler and a small operating system for it.
A microcontroller (ATmega328P, the chip on the Arduino Uno): 32 KB of program memory and 2 KB of RAM, no operating system, and one datasheet that describes every register. The program below runs on the bare chip, with no Arduino library in between.
An open processor design: the Raspberry Pi Pico 2 (RP2350) includes Hazard3 RISC-V cores whose complete design is published, so you can read the very circuit that runs your code.
A small operating system: MIT's teaching system xv6 – a rewrite of 1970s Unix in about 10,000 lines of C, with a free book that explains every part.
A bootstrap from a seed: stage0-posix and live-bootstrap, from a few hundred bytes to a full toolchain.

A taste of the microcontroller level: this C program blinks the LED on pin 13 of an Arduino Uno by writing directly to the chip's registers – the register names come from the datasheet. I could not test this one here, so treat it as a starting point and check the commands against the documentation of your board.

/* blink.c - ATmega328P (Arduino Uno), no Arduino library */
#include <avr/io.h>
#include <util/delay.h>

int main(void)
{
    DDRB |= (1 << DDB5);            /* pin PB5 (Arduino pin 13) is an output */
    for (;;) {
        PORTB ^= (1 << PORTB5);     /* switch the LED on or off */
        _delay_ms(500);             /* wait half a second */
    }
}
sudo apt install gcc-avr avr-libc avrdude
avr-gcc -mmcu=atmega328p -DF_CPU=16000000UL -Os blink.c -o blink.elf
avr-objcopy -O ihex blink.elf blink.hex
avrdude -c arduino -p m328p -P /dev/ttyACM0 -b 115200 -U flash:w:blink.hex

The result is a few hundred bytes of machine code that you can list with avr-objdump -d blink.elf and compare instruction by instruction with the datasheet – no hidden layers, no operating system, nothing between your program and the hardware. That is what "a system you can still control" means. The same chip family appears in Microcontroller Communication.

A Few More Thoughts

When I started on the Commodore 64, the distance between me and the hardware was short: a BASIC command, and under it a chip I could almost understand. Today a single python3 backup.py passes through an interpreter, a compiler that compiled it, libraries, a kernel, firmware and a processor that translates its own instructions into smaller ones – millions of lines of code written by thousands of people I will never meet. That is the price of everything modern computers can do, and on the whole it is a good bargain.

What I take from Thompson's lecture is not fear but modesty. Nobody can check everything, so trust is unavoidable – the question is only how well it is founded. Open source code, reproducible builds, bootstrapping from a seed you can read and open hardware such as RISC-V and OpenTitan all push in the same direction: from "trust us" to "check for yourself". And for anyone who wants to feel the bottom again, the small systems are still there – a few hundred bytes, one chip, one datasheet – where every bit is yours.

← All notes