IAMUVIN

Web3 Development

Understanding Ethereum Virtual Machine: A Developer's Guide

Uvin Vindula·March 25, 2024·11 min read
Share

TL;DR

The Ethereum Virtual Machine is the runtime that executes every smart contract on Ethereum and its L2s. Understanding how it works — the stack-based architecture, the three data locations (memory, storage, calldata), the gas metering system, and the storage slot layout — transforms you from someone who writes Solidity to someone who writes efficient, secure, and debuggable Solidity. I write contracts daily, and every gas optimization technique I use traces back to understanding one thing: what the EVM actually does with my code after the compiler turns it into bytecode. This guide covers the EVM from a developer's perspective, not an academic one. You will understand why uint8 costs more gas than uint256 in some cases, why storage is expensive, how calldata saves you money, and how to use this knowledge to write better contracts.


What the EVM Actually Is

The EVM is a deterministic, stack-based virtual machine that runs on every Ethereum node simultaneously. When you deploy a Solidity contract, the compiler (solc) transforms your human-readable code into bytecode — a sequence of hexadecimal instructions. Every node in the network executes that exact same bytecode and arrives at the exact same result. That determinism is what makes Ethereum work.

I like to think of the EVM as a very expensive calculator with three key properties:

  1. Deterministic. Given the same input and state, it always produces the same output. No randomness, no system calls, no file I/O.
  2. Isolated. Each contract execution runs in its own sandbox. A contract cannot access another contract's storage directly — it has to make an external call.
  3. Metered. Every single operation costs gas. There is no free computation. This is what prevents infinite loops from crashing the network.

The EVM is not a real CPU. It does not run on bare metal. It is a specification — a set of rules that every Ethereum client (Geth, Nethermind, Besu, Erigon) implements. Your contract does not care which client executes it because the result is always identical.

Here is what happens when a user calls one of your contract functions:

User sends transaction
    |
    v
Transaction enters mempool
    |
    v
Validator includes transaction in block
    |
    v
EVM loads contract bytecode from state
    |
    v
EVM executes bytecode opcode by opcode
    |
    v
Gas is deducted per opcode
    |
    v
State changes are committed (or reverted)
    |
    v
Receipt with gas used is returned

Every step in that flow matters. The bytecode loading, the opcode-by-opcode execution, the gas deduction — understanding each one is what separates someone who copies Solidity patterns from someone who designs efficient systems.

Stack-Based Architecture

The EVM is a stack machine, not a register machine. If you have written x86 assembly, forget everything about registers. The EVM has no general-purpose registers. Every computation happens by pushing values onto a stack and popping them off.

The stack is 1,024 items deep. Each item is exactly 256 bits (32 bytes) wide. That 256-bit word size is why uint256 is the native type in Solidity and why smaller types like uint8 or uint128 sometimes cost more gas — the EVM has to mask out the extra bits.

Here is a simple addition — what a + b looks like at the EVM level:

PUSH1 0x03    // Push 3 onto the stack         Stack: [3]
PUSH1 0x05    // Push 5 onto the stack         Stack: [5, 3]
ADD           // Pop two, push sum             Stack: [8]

Three opcodes to add two numbers. The ADD opcode pops the top two items off the stack, adds them, and pushes the result back on. Every arithmetic operation follows this pattern: push operands, execute operation, result lands on top.

The stack has a depth limit of 1,024, but you can only access the top 16 items directly. Opcodes like DUP1 through DUP16 duplicate items, and SWAP1 through SWAP16 swap the top item with deeper ones. This is why deeply nested Solidity expressions sometimes hit the infamous "Stack too deep" compiler error — the compiler cannot juggle enough items within the 16-slot access window.

Stack Access Diagram:

Position 1  (top)    <-- Directly accessible
Position 2           <-- DUP2, SWAP2
Position 3           <-- DUP3, SWAP3
...
Position 16          <-- DUP16, SWAP16
Position 17          <-- CANNOT directly access
...
Position 1024        <-- Maximum depth

When I hit "Stack too deep" in a complex function, I know exactly what is happening: the compiler needs more than 16 local variables alive at the same time. The fix is not to shorten variable names — it is to restructure the logic. Break the function into smaller ones, use structs, or use memory variables to consolidate state.

