📖

Chapter 11: Assembly Programming | Solidity Programming Essentialsを読む

2022/08/06に公開

イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter11「Assembly Programming」を読み進めます。

Solidity Programming Essentials: A guide to building smart contracts and tokens using the widely used Solidity language, 2nd Edition (English Edition)

読書ログは以下のスクラップで逐次更新していきます。
https://zenn.dev/mah/scraps/ea8c79961ae8c8


この章で扱われるトピックは以下の通りです。

  • An introduction to Solidity and its advantages
  • Getting started with Assembly programming in Solidity
  • Scopes and blocks
  • Returning values from assembly blocks
  • Using memory slots from assembly
  • Using storage slots from assembly
  • Calling contract functions
  • Determining contract or externally owned account addresses

なぜアセンブリプログラミングが必要なのか

Solidityのアセンブリプログラミングはopcodeを直接記述する低レベルプログラミングを指します。アセンブリ言語を使用する利点は以下の通りです。

  • ケイパビリティの増大:アセンブリを利用することでしかできないことがあります。例えばアドレスがコントラクトアドレスかどうかを判断することはアセンブリでならできますが、Solidityでは確認できません。
  • ガス使用量の最適化:Solidityコンパイラで生成されたコードと比較してアセンブリコードは命令数が少ないため、コードを最適化することができます。
  • 完全な制御が可能:アセンブリ言語を直接記述することで、コンパイラが生成するコードと比較して生成されるバイトコードをより詳細に制御することができます。

Solidityでアセンブリコードを記述するスタイルには関数型と非関数型がありますが、本書ではより直感的に理解できる関数型スタイルに焦点をあてます。関数と内部関数を使用して、opcodeを引数と共にスタックにプッシュします。

以下はmloadopcodeが0x80のメモリ位置に格納されている値をロードし、それに3を加えてmstoreopcodeを使って同じメモリ位置に格納し直す例です。

mstore(0x80, add(mload(0x80), 3))

非関数型スタイルでは以下のように記述されます。

3 0x80 mload add 0x80 mstore

アセンブリプログラミングをはじめよう

Solidityコード内にアセンブリコードを埋め込むインラインアセンブリはassemblyキーワードで埋め込むことができます。以下がそのコード例です。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract SimpleAssembly {
  function AssemblyUsage() public pure returns (uint256) {
    uint256 i = 10;

    assembly {
      i := 100
    }

    assembly {
      i := 200
    }

    return i; // 200が返る
  }
}

この例のアセンブリブロックには以下の特徴があります。

  • ステートメントの最後にセミコロンがありません。改行が文の終端として機能します。
  • 変数への値の代入には:=を使用します。
  • アセンブリブロックはブロックの外部で定義された変数にその名前を使用してアクセスすることができます。
  • アセンブリブロックは親スコープで定義された変数に対して値の読み書きができます。

また、アセンブリブロック内でletキーワードを使い新しい変数を宣言することができます。ただしデータ型はなく、常に32バイトのメモリサイズが変数に割り当てられます。以下がそのコード例です。

contract AssemblyVariablesAndSimpleFunctions {
  function AssemblyUsage() public pure returns (uint256) {
    uint256 i;

    assembly {
      // this is a comment
      /*
        this is a
        multiline comment
      */
      let stringVal := "ritesh modi"
      let uintVal := 100
      let byteVal := 0x100
      let newvariable := add(10, 30)
      i := newvariable
    }

    return i;
  }
}

スコープとブロック

アセンブリブロックはネストさせることができます。ブロックはスコープであり、スコープ内で宣言された変数はブロックから離れると同時に割り当てが解除されます。ネストされたブロックの中で宣言された変数はブロックの外からは見えません。

この動作のコード例は以下になります。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract AssemblyScopes {
  function getValues() public pure returns (uint256 retval) {
    assembly {
      let outerValue := 10

      {
        let innerValue := 20

        {
          innerValue := 30
        }
      }

      {
        // 以下の変数を宣言しようとすると、既に変数名が使われている
        // let outerValue := 40
        // innerValue変数をこのブロックで使うことはできない
        // innerValue := 50
      }

      // innerValue変数は既に解放されている
      // retval := innerValue
      retval := outerValue
    }
  }
}

値を返す

アセンブリブロックは親ブロックに値を返すことができます。一つの方法はこれまでの例のように関数内で定義されている変数に代入する方法で、もう一つがreturnopcodeを利用して値を返す方法です。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract ReturningValues {
  function usingReturnOpcode() public pure returns (uint256) {
    assembly {
      let temp := 10
      mstore(0x0, temp)
      return(0x0, 32)
    }
  }
}

Working with memory slots

これまでの例にも登場してきた通り、アセンブリにはメモリ変数に値をロードまたは保存するためのmloadおよびmstoreopcodeが用意されています。mloadopcodeはメモリ位置を指定してその場所に格納されている値をロードし、mstoreopcodeはメモリにデータを格納します。

ここでSolidityにおけるメモリアドレスの扱いについて補足を。

  • メモリの最初の64バイト(0x00x3f)は短期間の割り当てのためのスクラッチスペースとして使用することができる。
  • メモリ内の0x40の位置にフリーメモリポインタがある(0x400x5f)。メモリを確保したい場合はこのポインタが指す位置から始まるメモリ使用し、更新する。このメモリが以前に使われていない保証はないので、その中身が0バイトであると仮定することはできない。
  • フリーメモリポインタの後、0x60から始まる32バイトは永久にゼロであることを意味し、空の動的メモリアレイの初期値として使用される。
  • よって、割り当て可能なメモリは0x80から始まることになる。

