Understanding WebAssembly Deeply

To truly understand WebAssembly, you need to grapple with its stack-based virtual machine, linear memory model, binary format, and host environment interaction. This guide breaks down WebAssembly into its core concepts and provides projects that force you to confront each of them.

Goal: Progress from “I know WebAssembly exists” to “I could implement a WASM runtime from scratch.” By mastering these projects, you’ll understand WebAssembly at the level of the spec authors—able to design compilers, implement runtimes, debug at the bytecode level, and architect high-performance portable systems. You’ll gain the rare expertise needed to contribute to toolchains like wasmtime, write language backends targeting WASM, or build the next generation of edge computing platforms.


Why WebAssembly Matters in 2025

Since its MVP release in 2017, WebAssembly has evolved from a browser compilation target to a universal runtime. Here’s why understanding WASM in 2025 is critical:

Industry Adoption & Growth Statistics

Browser Penetration:

  • WebAssembly usage on websites reached 4.5% of all Chrome users in 2025 (up 20% from 2024)
  • Commercial adoption includes Zoom, Google Meet, Figma, AutoCAD Web, Snapchat, Pinterest, and Adobe Creative Cloud
  • American Express deployed the largest enterprise-scale WASM implementation for their internal Function-as-a-Service (FaaS) platform

Edge Computing Explosion:

  • Edge platforms like Cloudflare Workers and Fastly Compute@Edge report billions of daily WASM invocations
  • IDC forecasts 32-38% CAGR for WebAssembly-based edge solutions from 2025-2030
  • Cloudflare processes over 1 trillion requests/month through WASM workers
  • Sub-millisecond cold starts make WASM the preferred runtime for serverless at the edge

Performance & Capability Milestones:

  • Relaxed SIMD instructions achieve within 5% of native CPU performance for vector operations
  • Figma’s multiplayer canvas runs C++ compiled to WASM at 60 FPS with zero frame drops
  • Google Earth runs a 100MB+ C++ codebase entirely in the browser via WASM

The WASM 3.0 Revolution (September 2025)

WebAssembly 3.0 was officially released in September 2025, representing 6-8 years of development work and fundamentally expanding WASM’s capabilities:

Major Features:

  • Garbage Collection (GC): Managed languages (Java, Kotlin, Scala, Dart, C#, Python) no longer need to ship their own GC—the runtime provides it, dramatically reducing binary sizes
  • Tail Calls: Enables proper functional programming patterns and eliminates stack overflow for recursive algorithms
  • 64-bit Memory Addressing (i64): Address space expanded from 4GB to theoretically 16 exabytes, enabling massive data processing workloads
  • Multiple Memories: Modules can now use multiple isolated memory regions for better security and performance
  • Exception Handling: Native exception support for languages like C++ and Java
  • Typed References: Foundation for safe garbage collection and better type safety

These features unlock WebAssembly for managed languages (previously impossible without performance penalties) and big data workloads (no longer limited to 4GB).

WASI Preview 3: WebAssembly Beyond the Browser

WASI 0.3 (expected H1 2025) brings:

  • Async I/O and networking: Server-side workloads can match native performance
  • Capability-based security: Fine-grained permissions for file access, network sockets, and system resources
  • POSIX-like API: Portable system interface for running WASM outside browsers

Why This Matters:

  • WASM is no longer just a “browser technology”—it’s a universal compilation target for cloud, edge, embedded, and desktop
  • Blockchain platforms (Ethereum 2.0, Polkadot, Near Protocol) use WASM for smart contract execution
  • Plugin systems (Figma, Shopify, Unity) use WASM for safe third-party extensions without sandboxing overhead

The Shift from “Compilation Target” to “Universal Runtime”

2017-2020: WASM 1.0               2021-2024: WASM 2.0              2025+: WASM 3.0
Browser-only compilation          Server-side experimentation       Universal Runtime
C/C++/Rust only                   All languages (with hacks)        Managed languages (native GC)
4GB memory limit                  Same limit                        16 exabytes (i64 addressing)
Manual memory management          Same                              Automatic GC support
Single memory                     Same                              Multiple memories
No exceptions                     Same                              Native exception handling

WebAssembly Evolution Timeline

Understanding WASM in 2025 means:

  • Understanding the future of portable, sandboxed computation
  • Building systems that run anywhere (browser, server, edge, embedded) from a single codebase
  • Architecting the next generation of plugin systems, edge computing platforms, and secure execution environments

Sources:


Learning Path Overview

Hand-Write WAT (Weekend)
        ↓
   Binary Parser (1-2 weeks)
        ↓
   WASM Interpreter (1 month+)
        ↓
Compile Language to WASM (1 month+)
        ↓
   WASI Runtime (2-3 weeks)
        ↓
Full-Stack WASM Toolchain (2-3 months)

WebAssembly Learning Path


Core Concept Analysis

WebAssembly breaks down into these fundamental building blocks:

Concept What It Means Example
Stack Machine Operations push/pop values from a virtual stack—no registers i32.const 5; i32.const 3; i32.add → stack: [8]
Linear Memory A flat, contiguous byte array that WASM modules can grow Single ArrayBuffer (initial 1 page = 64KB)
Binary Format Compact .wasm bytecode with specific section structure Magic: 0x00 0x61 0x73 0x6d, version: 0x01
Text Format (WAT) Human-readable S-expression syntax that compiles to WASM (func $add (param i32 i32) (result i32) ...)
Module System Imports, exports, functions, tables, globals, memory (export "add" (func $add))
Type System Only 4 numeric types: i32, i64, f32, f64 (plus reference types) Function sig: [i32, i32] → [i32]
Host Binding How WASM talks to JavaScript, WASI, or other hosts JS: wasmExports.add(5, 3)
Sandboxing Memory isolation, no direct system access Only accesses own linear memory

The Stack Machine Model: Why WASM Works This Way

WebAssembly is a stack machine, not a register machine like x86 or ARM. This design choice enables portability:

Register Machine (x86):          Stack Machine (WASM):
mov eax, 5                        i32.const 5
mov ebx, 3                        i32.const 3
add eax, ebx                      i32.add

Stack: [nothing]                  Stack: [5] → [5, 3] → [8]
Registers: eax=8, ebx=3           Stack: [8]

Stack Machine vs Register Machine Comparison

Why a stack?

  1. Portability: No need to map to specific CPU registers
  2. Compactness: Instructions don’t need to specify operand locations
  3. Simplicity: Easy to validate and execute
  4. Performance: Modern JITs convert stack operations to registers anyway

The catch: You must think in terms of stack discipline—every operation must leave the stack in a valid state.


Linear Memory: The Foundation of WASM’s Security

Unlike native code, WASM can’t access arbitrary memory addresses. It gets a single, isolated linear memory:

┌────────────────────────────────────────────┐
│         Linear Memory (WebAssembly)        │
│  (Isolated, bounds-checked, grows down)    │
├────────────────────────────────────────────┤
│ 0x0000: [data] [data] [data]               │ ← Data section
│ 0x1000: [stack grows up ↑]                 │ ← Stack (if manual)
│ 0x2000:                                    │
│ 0x3000: [heap grows down ↓]                │ ← Heap (if using malloc)
│ ...                                        │
│ 0xFFFF: (end of 1 page = 64KB)            │
└────────────────────────────────────────────┘

Every memory.grow instruction adds 64KB pages
Max size: 4GB (32-bit addressing) or 16 exabytes (64-bit in WASM 3.0)

WebAssembly Linear Memory Layout

Security model:

  • Bounds checking on every load/store
  • No pointer arithmetic to escape the sandbox
  • Can’t access host memory, files, or network without explicit imports

Real example: When you call malloc() in C compiled to WASM, it’s just manipulating bytes in this linear memory. The host (browser/runtime) sees none of your internal pointers.


The Binary Format: Designed for Fast Loading

WASM’s binary format is dense and streamable. Browsers can parse and compile it while downloading:

File Header:
┌─────────────────────────────────────────┐
│ 0x00 0x61 0x73 0x6d  (Magic "\0asm")   │
│ 0x01 0x00 0x00 0x00  (Version 1)       │
└─────────────────────────────────────────┘

Sections (in order):
1. Type     → Function signatures
2. Import   → External dependencies
3. Function → Type indices for defined functions
4. Table    → Indirect call tables
5. Memory   → Linear memory config
6. Global   → Global variables
7. Export   → Public API
8. Start    → Optional startup function
9. Element  → Table initialization
10. Code    → Function bytecode
11. Data    → Memory initialization

WebAssembly Binary Format Structure

LEB128 Encoding: Variable-length integers save space:

Decimal 624 encoded as: 0xF0 0x04
        ^                ^    ^
        |                |    |
   (5*128 + 112)     10110000 00000100
                     (0xB0)   (0x04)

LEB128 Variable-Length Integer Encoding

The high bit (0x80) signals “more bytes coming.” This makes small numbers (most common) use 1 byte, but allows large values when needed.


Host Interaction: The Boundary Between Worlds

WASM modules can’t do I/O by themselves. They must import functions from the host:

(module
  ;; Import JavaScript's console.log
  (import "console" "log" (func $log (param i32)))

  (func $main
    i32.const 42
    call $log  ;; Calls JavaScript!
  )

  (export "main" (func $main))
)

From JavaScript:

const importObject = {
  console: {
    log: (arg) => console.log("WASM says:", arg)
  }
};

WebAssembly.instantiateStreaming(fetch('module.wasm'), importObject)
  .then(obj => obj.instance.exports.main());
// Output: WASM says: 42

WASI standardizes these imports for system calls:

(import "wasi_snapshot_preview1" "fd_write" (func $fd_write ...))

This allows WASM to run outside browsers with portable I/O.


Concept Summary Table

Concept Cluster What You Need to Internalize
Stack machine execution WASM has no registers. Every operation pushes/pops values. The stack is your workspace. Learn to “see” the stack state in your head.
Linear memory as a sandbox Memory is just a byte array. Bounds-checked. Isolated. You can’t escape it. This is WASM’s security superpower.
Binary format structure .wasm files are sectioned, ordered, LEB128-encoded. Understanding this structure is understanding how runtimes load modules.
Text format (WAT) as assembly WAT is to WASM what x86 assembly is to machine code. S-expressions map 1:1 to bytecode. Master WAT, understand WASM.
Module system & imports/exports Modules are self-contained but composable. Imports bring in host capabilities. Exports expose your API.
Type system & validation Only 4 numeric types (i32, i64, f32, f64) plus refs. Stack must balance. Types must match. Validation happens before execution.
Host binding & capability model WASM can only do what the host allows. Every system call is an import. This is intentional—security by design.
Control flow (blocks, loops, branches) No goto. Structured control flow only. block, loop, if, br, br_if. This enables one-pass validation.
Function calls & locals Functions have typed signatures. Locals are “registers” (but really stack slots). Parameters are locals. Recursion works.
Tables & indirect calls Function pointers via tables. Enables dynamic dispatch, closures, virtual methods.
Memory operations (load/store) Read/write bytes at offsets. Different widths (i32.load, i32.load8_u, etc.). Alignment hints. Bounds-checked.
WASI & system interface WASI = POSIX-like API for WASM. File I/O, sockets, clocks. Capability-based security (preopens). Async in Preview 3.
Garbage collection (GC) proposal WASM 3.0 adds structs, arrays, reference types. Enables Java, C#, Python without GC in WASM.
Threads & atomics Shared memory between workers. Atomic operations (i32.atomic.load, etc.). Memory model matches C++.
SIMD (vector instructions) Portable 128-bit SIMD (v128 type). Relaxed SIMD in 2025 for even faster math. Near-native performance.

Deep Dive Reading by Concept

This section maps each core WebAssembly concept to specific book chapters and resources for deeper understanding. Read these before or alongside the projects to build strong mental models.

WebAssembly Fundamentals (What WASM Actually Is)

Concept Book & Chapter
WebAssembly design goals and history WebAssembly: The Definitive Guide by Brian Sletten — Ch. 1: “What Is WebAssembly?” & Ch. 2: “WebAssembly Specification”
Why a stack machine? Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 3: “Machine-Level Representation of Programs” (compare to WASM’s approach)
Virtual machines and bytecode Low-Level Programming by Igor Zhirkov — Ch. 11: “Virtual Memory” & Ch. 12: “Compilers and Interpreters”
Sandboxing and security model The Secret Life of Programs by Jonathan Steinhart — Ch. 10: “Operating Systems” (isolation mechanisms)

Stack Machine Execution (The Core of WASM)

Concept Book & Chapter
Stack-based computation model Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 3.7: “Procedures” (contrast with WASM)
Stack discipline and calling conventions Low-Level Programming by Igor Zhirkov — Ch. 5: “Compilation Pipeline”
Why no registers? Write Great Code, Volume 1 by Randall Hyde — Ch. 6: “CPU Architecture” (understand what WASM abstracts away)
Implementing a stack machine Engineering a Compiler by Cooper & Torczon — Ch. 7.2: “Code Generation for Stack Machines”

Linear Memory (WASM’s Memory Model)

Concept Book & Chapter
Memory as a byte array Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 9: “Virtual Memory” (especially 9.1-9.3)
Bounds checking and safety Effective C by Robert Seacord — Ch. 5: “Pointers” (see what C lacks that WASM enforces)
Memory growth and allocation WebAssembly: The Definitive Guide by Brian Sletten — Ch. 5: “Linear Memory”
How malloc works in WASM The C Programming Language by Kernighan & Ritchie — Ch. 8.7: “Example—A Storage Allocator” (same algorithm, different sandbox)

Binary Format (How .wasm Files Work)

Concept Book & Chapter
Binary file formats and parsing Practical Binary Analysis by Dennis Andriesse — Ch. 2: “The ELF Format” (similar structure to WASM sections)
LEB128 variable-length encoding WebAssembly Specification (W3C) — Section 5.2: “Values”
Module structure and sections WebAssembly: The Definitive Guide by Brian Sletten — Ch. 3: “Understanding the Binary Format”
Bytecode design tradeoffs Low-Level Programming by Igor Zhirkov — Ch. 12: “Compilers and Interpreters”

Text Format (WAT - WebAssembly Text)

Concept Book & Chapter
S-expressions and syntax WebAssembly: The Definitive Guide by Brian Sletten — Ch. 4: “WebAssembly Text Format”
Assembly language concepts Low-Level Programming by Igor Zhirkov — Ch. 2: “Assembly Language”
WAT to WASM translation WebAssembly Specification (W3C) — Section 6: “Text Format”

Type System & Validation (Ensuring Safety)

Concept Book & Chapter
Type systems in low-level languages Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 2.1: “Information Storage”
Stack-based type checking WebAssembly Specification (W3C) — Section 3: “Validation”
Why WASM only has 4 types WebAssembly: The Definitive Guide by Brian Sletten — Ch. 2.3: “Types”
Reference types and GC WebAssembly Specification (W3C) — GC Proposal Documentation

Control Flow (Blocks, Loops, Branches)

Concept Book & Chapter
Structured vs. unstructured control flow Engineering a Compiler by Cooper & Torczon — Ch. 8: “Introduction to Code Optimization”
Why WASM forbids goto WebAssembly: The Definitive Guide by Brian Sletten — Ch. 4.5: “Control Flow”
Implementing branches in a stack machine Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 3.6: “Control”
Forward and backward branches Low-Level Programming by Igor Zhirkov — Ch. 7: “Control Flow”

Function Calls & Execution Model

Concept Book & Chapter
Call frames and activation records Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 3.7: “Procedures”
Parameter passing and return values The C Programming Language by Kernighan & Ritchie — Ch. 4: “Functions and Program Structure”
Recursion and tail calls C Primer Plus by Stephen Prata — Ch. 9: “Functions”
WASM function signature encoding WebAssembly Specification (W3C) — Section 2.3.5: “Function Types”

Host Binding & Imports/Exports (The Module System)

Concept Book & Chapter
Module linking and loading Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 7: “Linking”
Foreign Function Interface (FFI) WebAssembly: The Definitive Guide by Brian Sletten — Ch. 6: “Interacting with JavaScript”
Capability-based security WebAssembly: The Definitive Guide by Brian Sletten — Ch. 9: “Security Model”
Import resolution and namespacing WebAssembly Specification (W3C) — Section 4.5: “Instantiation”

WASI (WebAssembly System Interface)

Concept Book & Chapter
POSIX system calls The Linux Programming Interface by Michael Kerrisk — Ch. 4: “File I/O: The Universal I/O Model”
Capability-based file access WASI Documentation (wasi.dev) — “Security Model”
Sandboxed I/O design Operating Systems: Three Easy Pieces by Arpaci-Dusseau — Ch. 39: “Files and Directories”
WASI Preview 2/3 async model WASI Specification (GitHub) — Preview 2 & 3 proposals

Tables & Indirect Calls (Dynamic Dispatch)

Concept Book & Chapter
Function pointers in C Understanding and Using C Pointers by Richard Reese — Ch. 4: “Pointers and Functions”
Virtual method tables (vtables) Expert C Programming by Peter van der Linden — Ch. 5: “Thinking of Linking”
WASM table structure WebAssembly: The Definitive Guide by Brian Sletten — Ch. 5.4: “Tables”
Implementing closures Engineering a Compiler by Cooper & Torczon — Ch. 6: “The Procedure Abstraction”

Memory Operations (Load/Store Instructions)

Concept Book & Chapter
Byte-level memory access Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 2.1: “Information Storage”
Alignment and performance Write Great Code, Volume 1 by Randall Hyde — Ch. 4: “System Organization”
Endianness in WASM Understanding and Using C Pointers by Richard Reese — Ch. 1: “Introduction”
Bounds checking implementation Effective C by Robert Seacord — Ch. 2: “Objects, Functions, and Types”

Compiling to WebAssembly (Code Generation)

Concept Book & Chapter
Compiler backend design Engineering a Compiler by Cooper & Torczon — Ch. 7: “Code Generation”
Stack allocation strategies Writing a C Compiler by Nora Sandler — Ch. 5-8: “Variables and Stack Management”
Expression evaluation to stack code Engineering a Compiler by Cooper & Torczon — Ch. 7.2: “Code Generation for Stack Machines”
LLVM WASM backend LLVM Code Generation by Quentin Colombet — (WASM target specifics)

Virtual Machine Implementation (Interpreters & JITs)

Concept Book & Chapter
Building an interpreter Low-Level Programming by Igor Zhirkov — Ch. 12: “Compilers and Interpreters”
Bytecode execution loop Writing a C Compiler by Nora Sandler — (interpreter concepts)
JIT compilation basics Engineering a Compiler by Cooper & Torczon — Ch. 12: “Instruction Scheduling”
Reference: wasmtime architecture wasmtime source code (GitHub) — Study the interpreter and Cranelift JIT

Advanced Features (Threads, SIMD, GC)

Concept Book & Chapter
Shared memory and atomics Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron — Ch. 12: “Concurrent Programming”
SIMD/vector programming Modern X86 Assembly Language Programming by Daniel Kusswurm — Ch. 8-10: “AVX Programming”
Garbage collection algorithms WebAssembly GC Proposal (GitHub) — Specification and examples
Memory models and synchronization Rust Atomics and Locks by Mara Bos — Ch. 1-3: “Atomics and Memory Ordering”

Essential Reading Order

For maximum comprehension, read in this order:

  1. Foundation (Week 1):
    • WebAssembly: The Definitive Guide Ch. 1-2 (overview)
    • Computer Systems Ch. 3.7 (understand machine-level execution)
    • WebAssembly Specification Section 1: “Introduction”
  2. Stack Machine & Memory (Week 2):
    • WebAssembly: The Definitive Guide Ch. 4-5 (WAT and linear memory)
    • Engineering a Compiler Ch. 7.2 (stack machine codegen)
    • Computer Systems Ch. 9.1-9.3 (virtual memory fundamentals)
  3. Binary Format & Parsing (Week 3):
    • WebAssembly: The Definitive Guide Ch. 3 (binary format)
    • Practical Binary Analysis Ch. 2 (file format concepts)
    • WebAssembly Specification Section 5: “Binary Format”
  4. Compilation & Interpretation (Week 4+):
    • Engineering a Compiler Ch. 7 (code generation)
    • Writing a C Compiler (stack allocation, codegen)
    • Low-Level Programming Ch. 12 (interpreters)
  5. Advanced Topics (Month 2+):
    • WASI Specification (system interface)
    • WebAssembly Specification (threads, SIMD, GC proposals)
    • wasmtime source code (production implementation)

Prerequisites & Background Knowledge

Before diving into these projects, assess your readiness and set up your environment properly.

Essential Prerequisites (Must Have)

Programming Fundamentals:

  • Comfortable with at least one programming language (C, Rust, Go, Python, or JavaScript)
  • Understanding of functions, variables, and control flow
  • Basic command-line/terminal skills

Computer Science Basics:

  • How computers represent numbers (binary, integers, floats)
  • What a compiler does (source code → machine code)
  • Basic understanding of memory (heap vs stack concept)

Why These Matter:

  • WASM is a low-level compilation target—you need to understand what high-level code becomes
  • Stack machines require thinking about execution order and data flow
  • Binary formats require comfort with hexadecimal and byte-level thinking

Helpful But Not Required (You’ll Learn These During Projects)

⚠️ Advanced Topics You Don’t Need Yet:

  • Assembly language (helpful but not required—WAT is easier than x86)
  • Compiler theory (Project 4 will teach you what you need)
  • Virtual machine internals (Project 3 will build one from scratch)
  • JavaScript (only needed if you want to run WASM in browsers)

Self-Assessment Questions

Can you answer “yes” to these?

  1. Can you explain the difference between compiled and interpreted languages?
  2. Do you know what a function call stack is?
  3. Can you read hexadecimal (e.g., 0x6D = 109 in decimal)?
  4. Have you used a debugger to step through code line-by-line?
  5. Do you understand what “passing by value” vs “passing by reference” means?

If you answered “no” to more than 2:

  • Recommendation: Start with Computer Systems: A Programmer’s Perspective Ch. 1-3 to build foundational knowledge
  • Alternative: Begin with Project 1 anyway—you’ll learn by doing, but expect to spend extra time on research

Development Environment Setup

Required Tools:

Tool Purpose Installation
WABT (WebAssembly Binary Toolkit) Assemble WAT → WASM, disassemble WASM → WAT GitHub: WebAssembly/wabt
Node.js (v18+) or Deno Run WASM modules outside browsers brew install node or nodejs.org
Text Editor Write WAT/code (VS Code recommended) VS Code + WASM extension
C Compiler (gcc/clang) Compile C to WASM (Projects 2-4) Pre-installed on macOS/Linux; Windows: install MinGW
Rust (optional) Compile Rust to WASM (Projects 4-6) curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh

Recommended Tools:

Tool Purpose
wasm-objdump (part of WABT) Inspect WASM binaries like objdump for ELF
wasm-interp (part of WABT) Reference interpreter for testing
wasmtime Production WASM runtime (Projects 5-6)
wasm-pack Build Rust → WASM projects easily

Quick Setup Script:

# macOS (Homebrew)
brew install wabt node

# Linux (Ubuntu/Debian)
sudo apt-get install wabt nodejs

# Verify installation
wat2wasm --version
node --version

# Optional: Install wasmtime
curl https://wasmtime.dev/install.sh -sSf | bash

Time Investment (Realistic Estimates)

Project Minimum Time Comfortable Pace Mastery Level
Project 1: Hand-Write WAT 4-8 hours 1-2 weekends 3-4 weeks (deep exploration)
Project 2: Binary Parser 1-2 weeks (evenings) 2-3 weeks 1-2 months (handle all sections)
Project 3: WASM Interpreter 2-3 weeks 1-2 months 3-4 months (full validation)
Project 4: Language Compiler 2-3 weeks 1-2 months 3-6 months (optimizations)
Project 5: WASI Runtime 1-2 weeks 3-4 weeks 2-3 months (full WASI support)
Project 6: Full Toolchain 1 month 2-3 months 6+ months (production-quality)
Total (all projects) 3-4 months 6-9 months 1-2 years

Reality Check:

  • These are deep learning projects, not tutorials—expect to get stuck
  • Spending 2-3 hours on a single bug is normal
  • If a project takes 2x longer than estimated, you’re still on track
  • Taking breaks between projects helps concepts solidify

Important Reality Check

This is not a “weekend crash course.” These projects will challenge you to think like a systems programmer, a compiler writer, and a VM implementer. Here’s what to expect:

You will:

  • Spend hours debugging single-byte errors in binary formats
  • Read specs that feel like legal documents
  • Rewrite code 3-4 times as you understand concepts better
  • Feel lost, then have sudden “aha!” moments
  • Gain rare expertise that 95% of developers don’t have

You won’t:

  • Finish all projects in a month (unless full-time)
  • Understand everything on the first read
  • Write production-quality code immediately
  • Avoid frustration (embrace it—it means you’re learning)

Bottom Line: If you complete even 2-3 of these projects, you’ll understand WebAssembly better than most people who “use” it daily. That’s the goal.


Quick Start: Your First 48 Hours

Feeling overwhelmed? Start here instead of reading everything:

Day 1: Saturday Morning (3-4 hours)

Goal: Write your first WAT program and see it run.

  1. Install tools (30 min):
    brew install wabt node  # or equivalent for your OS
    wat2wasm --version      # verify installation
    
  2. Read only these sections (45 min):
    • “The Stack Machine Model” (page ~66-89)
    • “Linear Memory” (page ~93-122)
    • Skim Project 1’s “Practical Examples”
  3. Copy-paste and run this WAT program (15 min):
    (module
      (func $add (param $a i32) (param $b i32) (result i32)
        local.get $a
        local.get $b
        i32.add
      )
      (export "add" (func $add))
    )
    

    Save as add.wat, then:

    wat2wasm add.wat -o add.wasm
    node -e "WebAssembly.instantiate(require('fs').readFileSync('./add.wasm')).then(m => console.log('5 + 3 =', m.instance.exports.add(5, 3)))"
    

    Expected output: 5 + 3 = 8

  4. Experiment (1-2 hours):
    • Change i32.add to i32.mul (multiplication)
    • Add a third parameter
    • Try i32.sub, i32.div_s (signed division)
    • Break it intentionally—see what error messages look like

End of Day 1: You’ve written assembly code that runs in a virtual machine. That’s the core of WASM!

Day 2: Sunday Morning (3-4 hours)

Goal: Understand stack operations and memory.

  1. Read “Core Concept Analysis” section (45 min)

  2. Write a Fibonacci function in WAT (1-2 hours):
    • Use Project 1’s “Example 2” as a starting point
    • Get it working (copy-paste is fine)
    • Then modify it to compute fib(10) instead of fib(5)
  3. Write a memory manipulation program (1 hour):
    • Store a number in linear memory
    • Load it back
    • Use i32.store and i32.load (see Project 1’s Example 3)

End of Day 2: You understand:

  • Stack machines (push/pop values)
  • Local variables vs stack operations
  • Memory as a byte array

End of Weekend Assessment

If it clicked:

  • Continue to Project 1 full implementation
  • Start thinking about the “Core Question You’re Answering”

If confused but intrigued:

  • Re-read “Stack Machine vs Register Machine” diagram
  • Watch a video on stack-based VMs (YouTube: search “stack machine tutorial”)
  • Try again next weekend—sometimes concepts need time to sink in

If frustrated:

  • This is normal! Low-level programming is hard.
  • Take a week off, then return to Project 1
  • Consider reading Computer Systems Ch. 3 for foundational knowledge

Next Steps After Quick Start

  1. Week 1-2: Complete Project 1 fully (all examples + extensions)
  2. Week 3-4: Start Project 2 (binary parser)—builds directly on WAT knowledge
  3. Month 2: Project 3 (interpreter)—now you understand what you’re parsing AND executing
  4. Month 3+: Choose your path based on interests (compiler, WASI, or full toolchain)

These projects are designed to build on each other, but you can also approach them based on your background and goals.

Best for: Frontend/backend developers who want to understand WASM for performance-critical web apps

Sequence:

  1. Project 1 (Hand-Write WAT) — Start here. Get comfortable with stack thinking.
  2. Project 2 (Binary Parser) — Understand what browsers actually download and parse.
  3. Project 6 (Full Toolchain) — Skip to here! Build practical tools for C/Rust → WASM.
  4. Project 3 (Interpreter) — Return here if you want to understand how browsers execute WASM.
  5. Project 4 (Language Compiler) — Advanced: Only if you want to create your own language.
  6. Project 5 (WASI Runtime) — Only if you need WASM outside browsers.

Why this order:

  • You’ll get immediate practical value from Projects 1, 2, 6
  • Projects 3-5 are deeper dives for specific interests
  • You can stop after Project 6 and still understand 80% of real-world WASM usage

Path 2: The Systems Programmer → VM Implementer

Best for: C/Rust/Go developers who want to contribute to WASM runtimes (wasmtime, wasmer, etc.)

Sequence:

  1. Project 1 (Hand-Write WAT) — Understand the instruction set.
  2. Project 2 (Binary Parser) — Master the binary format (LEB128, sections, validation).
  3. Project 3 (Interpreter) — Build a full execution engine from scratch.
  4. Project 5 (WASI Runtime) — Add system call support.
  5. Project 4 (Language Compiler) — Understand code generation (optional but valuable).
  6. Project 6 (Full Toolchain) — Integrate everything into a production tool.

Why this order:

  • Linear progression from “What is WASM?” to “How do I execute it?”
  • By Project 3, you’ll have built a toy version of wasmtime’s interpreter
  • Project 5 adds WASI (critical for server-side WASM)
  • You’ll be ready to contribute to real WASM runtimes or build your own

Path 3: The Language Designer → Compiler Backend Writer

Best for: Those building new programming languages or adding WASM as a compilation target

Sequence:

  1. Project 1 (Hand-Write WAT) — Learn your compilation target.
  2. Project 4 (Language Compiler) — Jump here! Implement a simple language → WASM compiler.
  3. Project 2 (Binary Parser) — Deepen understanding of module structure and validation.
  4. Project 3 (Interpreter) — Understand execution semantics for debugging your compiler.
  5. Project 6 (Full Toolchain) — Build complete dev tools (syntax highlighting, debugging, etc.).
  6. Project 5 (WASI Runtime) — Only if your language needs I/O (most do).

Why this order:

  • Project 4 is the core for compiler writers
  • Projects 2-3 help you debug “Why is my generated WASM crashing?”
  • Project 6 gives you production-ready tooling

Path 4: The Security Researcher → WASM Sandbox Explorer

Best for: Security engineers analyzing WASM’s safety guarantees or finding vulnerabilities

Sequence:

  1. Project 1 (Hand-Write WAT) — Understand the attack surface.
  2. Project 2 (Binary Parser) — Learn validation rules (what’s enforced, what’s not).
  3. Project 3 (Interpreter) — Build your own sandbox—understand bounds checking, type safety.
  4. Project 5 (WASI Runtime) — Study capability-based security model.
  5. Project 6 (Full Toolchain) — Understand the compilation pipeline (source → WASM).
  6. Project 4 (Language Compiler) — Optional: Understand how unsafe code becomes “safe” WASM.

Why this order:

  • Validation (Project 2) and execution (Project 3) are critical for security analysis
  • WASI (Project 5) is the main expansion of the attack surface (file I/O, network)
  • You’ll understand both “How is WASM supposed to be safe?” and “Where might it break?”

Path 5: The Completionist → Full WASM Mastery

Best for: Those committed to becoming WASM experts (6-12 months, part-time)

Phase 1: Foundations (Month 1-2)

  • Project 1 (Hand-Write WAT) — 2-3 weeks
  • Project 2 (Binary Parser) — 2-3 weeks

Phase 2: Execution (Month 3-4)

  • Project 3 (Interpreter) — 4-6 weeks

Phase 3: Code Generation (Month 5-6)

  • Project 4 (Language Compiler) — 4-6 weeks

Phase 4: System Interface (Month 7-8)

  • Project 5 (WASI Runtime) — 3-4 weeks

Phase 5: Integration (Month 9-12)

  • Project 6 (Full Toolchain) — 2-3 months

Milestone Checks:

  • After Project 2: Can you explain WASM’s binary format to a colleague?
  • After Project 3: Can you debug WASM bytecode crashes?
  • After Project 4: Can you add a new language feature and compile it to WASM?
  • After Project 5: Can you run WASM programs with file I/O?
  • After Project 6: Can you build a complete WASM development environment?

Which Path Should You Choose?

If you want to… Choose this path
Use WASM in web apps (React, Vue, etc.) Path 1: Web Developer
Contribute to wasmtime/wasmer Path 2: Systems Programmer
Add WASM support to your language Path 3: Language Designer
Audit WASM for security vulnerabilities Path 4: Security Researcher
Become a WASM specification author Path 5: Completionist
Just curious and want to learn Start with Path 1, then follow your interests

Pro Tip: Start with Project 1 regardless of your path. It’s the foundation for everything else.


Project 1: “Hand-Write WAT Programs” — Learn Stack Machine Fundamentals

Attribute Value
Programming Language WAT (WebAssembly Text Format)
Coolness Level Level 4: Hardcore Tech Flex
Business Potential Level 2: The “Resume Gold”
Difficulty Level 1: Beginner
Knowledge Area Low-Level Web / Virtual Machines
Main Book “WebAssembly: The Definitive Guide” by Brian Sletten
Time Estimate Weekend
Prerequisites Basic programming fundamentals

What you’ll build: A collection of programs written directly in WebAssembly Text Format (WAT) that you assemble and run—covering arithmetic, control flow, memory operations, and function calls.

Why it teaches WebAssembly: Writing WAT by hand forces you to think in stack operations. You’ll feel the constraint of “no variables, only stack” and understand why WASM is designed the way it is. You’ll see exactly what high-level compilers produce.

Core Challenges & Concepts

Challenge Maps To Example
Understanding stack discipline Stack machine model i32.const 5 pushes 5; i32.add pops two, pushes sum
Managing local variables vs. stack WASM execution model Locals are registers; stack is workspace
Implementing loops and conditionals Structured control flow block, loop, br_if for branching
Reading/writing linear memory Memory model i32.store and i32.load with byte offsets
Calling imported functions Host binding JS function calls from WAT code

Practical Examples

Example 1: Simple Addition Function

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add
  )
  (export "add" (func $add))
)

