Lesson 5 — Building the instruction table step by step
Question
Build the opcode dispatch table from scratch. 256 slots; each maps a byte to a function pointer.
Principle (minimum model)
- Step 0 — naive.
match opcode { 0x01 => add(...), 0x02 => mul(...), ... }. Match on byte. Works but slow (jump table). - Step 1 — function pointer array.
[InstrFn; 256]indexed by opcode byte. O(1) dispatch. - Step 2 — const fn for compile-time fill.
const fn build_table() -> [InstrFn; 256] { ... }. Table computed at compile time; zero runtime cost. - Step 3 — generic over (host, inner-type).
[InstrFn<H, IT>; 256]. Lets us reuse for different host types. - Step 4 — full instruction table. Complete; ~150 entries (256 slots but many are STOP or INVALID for unused bytes).
- Step 5 — wire it into the interpreter.
interp.next() -> opcode_byte → table[opcode_byte](interp). Two lookups; very fast. - Why this matters. Every EVM tick goes through this table. Performance differentiator.
- Production parallel. Hyperliquid customises this table to add HyperEVM precompiles.
Worked example + steps
Building the instruction table step by step
📋 Recall before reading. Three questions from the last lessons. If any are shaky, scroll back to Building
addstep by step or its drill — the rest of this lesson assumes the answers are vocabulary you own.
- What does the type signature
<IT: ITy, H: ?Sized>on an opcode function buy us at compile time?- The
popn_top!macro bindsop2as a&mut. Why a reference, not a value?adduseswrapping_add. What test failure mode did you observe when you swapped it forsaturating_addand rerancargo test -p revm-interpreter?
When the EVM sees byte 0x01 in bytecode, what mechanism decides that add runs? That's dispatch — the lookup that turns one byte into one function call, repeated for every opcode in every transaction. Hot path of the hottest path. Get it wrong and your client is uncompetitive; get it right and a custom opcode is three lines of code (next lesson).
We'll start from the dumbest dispatch you could write and build up to revm's actual instruction table. By the end you'll have built every piece of:
const fn instruction_table_impl<WIRE: InterpreterTypes, H: Host>()
-> InstructionTable<WIRE, H>
{
let mut table = [Instruction::unknown(); 256];
table[ADD as usize] = Instruction::new(arithmetic::add);
table[MUL as usize] = Instruction::new(arithmetic::mul);
// ...
}
📂 Open
bluealloy/revmin another tab. As before — every claim below should be verified against the actual file.
Step 0 — The naive dispatch
Without thinking, you'd write:
fn dispatch(byte: u8, ctx: &mut Context) -> Result {
match byte {
0x01 => add(ctx),
0x02 => mul(ctx),
0x03 => sub(ctx),
// ... 256 arms
_ => return Err(Unknown),
}
}
256 match arms. The compiler might turn this into a jump table — or might not. Worse: every time you add or rename an opcode, you touch this giant match.
The two:
- No guarantee of jump-table compilation. A 256-arm match is large enough that LLVM usually does the right thing, but "usually" is not a contract you ship consensus on. You want a guaranteed O(1) lookup.
- Opcode mutation is expensive. Adding a custom opcode means editing this match. A "modular" custom-opcodes feature whose user-friendliness is "modify the dispatch match" is broken-by-design.
Step 1 — Function pointers in an array
Replace the match with an array indexed by opcode byte. Each slot holds a function pointer:
let mut table: [fn(&mut Context) -> Result; 256] = [unknown; 256];
table[0x01] = add;
table[0x02] = mul;
// ...
fn dispatch(byte: u8, ctx: &mut Context) -> Result {
(table[byte as usize])(ctx)
}
Dispatch is now one indexed lookup — no match, no jump table the compiler builds for you. The shape is guaranteed.
Because the EVM opcode is one byte. There are only 256 possible values, period. A fixed-size array exhausts the space — every byte either has an opcode or maps to unknown.
Step 2 — Make the table const
The naive code builds the table at runtime — push the assignments through at startup and hope the optimizer hoists them. Better: build it at compile time so the table is already populated the moment the binary loads.
const fn build_table() -> [fn(&mut Context) -> Result; 256] {
let mut t = [unknown; 256];
t[0x01] = add;
t[0x02] = mul;
// ...
t
}
const TABLE: [fn(&mut Context) -> Result; 256] = build_table();
const fn reads as "this function can be evaluated at compile time." The compiler executes build_table() during compilation, freezes the resulting array, and bakes it into the binary's data section. Dispatch never runs build_table at runtime — it just reads from the baked array.
What runs zero times: the loop/sequence that populates the table slots. The runtime TABLE is identical to a literal [unknown, add, mul, sub, ...; 256] written by hand. Zero runtime cost to set up dispatch. That's the whole point.
Step 3 — Wrap function pointers in an Instruction struct
Bare fn pointers work, but they're inflexible — you can't attach metadata (gas costs, opcode names) without breaking the dispatch type. Revm wraps the function pointer in a struct:
#[derive(Debug)]
pub struct Instruction<W: InterpreterTypes, H: ?Sized> {
fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult,
}
impl<W: InterpreterTypes, H: Host + ?Sized> Instruction<W, H> {
#[inline]
pub const fn new(fn_: fn(InstructionContext<'_, H, W>) -> InstructionExecResult) -> Self {
Self { fn_ }
}
}
Two things:
-
Future metadata. You could later add
gas_cost: u16,name: &'static str, etc. without changing the dispatch signature. -
Type discipline.
Instruction::new(arithmetic::add)is more type-safe than a bare function pointer assignment — the compiler verifies the signature matches the table's slot type at the call site. Concretely, with a barefnthe following compiles:table[ADD as usize] = some_fn_with_wrong_signature; // e.g. fn(InstructionContext, ExtraArg) -> SomethingElse // Function-pointer types coerce far enough that the assignment passes — // you find out at runtime when the dispatcher actually calls it.Instruction::new(...)is an explicit typed constructor, so a signature mismatch fails at the assignment line, not at runtime. Bug shifted from runtime to compile time.
The generics W: InterpreterTypes, H: ?Sized are exactly the same IT/H we built up two lessons ago. Same reasoning — let one table work across all execution modes and host types.
You'd need a separate Instruction type per execution mode (production / tracing / Inspector sandbox) and per host type — meaning a separate table and a separate dispatcher per combination. Instruction<W, H> lets one table + one dispatcher cover all modes × all hosts. Same reasoning that made add itself generic in the previous lesson.
Step 4 — The full real instruction table
Putting it together, the actual revm code from crates/interpreter/src/instructions.rs:
const fn instruction_table_impl<WIRE: InterpreterTypes, H: Host>()
-> InstructionTable<WIRE, H>
{
use bytecode::opcode::*;
let mut table = [Instruction::unknown(); 256];
table[ADD as usize] = Instruction::new(arithmetic::add);
table[MUL as usize] = Instruction::new(arithmetic::mul);
table[SUB as usize] = Instruction::new(arithmetic::sub);
table[DIV as usize] = Instruction::new(arithmetic::div);
table[SDIV as usize] = Instruction::new(arithmetic::sdiv);
// ...
table[LT as usize] = Instruction::new(bitwise::lt);
table[GT as usize] = Instruction::new(bitwise::gt);
// ...
table
}
You built every piece:
- Array indexed by opcode byte (Step 1) — guaranteed O(1) dispatch
const fn(Step 2) — table baked at compile time, zero startup costInstruction::unknown()everywhere first — every undefined byte halts cleanly; defined opcodes overwrite their slotInstruction::new(fn)(Step 3) — typed wrapper, future-proof for metadata
The opcode byte map (reference)
From bytecode::opcode:
| Byte | Opcode |
|---|---|
| 0x01 | ADD |
| 0x02 | MUL |
| 0x03 | SUB |
| 0x10 | LT |
| 0x14 | EQ |
| 0x16 | AND |
| 0x60–0x7F | PUSH1–PUSH32 |
| 0x80–0x8F | DUP1–DUP16 |
| 0xA0–0xA4 | LOG0–LOG4 |
| 0x0C–0x0F | unallocated ← gaps for custom opcodes |
| 0x21–0x2F | unallocated |
🔍 Verify, don't trust. Open
bytecode::opcodein the repo. Confirm0x0Cis genuinely unassigned on the version you'd actually fork. The gaps shift across hard forks — a table in a lesson is a snapshot, not a contract.
Recall before moving on
Without scrolling:
- Why an array of function pointers, not a
matchstatement or aHashMap<u8, fn>? - What does
const fnsave at runtime? - Why is every slot initialized to
Instruction::unknown()before defined opcodes overwrite their slots? - Why a struct
Instruction { fn_ }around the function pointer instead of a barefn?
Next lesson: now that you have the table, slot in your own opcode.
🛣️ The road not taken (Solana): Solana programs don't dispatch through a table like this — they compile to SBPF, an eBPF-derived register VM. Operands live in registers (not on a stack), instructions are JIT-compiled into native code, and dispatch happens through the JIT-emitted control flow, not through a function-pointer table. EVM's stack-machine + table-dispatch choice keeps the bytecode tiny, the interpreter portable, and formal reasoning about execution tractable. Solana's register-VM + JIT choice optimizes for raw throughput and lets a single compiled program execute at near-native speed. Two valid VM-design answers; the choice between them is a tradeoff between provability + portability and runtime throughput.
🧭 Where you are now in the stack: you've built the VM-layer instruction dispatch — 256-slot const table +
Instruction<W,H>wrapper, O(1) dispatch guaranteed at compile time. Same shape as CPython's bytecode dispatcher and the JVM's interpreter tables. Next lesson inserts your own opcode into this table — the dispatch surface is the extension point.
Summary (3 lines)
- 6-step buildup: match → array → const fn → generic → full table → wire to interpreter. O(1) dispatch.
- ~150 valid entries; 256 slots (others = STOP / INVALID). Compile-time table.
- Production: Hyperliquid customises for HyperEVM. Next: custom opcode wiring + failure modes.