🙆

自作Solidityを自作EVMで動かしてみる

2024/11/12に公開

はじめに

自分でコンパイラと実行環境作ってみたいなと思ってたので作ってみました。
構文とかopcodeは全然網羅してないのですが、最低限ちょっとしたもの動けば満足なので気が向いたらもうちょっと改良していきます。

以下の参考文献に大いに影響受けてるのですが、なるべくEthereumのYellow Paperとか、gethの実装を見て可能な限り自分のできる範囲でやりました。(自分のリポジトリ名もパクリっぽいんですけど、一応この参考リポジトリを見つける前につけたやつです。本当です。)

作ったものデモ

contract TestContract {
    uint256 a = 1 + 2 * 3 + 4;

    function increment() returns (uint256) {
        a = a + 1;
        return a;
    }

    function compare(uint256 b) returns (bool) {
        return a < b;
    }
}

以上のコードをコンパイルすると以下のバイトコードが生成されます。

7f00000000000000000000000000000000000000000000000000000000000000807f0000000000000000000000000000000000000000000000000000000000000040527f00000000000000000000000000000000000000000000000000000000000000047f00000000000000000000000000000000000000000000000000000000000000037f00000000000000000000000000000000000000000000000000000000000000027f00000000000000000000000000000000000000000000000000000000000000017f000000000000000000000000000000000000000000000000000000000000000b7f0000000000000000000000000000000000000000000000000000000000000000557f000000000000000000000000000000000000000000000000000000000000032f807f00000000000000000000000000000000000000000000000000000000000001515f395ff37f00000000000000000000000000000000000000000000000000000000000000807f0000000000000000000000000000000000000000000000000000000000000040527f00000000000000000000000000000000000000000000000000000000000000e07f0000000000000000000000000000000000000000000000000000000000000000351c7f00000000000000000000000000000000000000000000000000000000d09de08a147f0000000000000000000000000000000000000000000000000000000000000196577f00000000000000000000000000000000000000000000000000000000000000e07f0000000000000000000000000000000000000000000000000000000000000000351c7f00000000000000000000000000000000000000000000000000000000c4b8c257147f0000000000000000000000000000000000000000000000000000000000000283577f00000000000000000000000000000000000000000000000000000000000000007f0000000000000000000000000000000000000000000000000000000000000000fd5b7f00000000000000000000000000000000000000000000000000000000000000017f000000000000000000000000000000000000000000000000000000000000000054017f0000000000000000000000000000000000000000000000000000000000000000557f0000000000000000000000000000000000000000000000000000000000000000547f0000000000000000000000000000000000000000000000000000000000000080527f00000000000000000000000000000000000000000000000000000000000000207f0000000000000000000000000000000000000000000000000000000000000080f35b7f0000000000000000000000000000000000000000000000000000000000000004357f000000000000000000000000000000000000000000000000000000000000000054107f0000000000000000000000000000000000000000000000000000000000000080527f00000000000000000000000000000000000000000000000000000000000000207f0000000000000000000000000000000000000000000000000000000000000080f3

以上のデモでは以下のことをしています。

  1. evmを動かす
  2. コンパイルしたバイトコードをデプロイする(コントラクトコードが返ってくる)
  3. 引数bを12でcompareを実行する(aの初期値は11なのでtrueである1が返ってくる)
  4. incrementを実行する(16進数で12が返ってくる)
  5. 同じく引数bを12でcompareを実行する(12 == 12のためfalseである0が返ってくる)

ちなみに以下のように返り値の型を間違えると、

contract TestContract {
    uint256 a = 1 + 2 * 3 + 4;

    function compare(uint256 b) returns (uint256) {
        return a < b;
    }
}

このようにコンパイルエラーになります。

EVM

EVMはスタックベースのVMです。

メモリ

最初に確保されてるメモリ

なんかコンパイル後のバイトコードを眺めていると、mstoreに入れるのに0x40使うこと多いなと思ったので調べました。

