Quick Answer

A CPU executes code by repeating three steps millions to billions of times per second: fetch the next instruction from memory using the program counter, decode it to work out what operation and operands it specifies, and execute it by driving the ALU, registers, or memory. Every program, however complex, reduces to this cycle running one instruction at a time.

The Fetch-Decode-Execute Cycle, In One Sentence

Every program you've ever run — a React app, a game, a machine learning model — eventually gets compiled or interpreted down to a stream of simple instructions that a CPU understands, and the CPU runs that stream one instruction at a time using the same three-step loop, billions of times per second: fetch the next instruction from memory, decode it to work out what it's asking for, and execute it by actually doing the work. Then it does it again, for the next instruction, forever, until the program halts or the power goes off.

This sounds almost too simple to explain how a CPU can run a video game or a database at the same time as fifty browser tabs, but that's genuinely the whole trick. There is no instruction that does something magically different from fetch-decode-execute — a function call, a loop, an if statement, a network request being kicked off, all of it eventually reduces to a long sequence of tiny operations like "add these two numbers," "move this value from a register into memory," or "jump to a different instruction if this value is zero." The illusion of complexity comes from doing an enormous number of very small, very fast, very boring steps.

In this post we're not going to wave our hands at this. We're going to build an actual toy CPU in Python — with real registers, a real memory array, and a real program counter — and run a real program on it, so you can see the register and memory values change on every single cycle.

Registers, Memory, and the Program Counter

Before the cycle makes sense, you need three pieces of state that every CPU keeps track of:

  • Registers — a small number of storage slots built directly into the CPU chip itself, usually somewhere between 8 and 32 general-purpose ones on a typical modern architecture. Registers are stupidly fast to read and write because they're physically part of the processor, with no bus or memory controller in between. Real CPUs might name them things like RAX, RBX, R0-R15, or x0-x30 depending on the architecture (x86-64, ARM, and so on). Our toy CPU will use four: R0 through R3.
  • Memory (RAM) — a much larger, much slower array of storage, addressed by number. Every byte of memory has an address, and an instruction that wants a value from memory has to say which address it wants. Compared to a register, going to RAM can cost 100 times as many clock cycles, which is the entire reason CPU caches (L1, L2, L3) exist — they sit between the registers and RAM as a fast staging area for memory the CPU has recently touched or is likely to touch next.
  • The program counter (PC) — a special register that holds the memory address of the next instruction to run. This is the thing that actually drives the whole cycle forward. Most of the time the PC just increments by one after each instruction — run this one, move to the next one in sequence. A jump or a branch instruction is nothing more mysterious than an instruction that's allowed to overwrite the PC with some other address instead of letting it increment normally. That's the entire mechanism behind if, while, for, and function calls at the hardware level — all of them work by rewriting the program counter.

There's also the ALU (arithmetic logic unit), the piece of circuitry that actually performs additions, subtractions, comparisons, and bitwise operations on register values, and the control unit, which is the circuitry that reads the decoded instruction and turns on the right wires to make the ALU, registers, and memory do the correct thing for that specific instruction. You won't see the ALU and control unit as separate objects in our Python simulator — they're folded into the if/elif chain in the step() method — but conceptually they're always there in real hardware.

Fetch: Pulling the Next Instruction

The fetch stage does exactly one thing: it reads whatever is stored at the memory address the program counter is currently pointing to, and loads it into a temporary holding spot called the instruction register. In real hardware this involves putting the PC's value on the address bus, waiting for the memory (or, far more often, the instruction cache) to respond on the data bus, and latching that response. In our simulator, it's just instr = self.mem[self.pc] — same idea, minus the electrical engineering.

Critically, fetch does not yet know or care what the instruction means. It's purely "grab the next chunk of bits." On a real CPU those bits are a fixed-width binary encoding — on a classic 32-bit RISC design, for example, every instruction is exactly 32 bits, with certain bit ranges reserved for the opcode and others for register numbers or immediate values. Our simulator cheats a little for readability by storing each instruction as a Python tuple like ("ADD", 1, 0) instead of raw bits, but the role it plays in the cycle is identical.

Decode: Figuring Out What The Instruction Means

Decode takes the raw fetched instruction and works out two things: which operation is being requested (the opcode), and which registers, memory addresses, or literal values it operates on (the operands). On real silicon this is done by dedicated decoder circuitry that treats specific bit ranges of the instruction as an index into a lookup table of control signals — the decoder for an x86 chip is genuinely one of the most complex parts of the whole processor, partly because x86 instructions are variable-length (anywhere from 1 to 15 bytes) for historical backward-compatibility reasons, which makes figuring out "where does this instruction end and the next one begin" a real engineering problem. ARM and RISC-V, by contrast, use fixed-width instructions specifically to make decoding simpler and faster.

In our toy simulator, decode is the op = instr[0] line followed by the big if/elif chain that checks which opcode string it is — "LOAD", "ADD", "JMPZ", and so on. That chain is doing, in an extremely simplified and readable way, the same job as a hardware decoder: turning an encoded instruction into a concrete decision about what circuitry (or in our case, what Python code path) should run next.

