Skip to main content

2 posts tagged with "EVM"

View All Tags

· 8 min read
Thurendous

Hello, everybody!

Never Give Up

以前書いた時から、EVM について理解できてきているので、当時は読みづらかった技術系の文章もすらすら読めるようになりました。そこで EVM のことあるいは他のなにかで頑張っているあなたももしかして何かができずに悩んでいるのかもしれません。

ここで行っておきますが、僕もそうだったので、安心してください。そのうち理解できるようになるだろう。

ちなみに、こちらの画像をみてごらん。

本当に経験した人しかわからないと思いますし、経験したとしてももう一度経験すると、やはり信じられない気持ちになると思うよね。

何がどうなっても、続けることが一番大切。

  • 遅くてもいい

  • 理想と現実

  • 「続けてても意味ない」

  • 最初の第一歩が一番むずいけどね

  • 三日坊主 VS 続ける


今回は、メモリーについて説明する。

Part 1 では、EVM がどのようにバイトコードのどこを狙って run させるかを見てきた。 それは、外部の calldata を入力してきてコントラクトのどの関数を呼んでいるかを判別して run すべきバイトコードの箇所を決めていることがわかった。

これを理解することで、関数の署名・call stack・calldata・EVM のオペコード について理解が進んだと思う。

Part 2 では、EVM におけるメモリーについて色々見ていこう。

Memory

Part1 のコードを思いだしてみて。1_Storage.solというコントラクトがあった。

pragma solidity >=0.7.0 <0.9.0;

contract Storage {
uint256 number;

function store(uint256 num) public {
number = num;
}

function retrieve() public view returns (uint256) {
return number;
}
}

これのバイトコードを生成すると、こうなっている。

608060405234801561001057600080fd....

今回はこの部分にフォーカスして説明したいと思う。一番最初の 5bytes だ。

6080604052
60 80                       =   PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE

これが聞いたことあるがかもしれないけど、いわゆる「free memory pointer」だ。

これを理解するには、まずはコントラクトのメモリーを理解しなければならない。

メモリのデータ構造

コントラクトのメモリはシンプルにいうと、byte の array だ。32bytes(256bit)あるいは 1byte(8bit)の単位として保存される。読まれるときには、32bytes(256bit)単位ごとになる。下記の画像は話した内容を具現化したもの。

この機能は3つの opcode に左右されている。

  • MSTORE(x, y): 32 byte(256 bit) の値 y をメモリのロケーションの x に保存
  • MLOAD(x): 32 byte(256 bit) のメモリの場所をロケーション x から読み出し、スタックに入れる
  • MSOTRE8(x, y): 1 byte (8 bit)の値 y をメモリロケーションの x に保存する

メモリロケーションのことはどこからメモリを読み取る/書き込むかを決めていると考えてよい。もし、1byte より多く読み取る/書き込むことをしたいのであれば、そのまま継続すればよいだけの話になる。

EVM playground

この EVM playground はあなたの理解の手助けができるだろう。Run をクリックして、右上の矢印をクリックしてみよう。矢印ボタンはコードの稼働プロセスをたどっていってくれる。スタック、メモリはどうなっているのかもわかる。すごく直感的なツールだと感心する。

// MSTORE 32 bytes 0x11...1 at memory location 0
PUSH32 0x1111111111111111111111111111111111111111111111111111111111111111
PUSH1 0x00
MSTORE

// MSTORE8 1 byte 0x22 at memory location 32 (0x20 in hex)
PUSH1 0x22
PUSH1 0x20
MSTORE8

// MSTORE8 1 byte 0x33 at memory location 33 (0x21 in hex)
PUSH1 0x33
PUSH1 0x21
MSTORE8

// MLOAD 32 bytes to the call stack from memory location 0 ie 0-32 bytes of memory
PUSH1 0x00
MLOAD

// MLOAD 32 bytes to the call stack from memory location 32 ie 32-64 bytes of memory
PUSH1 0x20
MLOAD

