EVM を詳しく調べる: スマート コントラクトのコンパイルとデプロイ

著者: Ding Peiling (ArcBlock ソフトウェア エンジニア)
はじめに
イーサリアム仮想マシンはイーサリアムの基盤です。すべてのトランザクションを実行し、これらのトランザクションに基づいてイーサリアム全体のアカウント状態、またはより正確には世界状態を維持する責任があります。トランザクションには、最も単純なイーサ トランザクションや、スマート コントラクトを展開または呼び出すトランザクションなど、さまざまな種類があります。スマート コントラクトは、複雑なビジネス ロジックを完成させるために仮想マシンによって実行されるコードです。 Solidity は現在、スマート コントラクトを作成するための最も人気のある高水準言語です。 Solidity によって作成されたスマート コントラクトは、まず仮想マシンが直接受け入れられるバイトコードにコンパイルされ、その後、ユーザーによってスマート コントラクト展開用のトランザクションの形式でイーサリアムに送信されます。その後、ユーザーはスマート コントラクトの機能を呼び出してビジネス ロジックを完成させることができます。では、プロセス全体において、Solidity コードはどのようにバイトコードにコンパイルされるのでしょうか?バイトコードは仮想マシンでどのように実行されますか?バイトコードをコンパイルするとき、仮想マシンはどのようにバイトコードを最適化しますか? ArcBlock Engineering Blog の今号では、これらの問題を詳細に分析します。
例から始めましょう
最も単純なスマート コントラクトの例から始めましょう。
pragma solidity ^0.4.11;
contract C {
uint256 a;
function C() {
a = 1;
}
}このコードは Java に非常に似ています。わかりやすくするために、ここでは Java の用語を借用します。このスマート コントラクトにはメンバー変数 a があり、その型は 256 ビットの符号なし整数です。さらに、メンバー変数 a を 1 に割り当てるコンストラクターもあります。このコードをコンパイルしましょう。コードのコンパイルに使用できるツールが 2 つあります。
solc --bin --asm file_name.sol- http://remix.ethereum.org
1 つ目はコマンド ライン ツールで、最初にインストールする必要があります。 2 つ目は、スマート コントラクトを迅速にコンパイル、デプロイ、デバッグできる強力な Web バージョン IDE です。以下に示すように、コンパイルされたコードはバイトコードと呼ばれます。
60606040523415600e57600080fd5b600160008190555060358060236000396000f3006060604052600080fd00a165627a7a72305820d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d0029このバイトコードでは、各文字が 16 進数を表し、2 文字ごとに 1 バイトを表します。このバイトコードは、仮想マシン上で直接実行されるコードです。仮想マシンは、事前定義されたルールに従って各バイトを解釈して実行するだけで済みます。しかし、人間にとってこれらのバイトコードを直接読み取るのは面倒すぎるため、以下に示すように、バイトコードをより人間に優しい形式の OpCode に変換できます。
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP PUSH1 0x35 DUP1 PUSH1 0x23 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x60 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 0xd3 ISZERO DUP8 0x5f JUMP 0xb5 ORIGIN 0xab CALLDATACOPY SHR 0xf9 0xaa DUP7 0xa6 0x28 POP 0xe1 RETURNDATACOPY 0xb6 0xab NOT 0x48 0x47 ADD SAR 0xcd PUSH5 0x1B9A9D2F8D STOP 0x29上記のバイトコードまたはオペコードは同等であり、次の 3 つの部分に分割できます。
- スマートコントラクトコードをデプロイする
60606040523415600e57600080fd5b600160008190555060358060236000396000f300
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST PUSH1 0x1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP PUSH1 0x35 DUP1 PUSH1 0x23 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP- スマートコントラクト自体のコード
6060604052600080fd00
PUSH1 0x60 PUSH1 0x40 MSTORE PUSH1 0x0 DUP1 REVERT STOP- 補助データ
a165627a7a72305820d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d0029
LOG1 PUSH6 0x627A7A723058 KECCAK256 0xd3 ISZERO DUP8 0x5f JUMP 0xb5 ORIGIN 0xab CALLDATACOPY SHR 0xf9 0xaa DUP7 0xa6 0x28 POP 0xe1 RETURNDATACOPY 0xb6 0xab NOT 0x48 0x47 ADD SAR 0xcd PUSH5 0x1B9A9D2F8D STOP 0x29各部分を段階的に説明し、それらがどのように機能するかを見てみましょう。
1. スマートコントラクトコードをデプロイする
コードの最初の部分は、実際にスマート コントラクトをイーサリアムにデプロイするコードであり、私たちが注目する部分でもあります。このコードは 3 つの部分に分けることができます。
- 支払可能小切手
60606040523415600e57600080fd
PUSH1 0x60 PUSH1 0x40 MSTORE CALLVALUE ISZERO PUSH1 0xE JUMPI PUSH1 0x0 DUP1 REVERT- コンストラクターの実行
5b6001600081905550
JUMPDEST PUSH1 0x1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP- コードをコピーしてメモリに戻します
60358060236000396000f300
PUSH1 0x35 DUP1 PUSH1 0x23 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP1.1 支払可能な小切手
payableはSolidityのキーワードです。関数がマークされている場合、ユーザーは関数の呼び出し中にイーサリアムをスマート コントラクトに送信することもできます。バイトコードのこの部分の目的は、支払い可能としてマークされていない関数を呼び出すときに、ユーザーがスマート コントラクトに ether を送信できないようにすることです。下の図は、このコードをさらに計算したものです。左側の 2 つの列はバイトコードとオペレーション コードで、右端の列はステートメント実行後のスタックの状態です。