Call from JS: wasmModule.exports.add(5, 3)8

Example 2: Fibonacci with Loop

(func $fib (param $n i32) (result i32)
  (local $a i32)
  (local $b i32)
  (local $i i32)

  local.get $n
  i32.const 2
  i32.lt_s
  if (result i32)
    local.get $n
  else
    i32.const 0
    local.set $a
    i32.const 1
    local.set $b
    i32.const 0
    local.set $i

    block $break
      loop $continue
        local.get $i
        local.get $n
        i32.const 2
        i32.sub
        i32.gt_s
        br_if $break

        local.get $a
        local.get $b
        i32.add
        local.set $a
        local.get $b
        local.set $a

        local.get $i
        i32.const 1
        i32.add
        local.set $i
        br $continue
      end
    end

    local.get $b
  end
)

Example 3: Memory Operations (String Reversal)

(memory $mem 1)  ;; 1 page = 64KB

(func $reverse_in_place (param $ptr i32) (param $len i32)
  (local $left i32)
  (local $right i32)
  (local $temp i32)

  local.get $ptr
  local.set $left

  local.get $ptr
  local.get $len
  i32.add
  i32.const 1
  i32.sub
  local.set $right

  block $exit
    loop $swap
      local.get $left
      local.get $right
      i32.ge_u
      br_if $exit

      ;; temp = mem[left]
      local.get $left
      i32.load8_u
      local.set $temp

      ;; mem[left] = mem[right]
      local.get $left
      local.get $right
      i32.load8_u
      i32.store8

      ;; mem[right] = temp
      local.get $right
      local.get $temp
      i32.store8

      ;; left++, right--
      local.get $left
      i32.const 1
      i32.add
      local.set $left

      local.get $right
      i32.const 1
      i32.sub
      local.set $right

      br $swap
    end
  end
)

(export "reverse_in_place" (func $reverse_in_place))
(export "memory" (memory $mem))

Resources for Key Challenges

Challenge Resource Link
WAT Syntax Basics MDN WebAssembly Guide https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format
Stack Operations WebAssembly Spec §4.4 https://webassembly.github.io/spec/core/exec/instructions.html
Local Variables WAT Examples https://webassembly.org/getting-started/developers-guide/
Memory Model MDN Memory Docs https://developer.mozilla.org/en-US/docs/WebAssembly/Understanding_the_text_format#linear_memory
Interactive Playground WebAssembly Studio https://webassembly.org/getting-started/js-api/