Inline Assembly > Memory Management | Solidity

メモリアドレスの補足を踏まえた上で、実際のコード例は以下のような形になります。

function AssemblyUsage() public pure returns (uint256) {
  assembly {
    let addresult := add(100, 200)
    mstore(0x40, addresult)
    let y := mload(0x40)
    mstore(add(0x40, 0x40), add(y, 200))
    return(0x80, 32) // 500が返る
  }
}

Working with storage slots

メモリ変数と同じような形で、ストレージの場合はsloadopcodeとsstoreopcodeを利用します。sloadは引数に指定するストレージのスロット番号からデータを読み出します。sstore`は第一引数にストレージのスロット番号を取り、第二引数に格納するデータを指定します。

メモリアドレスと異なり、ストレージのスロット番号は0からの連番になります。以下がコード例です。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract StorageAssembly {
  uint256 StateVariable;

  function AssemblyUsage()
    public
    returns (uint256 newstatevariable, uint256 newderivedvariable)
  {
    assembly {
      sstore(0x0, 100)
      newstatevariable := sload(0x0)
      sstore(0x1, 200)
      newderivedvariable := sload(0x1)
    }
  }

  function GetStateVariable() public view returns (uint256) {
    return StateVariable;
  }

  function GetNewDerivedVariable() public view returns (uint256 slot1) {
    assembly {
      slot1 := sload(0x1)
    }
  }

  function UpdateStateVariable(uint256 intValue)
    public
    returns (uint256 newValue)
  {
    assembly {
      newValue := add(intValue, sload(0x0))
      sstore(0x0, newValue)
    }
  }
}

ステート変数は宣言順に0スロットから設定されていきます。すなわちStateVariableステート変数は0スロットに定義されており、sstore(0x0, 100)で値を上書きすることができます。更に上記の例ではsstore(0x1, 200)で新しいスロットに書き込みを行っています。ただ0x1の変数には名前が割り当てられていないので、アセンブリとは別に使用することはできません。

コントラクト関数を呼び出す

アセンブリコードからはcallopcodeを利用してコントラクト内の関数を呼び出すことができます。

call(g, a, v, in, insize, out, outsize)
  • g: callで送られるガスの量
  • a: ターゲットコントラクトのアドレス
  • v: Etherの量(wei)
  • in: EVMに送るデータ(メソッド署名、パラメータ値)を設定しているメモリ開始位置
  • insize: EVMに送るデータサイズ(16進数表現)
  • out: 戻り値を格納するメモリ位置
  • outsize: 戻り値のデータサイズ(16進数表現)

ここでは次のコントラクトをcallで呼び出す例を考えます。

contract TargetContract {
  function GetAddition(
    uint256 firstVal,
    uint256 secondVal,
    uint256 thirdVal
  ) public pure returns (uint256) {
    return firstVal + secondVal + thirdVal;
  }
}

以下がアセンブリコード内からTargetContract内のGetAddition関数を呼び出す例です。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract CallingContract {
  TargetContract targetContract;

  constructor() {
    targetContract = new TargetContract();
  }

  function InvokeTarget(
    uint256 firstVal,
    uint256 secondVal,
    uint256 thirdVal
  ) public returns (uint256 retval) {
    address addr = address(targetContract);

    bytes4 functionSignature = bytes4(
      keccak256("GetAddition(uint256,uint256,uint256)")
    );

    assembly {
      let freePointer := mload(0x40)
      mstore(freePointer, functionSignature)
      mstore(add(freePointer, 0x04), firstVal)
      mstore(add(freePointer, 0x24), secondVal)
      mstore(add(freePointer, 0x44), thirdVal)
      let success := call(100000, addr, 0, freePointer, 0x64, freePointer, 0x20)
      retval := mload(freePointer)
    }
  }
}

コントラクトのアドレスと関数の識別子はSolidityのコードで取得しています(参考:関数の識別子の取得方法はここで説明しています)。

この識別子を0x40の位置に設定し、その4バイト後の0x44の位置から32バイトずつ連続してパラメータを設定しています。合計で100バイトのデータを送信するので、送信サイズは16進数表現で0x64になります。

戻り値は同じ0x40に戻してもらうとして、戻り値のサイズは32バイトなので16進数表現で0x20を設定しています。最後にmloadopcodeで0x40位置のデータを返しています。

コントラクトアドレスの判別

次に与えられたアドレスが外部アカウントに属するのか、コントラクトアカウントに属するのかを調べてみましょう。これは今のところアセンブリを利用しないとできない処理です。

この判別のためにはextcodesizeopcodeを利用します。アドレスを引数にとり、返す値の長さが0よりも大きければコントラクトアカウントであり、そうでなければ外部アカウントになります。以下がコード例です。

function CheckIfContract(address contractAddress) public view returns (bool) {
  uint256 length;

  assembly {
    length := extcodesize(contractAddress)
  }

  if (length > 0) {
    return true;
  }

  return false;
}

extcodesizeopcodeはアドレスに格納されているコードサイズを返すので、この判断ができるわけですね。この章の内容はここで終わりです。

Discussion