Deep EVM #1: How the EVM Executes Your Code — Opcodes, Stack, and Gas
Engineering Team
The EVM Is a Stack Machine
The Ethereum Virtual Machine is not like the x86 processor in your laptop. It has no registers. Instead, it is a stack machine — every computation pushes to or pops from a 1024-element stack where each element is a 256-bit (32-byte) word.
When you call a smart contract, the EVM receives the contract’s bytecode — a flat sequence of single-byte opcodes — and begins executing from byte 0. There is no function table, no ELF header, no linking step. The bytecode is the program.
// Solidity:
// uint256 result = 2 + 3;
// Compiles to bytecode:
// PUSH1 0x02 PUSH1 0x03 ADD
// Stack trace:
// [] -> PUSH1 0x02 -> [2]
// [2] -> PUSH1 0x03 -> [2, 3]
// [2, 3] -> ADD -> [5]
Every opcode consumes its operands from the top of the stack and pushes its result back. The ADD opcode pops two values, adds them, and pushes the sum. This is fundamentally different from register-based architectures where you specify source and destination registers.
Opcode Categories
The EVM defines roughly 140 opcodes, grouped into functional categories:
Arithmetic and Comparison
- ADD, SUB, MUL, DIV, MOD — Basic 256-bit integer arithmetic. All cost 3 gas (the G_verylow tier).
- SDIV, SMOD — Signed division and modulo using two’s complement.
- ADDMOD, MULMOD — Modular arithmetic:
(a + b) % Nand(a * b) % Nin a single opcode. These are critical for elliptic curve operations and cost 8 gas. - EXP — Exponentiation. Costs 10 gas + 50 per byte in the exponent, making it one of the more expensive arithmetic opcodes.
- LT, GT, SLT, SGT, EQ, ISZERO — Comparison opcodes that push 1 (true) or 0 (false).
Bitwise Operations
- AND, OR, XOR, NOT — Bitwise logic, 3 gas each.
- SHL, SHR, SAR — Shift left, logical shift right, arithmetic shift right (added in Constantinople, EIP-145). Before these existed, shifts required MUL/DIV by powers of 2.
- BYTE — Extract a single byte from a 32-byte word.
BYTE(0, x)returns the most significant byte.
Stack Manipulation
- POP — Discard the top element.
- PUSH1 through PUSH32 — Push 1 to 32 bytes of immediate data onto the stack. PUSH1 is the most common opcode in deployed bytecode.
- DUP1 through DUP16 — Duplicate the Nth stack element to the top.
- SWAP1 through SWAP16 — Swap the top element with the Nth element below it.
Environment and Block Information
- CALLER (msg.sender), CALLVALUE (msg.value), CALLDATALOAD, CALLDATASIZE, CALLDATACOPY — Access transaction context.
- NUMBER, TIMESTAMP, BASEFEE, CHAINID — Block-level information.
- BALANCE, EXTCODESIZE, EXTCODECOPY — Query other accounts.
The Gas Schedule
Every opcode has a gas cost. Gas serves two purposes: it prevents infinite loops (the halting problem) and it prices computational resources fairly.
The gas costs fall into tiers:
| Tier | Gas | Examples |
|---|---|---|
| Zero | 0 | STOP, RETURN, REVERT |
| Base | 2 | ADDRESS, ORIGIN, CALLER |
| Very Low | 3 | ADD, SUB, LT, GT, AND, OR, POP |
| Low | 5 | MUL, DIV, MOD |
| Mid | 8 | ADDMOD, MULMOD, JUMP |
| High | 10 | JUMPI |
| Special | varies | SLOAD, SSTORE, CALL, CREATE |
The expensive opcodes are the ones that touch state:
// Gas costs for state access (post EIP-2929):
// SLOAD (cold): 2100 gas
// SLOAD (warm): 100 gas
// SSTORE (cold, 0->nonzero): 22100 gas
// SSTORE (warm): 100 gas (+ 20000 if 0->nonzero)
// CALL (cold): 2600 gas
// CALL (warm): 100 gas
// BALANCE (cold): 2600 gas
// BALANCE (warm): 100 gas
Cold vs Warm Access (EIP-2929)
EIP-2929 (Berlin upgrade, April 2021) introduced the concept of an access list — a per-transaction set of addresses and storage slots that have been touched.
The first time you access a storage slot or external address within a transaction, it is “cold” and costs extra gas. Subsequent accesses are “warm” and cheap. This is why the order you read storage slots matters for gas optimization.
// In Solidity, this pattern is expensive:
function bad() external view returns (uint256) {
// First read of slot: 2100 gas (cold)
uint256 a = myStorage;
// ... some logic ...
// Second read: 100 gas (warm) -- compiler may or may not cache this
uint256 b = myStorage;
return a + b;
}
// Cache in memory:
function good() external view returns (uint256) {
uint256 cached = myStorage; // 2100 gas (cold), only once
return cached + cached; // 6 gas (ADD + DUP)
}
Execution Flow: What Happens in a Transaction
When you send a transaction that calls a contract, here is the full execution sequence:
- Transaction validation — Nonce check, balance >= value + gas * gasPrice, signature verification.
- Intrinsic gas deduction — 21000 gas for the transaction itself, plus 16 gas per non-zero calldata byte and 4 per zero byte.
- Context setup — The EVM creates an execution context: code, calldata, caller, value, gas remaining.
- Program counter starts at 0 — The EVM reads the opcode at position 0 and executes it.
- Sequential execution — Each opcode is executed, gas is deducted. JUMP and JUMPI allow non-linear control flow, but only to positions marked with JUMPDEST.
- Termination — Execution ends with STOP (success, no return data), RETURN (success, with return data), REVERT (failure, state reverted, return error data), or running out of gas.
- State commit or rollback — On success, all state changes are committed. On revert, all changes within this call context are rolled back.
The Program Counter and JUMP
The program counter (PC) is an implicit register that tracks the current position in the bytecode. Most opcodes advance the PC by 1 (or by 1 + N for PUSH opcodes). Two opcodes modify the PC directly:
- JUMP — Pops a destination from the stack, sets PC to that value. The destination must contain a JUMPDEST opcode or the transaction reverts.
- JUMPI — Conditional jump. Pops destination and condition. If condition is nonzero, jump; otherwise continue sequentially.
This is how the EVM implements if/else, loops, and function dispatch. The Solidity compiler generates a function selector that loads the first 4 bytes of calldata, compares against known function signatures, and JUMPIs to the matching code block.
// Function dispatch (simplified bytecode):
// CALLDATALOAD(0) -> SHR(224) -> function_selector
// DUP1 PUSH4 0xa9059cbb EQ PUSH2 0x00a4 JUMPI // transfer(address,uint256)
// DUP1 PUSH4 0x70a08231 EQ PUSH2 0x00d2 JUMPI // balanceOf(address)
// PUSH1 0x00 DUP1 REVERT // fallback: revert
Sub-calls: CALL, STATICCALL, DELEGATECALL
Contracts can invoke other contracts using three call opcodes:
- CALL — Standard call. Creates a new execution context with its own stack and memory. The callee runs independently; if it reverts, only its changes are rolled back.
- STATICCALL — Read-only call (EIP-214). Any state-modifying opcode (SSTORE, CREATE, LOG, SELFDESTRUCT) within the callee causes an immediate revert.
- DELEGATECALL — Executes the callee’s code but in the caller’s storage context. msg.sender and msg.value are preserved from the original call. This is how proxy patterns and libraries work.
Each call opcode takes 7 stack arguments: gas, address, value (except STATICCALL/DELEGATECALL), argsOffset, argsLength, retOffset, retLength. The gas cost is 100 (warm) or 2600 (cold) plus any value transfer surcharge.
Gas Refunds and Their Limits
Historically, clearing a storage slot from nonzero to zero gave a gas refund — incentivizing state cleanup. Post EIP-3529 (London upgrade), the maximum refund is capped at 20% of total gas used (down from 50%). This killed the gas token arbitrage (CHI, GST2) that exploited refunds for profit.
The remaining refund sources:
- SSTORE: setting a nonzero slot back to zero refunds 4800 gas.
- SELFDESTRUCT: removed as a refund source in EIP-3529.
Practical Implications for MEV
If you are building MEV bots, understanding the EVM at the opcode level is not optional — it is a competitive requirement. Every gas unit saved in your bot’s execution is profit margin. Key insights:
- Simulate before submitting — Use
eth_callor a local EVM (revm, EVMONE) to trace execution and know the exact gas cost before your bundle hits the builder. - Minimize cold access — Pre-warm storage slots via access lists (EIP-2930).
- Use STATICCALL for reads — Slightly cheaper and guarantees no state mutation.
- Know your opcode costs — A single misplaced SLOAD can cost 2100 gas; in a competitive MEV environment, that’s the difference between profit and loss.
Conclusion
The EVM is elegant in its simplicity: a stack machine with 256-bit words, a flat bytecode format, and a gas metering system that prices every operation. Understanding this foundation — opcodes, the stack, and gas — is the prerequisite for everything that follows in this series: memory layout, storage optimization, security primitives, and finally writing raw Yul and Huff.
In the next article, we will explore the EVM’s four data locations: stack, memory, storage, and calldata — and why choosing the right one determines whether your contract costs $0.50 or $50 to execute.