// MLOAD 32 to the call stack from memory location 33 ie 33-65 bytes of memory
PUSH1 0x21
MLOAD

opcode についても、英語だが説明文がついている。

やってみて変だなと思うことはないのか? まず、MSTORE8 を使って 1 byte の 0x22 をメモリの 0x20 に書き込んでいるつもりだが、何故かメモリはここから、

こう変わるんだ。

この余分なゼロってなんだと思うだろう。

メモリの拡張

コントラクトがメモリへなにかを書き込む際、byte 数に従ってガス代を支払っている。これが opcode のコストと言う。もし、これまでメモリへ書き込んだことがない場所へ書き込む場合、追加的にメモリ拡張のコストがかかる。

これまで書き込んだことがない箇所へ書き込む場合、メモリは 32bytes(256bit)拡張されることになる。

メモリのコストの増え方に関しては、最初の 724bytes は線形的に増加するが、それ以降は二次指数関数的になる。

上の例では、まず 0x00 のロケーションへ 32 bytes 書き込んだが、そこからさらに書き込むとなると、メモリ拡張をしなければならず、結果的に、メモリは 64 bytes になった。

メモリにある保存領域の最初のデフォルト値はゼロ。だから 2200000000000000000000000000000000000000000000000000000000000000 がメモリに追加された。

メモリは byte の Array だから

次に注意しないといけないのは、メモリロケーションの 33(0x21)から MLOAD したときに、下記の値が返された。

3300000000000000000000000000000000000000000000000000000000000000

32 bytes ずつ読み込んでいなくても、読み込めるということであった。

memory はただの byte array なので、どこの場所からでも読み込めることを覚えて下さい。32bytes の制限は特になく、どこからでも byte 単位から読み込める。

関数内でのみ新たにメモリを作成することができます。それは新たにインスタンス化された複雑な型(例えば new int[...] など)であったり、ストレージ参照変数からコピーされたものであったりします。

現在、我々はデータ構造について理解できたので、free memory の話へ戻ろう。

Free Memory Pointer

Free Memory Pointer とは、簡単にいうとメモリロケーションのポインターであり、Free Memory がどこからスタートすべきなのかを記録している。言い換えると、メモリのロケーションはどこまで書き込まれていて、どこから書き込むべきなのかを記録している。

これはコントラクトがメモリを上書きするのを防ぐためでもある。

変数がメモリへ書き込むときに、コントラクトはまず Free Memory Pointer を参照し、どこから書き込むべきかを決めないといけない。それから Free Memory Pointer をアップデートし、最新のメモリの「境界値」あるいは「オフセット」を記録する。

  newFreeMemoryPointer = freeMemoryPointer + dataSizeBytes

Bytecode

先程にも言及したように、Free Memory Pointer は最初の runtime bytecode のこの部分によって定義される。

60 80                       =   PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE

これは基本的に何を言っているかというと、Free Memory Pointer はメモリロケーションの 0x40(64 in decimal)で、値は 0x80(128 in decimal)となる。

すぐに疑問に思うのは、なぜこの数字なのということだろう。

答え:

0x00 - 0x3f (64 bytes): scratch space

0x40 - 0x5f (32 bytes): free memory pointer

0x60 - 0x7f (32 bytes): zero slot

0x40 は solidity が定義した一番最初の Free Memory Pointer のメモリロケーションだ。値 0x80 は単に最初の 4 つの 32 bytes のポジションを記録している。

これらの予約されたメモリについて

  • Scrach space: inline assembly のハッシングメソッドとして使っても良い
  • Free memroy pointer: 今のメモリのサイズ、free memory のスタートロケーション、最初は 0x80
  • The zero slot: 動的な Memory Array の初期値であり、どんなときにでも書き込まれることはない

コントラクトのメモリ

リアルなコントラクトのメモリについて見ていくとしようか。

