How does EVM work? Decoding Ethereum Virtual Machine

1. Overview

EVM (Ethereum Virtual Machine) is the “CPU” that runs every smart contract on Ethereum. Understanding how EVM executes bytecode helps you read transaction traces, debug out of gas errors, and write gas-efficient Solidity contracts. This guide walks through the account model, stack-based architecture, and gas calculation, with Solidity examples and Java code (Web3j) to show the complete flow from bytecode to execution result.

2. What is EVM

EVM is a stack-based, deterministic virtual machine (same input always produces the same output) that runs on every Ethereum node. Each node re-executes all transactions in a block to reach consensus on the final state.

The key difference from JVM or regular CPUs: EVM has no threads, no I/O, and cannot call external APIs. Everything it needs (data, balances, other contracts’ code) lives in the world state — a Merkle Patricia Trie storing all accounts.

3. Account Model

Ethereum has two types of accounts:

  • EOA (Externally Owned Account): controlled by a private key, has no code.
  • Contract Account: has its own code (EVM bytecode) and storage.
Account {
  nonce:    number of transactions sent (EOA) or contracts created
  balance:  ETH balance (in wei)
  storage:  Merkle Trie key-value 256-bit (contract accounts only)
  codeHash: hash of bytecode (contract accounts only)
}

When you send a transaction to an address that has code, EVM loads that code and starts executing — this is when the “virtual machine” springs into action.

4. Virtual Machine Architecture: Stack, Memory, Storage, Calldata

EVM has four main data regions, each with different gas costs and lifespans.

4.1. Stack

Stack holds 256-bit words, maximum 1024 elements. All arithmetic operations (ADD, MUL, SUB…) pop operands from the stack and push results back.

PUSH1 0x05   ; stack: [5]
PUSH1 0x03   ; stack: [5, 3]
ADD          ; stack: [8]

These three opcodes perform 5 + 3: the two PUSH1s push 5 and 3 onto the stack, ADD pops the top two elements, adds them, and pushes the result 8 back.

4.2. Memory

Memory is a temporary byte array that exists only during the execution of a call. It’s used for large data like structs and arrays before returning or writing to storage.

4.3. Storage

Storage is persistent key-value (uint256 -> uint256), tied to each contract, written to the blockchain’s state trie. It’s the most gas-expensive region because it must reach consensus and is stored permanently.

4.4. Calldata

Calldata is the transaction’s input data — read-only, costs no gas to “allocate” unlike memory.

Note: confusing memory and storage in Solidity is the most common mistake for newcomers — unnecessarily declaring a variable as storage causes gas costs to spike.

5. Bytecode and Opcodes

Solidity compiles to bytecode, a hex string. Each byte (or byte + operand) corresponds to an opcode — a low-level instruction that EVM directly understands.

Example contract that increments a counter and stores it:

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

contract Counter {
    uint256 public count;

    function increment() external {
        count += 1;
    }
}

Compile with solc:

solc --bin --asm Counter.sol
PUSH1 0x01
PUSH1 0x00
DUP2
SLOAD
ADD
SWAP1
SSTORE

This opcode sequence reads count from storage (SLOAD), adds 1 to it (ADD), then writes it back (SSTORE) — matching the logic of count += 1.

To see how EVM decodes bytecode into opcodes, here’s a simple Java decoder (no real node needed, just illustrating the mechanism):

public class OpcodeDecoder {

    private static final Map<Integer, String> OPCODES = Map.of(
        0x00, "STOP",
        0x01, "ADD",
        0x54, "SLOAD",
        0x55, "SSTORE",
        0x60, "PUSH1"
    );

