死磕以太坊原始碼分析之EVM固定長度資料型別表示

mindcarver發表於2021-02-23

死磕以太坊原始碼分析之EVM固定長度資料型別表示

配合以下程式碼進行閱讀:https://github.com/blockchainGuide/

寫文不易,給個小關注,有什麼問題可以指出,便於大家交流學習。

翻譯自 https://medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7

我們先看一個簡單的Solidity合約的彙編程式碼:

contract C {
    uint256 a;
    function C() {
      a = 1;
    }
}

該合約歸結於sstore指令的呼叫:

// a = 1
sstore(0x0, 0x1)
  • EVM將0x1數值儲存在0x0的位置上
  • 每個儲存槽可以儲存正好32位元組(或256位)

在本文中我們將會開始研究Solidity如何使用32位元組的塊來表示更加複雜的資料型別如結構體和陣列。我們也將會看到儲存是如何被優化的,以及優化是如何失敗的。

在典型程式語言中理解資料型別在底層是如何表示的沒有太大的作用。但是在Solidity(或其他的EVM語言)中,這個知識點是非常重要的,因為儲存的訪問是非常昂貴的:

  • sstore指令成本是20000 gas,或比基本的算術指令要貴~5000x
  • sload指令成本是 200 gas,或比基本的算術指令要貴~100x

這裡說的成本,就是真正的金錢,而不僅僅是毫秒級別的效能。執行和使用合約的成本基本上是由sstore指令和sload指令來主導的!

Parsecs磁帶上的Parsecs

image-20210113174357355

構建一個通用計算機器需要兩個基本要素:

  • 一種迴圈的方式,無論是跳轉還是遞迴
  • 無限量的記憶體

EVM的彙編程式碼有跳轉,EVM的儲存器提供無限的記憶體。這對於一切就已經足夠了,包括模擬一個執行以太坊的世界,這個世界本身就是一個模擬執行以太坊的世界.........

EVM的儲存器對於合約來說就像一個無限的自動收報機磁帶,磁帶上的每個槽都能儲存32個位元組,就像這樣:

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

我們將會看到資料是如何在無限的磁帶中生存的。

磁帶的長度是2²⁵⁶,或者每個合約~10⁷⁷儲存槽。可觀測的宇宙粒子數是10⁸⁰。大概1000個合約就可以容納所有的質子、中子和電子。不要相信營銷炒作,因為它比無窮大要短的多。

空磁帶

儲存器初始的時候是空白的,預設是0。擁有無限的磁帶不需要任何的成本。

以一個簡單的合約來演示一下0值的行為:

pragma solidity ^0.4.11;
contract C {
    uint256 a;
    uint256 b;
    uint256 c;
    uint256 d;
    uint256 e;
    uint256 f;
    function C() {
      f = 0xc0fefe;
    }
}

儲存器中的佈局很簡單。

  • 變數a0x0的位置上
  • 變數b0x1的位置上
  • 以此類推.........

關鍵問題是:如果我們只使用f,我們需要為abcde支付多少成本?

編譯一下再看:

$ solc --bin --asm --optimize c-many-variables.sol

彙編程式碼:

// sstore(0x5, 0xc0fefe)
tag_2:
  0xc0fefe
  0x5
  sstore

所以一個儲存變數的宣告不需要任何成本,因為沒有初始化的必要。Solidity為儲存變數保留了位置,但是隻有當你儲存資料進去的時候才需要進行付費。

這樣的話,我們只需要為儲存0x5進行付費。

如果我們手動編寫彙編程式碼的話,我們可以選擇任意的儲存位置,而用不著"擴充套件"儲存器:

// 編寫一個任意的儲存位置
sstore(0xc0fefe, 0x42)

讀取零

你不僅可以寫在儲存器的任意位置,你還可以立刻讀取任意的位置。從一個未初始化的位置讀取只會返回0x0

讓我們看看一個合約從一個未初始化的位置a讀取資料:

pragma solidity ^0.4.11;
contract C {
    uint256 a;
    function C() {
      a = a + 1;
    }
}

編譯:

$ solc --bin --asm --optimize c-zero-value.sol

彙編程式碼:

tag_2:
  // sload(0x0) returning 0x0
  0x0
  dup1
  sload
  // a + 1; where a == 0
  0x1
  add
  // sstore(0x0, a + 1)
  swap1
  sstore

注意生成從一個未初始化的位置sload的程式碼是無效的。

然而,我們可以比Solidity編譯器聰明。既然我們知道tag_2是構造器,而且a從未被寫入過資料,那麼我們可以用0x0替換掉sload,以此節省5000 gas。

結構體的表示

來看一下我們的第一個複雜資料型別,一個擁有 6 個域的結構體:

pragma solidity ^0.4.11;
contract C {
    struct Tuple {
      uint256 a;
      uint256 b;
      uint256 c;
      uint256 d;
      uint256 e;
      uint256 f;
    }
    Tuple t;
    function C() {
      t.f = 0xC0FEFE;
    }
}