Hands-On Exercises

Exercise 1: Implement these functions in WAT (Weekend Day 1)

  • add(a, b) - returns a + b
  • multiply(a, b) - returns a * b
  • is_even(n) - returns 1 if even, 0 if odd
  • max(a, b) - returns larger value

Exercise 2: Implement these with loops (Weekend Day 2)

  • sum_first_n(n) - returns 1+2+…+n using loop
  • factorial(n) - returns n! using recursion
  • count_bits(n) - counts set bits in an integer

Exercise 3: Implement these with memory (Weekend Day 3)

  • store_array(ptr, values) - store array of i32s in memory
  • reverse_array(ptr, len) - reverse array in-place
  • string_length(ptr) - find null-terminated string length

Learning Milestones

  1. First WAT function compiles and runs → understand stack push/pop
    • Checkpoint: (func (i32.const 5) (i32.const 3) i32.add) returns 8
  2. Implement a loop with memory access → understand linear memory addressing
    • Checkpoint: Write i32.store and i32.load correctly
  3. Import a JS function and call it from WAT → understand the host boundary
    • Checkpoint: Call JavaScript’s Math.floor from WAT and get result

Tools & Setup

Quick Start:

# Install WebAssembly Binary Toolkit
npm install -g wabt

# Write your.wat, then compile:
wat2wasm your.wat -o your.wasm

# View the disassembly:
wasm2wat your.wasm

Interactive Online: Use WebAssembly Studio for zero-setup experimentation

Node.js Runner:

const fs = require('fs');
const wasmBuffer = fs.readFileSync('module.wasm');
const wasmModule = new WebAssembly.Module(wasmBuffer);
const wasmInstance = new WebAssembly.Instance(wasmModule);
console.log(wasmInstance.exports.add(5, 3)); // 8

Real World Outcome

You’ll have working programs that run in the browser or Node.js:

  • A WAT program that calculates Fibonacci numbers, callable from JavaScript
  • A memory-based string reverser where you watch bytes move in memory
  • Proof that you can write low-level code and understand exactly what’s happening

Key Insights

The biggest insight: There are no variables in WASM, only stack values and local storage. Every operation is a verb: “get this local, get that local, add them together, store the result in memory.” This constraint is why WASM is so portable and fast.


Project 2: “WASM Binary Parser” — Decode WebAssembly Byte by Byte

Attribute Value
Main Programming Language C
Alternative Languages Rust, Python, TypeScript
Coolness Level Level 4: Hardcore Tech Flex
Business Potential Level 1: The “Resume Gold”
Difficulty Level 2: Intermediate
Knowledge Area WebAssembly, Binary Parsing
Main Book WebAssembly Specification (W3C)
Time Estimate 1-2 weeks
Prerequisites Binary/hex understanding, file I/O, data structures

What you’ll build: A program (in C, Rust, or Python) that reads .wasm binary files and prints their structure—sections, function types, imports, exports, code.

Why it teaches WebAssembly: The binary format IS WebAssembly. By parsing it byte-by-byte, you’ll understand exactly how modules are structured, how LEB128 encoding works, and what information the runtime needs to execute code. You’ll learn that despite all the complexity, it’s just bytes with predictable structure.

Core Challenges & Concepts

Challenge Maps To Key Skill
Parsing LEB128 variable-length integers WASM encoding Read unsigned/signed variable-length numbers
Understanding section IDs and ordering Module structure Each section has fixed ID and must appear in order
Decoding function signatures and types Type system Match function bodies to their type signatures
Parsing instruction opcodes Instruction set Recognize and decode instruction bytecode
Handling binary format compactness Design tradeoffs Understand why WASM chose dense over readable

Understanding WASM Binary Format

WASM Module Structure (in order):

[Magic] [Version] [Type Section] [Import Section] [Function Section]
[Table Section] [Memory Section] [Global Section] [Export Section]
[Start Section] [Element Section] [Code Section] [Data Section]

Each Section Format:

[Section ID: 1 byte] [Size: LEB128] [Contents]

Example: Type Section ID = 1

0x01                    // Section ID = Type
0x07                    // Size = 7 bytes
0x01                    // 1 type definition
0x60                    // Function type indicator
0x02                    // 2 parameters
0x7f                    // i32 (param 1)
0x7f                    // i32 (param 2)
0x01                    // 1 result
0x7f                    // i32 (result)

Practical Code Example: LEB128 Decoder

Python Implementation:

def read_leb128_unsigned(data, offset):
    """Read unsigned LEB128 integer starting at offset.

    Returns: (value, new_offset)
    """
    result = 0
    shift = 0

    while True:
        byte = data[offset]
        offset += 1

        # Take the lower 7 bits
        result |= (byte & 0x7f) << shift

        # If high bit is 0, we're done
        if (byte & 0x80) == 0:
            break

        shift += 7

    return result, offset

def read_leb128_signed(data, offset):
    """Read signed LEB128 integer (like i32, i64)."""
    result = 0
    shift = 0
    byte = 0

    while True:
        byte = data[offset]
        offset += 1

        result |= (byte & 0x7f) << shift
        shift += 7

        if (byte & 0x80) == 0:
            break

    # Sign extend
    if shift < 32 and (byte & 0x40):
        result |= -(1 << shift)

    return result, offset

C Implementation:

#include <stdint.h>

// Read unsigned variable-length integer
uint32_t read_leb128_u32(const uint8_t *data, int *offset) {
    uint32_t result = 0;
    int shift = 0;
    uint8_t byte;

    do {
        byte = data[(*offset)++];
        result |= (byte & 0x7f) << shift;
        shift += 7;
    } while (byte & 0x80);

    return result;
}

// Read signed variable-length integer
int32_t read_leb128_i32(const uint8_t *data, int *offset) {
    int32_t result = 0;
    int shift = 0;
    uint8_t byte;

    do {
        byte = data[(*offset)++];
        result |= (byte & 0x7f) << shift;
        shift += 7;
    } while (byte & 0x80);

    // Sign extend if necessary
    if (shift < 32 && (byte & 0x40)) {
        result |= -(1 << shift);
    }

    return result;
}

Full Parser Example (Python)

import struct

class WasmParser:
    def __init__(self, filename):
        with open(filename, 'rb') as f:
            self.data = f.read()
        self.offset = 0

    def read_leb128_u(self):
        result = 0
        shift = 0
        while True:
            byte = self.data[self.offset]
            self.offset += 1
            result |= (byte & 0x7f) << shift
            if (byte & 0x80) == 0:
                break
            shift += 7
        return result

    def parse(self):
        # Check magic number
        magic = struct.unpack('<I', self.data[0:4])[0]
        if magic != 0x6d736100:  # \0asm
            raise ValueError("Invalid WASM magic")

        # Check version
        version = struct.unpack('<I', self.data[4:8])[0]
        print(f"WASM version: {version}")
        self.offset = 8

        # Parse sections
        while self.offset < len(self.data):
            section_id = self.data[self.offset]
            self.offset += 1

            if section_id == 0:
                self.parse_custom_section()
            elif section_id == 1:
                self.parse_type_section()
            elif section_id == 3:
                self.parse_function_section()
            elif section_id == 7:
                self.parse_export_section()
            elif section_id == 10:
                self.parse_code_section()
            else:
                # Skip unknown sections
                size = self.read_leb128_u()
                self.offset += size

    def parse_export_section(self):
        size = self.read_leb128_u()
        count = self.read_leb128_u()
        print(f"Exports ({count}):")

        for _ in range(count):
            name_len = self.read_leb128_u()
            name = self.data[self.offset:self.offset+name_len].decode('utf-8')
            self.offset += name_len

            kind = self.data[self.offset]
            self.offset += 1
            index = self.read_leb128_u()

            kind_name = ['Function', 'Table', 'Memory', 'Global'][kind]
            print(f"  {name} -> {kind_name} {index}")

Resources for Key Challenges

Challenge Resource Purpose
LEB128 Deep Dive WebAssembly Spec §5.2 Formal definition
Section IDs Reference WebAssembly Spec §5.1.3 Know which section is which
Instruction Opcodes WebAssembly Spec §5.4 Instruction reference table
Type Encoding WebAssembly Spec §5.3.1 How types are represented
Working Parser wabt source Reference implementation

Hands-On Exercises

Exercise 1: Parse Basic Structure (Days 1-2)

  • Read and validate magic number and version
  • Read section IDs and sizes correctly
  • Skip unknown sections without crashing

Exercise 2: Parse Specific Sections (Days 3-5)

  • Export section: read all exported names and indices
  • Function section: read function type indices
  • Type section: parse function signatures

Exercise 3: Disassemble Code (Days 6-7)

  • Map instruction bytes to opcode names
  • Read local variable declarations
  • Pretty-print WAT-like output

Learning Milestones

  1. Parse the magic number and version → understand WASM file identity
    • Checkpoint: Validate 0x00 0x61 0x73 0x6d 0x01 0x00 0x00 0x00
  2. Decode LEB128 numbers correctly → understand WASM encoding
    • Checkpoint: Read 0x87 0x01 correctly as 263
  3. Decode all section types → understand module anatomy
    • Checkpoint: Parse exports and know function indices
  4. Disassemble the code section → read raw WASM bytecode
    • Checkpoint: Print instruction names from bytecode

Real World Outcome

You’ll have a working binary parser that analyzes any .wasm file and produces detailed output like this:

$ ./wasm_parser add.wasm
WebAssembly Module Analysis
===========================
Magic Number: 0x6d736100 ✓
Version: 1

Module Structure:
-----------------
Type Section (1):
  Type 0: [i32, i32] -> [i32]

Function Section (1):
  Function 0: type 0

Export Section (2):
  "add" -> Function 0
  "memory" -> Memory 0

Code Section (1 function body):
  Function 0 (9 bytes):
    Locals: []
    Instructions:
      0x20 0x00    local.get 0
      0x20 0x01    local.get 1
      0x6a         i32.add
      0x0b         end

Memory Section:
  Initial: 1 page (64KB)
  Maximum: None

Total Size: 87 bytes

Your parser will handle real-world WebAssembly modules compiled from C, Rust, or hand-written WAT. You’ll be able to inspect any .wasm file and understand exactly what’s inside—something even many experienced WebAssembly developers can’t do.

Example analysis session:

$ ./wasm_parser fibonacci.wasm --verbose
Reading file: fibonacci.wasm (1247 bytes)

[0x00-0x03] Magic: 0x00 0x61 0x73 0x6d
[0x04-0x07] Version: 0x01 0x00 0x00 0x00

[0x08] Section ID: 1 (Type)
[0x09] Section Size: 0x07 (7 bytes, decoded from LEB128)
[0x0a] Type Count: 1
  [0x0b] Type 0: func
  [0x0c]   Param Count: 1
  [0x0d]     i32
  [0x0e]   Result Count: 1
  [0x0f]     i32

[0x10] Section ID: 3 (Function)
[0x11] Section Size: 0x02
[0x12] Function Count: 1
  [0x13] Function 0 -> Type 0

[0x14] Section ID: 7 (Export)
...

The Core Question You’re Answering

“How does a compact binary format represent complex program structure, and what design tradeoffs enable WebAssembly to be both portable and performant?”

This project forces you to confront the fundamental question: how do you encode a complete program—with types, functions, control flow, and data—into a minimal number of bytes while maintaining fast parsing and validation?

By parsing .wasm files byte-by-byte, you’ll discover:

  • Why variable-length encoding? LEB128 saves space for small numbers (most type indices, local counts) while supporting arbitrarily large values
  • Why section-based structure? Allows streaming compilation—you can start validating types before reading code
  • Why this specific ordering? Type information comes first so function signatures can be validated before execution
  • Why so compact? Every byte matters for network transfer and parsing speed—WASM is designed for the web

The deeper insight: Binary format design is a conversation with physics—network latency, parsing speed, memory footprint, and validation complexity all pull in different directions. WASM’s format is the elegant compromise.

Concepts You Must Understand First

Before you can parse WebAssembly binaries, you need solid understanding of these foundational concepts:

Concept Why It Matters Book Reference
Binary number representation WASM uses little-endian byte order; you need to read multi-byte integers correctly “Computer Systems: A Programmer’s Perspective” Ch. 2.1-2.3 (Bryant & O’Hallaron)
Variable-length encoding (LEB128) Core WASM encoding scheme—most integers are LEB128, not fixed-width “Practical Binary Analysis” Ch. 5 (Andriesse) - encoding schemes
File I/O and byte buffers You’ll read binary files into byte arrays and parse them sequentially “Low-Level Programming” Ch. 10 (Zhirkov) - file operations in C
Data structures for tree representation Module structure is hierarchical—you’ll need structs/classes to represent it “Computer Systems: A Programmer’s Perspective” Ch. 9 (Bryant & O’Hallaron)
Bitwise operations LEB128 decoding requires masking bits (0x7f, 0x80) and shifting “Computer Systems: A Programmer’s Perspective” Ch. 2.1 (Bryant & O’Hallaron)
Type systems basics WASM has a simple type system—understanding function signatures is critical “Types and Programming Languages” Ch. 1-3 (Pierce) - though WASM is simpler
State machines Your parser is a state machine—section ID determines what to parse next “Low-Level Programming” Ch. 3 (Zhirkov) - assembly and state

Detailed Prerequisites:

  1. Understand hexadecimal and binary representation (1 day study)
    • Read “Computer Systems” Ch. 2.1: “Information Storage”
    • Practice: Convert hex dumps to binary and back
    • Key insight: 0x7f in binary is 0111 1111, 0x80 is 1000 0000
  2. Master bitwise operations (2 days study)
    • Read “Computer Systems” Ch. 2.1.7-2.1.9: Bit-level operations
    • Practice: Implement extract_bits(value, start, length) in C
    • Key operations for LEB128: byte & 0x7f, byte & 0x80, result << shift
  3. Understand file I/O in your chosen language (1 day study)
    • C: fopen(), fread(), fclose() - “Low-Level Programming” Ch. 10
    • Python: open(filename, 'rb'), read() - standard library docs
    • Rust: std::fs::read() - Rust Book Ch. 12
  4. Learn LEB128 encoding specifically (2 days study + practice)
    • Read WebAssembly Spec §5.2.2: “Integers”
    • Understand: High bit (0x80) signals “more bytes coming”
    • Practice: Hand-decode 0x87 0x01 → 135, 0xff 0x00 → 127
  5. Study WebAssembly module structure (2 days study)
    • Read WebAssembly Spec §5.1: “Binary Format”
    • Understand: Each section has ID, size (LEB128), and contents
    • Memorize section IDs: 1=Type, 3=Function, 7=Export, 10=Code

Questions to Guide Your Design

Before writing code, think through these design questions. Your answers will shape your implementation:

1. How should you represent the parsed module in memory?

  • Will you use a single large struct? Multiple linked structures?
  • Do you need to preserve the original byte offsets for debugging?
  • Should you validate as you parse, or parse then validate separately?

2. What’s your error handling strategy?

  • What happens if the magic number is wrong?
  • How do you handle truncated files (file ends mid-section)?
  • Should you fail fast or collect multiple errors?

3. How will you handle unknown sections?

  • WASM allows custom sections (ID 0) with arbitrary data
  • Future sections you don’t know about might appear
  • Can you skip them gracefully?

4. Should you parse lazily or eagerly?

  • Do you read the entire file into memory, then parse?
  • Or stream from disk, parsing as you read?
  • What are the memory tradeoffs?

5. How will you test correctness?

  • Will you compare against wasm2wat output?
  • Test with hand-crafted minimal WASM files?
  • Use the official test suite?

Design principle: Start simple, iterate toward completeness. Parse magic and version first. Then add one section type at a time. Don’t try to handle every instruction opcode on day one.

Thinking Exercise: Before You Code

Exercise: Decode this hex dump by hand before writing any code. This builds your mental model.

00 61 73 6d    ; Magic number
01 00 00 00    ; Version 1
01             ; Section ID: 1 (Type)
07             ; Section size: 7 bytes
01             ; 1 type definition
60             ; func type
02             ; 2 parameters
7f 7f          ; i32, i32
01             ; 1 result
7f             ; i32

Questions to answer:

  1. What is the magic number in ASCII? (Hint: look at bytes 1-3)
  2. How many bytes does the Type section occupy (including section ID)?
  3. What function signature does this module define?
  4. If you see 0x03 as the next byte, what section is that?

Answer key:

  1. asm (null byte comes first: \0asm)
  2. 9 bytes (1 for ID, 1 for size, 7 for contents)
  3. [i32, i32] -> [i32] (two i32 params, one i32 result)
  4. Function section (ID 3)

Deeper exercise: Manually create a WASM file that exports a single function named “answer” returning i32 constant 42. Write it as hex bytes, then verify with wasm2wat.

This exercise forces you to understand:

  • Section ordering (Type → Function → Export → Code)
  • Name encoding (length prefix + UTF-8 bytes)
  • LEB128 for small numbers
  • The minimal module structure

The Interview Questions They’ll Ask

After completing this project, you should be able to confidently answer these questions in technical interviews:

Basic Level:

  1. “What is LEB128 and why does WebAssembly use it?”
    • Expected answer: Variable-length encoding that uses 7 bits per byte for data, 1 bit for continuation flag. Saves space for small numbers (most type indices, local counts) while supporting arbitrary sizes.
  2. “What are the main sections in a WASM module and what order do they appear in?”
    • Expected answer: Type, Import, Function, Table, Memory, Global, Export, Start, Element, Code, Data (in that order). Custom sections can appear anywhere.
  3. “How does WebAssembly achieve platform independence at the binary level?”
    • Expected answer: Fixed instruction encoding, explicit type information, little-endian byte order, standardized section format—no platform-specific assumptions.

Intermediate Level:

  1. “Explain how function signatures are encoded in the Type section.”
    • Expected answer: 0x60 byte signals function type, followed by LEB128 param count, param types (0x7f=i32, etc.), result count, result types. Indexed in Function section.
  2. “How would you validate a WASM module’s structure without executing it?”
    • Expected answer: Check magic/version, verify section IDs are in order, validate type indices are in bounds, check control flow is well-structured (blocks balanced).
  3. “What’s the relationship between the Function section and the Code section?”
    • Expected answer: Function section maps function indices to type indices (signatures). Code section provides the function bodies in same order. Imports come before module functions.