非常にシンプルなコントラクトを作成した。こいつの名は MemoryLane。たった一個の関数を持っており、2つの Array を持っている。それから b[0]に値 1 を付与する。

3 行程度のコードだけど、かなり多くのことが起きているよ。

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.3;

contract MemoryLane {
function memoryLane() public pure {
bytes32[5] memory a;
bytes32[5] memory b;
b[0] = bytes32(uint256(1));
}
}

上記のコードをもう一度 remix へ投入。

それから、コンパイルし、デプロイしてください。

memoryLane()関数を run して、debugging mode へ入ってください。

opcode を少しずつ見ていこう。

少し簡単なバージョンをEVM playgroundへ入れている。その opcode はそのようになっている。

この簡単なバージョンは opcode を順番に構成し、JUMP などのメモリ操作と関連しないことを除外した。いろいろコメントもしているので、参照してほしい。

このコードは 6 部分から構成されていて、これから dive in する。

play ground を使うことを強くおすすめする。

Free Memory Pointer Initialisation (EVM Playground Lines 1-15)

まず、“free memory pointer initialisation”からやっていく。上記でもあったように、0x80(128 in decimal)をスタックに入れる。これは free memory pointer の値として保存される。Solidity の memory のレイアウトを決めている。

この段階では、まだメモリにはなにもない。

続いて free memory pointer のロケーションの 0x40(64 in decimal)をプッシュする。

最後に、MSTORE を使って、0x40 ロケーションに 0x80 の値を記録。

この後、スタックには何もなく、メモリになにかを残せたことになる。メモリは 16 進数になっており、数字ごとに 4bit のデータを記録している。

今となって、192 の 16 進数の数字となっている(数字 2 桁 = 1 byte = 8 bit)。

先程の内容を思い出してください。最初の 64bytes は scrach space で、次の 32bytes は free memory pointer になる。これはまさに下記の状態。

Memory Allocation Variable “a” & Free Memory Pointer Update (EVM Playground Lines 16-34)

残りの部分については、簡単にするために一旦最後の飛ばしてハイレベルの概要を見てみよう。

次のメモリに関して、変数a(bytes32[5])のことを配置して free memory pointer をアップデートするということをしなければならないのがわかる。

コンパイラは array size によってどれくらいのスペースが必要か決定する。

メモリ array の要素の占める領域は 32 bytes の倍数になる。これは bytes1[]に対しても通用する。ただし、bytesstring は違う。

array のサイズ ×32bytes でどれくらいのメモリを食うかわかる。

この場合、5 * 32 bytes で、0xa0 (160)になるのだ。したがってその値は stack にプッシュされ、現在の free memory pointer に保存された。

この後、前の free memory pointer の値と足し算をして、値 0x120 (288)が生成された。この値が free memory pointer に保存された。

stack には変数aメモリをロケーション 0x80 にキープして後から参照できるようにしている。0xffffは JUMP のロケーションを意味しており、関係ないので当面は無視してよい(stack の underflow を防ぐためにある)。

Memory Initialisation Variable “a” (EVM Playground Lines 35-95)

今ではメモリに保存されて free memory pointer がアップデートされた。ここからは変数aのメモリにおける初期化をする。変数は宣言されて値代入していないので、ゼロ値になっているはず。

これをするためには、EVM の CALLDATACOPY を使って実施する。この opcode は3つの引数が必要:

  • memoryOffset(メモリロケーションへコピーする場所)
  • calldataOffset(calldata における byte offset のこと)
  • size(コピーする byte size)

今回の場合、memoryOffset はaのメモリロケーション 0x80。

calldataOffset は実際の calldata のサイズ、なぜならそもそも calldata の copy がいらない。メモリをゼロ値で初期化したい。結果的にサイズが 0xa0 あるいは 160 bytes になる。

ここで、メモリが 288 bytes になったとわかる(zero slot も含めて)。stack は今もう一度変数のメモリロケーションと JUMP ロケーションを持っている状態。

