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) estoragepró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
memoryestorageem Solidity é o erro mais comum de iniciantes — declarar uma variável comostoragedesnecessariamente 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;
}
}
OPCODESmapeia código hex para nome de opcode (a tabela real tem 140+ códigos).decodelê cada byte, procura o nome do opcode; se forPUSH1entã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:
- Verifica se o
nonce,balancesão suficientes para pagargasLimit * gasPrice. - Carrega o
calldataecodedo contrato de destino. - Inicializa stack e memory vazios; lê o
storageatual. - Executa cada opcode sequencialmente conforme o program counter (PC), descontando gas correspondente a cada passo.
- Se encontrar
REVERTou ficar sem gas → desfaz todas as mudanças de estado, mas o gas gasto ainda é retido. - Se bem-sucedido → confirma o novo storage no world state, emite um
receiptcomlogs(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();
}
}
Functiondescreve a funçãocount()a ser chamada e o tipo de dado retornado (Uint256).FunctionEncoder.encodeconverte para calldata hex (function selector + params) — exatamente o dado que a EVM carrega no stack durante a execução.ethCallenvia a requisiçãoeth_call— executa a EVM em modo leitura, sem criar uma transação real, sem gastar gas.FunctionReturnDecoder.decodedecodifica a saída hex retornada paraBigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa
Nota:
eth_callnão escreve estado, então não precisa degasLimitreal e não retornatransactionHash. Se você precisa escrever dados (chamarincrement()), deve usareth_sendRawTransactioncom 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
gasLimitmuito baixo para loops ou múltiplas escritas em storage. Sempre teste cometh_estimateGasantes 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.