Memory, Storage, and Calldata

The EVM gives you three places to put data, and choosing the right one is one of the most impactful decisions you make as a Solidity developer. Each has fundamentally different cost characteristics.

Storage

Storage is the EVM's persistent database. It survives between transactions. Every contract has its own storage, organized as a key-value store where both keys and values are 256 bits wide.

Storage is expensive because it modifies the global state that every node must maintain forever. The cost numbers tell the story:

  • SSTORE (cold, zero to non-zero): 22,100 gas — writing to a brand new storage slot
  • SSTORE (cold, non-zero to non-zero): 5,000 gas — modifying an existing slot
  • SSTORE (warm): 100 gas — modifying a slot you already touched in this transaction
  • SLOAD (cold): 2,100 gas — reading a slot for the first time in a transaction
  • SLOAD (warm): 100 gas — reading a slot you already touched

The cold vs warm distinction was introduced in EIP-2929 and it changed how I think about contract design. The first time you read or write a storage slot in a transaction, you pay the cold price. Every subsequent access to that same slot in the same transaction is warm. This means caching a storage value in a local variable is not just a readability preference — it is a 2,000-gas saving per subsequent read.

solidity
// Expensive: 3 cold SLOADs = 6,300 gas
function bad() external view returns (uint256) {
    return balance * balance + balance;
    // SLOAD(balance) + SLOAD(balance) + SLOAD(balance)
    // First is cold (2,100), next two are warm (100 each)
    // Actual: 2,300 gas — but compiler may not optimize this
}

// Cheap: 1 cold SLOAD + memory operations = ~2,200 gas
function good() external view returns (uint256) {
    uint256 _balance = balance;  // One SLOAD
    return _balance * _balance + _balance;  // Stack operations only
}

Memory

Memory is a byte-addressable, volatile scratch space. It exists only during a single external function call and is wiped clean afterward. Memory is linear — it starts at byte 0 and expands as you write to higher addresses.

Here is the critical detail about memory cost: it is not linear. The first 724 bytes are cheap (3 gas per 32-byte word). After that, the cost grows quadratically. This is why dynamically-sized arrays in memory can get expensive fast, and why the Solidity compiler reserves the first 4 slots (128 bytes) for internal bookkeeping:

Memory Layout:

0x00 - 0x3f    Scratch space (used by compiler)
0x40 - 0x5f    Free memory pointer
0x60 - 0x7f    Zero slot
0x80+          Your data starts here

The free memory pointer at 0x40 is something you encounter when writing inline assembly. It tells the compiler where the next available memory slot is. If you corrupt it in assembly, you corrupt every subsequent memory allocation in that function. I have debugged this exact issue more times than I want to admit.

Calldata

Calldata is read-only data attached to a transaction. When someone calls your function with arguments, those arguments arrive in calldata. It is the cheapest data location because you are not writing anything — just reading bytes that already exist in the transaction payload.

Gas cost comparison per 32-byte word:

Calldata read:     ~3 gas (CALLDATALOAD)
Memory read:       ~3 gas (MLOAD)
Memory write:      ~3 gas (MSTORE)  + expansion cost
Storage read:      2,100 gas cold / 100 gas warm (SLOAD)
Storage write:     22,100 gas cold / 5,000 gas warm (SSTORE)

This is why I always use calldata instead of memory for function parameters that I do not need to modify:

solidity
// Costs more: copies array from calldata into memory
function bad(uint256[] memory data) external { ... }

// Costs less: reads directly from calldata
function good(uint256[] calldata data) external { ... }

The savings scale with the size of the data. For a 10-element array, switching from memory to calldata saves roughly 600 gas. For a 100-element array, it saves 6,000+ gas. I use calldata by default and only switch to memory when I need to mutate the data in-place.

Gas Metering — Why Costs Vary

Gas is the EVM's pricing mechanism. Every opcode has a gas cost, and those costs reflect the computational and storage resources that opcode consumes on the network.

The gas costs are not arbitrary. They follow a clear hierarchy based on what the operation does to network state:

Operation Type              Gas Cost        Why
------------------------------------------------------------
Arithmetic (ADD, MUL)       3-5 gas         Pure computation, no state
Stack operations (POP)      2 gas           Trivial manipulation
Memory access (MLOAD)       3 gas           Volatile, local
SHA3 / KECCAK256            30 + 6/word     CPU-intensive hashing
Balance check (BALANCE)     2,600 gas       Cross-account state read
Log emission (LOG1)         750 gas         Permanent receipt storage
Storage read (SLOAD)        2,100 gas       Global state read
Storage write (SSTORE)      5,000-22,100    Global state modification
Contract creation (CREATE)  32,000 gas      Deploying new bytecode
External call (CALL)        2,600+ gas      Cross-contract interaction
SELFDESTRUCT               5,000 gas        State cleanup

The 1,000x difference between an ADD (5 gas) and an SSTORE (5,000-22,100 gas) is the single most important thing to internalize. Every time I review a contract, I look at hot paths and count how many storage operations happen. If a function that gets called frequently has unnecessary SSTOREs, that is the first thing I optimize.

Gas also has a concept of gas refunds. When you clear a storage slot (set it from non-zero to zero), you get a partial refund — up to 20% of the total transaction gas (post EIP-3529). This is why patterns like clearing mappings or resetting state variables actually give you gas back.

The 21,000 base gas that every transaction costs covers the intrinsic cost of signature verification, nonce checking, and transaction data processing. On top of that, you pay 16 gas per non-zero byte and 4 gas per zero byte of calldata. This is why shorter function signatures and zero-heavy calldata cost less — and why some developers obsess over function selector ordering.

Bytecode and Opcodes

When you compile Solidity, the output is two pieces of bytecode:

  1. Creation bytecode — runs once during deployment. It contains your constructor logic and copies the runtime bytecode to the blockchain.
  2. Runtime bytecode — the permanent code stored on-chain. This is what executes every time someone calls your contract.

Here is a minimal Solidity contract and its compiled opcodes:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Store {
    uint256 public value;

    function set(uint256 _value) external {
        value = _value;
    }
}

The runtime bytecode for the set function compiles down to something like:

PUSH1 0x04       // Check calldata length >= 4 bytes
CALLDATASIZE     // Get size of calldata
LT               // Compare
PUSH1 0x__       // Jump destination for fallback
JUMPI            // Jump if calldata too short

PUSH1 0x00       // Load first 4 bytes of calldata
CALLDATALOAD     // This is the function selector
PUSH4 0x60fe47b1 // Selector for set(uint256) = keccak256("set(uint256)")[:4]
EQ               // Compare
PUSH1 0x__       // Jump to set() implementation
JUMPI            // Jump if selectors match

// Inside set() implementation:
PUSH1 0x04       // Offset: skip 4-byte selector
CALLDATALOAD     // Load the uint256 argument
PUSH1 0x00       // Storage slot 0 (where 'value' lives)
SSTORE           // Write argument to storage
STOP             // End execution

The function selector mechanism is worth understanding. Every public or external function in Solidity gets a 4-byte selector computed as the first 4 bytes of keccak256("functionName(paramTypes)"). When the EVM receives calldata, it reads the first 4 bytes and compares them against each selector using a series of EQ and JUMPI opcodes. This is a linear scan — which is why contracts with many functions pay slightly more gas on the functions that appear later in the selector comparison chain.

I regularly use cast disassemble (from Foundry) to inspect bytecode when debugging unexpected gas costs. Seeing the raw opcodes often reveals what the compiler is doing behind the scenes — and sometimes it is doing more than you expect.

How Smart Contracts Execute

When the EVM executes a contract call, it follows a precise sequence that every developer should understand:

Step 1: Context Setup. The EVM creates an execution context with the transaction's sender (msg.sender), value (msg.value), gas limit, and calldata. It also loads the contract's bytecode from world state.

Step 2: Program Counter Starts at 0. The EVM reads the opcode at position 0 of the bytecode and begins executing. The program counter increments after each opcode (or jumps to a new position on JUMP / JUMPI).

Step 3: Opcode-by-Opcode Execution. Each opcode is executed, gas is deducted, and the stack/memory/storage is modified accordingly. If gas runs out at any point, execution reverts immediately.

Step 4: State Commitment or Revert. If execution completes successfully, all state changes (storage writes, ETH transfers, log emissions) are committed. If it reverts (via REVERT opcode, out of gas, or invalid opcode), all state changes in that context are rolled back — but gas is still consumed.