    public List<String> decode(String hexBytecode) {
        byte[] code = HexFormat.of().parseHex(hexBytecode.replace("0x", ""));
        List<String> ops = new ArrayList<>();
        int i = 0;
        while (i < code.length) {
            int opcode = code[i] & 0xFF;
            String name = OPCODES.getOrDefault(opcode, "UNKNOWN_0x" + Integer.toHexString(opcode));
            ops.add(name);
            // PUSH1 has 1 operand byte following, need to skip it
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODES maps hex codes to opcode names (the real table has 140+ codes).
  • decode reads each byte, looks up the opcode name; if it’s PUSH1, it skips 1 more byte because this opcode carries an operand.
  • The result is a list of opcodes in the order EVM will execute them.

Test with JUnit 5 + AssertJ:

@Test
void decode_shouldReturnPushAddSstoreSequence() {
    OpcodeDecoder decoder = new OpcodeDecoder();

    List<String> ops = decoder.decode("0x600101550054");

    assertThat(ops).containsExactly(
        "PUSH1", "ADD", "SSTORE", "SLOAD"
    );
}

This test asserts that the decoder reads the correct opcode sequence from raw bytecode — exactly what EVM does first before executing each instruction.

6. Gas: Unit of Execution Cost

Each opcode has a fixed gas cost (per the current EIP), reflecting the load on a node’s CPU and storage. Transactions must include a gasLimit; if execution exceeds it, EVM reverts all state changes but still deducts the gas spent (preventing spam).

ADD      -> 3 gas
SLOAD    -> 2100 gas (cold) / 100 gas (warm)
SSTORE   -> 20000 gas (create) / 2900 gas (update)
PUSH1    -> 3 gas

A small utility to estimate gas for a sequence of opcodes:

public class GasEstimator {

    private static final Map<String, Long> COST = Map.of(
        "ADD", 3L,
        "PUSH1", 3L,
        "SLOAD", 2100L,
        "SSTORE", 20000L
    );

    public long estimate(List<String> opcodes) {
        return opcodes.stream()
            .mapToLong(op -> COST.getOrDefault(op, 0L))
            .sum();
    }
}
@Test
void estimate_shouldSumGasForCounterIncrement() {
    GasEstimator estimator = new GasEstimator();
    List<String> ops = List.of("PUSH1", "SLOAD", "ADD", "SSTORE");

    long gas = estimator.estimate(ops);

    assertThat(gas).isEqualTo(3 + 2100 + 3 + 20000);
}

estimate sums gas for each opcode per the price table; the result 22106 shows that a single count += 1 instruction already spends most gas on SSTORE — that’s why Solidity devs always advise reducing the number of storage writes.

7. Transaction Lifecycle in EVM

When a transaction reaches a contract, EVM executes in this order:

  1. Check if nonce is valid and balance is enough to pay gasLimit * gasPrice.
  2. Load the target contract’s calldata and code.
  3. Initialize empty stack and memory; read current storage.
  4. Execute each opcode in program counter (PC) order, deducting corresponding gas at each step.
  5. If REVERT or out of gas occurs → undo all state changes, but spent gas is still deducted.
  6. If successful → commit new storage to world state, emit receipt with logs (events).
Transaction
   │
   ▼
Validate nonce & balance
   │
   ▼
Load code + calldata ──► Execute opcodes (PC increases)
   │                            │
   │                      deduct gas per step
   │                            │
   ▼                            ▼
 Out of gas / REVERT      Success
   │                            │
   ▼                            ▼
Rollback state            Commit state + logs

8. Practical Example: Calling Contracts from Spring Boot with Web3j

To interact with EVM in practice (via a local node like Ganache/Anvil or testnet), you need the Web3j library:

<dependency>
    <groupId>org.web3j</groupId>
    <artifactId>core</artifactId>
    <version>4.11.1</version>
</dependency>

A service that reads the count value from a deployed contract:

@Service
public class CounterService {

    private final Web3j web3j;
    private final String contractAddress;

    public CounterService(Web3j web3j,
                           @Value("${evm.counter.address}") String contractAddress) {
        this.web3j = web3j;
        this.contractAddress = contractAddress;
    }

    public BigInteger readCount() throws IOException {
        Function function = new Function(
            "count",
            Collections.emptyList(),
            List.of(new TypeReference<Uint256>() {})
        );
        String encoded = FunctionEncoder.encode(function);

        EthCall response = web3j.ethCall(
            Transaction.createEthCallTransaction(null, contractAddress, encoded),
            DefaultBlockParameterName.LATEST
        ).send();

        List<Type> results = FunctionReturnDecoder.decode(
            response.getValue(), function.getOutputParameters()
        );
        return (BigInteger) results.get(0).getValue();
    }
}
  • Function describes the count() method to call and its return type (Uint256).
  • FunctionEncoder.encode converts it to hex calldata (function selector + params) — exactly what EVM loads onto the stack during execution.
  • ethCall sends an eth_call request — runs EVM in read mode, doesn’t create a real transaction, costs no gas.
  • FunctionReturnDecoder.decode decodes the returned hex output into BigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

Note: eth_call doesn’t modify state, so it doesn’t require an actual gasLimit and doesn’t return a transactionHash. If you need to write data (call increment()), you must use eth_sendRawTransaction with a private key to sign the transaction — and that’s when you actually spend real gas.

9. Common Errors and When to Be Careful

  • Out of gas: gasLimit estimate too low for loops or multiple storage writes. Always test with eth_estimateGas before sending a real transaction.
  • Reentrancy: EVM allows contract A to call contract B, which calls A back before A updates state — leading to funds being withdrawn multiple times (The DAO incident). Use checks-effects-interactions pattern or nonReentrant modifier.
  • Stack too deep: Solidity compiler limits local variables since the stack has only 1024 slots; split functions into smaller ones when encountering this error.
  • Avoid running computationally heavy logic (complex hashing, large loops) directly on-chain — each loop multiplies gas cost, so compute off-chain and submit results with proof if possible.

10. Conclusion

EVM is a virtual machine with simple instructions but strict gas and state rules — every opcode has a cost, every storage change is permanent and expensive. Understanding stack/memory/storage, how gas is calculated, and a transaction’s lifecycle helps you write more efficient smart contracts and debug reverts. If you work with contracts regularly, practice reading real opcode traces using evm disasm or debuggers in Hardhat/Foundry — it’s far more intuitive than just reading Solidity.

evmethereumblockchainsmart-contractsolidity