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) andstorage.
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
memoryandstoragein Solidity is the most common mistake for newcomers — unnecessarily declaring a variable asstoragecauses 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;
}
}
OPCODESmaps hex codes to opcode names (the real table has 140+ codes).decodereads each byte, looks up the opcode name; if it’sPUSH1, 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:
- Check if
nonceis valid andbalanceis enough to paygasLimit * gasPrice. - Load the target contract’s
calldataandcode. - Initialize empty stack and memory; read current
storage. - Execute each opcode in program counter (PC) order, deducting corresponding gas at each step.
- If
REVERTor out of gas occurs → undo all state changes, but spent gas is still deducted. - If successful → commit new storage to world state, emit
receiptwithlogs(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();
}
}
Functiondescribes thecount()method to call and its return type (Uint256).FunctionEncoder.encodeconverts it to hex calldata (function selector + params) — exactly what EVM loads onto the stack during execution.ethCallsends aneth_callrequest — runs EVM in read mode, doesn’t create a real transaction, costs no gas.FunctionReturnDecoder.decodedecodes the returned hex output intoBigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa
Note:
eth_calldoesn’t modify state, so it doesn’t require an actualgasLimitand doesn’t return atransactionHash. If you need to write data (callincrement()), you must useeth_sendRawTransactionwith 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:
gasLimitestimate too low for loops or multiple storage writes. Always test witheth_estimateGasbefore 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
nonReentrantmodifier. - 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.