Execution Flow:

Transaction arrives
    |
    v
Create execution context
    |
    v
+---> Read opcode at program counter
|        |
|        v
|     Deduct gas for opcode
|        |
|        v
|     Execute opcode (modify stack/memory/storage)
|        |
|        v
|     Increment program counter
|        |
|        v
|     More opcodes?  ---YES---> (loop back)
|        |
|        NO
|        v
|     STOP / RETURN / REVERT
|        |
|        v
+-- Commit state changes (or rollback on revert)

One thing that tripped me up early: reverts within sub-calls do not always revert the parent. If you use a low-level call() that returns false, the parent continues executing. Only require(), revert(), and assert() in the current context cause the current execution to revert. This is why unchecked return values on external calls are a top security vulnerability.

Storage Layout and Slot Packing

Understanding storage layout is where EVM knowledge pays off the most. Solidity assigns storage slots sequentially starting from slot 0. Each slot is 32 bytes. The compiler packs smaller types into a single slot when they are declared adjacently.

solidity
contract Layout {
    uint256 a;      // Slot 0  (32 bytes, fills entire slot)
    uint256 b;      // Slot 1  (32 bytes, fills entire slot)
    uint128 c;      // Slot 2  (16 bytes, left side)
    uint64 d;       // Slot 2  (8 bytes, packed next to c)
    uint64 e;       // Slot 2  (8 bytes, packed next to d — slot full)
    uint256 f;      // Slot 3  (32 bytes, new slot — won't fit in slot 2)
    address g;      // Slot 4  (20 bytes)
    bool h;         // Slot 4  (1 byte, packed next to g)
    uint8 i;        // Slot 4  (1 byte, packed next to h)
}
Storage Slot Layout:

Slot 0: [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa]
Slot 1: [bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb]
Slot 2: [eeeeeeeeeeeeeeee dddddddddddddddd cccccccccccccccccccccccccccccccc]
Slot 3: [ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff]
Slot 4: [000000000000000000 ii hh gggggggggggggggggggggggggggggggggggggggg]

Each character represents one hex digit (4 bits).
Each slot is 32 bytes = 64 hex characters.

The packing rules are straightforward: Solidity fills a slot from right to left (low-order bytes first). If the next variable fits in the remaining space, it gets packed. If not, a new slot starts. This is why declaration order matters — rearranging your state variables can save or waste entire storage slots.

For mappings and dynamic arrays, the compiler uses keccak256 to compute slot positions. A mapping value at key k in a mapping declared at slot p lives at keccak256(k . p) where . is concatenation. A dynamic array declared at slot p stores its length at slot p, and element i lives at keccak256(p) + i.

Dynamic Storage Layout:

mapping(address => uint256) balances;  // Declared at slot N

balances[0xABC...] lives at: keccak256(0xABC... . N)
balances[0xDEF...] lives at: keccak256(0xDEF... . N)

uint256[] items;  // Declared at slot M

items.length lives at: slot M
items[0] lives at: keccak256(M) + 0
items[1] lives at: keccak256(M) + 1
items[2] lives at: keccak256(M) + 2

Knowing this layout is not academic. When I use forge inspect Contract storage-layout, I verify that my packing assumptions are correct. When I debug with cast storage to read raw slots on-chain, I know exactly which slot to query. And when I audit contracts for storage collision vulnerabilities in proxy patterns, this knowledge is the difference between catching a critical bug and missing it.

Why This Matters for Gas Optimization

Every gas optimization technique I use traces back to the EVM mechanics covered above. Here is how the knowledge connects to real savings:

Storage Packing (saves 20,000+ gas per slot reduced). Knowing that adjacent small variables share slots means I can reduce a 5-slot struct to 3 slots by reordering variables. That is 2 fewer SSTORE operations on writes and 2 fewer SLOADs on reads.

Calldata over Memory (saves 600-6,000+ gas). Understanding that calldata is read-only transaction data means I know it is safe and cheap to reference directly. No copying needed for parameters I will not modify.

Caching Storage Reads (saves 2,000 gas per cached variable). Knowing the cold/warm SLOAD distinction means I always cache storage variables in local stack variables when I need to read them more than once.

