Como funciona a EVM? Decodificando a Máquina Virtual Ethereum

1. Visão geral

EVM (Ethereum Virtual Machine) é a “CPU” que executa todos os smart contracts na Ethereum. Entender como a EVM executa bytecode ajuda você a ler traces de transação, debugar erros de out of gas e escrever contratos Solidity mais econômicos em relação ao gas. Este artigo vai desde o modelo de conta, arquitetura stack-based, até como o gas é calculado, com exemplos em Solidity e código Java (Web3j) para você ver todo o fluxo do bytecode até o resultado da execução.

2. O que é a EVM

A EVM é uma máquina virtual stack-based, determinística (mesma entrada sempre gera mesma saída), que executa em todos os nós Ethereum. Cada nó reexecuta exatamente as mesmas transações em um bloco para chegar a um consenso sobre o estado final.

A maior diferença em relação à JVM ou CPU comum: a EVM não tem o conceito de thread, não tem I/O, não pode chamar APIs externas. Tudo que ela precisa (dados, saldos, código de outros contratos) fica no world state — uma árvore Merkle Patricia Trie que armazena todas as contas.

3. Modelo de Conta

Ethereum tem dois tipos de conta:

  • EOA (Externally Owned Account): controlada por uma chave privada, não tem código.
  • Contract Account: tem code (bytecode EVM) e storage próprio.
Account {
  nonce:    número de transações enviadas (EOA) ou contratos criados
  balance:  saldo de ETH (em unidade wei)
  storage:  Merkle Trie chave-valor 256-bit (apenas para contract account)
  codeHash: hash do bytecode (apenas para contract account)
}

Quando você envia uma transação para um endereço que tem código, a EVM carrega esse code e começa a executá-lo — este é o momento em que a “máquina virtual” entra em ação.

4. Arquitetura da máquina virtual: Stack, Memory, Storage, Calldata

A EVM tem quatro áreas de dados principais, cada uma com custos de gas e tempos de vida diferentes.

4.1. Stack

O stack armazena palavras de 256 bits, no máximo 1024 elementos. Todas as operações (ADD, MUL, SUB…) pegam operandos do stack e empurram o resultado de volta para o stack.

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

As três linhas de opcode acima realizam 5 + 3: dois PUSH1 empurram os números 5 e 3 para o stack, ADD pega os dois elementos no topo, soma e empurra o resultado 8 de volta.

4.2. Memory

Memory é uma área temporária de byte array, que existe apenas durante a execução de uma chamada (call). É usada para dados grandes como structs, arrays antes de retornar ou escrever no storage.

4.3. Storage

Storage é um mapa chave-valor (uint256 → uint256) persistente, vinculado a cada contrato, escrito na árvore de estado da blockchain. Esta é a área mais cara em gas porque precisa ser consensual e armazenada permanentemente.

4.4. Calldata

Calldata são os dados de entrada da transação — somente leitura, sem custo de gas para “alocar” como memory.

Nota: confundir memory e storage em Solidity é o erro mais comum de iniciantes — declarar uma variável como storage desnecessariamente faz o custo de gas disparar.

5. Bytecode e Opcode

Solidity compila para bytecode, uma sequência de hexadecimais. Cada byte (ou um byte + operando) corresponde a um opcode — uma instrução de baixo nível que a EVM entende diretamente.

Exemplo de um contrato que soma dois números e salva o resultado:

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