(その間に細かい操作については、何度も pop しているのがあるが、個人的にはまだなぞで、わかる方なら教えて下さい)

Memory Allocation Variable “b” & Free Memory Pointer Update (EVM Playground Lines 96-112)

ここでの操作は上記の流れと非常に似ているが、ただし、今回はbytes32[2] memory bとなる。

free memory pointer を 0x160(352 in decimal)にアップデートする(前の free memory pointer 288 + 新しい変数のサイズ 64 bytes)。

注意としては、free memory pointer はメモリにて 0x160 にアップデートされ、そこで現在は変数bのメモリロケーション(0x120)をスタックに入れてある。

Memory Initialisation Variable “b” (EVM Playground Lines 113-162)

続いて変数b初期化について説明する。

現在、メモリは 352bytes へ拡張された。スタックにはメモリロケーションを 2 個持っている。

Assign Value to b[0] (EVM Playground Lines 163-207)

やっと最後の段階に来れた。正直にいって難しかった。

ここでは、array b の index 0に値を付与したい。

コードでは b[0] = 1 となっている。

まず、この値が stack に突っ込まれる。その後、bit shift が起きるが、これ移動したのは 0 で何も変わりはないことを意味する。

次に array の index には 0x00 へ書き込まれ、0 positon へ書き込むことを意味する。その際に、array length の 2 を超えていないことをチェック。もし条件をクリアできない場合、異なるバイトコードのポジションへ飛び、エラーハンドリングの部分へ飛ばされる。

MUL、ADD opcode はメモリいにおいて値が書き込まれる場所を決めている。

0x20 (32 in decimal) * 0x00 (0 in decimal) = 0x00

メモリの array は 32 byte 単位であることを思い出してほしい。こちらは 0x00 になるので、offset は不要でそのまま 0 のポジションから書着込む。

0x00 + 0x120 = 0x120 (288 in decimal)

ADD は offset の値をメモリにある変数 b のロケーションを定義するように使われた。offset は今回 0 なので、そのまま、free memory pointer の位置に書き込むこととなる。

一番最後に、MSTORE を使って値 0x01 をメモリロケーションの 0x120 へ書き込む。そして、スタックにあるすべての要素を pop し、終了。

これでメモリはb[0] = 1となっている。下から 3 行目のところに 1 という数字が入っているのがわかる。

今回の記事はここまで!Bye!

Reference

EVM Deep Dives: The Path to Shadowy Super Coder 🥷 💻 - Part 2

· 4 min read
Thurendous

Hello, everybody!

First Principle という言葉を耳にされたことはないだろうか。それは奥深く物事の基礎を理解した上で、よりよい発想を生まれることという概念だ。

スマートコントラクトの世界では、EVM とその周りにあるアルゴリズム、データ構造に関しては、まさにこの First Principle である。Solidity あるいはスマートコントラクトはこの基礎の上に作る構造物なので、EVM のことを理解せずにはよい solidity dev と称するにはまだ早いと言ってよいだろう。

基礎:Solidity → Bytecode → Opcode

まず、基礎的な部分について一定程度の知識を有することを前提としている。

  • おさらい:
    • Bytecode & ABI: バイトコードとは EVM 上で実行可能なコードで、ABI とはこの EVM バイトコードとやり取りができるための interface。
    • Solidity コードはまずバイトコードへコンパイルしてから、イーサリアムブロックチェーンへ乗せるという流れになる。バイトコードは実は色々なオペコードを意味している。

今回は基本的な solidity コントラクトについてのバイトコードの一部を一緒にみていき、EVM がどのように関数を選んでいるかを見よう。

コントラクトのバイトコードはコントラクトからコンパイルされたもので、コントラクトにはいくつかもの関数があるだろう。

よくある質問は、デプロイした後、EVM はどのようにバイトコードのどの部分を実行すべきかがわかったのかというのがある。