Advanced Level:

  1. “Design a streaming WASM parser—how would you validate types before reading the entire module?”
    • Expected answer: Section ordering enables this—Type section comes early, so you can build type table before validating Function/Code sections. Parse section headers first to skip ahead.
  2. “How does WASM’s binary format design support fast compilation?”
    • Expected answer: Dense encoding reduces I/O time, section structure enables parallel parsing, type information up front allows single-pass validation, LEB128 keeps common values small.
  3. “What are the security implications of parsing untrusted WASM binaries?”
    • Expected answer: Must validate sizes to prevent buffer overflows, check indices are in bounds, limit maximum sizes (recursion depth, section sizes), reject malformed LEB128 (infinite loops).

Systems Design Level:

  1. “If you were designing a binary format for a new VM today, what would you do differently than WASM?”
    • Expected answer: Consider tradeoffs—could use fixed-width for faster parsing but larger files, could support 128-bit integers, might add compression layer, might embed debugging info differently. Discuss constraints (web delivery, streaming compilation, backward compatibility).

Hints in Layers: When You Get Stuck

Layer 1: Gentle nudges (try these first)

Stuck on LEB128 decoding?

  • Remember: The high bit (0x80) tells you if more bytes are coming
  • Each byte contributes its lower 7 bits to the result
  • Bits accumulate from right to left (least significant first)

Can’t figure out section structure?

  • Every section starts the same way: [ID: 1 byte] [Size: LEB128] [Contents]
  • The size tells you how many bytes the contents occupy
  • You can skip unknown sections by reading Size bytes forward

Parser crashes on valid WASM files?

  • Are you checking for EOF before reading each byte?
  • Did you remember that section size doesn’t include the ID and size bytes themselves?
  • Are you updating your offset correctly after each read?

Layer 2: More specific guidance (if Layer 1 didn’t help)

LEB128 implementation issues?

// Common mistake: forgetting to check the continuation bit
uint32_t value = 0;
int shift = 0;
uint8_t byte;
do {
    byte = buffer[offset++];
    value |= (byte & 0x7f) << shift;  // Take lower 7 bits
    shift += 7;
} while (byte & 0x80);  // Continue if high bit is set

Section parsing confusion?

  • Type section (ID 1): Count of types, then for each: 0x60, param count, param types, result count, result types
  • Function section (ID 3): Count, then type indices (each is LEB128)
  • Export section (ID 7): Count, then for each: name length (LEB128), name bytes (UTF-8), kind (1 byte), index (LEB128)
  • Code section (ID 10): Count, then for each: body size (LEB128), locals, instructions

Name encoding problems?

# Names are length-prefixed UTF-8
name_length = read_leb128_unsigned(data, offset)
offset += bytes_read
name_bytes = data[offset:offset+name_length]
name = name_bytes.decode('utf-8')
offset += name_length

Layer 3: Concrete examples (for persistent issues)

Example: Parsing the Type section step-by-step

Bytes: 01 07 01 60 02 7f 7f 01 7f
       ^^ Section ID = 1 (Type)
          ^^ Size = 7 bytes
             ^^ Count = 1 type
                ^^ Function type indicator (0x60)
                   ^^ 2 parameters
                      ^^^^^ i32 (0x7f), i32 (0x7f)
                            ^^ 1 result
                               ^^ i32 (0x7f)

Result: One function type: [i32, i32] -> [i32]

Example: LEB128 decoding walkthrough

Decode 0xe5 0x8e 0x26 (example from spec):
  Byte 1: 0xe5 = 1110 0101
    High bit set (0x80) → more bytes coming
    Lower 7 bits: 110 0101 = 0x65
    Result so far: 0x65 (shift 0)

  Byte 2: 0x8e = 1000 1110
    High bit set → more bytes coming
    Lower 7 bits: 000 1110 = 0x0e
    Result: 0x65 | (0x0e << 7) = 0x65 | 0x700 = 0x765

  Byte 3: 0x26 = 0010 0110
    High bit clear → last byte
    Lower 7 bits: 010 0110 = 0x26
    Result: 0x765 | (0x26 << 14) = 0x765 | 0x98000 = 0x98765

Final: 624,485 decimal

Layer 4: Reference implementation snippets (last resort)

Complete Type section parser (C)

void parse_type_section(WasmParser *p) {
    uint32_t section_size = read_leb128_u32(p);
    uint32_t type_count = read_leb128_u32(p);

    printf("Type Section (%u types):\n", type_count);

    for (uint32_t i = 0; i < type_count; i++) {
        uint8_t form = read_byte(p);
        if (form != 0x60) {
            fprintf(stderr, "Expected function type (0x60)\n");
            exit(1);
        }

        uint32_t param_count = read_leb128_u32(p);
        printf("  Type %u: [", i);
        for (uint32_t j = 0; j < param_count; j++) {
            uint8_t type = read_byte(p);
            printf("%s%s", type_name(type), j < param_count-1 ? ", " : "");
        }
        printf("] -> [");

        uint32_t result_count = read_leb128_u32(p);
        for (uint32_t j = 0; j < result_count; j++) {
            uint8_t type = read_byte(p);
            printf("%s%s", type_name(type), j < result_count-1 ? ", " : "");
        }
        printf("]\n");
    }
}

Books That Will Help

These books provide the background knowledge and techniques you’ll need. Specific chapters are mapped to the concepts you’ll encounter:

Topic Book & Chapter What You’ll Learn When to Read It
Binary representation & bitwise ops “Computer Systems: A Programmer’s Perspective” by Bryant & O’Hallaron, Ch. 2.1-2.3 How computers represent numbers, byte ordering, bit manipulation Before starting - foundational
File I/O and binary parsing “Low-Level Programming” by Igor Zhirkov, Ch. 10 Reading binary files, buffering strategies, parsing state machines Week 1 - when setting up file reading
Variable-length encoding schemes “Practical Binary Analysis” by Dennis Andriesse, Ch. 5 How different encodings work, tradeoffs of variable-length formats Week 1 - before implementing LEB128
Data structure design for trees “Computer Systems: A Programmer’s Perspective” Ch. 9 Representing hierarchical data in memory, pointer structures Week 1 - when designing module representation
WebAssembly binary format WebAssembly Specification §5 (W3C) Official format definition—section structure, encoding rules Throughout - primary reference
Type systems fundamentals “Types and Programming Languages” by Benjamin Pierce, Ch. 1-3 Understanding function signatures, type checking basics Week 2 - when parsing Type section
Compiler infrastructure “Engineering a Compiler” by Cooper & Torczon, Ch. 1 How tools fit together, IR design, parsing strategies Optional - for context on where parsing fits
Security of binary parsing “The Art of Software Security Assessment” by Dowd et al., Ch. 5 Buffer overflow prevention, validating untrusted input Week 2 - when adding validation

Recommended reading order:

Before coding (2-3 days):

  1. “Computer Systems” Ch. 2.1-2.3 - Get solid on binary/hex representation
  2. WebAssembly Spec §5.1-5.2 - Understand the format you’re parsing

During implementation:

  1. “Low-Level Programming” Ch. 10 - Reference for file I/O patterns
  2. “Practical Binary Analysis” Ch. 5 - When implementing LEB128
  3. WebAssembly Spec §5.3-5.5 - As you add each section type

After basic parser works:

  1. “Types and Programming Languages” Ch. 1-3 - Deepen understanding of type system
  2. “The Art of Software Security Assessment” Ch. 5 - Add robust validation

For context (optional but valuable):

  1. “Engineering a Compiler” Ch. 1 - See where binary parsing fits in toolchains

Pro tip: Keep the WebAssembly specification open in a browser tab while coding. You’ll reference §5 constantly for byte value meanings (e.g., “What does 0x7f mean again?”).

Common Pitfalls & Debugging

Problem 1: “Parser crashes with ‘index out of range’ on valid WASM files”

  • Why: You’re not checking if there are enough bytes remaining before reading. LEB128 decoding can read multiple bytes, and section sizes can be larger than expected
  • Fix: Add bounds checking before every read operation. Track current offset and total file size
  • Quick test: Try parsing a truncated WASM file (cut off the last 10 bytes) - your parser should fail gracefully with a clear error message ```c // Before: Unsafe uint8_t byte = data[offset++]; // Crash if offset >= data_len

// After: Safe if (offset >= data_len) { fprintf(stderr, “Unexpected EOF at offset %d\n”, offset); return ERROR_TRUNCATED_FILE; } uint8_t byte = data[offset++];


**Problem 2: "LEB128 decoding hangs in infinite loop"**
- **Why:** Malformed WASM file has a LEB128 number where all bytes have the continuation bit (0x80) set, never terminating
- **Fix:** Add a maximum byte limit (5 bytes for u32, 10 bytes for u64) in your LEB128 decoder
- **Quick test:** Create a test file with bytes `0xFF 0xFF 0xFF 0xFF 0xFF 0xFF` and ensure your parser doesn't hang
```python
def read_leb128_unsigned(data, offset, max_bytes=5):
    result = 0
    shift = 0
    bytes_read = 0

    while True:
        if bytes_read >= max_bytes:
            raise ValueError(f"LEB128 too long (>{max_bytes} bytes)")

        byte = data[offset]
        offset += 1
        bytes_read += 1

        result |= (byte & 0x7f) << shift

        if (byte & 0x80) == 0:
            break

        shift += 7

    return result, offset

Problem 3: “Type section parsed correctly but function signatures are wrong”

  • Why: You forgot that WASM supports multiple result types (multi-value returns). Result count can be 0, 1, or more
  • Fix: Don’t assume result count is always 1. Read the count first, then read that many type bytes
  • Debug: Print both param_count and result_count. Check if result_count > 1 for any functions ```python

    Common mistake: assuming single result

    result_type = read_byte() # Wrong if result_count != 1

Correct: read count first

result_count = read_leb128_u() results = [read_byte() for _ in range(result_count)]


**Problem 4: "Section size doesn't match - parser gets misaligned"**
- **Why:** Section size in LEB128 doesn't include the section ID byte or the size bytes themselves - only the content
- **Fix:** After reading section ID and size, save the current offset. After parsing section contents, verify you've read exactly 'size' bytes
- **Quick test:** Parse a known-good WASM file and verify offset alignment after each section
```c
uint8_t section_id = read_byte(p);
uint32_t section_size = read_leb128_u32(p);
uint32_t section_start = p->offset;  // Save start position

// ... parse section contents ...

uint32_t bytes_read = p->offset - section_start;
if (bytes_read != section_size) {
    fprintf(stderr, "Section %d: expected %u bytes, read %u\n",
            section_id, section_size, bytes_read);
}

Problem 5: “Name section parsing fails with UTF-8 decode error”

  • Why: You’re trying to decode bytes as UTF-8 before validating the encoding. Some exported names might contain invalid UTF-8 sequences
  • Fix: Validate UTF-8 before decoding, or use a permissive decoder that replaces invalid sequences
  • Quick test: Create a WASM file with an export name containing bytes 0xFF 0xFE (invalid UTF-8) and see if your parser handles it ```python

    Before: May crash on invalid UTF-8

    name = name_bytes.decode(‘utf-8’)

After: Handle invalid UTF-8 gracefully

try: name = name_bytes.decode(‘utf-8’) except UnicodeDecodeError: # Fallback: replace invalid bytes name = name_bytes.decode(‘utf-8’, errors=’replace’) print(f”Warning: Invalid UTF-8 in name, using: {name}”)


**Problem 6: "Magic number validation passes but version is wrong"**
- **Why:** You're reading version as big-endian instead of little-endian, or vice versa
- **Fix:** WASM uses little-endian byte order for all multi-byte values. Use proper endianness conversion
- **Quick test:** Check that version bytes `0x01 0x00 0x00 0x00` decode to 1, not 16777216
```c
// Wrong: big-endian
uint32_t version = (data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7];

// Correct: little-endian
uint32_t version = data[4] | (data[5] << 8) | (data[6] << 16) | (data[7] << 24);

// Or use struct unpack (safer)
uint32_t version = *(uint32_t*)&data[4];  // Assumes little-endian system

Problem 7: “Custom sections (ID 0) cause parser to fail”

  • Why: Custom sections can appear anywhere and contain arbitrary data. Your parser might expect only known section IDs
  • Fix: Always handle section ID 0 specially - read the size and skip that many bytes without trying to parse contents
  • Debug: Parse Rust-generated WASM files, which often include custom “name” sections for debugging info
    def parse_module(self):
      while self.offset < len(self.data):
          section_id = self.read_byte()
          section_size = self.read_leb128_u()
    
          if section_id == 0:  # Custom section
              # Read name length and name
              name_len = self.read_leb128_u()
              name = self.data[self.offset:self.offset+name_len]
              self.offset += name_len
    
              # Skip remaining custom section data
              remaining = section_size - (name_len + leb128_size(name_len))
              self.offset += remaining
              print(f"Skipped custom section: {name.decode('utf-8', errors='ignore')}")
          elif section_id == 1:
              self.parse_type_section()
          # ... other sections
    

Testing Your Parser:

# Generate test WASM files with known structure
echo "(module (func (export \"test\") (result i32) (i32.const 42)))" > test.wat
wat2wasm test.wat -o test.wasm

# Verify your parser output
./wasm_parser test.wasm

# Compare against reference implementation
wasm2wat test.wasm
wasm-objdump -x test.wasm  # Detailed section dump

# Test edge cases
dd if=test.wasm of=truncated.wasm bs=1 count=50  # Truncated file
./wasm_parser truncated.wasm  # Should fail gracefully

# Hexdump for manual verification
hexdump -C test.wasm | head -20

Debugging Strategy:

  1. Start with minimal WASM files - Use the smallest possible valid module (just magic + version + one empty section)
  2. Add verbose logging - Print every byte read with its interpretation: [0x42] Section ID: 1 (Type)
  3. Compare hex dumps - When stuck, compare your file against a working example byte-by-byte
  4. Use reference tools - Cross-check your output against wasm2wat and wasm-objdump
  5. Build incrementally - Parse one section type at a time, verify it works, then add the next

Key Insights

The binary format is just structure. Once you understand LEB128 and section ordering, parsing WASM is straightforward. The density and compactness that makes WASM fast is exactly what makes parsing it fun—you’re decoding compressed information.

Every byte has a reason. WebAssembly’s format has zero waste—no padding, no alignment requirements, no reserved fields. This minimalism reflects its web origins where every byte matters for download time.

Parsing teaches encoding. By the time you’ve parsed dozens of .wasm files, you’ll understand exactly how to generate them. This project is perfect preparation for building a WASM compiler.



Project 3: “WASM Interpreter” — Execute Stack Machine Bytecode

Attribute Value
Main Programming Language C
Alternative Languages Rust, Go, TypeScript
Coolness Level Level 5: Pure Magic (Super Cool)
Business Potential Level 1: The “Resume Gold”
Difficulty Level 3: Advanced (The Engineer)
Knowledge Area WebAssembly, Virtual Machines
Main Book Computer Systems: A Programmer’s Perspective by Bryant & O’Hallaron
Time Estimate 1 month+
Prerequisites Strong programming skills, completed Project 2 (parser)

What you’ll build: A working interpreter that executes WebAssembly bytecode—implementing the stack machine, memory, and basic instruction set.

Why it teaches WebAssembly: This is the deepest understanding possible. You’ll implement i32.add, local.get, call, br_if, memory load/store—everything. When you’re done, you’ll know WASM like you designed it.

Core Challenges & Concepts

Challenge Maps To Key Skill
Implementing a value stack with type safety Stack machine semantics Push/pop operations, type tracking
Managing call frames and local variables Function execution model Activation records, scoped storage
Implementing structured control flow WASM’s unique control flow Block nesting, label stack, branches
Linear memory with bounds checking Memory safety model Array bounds validation
Import/export resolution Module linking Dynamic function dispatch

Real World Outcome

Your interpreter will run real .wasm files compiled from C, Rust, or hand-written WAT. Here’s what using it looks like:

Example 1: Running a simple arithmetic program

$ ./wasm_interpreter add.wasm --export add --args 5,3
Initializing interpreter...
Loading module: add.wasm
Module loaded successfully (87 bytes)
Validating module... OK
Instantiating module... OK

Executing export 'add' with arguments [5, 3]

Execution trace (instruction-by-instruction):

Executing instruction 0x20 (local.get 0)
  Locals: [param0=5, param1=3]
  Stack before: []
  Stack after:  [i32(5)]
  IP: 0x00 → 0x02

Executing instruction 0x20 (local.get 1)
  Locals: [param0=5, param1=3]
  Stack before: [i32(5)]
  Stack after:  [i32(5), i32(3)]
  IP: 0x02 → 0x04

Executing instruction 0x6a (i32.add)
  Locals: [param0=5, param1=3]
  Stack before: [i32(5), i32(3)]
  Operation: 5 + 3 = 8
  Stack after:  [i32(8)]
  IP: 0x04 → 0x05

Executing instruction 0x0b (end)
  Stack before: [i32(8)]
  Stack after:  [i32(8)]
  Return value: i32(8)
  IP: 0x05 → RETURN

Result: 8 (i32)
Total instructions executed: 4
Execution time: 0.002ms

Example 2: Running Fibonacci with detailed trace

$ ./wasm_interpreter fibonacci.wasm --export fib --args 10 --trace
Loading module: fibonacci.wasm (1247 bytes)
Module structure:
  - 1 type signature
  - 1 function
  - 1 export: "fib"
  - Memory: 1 page (64KB)

Executing fib(10)...

Call stack:
┌─────────────────────────────────────┐
│ Frame 0: fib                        │
│   Locals: [n=10, a=0, b=1, i=0]    │
│   Stack: []                         │
└─────────────────────────────────────┘

Instruction 0x00: local.get $n
  Stack: [10]

Instruction 0x02: i32.const 2
  Stack: [10, 2]

Instruction 0x04: i32.lt_s
  Stack: [0]  (10 < 2 = false)

Instruction 0x05: if (result i32)
  Taking else branch

[... execution continues ...]

Instruction 0x3a: loop $continue
  Stack: [0, 1, 0]

[Loop iteration 1]
  Stack after iteration: [1, 1, 1]

[Loop iteration 2]
  Stack after iteration: [1, 2, 2]

[... 6 more iterations ...]

Final result: 89 (i32)
Total instructions executed: 127
Peak stack depth: 4
Max call depth: 1
Execution time: 0.015ms

Example 3: Running the official test suite