上の図で、最初の 3 つの文は、メモリ内の 0x40 からのアドレス 32 バイトを、仮想マシンによって予約されたメモリ アドレスである値 0x60 に割り当てることです。次のいくつかの文は、送信されたイーサが 0 かどうかをチェックすることによって支払い可能チェックを行っています。0 の場合、仮想マシン プログラム カウンター (PC) は 0xe の位置にジャンプして実行を継続します。そうでない場合は、プログラムを終了します。
ここで、スタック内の各要素の長さは 32 バイトであることに注意してください。便宜上、ここでは上位の 0 を省略しています。
1.2 コンストラクターを実行する
スマート コントラクト デプロイメント コードの 2 番目の部分は、コントラクトのコンストラクターを実行するために使用されます。次の図に示すように、このバイトコードを実行すると、ヒープ内のアドレス 0x0 に値 0x1 が割り当てられます。 0x0 は、仮想マシンによってワールド状態の変数 a に割り当てられたアドレスです。

上の図では、JUMPDEST は上記の 0xe に対応します。つまり、上記の支払小切手に合格した場合は、ここにジャンプしてコードの実行を続行する必要があります。 SSTORE コマンドは、スタック上の値をワールド状態に保存するために使用されます。世界の状態には多くの類似点があるため、図ではヒープを使用して世界の状態を表しました。 Java では、スタックは関数の実行時に一時変数を格納するために使用され、ヒープはメンバー変数などのより長いライフサイクルを持つ変数を格納するために使用されることがわかっています。スタック上のデータはメソッドが実行されるとリアルタイムでクリアされ、ヒープ上のデータはクラス インスタンスのライフ サイクル全体を通じて常に有効になります。 Java 仮想マシンは、クラスのインスタンスがリサイクルされない限り、ヒープ内のメンバー変数をクリアしません。イーサリアム上にデプロイされたスマート コントラクトは、永久に存続するコントラクト インスタンスとみなすことができます (もちろん、コントラクトを強制終了することもできます)。したがって、スマートコントラクトの状態を保存するために使用されるワールドステートは、イーサリアムのヒープとみなすことができます。ここで世界状態を指すためにヒープを使用する理由は、第一にスタックをエコーするためであり、第二に、イーサリアムの本質を別の側面から説明するためです。 イーサリアムは、ネットワーク全体のすべてのコンピューターを接続して単一のコンピューターを形成するコンピューター ネットワークです。このコンピュータでは、データ構造を使用してメモリの動作メカニズムをシミュレートし、チューリング完全プログラミング言語を実装します。
イーサリアムでは、World State はキーと値のペアです。各キーは 32 バイト長のデータ ブロックに対応します。したがって、上の図に示されている状況では、キー 0x0 に対応するデータ ブロックには数値 0x1 (32 バイト、上位ビットは 0 で埋められます) が格納されます。
1.3 コードをコピーする
スマート コントラクト デプロイメント コードの 3 番目の部分は、残りのコード、つまりスマート コントラクト自体のコードとトランザクションからの Auxdata をメモリにコピーして返します。

上の図からわかるように、0x23 から 0x58 までのバイトコード (合計 0x35 バイトコード) をメモリ内の 0x0 から 0x35 までのアドレスにコピーしました。
2. スマートコントラクト自体のコード
バイトコード全体の 2 番目の部分はスマート コントラクト自体のコードで、スマート コントラクト関数が呼び出されたときに実行されます。現在の例では、スマート コントラクトにはコンストラクターが 1 つだけあり、他のメソッドがないため、以下に示すコードは実用的な意味を持ちません。