儲存器中的佈局和狀態變數是一樣的:

  • t.a域在0x0的位置上
  • t.b域在0x1的位置上
  • 以此類推.........

就像之前一樣,我們可以直接寫入t.f而不用為初始化付費。

編譯一下:

$ solc --bin --asm --optimize c-struct-fields.sol

然後我們看見一模一樣的彙編程式碼:

tag_2:
  0xc0fefe
  0x5
  sstore

固定長度陣列

讓我們來宣告一個定長陣列:

pragma solidity ^0.4.11;
contract C {
    uint256[6] numbers;
    function C() {
      numbers[5] = 0xC0FEFE;
    }
}

因為編譯器知道這裡到底有幾個uint256(32位元組)型別的數值,所以它可以很容易讓陣列裡面的元素依次儲存起來,就像它儲存變數和結構體一樣。

在這個合約中,我們再次儲存到0x5的位置上。

編譯:

$ solc --bin --asm --optimize c-static-array.sol

彙編程式碼:

tag_2:
  0xc0fefe
  0x0
  0x5
tag_4:
  add
  0x0
tag_5:
  pop
  sstore

這個稍微長一點,但是如果你仔細一點,你會看見它們其實是一樣的。我們手動的來優化一下:

tag_2:
  0xc0fefe
  // 0+5. 替換為0x5
  0x0
  0x5
  add
  // 壓入棧中然後立刻出棧。沒有作用,只是移除
  0x0
  pop
  sstore

移除掉標記和偽指令之後,我們再次得到相同的位元組碼序列:

tag_2:
  0xc0fefe
  0x5
  sstore

陣列邊界檢查

我們看到了定長陣列、結構體和狀態變數在儲存器中的佈局是一樣的,但是產生的彙編程式碼是不同的。這是因為Solidity為陣列的訪問產生了邊界檢查程式碼。

讓我們再次編譯陣列合約,這次去掉優化的選項:

$ solc --bin --asm c-static-array.sol

彙編程式碼在下面已經註釋了,並且列印出每條指令的機器狀態:

tag_2:
  0xc0fefe
    [0xc0fefe]
  0x5
    [0x5 0xc0fefe]
  dup1
  /* 陣列邊界檢查程式碼 */
  // 5 < 6
  0x6
    [0x6 0x5 0xc0fefe]
  dup2
    [0x5 0x6 0x5 0xc0fefe]
  lt
    [0x1 0x5 0xc0fefe]
  // bound_check_ok = 1 (TRUE)
  // if(bound_check_ok) { goto tag5 } else { invalid }
  tag_5
    [tag_5 0x1 0x5 0xc0fefe]
  jumpi
    // 測試條件為真,跳轉到 tag_5.
    //  `jumpi` 從棧中消耗兩項資料
    [0x5 0xc0fefe]
  invalid
// 資料訪問有效,繼續執行
// stack: [0x5 0xc0fefe]
tag_5:
  sstore
    []
    storage: { 0x5 => 0xc0fefe }

我們現在已經看見了邊界檢查程式碼。我們也看見了編譯器可以對這類東西進行一些優化,但是不是非常完美。

在本文的後面我們將會看到陣列的邊界檢查是如何干擾編譯器優化的,比起儲存變數和結構體,定長陣列的效率更低。

打包行為

儲存是非常昂貴的。一個關鍵的優化就是儘可能的將資料打包成一個32位元組數值。

考慮一個有 4 個儲存變數的合約,每個變數都是 64 位,全部加起來就是 256 位(32位元組):

pragma solidity ^0.4.11;
contract C {
    uint64 a;
    uint64 b;
    uint64 c;
    uint64 d;
    function C() {
      a = 0xaaaa;
      b = 0xbbbb;
      c = 0xcccc;
      d = 0xdddd;
    }
}

我們期望(希望)編譯器使用一個sstore指令將這些資料存放到同一個儲存槽中。

編譯:

$ solc --bin --asm --optimize c-many-variables--packing.sol

彙編程式碼:

tag_2:
    /* "c-many-variables--packing.sol":121:122  a */
  0x0
    /* "c-many-variables--packing.sol":121:131  a = 0xaaaa */
  dup1
  sload
    /* "c-many-variables--packing.sol":125:131  0xaaaa */
  0xaaaa
  not(0xffffffffffffffff)
    /* "c-many-variables--packing.sol":121:131  a = 0xaaaa */
  swap1
  swap2
  and
  or
  not(sub(exp(0x2, 0x80), exp(0x2, 0x40)))
    /* "c-many-variables--packing.sol":139:149  b = 0xbbbb */
  and
  0xbbbb0000000000000000
  or
  not(sub(exp(0x2, 0xc0), exp(0x2, 0x80)))
    /* "c-many-variables--packing.sol":157:167  c = 0xcccc */
  and
  0xcccc00000000000000000000000000000000
  or
  sub(exp(0x2, 0xc0), 0x1)
    /* "c-many-variables--packing.sol":175:185  d = 0xdddd */
  and
  0xdddd000000000000000000000000000000000000000000000000
  or
  swap1
  sstore