Execute: The ALU and Registers Actually Do The Work

Execute is where the instruction's effect actually happens. For an arithmetic instruction like ADD, this means the ALU reads the two source register values, computes the sum, and writes the result back into the destination register. For a memory instruction like STORE, it means putting a register's value onto the data bus and a memory address onto the address bus, then asserting a "write" signal so memory latches that value. For a branch instruction like our JMPZ (jump if zero), execute means checking a register's value and conditionally overwriting the program counter instead of just incrementing it.

This is also the stage where a real CPU's behavior can get surprisingly subtle. A single ADD might also need to update flag bits — a carry flag if the result overflowed, a zero flag if the result was exactly zero — because later instructions like conditional jumps read those flags to decide what to do. Our simulator skips flags entirely and has JMPZ check the register directly, which is a legitimate simplification (some real architectures, like MIPS and RISC-V, actually do compare registers directly instead of relying on a separate flags register, precisely because it makes the pipeline simpler to reason about).

After execute finishes, the program counter is wherever it needs to be for the next fetch — either incremented by one, or overwritten by a jump — and the whole cycle starts over from fetch. This loop, fetch then decode then execute then fetch again, is formally called the instruction cycle, and it runs once per clock tick on a very simple CPU (real modern CPUs overlap multiple instructions' cycles at once, which we'll get to).

Building a Toy CPU in Python and Actually Running It

Talk is easy to fake. Let's build one and run a real program on it so there's no room for hand-waving. Here's a complete ToyCPU class with four registers, a flat memory array, a program counter, and eight instructions:

class ToyCPU:
    """4 registers (R0-R3), a memory array, a program counter, a tiny ISA."""
    def __init__(self, memory):
        self.reg = [0, 0, 0, 0]
        self.mem = memory[:]
        self.pc = 0
        self.halted = False
        self.trace = []

    def step(self):
        if self.halted:
            return
        # FETCH: read the instruction the program counter points at
        instr = self.mem[self.pc]
        fetched_pc = self.pc
        op = instr[0]

        # DECODE + EXECUTE: branch on opcode, apply the operation
        if op == "LOAD":
            rd, addr = instr[1], instr[2]
            self.reg[rd] = self.mem[addr]
            self.pc += 1
        elif op == "LOADI":
            rd, imm = instr[1], instr[2]
            self.reg[rd] = imm
            self.pc += 1
        elif op == "ADD":
            rd, rs = instr[1], instr[2]
            self.reg[rd] = self.reg[rd] + self.reg[rs]
            self.pc += 1
        elif op == "SUB":
            rd, rs = instr[1], instr[2]
            self.reg[rd] = self.reg[rd] - self.reg[rs]
            self.pc += 1
        elif op == "STORE":
            addr, rs = instr[1], instr[2]
            self.mem[addr] = self.reg[rs]
            self.pc += 1
        elif op == "JMPZ":
            rcond, addr = instr[1], instr[2]
            self.pc = addr if self.reg[rcond] == 0 else self.pc + 1
        elif op == "JMP":
            self.pc = instr[1]
        elif op == "HALT":
            self.halted = True

        self.trace.append((fetched_pc, instr, list(self.reg), self.pc))

    def run(self, max_steps=1000):
        steps = 0
        while not self.halted and steps < max_steps:
            self.step()
            steps += 1
        return steps

Now let's give it an actual program to run: adding up the numbers 1 through 5 using a countdown loop, and storing the result at memory address 20. This is a genuinely representative small program — it has a loop, a conditional branch, arithmetic, and a memory write, which covers the four things almost every real program does at the hardware level:

# Program: sum 1+2+3+4+5, store the result in memory address 20
# R0 = countdown counter (starts at 5), R1 = running total, R2 = constant 1
program = [
    ("LOADI", 0, 5),   # addr 0: R0 = 5
    ("LOADI", 1, 0),   # addr 1: R1 = 0
    ("LOADI", 2, 1),   # addr 2: R2 = 1
    ("JMPZ", 0, 7),    # addr 3: if R0 == 0, jump to addr 7 (done)
    ("ADD", 1, 0),     # addr 4: R1 = R1 + R0
    ("SUB", 0, 2),     # addr 5: R0 = R0 - R2  (R0 -= 1)
    ("JMP", 3),        # addr 6: go back to the loop check
    ("STORE", 20, 1),  # addr 7: mem[20] = R1
    ("HALT",),         # addr 8: stop
]
memory = program + [0] * (32 - len(program))
cpu = ToyCPU(memory)
steps_taken = cpu.run()
print(steps_taken, cpu.reg, cpu.mem[20])

We actually ran this. Here is the real output — not a guess at what it should print, the actual result of executing cpu.run():

26 [0, 15, 1, 0] 15

