Wie funktioniert EVM? Ethereum Virtual Machine Schritt für Schritt

1. Übersicht

EVM (Ethereum Virtual Machine) ist die “CPU”, die alle Smart Contracts auf Ethereum ausführt. Das Verständnis für die Bytecode-Ausführung durch die EVM hilft Ihnen, Transaction Traces zu lesen, out of gas-Fehler zu debuggen und Solidity-Contracts effizienter zu schreiben. Dieser Artikel behandelt das Account-Modell, die Stack-basierte Architektur bis hin zur Gas-Berechnung, mit Solidity- und Java-Beispielen (Web3j), damit Sie den gesamten Ablauf vom Bytecode bis zum Ausführungsergebnis verstehen.

2. EVM – was ist das?

EVM ist eine Stack-basierte, deterministische virtuelle Maschine (gleiche Eingabe führt immer zum gleichen Ergebnis), die auf jedem Ethereum-Node läuft. Jeder Node führt alle Transaktionen in einem Block erneut aus, um sich auf den endgültigen State zu einigen.

Der größte Unterschied zu JVM oder normalen CPUs: EVM hat kein Thread-Konzept, keine I/O-Operationen und kann keine externen APIs aufrufen. Alles, was sie benötigt (Daten, Guthaben, Code anderer Contracts), befindet sich im world state – ein Merkle Patricia Trie, der alle Accounts speichert.

3. Account-Modell

Ethereum hat zwei Arten von Accounts:

  • EOA (Externally Owned Account): wird durch einen privaten Schlüssel kontrolliert, hat keinen Code.
  • Contract Account: hat code (EVM-Bytecode) und eigenen storage.
Account {
  nonce:   Anzahl der versendeten Transaktionen (EOA) oder erzeugter Contracts
  balance: ETH-Guthaben (in Wei)
  storage: Merkle Trie mit 256-Bit-Schlüssel-Wert-Paaren (nur Contract Account)
  codeHash: Hash des Bytecode (nur Contract Account)
}

Wenn Sie eine Transaktion an eine Adresse mit Code senden, lädt die EVM diesen code und beginnt mit der Ausführung – genau hier kommt die “virtuelle Maschine” zum Einsatz.

4. Architektur der virtuellen Maschine: Stack, Memory, Storage, Calldata

Die EVM hat vier Hauptdatenbereiche, die sich in Gas-Kosten und Gültigkeitsdauer unterscheiden.

4.1. Stack

Der Stack enthält 256-Bit-Wörter, maximal 1024 Elemente. Alle Operationen (ADD, MUL, SUB …) entnehmen Operanden aus dem Stack und schieben das Ergebnis zurück auf den Stack.

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

Diese drei Opcode-Zeilen führen 5 + 3 durch: Zwei PUSH1 schieben die Zahlen 5 und 3 auf den Stack, ADD nimmt die beiden obersten Elemente, addiert sie und schiebt das Ergebnis 8 zurück.

4.2. Memory

Memory ist ein temporärer Byte-Array-Bereich, der nur während der Ausführung eines Aufrufs (call) existiert. Er wird für Daten wie Structs und Arrays verwendet, bevor diese zurückgegeben oder im Storage gespeichert werden.

4.3. Storage

Storage ist ein persistentes Schlüssel-Wert-Verzeichnis (uint256 -> uint256), das an jeden Contract gebunden ist und in den State-Trie der Blockchain geschrieben wird. Dies ist der teuerste Bereich, da jede Änderung abgestimmt und dauerhaft gespeichert werden muss.

4.4. Calldata

Calldata sind die Eingabedaten der Transaktion – schreibgeschützt, es entstehen keine Gas-Kosten wie bei der Speicherzuteilung im Memory.

Hinweis: Die Verwechslung zwischen memory und storage in Solidity ist der häufigste Fehler von Anfängern – wenn Variablen unnötig als storage deklariert werden, explodieren die Gas-Kosten.

5. Bytecode und Opcode

Solidity wird zu Bytecode kompiliert – eine Hexadezimalzeichenfolge. Jedes Byte (oder ein Byte plus Operand) entspricht einem Opcode – eine Low-Level-Anweisung, die die EVM direkt versteht.

Beispiel-Contract, der zwei Zahlen addiert und das Ergebnis speichert:

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