contract Counter {
    uint256 public count;

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

Compilando com solc:

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

Esta sequência de opcodes lê count do storage (SLOAD), soma 1 (ADD), e escreve de volta (SSTORE) — exatamente a lógica de count += 1.

Para ver claramente como a EVM decodifica bytecode em opcodes, aqui está um decodificador Java simples (não precisa de um nó real, é apenas para ilustrar o mecanismo):

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 tem 1 byte operando seguindo, então precisa pular
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODES mapeia código hex para nome de opcode (a tabela real tem 140+ códigos).
  • decode lê cada byte, procura o nome do opcode; se for PUSH1 então pula 1 byte adicional porque este opcode vem com um operando.
  • O resultado é uma lista de opcodes na ordem exata em que a EVM executará.

Teste com JUnit 5 + AssertJ:

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

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

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

Este teste confirma que o decodificador lê a sequência correta de opcodes do bytecode bruto — exatamente o primeiro passo que a EVM realiza antes de executar cada instrução.

6. Gas: unidade de medida do custo de execução

Cada opcode tem um custo de gas fixo (conforme o EIP atual), refletindo a carga na CPU/storage do nó. A transação deve vir com um gasLimit; se exceder esse limite, a EVM faz rollback de todas as mudanças de estado mas ainda retém o gas gasto (para evitar spam).

ADD      -> 3 gas
SLOAD    -> 2100 gas (cold) / 100 gas (warm)
SSTORE   -> 20000 gas (novo) / 2900 gas (atualização)
PUSH1    -> 3 gas

Um utilitário pequeno para estimar o gas de uma sequência de 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 soma o gas de cada opcode conforme a tabela de preços; o resultado 22106 mostra que uma única instrução count += 1 gasta a maior parte do gas em SSTORE — por isso dev de Solidity sempre recomendam reduzir o número de escritas em storage.

7. Ciclo de vida da execução de uma transação na EVM

Quando uma transação chega a um contrato, a EVM executa na seguinte sequência:

  1. Verifica se o nonce, balance são suficientes para pagar gasLimit * gasPrice.
  2. Carrega o calldata e code do contrato de destino.
  3. Inicializa stack e memory vazios; lê o storage atual.
  4. Executa cada opcode sequencialmente conforme o program counter (PC), descontando gas correspondente a cada passo.
  5. Se encontrar REVERT ou ficar sem gas → desfaz todas as mudanças de estado, mas o gas gasto ainda é retido.
  6. Se bem-sucedido → confirma o novo storage no world state, emite um receipt com logs (eventos).
Transaction
   │
   ▼
Valida nonce & balance
   │
   ▼
Carrega code + calldata ──► Executa opcodes (PC incrementa)
   │                            │
   │                      desconta gas a cada passo
   │                            │
   ▼                            ▼
 Sem gas / REVERT         Bem-sucedido
   │                            │
   ▼                            ▼
Desfaz estado            Confirma estado + logs

8. Exemplo prático: chamando contrato do Spring Boot com Web3j

Para interagir de verdade com a EVM (via um nó local como Ganache/Anvil ou testnet), você precisa da biblioteca Web3j:

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

Um service para ler o valor de count do contrato já deployed:

@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 descreve a função count() a ser chamada e o tipo de dado retornado (Uint256).
  • FunctionEncoder.encode converte para calldata hex (function selector + params) — exatamente o dado que a EVM carrega no stack durante a execução.
  • ethCall envia a requisição eth_call — executa a EVM em modo leitura, sem criar uma transação real, sem gastar gas.
  • FunctionReturnDecoder.decode decodifica a saída hex retornada para BigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

Nota: eth_call não escreve estado, então não precisa de gasLimit real e não retorna transactionHash. Se você precisa escrever dados (chamar increment()), deve usar eth_sendRawTransaction com a chave privada para assinar a transação — e nesse caso você realmente gasta gas.

9. Erros comuns e quando tomar cuidado

  • Out of gas: estima gasLimit muito baixo para loops ou múltiplas escritas em storage. Sempre teste com eth_estimateGas antes de enviar uma transação real.
  • Reentrancy: a EVM permite que o contrato A chame o contrato B, e B chame A novamente antes de A atualizar o estado — causando resgates de dinheiro múltiplos (o ataque The DAO). Use o padrão checks-effects-interactions ou o modificador nonReentrant.
  • Stack too deep: o compilador Solidity limita o número de variáveis locais por causa do stack que tem apenas 1024 slots; divida a função em funções menores quando encontrar este erro.
  • Não recomendado executar lógica intensiva em cálculos (hash complexo, loops grandes) direto on-chain — cada iteração de loop multiplica o custo de gas, então é melhor fazer off-chain e submeter o resultado com prova se aplicável.

10. Conclusão

A EVM é uma máquina virtual stack-based simples em conjunto de instruções mas rigorosa em gas e estado — cada opcode tem preço, cada mudança de storage é permanente e cara. Entender bem stack/memory/storage, como gas é calculado, e o ciclo de vida de uma transação ajuda você escrever smart contracts mais eficientes e debugar quando transações revert. Se você trabalha com contratos regularmente, pratique ler traces de opcode de verdade com evm disasm ou debugger no Hardhat/Foundry — é muito mais intuitivo do que apenas ler Solidity.

evmethereumblockchainsmart-contractsolidity