cycle  1: PC=0 LOADI R0,5   -> regs=[5, 0, 0, 0]  next_pc=1
cycle  2: PC=1 LOADI R1,0   -> regs=[5, 0, 0, 0]  next_pc=2
cycle  3: PC=2 LOADI R2,1   -> regs=[5, 0, 1, 0]  next_pc=3
cycle  4: PC=3 JMPZ R0,7    -> regs=[5, 0, 1, 0]  next_pc=4   (R0 != 0, fall through)
cycle  5: PC=4 ADD R1,R0    -> regs=[5, 5, 1, 0]  next_pc=5
cycle  6: PC=5 SUB R0,R2    -> regs=[4, 5, 1, 0]  next_pc=6
cycle  7: PC=6 JMP 3        -> regs=[4, 5, 1, 0]  next_pc=3
   ... loop repeats, R0 counting down, R1 accumulating ...
cycle 24: PC=3 JMPZ R0,7    -> regs=[0, 15, 1, 0] next_pc=7   (R0 == 0, jump taken)
cycle 25: PC=7 STORE 20,R1  -> regs=[0, 15, 1, 0] next_pc=8
cycle 26: PC=8 HALT         -> regs=[0, 15, 1, 0] next_pc=8   (halted=True)

Watch what happens cycle by cycle: R0 starts at 5 and counts down to 0 while R1 climbs 0 → 5 → 9 → 12 → 14 → 15, which is exactly 5+4+3+2+1 accumulated one loop iteration at a time. The JMPZ check at address 3 keeps sending the program counter back to address 4 as long as R0 isn't zero yet, and the moment it hits zero on cycle 24, the jump is taken to address 7 instead, which stores the final total and halts. It took 26 total fetch-decode-execute cycles to run a 9-instruction program, because the 3-instruction loop body (addresses 3 through 6) executed 5 separate times before the counter ran out. That's the whole story of why loops are "expensive" relative to straight-line code — the same handful of instructions gets fetched, decoded, and executed over and over.

Why Real CPUs Are Wildly More Complicated Than This

Our simulator does one full fetch-decode-execute cycle per step() call, in strict sequence. If that's genuinely how a 4 GHz processor worked, it would mean 4 billion instructions per second, full stop. Real CPUs get far more throughput than their clock speed alone would suggest, mainly through two ideas our toy CPU doesn't implement:

  • Pipelining. Instead of finishing one instruction's fetch, decode, and execute before starting the next instruction's fetch, a pipelined CPU starts fetching instruction 2 the moment instruction 1 moves from fetch to decode — like an assembly line where multiple cars are at different stations simultaneously. A classic 5-stage pipeline (fetch, decode, execute, memory access, write-back) can have up to five instructions in flight at once, which is a big part of how clock speed translates into real throughput. The catch is a branch instruction (our JMPZ) can invalidate instructions the pipeline already speculatively fetched, which is exactly what branch prediction exists to minimize — the CPU guesses which way a branch will go and starts executing down that path before it actually knows, then throws the work away and restarts if it guessed wrong.
  • Superscalar execution. Modern CPUs don't have just one ALU — they have several execution units and can issue more than one instruction per clock cycle if those instructions don't depend on each other's results. Combined with out-of-order execution, where independent instructions can run in a different order than they appear in the program (as long as the visible result is the same as if they'd run in order), this is why a single CPU core today can retire multiple instructions per cycle rather than being hard-capped at one.

None of that changes the underlying model, though — it's still fetch, decode, execute, repeated relentlessly. Pipelining, branch prediction, and out-of-order execution are all just increasingly clever ways of running more of that same basic cycle in parallel and hiding its latency. If you understand the toy CPU above, you understand the actual conceptual core of how a real one works — the rest is (very impressive) engineering built on top of exactly this loop.

Frequently Asked Questions

What is a clock cycle, exactly? A clock cycle is one tick of the CPU's internal timer, generated by a crystal oscillator. On a simple, non-pipelined CPU one full fetch-decode-execute cycle roughly corresponds to one or more clock ticks; a 3 GHz clock means 3 billion ticks per second, not necessarily 3 billion completed instructions, since pipelining and instruction complexity change that ratio.
Does every instruction take the same number of clock cycles? No. A register-to-register addition is typically much cheaper than an instruction that has to go all the way to RAM, and on pipelined superscalar CPUs, throughput also depends on whether nearby instructions depend on each other's results, so identical-looking instructions can effectively take different amounts of time depending on context.
What's the actual difference between a CPU register and RAM? Registers are a handful of storage slots built directly into the processor die with almost zero access latency, while RAM is external (or on-package) memory that's much larger but far slower to reach, which is exactly why CPU caches (L1/L2/L3) exist as a fast middle layer between the two.
Why do modern CPUs have multiple cores instead of one faster core? Clock speeds hit practical power and heat limits in the mid-2000s, so manufacturers shifted to putting multiple independent fetch-decode-execute units (cores) on one chip, letting the CPU run separate instruction streams truly in parallel instead of only relying on a single faster pipeline.
What is pipelining and why does it matter? Pipelining overlaps the fetch, decode, and execute stages of consecutive instructions the way an assembly line overlaps different cars at different stations, so multiple instructions are in progress simultaneously instead of the CPU fully finishing one before starting the next.