バージョンによって変わるかもしれませんが、私が参考にしたのはこちらです。
https://docs.soliditylang.org/en/v0.8.27/internals/layout_in_memory.html

最初の128バイトは用途が決まっています。

  1. 0x00 - 0x3f (64バイト): ハッシュ化メソッドのためのスクラッチスペース
  2. 0x40 - 0x5f (32バイト): 現在のメモリサイズ(いわゆる空きメモリポインタ)
  3. 0x60 - 0x7f (32バイト): ゼロスロット

1では、ハッシュメソッドなどで使うための一時的領域を確保しています。この領域に収まらないこともありますが、その際は空きメモリを使うようです。

2では、現在使用可能なメモリのポインタを指定するための領域です。つまり、初めて何かをメモリに保存する場合にはすでに最初の128バイト、つまり0x7fまでは埋まっているので初めてmstoreを実行する際には0x80を指定する必要があります。つまり、ここを基準にメモリを使うためコントラクトの最初の命令は0x40のメモリ番地に0x80をmstoreすることです。

3は、動的メモリ配列の初期値として使用され、何か書き込まれることはありません。つまり0x00で埋まっており、これをこのままコピーすれば初期値として使えますよということです。

メモリ割り当て

メモリは32バイトごとに割り当てが行われます。
前節より最初の128バイトは用途が決まっているため、最初に書き込まれるメモリアドレスは0x80からです。
メモリは必要な時に拡張されるため、例えば30バイトの文字列の変数に40バイトの文字列を代入した場合、0x80 - 0x9fまで使われていたメモリが自動的に0x80 - 0xbfまで拡張します。

この拡張にガス代がさらにかかるので注意が必要です。また、メモリは拡張はされますが使われなくなったからといって勝手にフリーにはなりません。
https://learnevm.com/chapters/evm/memory#memory-expansion

また、次のメモリ空間がすでに埋まっていた場合は一番後ろに新しくメモリを確保します。

https://medium.com/@PaulElisha1/layout-of-storage-and-memory-management-in-low-level-solidity-feb41c3de140

Storage

Slot

storageはメモリと違って必要な分を確保するのではなく、slotと呼ばれる32バイトの単位で最初に2^256-1個用意されています。

32バイト以下のデータ型は順番にデータを入れていくのですが、動的長さの型に関しては、以下のようなルールでデータを格納します。

  • slotにはデータの長さを格納する
  • そのslotの番号をkeccak256した値のslotにデータを入れる

例えばslotの番号が3の場合、slot(3)には、データの長さを格納し、keccak256(3) = 55b2c6...であるので、slot(55b2c6...)には実際のデータを格納する。slot(3)にはデータ長が格納されているので32バイトより大きければ、slot(55b2c6...)以降のslotも確認する仕様になっています。配列も同様です。この仕様で実装はしていますが、現状は32バイト以下のStringしか動きません。

https://docs.alchemy.com/docs/smart-contract-storage-layout

ただし、二次元配列やマッピングに関しては、slot番号やkeyの値を計算に入れています。(詳細は下記リンク参照ください)

https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html

また、現在の実装ではslotと値のマッピングはRustのHashMapを使用していますが、実際はMerkle Patricia Trieを使ってマッピングします。

関数定義

関数は定義をkeccak256したものの最初の4バイトを関数セレクタとします。
https://docs.soliditylang.org/en/latest/abi-spec.html

例えば、function mint(uint256)のような関数があった場合にはkeccak256(mint(uint256)) = 0xa0712d680358d64f694230b7cc0b277c73686bdf768385d55cd7c547d0ffd30e
これの最初の4バイトをとって、0xa0712d68が関数セレクタとなります。

txを送った時にcalldataの最初の4バイトが関数セレクタであるため、これが上記で計算した関数セレクタと一致していれば当該関数にJUMPする処理が必要です。

