イーサリアム仮想マシンの内部。 パート1-堅牢性の基本

最近、ニュースで頻繁に「暗号通貨」と「ブロックチェーン」という言葉を聞くことができ、その結果、これらの技術に興味を持っている多くの人々の流入があり、それとともに膨大な数の新製品があります。 多くの場合、プロジェクトの何らかの内部ロジックを実装したり、資金を調達したりするために、「スマートコントラクト」が使用されます。これは、イーサリアムプラットフォームで作成され、ブロックチェーン内で動作する特別なプログラムです。 ネットワーク上には、単純なスマートコントラクトと基本原則の作成に専念する資料が既に十分にありますが、実際には、Ethereum仮想マシン(以下、EVM)の動作についての説明は下位レベルにないため、このシリーズの記事では、EVMの動作をより詳細に分析したいと思います。







Solidity-スマートコントラクトの開発用に作成された言語は比較的最近存在します-その開発は2014年にのみ開始され、その結果、一部の場所では ``湿った ''です。 この記事では、EVMの操作のより一般的な説明と、下位レベルの操作を理解するために必要な堅牢性の特徴的な機能のいくつかから始めます。







追伸この記事では、スマートコントラクトの作成に関する一般的な知識と、イーサリアムブロックチェーン全般に関する知識があることを前提としています。









目次



  1. 記憶

    • 保管
    • 記憶
    • スタック
  2. 複合型のデータの場所
  3. トランザクションとメッセージ呼び出し
  4. 可視性
  5. リンク集


メモリタイプ



EVMの複雑さを詳しく説明する前に、最も重要なポイントの1つ、つまりすべてのデータがどこにどのように保存されているかを理解する必要があります。 これは非常に重要です。EVMのメモリ領域はデバイスによって大きく異なり、その結果、データの読み取り/書き込みのコストだけでなく、それらを操作するメカニズムも異なるためです。







保管



最初で最も高価なタイプのメモリはストレージです。 各コントラクトには独自のストレージメモリがあり、すべてのグローバル変数( 状態変数 )が格納され、その状態は関数呼び出し間で一定です。 ハードドライブと比較できます。現在のコードの完了後、すべてがブロックチェーンに書き込まれ、次回契約が呼び出されたときに、以前に取得したすべてのデータにアクセスできます。







contract Test { // this variable is stored in storage uint some_data; // has default value for uint type (0) function set(uint arg1) { some_data = arg1; // some_data value was changed and saved in global } }
      
      





構造的に、ストレージはキー値ストレージであり、すべてのセルのサイズは32バイトであり、ハッシュテーブルと非常によく似ているため、このメモリは非常にまばらであり、2つの隣接セルにデータを保存してもメリットはありません。最初のセルと1000番目のセルの他のセルには、セル1とセル2に保存した場合と同じ量のガスがかかります。







 [32 bytes][32 bytes][32 bytes]...
      
      





既に述べたように、このタイプのメモリは最も高価です-ストレージ内の新しいセルを占有するには20,000ガス、占有を変更するには5,000ガス、読み取りには200ガスが必要です。 理由は簡単です-ストレージコントラクトに保存されたデータはブロックチェーンに書き込まれ、永遠にそこに残ります。







また、コントラクトに保存できる情報の最大量を計算することは難しくありません。セルの数は2 ^ 256、各サイズは32バイトなので、2 ^ 261バイトです。 実際、一種のチューリングマシンがあります-再帰的に呼び出し/ジャンプする能力と、ほぼ無限のメモリです。 イーサリアムをシミュレートする別のイーサリアムをシミュレートするには十分すぎる:)







https://i.imgur.com/fPD96YR.jpg



記憶



