EVMの動作原理:Ethereum仮想マシンのステップバイステップ解読

1. 概要

EVM (Ethereum Virtual Machine) はEthereum上のすべてのスマートコントラクトを実行する「CPU」です。EVMがbytecodeをどのように実行するかを理解することで、transaction traceを読み、out of gasエラーをデバッグし、ガス効率の良いSolidityコントラクトを書くことができます。この記事では、アカウントモデル、stack-basedアーキテクチャからガスの計算方法まで、Solidityの例とJavaコード(Web3j)を用いて、bytecodeから実行結果までの全体的な流れを説明します。

2. EVMとは何か

EVMはstack-basedでdeterministicな仮想マシン(同じinputはいつも同じoutputを出します)で、すべてのEthereumノード上で動作します。各ノードはブロック内のトランザクションを正確に再実行し、最終的なstateについて合意に達します。

JVMや通常のCPUとの大きな違いは、EVMはthreadの概念がなく、I/Oがなく、外部APIを呼び出すことができないということです。EVMが必要とするすべてのもの(データ、残高、他のコントラクトのコード)はworld stateに存在します。これはすべてのアカウントを保存するMerkle Patricia Trieツリーです。

3. アカウントモデル

Ethereumには2つのタイプのアカウントがあります:

  • EOA (Externally Owned Account):private keyで制御され、codeを持ちません。
  • Contract Account:code(EVM bytecode)と独自のstorageを持ちます。
Account {
  nonce:   送信されたtransaction数(EOA)またはデプロイされたコントラクト数
  balance: ETH残高(単位wei)
  storage: Merkle Trie key-value 256-bit(contract accountのみ)
  codeHash: bytecodeのhash(contract accountのみ)
}

コードを持つアドレスにトランザクションを送信すると、EVMはそのコードをロードして実行を開始します。これが「仮想マシン」が動作する瞬間です。

4. 仮想マシンのアーキテクチャ:Stack、Memory、Storage、Calldata

EVMには4つの主要なデータ領域があり、それぞれgas cost と存在期間が異なります。

4.1. Stack

Stackは256-bitのwordを格納し、最大1024個の要素を持つことができます。すべての演算(ADD、MUL、SUB…)はstackからoperandを取得し、結果をstackに戻します。

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

上記の3行のopcodeは5 + 3を実行します:2つのPUSH1は5と3をstackに押し込み、ADDは上位2つの要素を取得して加算し、結果8をstackに戻します。

4.2. Memory

Memoryは一時的なbyte array領域で、単一のcall実行中にのみ存在します。structやarrayなどの大きなデータをstorageに書き込む前に返すために使用されます。

4.3. Storage

Storageはpersistentなkey-value (uint256 -> uint256)で、各コントラクトに関連付けられ、blockchainのstate trieに書き込まれます。合意が必要で永続的に保存されるため、最もgas costが高い領域です。

4.4. Calldata

Calldataはトランザクションの入力データです。read-only で、memoryのような「割り当て」によるgas costはかかりません。

注意: Solidityでmemoryとstorageを混同することは初心者が最もよく犯す間違いです。不要なstorage変数の宣言によってgas costが急上昇します。

5. BytecodeとOpcode

Solidityはbytecodeにcompileされます。これはhex形式の文字列です。各byte(またはbyte + operand)はopcodeに対応します。これはEVMが直接理解する低レベルの命令です。

2つの数を足してその結果を保存するコントラクトの例:

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

contract Counter {
    uint256 public count;

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

solcでコンパイルします:

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

このopcodeシーケンスはstorageからcountを読み込み(SLOAD)、1を加え(ADD)、書き戻します(SSTORE)。これはcount += 1のロジックと一致します。

EVMがbytecodeをopcodeにdecodeする方法を明確に見るために、シンプルなJava decoderを書きました(実際のnodeは不要で、メカニズムを示すためです):

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の後に1 byteのoperandがあるため、スキップする必要があります
            i += name.equals("PUSH1") ? 2 : 1;
        }
        return ops;
    }
}
  • OPCODESはhexコードをopcode名にマップします(実際のテーブルには140以上のコードがあります)。
  • decodeは各byteを読み込んで、opcode名を検索します。PUSH1の場合、このopcodeにはoperandが伴うため、追加の1 byteをスキップします。
  • 結果はEVMが実行する正確な順序でのopcodeのリストです。

JUnit 5 + AssertJでテストします:

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

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

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

このテストは、decoderが生のbytecodeから正しい順序のopcodeを読み込むことを確認します。これはEVMが各命令を実行する前に行う最初のステップです。