Using `unchecked` for Safe Math (saves 80-150 gas per operation). Understanding that overflow checks compile to additional comparison opcodes means I can skip them when I have already validated the math (like loop counters that are bounded by array length).

Packing Events Efficiently. Knowing that LOG opcodes cost 375 gas base + 375 per indexed topic + 8 gas per byte of data means I think carefully about what to index and how much data to emit.

Short-Circuit Evaluation. Understanding that JUMPI skips the second condition in && and || means I put the cheapest or most likely-to-fail check first.

I go deep on all 30 techniques in my dedicated article on smart contract gas optimization with before/after code and Foundry gas reports for each one.

Debugging with EVM Knowledge

The EVM knowledge I have described is not just about writing contracts — it is about debugging them when things go wrong. Here is how I use it daily:

Reading Revert Data. When a transaction reverts, the EVM returns revert data that includes the error selector and encoded parameters. I use cast run <tx_hash> --trace to replay transactions and see exactly which opcode caused the revert. Knowing that REVERT returns data at a specific memory offset means I can decode custom errors instantly.

Storage Inspection. When a contract behaves unexpectedly, I read raw storage slots with cast storage <address> <slot>. Knowing the storage layout means I can verify that packed variables have the correct values — and catch issues like dirty higher-order bits that should have been masked.

Gas Profiling. Foundry's forge test --gas-report gives me per-function gas breakdowns. When a function costs more than expected, I use forge debug to step through opcodes and identify which operations are expensive. Usually it is an unexpected cold SLOAD or an unnecessary memory expansion.

Trace Analysis. For cross-contract interactions, I use cast run --trace to see the full call tree, including internal calls, delegate calls, and static calls. Understanding the difference between CALL, DELEGATECALL, and STATICCALL at the opcode level means I can immediately spot when a contract is calling another contract's code in its own storage context (delegatecall) versus in the target's context (call).

Common debugging patterns I follow:

  1. Unexpected revert? Check the trace for the failing opcode. Is it an out-of-gas? An invalid JUMP? A failed REQUIRE?
  2. Wrong return value? Inspect memory at the RETURN opcode. Is the ABI encoding correct?
  3. State not updating? Read the storage slot directly. Is the value there but in a packed format you did not expect?
  4. Gas too high? Profile with forge debug. Count the SSTORE and SLOAD operations. Look for unnecessary memory expansion.

Every one of these debugging techniques requires understanding the EVM at the level I have described in this article. You cannot debug bytecode if you do not know what bytecode is.


Key Takeaways

  1. The EVM is a stack-based, deterministic virtual machine. It has no registers. Every computation happens by pushing and popping 256-bit values on a 1,024-deep stack.
  1. Storage is 100-1,000x more expensive than memory or calldata. Design your contracts to minimize storage reads and writes. Cache storage values in local variables. Use calldata for read-only function parameters.
  1. Gas costs reflect resource consumption. Arithmetic is cheap (3-5 gas) because it is pure computation. Storage is expensive (2,100-22,100 gas) because it modifies global state that every node stores forever.
  1. Storage layout matters. Variable declaration order determines slot packing. Rearranging your struct fields can save thousands of gas per transaction.
  1. Bytecode is your contract's reality. Solidity is just an abstraction. Understanding opcodes, function selectors, and the compilation pipeline gives you the ability to debug, optimize, and audit at the deepest level.
  1. Cold vs warm access changes everything. The first storage read in a transaction costs 2,100 gas. Subsequent reads to the same slot cost 100 gas. This distinction drives most of my caching optimizations.
  1. EVM knowledge compounds. Every new concept — stack mechanics, memory expansion costs, storage slot computation for mappings — builds on the previous ones. The investment in learning pays dividends on every contract you write.

If you are building smart contracts and want professional EVM-level optimization for your project, check out my development services. I bring this level of depth to every contract I ship.


*Written by Uvin Vindula — Web3 and AI engineer building production-grade smart contracts and full-stack applications from Sri Lanka and the UK. I write Solidity daily, deploy on Ethereum and L2s through Terra Labz, and share everything I learn on this blog. Follow my work at @IAMUVIN or reach out at contact@uvin.lk.*

Working on a Web3 or AI project?

Share
Uvin Vindula

Uvin Vindula

Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.