The EVM in Depth

Understanding the Ethereum Virtual Machine at the bytecode level


The EVM in Depth

The Ethereum Virtual Machine (EVM) is the runtime environment for smart contracts. Understanding how the EVM works at a low level helps you write more efficient contracts, debug complex issues, and understand security vulnerabilities.

EVM Architecture

The EVM is a stack-based, 256-bit virtual machine. Key characteristics:

  • Stack-based — Operations push and pop values from a stack
  • 256-bit word size — All values are 256 bits (32 bytes)
  • Deterministic — Same input always produces same output
  • Isolated — Contracts can't access other contracts' storage directly
  • Metered — Every operation costs gas

Memory Model

The EVM has three main data locations:

Stack — Temporary values during computation (max 1024 elements)

Memory — Byte-addressable, volatile storage (cleared after execution)

Storage — Persistent key-value store (256-bit keys, 256-bit values)

┌─────────────────────────────────────────────────────────────┐
│                         EVM                                  │
├─────────────────┬─────────────────┬────────────────────────┤
│     Stack       │     Memory      │       Storage          │
│  (1024 x 256)   │  (byte array)   │  (key → value map)     │
│  • Temporary    │  • Volatile     │  • Persistent          │
│  • Fast         │  • Expandable   │  • Expensive           │
│  • Free         │  • Cheap        │  • 20,000 gas to set   │
└─────────────────┴─────────────────┴────────────────────────┘

The Stack

Most EVM operations work on the stack:

PUSH1 0x60    // Push 0x60 onto stack
PUSH1 0x40    // Push 0x40 onto stack
MSTORE        // Store 0x60 at memory location 0x40

Stack before MSTORE: [0x40, 0x60]
Stack after MSTORE:  []

The stack is limited to 1024 elements. Stack overflow causes the transaction to fail.

Memory

Memory is a byte array that expands as needed:

MLOAD   - Load 32 bytes from memory
MSTORE  - Store 32 bytes to memory
MSTORE8 - Store 1 byte to memory

Memory expansion costs gas based on how much is used. The cost grows quadratically with size.

Storage

Storage persists between transactions. It's the most expensive data location:

SLOAD  - Load from storage (2,100 gas cold / 100 gas warm)
SSTORE - Store to storage (20,000 gas new / 5,000 gas update)

Each contract has its own storage space, isolated from other contracts.

EVM Opcodes

Opcodes are the machine instructions of the EVM. Here are the major categories:

Stack Operations

OpcodeDescription
PUSH1-PUSH32Push N bytes onto stack
POPRemove top stack item
DUP1-DUP16Duplicate Nth stack item
SWAP1-SWAP16Swap top with Nth stack item

Arithmetic

OpcodeDescription
ADDAddition
MULMultiplication
SUBSubtraction
DIVInteger division
MODModulo
EXPExponentiation
ADDMODModular addition
MULMODModular multiplication

Comparison & Bitwise

OpcodeDescription
LT, GTLess than, greater than
SLT, SGTSigned comparison
EQEquality
ISZEROCheck if zero
AND, OR, XOR, NOTBitwise operations
SHL, SHR, SARBit shifting

Memory & Storage

OpcodeDescription
MLOADLoad word from memory
MSTOREStore word to memory
MSTORE8Store byte to memory
SLOADLoad word from storage
SSTOREStore word to storage

Contract Interaction

OpcodeDescription
CALLCall another contract
DELEGATECALLCall with current context
STATICCALLRead-only call
CREATEDeploy new contract
CREATE2Deploy with deterministic address
SELFDESTRUCTDestroy contract

Block Information

OpcodeDescription
BLOCKHASHHash of a recent block
COINBASEBlock producer address
TIMESTAMPBlock timestamp
NUMBERBlock number
DIFFICULTY / PREVRANDAOPoW difficulty or PoS randomness
GASLIMITBlock gas limit
The `DIFFICULTY` opcode returns different values on **Ethereum Classic (PoW)** vs **Ethereum (PoS)**. After The Merge, Ethereum renamed it to `PREVRANDAO` and it returns random data from the beacon chain instead of mining difficulty.

Gas Mechanics

Every opcode has a gas cost. Simple operations are cheap, complex operations are expensive:

OperationGas Cost
ADD, SUB3
MUL, DIV5
SLOAD (cold)2,100
SLOAD (warm)100
SSTORE (new)20,000
SSTORE (update)5,000
CALL (cold)2,600
CREATE32,000

Gas Refunds

Some operations provide refunds:

  • Setting storage to zero: 4,800 gas refund
  • SELFDESTRUCT: 24,000 gas refund (deprecated)