1_Storage.sol コントラクト

pragma solidity >=0.7.0 <0.9.0;

contract Storage {
uint256 number;

function store(uint256 num) public {
number = num;
}

function retrieve() public view returns (uint256) {
return number;
}
}

今回のコントラクトは上記のものとなる。コントラクトには二個の関数がある。store()retrieve()

runtime のバイトコードは以下の通り:

608060405234801561001057600080fd5b50600436106100365760003560e01c80632e64cec11461003b5780636057361d14610059575b600080fd5b610043610075565b60405161005091906100d9565b60405180910390f35b610073600480360381019061006e919061009d565b61007e565b005b60008054905090565b8060008190555050565b60008135905061009781610103565b92915050565b6000602082840312156100b3576100b26100fe565b5b60006100c184828501610088565b91505092915050565b6100d3816100f4565b82525050565b60006020820190506100ee60008301846100ca565b92915050565b6000819050919050565b600080fd5b61010c816100f4565b811461011757600080fd5b5056fea2646970667358221220404e37f487a89a932dca5e77faaf6ca2de3b991f93d230604b1b8daaef64766264736f6c63430008070033

今回フォーカスするのは以下の部分:

60003560e01c80632e64cec11461003b5780636057361d1461005957

この部分は全体のバイトコードから抜き取ったもの。

関数のセレクタのロジックが含まれている。

言い遅れたが、EVM のオペコードに関しては、ここから確認できる。

オペコードは 1 byte の長さになる。そうすると自然に 256 パターンがあると分ると思う。

しかし、実際には 140 個のオペコードしか存在しない。

以下はバイトコードを関係するオペコードへ変換したもの。

60 00                       =   PUSH1 0x00
35 = CALLDATALOAD
60 e0 = PUSH1 0xe0
1c = SHR
80 = DUP1
63 2e64cec1 = PUSH4 0x2e64cec1
14 = EQ
61 003b = PUSH2 0x003b
57 = JUMPI
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI

スマコンの関数呼び出しや Calldata

オペコードへダイブする前に、コントラクトの関数の呼び出しについて復習しよう。

関数の呼び出しの前に、calldata に、関数の署名、そして続いて引数も入れる。

Solidity のコードに表現してもらうと、こうなるだろう。

event FunctionCalldata(bytes);
bytes memory functionCalldata = abi.encodeWithSignature('store(uint256)', 10);
emit FunctionCalldata(functionCalldata);
address(storageContract).call(functionCalldata);

今回の場合、僕は store 関数を呼び、10 引数として代入したい。

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

上の長い数字の配列は abi.encodeWithSignature(”store(uint256)”,10)の結果。

関数シグネチャーは4バイトの Keccak ハッシュ値によって定義されている「0x6057361d」。

keccak256(“store(uint256)”) →  first 4 bytes = 6057361d

keccak256(“retrieve()”) → first 4 bytes = 2e64cec1

先程の calldata を見ると、全部で 36bytes あった。最初の 4bytes: 6057361dは関数のセレクタ関連で、store(uint256)と関係する。

ご自身ではッシュしてみて →ここ

6057361d = function signature (4 bytes)

000000000000000000000000000000000000000000000000000000000000000a = uint256 input (32 bytes)

以上、calldata の準備だった。

オペコード&スタック

それでは、EVM レベルへダイブしていこう。スタックについて理解しておく必要がある。わからない場合はこれを見て。

先程のオペコードはこれ:

60 00                       =   PUSH1 0x00
35 = CALLDATALOAD
60 e0 = PUSH1 0xe0
1c = SHR
80 = DUP1
63 2e64cec1 = PUSH4 0x2e64cec1
14 = EQ
61 003b = PUSH2 0x003b
57 = JUMPI
80 = DUP1
63 6057361d = PUSH4 0x6057361d
14 = EQ
61 0059 = PUSH2 0x0059
57 = JUMPI