6. Gas:実行コストの測定単位

各opcodeにはgas costがあり、固定値です(現在のEIPに従う)。これはnode上のCPU/storageの負荷レベルを反映します。transactionはgasLimitを含める必要があります。その制限を超えて実行された場合、EVM すべてのstate changeをrevertしますが、使用されたgasは保持されたままです(spamを防ぐため)。

ADD      -> 3 gas
SLOAD    -> 2100 gas (cold) / 100 gas (warm)
SSTORE   -> 20000 gas (新規) / 2900 gas (更新)
PUSH1    -> 3 gas

opacode列のgas使用量を推定するための小さなutilです:

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はテーブルに従ってopcodeごとのgasを合算します。結果の22106は、単一のcount += 1命令がSSTOREにほとんどのgasを費やしていることを示しています。これが、Solidity開発者が常にstorage書き込み回数を減らすことを勧める理由です。

7. EVM内のトランザクション処理のライフサイクル

transactionがコントラクトに到達すると、EVMは次の順序で実行します:

  1. nonceとbalanceをチェックし、gasLimit * gasPriceを支払うのに十分かどうかを確認します。
  2. ターゲットコントラクトのcalldataとcodeをロードします。
  3. stackとmemoryを初期化(空の状態)し、現在のstorageを読み込みます。
  4. **program counter (PC)**に従って各opcodeを実行し、各ステップで対応するgasを差し引きます。
  5. REVERTまたはgasが不足した場合→すべてのstate変更をロールバックしますが、使用されたgasは差し引かれたままです。
  6. 成功した場合→新しいstorageをworld stateにcommitし、receiptをlogs(event)と共に発行します。
Transaction
   │
   ▼
Validate nonce & balance
   │
   ▼
Load code + calldata ──► Execute opcodes (PC増加)
   │                            │
   │                      各ステップでgas差引
   │                            │
   ▼                            ▼
 Out of gas / REVERT      成功
   │                            │
   ▼                            ▼
Rollback state            Commit state + logs

8. 実践例:Spring BootからWeb3jでコントラクトをCallする

EVM(Ganache/Anvilなどのlocal nodeまたはtestnetを介して)と実際に相互作用するには、Web3jライブラリが必要です:

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

デプロイされたコントラクトからcount値を読み取るサービス:

@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は呼び出すcount()関数と戻り値の型(Uint256)を記述します。
  • FunctionEncoder.encodeはcalldata hex(function selector + params)に変換します。これはEVMが実行時にstackにロードする正確なデータです。
  • ethCallはeth_callリクエストを送信します。EVMの読み取りモードで実行し、実際のtransactionを作成せず、gasを消費しません。
  • FunctionReturnDecoder.decodeは返された出力hexをBigIntegerにdecodeします。
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa

注意: eth_callはstateを書き込まないため、実際のgasLimitは必要なく、transactionHashを返しません。データを書き込む必要がある場合(increment()を呼び出す場合)、private keyでトランザクションに署名するeth_sendRawTransactionを使用する必要があります。そのときだけ、実際のgasが消費されます。

9. よくあるエラーと注意が必要なとき

  • Out of gas:loopまたは複数回のstorage書き込みに対してgasLimitを低く見積もっています。実際のtransactionを送信する前に、常にeth_estimateGasでテストしてください。
  • Reentrancy:EVMはcontract AがContract Bを呼び出し、Bが状態を更新する前にAに呼び戻す(The DAOの事件のように複数回の資金引き出しにつながる)ことを許可します。checks-effects-interactionsパターンまたはnonReentrant modifierを使用してください。
  • Stack too deep:Solidityコンパイラはstackが1024スロットしかないため、local変数の数を制限します。このエラーが発生した場合、関数をより小さく分割してください。
  • Do not 重い計算ロジック(複雑なhash、大きなループ)をon-chainで直接実行します。各ループがgas costを乗算するため、off-chainで計算して、可能であれば結果をproofと共に送信してください。

10. 結論

EVMは命令セット上はシンプルなstack-based仮想マシンですが、gasとstateについては厳密です。すべてのopcodeには価格があり、すべてのstorageの変更は永続的で高コストです。stack/memory/storage、gasの計算方法、およびtransactionのライフサイクルをよく理解することで、より効率的なスマートコントラクトを書き、transactionがrevertした場合にデバッグできるようになります。コントラクトを頻繁に使用する場合は、evm disasmまたはHardhat/Foundryのdebuggerを使用して実際のopcode traceを読むことを習慣づけてください。Solidityを読むだけよりもはるかに直感的です。

evmethereumblockchainsmart-contractsolidity