Building an EVM from Scratch: Lessons Learned
A practical guide to building an EVM implementation from scratch using Zig, covering stack architecture, opcode dispatch, memory management, and testing.
TL;DR: Building an Ethereum Virtual Machine from scratch teaches you more about blockchain internals than any tutorial. This article walks through the architecture decisions, opcode dispatch patterns, 256-bit arithmetic challenges, and testing strategies we used to build the Zig EVM — a fully functional EVM implementation in Zig with 96 opcodes, gas metering, and parallel execution support.
Why Build an EVM from Scratch?
Most developers interact with the EVM through Solidity or Vyper. They deploy contracts, call functions, and read events — but never see what happens beneath the surface. Building an EVM implementation from scratch strips away that abstraction layer entirely.
There are three practical reasons to undertake this project:
- Deep understanding of execution semantics. You cannot implement
DELEGATECALLwithout understanding how execution contexts nest. You cannot implementCREATE2without understanding how address derivation works. - Performance research. Existing EVMs make tradeoffs that may not suit every use case. A custom implementation lets you experiment with parallel execution, JIT compilation, or alternative storage backends.
- Education. The EVM is a real-world virtual machine with a complete specification. Implementing it is a better learning exercise than building a toy language interpreter.
We chose Zig for the Zig EVM project because its explicit memory management, comptime metaprogramming, and lack of hidden control flow make it possible to reason about every allocation and every branch.
Architecture Decisions: The Core EVM Struct
The EVM is a stack-based virtual machine. Every implementation must model three primary data structures: the stack, memory, and storage.
The Stack
The EVM stack holds 256-bit words and has a maximum depth of 1024 items. This is not a suggestion — exceeding 1024 items must trigger a stack overflow error.
// Stack implementation: 256-bit words, 1024-item hard limit
pub const Stack = struct {
items: [1024]u256,
size: usize,
pub fn push(self: *Stack, value: u256) !void {
if (self.size >= 1024) return error.StackOverflow;
self.items[self.size] = value;
self.size += 1;
}
pub fn pop(self: *Stack) !u256 {
if (self.size == 0) return error.StackUnderflow;
self.size -= 1;
return self.items[self.size];
}
};
A key design decision: we use a fixed-size array rather than a dynamic list. Since the maximum depth is known at compile time, there is no reason to allocate dynamically. This eliminates allocation failures during execution and improves cache locality.
Memory
EVM memory is a byte-addressable array that expands dynamically in 32-byte word increments. The critical detail is that memory expansion has a gas cost, and that cost grows quadratically:
memory_cost = (memory_size_words ** 2) / 512 + 3 * memory_size_words
This means accessing memory at offset 1,000,000 is dramatically more expensive than accessing offset 100. Our implementation tracks the highest accessed offset and computes expansion costs lazily:
// Memory expansion: only pay gas when you access new regions
pub fn ensureCapacity(self: *Memory, offset: usize, size: usize) !u64 {
const new_size = offset + size;
if (new_size <= self.data.len) return 0; // no expansion needed
const new_words = (new_size + 31) / 32;
const old_words = (self.data.len + 31) / 32;
// Quadratic cost formula from the Yellow Paper
const new_cost = (new_words * new_words) / 512 + 3 * new_words;
const old_cost = (old_words * old_words) / 512 + 3 * old_words;
self.data = try self.allocator.realloc(self.data, new_words * 32);
return @intCast(new_cost - old_cost);
}
Storage
Storage is a persistent key-value mapping from 256-bit keys to 256-bit values. Each contract has its own storage space. We implemented this as a HashMap(u256, u256) because the access pattern is purely random lookup:
// Storage: persistent key-value store per contract
storage: std.HashMap(u256, u256, std.hash_map.AutoContext(u256), 80),
In a production EVM, storage sits on top of a Merkle Patricia Trie for state root computation. For execution purposes, a hash map is the correct abstraction.
Opcode Dispatch: The Heart of the Interpreter
The EVM executes bytecode by reading one byte at a time, looking up the corresponding opcode handler, and executing it. The dispatch mechanism is the single hottest path in the entire system.
Dispatch Table Design
We evaluated three approaches:
| Approach | Lookup Time | Memory | Extensibility |
|---|---|---|---|
| Switch statement | O(log n) with jump table | Minimal | Poor — one giant function |
| Function pointer array | O(1) | 256 * 8 bytes | Good — modular handlers |
| HashMap | O(1) amortized | Higher overhead | Best — runtime registration |
We chose a HashMap of function pointers for development flexibility. Each opcode lives in its own file and registers itself:
// Each opcode is a separate file with a standard interface
// src/opcodes/add.zig
pub fn getImpl() struct { code: u8, impl: OpcodeImpl } {
return .{
.code = 0x01, // ADD opcode
.impl = OpcodeImpl{ .execute = execute },
};
}
fn execute(evm: *EVM) !void {
const a = try evm.stack.pop();
const b = try evm.stack.pop();
try evm.stack.push(a +% b); // wrapping addition for 256-bit
}
This pattern — one file per opcode, a standard getImpl() interface — has proven excellent for maintainability. Adding a new opcode means creating one file and adding one line to the registration function. There is no risk of accidentally breaking an adjacent opcode’s case branch.
The Zig EVM implements 96 opcodes using a modular file-per-opcode architecture, where each opcode is a self-contained module that exports a standard interface for registration with the dispatch table.
Gas Metering
Every opcode must deduct gas before execution, not after. This is a critical correctness requirement:
// Gas is deducted BEFORE opcode execution
fn dispatch(evm: *EVM, opcode: u8) !void {
const gas_cost = getGasCost(opcode);
if (evm.gas_remaining < gas_cost) return error.OutOfGas;
evm.gas_remaining -= gas_cost;
const impl = evm.opcodes.get(opcode) orelse return error.InvalidOpcode;
try impl.execute(evm);
}
Gas costs vary widely. ADD costs 3 gas. SSTORE can cost 2,600 to 20,000 gas depending on whether the storage slot is cold or warm. Dynamic gas costs for memory-touching operations require calculating the expansion cost at runtime.
The 256-Bit Arithmetic Challenge
The EVM operates on 256-bit unsigned integers. Most CPUs natively support 64-bit operations. Bridging this gap is one of the most technically demanding parts of an EVM implementation.
Representing u256
We represent 256-bit integers as four 64-bit words in little-endian order:
// 256-bit integer: 4 x 64-bit words, little-endian
pub const BigInt = struct {
words: [4]u64,
pub fn fromU64(value: u64) BigInt {
return .{ .words = .{ value, 0, 0, 0 } };
}
};
Zig’s built-in u256 type handles much of this natively through compiler support, but we maintain a BigInt module for operations that need explicit word-level control — especially multiplication and division.
Multiplication and Overflow
Multiplying two 256-bit numbers produces a 512-bit result. The EVM takes only the lower 256 bits (wrapping multiplication). Implementing this correctly requires propagating carries across all word boundaries:
// Wrapping 256-bit multiplication using word-level decomposition
pub fn mul(a: BigInt, b: BigInt) BigInt {
var result: [4]u64 = .{ 0, 0, 0, 0 };
for (0..4) |i| {
var carry: u64 = 0;
for (0..4) |j| {
if (i + j >= 4) break; // discard overflow beyond 256 bits
const product: u128 = @as(u128, a.words[i]) * @as(u128, b.words[j]);
const sum: u128 = @as(u128, result[i + j]) + product + carry;
result[i + j] = @truncate(sum);
carry = @truncate(sum >> 64);
}
}
return .{ .words = result };
}
Signed Arithmetic
The EVM uses two’s complement for signed operations (SDIV, SMOD, SLT, SGT, SIGNEXTEND). The subtle part is that the most negative 256-bit value (-2^255) has no positive counterpart in 256 bits. Division of -2^255 by -1 must return -2^255, not overflow.
Testing Strategy
EVM correctness is non-negotiable. A single opcode bug can cause consensus failures, making the chain unable to validate blocks.
Unit Tests Per Opcode
Every opcode has dedicated tests covering normal operation, edge cases, and error conditions:
// Test ADD opcode: normal case and overflow
test "ADD basic" {
var evm = try EVM.init(allocator);
try evm.stack.push(10);
try evm.stack.push(20);
try evm.executeOpcode(0x01); // ADD
try std.testing.expectEqual(@as(u256, 30), evm.stack.peek());
}
test "ADD overflow wraps" {
var evm = try EVM.init(allocator);
try evm.stack.push(std.math.maxInt(u256));
try evm.stack.push(1);
try evm.executeOpcode(0x01); // ADD
try std.testing.expectEqual(@as(u256, 0), evm.stack.peek());
}
Integration Tests
We test complete bytecode sequences that represent real contract patterns:
// Test a simple counter increment: PUSH1 5, PUSH1 3, ADD
test "bytecode sequence: addition" {
var evm = try EVM.init(allocator);
evm.code = &[_]u8{ 0x60, 0x05, 0x60, 0x03, 0x01 };
try evm.run();
try std.testing.expectEqual(@as(u256, 8), evm.stack.peek());
}
Running the Test Suite
# Run all tests
zig build test
# Run with optimizations to catch release-mode bugs
zig build test -Doptimize=ReleaseFast
You can also explore opcode behavior interactively using the Zig EVM Playground, which lets you step through bytecode execution and inspect the stack at each step.
Challenges and Lessons Learned
Lesson 1: The Yellow Paper Is Necessary but Insufficient
The Ethereum Yellow Paper provides formal definitions, but many practical details live in EIPs, client test suites, and implementation notes. For example, the CREATE2 address derivation includes the deployer address, a salt, and the keccak256 of the init code — but the exact encoding (with a 0xff prefix byte) is specified in EIP-1014, not the Yellow Paper.
Lesson 2: Gas Costs Are the Specification
Getting execution semantics right is table stakes. Getting gas costs right is what makes an EVM implementation usable for tooling. If your gas estimation is wrong, transactions will either fail unexpectedly or overpay.
Lesson 3: Zig’s Comptime Is Ideal for Opcode Registration
We use Zig’s compile-time features to generate the opcode table from the individual opcode files. This eliminates runtime initialization overhead and catches registration errors at compile time:
// Compile-time opcode registration
inline fn loadOpcodes(evm: *EVM) void {
// Each import and registration is resolved at comptime
const add = @import("opcodes/add.zig").getImpl();
evm.opcodes.put(add.code, add.impl);
// ... repeated for all 96 opcodes
}
Lesson 4: Test Against Known-Good Implementations
The Ethereum Foundation maintains a comprehensive test suite with over 30,000 test vectors. Any serious EVM implementation must pass these tests. During development, we found 12 edge-case bugs that only surfaced through conformance testing — bugs that no amount of manual unit testing would have caught.
Lesson 5: Performance Requires Profiling, Not Guessing
Our initial HashMap-based dispatch was not the bottleneck we expected. Profiling revealed that 256-bit division consumed 40% of execution time on arithmetic-heavy workloads. Optimizing the division algorithm (using Knuth’s Algorithm D) yielded a larger speedup than switching to array-based dispatch.
Performance Snapshot
| Metric | Zig EVM | Notes |
|---|---|---|
| Opcodes implemented | 96 | Full arithmetic, stack, memory, flow control |
| Dispatch overhead | ~5ns per opcode | HashMap lookup with cached hash |
| 256-bit addition | ~2ns | Native Zig u256 support |
| 256-bit multiplication | ~8ns | Compiler-generated widening multiply |
| Memory expansion | O(1) amortized | Geometric reallocation strategy |
| Parallel speedup | 5-6x | For independent transaction batches |
For detailed benchmark methodology and comparisons, see our benchmarking article.
Key Takeaways
- Start with the stack and arithmetic. These are the foundation that every other opcode depends on. Get
PUSH,POP,ADD,SUB,MUL, andDIVworking correctly before anything else. - One file per opcode scales well. With 96+ opcodes, a monolithic switch statement becomes unmaintainable. The modular pattern pays for itself quickly.
- 256-bit arithmetic is the performance bottleneck. Invest in optimized bigint operations early. Division and modular exponentiation deserve special attention.
- Gas metering is not optional. Even for research implementations, accurate gas costs are essential for realistic behavior.
- Test relentlessly. Use both unit tests per opcode and integration tests with real bytecode sequences. The Ethereum test suite is your ultimate validation.
- Zig is well-suited for VM implementation. Explicit allocators, comptime code generation, and predictable performance characteristics make it an excellent choice.
Getting Started
The complete Zig EVM source code is available at github.com/cryptuon/zig-evm. Clone the repository and run the test suite:
# Clone and build
git clone https://github.com/cryptuon/zig-evm.git
cd zig-evm
zig build test
# Run the EVM
zig build run
# Try the interactive playground
# Visit the online playground at /playground/
To experiment with bytecode execution without installing anything, try the Zig EVM Playground — it provides a browser-based interface for stepping through EVM bytecode and inspecting machine state at each instruction.
Frequently Asked Questions
- How long does it take to build an EVM from scratch?
- A minimal EVM with arithmetic and stack opcodes can be built in a few weeks. A production-grade implementation with all 144+ opcodes, gas metering, and parallel execution takes several months of focused development.
- What programming language is best for building an EVM?
- Systems languages like Zig, Rust, and C++ are ideal for EVM implementations because they offer fine-grained memory control and zero-cost abstractions. Zig is particularly well-suited due to its comptime features and explicit allocator patterns.
- How many opcodes does the EVM have?
- The Ethereum Virtual Machine defines approximately 144 opcodes across categories including arithmetic, stack manipulation, memory operations, storage, flow control, logging, and system operations. The Zig EVM currently implements 96 of these.
- What is the hardest part of building an EVM?
- The most challenging aspects are implementing correct 256-bit arithmetic (especially signed division and modular exponentiation), achieving gas-accurate execution that matches the Ethereum specification, and handling edge cases in memory expansion costs.
- Can I use a custom EVM for production blockchain nodes?
- Yes, but it requires exhaustive conformance testing against the Ethereum test suite (over 30,000 test vectors). The EVM must match the reference implementation exactly for consensus compatibility.
Try Zig EVM
Explore the interactive playground or dive into the source code.