アルゴリズムとかオーダーとか

仕事で勉強したことなどをまとめてます

Solidity Assembly入門 ~ 5つの記憶領域 ~

今回の記事はSolidity Assembly入門という連載記事の第2回目です。
この連載ではSolidityのコードをコンパイルした時に生成されるopcodeについて解説していきます。
この連載ではSolidityのコードをデバッグするのに必要な知識を得られることを目的としています。
前回の記事はこちら。
y-nakajo.hatenablog.com

第2回目の今回は、Solidity(というかEVM上)で利用可能なデータの記憶領域について説明します。

5つの記憶領域

Solidityのコードを実行する時にEVMでは以下の5つの領域を使ってデータのやりとりを行います。

  • stack
  • memory
  • storage
  • calldata
  • returndata

上記5つ以外にもcode領域というSmartContractの本体コードを格納する領域があります。code領域は読み取り専用のため、記憶領域とはちょっと違うので今回の説明では除外します。
基本的にSmartContractのfunctionを実行する時はcode領域を除いた残りの5つの領域を使います。それぞれの領域がいつ使われるのかを説明していきたいと思います。

stack領域

pragma solidity ^0.4.24;


contract SimpleContract {
  function getNum() public pure returns(uint){
      uint a = 1;
      uint b = 2;
      return a + b;
  }
}

上記コードを実行した場合基本的にstack領域のみしか利用しません。stack領域を操作する基本のopcodeは以下のものがあります。

  • PUSH01〜PUSH32: 次に続く1〜32byteのデータをstackの先頭に積みます
  • DUP1〜DUP16: stack上の1〜16番目の値をコピーしてstackの先頭に積みます
  • POP: stackの先頭のデータを取り除きます
  • SWAP1〜SWAP16: stack上の2〜17番目のデータと先頭のデータを入れ替えます。
  • ADD, SUB, MOD, MUL, EXP, ADDMOD, MULMOD: 四則演算系のopcode

それ以外のopcodeも基本的にstack上の値を使います。なのでstackは一番利用される領域になります。

memory領域

pragma solidity ^0.4.24;


contract SimpleContract {
  struct Data {
      uint a;
      uint b;
  }
  function getNum() public pure returns(uint){
      Data memory datas;
      datas.a = 1;
      datas.b = 2;
      return datas.a + datas.b;
  }
}

上記のコードを実行すると、Data構造体に値を格納するためにmemory領域を利用します。
*配列の例も考えたのですが、配列だと結構複雑なopcodeが生成されちゃったのでstructにしました。。。。
Remixでデバッガーを起動すると、memoryの0x80に1を0xa0に2をそれぞれ格納し、0x80と0xa0から値を取り出してADDするopcodeが確認できると思います。memoryを操作するための基本のopcodeは以下です。

  • MSTORE(p, v); pを先頭アドレスとしてp+32の32byte領域にvの値を格納します。
  • MSTOREB(p, v); pの位置にv & 0xffの1byteデータを格納します。
  • MLOAD(p): pを先頭アドレスしてp+32までの32byteの値をロードしてstackの先頭に積みます。

この様に基本的にmemoryへの値のin/outは32byte単位で行われます。他にもsha3やdelegatecallなどの大きなデータを扱うopcodeはメモリを対象として動作します。また、MSTOREやMLOADで渡されるp, vはそれぞれstack上から読み取ります。なのでMSTOREを実行するとstack上から2つの値が取り除かれます。MLOADの場合は1つの値がstack上から取り除かれ、新しい値が積まれます。
memory領域はstackについで2番目によく使われる領域です。

storage領域

pragma solidity ^0.4.24;


contract SimpleContract {
    uint public a;
    uint public b;
    
    function setNum() public {
        a = 1;
        b = 2;
    }
    
    function getAdd() public view returns(uint) {
        return a + b;
    }
}

上記のsetNum()を実行すると、aの値をstorageの0x00の位置に、bの値をstorageの0x01の位置に格納します。
この説明でわかる通り、storage変数はcompileするとすべてstorage上のアドレスへと変換されます。アドレスの振り分けとしては0x00から開始し、変数を定義した順番に0x01、0x02...と振られていきます。1つのstorageアドレスには32byteのデータが格納されます。