$ ./wasm_interpreter --test-suite testsuite/
Running WebAssembly Core Test Suite
====================================

[1/352] i32.wast::add ............................ PASS (0.001s)
[2/352] i32.wast::sub ............................ PASS (0.001s)
[3/352] i32.wast::mul ............................ PASS (0.001s)
[4/352] i32.wast::div_s .......................... PASS (0.001s)
[5/352] memory.wast::load_i32 .................... PASS (0.002s)
[6/352] memory.wast::store_i32 ................... PASS (0.002s)
[7/352] call.wast::direct_call ................... PASS (0.003s)
[8/352] call.wast::indirect_call ................. PASS (0.004s)
[9/352] br.wast::forward_branch .................. PASS (0.002s)
[10/352] br_if.wast::conditional_branch ........... PASS (0.003s)
...
[352/352] gc.wast::struct_new ..................... SKIP (GC not impl)

Results:
  PASS: 298 tests
  FAIL: 0 tests
  SKIP: 54 tests (advanced features)

Your interpreter is 84.7% conformant! 🎉

You’ll have built a production-quality WASM runtime that can:

  • Execute real-world programs compiled from C/Rust
  • Step through execution instruction-by-instruction
  • Validate modules before execution
  • Report detailed error messages for invalid bytecode
  • Run the same code that browsers run

The Core Question You’re Answering

“How does a virtual machine actually execute code? What does it mean to ‘run’ a stack machine instruction?”

Before you start coding, grapple with this: When you see i32.add in bytecode, what literally happens? There’s no CPU instruction called i32.add. Your interpreter is creating the illusion of a machine that doesn’t physically exist.

This project forces you to answer:

  • What is “execution” when there’s no real CPU?
  • How do you maintain program state (stack, memory, call frames) in software?
  • What makes control flow “structured” vs. unstructured?
  • How does a sandboxed VM achieve near-native performance?

The deeper insight: Virtual machines are state machines operating on well-defined abstractions. Your interpreter is a loop that reads opcodes and transforms state. That’s all execution ever is—even on real hardware. The only difference is whether the state machine is implemented in silicon or software.

Concepts You Must Understand First

Before implementing an interpreter, you need solid understanding of:

Concept Why It Matters Book Reference
Stack-based computation WASM has no registers—everything is stack operations “Computer Systems: A Programmer’s Perspective” Ch. 3.7 - Bryant & O’Hallaron
The fetch-decode-execute cycle Your interpreter loop mirrors a CPU’s operation “Computer Organization and Design” Ch. 4 - Patterson & Hennessy
Activation records (stack frames) Function calls create nested state that must be preserved “Computer Systems: A Programmer’s Perspective” Ch. 3.7 - Bryant & O’Hallaron
Type systems and validation WASM validates before execution—you must check stack types “Types and Programming Languages” Ch. 1-3 - Pierce (simplified for WASM)
Structured vs. unstructured control flow WASM forbids goto—only structured blocks/loops “Engineering a Compiler” Ch. 8 - Cooper & Torczon
Memory-mapped I/O Linear memory is just a byte array with bounds checking “Operating Systems: Three Easy Pieces” Ch. 13-15 - Arpaci-Dusseau
Interpreter vs. compiler You’re building an interpreter—understand the tradeoffs “Low-Level Programming” Ch. 12 - Zhirkov

Detailed Prerequisites:

  1. Understand stack machine execution (3-4 days study)
    • Read “Computer Systems” Ch. 3.7: Stack frame layout, push/pop mechanics
    • Practice: Manually trace a stack machine executing 5 3 + 2 *
    • Key insight: No registers means every value flows through the stack
  2. Master structured control flow (2-3 days study)
    • Read “Engineering a Compiler” Ch. 8: CFG, dominators, structured vs. goto
    • Understand: WASM’s block/loop/if are not just syntax—they enable one-pass validation
    • Practice: Convert a goto-based loop to WASM block/loop/br
  3. Learn activation record management (2-3 days study)
    • Read “Computer Systems” Ch. 3.7: Function call mechanics
    • Understand: Each call needs saved locals, return address, stack snapshot
    • Practice: Draw call stack for nested function calls
  4. Study bytecode interpreters (3-5 days study + experimentation)
    • Read “Low-Level Programming” Ch. 12: Interpreter architecture
    • Study: wasm3 source code (lightweight interpreter)
    • Understand: Fetch opcode → decode → execute → repeat

Questions to Guide Your Design

1. How will you represent the value stack?

  • Just an array of i64 and cast based on type?
  • Or a union type that preserves i32/i64/f32/f64 distinction?
  • How do you prevent type confusion (pushing i32, popping f32)?

2. What’s your call frame structure?

  • How do you store locals? (flat array vs. per-frame)
  • Where do you save the instruction pointer for returns?
  • How do you handle nested calls and recursion?

3. How will you implement br (branch)?

  • Labels are relative to block depth, not absolute addresses
  • A br 2 means “break out of the 3rd enclosing block”
  • How do you maintain a label stack?

4. What’s your instruction dispatch strategy?

  • Giant switch statement? (simple, slower)
  • Jump table? (faster, more complex)
  • Threaded code? (fastest, most complex)

5. How do you validate vs. execute?

  • Do you validate the module upfront, then trust it during execution?
  • Or check types on every stack operation?
  • What’s the performance tradeoff?

Design principle: Start with correctness, optimize later. Use a simple switch-based dispatch and explicit type checking. Make it work, make it right, then make it fast.

Thinking Exercise: Before You Code

Exercise 1: Manually execute this WAT

(func $compute (param $x i32) (result i32)
  (local $temp i32)

  local.get $x
  i32.const 10
  i32.add
  local.set $temp

  local.get $temp
  i32.const 2
  i32.mul
)

Trace execution step-by-step. For each instruction, write:

  • Current instruction pointer
  • Stack contents BEFORE execution
  • Stack contents AFTER execution
  • Local variables state

Example:

IP=0x00, Locals=[x=5, temp=0], Stack=[]
  → Instruction: local.get $x
IP=0x02, Locals=[x=5, temp=0], Stack=[5]
  → Instruction: i32.const 10
IP=0x04, Locals=[x=5, temp=0], Stack=[5, 10]
  → Instruction: i32.add
...

Questions:

  • After i32.add, what’s on the stack?
  • When does $temp get set?
  • What value does the function return?
  • How many stack slots are used at peak?

Exercise 2: Trace a branch

(func $abs (param $x i32) (result i32)
  local.get $x
  i32.const 0
  i32.ge_s
  if (result i32)
    local.get $x
  else
    i32.const 0
    local.get $x
    i32.sub
  end
)

Call with $abs(-5):

  • At the if, what’s on the stack?
  • Which branch executes?
  • What instructions run in the else block?
  • What’s the final result?

Exercise 3: Trace a loop

(func $sum_to_n (param $n i32) (result i32)
  (local $sum i32)
  (local $i i32)

  i32.const 0
  local.set $sum
  i32.const 0
  local.set $i

  block $break
    loop $continue
      local.get $i
      local.get $n
      i32.gt_s
      br_if $break

      local.get $sum
      local.get $i
      i32.add
      local.set $sum

      local.get $i
      i32.const 1
      i32.add
      local.set $i

      br $continue
    end
  end

  local.get $sum
)

Call with $sum_to_n(3). Trace each loop iteration:

  • What does br_if $break do?
  • What does br $continue do?
  • After how many iterations does it exit?

The Interview Questions They’ll Ask

Basic Level:

  1. “What’s the difference between an interpreter and a compiler?”
  2. “How does a stack machine differ from a register machine?”
  3. “What is an activation record?”

Intermediate Level:

  1. “Explain how WASM’s structured control flow enables single-pass validation.”
  2. “How would you implement br_if (conditional branch)?”
  3. “What’s the difference between block and loop in WASM?”
  4. “How do you ensure type safety during execution?”

Advanced Level:

  1. “Design the data structures for your interpreter’s runtime state.”
  2. “How would you implement tail call optimization?”
  3. “What’s the difference between indirect calls (call_indirect) and direct calls?”
  4. “How would you add a JIT compiler to your interpreter?”

Systems Design Level:

  1. “Compare switch-based dispatch vs. threaded code vs. JIT compilation.”
  2. “How would you make your interpreter secure against malicious bytecode?”
  3. “Design a debugging interface for your interpreter (breakpoints, stepping, inspection).”

Hints in Layers

Layer 1: Getting started

Stuck on overall structure? Your interpreter has three phases:

  1. Load and parse the module (use your parser from Project 2)
  2. Validate the module (check types, control flow)
  3. Execute starting from an exported function

Can’t figure out the execution loop?

while (true) {
    uint8_t opcode = read_byte(&ip);
    switch (opcode) {
        case 0x20: /* local.get */ { ... }
        case 0x6a: /* i32.add */ { ... }
        case 0x0b: /* end */ { return; }
        // ...
    }
}

Layer 2: Specific challenges

How to implement the value stack?

typedef struct {
    enum { VAL_I32, VAL_I64, VAL_F32, VAL_F64 } type;
    union {
        int32_t i32;
        int64_t i64;
        float f32;
        double f64;
    } value;
} Value;

Value stack[1024];
int stack_top = 0;

void push_i32(int32_t val) {
    stack[stack_top].type = VAL_I32;
    stack[stack_top].value.i32 = val;
    stack_top++;
}

int32_t pop_i32() {
    stack_top--;
    assert(stack[stack_top].type == VAL_I32);
    return stack[stack_top].value.i32;
}

How to manage call frames?

typedef struct {
    uint32_t function_idx;
    uint32_t return_ip;
    uint32_t locals_start;  // Index into locals array
    uint32_t stack_start;   // Stack depth when called
} CallFrame;

CallFrame call_stack[256];
int call_depth = 0;

Layer 3: Control flow implementation

Implementing block and loop

// Blocks and loops create labels for br instructions
typedef struct {
    enum { LABEL_BLOCK, LABEL_LOOP } type;
    uint32_t end_ip;        // Where to jump for block
    uint32_t start_ip;      // Where to jump for loop
    uint32_t stack_height;  // Stack depth when entered
} Label;

Label label_stack[64];
int label_depth = 0;

// When you see 'br 0', jump to label_stack[label_depth - 1]
// When you see 'br 1', jump to label_stack[label_depth - 2]

Implementing br (unconditional branch)

case 0x0c: { // br
    uint32_t label_idx = read_leb128_u32(&ip);
    Label* target = &label_stack[label_depth - 1 - label_idx];

    // Unwind stack to target height
    stack_top = target->stack_height;

    if (target->type == LABEL_LOOP) {
        ip = target->start_ip;  // Jump to loop start
    } else {
        ip = target->end_ip;    // Jump past block end
    }
    break;
}

Layer 4: Complete example

Full i32.add implementation

case 0x6a: { // i32.add
    int32_t b = pop_i32();
    int32_t a = pop_i32();
    push_i32(a + b);
    break;
}

Full local.get implementation

case 0x20: { // local.get
    uint32_t local_idx = read_leb128_u32(&ip);
    CallFrame* frame = &call_stack[call_depth - 1];
    Value local = locals[frame->locals_start + local_idx];
    push_value(local);
    break;
}

Books That Will Help

Topic Book & Chapter What You’ll Learn
Stack machine execution “Computer Systems: A Programmer’s Perspective” Ch. 3.7 How stack frames work, push/pop mechanics
Interpreter architecture “Low-Level Programming” Ch. 12 Fetch-decode-execute cycle, bytecode dispatch
Activation records “Computer Systems: A Programmer’s Perspective” Ch. 3.7 Call frame structure, local variable storage
Type systems “Types and Programming Languages” Ch. 1-3 Type checking, type safety, validation
Control flow graphs “Engineering a Compiler” Ch. 8 Structured vs. unstructured control flow
Virtual machine design “Virtual Machines” by Smith & Nair VM taxonomy, interpretation vs. compilation
WASM execution semantics WebAssembly Specification §4 Official execution rules
Memory safety “Operating Systems: Three Easy Pieces” Ch. 13-15 Virtual memory, bounds checking

Recommended reading order:

  1. Before coding (Week 1):
    • “Computer Systems” Ch. 3.7 (stack frames)
    • WebAssembly Spec §4.1-4.2 (execution overview)
    • Study wasm3 source code (simple interpreter)
  2. During implementation (Weeks 2-4):
    • “Low-Level Programming” Ch. 12 (as you build dispatch loop)
    • “Engineering a Compiler” Ch. 8 (when implementing control flow)
    • WebAssembly Spec §4.3-4.4 (as you add instructions)
  3. For optimization (After basic version works):
    • “Virtual Machines” Ch. 2-3 (interpreter optimization)
    • Study wasmtime’s Cranelift JIT

Common Pitfalls & Debugging

Building a WASM interpreter is an exercise in precision. Here are the bugs everyone encounters and how to debug them:

Pitfall 1: Stack Underflow

Symptom: Your interpreter crashes when trying to pop from an empty stack.

Error: Stack underflow at instruction 0x6a (i32.add)
  Stack depth: 1 (expected: 2)
  IP: 0x0042

Root cause: Usually a missing local.get or incorrect instruction ordering.

How to debug:

int32_t pop_i32() {
    if (stack_top == 0) {
        fprintf(stderr, "STACK UNDERFLOW at IP 0x%04x\n", ip);
        fprintf(stderr, "Last 5 instructions:\n");
        print_instruction_history();
        abort();
    }
    stack_top--;
    assert(stack[stack_top].type == VAL_I32);
    return stack[stack_top].value.i32;
}

Prevention: Track expected stack depth during validation phase. Every function should have a known stack effect.

Pitfall 2: Type Mismatches

Symptom: You push an i32 but pop an f32, or vice versa.

Error: Type mismatch at instruction 0x99 (f32.add)
  Expected: f32, f32
  Got: i32, f32
  Stack: [i32(5), f32(3.14)]

Root cause: Either your parser incorrectly decoded an instruction, or there’s a validation bug.

How to debug:

int32_t pop_i32() {
    stack_top--;
    if (stack[stack_top].type != VAL_I32) {
        fprintf(stderr, "TYPE MISMATCH: Expected i32, got %s\n",
                type_name(stack[stack_top].type));
        fprintf(stderr, "Stack state:\n");
        dump_stack();
        abort();
    }
    return stack[stack_top].value.i32;
}

Prevention: Run the official WASM validation algorithm before execution. This catches all type errors upfront.

Pitfall 3: Incorrect Branch Targets

Symptom: br or br_if jumps to the wrong location, causing nonsensical execution.

Error: Jumped to invalid instruction at IP 0xDEAD
  Branch instruction: br 1
  Label stack depth: 2
  Target label: block (expected end at 0x00B4, got 0xDEAD)

Root cause: Label stack is incorrectly managed. Remember:

  • br 0 = jump to innermost enclosing block/loop
  • br 1 = jump to 2nd innermost enclosing block/loop
  • For loop: jump to the start of the loop
  • For block: jump past the end

How to debug:

case 0x0c: { // br
    uint32_t label_idx = read_leb128_u32(&ip);

    if (label_idx >= label_depth) {
        fprintf(stderr, "INVALID BRANCH: br %u (label depth: %u)\n",
                label_idx, label_depth);
        fprintf(stderr, "Label stack:\n");
        dump_label_stack();
        abort();
    }

    Label* target = &label_stack[label_depth - 1 - label_idx];

    fprintf(stderr, "BRANCH: br %u → %s at IP 0x%04x\n",
            label_idx,
            target->type == LABEL_LOOP ? "loop" : "block",
            target->type == LABEL_LOOP ? target->start_ip : target->end_ip);

    // ... rest of implementation
}

Prevention: Add assertions that every br target is actually a valid instruction offset.

Pitfall 4: Call Frame Corruption

Symptom: Function returns but locals are wrong, or stack is corrupted.

Error: After call to function 2, local 0 has wrong value
  Expected: 42
  Got: 0x???????? (garbage)

Root cause: Call frames not properly saved/restored, or locals array is shared when it shouldn’t be.

How to debug:

void call_function(uint32_t func_idx, uint32_t num_params) {
    CallFrame* frame = &call_stack[call_depth];
    frame->function_idx = func_idx;
    frame->return_ip = ip;
    frame->locals_start = locals_count;
    frame->stack_start = stack_top - num_params;

    fprintf(stderr, "CALL: func %u\n", func_idx);
    fprintf(stderr, "  Params: %u\n", num_params);
    fprintf(stderr, "  Stack: %d → %d\n", frame->stack_start, stack_top);
    fprintf(stderr, "  Locals: %u → %u\n", frame->locals_start, locals_count);

    call_depth++;

    // Allocate locals for this function
    allocate_locals(func_idx);

    // Copy params from stack to locals
    for (int i = num_params - 1; i >= 0; i--) {
        locals[locals_count - num_params + i] = stack[stack_top - num_params + i];
    }
    stack_top -= num_params;
}

Prevention: Zero-initialize all locals when entering a function. The spec requires this.

Pitfall 5: Memory Bounds Violations

Symptom: i32.load or i32.store accesses out-of-bounds memory.

Error: Memory access out of bounds
  Instruction: i32.load offset=0 align=2
  Address: 0x00012000 (requested 4 bytes)
  Memory size: 0x00010000 (65536 bytes, 1 page)

Root cause: Either the address calculation overflowed, or the module tried to access beyond allocated memory.

How to debug:

int32_t load_i32(uint32_t addr, uint32_t offset) {
    uint64_t effective_addr = (uint64_t)addr + (uint64_t)offset;

    if (effective_addr + 4 > memory_size) {
        fprintf(stderr, "MEMORY BOUNDS ERROR: i32.load\n");
        fprintf(stderr, "  Base address: 0x%08x\n", addr);
        fprintf(stderr, "  Offset: 0x%08x\n", offset);
        fprintf(stderr, "  Effective address: 0x%08llx\n", effective_addr);
        fprintf(stderr, "  Memory size: 0x%08x (%u bytes)\n",
                memory_size, memory_size);
        abort();
    }

    return *(int32_t*)(memory + effective_addr);
}

Prevention: Always check bounds before accessing memory. Consider using a bounds-checking wrapper.

Pitfall 6: LEB128 Decoding Errors

Symptom: Instructions decode to nonsensical values, or IP jumps to garbage.

Error: Decoded impossibly large value
  Instruction: local.get
  Decoded index: 0xFFFFFFFF
  Function has only 3 locals