remixで簡単なコントラクトをコンパイルして、関数を呼び出す部分だけを抜き出すと以下のようなopcodeです。

- CALLDATALOAD
- PUSH1 0xe0
- SHR
- DUP1
- PUSH4 0x6057361d
- EQ
- PUSH1 0x2a
- JUMPI

簡単に説明すると、

  • CALLDATALOAD
  • PUSH1 0xe0
  • SHR
    これで、スタックのトップにはcalldataで指定した4バイトの関数セレクタが保存されます。

DUP1は一旦無視して頂いて、

  • PUSH4 0x6057361d
  • EQ

ここでcalldataで指定された関数セレクタと、実際にコントラクトバイトコードに保存してある関数セレクタが同じかを確認します。

同じであれば、以下のopcodeで当該関数箇所にjumpします。(0x2aはバイトコード内でのプログラムカウンタです。)

  • PUSH1 0x2a
  • JUMPI

calldata

calldataの最初の4バイトはfunction selectorで、あとは引数が32バイトごとに格納されます。
今回は全ての引数が32バイト以内で呼ばれる想定なので、引数の数 * 32バイトがfunction selectorの後に入っています。

Solidityコンパイラ

構文

現時点でpublicとかprivateとかもないです。
なんならif文もfor文もありません。あとは関数内で他の関数も呼び出せません。

型も全てを網羅しておらず、現時点ではstring, uint256, boolのみサポートしています。
Stringも定義はしましたが、現状256bit以下でないと動きません。

Constant Folding

Constant Foldingとか定数畳み込みとか呼ばれる処理です。
例えば、uint256 a = 1 + 2;という処理があった場合に、1+2という処理をevm上で行うのではなくコンパイル時に計算しておいて、実際にevm上で実行するのは、uint256 a = 3;にする処理です。

これは割と簡単な例なのですが、事前に値が分からない関数の引数とかが入っていると厄介です。
例えば、uint256 a = 1 + arg + 2;の場合(argは関数の引数)、本当はuint256 = 3 + arg;とコンパイル時に計算して、evm上で3 + argを計算させたかったのですが、ちょっと難しそうだったので、途中で値が分からないデータが入ったらもうevm上で計算させることにしました。

デプロイコード

EVMではデプロイ用のバイトコードと、デプロイされるコントラクトのバイトコードの2種類があります。
デプロイされるコントラクトコードはデプロイ用のバイトコードに含まれるのですが、codecopyというopcodeで最終的にはコントラクトがデプロイされます。(正確にはReturnされたタイミングですが)

https://github.com/akatsuki105/ToyEVM/blob/master/docs/guide.md#-コントラクトのデプロイ

solcでコンパイルしたバイトコードだと、デプロイしたいコントラクトコードの前後に何かコードが含まれていたのですが、toythereumだと以下のような流れでコントラクトコードをデプロイしました。

  1. コントラクトコードを全て走査するまで関数はコンパイルせず一時的にどこかに保存しておく
  2. 関数以外のコンパイル処理を行う(storage変数の初期化など)
  3. codecopyなどデプロイ用の処理を追加する(この時点ではコントラクトコードの長さは分からないので仮変数を入れておく)
  4. 関数をコンパイルする(これがコントラクトコードになる)
  5. コンパイルした関数の長さを計算してデプロイ用のopcodeの仮変数に長さを入れる

こうするとバイトコードの全体としては前半がstorageなどの初期化+コードデプロイの処理が含まれており、後半がコントラクトのバイトコードになります。

まとめ

前々からプログラミング言語作ってみたいと思っていたので、大変でしたが結構楽しかったです。
solidityのassembly(yul言語)の理解度も深まった気がします。
とはいえちゃんとした言語を作るのは難しくて、最適化なんて考える余裕もなかったです。
ただ、if文とかfor文すらないのはどうかと思うので余裕があるときに構文追加したり、pubilicとかviewとか修飾子を追加したいなぁと思います。

Discussion