イエローペーパーにおけるEVM仕様の理解
イエローペーパー(opens in a new tab)は、イーサリアムの正式な仕様です。 EIPプロセスによって修正される場合を除き、すべての動作方式が正確に記されています。 数学の論文形式で書かれており、プログラマーには馴染みのない専門用語が含まれています。 この論文を通して、論文の読み方を学び、他の関連する数学論文も読めるようにしましょう。
解説するイエローペーパーについて
イーサリアムに関する他のものと同じように、イエローペーパーも時間の経過とともに進化しています。 特定のバージョンを参照できるように、執筆時のバージョンをアップロードしています。 このドキュメントで使用するセクション番号、ページ番号、数式番号は、そのバージョンを参照しています。 ドキュメントを読む際は、アップロードされたイエローペーパーを別のウィンドウで開いておくと便利です。
EVMを解説する理由
イーサリアムの開発が始まったときに書かれたオリジナルのイエローペーパーでは、 コンセンサスメカニズムのベースとなったオリジナルのプルーフ・オブ・ワークについて記述されていました。 しかし、イーサリアムでは、2022年9月にプルーフ・オブ・ワークを止め、プルーフ・オブ・ステークをベースとしたコンセンサスを使い始めました。 このチュートリアルでは、イエローペーパーにおけるイーサリアム仮想マシンの定義部について解説します。 プルーフ・オブ・ステークへの移行後も、EVMは変更されていないことが解説する理由です。ただし、DIFFICULTY オペコードの戻り値は変更されています。
9 実行モデル
このセクション(p. 12-14)には、EVMの定義のほとんどが記述されています。
システム状態とは、システムを実行するため必要なすべての情報を指します。 典型的なコンピュータでは、これはメモリやレジスタの内容などです。
チューリングマシン(opens in a new tab)は、計算モデルです。 チューリングマシンは、コンピュータを本質的に単純化したもので、通常のコンピュータと同じ計算を実行する能力があることが証明されています。つまり、コンピュータが計算できるものはすべてチューリングマシンでも計算でき、またその逆も同様です。 このモデルは、さまざまな定理で何が計算可能で何が計算不可能であるかを証明するのに役立ちます。
チューリング完全(opens in a new tab)とは、チューリングマシンと同じ計算を実行できるコンピュータのことを指します。 チューリングマシンは無限ループができますが、EVMではガスがなくなると無限ループができません。そのため、EVMは準チューリング完全であるといわれています。
9.1 基本事項
このセクションでは、EVMの基本事項と、EVMと他の計算モデルとの比較について説明しています。
スタックマシン(opens in a new tab)は、中間データをレジスタではなくスタック(opens in a new tab)に格納するコンピュータです。 これは、仮想マシンで好まれるアーキテクチャです。なぜなら、実装が簡単で、バグやセキュリティの脆弱性が発生する可能性を大幅に低くできるためです。 スタック内のメモリは、256ビットのワードに分割されます。 256ビットが選ばれた理由としては、Kecck-256ハッシュや楕円曲線計算など、イーサリアムの中核となる暗号操作に都合が良いためです。 スタックの最大サイズは、1024バイトです。 オペコード実行時、通常、スタックからパラメータを取得します。 POP
(スタックの先頭のアイテムの削除)、DUP_N
(スタックのN番目のアイテムの複製)など、スタック内の要素を再構成するための専用オペコードがあります。
EVMには、 実行中にデータを保存するために使用されるメモリと呼ばれる揮発性スペースもあります。 このメモリは、32バイトのワードで構成されます。 すべてのメモリのロケーションは、ゼロで初期化されます。 次のYul(opens in a new tab)コードを実行してメモリにワードを追加すると、32バイトのメモリのワード内にある空スペースがパディングによってゼロで埋められます。つまり、ロケーション0~29、0x60~30、0xA7~31にゼロが含まれる1つのワードが作成されます。
1mstore(0, 0x60A7)
EVMは、メモリとやり取りをする3つのオペコードを提供しています。そのうちの1つがmstore
で、ワードをメモリにロードします。 他の2つは、1バイトをメモリにロードするmstore8
、ワードをメモリからスタックに移動するmload
です。
EVMには、システム状態の一部として保持される独自の不揮発性ストレージモデルもあります。このメモリは、(ワードアドレス可能なスタック内のバイト配列とは違い)ワードの配列で構成されます。 このストレージは、コントラクトが永続データを保存する場所です。コントラクトは、自分のストレージとしかやり取りできません。 ストレージは、キーと値のマッピングで構成されます。
イエローペーパーのこのセクションでは言及されていませんが、メモリに4番目のタイプがあることを知っておくといいでしょう。 Calldataは、トランザクションのdata
パラメータで渡された値を保存するために使用される、バイトアドレス可能な読み取り専用のメモリです。 EVMには、calldata
を管理する専用のオペコードがあります。 calldatasize
は、そのデータのサイズを返します。 calldataload
は、そのデータをスタックにロードします。 calldatacopy
は、そのデータをメモリにコピーします。
標準のフォンノイマン型アーキテクチャ(opens in a new tab)では、コードとデータが同じメモリに保存されます。 しかし、EVMでは、セキュリティ上の理由からこの標準に準拠していません。なぜなら、揮発性メモリを共有すると、プログラムのコードが変更される可能性があるからです。 そのため、コードはストレージに保存されます。
コードがメモリから実行されるのは、次の2つのケースだけです。
- コントラクトが他のコントラクトを作成する場合(
CREATE
(opens in a new tab) またはCREATE2
(opens in a new tab)を使用)、コントラクトにあるコンストラクタのコードはメモリから取得されます。 - あらゆるコントラクトの作成において、コンストラクタのコードが実行され、メモリから実際のコントラクトのコードが返されます。
例外実行とは、現在のコントラクトの実行を停止させる例外を意味します。
9.2 フィーの概要
このセクションでは、ガス代の計算方法について説明しています。 次の3つのコストがあります。
オペコードコスト
特定のオペコードで発生する固有のコストです。 この値を取得するには、付録H(ページ28)の式(327)の下部でオペコードのコストグループを見つけます。そして、式(324)でコストグループを見つけます。 ここには、コスト関数があります。ほとんどのケースにおいて、付録G(ページ27)のパラメータを使用します。
例えば、オペコードCALLDATACOPY
(opens in a new tab)は、Wcopyグループのメンバーです。 このグループにおけるオペコードのコストは、Gverylow+Gcopy×⌈μs[2]÷32⌉となります。 付録Gを見ると、両方の定数が3であることがわかり、この式は、3+3×⌈μs[2]÷32⌉です。
また、⌈μs[2]÷32⌉という式を解読する必要があります。 一番外側の部分の ⌈ \<value> ⌉は、天井関数です。この関数に値を指定すると、その値以上の最小の整数を返します。 例えば、⌈2.5⌉ = ⌈3⌉ = 3となります。 この式の内側部分は、μs[2]÷32です。 ページ3のセクション3(慣習)を参照してください。μは、マシンの状態を表します。 マシンの状態は、ページ13のセクション9.4.1で定義されています。 そのセクションによると、マシンの状態パラメータの1つは、スタックのsになります。 したがって、μs[2] は、スタックの2番目のロケーションにあるということです。 こちらのオペコード(opens in a new tab)では、スタック内のロケーション#2に、データのサイズ(バイト単位)が格納されています。 グループWの他のオペコードcopyであるCODECOPY
(opens in a new tab)とRETURNDATACOPY
(opens in a new tab)も、同じロケーションにデータのサイズを格納しています。 したがって、⌈μs[2]÷32⌉ は、コピーされるデータを保存するために必要になる32バイトのワード数です。 以上から、CALLDATACOPY
(opens in a new tab)に固有のコストは、3ガスにコピーされるデータのワードごとに3ガスを加えたものになります。
実行コスト
呼び出すコードの実行コスト。
CREATE
(opens in a new tab)およびCREATE2
(opens in a new tab)の場合は、新しいコントラクトのコンストラクタCALL
(opens in a new tab)、CALLCODE
(opens in a new tab)、STATICCALL
(opens in a new tab)、DELEGATECALL
(opens in a new tab)の場合は、呼び出すコントラクト
メモリーコストの拡張
(必要な場合の)メモリ拡張におけるコストについて。
この値は、式324でCmem(μi')-Cmem(μi)として記述されています。 セクション9.4.1をもう一度見てみましょう。μiは、メモリ内のワード数であることがわかります。 そのためμiは、オペコードの前のメモリ内のワード数となります。また、μi 'は、オペコード後のメモリ内のワード数を表します。
関数Cmemは、式326で次のように定義されます。Cmem (a) = Gmemory × a + ⌊a2 ÷ 512⌋ ⌊x⌋は、床関数を表します。これは値を指定すると、その値以下の最大の整数を返す関数です。 例えば、 ⌊2.5⌋ = ⌊2⌋ = 2となります。 a < √512のときは、 a2 < 512となり、この床関数の結果はゼロとなります。 そのため、最初の22ワード(704 バイト)については、必要なメモリワード数に応じてコストが線形的に増加します。 そのワード数を超えると、⌊a2 ÷ 512⌋が正になります。 必要なメモリが大きい場合、ガスコストがメモリ量の2乗に比例します。
これらの要素は、固有のガス代にのみ影響を与えることに注意してください。フィーマーケットやバリデータへのチップは、エンドユーザーが支払う必要がある金額を決定する要素であり、この式には含まれていません。この式は、EVMで特定の操作を実行するための直接コストのみを表しています。
9.3 実行環境
実行環境は、Iで表します。これは、ブロックチェーンの状態やEVMの以外の情報を含みます。
パラメータ | データにアクセスするためのオペコード | データにアクセスするためのSolidityのコード |
---|---|---|
Ia | ADDRESS (opens in a new tab) | address(this) |
Io | ORIGIN (opens in a new tab) | tx.origin |
Ip | GASPRICE (opens in a new tab) | tx.gasprice |
Id | CALLDATALOAD (opens in a new tab), etc. | msg.data |
Is | CALLER (opens in a new tab) | msg.sender |
Iv | CALLVALUE (opens in a new tab) | msg.value |
Ib | CODECOPY (opens in a new tab) | address(this).code |
IH | ブロックヘッダーフィールド、NUMBER (opens in a new tab)やDIFFICULTY (opens in a new tab)など | block.number , block.difficulty など |
Ie | コントラクト間のコールのコールスタックの深さ(コントラクトの作成を含む) | |
Iw | EVMの状態の変更を許可されているか、もしくは静的に実行されているか |
セクション9の続きを理解するには、次にある他のパラメータのいくつかを理解する必要があります。
パラメータ | 定義されているセクション | 説明 |
---|---|---|
σ | 2(2ページ目, 数式1) | ブロックチェーンの状態 |
g | 9.3(13ページ目) | ガスの残量 |
A | 6.1(8ページ目) | 発生したサブ状態(トランザクション終了時にスケジュールされた変更) |
o | 9.3(13ページ目) | 出力 - 内部トランザクションの場合に返される結果(あるコントラクトが別のコントラクトを呼び出すとき)およびビュー関数の呼び出し(情報を求めるだけなので、トランザクションを待つ必要がない場合) |
9.4 実行の概要
それでは準備が整ったので、ようやくEVMがどのように機能するかについての説明を開始します。
式137~142は、EVMを実行するための次の初期条件を示しています。
Symbol | 初期値 | 説明 |
---|---|---|
μg | g | ガスの残量 |
μpc | 0 | プログラムカウンタ、実行する次の命令が格納されたアドレス |
μm | (0, 0, ...) | メモリ、ゼロで初期化 |
μi | 0 | 使用される最上位のメモリ位置 |
μs | () | スタック、初期値は空 |
μo | ∅ | 戻りデータ(RETURN (opens in a new tab)またはREVERT (opens in a new tab))もしくは戻りデータ無し(STOP (opens in a new tab)またはSELFDESTRUCT (opens in a new tab))で止めない限り、戻りデータは空の出力 |
式143では、実行中の各時点で4つ状態が発生する可能性があること、そしてそれらをどのように処理するかを示しています。
Z(σ,μ,A,I)
Zは関数を表しており、操作によって無効な状態遷移が発生するかどうかをテストします(例外による停止を参照)。 Trueと評価された場合、変更が行われていないため、新しい状態は古い状態と同じです (ガスがバーンされることは除く) 。- 実行されているオペコードが
REVERT
(opens in a new tab)の場合は、ガスが失われ、新しい状態と古い状態は同一になります。 - 一連の演算が終了すると、
RETURN
(opens in a new tab)で示されるように、状態は新しい状態に更新されます。 - 終了条件1~3のいずれでもない場合は、実行を続けます。
9.4.1 マシンの状態
このセクションでは、マシンの状態について詳しく説明します。 ここでは、wが現在のオペコードであることを規定しています。 μpcがコードの長さを示す||Ib|| 未満の場合、そのバイト(Ib[μpc])は、オペコードです。 それ以外の場合、オペコードはSTOP
(opens in a new tab)と定義されています。
スタックマシン(opens in a new tab)であるため、ポップアウトされたアイテムの数(δ)と各オペコードによってプッシュインされたアイテムの数(α)を追跡する必要があります。
9.4.2 例外による停止
このセクションでは、異常終了がいつ発生するかを規定するZ関数を定義しています。 これは Boolean(opens in a new tab)関数であり、論理和では∨(opens in a new tab)を使用します。また、論理積では∧(opens in a new tab)を使用します。
次のいずれかの条件が真になる場合、例外による停止をします。
μg < C(σ,μ,A,I) セクション9.2に書かれているように、Cはガスのコストを規定する関数です。 次のオペコードを実施するのに十分なガスが残っていない場合の条件です。
δw=∅ オペコードに対してポップされるアイテムの数が未定義の場合、オペコード自体も未定義となります。
|| μs || < δw スタックのアンダーフロー。現在のオペコードに対してスタック内に十分なアイテムがありません。
w = JUMP ∧ μs[0]∉D(Ib) オペコードが
JUMP
(opens in a new tab)で、またアドレスがJUMPDEST
(opens in a new tab)でない場合です。 ジャンプは、ジャンプ先がJUMPDEST
(opens in a new tab)のときのみ有効です。w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) オペコードが
JUMPI
(opens in a new tab)であり、条件が真(ゼロ以外)であるため、ジャンプが発生しますが、JUMPDEST
(opens in a new tab)がアドレスではありません。 ジャンプは、ジャンプ先がJUMPDEST
(opens in a new tab)のときのみ有効です。w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || オペコードは、
RETURNDATACOPY
(opens in a new tab)です。 このオペコードでは、スタックの要素μs[1]は、戻り値データのバッファ内からの読み取り位置です。そしてスタックの要素 μs[2]はデータの長さです。 この条件は、戻りデータのバッファの末尾を超えて読み取ろうとしたときに発生します。 コールデータやコード自体には類似した条件がないことに注意してください。 これらのバッファの末尾を超えて読み取ろうとすると、ゼロが返されるだけです。|| μs || - δw + αw > 1024
スタックオーバーフロー オペコードの実行で1024を超えるアイテムのスタックが生成された場合は、中断されます。
¬Iw ∧ W(w,μ) 静的に実行しようとしていますか? (¬は否定を表します(opens in a new tab) 。また、ブロックチェーンの状態を変更できる場合Iwが真になります) そのような場合、状態の変更操作を試行しようとしてもできません。
関数W(w,μ)は、この後に式150にて定義されています。 次の条件が真の場合、W(w,μ)が真になります。
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} これらのオペコードは、新しいコントラクトの作成、値の保存、現在のコントラクトの破棄によって状態を変更します。
LOG0≤w ∧ w≤LOG4 静的に呼び出した場合、ログエントリは出力されません。
LOG0
(A0)(opens in a new tab)からLOG4
(A4)(opens in a new tab)の間における範囲内に全てのログオペコードがあります。 ログオペコードの後にある数字は、ログエントリに含まれるトピックの数を規定しています。w=CALL ∧ μs[2]≠0 静的な場合、他のコントラクトは呼び出せますが、ETHの送金はできません。
w = SSTORE ∧ μg ≤ Gcallstipend Gcallstipend(付録Gで2300で定義)以上のガスがなければ、
SSTORE
(opens in a new tab)を実行することはできません。
9.4.3 ジャンプ先の有効性
ここでは、JUMPDEST
(opens in a new tab)オペコードについて形式的に定義します。 バイト値0x5Bを単純に見つけることはできません。なぜなら、バイト値0x5Bは、PUSH内にある可能性があるためです(つまり、オペコードではなくデータ)。
式(153)では、関数N(i,w)を定義します。 最初のパラメータ i は、オペコードのロケーションです。 2番目のパラメータwは、そのオペコードです。 w∈[PUSH1, PUSH32]の場合、オペコードがPUSHであることを意味します(角括弧は端点を含む範囲を定義しています)。 この場合では、次のオペコードがi+2+(w−PUSH1)になります。 PUSH1
(opens in a new tab)では、2バイト(PUSH自体と1バイトの値)進む必要があります。PUSH2
(opens in a new tab)では、2バイトの値であるため、3バイト進める必要があります。 他のすべてのEVMオペコードの長さは1バイトであるため、その他のすべてのケースにおいてはN(i,w)=i+1となります。
この関数は、式(152)でDJ(c,i)と定義され、コードc内のすべての有効なジャンプ先の集合(opens in a new tab)で、オペコードのロケーションiから始まります。 この関数は、再帰的に定義されています。 i≥||c||では、コードが終了していることを意味します。 そのため、これ以上のジャンプ先は存在しないので、空集合を返すだけです。
それ以外の場合は、次のオペコードに移動し、そこから始まる集合を取得することで、コードの残りの部分を調べます。 現在のオペコードがc[i]であるため、次のオペコードのロケーションは、N(i,c[i])になります。 したがって、DJ(c,N(i,c[i]))は、次のオペコードから始まる有効なジャンプ先の集合です。 現在のオペコードがJUMPDEST
でなければ、その集合を返すだけです。 JUMPDEST
であれば、結果の集合にそれを含めて返します。
9.4.4 通常停止
停止関数であるHは、3つの型の値を返すことができます。
- 停止オペコードでない場合は、空のセットである∅を返します。 慣例により、この値はブール型の偽(false)として解釈されます。
- 出力を生成しない停止オペコードの場合、(
STOP
(opens in a new tab)またはSELFDESTRUCT
(opens in a new tab))、サイズがゼロバイトのシーケンスを戻り値として返します。 これは空のセットとは、大きく異なることに注意してください。 この値は、EVMが実際に停止し、読み取る戻りデータがないことを意味します。 - 出力を生成する停止オペコードがある場合(
RETURN
(opens in a new tab)またはREVERT
(opens in a new tab))、そのオペコードで指定されたバイトのシーケンスを返します。 このシーケンスはメモリから取り出され、スタックの先頭の値(μs[0])が最初のバイトであり、その後の値(μs[1])は長さです。
H.2 命令セット
EVMに関する最後のサブセクション9.5に進む前に、命令自体について考察してみましょう。 付録H.2で定義されており、ページ29から開始しています。 特定のオペコードでは、規定されていないものはすべて同じままであることが求められます。 変化する変数は、\<something>'と規定されています。
例として、ADD
(opens in a new tab)オペコードを見ていきます。
値 | Mnemonic | δ | α | 説明 |
---|---|---|---|---|
0x01 | ADD | 2 | 1 | 加算演算 |
μ′s[0] ≡ μs[0] + μs[1] |
δは、スタックからポップする値の個数です。 この場合は、先頭にある2つの値を加算するので、2になります。
αは、プッシュバックする値の個数です。 この場合は、合計で1になります。
なぜなら、新しいスタックの先頭(μ′s[0])は、古いスタックの先頭(μs[0])とその次の古い値(μs[1])の合計となるためです。
この記事では、すべてのオペコードを網羅するのではなく、新規性のあるオペコードのみを説明します。
値 | Mnemonic | δ | α | 説明 |
---|---|---|---|---|
0x20 | KECCAK256 | 2 | 1 | Keccak-256ハッシュの計算 |
μ′s[0] ≡ KEC(μm[μs[0] . 。 。 (μs[0] + μs[1] − 1)]) | ||||
μ′i ≡ M(μi,μs[0],μs[1]) |
これはメモリにアクセスする最初のオペコードです(この場合は、読み取り専用)。 ただし、現在のメモリの制限を超えて拡張される可能性があるため、μiを更新する必要があります。ページ29の式328に定義されているM関数を使ってこの更新を行っています。
値 | Mnemonic | δ | α | 説明 |
---|---|---|---|---|
0x31 | BALANCE | 1 | 1 | 指定されたアカウントの残高を取得 |
... |
残高を知る必要のあるアドレスは、μs[0] mod 2160 です。 スタックの最上位がアドレスなのは、アドレスは160ビットしかないためです。値をmodulo(opens in a new tab) 2160で計算します。
σ[μs[0] mod 2160] ≠ ∅の場合、このアドレスに関する情報が存在します。 その場合、σ[μs[0] mod 2160]bは、そのアドレスの残高です。 σ[μs[0] mod 2160] = ∅ の場合、このアドレスは初期化されておらず、残高はゼロです。 アカウント情報フィールドのリストは、4ページ目のセクション4.1に記載されています。
2つ目の数式、A'a ≡ Aa ∪ {μs[0] mod 2160}は、ウォームストレージ(最近アクセスされ、キャッシュされる可能性が高いストレージ)とコールドストレージ(アクセスされておらず、取得コストが高く低速な可能性が高いストレージ)とのアクセスのコストの差に関連しています。 Aaは、トランザクションによって以前アクセスされたアドレスのリストです。このリストは、8ページ目のセクション6.1に定義されているように、アクセスするコストが安くなっています。 この件についての詳細は、EIP-2929(opens in a new tab)をご覧ください。
値 | Mnemonic | δ | α | 説明 |
---|---|---|---|---|
0x8F | DUP16 | 16 | 17 | 16番目のスタックアイテムを複製 |
μ′s[0] ≡ μs[15] |
スタックアイテムを使用するには、ポップする必要があることに注意してください。つまり、そのアイテム上にあるすべてのスタックアイテムもポップする必要があります。 DUP<n>
(opens in a new tab)およびSWAP<n>
(opens in a new tab)の場合は、最大で16個の値をポップして、その後にプッシュしなければならなりません。
9.5 実行サイクル
すべてのパーツが揃ったので、ようやくEVMの実行サイクルがどのように文書化されているのかを理解できます。
数式(155)は、次の状態を示しています。
- σ(グローバルブロックチェーンの状態)
- μ(EVMの状態)
- A(サブ状態、トランザクション終了時に発生する変更)
- I(実行環境)
新たな状態は、(σ', μ', A', I')となります。
数式(156)~(158)は、スタックとオペコード(μs)によるスタックの変化を定義しています。 数式(159)は、ガスの変化(μg)です。 数式(160)は、プログラムカウンタ(μpc)の変化です。 最後に、数式(161)~(164)は、オペコードによって明示的に変更されない限り、他のパラメータが同じままであることを明記しています。
以上より、EVMが完全に定義されました。
まとめ
数学的表記法は正確であるため、イエローペーパーでは、イーサリアムのあらゆる詳細が記述されています。 ただし、次のような欠点があります。
- 人間のみが理解できるため、コンプライアンステスト(opens in a new tab)は、手作業によって記述する必要があります。
- プログラマーはコンピュータのコードは理解できますが、 数学的な表記法については、理解できない人もいます。
このような理由から、新たなコンセンサスレイヤーの仕様(opens in a new tab)は、 Pythonで記述されています。 こちらに(opens in a new tab)Pythonで書かれた実行レイヤーの仕様がありますが、完全ではありません。 イエローペーパー全体がPythonもしくは同様の言語に翻訳されるまで、イエローペーパーは使われ続けます。そのため、イエローペーパーを読めるようにしておくと便利です。
最終編集者: @wackerow(opens in a new tab), 2024年3月26日