Root cause: LEB128 decoder doesn’t properly handle multi-byte values or sign extension.

How to debug:

uint32_t read_leb128_u32(const uint8_t** ptr) {
    uint32_t result = 0;
    uint32_t shift = 0;
    uint8_t byte;

    do {
        byte = **ptr;
        (*ptr)++;

        fprintf(stderr, "  LEB128 byte: 0x%02x (shift: %u)\n", byte, shift);

        result |= (byte & 0x7F) << shift;
        shift += 7;

        if (shift > 35) {
            fprintf(stderr, "LEB128 OVERFLOW: Too many bytes\n");
            abort();
        }
    } while (byte & 0x80);

    fprintf(stderr, "  LEB128 result: %u (0x%08x)\n", result, result);
    return result;
}

Prevention: Test your LEB128 decoder against known values before using it in the interpreter.

Pitfall 7: Validation vs. Execution Confusion

Symptom: Module validates successfully but crashes during execution, or vice versa.

Root cause: Validation checks are incomplete, or execution makes assumptions that validation doesn’t enforce.

The fix: Always validate before execution. The WASM spec requires two-phase processing:

  1. Validation phase: Check all types, control flow, memory bounds statically
  2. Execution phase: Trust the validation and execute without redundant checks

Don’t do this:

void execute_module(Module* m) {
    // Validation is commented out because "it's slow"
    // validate_module(m);  // WRONG!
    run(m);
}

Do this:

void execute_module(Module* m) {
    if (!validate_module(m)) {
        fprintf(stderr, "Module validation failed\n");
        return;
    }
    // Now we can trust types and control flow
    run(m);
}

Debugging Strategy: Instruction Tracing

The single most useful debugging tool is detailed execution tracing:

void trace_instruction(uint32_t ip, uint8_t opcode) {
    static uint64_t instruction_count = 0;

    fprintf(stderr, "[%llu] IP=0x%04x Opcode=0x%02x (%s)\n",
            instruction_count++, ip, opcode, opcode_name(opcode));
    fprintf(stderr, "     Stack (depth=%d): ", stack_top);
    for (int i = 0; i < stack_top; i++) {
        fprintf(stderr, "%s(%s) ",
                type_name(stack[i].type),
                value_str(&stack[i]));
    }
    fprintf(stderr, "\n");

    fprintf(stderr, "     Locals: ");
    CallFrame* frame = &call_stack[call_depth - 1];
    for (int i = 0; i < current_func->num_locals; i++) {
        fprintf(stderr, "$%d=%s ", i, value_str(&locals[frame->locals_start + i]));
    }
    fprintf(stderr, "\n");
}

Enable with --trace flag. This shows exactly what the interpreter is doing at each step.

Testing Strategy

1. Start with hand-written WAT files (not compiled code)

  • You control every instruction
  • You know exactly what should happen
  • Example: (module (func (result i32) i32.const 42))

2. Use the official test suite

  • Download: https://github.com/WebAssembly/testsuite
  • Tests every edge case in the spec
  • Your interpreter should pass 90%+ of core tests

3. Add your own crash-case tests

  • Stack underflow: (func (result i32) i32.add)
  • Type mismatch: (func (result i32) f32.const 1.0)
  • Bounds error: (func (result i32) i32.const 0x10000 i32.load)

4. Test with real compiled code

  • Only after your interpreter passes hand-written tests
  • Start with simple C: int add(int a, int b) { return a + b; }
  • Then try Rust, Go, etc.

When Something Goes Wrong

Step 1: Add execution tracing (see above)

Step 2: Find the first divergence

  • Where does the trace stop making sense?
  • What instruction executed right before the error?

Step 3: Check the spec

  • WebAssembly Specification §4.4 has execution rules for every instruction
  • Compare your implementation to the spec’s pseudocode

Step 4: Compare with a reference implementation

  • Run the same .wasm file in wasmtime with --invoke-func
  • Compare your trace to wasmtime’s behavior

Step 5: Isolate the bug

  • Create the smallest possible .wasm file that reproduces the issue
  • Often reduces a 10KB file to a 20-byte minimal test case

Golden Rule: If your interpreter disagrees with the spec or with another implementation, you are wrong. The spec is the source of truth.


Project 4: “Language to WASM Compiler” — Generate WebAssembly from Source

Attribute Value
Main Programming Language C
Alternative Languages Rust, Go, TypeScript
Coolness Level Level 5: Pure Magic (Super Cool)
Business Potential Level 5: The “Industry Disruptor”
Difficulty Level 4: Expert (The Systems Architect)
Knowledge Area Compilers, WebAssembly
Main Book Engineering a Compiler by Cooper & Torczon
Time Estimate 1 month+
Prerequisites Basic compiler knowledge, completed Projects 1 and 2

What you’ll build: A compiler for a simple language (arithmetic expressions, variables, functions, control flow) that outputs .wasm binary files.

Why it teaches WebAssembly: Compilation forces you to understand WASM as a target. You’ll see why certain design decisions were made—why structured control flow instead of goto, why a stack machine, why only 4 numeric types.

Core Challenges & Concepts

Challenge Maps To Key Skill
Generating LEB128-encoded binary output Binary format mastery Encoding variable-length integers
Stack allocation for expression evaluation Stack machine code generation Managing temporary values
Implementing variables as locals vs. memory Storage model decisions Register allocation for stack machines
Compiling control flow to WASM blocks Structured control flow Converting AST to block structure
Type checking matching WASM’s type system WASM type constraints Ensuring type compatibility

Real World Outcome

You’ll build a complete compiler that takes source code in your language and produces valid .wasm binaries that run anywhere WebAssembly runs.

Your language (example syntax):

