A Stack Machine of 256-Bit Words
The overview framed the EVM as the shared world computer’s CPU: the one component every node runs identically so that untrusting strangers can agree on the result of executing a contract. This page opens the CPU and looks at how it actually computes. Before we can talk about where data lives, what gas costs, or why every node lands on the same answer, we need the machine’s most basic shape: how does it hold numbers, and how does an instruction turn inputs into outputs?
The answer is deliberately austere. The EVM is a stack machine: a single last-in-first-out stack of fat 256-bit words, a list of opcodes that each pop their inputs off that stack and push their result back, and a program stored on-chain as a flat array of bytes. No registers, no variables, no named memory cells at the instruction level — just a stack and a stream of one-byte instructions. That austerity is not a limitation to apologize for; it is what makes the machine small enough for thousands of independent nodes to implement bit-for-bit identically.
Throughout this page we ground the ideas in this book’s companion crate, ethmini — a working mini-EVM in Rust. ethmini makes one honest simplification we will flag every time it matters: its words are 64 bits wide, not 256. Everything else — the stack discipline, the opcode byte values, the wrapping arithmetic, the two clean halts — is faithful to real Ethereum.
No registers, just a stack
Section titled “No registers, just a stack”A normal CPU has registers: a small set of named scratch slots (rax, rbx, …) that instructions read and write by name. add rax, rbx means “add the register named rbx into the register named rax.” The instruction has to say which registers it touches, so every instruction carries operand addresses.
A stack machine throws that away. There is exactly one place to put a working value — the top of a stack — and instructions do not name their operands at all. They just say what to do, and the machine takes inputs from the top of the stack and puts the result back on top.
Register machine Stack machine ---------------- ------------- add rax, rbx vs. ADD (names 2 registers) (names nothing; pops 2, pushes 1)This is the same idea as reverse Polish notation on an old HP calculator: to compute 2 + 3 you press 2, 3, +. The + has no arguments written next to it — it consumes whatever two numbers are sitting on top. The EVM is exactly this, mechanized.
Why choose the austere design for a world computer? Because it shrinks the specification. An opcode with no operand fields is one byte with a fixed, obvious meaning. There are no addressing modes to get subtly wrong, no register-allocation rules two implementations might interpret differently. Every client — Geth in Go, Nethermind in C#, Reth in Rust — must reproduce the same stack after the same opcode, and a stack machine makes “the same” easy to pin down. Simplicity here is a consensus feature, not an aesthetic one.
Tracing (2 + 3) * 4
Section titled “Tracing (2 + 3) * 4”Let’s run one real expression through the machine by hand. The program, written as opcodes:
PUSH 2 PUSH 3 ADD PUSH 4 MUL RETURNWatch the stack. The top of the stack is on the right; each column is the state after that instruction runs.
instruction stack (top on the right) what happened----------- ------------------------ -------------PUSH 2 [ 2 ] push the literal 2PUSH 3 [ 2, 3 ] push the literal 3ADD [ 5 ] pop 3, pop 2, push 2+3PUSH 4 [ 5, 4 ] push the literal 4MUL [ 20 ] pop 4, pop 5, push 5*4RETURN [ ] -> returns 20 pop 20, halt, hand it backSix instructions, one stack, no variables — and the machine has evaluated (2 + 3) * 4 = 20. Notice that the parentheses never appear anywhere. Operator precedence is encoded entirely in the order the opcodes are laid out: we added before we multiplied because ADD comes before MUL in the stream. A compiler (Solidity’s, say) does the work of flattening a nested expression into this linear push-and-operate order. The VM itself has no notion of precedence; it just runs the list top to bottom.
In ethmini, that exact program is a test:
// (2 + 3) * 4 = 20let code = assemble(&[ Op::Push(2), Op::Push(3), Op::Add, Op::Push(4), Op::Mul, Op::Return,]);let exec = run(&code, &empty(), 1000).unwrap();assert_eq!(exec.return_value, Some(20));assemble turns those opcodes into on-chain bytes; run walks the bytes and produces the result. We will unpack both in a moment.
Why 256-bit words
Section titled “Why 256-bit words”Each slot on the stack is not a byte or a 64-bit integer — it is a 256-bit word, a number up to 2^256 − 1. That is an enormous, deliberate choice. Where does 256 come from?
Two things Ethereum touches constantly are exactly 256 bits wide:
- A Keccak-256 hash is 256 bits. Hashes are everywhere in Ethereum — addresses, storage keys, Merkle roots, block hashes. Making the native word 256 bits means a hash fits in one stack slot, so hashing and comparing hashes never straddle multiple words.
- An account balance is measured in wei, and the total supply is enormous when counted in wei (1 ether = 10^18 wei). A 256-bit word holds any balance the system could ever represent with room to spare, so balance arithmetic never overflows into a second word.
Put simply: the word was sized so that the two quantities the machine handles most — a hash and a balance — each occupy a single, indivisible slot. That keeps the instruction set small (no multi-word arithmetic) at the cost of making every word big.
Two views of the same code
Section titled “Two views of the same code”Here is a distinction that trips people up until it clicks: a program has two representations, and both are “the code.”
- Opcodes as a typed enum — how a human (or a compiler) writes and reasons about a program. Each instruction is a named thing:
ADD,MUL,PUSH 2. Inethminithis is theOpenum. - Raw bytecode — how the program is stored on-chain: a flat
Vec<u8>, a run of bytes with no structure of its own. This is what actually lives in a contract’scodefield and what every node downloads.
The two are connected by a pair of functions. assemble goes from opcodes to bytes (you do this once, to deploy). decode goes from bytes back to one opcode at a time (the VM does this on every step, as it executes).
Op enum ──assemble──► bytecode (Vec<u8>) ──stored on-chain──► the network (reason) (canonical form) │ └──decode──► one Op + position of next instruction (what the interpreter loop consumes)The bytecode is the canonical form — the thing hashes are taken over, the thing consensus is about. The Op enum is a convenience for us; the chain never sees it.
Opcode byte values are real
Section titled “Opcode byte values are real”The byte values are not arbitrary. ethmini uses the same numbers as real Ethereum, so a program you build in the crate would be recognizable to anyone who has read the yellow paper:
| Opcode | Byte | Effect |
|---|---|---|
STOP | 0x00 | Halt successfully, no return value |
ADD | 0x01 | Pop b, pop a, push a + b (wrapping) |
MUL | 0x02 | Pop b, pop a, push a * b (wrapping) |
SUB | 0x03 | Pop b, pop a, push a − b (wrapping) |
PUSH1…PUSH32 | 0x60…0x7f | Push a literal of 1…32 immediate bytes |
RETURN | 0xf3 | Pop a word and halt, returning it |
The PUSH family is a range: 0x60 is PUSH1 (one immediate byte follows), 0x61 is PUSH2 (two bytes follow), all the way to 0x7f = PUSH32. Real Ethereum needs 32 variants because a literal can be anywhere from 1 to 32 bytes wide. ethmini collapses this to a single PUSH carrying a full 8-byte word, and reuses the byte 0x67 (which is PUSH8 on real EVM, since 0x60 + 7 = 0x67).
PUSH carries an immediate — so decode must skip it
Section titled “PUSH carries an immediate — so decode must skip it”Almost every opcode is one lonely byte. PUSH is the exception: it is followed by immediate bytes — the literal value to push, sitting inline in the code stream. That single fact shapes how decoding works.
Consider PUSH 2 in ethmini’s encoding: the byte 0x67, then eight bytes for the number 2.
byte offset: 0 1 2 3 4 5 6 7 8 9content: 0x67 00 00 00 00 00 00 00 02 (next opcode) ^PUSH8 └──────── immediate = 2 ─────────┘If the interpreter naively advanced one byte at a time, at offset 1 it would try to interpret 0x00 as an opcode (STOP!) and derail completely. So decode must return the position of the next instruction, not just the opcode it read. For a plain one-byte op that is pc + 1; for PUSH8 it is pc + 9 (the opcode plus its eight immediate bytes). The interpreter loop uses that returned position to step forward correctly and never wanders into a PUSH’s payload.
/// Decode one instruction starting at `pc`. Returns the op AND the position/// of the *next* instruction, so the interpreter never steps into a PUSH's/// immediate data by accident.pub fn decode(code: &[u8], pc: usize) -> Result<(Op, usize), VmError> { let opcode = code[pc]; match opcode { OP_PUSH8 => { let start = pc + 1; let end = start + 8; // 8 immediate bytes // ... read the 8 bytes as a big-endian u64 ... Ok((Op::Push(value), end)) // next instruction is at `end` } OP_ADD => Ok((Op::Add, pc + 1)), // one-byte op: next is pc + 1 // ... other => Err(VmError::InvalidOpcode(other)), }}This “return the next position” trick is also what makes jump-destination analysis possible — a scan that walks the code opcode by opcode, so it can tell a real instruction from a byte that merely looks like one but is buried inside a PUSH immediate. We rely on that when we reach jumps in Where Data Lives, and it is the reason you cannot smuggle a jump target into the middle of a literal.
Arithmetic wraps — overflow is defined, not a crash
Section titled “Arithmetic wraps — overflow is defined, not a crash”What happens when you add 1 to the largest word? On most CPUs in a normal program this is either undefined behavior or a trapped exception. On the EVM it is defined: arithmetic is done modulo 2^256, so the largest word plus one wraps around to zero. In ethmini, with its 64-bit words, the same law holds modulo 2^64:
// u64::MAX + 1 wraps to 0, just as real EVM wraps mod 2^256.let code = assemble(&[Op::Push(u64::MAX), Op::Push(1), Op::Add, Op::Return]);let exec = run(&code, &empty(), 1000).unwrap();assert_eq!(exec.return_value, Some(0));That is why the crate uses Rust’s wrapping_add, wrapping_sub, and wrapping_mul rather than the checked or panicking variants. This is not sloppiness — it is a consensus requirement. Here is the throughline in one sentence: the EVM runs untrusted code submitted by strangers. If overflow were undefined, two node implementations might do two different things on the same input, and the network would split. If overflow panicked the process, an attacker could crash every node by deploying one malicious contract. By defining overflow as silent wraparound, the spec guarantees that every node computes the identical result on every input, including adversarial ones — which is the whole point of the machine, revisited on Why Every Node Computes the Same Result.
The practical consequence bites application developers: at the Solidity level, a + b overflowing used to wrap silently, which caused real losses (see below). Modern Solidity inserts overflow checks above the opcodes — the wrapping opcode is still there underneath; the compiler just adds a comparison that reverts if it would wrap.
Bounds and clean halts
Section titled “Bounds and clean halts”Two more rules complete the model, and both exist to keep untrusted code from running away with the machine.
The stack is bounded to 1024 words. You cannot push forever. ethmini mirrors this exactly with STACK_LIMIT = 1024; a push onto a full stack is a StackOverflow error, not an out-of-memory crash. The bound means a node can size the stack up front and know a hostile program can never exhaust its RAM by pushing. (Depth also runs into gas long before it becomes a memory problem — a topic for Gas Metering.)
There are exactly two clean ways to stop. A program halts successfully only via:
STOP(0x00) — finish, commit any state changes, return nothing.RETURN(0xf3) — pop one word and finish, handing that value back to the caller.
Running off the end of the code counts as an implicit STOP. Any other way to stop — running out of gas, underflowing the stack, hitting an invalid opcode — is an exceptional halt that reverts all state changes rather than committing them. ethmini models the clean halts as early returns from its interpreter loop and the exceptional halts as Err(VmError::…) values, which the caller uses to throw away the working copy of state. That revert-on-failure discipline is a whole story of its own; here we just note that the machine has a small, sharp set of exits and never simply “gives up in the middle.”
halt outcomes ------------- STOP / RETURN / fell off end ─► SUCCESS (commit state, optional return value) OutOfGas / StackUnderflow / StackOverflow / InvalidOpcode ─► EXCEPTIONAL HALT (revert state, transaction still mined)The architect’s lens
Section titled “The architect’s lens”- Why does it exist? A world computer needs a computational model that thousands of independent, mutually distrusting nodes can implement identically. A register-free stack machine over fixed-width words is about the smallest, least-ambiguous such model — small enough that “the same result” is easy to specify and verify.
- What problem does it solve? It lets strangers run each other’s untrusted code without any node being able to crash, diverge, or gain an advantage. Defined wraparound arithmetic and a bounded stack mean every input — including malicious ones — produces one agreed-upon outcome.
- What are the trade-offs? Simplicity for the spec costs efficiency for the program: no registers means more stack shuffling, and 256-bit words are wasteful for small numbers. You trade raw performance and expressiveness for verifiability and determinism.
- When should I avoid it? When you need heavy, cheap computation, the EVM is the wrong tool — every opcode is metered and every node re-runs it, so it is thousands of times more expensive than an ordinary CPU. Do the compute off-chain; put only the agreement on-chain.
- What breaks if I remove it? Remove the shared, deterministic instruction set and nodes can no longer agree on what a contract does. State roots diverge, consensus fails, and “the shared world computer” stops being shared — it becomes a pile of computers that happen to disagree.
Check your understanding
Section titled “Check your understanding”- A stack machine’s
ADDopcode names no operands. Where do its two inputs come from, and where does its result go? - Trace
(2 + 3) * 4on the stack, one opcode at a time. Why do the parentheses never appear in the bytecode? - Why were 256-bit words chosen, and what two everyday Ethereum quantities each fit in exactly one word? What does
ethminiuse instead, and why is that a safe simplification? decodereturns both an opcode and the position of the next instruction. Why is that second value essential — what goes wrong if the interpreter just advances one byte at a time?- Adding 1 to the maximum word gives 0 rather than an error. Explain why defined wraparound (not a panic, not undefined behavior) is required for the machine to run untrusted code.
Show answers
- Both inputs are popped from the top of the LIFO stack (it pops
b, thena), and the single result (a + b) is pushed back onto the top. The opcode carries no operand addresses at all — the stack is the operand source and destination. PUSH 2→[2];PUSH 3→[2,3];ADD→[5];PUSH 4→[5,4];MUL→[20];RETURNhands back20. Parentheses never appear because precedence is encoded in the order of the opcodes:ADDis emitted beforeMUL, so the addition happens first. A compiler flattens the nested expression into this linear order.- 256 bits was chosen so a Keccak-256 hash (256 bits) and a full account balance in wei each fit in a single, indivisible stack slot, keeping the instruction set free of multi-word arithmetic.
ethminiuses 64-bitu64words; only the width shrinks — the stack discipline, opcode values, and wrapping arithmetic are unchanged, so the machine’s shape is faithful. PUSHis followed by inline immediate bytes (the literal value). If the interpreter advanced one byte at a time, it would try to execute those immediate bytes as opcodes and derail. Returning the next instruction’s position lets the loop step over the immediate —pc + 9forPUSH8,pc + 1for a one-byte op.- The EVM runs untrusted code from strangers, and every node must compute the identical result. Undefined behavior would let two implementations diverge (splitting the network); a panic would let one malicious contract crash every node. Defined modular wraparound guarantees one agreed outcome on every input, adversarial ones included — safety must then be added above the opcodes (as post-0.8.0 Solidity does).