また、getAdd()を実行すると、storageの0x00位置と0x01の位置からデータをロードして加算した結果を返します。
storageを操作するopcodeは以下の2つのみです。

  • SSTORE(p, v): storageのpの位置に32byteのv値を格納します。
  • SLOAD(p): storageのpの位置から32byteの値をロードしstackの先頭に積みます。

storageはcontractの状態を保存する領域です。storageに保存した値は永続化されます。そのためstorageの操作には大量のgasが消費されます。gasの消費量についてはこちらを参照ください。
y-nakajo.hatenablog.com

calldata領域

pragma solidity ^0.4.24;


contract SimpleContract {
    function getNum(uint a, uint b) public pure returns(uint) {
        return a + b;
    }
}

上記コードを実行し、デバッガーを起動してprogram counter 086(optimize = falseでcompileした場合)から実行を開始することで、calldatasize, calldataloadの処理が確認できます。よくわからない場合はデバッガーを起動後、プログラムカウンタの先頭からstep実行していくと良いです。
コードからみてわかる通り、calldataはfunctionの引数を格納するための領域です。contextの切り替え(call, delegatecallを呼び出し時に切り替わります)が行われる時に再設定されますが、contextが切り替わらない間は値は保持されます。
calldataを操作するopcodeは以下の3つです。

  • calldatasize: calldataに格納されたデータサイズをstackの先頭に積みます。
  • calldataload(p): calldataのpの位置からp+32の位置までの32byteのデータをstackの先頭に積みます。
  • calldatacopy(t, f, s): calldata領域のfの位置からsで指定されたサイズ(s bytes)のデータをmemory tの位置にコピーします。つまり、メモリのt〜t+sまでの間にコピーします。

calldata領域が使われるのは、transactionをトリガーとしてfunctionがcallされた時か、call, delegatecallで別のcontractを呼び出した時だけです。同じContract内の他のfunctionを呼び出す時はstack領域を使って引数は渡されます。

returndata領域

pragma solidity ^0.4.24;


contract SimpleContract {
    Callee callee;
    constructor(address _callee) public {
        callee = Callee(_callee);
    }
    function getNum() public view returns(uint) {
        uint[] memory res = callee.calcAdd(1,2);
        return res[0] + res[1];
    }
}
contract Callee {
    function calcAdd(uint a, uint b) public pure returns(uint[]) {
        uint[] memory array = new uint[](2);
        array[0] = a;
        array[1] = b;
        return array;
    }
}

上記コードのSimpleContract#getNum()をコールし、デバッガーを起動するとreturndatasize、returndatacopyの動作が確認できます。
program counterの299(optimize=falseでcompileした場合)以降がreturndataから値を取得する処理になります。
コードからわかる通り、returndata領域は他のContractの関数を呼び出したときの返却データの格納先です。これはcalldataと同様にcall、delegatecallでContractのfunctionを呼び出す時(=別のcontextからデータをreturnする時)に利用されます。
Contract内の他のfunctionを呼び出す場合はstackにreturn dataが積まれるので、returndata領域は使われません。
returndataを操作するopcodeはByzantiumがリリースされた時に実装されました。そのため比較的新しいopcodeです。
returndata領域を操作するopcodeは以下の2つです。

  • returndatasize: returndataに格納されているデータのサイズをstackの先頭に積みます。
  • returndatacopy(t, f, s): returndata領域のfの位置からsで指定されたサイズ(s bytes)のデータをmemory tの位置にコピーします。つまり、メモリのt〜t+sまでの間にコピーします。

実はreturn dataはstackやmemory 上にも残っています(context 切り替え時にもmemoryとstackは共通して使われるため)。そのため、return dataが固定サイズ(例えばuintのみとか)の場合はreturndata領域を使わずmemoryからsimpleにロードする様なopcodeに変換されます。

まとめ

基本的にSolidityを書く場合に意識するのはmemoryかstorage領域のみです。calldataやreturndataは直接assemblyを書く時ぐらいしか明示的に使うことはないと思います。
ですが、デバッガーを動かして問題のあるコードを特定したい時はこれらの5つの保存領域とそれぞれを操作するopcodeへの理解があればより効率的に問題点を見つけることができます。