CPU Instruction Execution and Runtime Behavior

A processor is a machine that repeats one cycle forever: read the instruction whose address is in the program counter, work out what it means, do it, and move the program counter on.

Learning Objectives

Describe the fetch-decode-execute cycle, the program counter, instruction register, general-purpose registers, stack and frame pointers, ALU and control unit.

Read machine instructions for loads, stores, arithmetic, branches, loops, calls and returns, and recognise addressing modes.

Explain interrupts, exceptions and privilege levels.

Explain pipelining, out-of-order and speculative execution, branch prediction, register renaming, instruction and data caches, the TLB, memory barriers and atomic instructions.

Compare x86-64, AArch64 and RISC-V; read compiler-generated assembly; relate optimization and undefined behavior to machine code.

Describe reset vectors, interrupt vector tables and bare-metal versus OS-hosted execution.

Plain-Language Explanation

A processor is a machine that repeats one cycle forever: read the instruction whose address is in the program counter, work out what it means, do it, and move the program counter on. Instructions are tiny: "load this word from memory into register 3", "add register 3 and register 4", "if the result was zero, jump to address X". Modern processors overlap dozens of these steps, guess which way branches will go, and run instructions out of order — while making it *look* as if they ran one after another.

Precise Technical Explanation

Fetch — read the instruction bytes at the address in the program counter (through the instruction cache).

Decode — determine the operation and operands (x86 decodes variable-length instructions into internal micro-operations).

Execute — perform the ALU operation, address calculation or branch decision.

Memory — load or store data (through the data cache).

Write back — store the result in a register; update the program counter.

Program counter / instruction pointer The register holding the address of the next instruction: rip on x86-64, pc on AArch64 and RISC-V. Sequential execution adds the instruction length; branches, calls, returns, interrupts and exceptions load a new value.

Instruction register concept In textbook designs the fetched instruction is held in an instruction register while it is decoded. Pipelined CPUs have one such latch per pipeline stage rather than a single visible register.

Fast storage inside the core used as operands: x86-64 has 16 (rax … r15), AArch64 31 (x0 … x30) plus a zero register, RISC-V 32 (x0 hard-wired to zero). There are also flags/status registers, vector registers (SSE/AVX, NEON/SVE, RVV) and system registers.

Stack pointer and frame pointer rsp/sp points to the top of the current stack; rbp/x29/s0 optionally to the current frame (Module 5).

Arithmetic logic unit and control unit The ALU performs integer arithmetic, logic and comparisons and sets condition flags (zero, carry, sign, overflow). The control unit decodes instructions and steers data between registers, ALUs, the load/store unit and the branch unit.

Machine instructions and instruction set architecture (ISA) The ISA is the contract between software and hardware: instructions, registers, encodings, addressing modes, memory model, privilege levels. Implementations (micro-architectures) differ wildly while running the same binaries. CISC ISAs (x86) have variable-length, memory-operand instructions; RISC ISAs (Arm, RISC-V) have fixed-length instructions and access memory only with loads and stores.

Instruction sequencing and branches Unconditional jumps (jmp, b, j) set the PC; conditional branches (je, b.eq, beq) set it only if a condition holds; otherwise execution falls through.

Conditional jumps and loops A loop is a backward conditional branch: compare the counter, branch back while the condition holds. Compilers also turn simple conditions into branchless code (cmov, csel) when that is cheaper.

Function call/return instructions call/ret (x86 pushes/pops the return address on the stack); bl/ret (AArch64, link register x30); jal/jalr (RISC-V, link register ra).

Loads and stores Moving data between memory and registers: mov with a memory operand (x86), ldr/str (Arm), lw/sw/ld/sd (RISC-V). Loads are the main source of latency (cache misses, Module 13).