メモリの2番目のタイプはメモリです。 ストレージよりもはるかに安価で、外部(次の章で関数タイプについて読むことができます)関数呼び出し間でクリアされ、一時データを保存するために使用されます:たとえば、関数に渡される引数、ローカル変数、戻り値を保存します。 これはRAMと比較できます-コンピューター(この場合はEVM)がシャットダウンすると、その内容は消去されます。







 contract Test { ... function (uint a, uint b) returns (uint) { // a and b are stored in memory uint c = a + b // c has been written to memory too return c } }
      
      





内部デバイスによると、メモリはバイト配列です。 最初はサイズはゼロですが、32バイト部分に拡張できます。 ストレージとは異なり、メモリは連続的であるため、十分にパックされています.2つの変数を格納する長さ2の配列を格納する方が、同じ2つの変数を両端に格納し、中央にゼロを格納する長さ1000の配列よりもはるかに安価です。







1つのマシンワードの読み取りと書き込み(EVMでは256ビットであることを思い出します)には3ガスしかかかりませんが、メモリの拡張により現在のサイズに応じてコストが増加します。 数KBのストレージは安価になりますが、価格が二次的に上昇するため、すでに1 MBには数百万のガスがかかります。







 // fee for expanding memory to SZ TOTALFEE(SZ) = SZ * 3 + floor(SZ**2 / 512) // if we need to expand memory from x to y, it would be // TOTALFEE(y) - TOTALFEE(x)
      
      





スタック



EVMにはスタック構成があるため、メモリの最後の領域がスタックであることは驚くことではありません。すべてのEVM計算に使用され、その価格はメモリに似ています。 最大サイズは1024要素の256ビットですが、使用できるのは上位16要素のみです。 もちろん、スタック要素をメモリまたはストレージに移動できますが、最初にスタックの最上部を削除しないとランダムアクセスは不可能です。 スタックがいっぱいになると、契約が中断されるため、コンパイラーにすべての作業を残すようにアドバイスします;)







複合型のデータの場所



堅牢性において、256ビットに収まらない可能性のある構造や配列などの「複雑な」タイプを扱う場合は、より慎重に整理する必要があります。 それらのコピーは非常に高価になる可能性があるため、メモリ(定数ではない)またはストレージ(すべてのグローバル変数が格納される)にそれらを格納する場所を考慮する必要があります。 このため、配列と構造の堅牢性には、追加のパラメーター「データの場所」があります。 コンテキストに応じて、このパラメーターは常に標準値を持ちますが、storageおよびmemoryキーワードで変更できます。 関数の引数のデフォルト値はメモリ、ローカル変数の場合はストレージ(単純型の場合はメモリ)、グローバル変数の場合は常にストレージです。







3番目の場所-calldataもあります。 そこにあるデータは不変であり、それらを扱う作業はメモリ内のように編成されます。 外部関数の引数は常にcalldataに保存されます。







データの場所も重要です。割り当て演算子の動作に影響するためです。ストレージとメモリ内の変数間の割り当ては、常に独立したコピーを作成しますが、ローカルストレージを変数に割り当てると、グローバル変数を指すリンクのみが作成されます。 タイプメモリの割り当て-メモリもコピーを作成しません。







 contract C { uint[] x; // the data location of x is storage // the data location of memoryArray is memory function f(uint[] memoryArray) { x = memoryArray; // works, copies the whole array to storage // var is just a shortcut, that allows us automatically detect a type // you can replace it with uint[] var y = x; // works, assigns a pointer, data location of y is storage y[7]; // fine, returns the 8th element of x y.length = 2; // fine, modifies x through y delete x; // fine, clears the array, also modifies y uint[3] memory tmpArr = [1, 2, 3]; // tmpArr is located in memory var z = tmpArr; // works, assigns a pointer, data location of z is memory // The following does not work; it would need to create a new temporary / // unnamed array in storage, but storage is "statically" allocated: y = memoryArray; // This does not work either, since it would "reset" the pointer, but there // is no sensible location it could point to. delete y; g(x); // calls g, handing over a reference to x h(x); // calls h and creates an independent, temporary copy of x in memory h(tmpArr) // calls h, handing over a reference to tmpArr } function g(uint[] storage storageArray) internal {} function h(uint[] memoryArray) internal {} }
      
      





トランザクションとメッセージ呼び出し



Ethereumには、同じアドレススペースを共有する2種類のアカウントがあります。 外部アカウント -秘密公開キー(または単に人のアカウント)のペアによって制御される通常のアカウントと契約アカウント -一緒に保存されるコードによって制御されるアカウント(スマート契約)。 トランザクションは、あるアカウントから別のアカウント(同じ、または特別なゼロアカウント、以下を参照)へのメッセージであり、データ( ペイロード )とイーサが含まれます。







通常のアカウント間のトランザクションでは、すべてが明確です-それらは価値を伝えるだけです。 ターゲットアカウントがゼロアカウント(アドレス0)の場合、トランザクションは新しい契約を作成し、そのアドレスは送信者のアドレスと送信されたトランザクションの数(「nonce」アカウント)から形成されます。 このようなトランザクションのペイロードは、EVMによってバイトコードとして解釈されて実行され、出力は契約コードとして保存されます。







ターゲットアカウントが契約アカウントの場合、そのアカウントのコードが実行され、ペイロードが入力として送信されます。 契約アカウントはトランザクションを個別に送信できませんが、受信したトランザクション(外部​​アカウントと他の契約アカウントの両方から)に応じてトランザクションを実行できます。 したがって、内部トランザクション( メッセージ呼び出し )を介して、コントラクトの相互作用を保証することが可能です。 内部トランザクションは通常のものと同一です-送信者、受信者、エーテル、ガスなどもあり、送信時にそれらのガス制限を契約で設定できます。 通常のアカウントで作成されたトランザクションとの唯一の違いは、Ethereumランタイムに排他的に存在することです。







可視性



solidityには関数と変数の「可視性」の4種類があります-external、 publicinternalprivate 、標準はpublicです。 グローバル変数の場合、標準は内部であり、外部は不可能です。 したがって、すべてのオプションを検討します。









明確にするために、小さな例を考えてみましょう。







 contract C { uint private data; function f(uint a) private returns(uint b) { return a + 1; } function setData(uint a) { data = a; } // default to public function getData() public returns(uint) { return data; } function compute(uint a, uint b) internal returns (uint) { return a+b; } } contract D { uint local; function readData() { C c = new C(); uint local = cf(7); // error: member "f" is not visible c.setData(3); local = c.getData(); local = c.compute(3, 5); // error: member "compute" is not visible } } contract E is C { function g() { C c = new C(); uint val = compute(3, 5); // acces to internal member (from derivated to parent contract) uint tmp = f(8); // error: member "f" is not visible in derived contracts } }
      
      





最も一般的な質問の1つは、「常にパブリックを使用できるのに、なぜ外部関数が必要なのか」です。 実際、外部をパブリックに置き換えることができない場合はありませんが、既に書いたように、場合によってはより効率的です。 特定の例を見てみましょう。







 contract Test { function test(uint[3] a) public returns (uint) { // a is copied to memory return a[2]*2; } function test2(uint[3] a) external returns (uint) { // a is located in calldata return a[2]*2; } }
      
      





パブリック関数の実行には413ガスかかりますが、外部バージョンの呼び出しは281のみです。これは、外部関数からの読み取りが直接calldataから行われている間に配列がパブリック関数のメモリにコピーされるためです。 メモリの割り当ては、calldataからの読み取りよりも明らかに高価です。







パブリック関数がすべての引数をメモリにコピーする必要がある理由は、それらがコントラクト内から呼び出すこともできるためです。これは完全に異なるプロセスです。前述のように、コードをジャンプすることで機能し、配列が渡されますメモリへのポインタ。 したがって、コンパイラは内部関数のコードを生成するときに、メモリ内の引数を参照することを期待しています。







外部関数の場合、コンパイラは内部アクセスを提供する必要がないため、メモリへのコピー手順をバイパスして、calldataから直接データを読み取るためのアクセスを提供します。







したがって、「可視性」のタイプを適切に選択すると、機能へのアクセスを制限するだけでなく、機能をより効率的に使用できるようになります。







PS:次の記事では、バイトコードレベルでの複合型の分析と最適化について説明し、現時点で堅牢性に存在する主な脆弱性とバグについても説明します。












All Articles