// myfile.lang
func fibonacci(n: i32) -> i32 {
  if (n < 2) {
    return n;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}

func main() -> i32 {
  return fibonacci(10);
}

Compilation session:

$ ./mycompiler fibonacci.lang -o fibonacci.wasm
Parsing fibonacci.lang...
  - Found 2 functions
  - Found 0 global variables

Type checking...
  ✓ Function 'fibonacci': signature [i32] -> [i32]
  ✓ Function 'main': signature [] -> [i32]
  ✓ All type constraints satisfied

Generating code...
  - Compiling function 'fibonacci' (12 instructions)
  - Compiling function 'main' (3 instructions)

Encoding module...
  - Type section: 2 function signatures (14 bytes)
  - Function section: 2 function indices (3 bytes)
  - Export section: 2 exports (22 bytes)
  - Code section: 2 function bodies (87 bytes)

Output written to fibonacci.wasm (134 bytes)
Compilation successful! ✨

$ wasm2wat fibonacci.wasm
(module
  (type $t0 (func (param i32) (result i32)))
  (type $t1 (func (result i32)))
  (func $fibonacci (export "fibonacci") (type $t0) (param $p0 i32) (result i32)
    local.get $p0
    i32.const 2
    i32.lt_s
    if (result i32)
      local.get $p0
    else
      local.get $p0
      i32.const 1
      i32.sub
      call $fibonacci
      local.get $p0
      i32.const 2
      i32.sub
      call $fibonacci
      i32.add
    end)
  (func $main (export "main") (type $t1) (result i32)
    i32.const 10
    call $fibonacci))

$ wasmtime fibonacci.wasm --invoke main
89

$ node
> const fs = require('fs');
> const wasm = fs.readFileSync('fibonacci.wasm');
> WebAssembly.instantiate(wasm).then(m => {
>   console.log(m.instance.exports.main());  // 89
>   console.log(m.instance.exports.fibonacci(15));  // 610
> });

Advanced example with memory:

// arrays.lang
func sum_array(arr: *i32, len: i32) -> i32 {
  var total: i32 = 0;
  var i: i32 = 0;

  while (i < len) {
    total = total + arr[i];
    i = i + 1;
  }

  return total;
}

Compiles to WASM with linear memory operations:

(func $sum_array (param $arr i32) (param $len i32) (result i32)
  (local $total i32)
  (local $i i32)
  i32.const 0
  local.set $total
  i32.const 0
  local.set $i
  block $break
    loop $continue
      local.get $i
      local.get $len
      i32.ge_s
      br_if $break

      local.get $total
      local.get $arr
      local.get $i
      i32.const 4
      i32.mul
      i32.add
      i32.load
      i32.add
      local.set $total

      local.get $i
      i32.const 1
      i32.add
      local.set $i
      br $continue
    end
  end
  local.get $total
)

Step-by-Step Compilation: Source to AST to WASM

Let’s trace exactly how compilation works with a detailed example.

Input source code:

let x = 5 + 3;
let y = x * 2;

Step 1: Lexing (source → tokens)

[LET, IDENTIFIER("x"), EQUALS, NUMBER(5), PLUS, NUMBER(3), SEMICOLON,
 LET, IDENTIFIER("y"), EQUALS, IDENTIFIER("x"), STAR, NUMBER(2), SEMICOLON]

Step 2: Parsing (tokens → AST)

Program
├── VarDecl
│   ├── name: "x"
│   └── init: BinaryOp(ADD)
│       ├── left: Literal(5)
│       └── right: Literal(3)
└── VarDecl
    ├── name: "y"
    └── init: BinaryOp(MUL)
        ├── left: VarRef("x")
        └── right: Literal(2)

Step 3: Semantic Analysis (AST → typed AST)

Symbol Table:
  x → local_idx=0, type=i32
  y → local_idx=1, type=i32

Type Checking:
  ✓ 5 + 3: i32 + i32 → i32
  ✓ x * 2: i32 * i32 → i32
  ✓ All assignments type-compatible

Step 4: Code Generation (typed AST → WASM instructions)

;; Compilation of: let x = 5 + 3;
(local $x i32)          ;; Declare local 0
i32.const 5             ;; Push 5 onto stack
i32.const 3             ;; Push 3 onto stack
i32.add                 ;; Pop two, add, push result (8)
local.set $x            ;; Pop stack, store in local 0

;; Compilation of: let y = x * 2;
(local $y i32)          ;; Declare local 1
local.get $x            ;; Push value of local 0 (8)
i32.const 2             ;; Push 2 onto stack
i32.mul                 ;; Pop two, multiply, push result (16)
local.set $y            ;; Pop stack, store in local 1

Step 5: Binary Encoding (WASM instructions → .wasm bytes)

Module Structure:
┌─────────────────────────────────────┐
│ Magic Number: 0x00 0x61 0x73 0x6d  │ ← "\0asm"
│ Version: 0x01 0x00 0x00 0x00        │ ← Version 1
├─────────────────────────────────────┤
│ Type Section (0x01)                 │
│   - Function signature: [] → []     │
├─────────────────────────────────────┤
│ Function Section (0x03)             │
│   - Function 0: type index 0        │
├─────────────────────────────────────┤
│ Export Section (0x07)               │
│   - Export "main" → func 0          │
├─────────────────────────────────────┤
│ Code Section (0x0a)                 │
│   - Func 0 body:                    │
│     - Locals: 2 × i32               │ ← Our x and y
│     - Code: 0x41 0x05 0x41 0x03 ... │ ← Instructions
└─────────────────────────────────────┘

Hex dump (first 30 bytes):
00000000: 00 61 73 6d 01 00 00 00  01 04 01 60 00 00 03 02  .asm.......`....
00000010: 01 00 07 08 01 04 6d 61  69 6e 00 00 0a 13 01 11  ......main......

Instruction encoding details:

i32.const 5    → 0x41 0x05        (opcode 0x41, LEB128-encoded 5)
i32.const 3    → 0x41 0x03        (opcode 0x41, LEB128-encoded 3)
i32.add        → 0x6a             (opcode for i32.add)
local.set 0    → 0x21 0x00        (opcode 0x21, local index 0)
local.get 0    → 0x20 0x00        (opcode 0x20, local index 0)
i32.const 2    → 0x41 0x02        (opcode 0x41, LEB128-encoded 2)
i32.mul        → 0x6c             (opcode for i32.mul)
local.set 1    → 0x21 0x01        (opcode 0x21, local index 1)

Complete binary (in hex):

0000000: 00 61 73 6d 01 00 00 00  01 04 01 60 00 00 03 02  .asm.......`....
0000010: 01 00 0a 11 01 0f 02 01  7f 01 7f 41 05 41 03 6a  ...........A.A.j
0000020: 21 00 20 00 41 02 6c 21  01 0b                    !. .A.l!..

Verify it works:

$ hexdump -C output.wasm | head -3
00000000  00 61 73 6d 01 00 00 00  01 04 01 60 00 00 03 02  |.asm.......`....|
00000010  01 00 0a 11 01 0f 02 01  7f 01 7f 41 05 41 03 6a  |...........A.A.j|
00000020  21 00 20 00 41 02 6c 21  01 0b                    |!. .A.l!..|

$ wasm2wat output.wasm
(module
  (type (;0;) (func))
  (func (;0;) (type 0)
    (local i32 i32)
    i32.const 5
    i32.const 3
    i32.add
    local.set 0
    local.get 0
    i32.const 2
    i32.mul
    local.set 1))

$ wasmtime output.wasm
(no output - locals stay internal, but it executed!)

This is the full compilation pipeline your compiler implements.

The Core Question You’re Answering

“How do you transform high-level language constructs into stack machine operations? What does ‘compiling to a target’ actually mean?”

This project confronts you with the core challenge of compilation: bridging abstraction levels. You have:

  • Source level: Variables, expressions, if/while statements, function calls
  • Target level: Stack operations, blocks, branches, local indices

The questions you’ll answer:

  • How do you evaluate (a + b) * c using only a stack?
  • How do variables map to WASM locals?
  • How do you compile if/else into WASM’s structured blocks?
  • How do you make recursive function calls work?
  • Why is WASM’s design actually helpful for compilation?

The deeper insight: Compilation is systematic pattern matching from source constructs to target idioms. Once you understand the patterns, code generation becomes mechanical. The art is in choosing the right target idioms for each source construct.

Concepts You Must Understand First

Concept Why It Matters Book Reference
Abstract Syntax Trees (AST) Your source is parsed into an AST that you traverse during codegen “Engineering a Compiler” Ch. 3-4 - Cooper & Torczon
Stack-based code generation Expressions must be compiled to stack operations “Engineering a Compiler” Ch. 7.2 - Cooper & Torczon
Symbol tables and scoping Track variables, functions, and their types “Compilers: Principles, Techniques, and Tools” Ch. 2.7 - Aho et al.
Type checking and inference Ensure source program is well-typed before codegen “Types and Programming Languages” Ch. 1-9 - Pierce
Control flow graph (CFG) Understand how source control flow maps to WASM blocks “Engineering a Compiler” Ch. 8 - Cooper & Torczon
Code generation patterns Learn idioms for common constructs “Writing a C Compiler” - Nora Sandler
LEB128 encoding Generate compact WASM binary output WebAssembly Spec §5.2

Detailed Prerequisites:

  1. Build a simple interpreter first (1-2 weeks if starting from scratch)
    • Read “Writing a C Compiler” Ch. 1-4
    • Build: Lexer → Parser → AST → Interpreter
    • This gives you the frontend; you’ll swap interpreter for codegen
  2. Understand expression compilation to stack code (2-3 days study)
    • Read “Engineering a Compiler” Ch. 7.2: “Generating Code for Expressions”
    • Practice: Hand-compile (a + b) * (c - d) to stack operations
    • Key pattern: Postorder traversal of expression tree
  3. Master structured control flow (2-3 days study)
    • Read “Engineering a Compiler” Ch. 8.3: “Control Flow Graphs”
    • Understand: How to convert if/while to WASM’s block/loop/br_if
    • Practice: Draw CFG for simple programs, map to WASM blocks
  4. Learn local variable allocation (1-2 days study)
    • Read “Engineering a Compiler” Ch. 13: “Register Allocation”
    • Adapt for WASM: No registers, but locals are similar
    • Understand: How to assign each source variable a local index

Questions to Guide Your Design

1. What’s your compilation strategy?

  • Single-pass (parse and generate code simultaneously)?
  • Multi-pass (parse → AST → codegen)?
  • What information do you need to track between passes?

2. How will you manage the WASM stack during expression evaluation?

  • Does your compiler need to track stack depth?
  • How do you handle subexpressions?
  • What’s your strategy for complex expressions like f(a + b, c * d)?

3. How do variables map to WASM locals?

  • One local per source variable?
  • Do you reuse locals for different variables?
  • How do you handle variable shadowing in nested scopes?

4. How will you compile control flow?

Source:                   Target:
if (cond) {              (compile cond)
  then_body;             (if (result i32)
} else {                   (compile then_body)
  else_body;             (else)
}                          (compile else_body)
                         (end))
  • How do you handle nested ifs?
  • What about if without else?
  • How do break and continue map to br?

5. How will you encode the binary output?

  • Build the entire module in memory, then write?
  • Stream binary data as you generate code?
  • How do you handle forward references (function calls before definitions)?

Design principle: Separate concerns. Build the compiler in phases: lexer, parser, type checker, code generator, binary encoder. Each phase has a clear input and output. This makes debugging tractable.

Thinking Exercise: Before You Code

Exercise 1: Hand-compile an expression

Source: result = (a + b) * (c - d);

Step 1: Draw the AST

        =
       / \
   result  *
          / \
         +   -
        / \ / \
       a  b c  d

Step 2: Generate WASM stack code (postorder traversal)

local.get $a      ;; Stack: [a]
local.get $b      ;; Stack: [a, b]
i32.add           ;; Stack: [a+b]
local.get $c      ;; Stack: [a+b, c]
local.get $d      ;; Stack: [a+b, c, d]
i32.sub           ;; Stack: [a+b, c-d]
i32.mul           ;; Stack: [(a+b)*(c-d)]
local.set $result ;; Stack: []

Exercise 2: Hand-compile an if statement

Source:

if (x > 10) {
  y = x * 2;
} else {
  y = x + 5;
}

Target:

local.get $x
i32.const 10
i32.gt_s
if
  local.get $x
  i32.const 2
  i32.mul
  local.set $y
else
  local.get $x
  i32.const 5
  i32.add
  local.set $y
end

Exercise 3: Hand-compile a while loop

Source:

while (i < n) {
  sum = sum + i;
  i = i + 1;
}

Target:

block $break
  loop $continue
    local.get $i
    local.get $n
    i32.ge_s         ;; Inverted condition!
    br_if $break

    local.get $sum
    local.get $i
    i32.add
    local.set $sum

    local.get $i
    i32.const 1
    i32.add
    local.set $i

    br $continue
  end
end

Key insight: Loop condition is inverted (i < n becomes i >= n with br_if)

The Interview Questions They’ll Ask

Basic Level:

  1. “How do you compile an arithmetic expression to stack machine code?”
  2. “What’s the difference between a single-pass and multi-pass compiler?”
  3. “How do you perform type checking?”

Intermediate Level:

  1. “Explain how postorder traversal of an AST generates stack code.”
  2. “How do you compile an if statement to WASM’s structured blocks?”
  3. “What’s the difference between compiling to a register machine vs. a stack machine?”
  4. “How do you handle function calls in your codegen?”

Advanced Level:

  1. “Design a symbol table that handles nested scopes.”
  2. “How would you implement variable shadowing?”
  3. “Explain how to compile short-circuit boolean evaluation (&&, ||).”
  4. “How do you generate LEB128-encoded output?”

Systems Design Level:

  1. “Design a compiler for a language with closures targeting WASM.”
  2. “How would you add optimization passes (constant folding, dead code elimination)?”
  3. “Compare compiling to WASM vs. compiling to x86 assembly.”

Hints in Layers

Layer 1: Getting started

Stuck on overall architecture? Your compiler has these phases:

  1. Lexer: Source string → tokens
  2. Parser: Tokens → AST
  3. Type checker: AST → typed AST (with errors for type mismatches)
  4. Code generator: Typed AST → WASM instruction sequence
  5. Binary encoder: Instructions → .wasm file

Start with a tiny language (just integers and addition), get all 5 phases working, then expand.

How to structure the code generator?

void compile_expr(ASTNode* node) {
    switch (node->type) {
        case AST_INT:
            emit_i32_const(node->int_value);
            break;
        case AST_ADD:
            compile_expr(node->left);   // Left on stack
            compile_expr(node->right);  // Right on stack
            emit_i32_add();             // Result on stack
            break;
        // ...
    }
}

Layer 2: Expression compilation

Handling variables

// Symbol table maps variable names to local indices
typedef struct {
    char* name;
    uint32_t local_idx;
    ValueType type;
} Symbol;

Symbol symbols[256];
int symbol_count = 0;

void compile_var_ref(const char* name) {
    Symbol* sym = lookup_symbol(name);
    emit_local_get(sym->local_idx);
}

void compile_var_assign(const char* name, ASTNode* value) {
    compile_expr(value);  // Value on stack
    Symbol* sym = lookup_symbol(name);
    emit_local_set(sym->local_idx);
}

Layer 3: Control flow compilation

Compiling if statements

void compile_if(ASTNode* node) {
    compile_expr(node->condition);  // Condition on stack

    if (node->else_body) {
        emit_if();  // Start if-else
        compile_block(node->then_body);
        emit_else();
        compile_block(node->else_body);
        emit_end();
    } else {
        emit_if();  // Start if (no else)
        compile_block(node->then_body);
        emit_end();
    }
}

Compiling while loops

void compile_while(ASTNode* node) {
    emit_block();  // $break label
    emit_loop();   // $continue label

    compile_expr(node->condition);
    emit_i32_eqz();  // Invert condition (becomes "if NOT condition")
    emit_br_if(1);   // Break out of block

    compile_block(node->body);
    emit_br(0);      // Continue loop
    emit_end();      // End loop
    emit_end();      // End block
}

Layer 4: Binary encoding

Emitting LEB128

void emit_leb128_u32(uint32_t value) {
    do {
        uint8_t byte = value & 0x7f;
        value >>= 7;
        if (value != 0) {
            byte |= 0x80;  // More bytes coming
        }
        write_byte(byte);
    } while (value != 0);
}

Building the module structure

void emit_module() {
    write_magic();     // 0x00 0x61 0x73 0x6d
    write_version();   // 0x01 0x00 0x00 0x00

    emit_type_section();      // Function signatures
    emit_function_section();  // Function type indices
    emit_export_section();    // Public API
    emit_code_section();      // Function bodies
}

Books That Will Help

Topic Book & Chapter What You’ll Learn
Compiler phases “Engineering a Compiler” Ch. 1 Lexer, parser, semantic analysis, codegen
Parsing and AST construction “Engineering a Compiler” Ch. 3-4 Top-down and bottom-up parsing
Type checking “Types and Programming Languages” Ch. 1-9 Type systems, type inference, soundness
Code generation for stack machines “Engineering a Compiler” Ch. 7.2 Expression evaluation, EBNF to code patterns
Symbol tables “Compilers: Principles, Techniques, and Tools” Ch. 2.7 Scoping, name resolution, symbol management
Control flow compilation “Engineering a Compiler” Ch. 8 CFGs, dominators, structured control flow
Binary encoding WebAssembly Spec §5 Module structure, LEB128, section format
Complete compiler example “Writing a C Compiler” by Nora Sandler End-to-end compiler implementation

Recommended reading order:

  1. Before starting (Week 1):
    • “Writing a C Compiler” Introduction & Ch. 1-2 (understand the big picture)
    • “Engineering a Compiler” Ch. 1 (compiler structure)
  2. Building the frontend (Week 2):
    • “Engineering a Compiler” Ch. 3-4 (parsing)
    • Implement: Lexer and parser for your language
  3. Type checking (Week 3):
    • “Types and Programming Languages” Ch. 1-3
    • Implement: Symbol table and type checker
  4. Code generation (Weeks 4-6):
    • “Engineering a Compiler” Ch. 7 (expressions), Ch. 8 (control flow)
    • “Writing a C Compiler” (codegen chapters)
    • Implement: WASM code generator
  5. Binary encoding (Week 7):
    • WebAssembly Spec §5 (binary format)
    • Implement: Module encoder

Common Pitfalls & Debugging

Pitfall 1: Stack Type Mismatches

Symptom:

Error: type mismatch at end of function
  expected [], found [i32]

Root cause: Your codegen left a value on the stack when the function ended.

Example:

// Bad codegen for: if (x > 5) { y = 10; }
local.get $x
i32.const 5
i32.gt_s
if
  i32.const 10      // Pushes value
  local.set $y      // Pops value
  i32.const 10      // BUG: Pushes another value that's never popped!
end
// Stack now has dangling i32, but function expects empty stack

Fix: Carefully track stack depth. Every local.get or i32.const pushes; every local.set or binary op consumes.

Debug technique:

// Add stack depth tracking to codegen
int stack_depth = 0;

void emit_i32_const(int32_t value) {
    write_instruction(0x41);  // i32.const opcode
    write_leb128_s32(value);
    stack_depth++;
    printf("DEBUG: Stack depth now %d (after i32.const)\n", stack_depth);
}

void emit_local_set(uint32_t idx) {
    write_instruction(0x21);  // local.set opcode
    write_leb128_u32(idx);
    stack_depth--;
    printf("DEBUG: Stack depth now %d (after local.set)\n", stack_depth);
}

Pitfall 2: Incorrect LEB128 Encoding

Symptom: Binary parses incorrectly, WABT reports “unexpected end of section”.

Root cause: Signed vs. unsigned LEB128 confusion.

Example:

// BAD: Using unsigned LEB128 for function type indices (should be unsigned)
void emit_leb128_s32(int32_t value) {  // Wrong! Type indices are unsigned
    // ... signed encoding ...
}

// GOOD: Match the spec
// - Local counts, type indices, function indices: unsigned
// - i32.const, i64.const with negative numbers: signed
void emit_type_idx(uint32_t idx) {
    emit_leb128_u32(idx);  // Correct
}

void emit_i32_const(int32_t value) {
    write_instruction(0x41);
    emit_leb128_s32(value);  // Correct (can be negative)
}

Debug technique:

# Encode a known value and check with hexdump
$ ./mycompiler test.lang -o test.wasm
$ wasm-objdump -d test.wasm    # Shows disassembly
$ wasm2wat test.wasm           # If it fails here, binary is malformed

Pitfall 3: Block Type Confusion

Symptom: if statements that should produce values don’t work.

Root cause: Forgot to specify block type for if with result.

Example:

// Source: x = (y > 0) ? 10 : 20;
// Target: Ternary as if-expression

// BAD: No block type specified
emit_if();  // Defaults to void block, can't return value

// GOOD: Specify result type
emit_if_i32();  // Block produces i32
compile_expr(then_expr);
emit_else();
compile_expr(else_expr);
emit_end();
local.set $x;  // Consumes the i32 from if-block

The fix:

void compile_ternary(ASTNode* node) {
    compile_expr(node->condition);

    // Emit: if (result i32)
    write_instruction(0x04);  // if opcode
    write_byte(0x7f);         // i32 value type (NOT void 0x40)

    compile_expr(node->then_expr);
    emit_else();
    compile_expr(node->else_expr);
    emit_end();
}

Pitfall 4: Forward References in Function Calls

Symptom: Calling a function before it’s defined produces wrong function index.

Root cause: Single-pass compilation assigns wrong indices.

Example:

func main() {
  return helper(5);  // Calls function 1 (wrong!)
}

func helper(x: i32) -> i32 {
  return x * 2;
}

// Problem: "helper" gets index 1, but we emitted call 0 when we saw it in main

Fix: Use two-pass compilation:

// Pass 1: Collect all function signatures
for (ASTNode* func : program->functions) {
    Symbol sym;
    sym.name = func->name;
    sym.func_idx = function_count++;
    sym.signature = func->signature;
    add_symbol(&sym);
}

// Pass 2: Generate code (lookups now work)
for (ASTNode* func : program->functions) {
    compile_function(func);
}

Pitfall 5: Stack Alignment in Loops

Symptom: Loop body breaks with “type mismatch” on br instruction.

Root cause: Loop body doesn’t maintain stack balance.

Example:

// BAD: Stack grows unbounded
block $break
  loop $continue
    local.get $i
    i32.const 1
    i32.add
    // BUG: Didn't store result! Stack has extra i32
    br $continue  // Error: loop expects empty stack
  end
end

Fix: Loop body must have same stack depth at start and end:

// GOOD: Stack balanced
block $break
  loop $continue
    local.get $i
    i32.const 1
    i32.add
    local.set $i   // Pop the result
    br $continue   // Stack empty, matches loop entry
  end
end

Pitfall 6: Memory Alignment Violations

Symptom: i32.load or i32.store crashes at runtime.

Root cause: WASM requires natural alignment for loads/stores.

Example:

// Source: arr[i] = value;  (arr is *i32, i is i32)

// BAD: No alignment guarantee
local.get $arr
local.get $i
i32.add          // Address might not be 4-byte aligned!
local.get $value
i32.store        // Crashes if misaligned

// GOOD: Scale index properly
local.get $arr
local.get $i
i32.const 4
i32.mul          // i * sizeof(i32) = properly aligned offset
i32.add
local.get $value
i32.store offset=0 align=4  // Explicit alignment

Debugging Strategies

  1. Emit WAT first, binary later:
    • Generate .wat text format initially
    • Use wat2wasm to convert (catches errors early)
    • Only generate binary when WAT works perfectly
  2. Add verbose logging:
    void compile_expr(ASTNode* node) {
        printf("CODEGEN: Compiling %s (type=%s)\n",
               ast_type_name(node), type_name(node->expr_type));
        // ... actual codegen ...
    }
    
  3. Test incrementally:
    // Week 1: Just integer literals
    assert(compile("42;") works);
    
    // Week 2: Add binary operations
    assert(compile("5 + 3;") works);
    
    // Week 3: Add variables
    assert(compile("let x = 5; x + 3;") works);
    
    // Don't jump to full language at once!
    
  4. Use reference compilers:
    # Compare your output to Clang's
    $ clang -target wasm32 -nostdlib -Wl,--no-entry test.c -o ref.wasm
    $ wasm2wat ref.wasm > ref.wat
    $ wasm2wat my_output.wasm > mine.wat
    $ diff ref.wat mine.wat
    
  5. Validate aggressively:
    # WABT's validator catches many errors
    $ wasm-validate my_output.wasm
    $ echo $?  # 0 = valid, non-zero = invalid
    

Red Flags (Stop and Debug)

  • Function body size in code section is 0 (you forgot to emit instructions)
  • Stack depth is negative (you popped too much)
  • Stack depth is > 0 at function end (didn’t consume all values)
  • Type section is empty (no function signatures defined)
  • Export section references function index >= function count (out of bounds)
  • LEB128-encoded value has more than 5 bytes (encoding bug)

When Stuck for Hours

  1. Generate the smallest possible test case (1-2 lines of source)
  2. Hand-write the expected WAT output
  3. Use wat2wasm to get expected binary
  4. hexdump both expected and actual, compare byte-by-byte
  5. Find the first differing byte, work backwards from there

Most compiler bugs are in codegen logic, not binary encoding. If your .wat output looks wrong, fix that before debugging binary format.


Project 5: “WASI Runtime” — System Interface for WebAssembly

Attribute Value
Main Programming Language C
Alternative Languages Rust, Go, Zig
Coolness Level Level 5: Pure Magic (Super Cool)
Business Potential Level 4: The “Open Core” Infrastructure
Difficulty Level 3: Advanced (The Engineer)
Knowledge Area WebAssembly, System Interfaces
Main Book The Linux Programming Interface by Michael Kerrisk
Time Estimate 2-3 weeks (after Project 3)
Prerequisites Completed Project 3, familiarity with POSIX syscalls

What you’ll build: An extension to your interpreter (Project 3) that implements WASI—the WebAssembly System Interface—allowing WASM programs to do file I/O, read environment variables, and more.

Why it teaches WebAssembly: WASI shows how WASM escapes the browser sandbox while maintaining security. You’ll understand capability-based security, how imports become system calls, and why WASM is becoming a universal runtime.

Core Challenges & Concepts

Challenge Maps To Key Skill
Implementing file descriptors and preopens Capability-based security Sandboxed filesystem access
Memory marshalling for syscalls Host/guest data transfer Reading/writing WASM linear memory
Implementing fd_read, fd_write, path_open POSIX-like abstraction System call interface design
Argument and environment passing Program initialization Startup sequence

Real World Outcome

Your WASI runtime will execute real command-line programs compiled to WebAssembly. Here’s what you’ll be able to run:

Example 1: Hello World

$ cat hello.c
#include <stdio.h>

int main() {
    printf("Hello from WebAssembly!\n");
    return 0;
}

$ clang --target=wasm32-wasi hello.c -o hello.wasm

$ ./wasi_runtime hello.wasm
[WASI] Initializing runtime...
[WASI] Preopen: fd=3 -> /
[WASI] Loading module: hello.wasm
[WASI] Importing WASI functions (22 imports)
[WASI] Executing _start

Hello from WebAssembly!

[WASI] Process exited with code 0

Example 2: File I/O

$ cat fileio.c
#include <stdio.h>

int main(int argc, char** argv) {
    if (argc < 2) {
        fprintf(stderr, "Usage: fileio <filename>\n");
        return 1;
    }

    FILE* f = fopen(argv[1], "r");
    if (!f) {
        perror("fopen");
        return 1;
    }

    char buffer[256];
    while (fgets(buffer, sizeof(buffer), f)) {
        printf("%s", buffer);
    }

    fclose(f);
    return 0;
}

$ clang --target=wasm32-wasi fileio.c -o fileio.wasm

$ echo "Hello from a file!" > test.txt

$ ./wasi_runtime fileio.wasm test.txt
[WASI] argc=2, argv=["fileio.wasm", "test.txt"]
[WASI] Preopen: fd=3 -> . (current directory)
[WASI] fd_open: path="test.txt", fd=4
[WASI] fd_read: fd=4, len=256
Hello from a file!
[WASI] fd_close: fd=4

Example 3: Environment Variables

$ cat env.c
#include <stdio.h>
#include <stdlib.h>

int main() {
    char* user = getenv("USER");
    char* home = getenv("HOME");

    printf("USER=%s\n", user ? user : "(not set)");
    printf("HOME=%s\n", home ? home : "(not set)");
    return 0;
}

$ clang --target=wasm32-wasi env.c -o env.wasm

$ USER=wasm_dev HOME=/home/wasm ./wasi_runtime env.wasm
[WASI] Environment variables: 2
  USER=wasm_dev
  HOME=/home/wasm
USER=wasm_dev
HOME=/home/wasm

Example 4: Full trace of WASI calls

$ ./wasi_runtime --trace hello.wasm
[WASI] Module instantiation
  Import: wasi_snapshot_preview1.fd_write
  Import: wasi_snapshot_preview1.environ_sizes_get
  Import: wasi_snapshot_preview1.environ_get
  Import: wasi_snapshot_preview1.proc_exit

[WASI] Call: environ_sizes_get()count=0, buf_size=0

[WASI] Call: fd_write(fd=1, iovs_ptr=1024, iovs_len=1, nwritten_ptr=1040)
  Reading iov[0]: buf=2048, len=26
  Reading from linear memory[2048]: "Hello from WebAssembly!\n"
  Writing 26 bytes to stdout
  → nwritten=26, result=SUCCESS

[WASI] Call: proc_exit(exitcode=0)
  → Process terminated

The Core Question You’re Answering

“How does sandboxed code safely interact with the host operating system? What is capability-based security and why does it matter?”

WASI solves a fundamental problem: WASM modules are isolated (can’t access files, network, or system resources), but real programs need I/O. How do you provide system access while maintaining security?

This project forces you to answer:

  • How do you give a WASM program file access without letting it read /etc/shadow?
  • What’s the difference between ambient authority (traditional POSIX) and capabilities?
  • How do you marshal data between WASM linear memory and host memory?
  • Why is WASI designed around file descriptors?

The deeper insight: Capabilities are unforgeable tokens that grant specific rights. Unlike traditional permissions (where any code can try to open any file), capabilities mean you can only operate on what you’ve been explicitly given access to. This is how WASI achieves both functionality and security.

Concepts You Must Understand First

Concept Why It Matters Book Reference
POSIX file I/O WASI is modeled after POSIX—understand open, read, write, close “The Linux Programming Interface” Ch. 4-5 - Kerrisk
File descriptors WASI uses FDs as handles to resources “Advanced Programming in the UNIX Environment” Ch. 3 - Stevens & Rago
Capability-based security WASI’s security model—understand unforgeable references “WASI Security Model” - Bytecode Alliance
Memory marshalling Reading/writing data across the WASM/host boundary “Computer Systems: A Programmer’s Perspective” Ch. 9 - Bryant & O’Hallaron
I/O vectors (iovecs) WASI uses scatter-gather I/O “The Linux Programming Interface” Ch. 5.13 - Kerrisk
Process initialization Arguments and environment variables “The Linux Programming Interface” Ch. 6 - Kerrisk

Detailed Prerequisites:

  1. Understand POSIX file I/O (2-3 days study)
    • Read “The Linux Programming Interface” Ch. 4: “File I/O: The Universal I/O Model”
    • Practice: Write C programs using open, read, write, close
    • Key insight: Everything is a file descriptor
  2. Learn capability-based security (1-2 days study)
    • Read WASI documentation on preopens and capabilities
    • Understand: A process can only access what it’s given at startup
    • Practice: Design a capability system for a simple shell
  3. Master memory marshalling (2-3 days study)
    • Understand: WASM memory is just a byte array to the host
    • Practice: Read/write C structs from byte arrays
    • Key operations: Reading iovec structures, copying strings
  4. Study the WASI API (3-5 days study)
    • Read WASI Specification on GitHub
    • Focus on: fd_read, fd_write, fd_prestat_get, path_open
    • Understand: Error codes (errno values), flag meanings

Questions to Guide Your Design

1. How will you implement preopens (capability grants)?

  • Which directories should be accessible by default?
  • How do you map host paths to guest paths?
  • How do you prevent path traversal attacks (../../../etc/passwd)?

2. How will you manage file descriptor state?

  • Where do you store open file handles?
  • How do you map WASM FDs to host FDs?
  • What about stdin/stdout/stderr (FDs 0, 1, 2)?

3. How will you read data from WASM linear memory?

  • WASI functions take pointers (as i32 offsets into linear memory)
  • How do you safely read a string at offset 1024?
  • How do you read an array of iovecs?

4. How will you handle errors?

  • WASI functions return errno values
  • How do you map host errors to WASI errors?
  • What happens if the host operation fails?

5. How will you pass arguments and environment variables?

  • args_sizes_get returns count and buffer size needed
  • args_get writes argv into linear memory
  • How do you serialize strings into WASM memory?

Thinking Exercise: Before You Code

Exercise 1: Trace fd_write by hand

// WASM code calls:
fd_write(1,  // stdout
         1024,  // iovs pointer
         1,     // iovs count
         2048)  // nwritten pointer

Linear memory at offset 1024:

[1024] = 3000  (buf ptr)
[1028] = 13    (buf len)

Linear memory at offset 3000:

[3000] = "Hello, WASI!\n"

Your task:

  1. Read the iovec structure from linear memory[1024]
  2. Extract buf=3000, len=13
  3. Read 13 bytes from linear memory[3000]
  4. Write “Hello, WASI!\n” to stdout (host FD 1)
  5. Write 13 to linear memory[2048] (nwritten)
  6. Return 0 (success)

Exercise 2: Implement preopen validation

Given:

  • Preopen: /home/user/sandbox mapped to WASM path /
  • WASM calls path_open with path ../../../etc/passwd

Your task:

  1. Resolve the path relative to /home/user/sandbox
  2. Check if result is within /home/user/sandbox
  3. Reject if path escapes sandbox
  4. What’s the algorithm?

The Interview Questions They’ll Ask

Basic Level:

  1. “What is WASI and why does it exist?”
  2. “What’s the difference between WASM in browsers and WASM with WASI?”
  3. “What are file descriptors?”

Intermediate Level:

  1. “Explain capability-based security vs. ambient authority.”
  2. “How does WASI prevent a WASM program from reading arbitrary files?”
  3. “What are preopens in WASI?”
  4. “How do you read data from WASM linear memory in your host implementation?”

Advanced Level:

  1. “Design a secure preopen system for a multi-tenant environment.”
  2. “How would you implement fd_readdir (directory listing)?”
  3. “Explain memory marshalling for complex structures (iovecs).”

Systems Design Level:

  1. “Compare WASI’s security model to Docker containers.”
  2. “How would you add networking to WASI while maintaining security?”
  3. “Design a WASI implementation that works on Windows and Linux.”

Hints in Layers

Layer 1: Getting started

How to structure WASI imports?

// Register WASI functions as imports
void register_wasi_imports(WasmInstance* instance) {
    import_function(instance, "wasi_snapshot_preview1", "fd_write",
                    host_fd_write);
    import_function(instance, "wasi_snapshot_preview1", "fd_read",
                    host_fd_read);
    // ... more functions
}

Layer 2: Implementing fd_write

int32_t host_fd_write(uint32_t fd, uint32_t iovs_ptr,
                       uint32_t iovs_len, uint32_t nwritten_ptr) {
    uint8_t* memory = get_linear_memory();
    size_t total_written = 0;

    for (uint32_t i = 0; i < iovs_len; i++) {
        // Each iovec is 8 bytes: [buf:u32, len:u32]
        uint32_t iov_offset = iovs_ptr + (i * 8);
        uint32_t buf_ptr = read_u32(memory, iov_offset);
        uint32_t buf_len = read_u32(memory, iov_offset + 4);

        // Write buffer to host FD
        ssize_t written = write(fd, memory + buf_ptr, buf_len);
        if (written < 0) return errno;
        total_written += written;
    }

    // Write total to nwritten pointer
    write_u32(memory, nwritten_ptr, total_written);
    return 0;  // SUCCESS
}

Layer 3: Implementing preopens

typedef struct {
    uint32_t wasi_fd;
    char* host_path;
    char* guest_path;
} Preopen;

Preopen preopens[] = {
    {3, "/home/user/sandbox", "/"},
    {4, "/tmp/wasi", "/tmp"},
};

// Check if path is within preopen
bool is_path_allowed(const char* path) {
    for (int i = 0; i < preopen_count; i++) {
        if (path_starts_with(path, preopens[i].guest_path)) {
            return path_within_bounds(path, preopens[i].host_path);
        }
    }
    return false;
}

Books That Will Help

Topic Book & Chapter What You’ll Learn
POSIX file I/O “The Linux Programming Interface” Ch. 4-5 open, read, write, close, file descriptors
I/O vectors “The Linux Programming Interface” Ch. 5.13 readv, writev, scatter-gather I/O
Process environment “The Linux Programming Interface” Ch. 6 argc, argv, envp
Filesystem operations “Advanced Programming in the UNIX Environment” Ch. 3-4 File metadata, directories
Capability security “WASI Security Model” (wasi.dev) Preopens, sandboxing
Memory safety “Computer Systems: A Programmer’s Perspective” Ch. 9 Bounds checking, safe memory access

Recommended reading order:

  1. Before coding (Week 1):
    • “The Linux Programming Interface” Ch. 4 (file I/O basics)
    • WASI Specification (high-level overview)
  2. During implementation (Weeks 2-3):
    • “The Linux Programming Interface” Ch. 5.13 (when implementing fd_read/write)
    • WASI Specification detailed function docs (reference while coding)
  3. For advanced features (Optional):
    • “Advanced Programming in the UNIX Environment” Ch. 3-4 (filesystem operations)

Common Pitfalls & Debugging

Pitfall 1: Capability violations (forgetting preopens)

$ ./wasi_runtime --no-preopens fileio.wasm test.txt
[WASI] Call: path_open(dirfd=3, path="test.txt", ...)
[ERROR] Capability violation: fd=3 not found in preopen table
[WASI] Returning errno=76 (ENOTCAPABLE)

Program exited with error: capability violation

Fix: Always initialize preopens before _start

void init_wasi_runtime() {
    add_preopen(3, ".", "/");  // Current directory
    add_preopen(0, NULL, NULL); // stdin
    add_preopen(1, NULL, NULL); // stdout
    add_preopen(2, NULL, NULL); // stderr
}

Pitfall 2: Path traversal attacks

$ ./wasi_runtime --trace fileio.wasm ../../../etc/passwd
[WASI] Call: path_open(dirfd=3, path="../../../etc/passwd", ...)
[WASI] Resolving path: . + ../../../etc/passwd
[WASI] Canonical path: /etc/passwd
[WASI] Preopen bounds: /home/user/sandbox
[ERROR] Path escapes sandbox: /etc/passwd not in /home/user/sandbox
[WASI] Returning errno=76 (ENOTCAPABLE)

Fix: Canonicalize paths and check bounds

bool validate_path(const char* path, const char* sandbox) {
    char canonical[PATH_MAX];
    realpath(path, canonical);

    // Check if canonical path starts with sandbox
    if (strncmp(canonical, sandbox, strlen(sandbox)) != 0) {
        return false;  // Path escapes sandbox
    }
    return true;
}

Pitfall 3: Memory marshalling bugs (wrong endianness/alignment)

// WRONG: Assumes host alignment
uint32_t* ptr = (uint32_t*)(memory + offset);
uint32_t value = *ptr;  // May crash on unaligned access!

// CORRECT: Read bytes explicitly
uint32_t read_u32_le(uint8_t* memory, uint32_t offset) {
    return memory[offset] |
           (memory[offset + 1] << 8) |
           (memory[offset + 2] << 16) |
           (memory[offset + 3] << 24);
}

Debugging trace:

[WASI] fd_write: Reading iovec at offset=1024
[DEBUG] memory[1024] = 0xB8
[DEBUG] memory[1025] = 0x0B
[DEBUG] memory[1026] = 0x00
[DEBUG] memory[1027] = 0x00
[DEBUG] buf_ptr = 0x00000BB8 (3000 in little-endian)
[DEBUG] buf_len = 13
[DEBUG] Reading 13 bytes from memory[3000]

Pitfall 4: Forgetting to write return values

// WRONG: Returns nwritten but doesn't write to memory
int32_t host_fd_write(..., uint32_t nwritten_ptr) {
    size_t written = write(fd, buf, len);
    return 0;  // Forgot to write 'written' to nwritten_ptr!
}

// CORRECT: Write return value to linear memory
int32_t host_fd_write(..., uint32_t nwritten_ptr) {
    size_t written = write(fd, buf, len);
    write_u32(memory, nwritten_ptr, written);  // ✓
    return 0;
}

Debugging output:

[WASI] fd_write(fd=1, iovs=1024, iovs_len=1, nwritten=2048)
  → Writing 26 bytes to stdout
  → Setting memory[2048] = 26
  → Return: SUCCESS (0)

# Without the fix:
[WASI] WASM side reads memory[2048] = 0 (garbage)
[ERROR] WASM thinks write failed (nwritten=0)

Pitfall 5: File descriptor leaks

// WRONG: Never closes host FDs
int32_t host_path_open(...) {
    int host_fd = open(path, flags);
    return assign_wasm_fd(host_fd);  // fd leaks if never closed
}

// CORRECT: Track FDs and implement fd_close
typedef struct {
    uint32_t wasi_fd;
    int host_fd;
    bool is_open;
} FdMapping;

int32_t host_fd_close(uint32_t wasi_fd) {
    FdMapping* mapping = find_fd(wasi_fd);
    if (!mapping || !mapping->is_open) return EBADF;

    close(mapping->host_fd);
    mapping->is_open = false;
    return 0;
}

Debugging with strace:

$ strace -e openat,close ./wasi_runtime leak.wasm
openat(AT_FDCWD, "file1.txt", O_RDONLY) = 4
openat(AT_FDCWD, "file2.txt", O_RDONLY) = 5
openat(AT_FDCWD, "file3.txt", O_RDONLY) = 6
# ... no close() calls! FD leak detected

Pitfall 6: Incorrect errno mapping

// Host errors don't always map 1:1 to WASI errors
int32_t host_fd_read(...) {
    ssize_t result = read(host_fd, buf, len);
    if (result < 0) {
        return errno;  // WRONG: Linux errno != WASI errno
    }
}

// CORRECT: Map host errno to WASI errno
int32_t map_errno(int host_errno) {
    switch (host_errno) {
        case EACCES: return 76;  // WASI::ENOTCAPABLE
        case ENOENT: return 44;  // WASI::ENOENT
        case EBADF:  return 8;   // WASI::EBADF
        default:     return 76;  // WASI::ENOTCAPABLE (safe default)
    }
}

Pitfall 7: Not handling iovec edge cases

# WASM passes empty iovec array
[WASI] fd_write(fd=1, iovs=1024, iovs_len=0, ...)
[BUG] Segfault: reading from iovs[0] when iovs_len=0

# Fix: Check bounds
if (iovs_len == 0) {
    write_u32(memory, nwritten_ptr, 0);
    return 0;
}

Complete debugging session:

$ ./wasi_runtime --trace --validate hello.wasm
[WASI] Validating module...
  ✓ Import: wasi_snapshot_preview1.fd_write (func)
  ✓ Memory: 1 page (64KB)
  ✓ Export: _start (func)

[WASI] Initializing preopens...
  fd=0 → stdin (no capability)
  fd=1 → stdout (no capability)
  fd=2 → stderr (no capability)
  fd=3 → . (preopen: /home/user/sandbox → /)

[WASI] Executing _start...

[WASI] Call: fd_write(fd=1, iovs_ptr=1024, iovs_len=1, nwritten_ptr=2048)
  Step 1: Checking fd=1... ✓ (stdout)
  Step 2: Reading iovec[0] from memory[1024]...
    memory[1024..1027] = 0x00000800 (buf_ptr=2048)
    memory[1028..1031] = 0x0000001A (buf_len=26)
  Step 3: Reading 26 bytes from memory[2048]...
    "Hello from WebAssembly!\n"
  Step 4: Writing to host stdout...
    write(1, "Hello from WebAssembly!\n", 26) = 26 ✓
  Step 5: Writing nwritten to memory[2048]...
    memory[2048] = 26 ✓
  Step 6: Returning SUCCESS (0) ✓

Hello from WebAssembly!

[WASI] Call: proc_exit(exitcode=0)
  Process terminated cleanly ✓

Runtime statistics:
  Total WASI calls: 2
  Memory accesses: 4 reads, 1 write
  Syscalls: 1 write
  Exit code: 0

Tools for debugging WASI:

  1. strace - Trace host syscalls
  2. gdb - Step through your runtime
  3. wasm-objdump - Inspect WASM imports/exports
  4. Custom tracing - Log every WASI call with memory dumps

Key debugging principle: When a WASI program fails, trace the exact sequence:

  1. What did WASM call?
  2. What did you read from linear memory?
  3. What host operation did you perform?
  4. What did you write back to linear memory?
  5. What did you return?

Project Comparison Table

# Project Difficulty Time Depth of Understanding Fun Factor
1 Hand-Write WAT Programs Beginner Weekend ⭐⭐⭐ (foundational) ⭐⭐⭐⭐
2 WASM Binary Parser Intermediate 1-2 weeks ⭐⭐⭐⭐ (structural) ⭐⭐⭐
3 WASM Interpreter Advanced 1 month+ ⭐⭐⭐⭐⭐ (complete) ⭐⭐⭐⭐⭐
4 Language to WASM Compiler Expert 1 month+ ⭐⭐⭐⭐⭐ (production view) ⭐⭐⭐⭐
5 WASI Runtime Advanced 2-3 weeks ⭐⭐⭐⭐ (systems integration) ⭐⭐⭐⭐
6 Full-Stack WASM Toolchain Expert 2-3 months ⭐⭐⭐⭐⭐ (complete mastery) ⭐⭐⭐⭐⭐

Recommendation

Based on wanting to deeply understand WebAssembly:

Start with Project 1 (Hand-Write WAT) this weekend. It costs almost nothing but gives you the mental model everything else builds on. You’ll understand the stack machine in your bones.

Then build Project 2 (Binary Parser) over the next 1-2 weeks. This demystifies the .wasm format completely.

Finally, tackle Project 3 (Interpreter) for the deepest possible understanding. When you’ve implemented br_if and watched your stack machine execute a loop, you’ll understand WebAssembly at a level few developers ever reach.


Project 6: “Full-Stack WASM Toolchain” — Complete WebAssembly Development Environment

Attribute Value
Main Programming Language C
Alternative Languages Rust, Go
Coolness Level Level 5: Pure Magic (Super Cool)
Business Potential Level 5: The “Industry Disruptor”
Difficulty Level 5: Expert
Knowledge Area Compilers, Virtual Machines, Toolchains
Main Book Engineering a Compiler by Cooper & Torczon
Time Estimate 2-3 months
Prerequisites Completed Projects 1-5

What you’ll build: A complete WebAssembly toolchain—a simple language, its compiler to WASM, a binary validator, an interpreter with WASI support, and a simple debugger that can step through WASM execution.

Why it teaches WebAssembly: You’ll have touched every layer: authoring (WAT/language), compilation (codegen), validation (type checking), execution (interpreter), and systems integration (WASI). This is how the WebAssembly ecosystem actually works.

Core challenges you’ll face:

  • Integrating all previous projects into a cohesive toolchain
  • Implementing validation per the spec (reject malformed modules)
  • Building a debugger that shows stack state and memory
  • Supporting multiple output targets (browser JS glue, WASI CLI)
  • Optimizations (constant folding, dead code elimination)

Key Concepts:

  • Toolchain architecture: “LLVM: A Compilation Framework” - Chris Lattner
  • WASM validation: WebAssembly Specification Section 3 - W3C
  • Debugging VMs: “Building a Debugger” - Sy Brand

Real world outcome: A working toolchain you can demo:

$ ./mywasmcc factorial.mylang -o factorial.wasm
$ ./mywasm validate factorial.wasm
Valid module: 1 function, 0 imports, 1 export
$ ./mywasm run factorial.wasm --arg 10
3628800
$ ./mywasm debug factorial.wasm
(wdb) break factorial
(wdb) run --arg 5
Breakpoint hit at factorial
(wdb) stack
[i32: 5]
(wdb) step
...

Learning milestones:

  1. Compiler produces valid WASM → end-to-end works
  2. Interpreter passes conformance tests → spec-compliant
  3. Debugger shows execution state → you can teach others
  4. Someone else uses your toolchain → production quality

Additional Resources

Official Documentation

Books

  • “WebAssembly: The Definitive Guide” by Brian Sletten - O’Reilly
  • “Programming WebAssembly with Rust” by Kevin Hoffman - Pragmatic Bookshelf

Tools

  • wabt - WebAssembly Binary Toolkit (wat2wasm, wasm2wat, etc.)
  • wasmtime - Fast WASI runtime
  • wasm3 - High-performance interpreter (good reference)

This progression takes you from “I know WebAssembly exists” to “I could implement a WASM runtime from scratch.”