EIP-2929 Access Lists

Introduced "cold" and "warm" storage:

  • First access to an address/slot is "cold" (expensive)
  • Subsequent accesses are "warm" (cheaper)
// First access: cold (2,100 gas)
uint256 a = someMapping[key];
 
// Second access: warm (100 gas)
uint256 b = someMapping[key];

Bytecode Analysis

Disassembling Contracts

Use tools to analyze bytecode:

# With cast (Foundry)
cast disassemble 0x608060405234801561001057600080fd5b...
 
# Output:
# 0x0000: PUSH1 0x80
# 0x0002: PUSH1 0x40
# 0x0004: MSTORE
# ...

Contract Creation Code

When you deploy a contract, you actually send "creation code" that returns the "runtime code":

┌────────────────────────────────────────────────────────────┐
│                    Creation Code                           │
│  ┌──────────────────┐  ┌─────────────────────────────────┐ │
│  │  Initialization  │  │       Runtime Code              │ │
│  │  (constructor)   │  │  (actual contract logic)        │ │
│  └──────────────────┘  └─────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
           │                          │
           ▼                          ▼
       Executed once            Stored on-chain

Function Selectors

Functions are identified by the first 4 bytes of their keccak256 hash:

// "transfer(address,uint256)"
keccak256("transfer(address,uint256)") = 0xa9059cbb...
// First 4 bytes: 0xa9059cbb

The runtime code typically starts with a dispatcher:

CALLDATALOAD  // Get first 32 bytes of calldata
PUSH1 0xe0
SHR           // Shift right to get first 4 bytes
DUP1
PUSH4 0xa9059cbb  // transfer selector
EQ
PUSH2 0x0123  // Jump destination
JUMPI         // Jump if match
// ... more function checks

Writing Inline Assembly

Solidity allows inline assembly (Yul) for low-level operations:

function addNumbers(uint256 a, uint256 b) public pure returns (uint256 result) {
    assembly {
        result := add(a, b)
    }
}
 
function efficientCopy(bytes memory data) public pure returns (bytes32) {
    bytes32 result;
    assembly {
        result := mload(add(data, 32))  // Skip length prefix
    }
    return result;
}

When to Use Assembly

✅ Use for:

  • Gas optimization in hot paths
  • Operations not available in Solidity
  • Low-level memory manipulation

❌ Avoid for:

  • Most contract logic (use Solidity)
  • Anything a junior developer needs to maintain
  • Unless you truly need the optimization

Gas Optimization Patterns

Storage Packing

Pack multiple values into single storage slots:

// Bad: Uses 3 storage slots
contract Unpacked {
    uint256 a;  // Slot 0
    uint256 b;  // Slot 1
    uint256 c;  // Slot 2
}
 
// Good: Uses 1 storage slot
contract Packed {
    uint128 a;  // Slot 0 (lower 128 bits)
    uint64 b;   // Slot 0 (next 64 bits)
    uint64 c;   // Slot 0 (upper 64 bits)
}

Caching Storage Reads

// Bad: Multiple SLOAD operations
function sum() public view returns (uint256) {
    return values[0] + values[1] + values[2];  // 3 SLOADs
}
 
// Good: Cache in memory
function sum() public view returns (uint256) {
    uint256 v0 = values[0];  // 1 SLOAD
    uint256 v1 = values[1];  // 1 SLOAD
    uint256 v2 = values[2];  // 1 SLOAD
    return v0 + v1 + v2;     // Operations on memory are cheap
}

Using unchecked for Safe Math

// When you know overflow is impossible
function increment(uint256 i) public pure returns (uint256) {
    unchecked {
        return i + 1;  // Saves ~30 gas by skipping overflow check
    }
}

Short-Circuit Evaluation

// Put cheaper conditions first
if (cheapCheck && expensiveCheck) { ... }
 
// Put likely-false conditions first
if (likely_false || likely_true) { ... }

Debugging at the EVM Level

Using Foundry's Debugger

forge test --debug "testMyFunction"

This opens an interactive debugger showing:

  • Current opcode
  • Stack contents
  • Memory contents
  • Storage changes
  • Gas usage

Trace Analysis

cast run <txhash> --trace

Shows the complete execution trace of a transaction.

Conclusions

Understanding the EVM at the bytecode level helps you:

  • Write more gas-efficient contracts
  • Debug complex issues
  • Understand security vulnerabilities
  • Optimize critical paths

Key takeaways:

  • The EVM is stack-based with 256-bit words
  • Storage is expensive, memory is cheap, stack is free
  • Function calls are dispatched by selector matching
  • Gas costs vary dramatically by operation
  • Use inline assembly sparingly and carefully