contract Counter {
    uint256 public count;

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

Mit solc kompiliert:

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

Diese Opcode-Folge liest count aus dem Storage (SLOAD), addiert 1 (ADD) und schreibt es zurück (SSTORE) – genau wie die Logik von count += 1.

Um zu sehen, wie die EVM Bytecode in Opcodes dekodiert, hier ein einfacher Java-Decoder (ohne echten Node, nur zur Veranschaulichung):

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 hat 1 Byte Operand, das folgt, daher muss übersprungen werden
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODES ordnet Hex-Codes Opcode-Namen zu (die vollständige Tabelle hat über 140 Codes).
  • decode liest jedes Byte, schlägt den Opcode-Namen auf; wenn es PUSH1 ist, wird 1 Byte übersprungen, da dieser Opcode einen Operand hat.
  • Das Ergebnis ist eine Liste von Opcodes in der Reihenfolge, die die EVM ausführt.

Test mit JUnit 5 + AssertJ:

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

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

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

Dieser Test bestätigt, dass der Decoder die richtige Opcode-Sequenz aus dem Roh-Bytecode liest – das ist genau der erste Schritt, den die EVM macht, bevor sie jede Anweisung ausführt.

6. Gas: die Einheit für Ausführungskosten

Jeder Opcode hat feste Gas-Kosten (nach den aktuellen EIP-Regeln), die die Last auf CPU/Storage des Nodes widerspiegeln. Eine Transaktion muss mit gasLimit versehen sein; wird sie überschritten, setzt die EVM alle State-Änderungen zurück, aber das verbrauchte Gas geht verloren (verhindert Spam).

ADD      -> 3 gas
SLOAD    -> 2100 gas (kalt) / 100 gas (warm)
SSTORE   -> 20000 gas (neu) / 2900 gas (aktualisiert)
PUSH1    -> 3 gas

Ein kleines Hilfsprogramm, um Gas-Verbrauch einer Opcode-Folge abzuschätzen:

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 addiert die Gas-Kosten jedes Opcodes nach dem Preisschema; das Ergebnis 22106 zeigt, dass allein eine count += 1-Anweisung den Großteil des Gas für SSTORE aufwendet – daher raten Solidity-Entwickler immer, die Anzahl der Storage-Schreibvorgänge zu minimieren.

7. Lebenszyklus der Transaktionsverarbeitung in der EVM

Wenn eine Transaktion zu einem Contract kommt, folgt die EVM dieser Sequenz:

  1. Prüfe nonce, balance – ist genug vorhanden, um gasLimit * gasPrice zu zahlen?
  2. Lade calldata und code des Ziel-Contracts.
  3. Initialisiere leeren Stack und Memory; lese den aktuellen storage.
  4. Führe jeden Opcode nach dem program counter (PC) aus, subtrahiere Gas entsprechend.
  5. Bei REVERT oder Gas-Mangel → rückgängig machen aller State-Änderungen, aber verbrauchtes Gas bleibt weg.
  6. Bei Erfolg → speichere neuen Storage im world state, veröffentliche receipt mit logs (Events).
Transaction
   │
   ▼
Validate nonce & balance
   │
   ▼
Load code + calldata ──► Execute opcodes (PC steigt schrittweise)
   │                            │
   │                      Gas wird je Schritt abgezogen
   │                            │
   ▼                            ▼
 Out of gas / REVERT      Erfolgreich
   │                            │
   ▼                            ▼
Rollback state            Commit state + logs

8. Praktisches Beispiel: Contract aus Spring Boot mit Web3j aufrufen

Um echte Interaktionen mit der EVM (über lokale Nodes wie Ganache/Anvil oder Testnets) durchzuführen, benötigen Sie die Web3j-Bibliothek:

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

Ein Service, der den Wert count aus einem bereitgestellten Contract ausliest:

@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 beschreibt die Funktion count(), die aufgerufen werden soll, und den Rückgabedatentyp (Uint256).
  • FunctionEncoder.encode konvertiert sie in Calldata-Hex (function selector + params) – genau die Daten, die die EVM auf den Stack lädt, wenn sie ausgeführt wird.
  • ethCall sendet eine Anfrage eth_call – führt die EVM im Lesemodus aus, erzeugt keine echte Transaktion, keine Gas-Kosten.
  • FunctionReturnDecoder.decode dekodiert die zurückgegebene Hex-Ausgabe zu BigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

Hinweis: eth_call schreibt keinen State, daher ist kein echter gasLimit erforderlich und es gibt kein transactionHash. Wenn Sie Daten schreiben möchten (rufen Sie increment() auf), müssen Sie eth_sendRawTransaction mit privaten Schlüsseln verwenden – und erst dann entstehen echte Gas-Kosten.

9. Häufige Fehler und worauf Sie achten sollten

  • Out of gas: geschätzter gasLimit zu niedrig für Schleifen oder mehrfache Storage-Schreibvorgänge. Testen Sie immer mit eth_estimateGas, bevor Sie eine echte Transaktion senden.
  • Reentrancy: Die EVM erlaubt Contract A, Contract B aufzurufen, und B, A zurückzurufen, bevor A den State aktualisiert – was zu mehrfachem Abheben führt (DAO-Angriff). Verwenden Sie das checks-effects-interactions-Muster oder den nonReentrant-Modifikator.
  • Stack too deep: Der Solidity-Compiler begrenzt die Anzahl lokaler Variablen wegen des 1024-Slot-Stacks; teilen Sie Funktionen auf, wenn dieser Fehler auftritt.
  • Nicht empfohlen: Führen Sie keine schwere Rechenlast direkt on-chain durch (komplexe Hashing, große Schleifen) – jede Iteration multipliziert die Gas-Kosten, es ist besser, off-chain zu berechnen und das Ergebnis mit Beweis einzureichen, falls nötig.

10. Fazit

EVM ist eine Stack-basierte virtuelle Maschine mit einfachem Befehlssatz, aber streng in Bezug auf Gas und State – jeder Opcode hat einen Preis, jede Storage-Änderung ist dauerhaft und kostspielig. Das Verständnis von Stack/Memory/Storage, die Berechnung von Gas und der Lebenszyklus einer Transaktion helfen Ihnen, effizientere Smart Contracts zu schreiben und Probleme beim Revert zu debuggen. Wenn Sie regelmäßig mit Contracts arbeiten, üben Sie, echte Opcode-Traces mit evm disasm oder dem Debugger in Hardhat/Foundry zu lesen – das ist viel intuitiver, als nur Solidity zu lesen.

evmethereumblockchainsmart-contractsolidity