PUSH1 は 1byte のデータをスタックへ入れることを意味する。そうすると、スタックはこうなる

PUSH1 0x00    | 0 |

続いて CALLDATALOAD は最初の stack(0)の値をポップさせる。この 0 値を input として使用し、offset として使う。スタックアイテムのサイズは 32bytes なのに対し、今回の calldata は 36bytes になっている。プッシュする値は msg.data[i : i+32]で、i は今回の input 値となる。これは毎回プッシュする値が 32bytes になることを保証できる。同時に、どの部分にもアクセルできる。

今回の場合、offset はなかったので、32bytes の calldata の値をスタックに push した。さきほど用意した calldata はこれ。やっと出番がきた。

0x6057361d000000000000000000000000000000000000000000000000000000000000000a

というのは、最後の0000000aが除外された 32bytes が今回の入力となる。

CALLDATALOAD    | 0x6057361d0...00 |

次に PUSH1 を使って hex value の 0xe0 をスタックに入れる。これは十進数だと 224。

PUSH1 0xe0      |       224        |
| 0x6057361d0...00 |

SHR を使ってライトシフトさせる。今回は最初のアイテムである 224 を取り出し、input として扱う。スタックにある二番目のアイテムをどれくらい右へライトシフトさせるかを定義している。256 - 244 = 32 bit とわかるように、最後には 4bytes のセレクタが call stack に残る。

SHR    | 0x6057361d |

DUP1 を使ってスタックの一番上の値をコピーする。

DUP1    | 0x6057361d |
| 0x6057361d |

PUSH4 を使って 4byte の関数のシグネチャーのretrieve() (0x2e64cec1) をスタックにプッシュする。

PUSH4 0x2e64cec1    | 0x2e64cec1 |
| 0x6057361d |
| 0x6057361d |

EQ は二個の値をスタックから出し、イコールなのかどうかをチェックする。もしイコールなら 1(true) をスタックにプッシュ、そうでない場合は 0(false) をプッシュする。

EQ          |      0     |
| 0x6057361d |

次に PUSH2 を使って二個の値をプッシュ。(0x003b, 十進数だと 59)

ここでは、59 が出たのはプログラムカウンターがバイトコードに次の実行コマンドはどこにあるのかを確認しているから。この 59 は retrieve()がスタート地点は 59 を意味している。

PUSH2 0x003b    |     59     |
| 0 |
| 0x6057361d |

JUMPI は”jump if”を意味する。二個の値をポップさせ、一個目の値は 59 で、二個目は 0。二個目の値は bool 値でこの jump を実行すべきかを確定している。1 = true, 0 = false。

もし true の場合、プログラムカウンターはアップデートされ実行はそちらへ jump する。今回は false なので、スキップ。

JUMPI    | 0x6057361d |

DUP1 again

DUP1    | 0x6057361d |
| 0x6057361d |

PUSH4 は 4byte の値をスタックにプッシュする

PUSH4 0x6057361d    | 0x6057361d |
| 0x6057361d |
| 0x6057361d |

また EQ して、今回は true なので。シグネチャーが合った。

EQ    |      1     |
| 0x6057361d |

JUMPI、今回は true なんで jump を実行する。プログラムカウンターは 89 で、バイトコードの違う場所へ移動。

PUSH2 0x0059    |     89     |
| 1 |
| 0x6057361d |

この場所に JUMPDEST オペコードがある。これがないと失敗する。

JUMPI    | 0x6057361d |

そこで終わり。これでオペコードの実行はstore(uint156)の場所へ移動できた。

今回は二個の関数しかないものの、たとえ 20 個の関数があったとしても、プロセスは一緒。

このリンクは非常におすすめ。触ってみると吉。

EVM のスタック・メモリ・ストレージをシミュレートしてくれる。

Reference

EVM Deep Dives: The Path to Shadowy Super Coder 🥷 💻 - Part 1