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は次の順序で実行します:
nonceとbalanceをチェックし、gasLimit * gasPriceを支払うのに十分かどうかを確認します。- ターゲットコントラクトの
calldataとcodeをロードします。 - stackとmemoryを初期化(空の状態)し、現在の
storageを読み込みます。 - **program counter (PC)**に従って各opcodeを実行し、各ステップで対応するgasを差し引きます。
REVERTまたはgasが不足した場合→すべてのstate変更をロールバックしますが、使用されたgasは差し引かれたままです。- 成功した場合→新しい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パターンまたは
nonReentrantmodifierを使用してください。 - 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を読むだけよりもはるかに直感的です。