這裡還是有很多的位轉移我沒能弄明白,但是無所謂。最關鍵事情是這裡只有一個sstore指令。

這樣優化就成功!

干擾優化器

優化器並不能一直工作的這麼好。讓我們來干擾一下優化器。唯一的改變就是使用協助函式來設定儲存變數:

pragma solidity ^0.4.11;
contract C {
    uint64 a;
    uint64 b;
    uint64 c;
    uint64 d;
    function C() {
      setAB();
      setCD();
    }
    function setAB() internal {
      a = 0xaaaa;
      b = 0xbbbb;
    }
    function setCD() internal {
      c = 0xcccc;
      d = 0xdddd;
    }
}

編譯:

$ solc --bin --asm --optimize c-many-variables--packing-helpers.sol

輸出的彙編程式碼太多了,我們忽略了大多數的細節,只關注結構體:

// 構造器函式
tag_2:
  // ...
  // 通過跳到tag_5來呼叫setAB()
  jump
tag_4:
  // ...
  //通過跳到tag_7來呼叫setCD() 
  jump
// setAB()函式
tag_5:
  // 進行位轉移和設定a,b
  // ...
  sstore
tag_9:
  jump  // 返回到呼叫setAB()的地方
//setCD()函式
tag_7:
  // 進行位轉移和設定c,d
  // ...
  sstore
tag_10:
  jump  // 返回到呼叫setCD()的地方

現在這裡有兩個sstore指令而不是一個。Solidity編譯器可以優化一個標籤內的東西,但是無法優化跨標籤的。

呼叫函式會讓你消耗更多的成本,不是因為函式呼叫昂貴(他們只是一個跳轉指令),而是因為sstore指令的優化可能會失敗。

為了解決這個問題,Solidity編譯器應該學會如何行內函數,本質上就是不用呼叫函式也能得到相同的程式碼:

a = 0xaaaa;
b = 0xbbbb;
c = 0xcccc;
d = 0xdddd;

如果我們仔細閱讀輸出的完整彙編程式碼,我們會看見setAB()setCD()函式的彙編程式碼被包含了兩次,不僅使程式碼變得臃腫了,並且還需要花費額外的gas來部署合約。在學習合約的生命週期時我們再來談談這個問題。

為什麼優化器會被干擾?

因為優化器不會跨標籤進行優化。思考一下"1+1",在同一個標籤下,它會被優化成0x2:

// 優化成功!
tag_0:
  0x1
  0x1
  add
  ...

但是如果指令被標籤分開的話就不會被優化了:

// 優化失敗!
tag_0:
  0x1
  0x1
tag_1:
  add
  ...

在0.4.13版本中上面的行為都是真實的。也許未來會改變。

再次干擾優化器

讓我們看看優化器失敗的另一種方式,打包適用於定長陣列嗎?思考一下:

pragma solidity ^0.4.11;
contract C {
    uint64[4] numbers;
    function C() {
      numbers[0] = 0x0;
      numbers[1] = 0x1111;
      numbers[2] = 0x2222;
      numbers[3] = 0x3333;
    }
}

再一次,這裡有4個64位的數值我們希望能打包成一個32位的數值,只使用一個sstore指令。

編譯的彙編程式碼太長了,我們就數數sstoresload指令的條數:

$ solc --bin --asm --optimize c-static-array--packing.sol | grep -E '(sstore|sload)'
  sload
  sstore
  sload
  sstore
  sload
  sstore
  sload
  sstore

哦,不!即使定長陣列與等效的結構體和儲存變數的儲存佈局是一樣的,優化也失敗了。現在需要4對sloadsstore指令。

快速的看一下彙編程式碼,可以發現每個陣列的訪問都有一個邊界檢查程式碼,它們在不同的標籤下被組織起來。優化無法跨標籤,所以優化失敗。

不過有個小安慰。其他額外的3個sstore指令比第一個要便宜:

  • sstore指令第一次寫入一個新位置需要花費 20000 gas
  • sstore指令後續寫入一個已存在的位置需要花費 5000 gas

所以這個特殊的優化失敗會花費我們35000 gas而不是20000 gas,多了額外的75%。

總結

如果Solidity編譯器能弄清楚儲存變數的大小,它就會將這些變數依次的放入儲存器中。如果可能的話,編譯器會將資料緊密的打包成32位元組的塊。

總結一下目前我們見到的打包行為:

  • 儲存變數:打包
  • 結構體:打包
  • 定長陣列:不打包。在理論上應該是打包的

因為儲存器訪問的成本較高,所以你應該將儲存變數作為自己的資料庫模式。當寫一個合約時,做一個小實驗是比較有用的,檢測彙編程式碼看看編譯器是否進行了正確的優化。

我們可以肯定Solidity編譯器在未來肯定會改良。對於現在而言,很不幸,我們不能盲目的相信它的優化器。

它需要你真正的理解儲存變數。

相關文章