死磕以太坊源碼分析之EVM固定長度數據類型表示

死磕以太坊源碼分析之EVM固定長度數據類型表示

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

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

翻譯自 //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編譯器在未來肯定會改良。對於現在而言,很不幸,我們不能盲目的相信它的優化器。

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