Memory addressing modes How an instruction forms an address: absolute, register-indirect ([x1]), base + displacement (8(%rsp)), base + index × scale + displacement (x86: (%rdi,%rsi,4) for a[i] of ints), PC-relative (.LC0(%rip) for position-independent code), and pre/post-increment (Arm: [x1], #4).

Interrupts and exceptions Interrupts are asynchronous, caused by devices (timer, network card) — the CPU finishes the current instruction, saves state and jumps to a handler.

Exceptions (faults, traps, aborts) are synchronous, caused by the current instruction: page fault, divide by zero, invalid opcode, breakpoint, system call.

Handlers are found through a table: the IDT on x86, the vector table at VBAR_EL1 on AArch64, mtvec on RISC-V, the NVIC vector table on Cortex-M.

Privilege levels and protection rings / exception levels x86: rings 0 (kernel) to 3 (user), plus hypervisor (VMX root) and SMM. Arm: EL0 (applications), EL1 (OS kernel), EL2 (hypervisor), EL3 (secure monitor). RISC-V: U, S, M modes. Privileged instructions and memory are only accessible at higher levels.

Like a factory line, each stage works on a different instruction at the same time, so a 5-stage pipeline can finish one instruction per cycle. Hazards — data dependencies, branches, resource conflicts — cause stalls; forwarding, prediction and out-of-order execution reduce them.

Out-of-order execution and speculation (conceptual) Modern cores fetch far ahead, place hundreds of instructions in a window, execute each as soon as its inputs are ready, and retire results in program order so that exceptions and the architectural state look sequential. Instructions after an unresolved branch execute speculatively; if the guess was wrong, their results are discarded — but side effects on caches can remain, the root of Spectre-class side channels (Module 19).

Predictors guess branch directions and targets from history, so the front end keeps fetching without waiting. A misprediction flushes the pipeline — about 15–20 cycles on current cores. Data-dependent branches on random data mispredict often; the experiment below measures it.

The ISA's few architectural registers are mapped to a much larger physical register file, removing false dependencies (two unrelated uses of rax) so more instructions can run in parallel.

Instruction cache and data cache L1 is split into an instruction cache (I-cache) and a data cache (D-cache), each typically 32–64 KiB; L2 and L3 are unified. Self-modifying code and JIT compilers must synchronise them (automatic on x86, explicit cache maintenance on Arm).

TLB and address translation Every instruction fetch and data access uses a virtual address; the TLB caches recent virtual-to-physical translations so page tables are walked only on a TLB miss (Module 12).

Memory barriers and ordering CPUs and compilers reorder memory operations for speed. x86 has a strong model (only store→load reordering is visible); Arm and RISC-V have weak models. Barriers/fences (mfence, dmb ish, fence) and acquire/release instructions (ldar/stlr) restore order where threads need it; C/C++ atomics generate them for you (Module 14).

Read-modify-write operations that no other core can interrupt: x86 lock add, lock xadd, lock cmpxchg, xchg; Arm LSE ldadd, cas or LL/SC loops ldxr/stxr; RISC-V amoadd, lr/sc.

CPU architecture differences: x86-64, ARM/AArch64, RISC-V RISC-V (RV64GC) CISC, variable 1–15 byte instructions RISC, fixed 32-bit RISC, 32-bit (16-bit compressed) 31 + zero register Memory operands in ALU ops no (load/store) pushed on stack link register x30 Reading compiler-generated assembly

Find the function label, ignore directives (lines starting with .), identify the argument registers from the ABI, follow the data from arguments to the return register, and map loops by finding backward branches. Use -O1/-O2 output — -O0 is cluttered with stack traffic. Compiler Explorer helps.

Source-level versus machine-level debugging Source-level debugging maps instructions back to lines using debug information; machine-level debugging (disassembly view, stepping one instruction, inspecting registers) is needed when the mapping is lost — optimised code, missing symbols, crashes in libraries.

Optimization and observable behavior Compilers may transform the program in any way that preserves its observable behavior: volatile accesses, I/O and the final state of output. Everything else — the number of instructions, the order of non-volatile memory accesses, whether a variable exists — is free to change.

Undefined behavior and optimizer assumptions Optimizers assume UB never occurs and derive facts from that: signed x + 1 > x is always true; a pointer that was dereferenced is non-null; a loop with signed counter cannot wrap. Example below.

Embedded reset vector and startup code At reset a Cortex-M loads the initial stack pointer from address 0x0 (of the vector table) and the reset handler's address from 0x4, then starts executing it. Cortex-A and RISC-V jump to a fixed reset address in boot ROM. The reset handler is the first code you control (Module 16).

Interrupt vector tables An array of handler addresses indexed by exception/interrupt number. On Cortex-M it is a plain C array placed by the linker at the start of flash and relocatable through SCB->VTOR:

Bare-metal execution versus OS-hosted execution Who starts the program kernel execve + dynamic loader physical (or MPU-protected) virtual, per process direct register access system calls and drivers kernel handlers, signals for user space one program (or an RTOS) forever many processes, scheduled

/* illustrative Cortex-M vector table — not runnable on a PC */
extern uint32_t _estack;
void Reset_Handler(void);
void SysTick_Handler(void);
__attribute__((section(".isr_vector")))
const void *vector_table[] = {
    &_estack,          /* 0x00: initial stack pointer */
    Reset_Handler,     /* 0x04: reset */
    /* … NMI, HardFault, … */
    SysTick_Handler,   /* 0x3C: system timer */
};

Why It Matters in Real Systems

Performance problems are often explained at this level — mispredicted branches, dependency chains, missing vectorisation, atomic contention. Crash analysis in optimised code requires reading assembly. Embedded engineers write vector tables and start-up code. Security work (Spectre, CFI) needs the micro-architectural model.

Key Terms

Instruction set architecture: the software-visible contract Register with the address of the next instruction Arithmetic logic unit Rule for computing an operand's address

Overlapped execution of instruction stages Executing instructions as inputs become ready, retiring in order Executing past unresolved branches Hardware that guesses branch outcomes

Mapping architectural to physical registers Instruction enforcing memory-operation ordering Exception level / ring

Table of handler addresses for interrupts and exceptions

Syntax

Representative instructions (each architecture has its own syntax; GNU assembler shown):

x86-64 (AT&T)          AArch64                 RISC-V
movl  (%rdi), %eax     ldr  w0, [x0]           lw   a0, 0(a0)       load
addl  $1, %eax         add  w0, w0, #1         addi a0, a0, 1       add immediate
cmpl  %esi, %edi       cmp  w0, w1             blt  a0, a1, L       compare (+ branch on RISC-V)
jne   .L3              b.ne .L3                bne  a0, a1, .L3     conditional branch
call  f                bl   f                  call f               call
ret                    ret                     ret                  return
lock xaddl %eax,(%rdi) ldaddal w1, w0, [x0]    amoadd.w.aqrl a0,a1,(a2)  atomic add

Complete Runnable Example

The specification's function, compiled by the build machine's GCC at -O0 and -O2:

The same functions for three ISAs, compiled with Clang 21 (bundled with Zig) as a cross-compiler — a real compiler run, not a hand-written listing:

int sum_to_three(void)
{
    int total = 0;
    total += 1;
    total += 2;
    total += 3;
    return total;
}

int load_element(const int *a, long i) { return a[i]; }

int count_matches(const int *a, int n, int key)
{
    int count = 0;
    for (int i = 0; i < n; ++i) {
        if (a[i] == key) {
            ++count;
        }
    }
    return count;
}
gcc -std=c17 -O0 -S -fno-asynchronous-unwind-tables m11_sum.c -o sum0.s
gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables m11_sum.c -o sum2.s
echo "--- sum_to_three, GCC 11 x86-64 -O0 ---"; sed -n '/^sum_to_three:/,/ret/p' sum0.s
echo "--- sum_to_three, -O2 ---"; sed -n '/^sum_to_three:/,/ret/p' sum2.s
echo "--- load_element, -O2: base + index*4 addressing ---"; sed -n '/^load_element:/,/ret/p' sum2.s
echo "--- count_matches, -O2: loop with compare and backward branch ---"; sed -n '/^count_matches:/,/^\s*ret/p' sum2.s
import re
import subprocess
import sys

for target in ["x86_64-linux-gnu", "aarch64-linux-gnu", "riscv64-linux-gnu"]:
    out = f"isa_{target.split('-')[0]}.s"
    subprocess.run([sys.executable, "-m", "ziglang", "cc", "-target", target, "-O2", "-g0",
                    "-fomit-frame-pointer", "-fno-asynchronous-unwind-tables", "-S",
                    "m11_sum.c", "-o", out], check=True, capture_output=True)
    lines = open(out).read().splitlines()
    print(f"=== {target} ===")
    for name in ["sum_to_three", "load_element"]:
        start = lines.index(f"{name}:" if f"{name}:" in lines else next(l for l in lines if l.startswith(name + ":")))
        for line in lines[start:]:
            if line.strip().startswith(".") or line.strip().startswith("#") or line.strip().startswith("//"):
                continue
            print(re.sub(r"\s+(//|#\s).*$", "", line))
            if line.strip().startswith("ret"):
                break

Line-by-Line Explanation

At -O0 GCC stores total in the frame and performs three separate addl instructions on memory/register — a direct translation.

At -O2 constant folding computes 6 at compile time: movl $6, %eax; ret.

load_element uses x86's scaled-index addressing (%rdi,%rsi,4) — one instruction computes a + i*4 and loads.

count_matches shows a loop: compare (cmpl %edx, (%rdi) — a memory operand, CISC style), sete turns the result into 0/1 which is added without a branch, the pointer advances by 4, and a backward conditional jump (jne .L7) closes the loop.

In the cross-compiled listings: AArch64 uses mov w0, #6 and ldr w0, [x0, x1, lsl #2]; RISC-V has no scaled addressing, so it shifts (slli), adds, then loads (lw). Same C, three ISAs.

Expected Output

--- sum_to_three, GCC 11 x86-64 -O0 ---
sum_to_three:
	endbr64
	pushq	%rbp
	movq	%rsp, %rbp
	movl	$0, -4(%rbp)
	addl	$1, -4(%rbp)
	addl	$2, -4(%rbp)
	addl	$3, -4(%rbp)
	movl	-4(%rbp), %eax
	popq	%rbp
	ret
--- sum_to_three, -O2 ---
sum_to_three:
	endbr64
	movl	$6, %eax
	ret
--- load_element, -O2: base + index*4 addressing ---
load_element:
	endbr64
	movl	(%rdi,%rsi,4), %eax
	ret
--- count_matches, -O2: loop with compare and backward branch ---
count_matches:
	endbr64
	testl	%esi, %esi
	jle	.L8
	leal	-1(%rsi), %eax
	leaq	4(%rdi,%rax,4), %rsi
	xorl	%eax, %eax
	.p2align 4,,10
	.p2align 3
.L7:
	xorl	%ecx, %ecx
	cmpl	%edx, (%rdi)
	sete	%cl
	addq	$4, %rdi
	addl	%ecx, %eax
	cmpq	%rsi, %rdi
	jne	.L7
	ret
=== x86_64-linux-gnu ===
sum_to_three:
	movl	$6, %eax
	retq
load_element:
	movl	(%rdi,%rsi,4), %eax
	retq
=== aarch64-linux-gnu ===
sum_to_three:
	mov	w0, #6
	ret
load_element:
	ldr	w0, [x0, x1, lsl #2]
	ret
=== riscv64-linux-gnu ===
sum_to_three:
	li	a0, 6
	ret
load_element:
	slli	a1, a1, 2
	add	a0, a0, a1
	lw	a0, 0(a0)
	ret

Step-by-Step Execution Trace

sum_to_three at -O0, one instruction at a time:

CET landing pad (no effect on data) save caller's frame pointer mov %rsp,%rbp movl $0,-4(%rbp) addl $1, addl $2, addl $3 total = 1, 3, 6 movl -4(%rbp),%eax return value = 6 pop %rbp; ret restore frame; PC = return address

Interactive Visualization

The CPU visualizer above runs a tiny *invented* educational instruction set (not x86 or Arm): single-step, play/pause, reset and speed control, with the program counter, registers, memory, loads/stores, branches, calls and returns highlighted and explained at each step.

Common Mistakes and Failure Modes

Reading -O0 assembly to judge performance.

Assuming instructions execute in the order written, or that memory operations are seen by other cores in program order (weak memory models).

Assuming one listing is "the" assembly — it changes with compiler, version, flags and target.

Writing UB and expecting the "natural" machine behavior:

For int, GCC returns the constant 1: since signed overflow is undefined, x + 1 > x "must" be true. For unsigned, wrap-around is defined, so the comparison is real (cmpl $-1 checks for UINT_MAX).

cat > ub.c <<'EOF'
int always_bigger(int x) { return x + 1 > x; }        /* signed: optimizer assumes no overflow */
int checked(unsigned x)  { return x + 1 > x; }        /* unsigned wraps: a real check */
EOF
gcc -O2 -S -fno-asynchronous-unwind-tables ub.c -o ub.s
echo "--- always_bigger (int) ---"; sed -n '/^always_bigger:/,/ret/p' ub.s
echo "--- checked (unsigned) ---"; sed -n '/^checked:/,/ret/p' ub.s
--- always_bigger (int) ---
always_bigger:
	endbr64
	movl	$1, %eax
	ret
--- checked (unsigned) ---
checked:
	endbr64
	xorl	%eax, %eax
	cmpl	$-1, %edi
	setne	%al
	ret

Corrected / Safe Pattern

Use atomics (not volatile or plain variables) when threads share data; the compiler emits the right instructions and fences for the target:

On x86-64 an atomic increment is one lock add; a sequentially consistent store becomes xchg (an implicit full barrier) or mov + mfence; a release store is a plain mov because x86's TSO model already provides release ordering. On AArch64 the same source produces ldaddal/stlr or LL/SC loops.

cat > atom.c <<'EOF'
#include <stdatomic.h>
atomic_int hits;
void count(void)          { atomic_fetch_add(&hits, 1); }                               /* seq_cst RMW */
void publish(int v)       { atomic_store(&hits, v); }                                   /* seq_cst store */
void publish_release(int v) { atomic_store_explicit(&hits, v, memory_order_release); }  /* release store */
EOF
gcc -std=c17 -O2 -S -fno-asynchronous-unwind-tables atom.c -o atom.s
for f in count publish publish_release; do echo "--- $f ---"; sed -n "/^$f:/,/ret/p" atom.s; done
--- count ---
count:
	endbr64
	lock addl	$1, hits(%rip)
	ret
--- publish ---
publish:
	endbr64
	xchgl	hits(%rip), %edi
	ret
--- publish_release ---
publish_release:
	endbr64
	movl	%edi, hits(%rip)
	ret

Debugging and Diagnostic Techniques

objdump -d -S / -M intel for disassembly; gdb disassemble, stepi, info registers, x/8i $pc.

perf stat -e branches,branch-misses,instructions,cycles reports IPC and mispredictions (on bare Linux; not available inside every VM).

perf annotate maps samples to instructions; Intel VTune and AMD uProf give micro-architectural analysis.

A branch-prediction experiment — the same work on random and sorted data:

Output (one real run on the build machine — timings vary):

With random data the branch goes either way unpredictably, so roughly half the iterations mispredict; sorted data makes the branch perfectly predictable. The ratio you see depends on the CPU, the compiler's choices and system load — measure on your own hardware rather than quoting a number.

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

#define N (1 << 20)

static long long sum_large(const int *v)
{
    long long sum = 0;
    for (int rep = 0; rep < 20; ++rep) {
        for (int i = 0; i < N; ++i) {
            if (v[i] >= 128) {                       /* data-dependent branch */
                sum += v[i];
            }
            __asm__ volatile("" ::: "memory");       /* keep the branch: block if-conversion */
        }
    }
    return sum;
}

static int cmp(const void *a, const void *b) { return *(const int *)a - *(const int *)b; }

static double seconds(void)
{
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return (double)t.tv_sec + (double)t.tv_nsec / 1e9;
}

int main(void)
{
    int *v = malloc(N * sizeof *v);
    if (v == NULL) return 1;
    srand(42);
    for (int i = 0; i < N; ++i) v[i] = rand() % 256;

    double t0 = seconds();
    long long a = sum_large(v);
    double t1 = seconds();
    qsort(v, N, sizeof *v, cmp);
    double t2 = seconds();
    long long b = sum_large(v);
    double t3 = seconds();

    printf("random order: sum=%lld  %.1f ms\n", a, (t1 - t0) * 1e3);
    printf("sorted order: sum=%lld  %.1f ms\n", b, (
…
random order: sum=2006981220  39.4 ms
sorted order: sum=2006981220  15.2 ms
same result, sorted/random time ratio = 0.38

Performance Implications

Instruction count is a poor predictor of speed: cache misses (Module 13), branch mispredictions and dependency chains usually dominate. Help the hardware with predictable branches, independent operations, contiguous data and vectorisable loops; check the generated assembly for hot code; and measure.

Security and Safety Implications

Privilege levels and the MMU isolate the kernel and processes. Speculative execution side channels (Spectre, Meltdown) break isolation through cache timing; mitigations (retpolines, IBRS, KPTI, lfence, speculative load hardening) cost performance. Control-flow integrity (Intel CET endbr64/shadow stacks, Arm BTI and PAC) blocks code-reuse attacks.

Platform and Language Caveats

All listings are from the stated compilers and flags; other versions produce different code.

Managed runtimes (JVM, CLR, V8) generate machine code at run time; inspect it with -XX:+PrintAssembly, DOTNET_JitDisasm, or node --print-opt-code.

Memory ordering behavior seen on x86 does not carry over to Arm; test concurrent code on weakly ordered hardware.

Real-World Application

Compiler engineers, game and HPC developers tune hot loops at the instruction level; kernel developers write entry code, context switches and barrier-correct lock-free structures; embedded developers write vector tables and boot code; security researchers analyse speculative execution.

Best Practices Checklist

[ ] Inspect optimised assembly for hot paths (Compiler Explorer or -S).

[ ] Never rely on UB "doing what the hardware does".

[ ] Use atomics with explicit memory orders for shared data.

[ ] Measure with hardware counters before micro-optimising.

[ ] Keep the reset handler and vector table minimal and documented.

[ ] Test on every target ISA you ship.

Exercises

E1 · Beginner. In the -O0 listing of sum_to_three, which instruction sets up the frame pointer, and which one leaves the result in the return register?

Answer: movq %rsp, %rbp establishes the frame; movl -4(%rbp), %eax loads total into %eax, the return register.

E2 · Intermediate. Why does RISC-V need three instructions for a[i] where x86-64 needs one?

Answer: RISC-V load/store instructions only support base + 12-bit immediate addressing, so the index must be scaled (slli) and added (add) explicitly before lw. x86's scaled-index addressing does both inside the load.

E3 · Advanced challenge. The -O2 listing of count_matches contains no data-dependent branch. Explain how GCC achieved that, and write the source form that states the same idea explicitly.

Answer: GCC turned the if into cmpl + sete %cl + addl %ecx, %eax: the comparison produces 0 or 1, which is added unconditionally; the only branch left is the loop's own jne. The explicit source form is count += (a[i] == key);. At -O3 GCC can additionally vectorise the loop.

Quiz

? What does the program counter hold?
+ The address of the next instruction to execute :: rip / pc.
- The number of instructions executed so far :: That is a performance counter.
- The top of the stack :: That is the stack pointer.
- The current instruction's opcode :: The instruction register (conceptually) holds that.

? Why does the sorted array make the loop faster?
- Sorted data takes less memory. :: Both arrays are the same size.
+ The branch becomes predictable, so the pipeline is not flushed by mispredictions. :: First all not-taken, then all taken.
- The compiler removes the if statement for sorted data. :: The compiler cannot know the data is sorted.
- qsort warms up the cache. :: Both runs traverse the same array sequentially.

? Which are synchronous exceptions rather than asynchronous interrupts? (select all that apply)
+ A page fault :: Caused by the current instruction's memory access.
+ A divide-by-zero fault :: Caused by the current instruction.
- A timer interrupt :: Asynchronous, from a device.
+ A system-call trap (syscall / svc / ecall) :: Deliberately caused by an instruction.

? GCC compiles int f(int x) { return x + 1 > x; } to "return 1". Why is that correc
…

Summary

CPUs fetch, decode and execute instructions from the address in the program counter, using registers, an ALU and load/store units. ISAs differ (x86-64 CISC with rich addressing; AArch64 and RISC-V load/store RISC), but compilers map the same C to each. Modern cores pipeline, predict, speculate and reorder while preserving the illusion of sequential execution. Interrupts and exceptions enter handlers through vector tables at higher privilege. Optimizers preserve only observable behavior and assume no UB.

References and Further Reading

Patterson & Hennessy, *Computer Organization and Design* (RISC-V edition).

Intel® 64 and IA-32 Architectures Software Developer's Manuals; Arm Architecture Reference Manual for A-profile; The RISC-V Instruction Set Manual.

Agner Fog, "The microarchitecture of Intel, AMD and VIA CPUs".

Matt Godbolt, Compiler Explorer (godbolt.org).