3.補助データ
3 番目の部分である Auxdata には、固定テンプレートがあります。
0xa1 0x65 'b' 'z' 'z' 'r' '0' 0x58 0x20 <32 bytes swarm hash> 0x00 0x29上記のバイトコード a165627a7a72305820d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d0029 をテンプレートに取り込むと、swarm ハッシュを d315875f56b532ab371cf9aa86a62850e13eb6ab194847011dcd641b9a9d2f8d として取得できます。この Swarm Hash は、スマート コントラクトのコードを検証するために使用でき、スマート コントラクトのメタデータを取得するためにも使用できます。
契約のための契約書を作成する
上記の説明を通じて、スマートコントラクトを展開するプロセス全体をすでに理解しました。このプロセスでは、バイトコードがトランザクションの形式でイーサリアムに送信され、デプロイが完了します。ただし、スマート コントラクトは手動で作成できるだけでなく、他の既存のスマート コントラクトによって作成することもできます。
pragma solidity ^0.4.11;
contract Foo {
}
contract FooFactory {
address fooInstance;
function makeNewFoo() {
fooInstance = new Foo();
}
}上記のコードでは 2 つのコントラクトが確認できます。1 つは Foo で、もう 1 つは Foo の作成に使用される FooFactory です。上記のコードをコンパイルすると、次のバイトコードが得られます。
FooFactoryDeployCode
FooFactoryContractCode
FooDeployCode
FooContractCode
FooAUXData
FooFactoryAUXDataバイトコード全体が 2 つの層に分割されており、各層は前述したように 3 つの部分に分割されていることがわかります。最も外側のバイトコードは FooFactory のデプロイに使用され、そのコントラクト コード部分はコントラクト Foo の作成に使用されるため、コントラクト デプロイのための完全なコード セットがこの部分にネストされます。
メンバー変数を追加する
最初の例では、コントラクト全体でメンバー変数を 1 つだけ作成しました。ここで、コントラクトをもう少し複雑にして、別のメンバー変数を追加し、対応するバイトコードに何が起こるかを見てみましょう。
pragma solidity ^0.4.11;
contract C {
uint256 a;
uint256 b;
function C() {
a = 1;
b = 2;
}
}残りを省略すると、コンストラクターを実行する部分は次のようになります。
5b JUMPDEST
60 01 PUSH1 0x1
60 00 PUSH1 0x0
81 DUP2
90 SWAP1
55 SSTORE // heap {0x0 => 0x1}
50 POP
60 02 PUSH1 0x2
60 01 PUSH1 0x1
81 DUP2
90 SWAP1
55 SSTORE // heap {0x0 => 0x1} {0x1 => 0x2}
50 POP仮想マシンがワールド ステートの 2 つのアドレス 0x0 と 0x1 を変数 a と b に順番に割り当て、対応する値 1 と 2 を割り当てることが簡単にわかります。実際、さらに多くのメンバー変数がある場合、仮想マシンはそれらにストレージ アドレスを順番に割り当てます。ここで割り当てるストレージ アドレスは、RPC の 2 番目のパラメーターに対応します。
256ビットから128ビットへ
上の例では、2 つの 256 ビット (32 バイト) 符号なし整数を宣言しました。実際のアプリケーションでは、それほど多くのスペースは必要ないかもしれません。たとえば、他の言語で一般的に使用される整数は 4 バイトしかありません。それでは、少し最適化を行って、これら 2 つの 32 バイトの数値を 2 つの 16 バイトの整数に変換して、何が起こるかを見てみましょう。
pragma solidity ^0.4.11;
contract C {
uint128 a;
uint128 b;
function C() {
a = 1;
b = 2;
}
}同様に、残りを省略すると、コンストラクターを実行する部分は次のようになります。
/********************** a = 1 ********************************/
60 01 PUSH1 0x1 stack: [0x1]
60 00 PUSH1 0x0 stack: [0x0 0x1]
80 DUP1 stack: [0x0 0x0 0x1]
61 0100 PUSH2 0x100 stack: [0x100 0x0 0x0 0x1]
0a EXP(base, exponent) stack: [0x01 0x0 0x1]
81 DUP2 stack: [0x0 0x1 0x0 0x1]
54 SLOAD(location) stack: [0x0 0x1 0x0 0x1]
81 DUP2 stack: [0x1 0x0 0x1 0x0 0x1]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x1 0x0 0x1 0x0 0x1]
02 MUL(x, y) stack: [0xffffffffffffffffffffffffffffffff 0x0 0x1 0x0 0x1]
19 NOT stack: [0xffffffffffffffffffffffffffffffff00000000000000000000000000000000 0x0 0x1 0x0 0x1]
16 AND(x, y) stack: [0x0 0x1 0x0 0x1]
90 SWAP1 stack: [0x1 0x0 0x0 0x1]
83 DUP4 stack: [0x1 0x1 0x0 0x0 0x1]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x1 0x1 0x0 0x0 0x1]
16 AND stack: [0x1 0x1 0x0 0x0 0x1]
02 MUL stack: [0x1 0x0 0x0 0x1]
17 OR stack: [0x1 0x0 0x1]
90 SWAP1 stack: [0x0 0x1 0x1]
55 SSTORE(pos, val) stack: [0x1]
heap: {0x0 => 0x1}
50 POP stack: []
/********************** b = 2 ********************************/
60 02 PUSH1 0x2 stack: [0x2]
60 00 PUSH1 0x0 stack: [0x0 0x2]
60 10 PUSH1 0x10 stack: [0x10 0x0 0x2]
61 0100 PUSH2 0x100 stack: [0x100 0x10 0x0 0x2]
0a EXP stack: [0x100000000000000000000000000000000 0x0 0x2]
81 DUP2 stack: [0x0 0x100000000000000000000000000000000 0x0 0x2]
54 SLOAD(location) stack: [0x1 0x100000000000000000000000000000000 0x0 0x2]
81 DUP2 stack: [0x100000000000000000000000000000000 0x1 0x100000000000000000000000000000000 0x0 0x2]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x100000000000000000000000000000000 0x1 0x100000000000000000000000000000000 0x0 0x2]
02 MUL stack: [0xffffffffffffffffffffffffffffffff00000000000000000000000000000000 0x1 0x100000000000000000000000000000000 0x0 0x2]
19 NOT stack: [0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff 0x1 0x100000000000000000000000000000000 0x0 0x2]
16 AND stack: [0x1 0x100000000000000000000000000000000 0x0 0x2]
90 SWAP1 stack: [0x100000000000000000000000000000000 0x1 0x0 0x2]
83 DUP4 stack: [0x2 0x100000000000000000000000000000000 0x1 0x0 0x2]
6f ffffffffffffffffffffffffffffffff PUSH16 stack: [0xffffffffffffffffffffffffffffffff 0x2 0x100000000000000000000000000000000 0x1 0x0 0x2]
16 AND stack: [0x2 0x100000000000000000000000000000000 0x1 0x0 0x2]
02 MUL stack: [0x200000000000000000000000000000000 0x1 0x0 0x2]
17 OR stack: [0x200000000000000000000000000000001 0x0 0x2]
90 SWAP1 stack: [0x0 0x200000000000000000000000000000001 0x2]
55 SSTORE stack: [0x2]
heap: {0x0 => 0x200000000000000000000000000000001}
50 POP stack: []一般に、上記のコードは 2 つの部分に分かれています。最初の部分は a = 1 に対応します。コードのこの部分では、アドレス 0x0 の下位 16 バイトに 0x1 を格納します。 2 番目の部分は b = 2 に対応し、0x0 の上位 16 バイトに 0x2 が格納されることを意味します。したがって、上記のコードを実行した後、World State キーの 1 つだけ、つまり 0x0 を使用することで、2 つの変数の保存が完了します。より鮮明に表現すると、次のように表現できます。
[ b ][ a ]
[16 bytes / 128 bits][16 bytes / 128 bits]梱包と保管
そこで問題は、なぜ仮想マシンでこの変更を行う必要があるのかということです。これら 2 つの例の Solidity コードはほぼ同じです。変数の型を変更しただけです。ただし、2 番目の例の仮想マシンによってコンパイルされたバイトコードは、前の例のバイトコードの 2 倍以上の長さになります。これらの追加されたバイトコードは、トランザクションのサイズに直接影響します。では、仮想マシンはどのような目的でこれほど多くのバイトコードを生成するのでしょうか?
実際、上記の質問には簡単な答えがあります。それはガスです。コントラクトの実行とデプロイにはガスが必要であることがわかっています。具体的には EVM レベルで、各オペレーション コードには、消費する必要がある対応するガスがあります。以下は、いくつかのオペコードのガス消費量の説明です。
sstoreは、このオペコードを使用してデータを新しいアドレスに保存するときに 20000 ガスを消費しますsstoreは、このオペコードを使用してデータを既存のアドレスに保存するときに 5000 ガスを消費しますsloadこのオペコードを使用して World State からデータを読み取ると、500 ガスを消費します。- 残りのオペコードには 3 ~ 10 ガスのコストがかかります
したがって、2 つの例で消費するガスは次のようになります。
- 20000 + 20000 = 40000
- 500 + 20000 + 5000 + 500 = 26000
パッケージ化されたストレージの場合、2 回目に sstore を使用するときは、既存のアドレスにデータを再度書き込むだけなので、15,000 ガスが節約されます。このため、仮想マシンはストレージ アドレスを直接使用するよりも、このような複雑なバイトコードをコンパイルすることを選択します。
コンパイルの最適化
実際、上記のバイトコードはまだ少し長いですが、実際に a と b に対応するデータをメモリ内に準備し、それを一度にワールド ステートに保存できると考えるのが簡単だからです。このようにして、2 台目の sstore で消費される 5000 ガスも節約できます。これは、バイトコードを最適化するようにコンパイラーに指示することで実現できます。前述のコンパイル ツールでは、コンパイラーにコードを最適化させる方法は次のとおりです。
solc --bin --asm --optimize file_name.sol- http://remix.ethereum.org
enable-Optimizationオプションを確認してください
もう一度コンパイルして結果がどうなるかを見てみましょう
60 00 PUSH 0x0
80 DUP1
54 SLOAD
70 0200000000000000000000000000000000 PUSH17
/* not(sub(exp(0x2, 0x80), 0x1)) 高16字节bitmask */
60 01 PUSH 0x1
60 80 PUSH 0x80
60 02 PUSH 0x2
0a EXP
03 SUB
19 NOT
90 SWAP1
91 SWAP2
16 AND
60 01 PUSH 0x1
17 OR
/* sub(exp(0x2, 0x80), 0x1) 低16字节bitmask */
60 01 PUSH 0x1
60 80 PUSH 0x80
60 02 PUSH 0x02
0a EXP
03 SUB
16 AND
17 OR
90 SWAP1
55 SSTORE上記から、仮想マシンはビットマスクを使用して上位 16 バイトと下位 16 バイトをそれぞれ割り当て、データを Worl State に保存するために sstore 命令を 1 つだけ使用することがわかります。最適化目標を達成しました!
しかし、ちょっと待って、なぜ 17 バイトの 0200000000000000000000000000000000 がバイトコードに直接埋め込まれているのでしょうか?この値を取得するには、単純な操作 exp(0x2, 0x81) を実行するだけでよいことに注意してください。言い換えれば、実際にはこれら 17 バイトを表すために 3 バイトしか使用する必要がないのに、仮想マシンはなぜこれを行わないのでしょうか?答えは簡単、まだガスが残っているからです。バイトごとのガス消費量のルールを見てみましょう。
- 各 0 バイトは 4 ガスを消費します
- ゼロ以外の各バイトは 68 ガスを消費します
このルールによれば、次の 2 つのケースで消費されるガスの値を簡単に計算できます。
- 68 + 16 × 4 = 132
- 68 × 3 = 204
したがって、0200000000000000000000000000000000 を直接組み込むのは不器用に見えますが、安いというよりは高価です。仮想マシンはむしろバイトコードのサイズを増やし、ユーザーのあらゆるガスを節約します。
概要
さて、ここまでお話しましたが、復習してまとめてみましょう。
- スマート コントラクトのライフ サイクルは、展開時と実行時の 2 つの段階に厳密に分割されます。
- スマート コントラクトのコンストラクターは、デプロイ時にのみ実行されます。一度デプロイすると、コンストラクターを再度実行することはできません。
- World State はキーと値のペアであり、各キーは 32 バイト長のデータ ブロックに対応します。
- 上記の点により、イーサリアム仮想マシンは 256 ビット マシンであり、32 バイト長のデータに対して操作を実行するように設計されています。
- World State へのデータの保存には非常にコストがかかります。
- イーサリアム仮想マシンはお金がすべてであり、すべての最適化は必要なガスの削減を中心としています。
次号のプレビュー
パラメーターのないコンストラクターがバイトコードの形式でどのように実行されるかはすでにわかりましたが、パラメーターのあるコンストラクターはどうなるでしょうか?コントラクトをデプロイした後、コントラクト上の関数を呼び出すにはどうすればよいでしょうか? ABI エンコーディングとは何ですか? EVM について詳しく説明する次のブログでは、これらの質問に 1 つずつ答えていきます。