死磕以太坊源碼分析之EVM動態數據類型
死磕以太坊源碼分析之EVM動態數據類型
配合以下程式碼進行閱讀://github.com/blockchainGuide/
寫文不易,給個小關注,有什麼問題可以指出,便於大家交流學習。
Solidity提供了在其他程式語言常見的數據類型。除了簡單的值類型比如數字和結構體,還有一些其他數據類型,隨著數據的增加可以進行動態擴展的動態類型。動態類型的3大類:
- 映射(Mappings):
mapping(bytes32 => uint256)
,mapping(address => string)
等等 - 數組(Arrays):
[]uint256
,[]byte
等等 - 位元組數組(Byte arrays):只有兩種類型:
string
,bytes
在本系列的第二篇文章中我們看見了固定大小的簡單類型在記憶體中的表示方式。
- 基本數值:
uint256
,byte
等等 - 定長數組:
[10]uint8
,[32]byte
,bytes32
- 組合了上面類型的結構體
固定大小的存儲變數都是儘可能的打包成32位元組的塊然後依次存放在存儲器中的。(如果這看起來很陌生,請閱讀本系列的第二篇文章: 固定長度數據類型的表示方法
在本文中我們將會研究Solidity是如何支援更加複雜的數據結構的。在表面上看可能Solidity中的數組和映射比較熟悉,但是從它們的實現方式來看在本質上卻有著不同的性能特徵。
我們會從映射開始,這是三者當中最簡單的。數組和位元組數組其實就是擁有更加高級特徵的映射。
映射
讓我們存儲一個數值在uint256 => uint256
映射中:
pragma solidity ^0.4.11;
contract C {
mapping(uint256 => uint256) items;
function C() {
items[0xC0FEFE] = 0x42;
}
}
編譯:
solc --bin --asm --optimize c-mapping.sol
彙編程式碼:
tag_2:
// 不做任何事情,應該會被優化掉
0xc0fefe
0x0
swap1
dup2
mstore
0x20
mstore
// 將0x42 存儲在地址0x798...187c上
0x42
0x79826054ee948a209ff4a6c9064d7398508d2c1909a392f899d301c6d232187c
sstore
我們可以將EVM想成一個鍵-值( key-value)資料庫,不過每個key都限制為32位元組。與其直接使用key0xC0FEFE
,不如使用key的哈希值0x798...187c
,並且0x42
存儲在這裡。哈希函數使用的是keccak256
(SHA256)函數。
在這個例子中我們沒有看見keccak256
指令本身,因為優化器已經提前計算了結果並內聯到了位元組碼中。在沒什麼作用的mstore
指令中,我們依然可以看到計算的痕迹。
計算地址
使用一些Python程式碼來把0xC0FEFE
哈希成0x798...187c
。如果你想要跟著做下去,你需要安裝Python 3.6,或者安裝pysha3 來獲得keccak_256
哈希函數。
定義兩個協助函數:
import binascii
import sha3
#將數值轉換成32位元組數組
def bytes32(i):
return binascii.unhexlify('%064x' % i)
# 計算32位元組數組的 keccak256 哈希值
def keccak256(x):
return sha3.keccak_256(x).hexdigest()
將數值轉換成32個位元組:
>>> bytes32(1)
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01'
>>> bytes32(0xC0FEFE)
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc0\xfe\xfe'
使用+
操作符,將兩個位元組數組連接起來:
>>> bytes32(1) + bytes32(2)
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02'
計算一些位元組的 keccak256 哈希值:
>>> keccak256(bytes(1))
'bc36789e7a1e281436464229828f817d6612f7b477d66591ff96a9e064bcc98a'
現在我們可以計算0x798...187c
了。
存儲變數items
的位置是0x0
(因為它是第一個存儲變數)。連接key0xc0fefe
和items
的位置來獲取地址:
# key = 0xC0FEFE, position = 0
>>> keccak256(bytes32(0xC0FEFE) + bytes32(0))
'79826054ee948a209ff4a6c9064d7398508d2c1909a392f899d301c6d232187c'
為key計算存儲地址的公式是:
keccak256(bytes32(key) + bytes32(position))
兩個映射
我們先把公式放在這裡,後面數值存儲時需要計算會用到該公式。
假設我們的合約有兩個映射:
pragma solidity ^0.4.11;
contract C {
mapping(uint256 => uint256) itemsA;
mapping(uint256 => uint256) itemsB;
function C() {
itemsA[0xAAAA] = 0xAAAA;
itemsB[0xBBBB] = 0xBBBB;
}
}
itemsA
的位置是0
,key為0xAAAA
:
# key = 0xAAAA, position = 0
>>> keccak256(bytes32(0xAAAA) + bytes32(0))
'839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3'
itemsB
的位置為1
,key為0xBBBB
:
# key = 0xBBBB, position = 1
>>> keccak256(bytes32(0xBBBB) + bytes32(1))
'34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395'
用編譯器來驗證一下這些計算:
$ solc --bin --asm --optimize c-mapping-2.sol
彙編程式碼:
tag_2:
// ... 忽略可能會被優化掉的記憶體操作
0xaaaa
0x839613f731613c3a2f728362760f939c8004b5d9066154aab51d6dadf74733f3
sstore
0xbbbb
0x34cb23340a4263c995af18b23d9f53b67ff379ccaa3a91b75007b010c489d395
sstore
跟期望的結果一樣。
彙編程式碼中的KECCAK256
編譯器可以提前計算key的地址是因為相關的值是常量。如果key使用的是變數,那麼哈希就必須要在彙編程式碼中完成。現在我們無效化優化器,來看看在彙編程式碼中哈希是如何完成的。
事實證明很容易就能讓優化器無效,只要引入一個間接的虛變數i
:
pragma solidity ^0.4.11;
contract C {
mapping(uint256 => uint256) items;
//這個變數會造成常量的優化失敗
uint256 i = 0xC0FEFE;
function C() {
items[i] = 0x42;
}
}
變數items
的位置依然是0x0
,所以我們應該期待地址與之前是一樣的。
加上優化選項進行編譯,但是這次不會提前計算哈希值:
$ solc --bin --asm --optimize c-mapping--no-constant-folding.sol
注釋的彙編程式碼:
tag_2:
// 載入`i` 到棧中
sload(0x1)
[0xC0FEFE]
// 將key`0xC0FEFE`存放在記憶體中的0x0位置上,為哈希做準備
0x0
[0x0 0xC0FEFE]
swap1
[0xC0FEFE 0x0]
dup2
[0x0 0xC0FEFE 0x0]
mstore
[0x0]
memory: {
0x00 => 0xC0FEFE
}
// 將位置 `0x0` 存儲在記憶體中的 0x20 (32)位置上,為哈希做準備
0x20 // 32
[0x20 0x0]
dup2
[0x0 0x20 0x0]
swap1
[0x20 0x0 0x0]
mstore
[0x0]
memory: {
0x00 => 0xC0FEFE
0x20 => 0x0
}
// 從第0個位元組開始,哈希在記憶體中接下來的0x40(64)個位元組
0x40 // 64
[0x40 0x0]
swap1
[0x0 0x40]
keccak256
[0x798...187c]
// 將0x42 存儲在計算的地址上
0x42
[0x42 0x798...187c]
swap1
[0x798...187c 0x42]
sstore
store: {
0x798...187c => 0x42
}
mstore
指令寫入32個位元組到記憶體中。記憶體操作便宜很多,只需要3 gas就可以讀取和寫入。前半部分的彙編程式碼就是通過將key和位置載入到相鄰的記憶體塊中來進行「連接」的:
0 31 32 63
[ key (32 bytes) ][ position (32 bytes) ]
然後keccak256
指令哈希記憶體中的數據。成本取決於被哈希的數據有多少:
- 每個SHA3操作需要支付 30 gas
- 每個32位元組的字需要支付 6 gas
對於一個uint256
類型key,gas的成本是42:30 + 6 * 2
。
映射大數值
每個存儲槽只能存儲32位元組。如果我們嘗試存儲一個更大一點的結構體會怎麼樣?
pragma solidity ^0.4.11;
contract C {
mapping(uint256 => Tuple) tuples;
struct Tuple {
uint256 a;
uint256 b;
uint256 c;
}
function C() {
tuples[0x1].a = 0x1A;
tuples[0x1].b = 0x1B;
tuples[0x1].c = 0x1C;
}
}
編譯,你會看見3個sstore
指令:
tag_2:
//忽略未優化的程式碼
0x1a
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
sstore
0x1b
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7e
sstore
0x1c
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7f
sstore
注意計算的地址除了最後一個數字其他都是一樣的。Tulp
結構體成員是依次排列的(..7d, ..7e, ..7f)。
映射不會打包
考慮到映射的設計方式,每項需要的最小存儲空間是32位元組,即使你實際只需要存儲1個位元組:
pragma solidity ^0.4.11;
contract C {
mapping(uint256 => uint8) items;
function C() {
items[0xA] = 0xAA;
items[0xB] = 0xBB;
}
}
如果一個數值大於32位元組,那麼你需要的存儲空間會以32位元組依次增加。
動態數組是映射的升級
在典型語言中,數組只是連續存儲在記憶體中一系列相同類型的元素。假設你有一個包含100個uint8
類型的元素數組,那麼這就會佔用100個位元組的記憶體。這種模式的話,將整個數組載入到CPU的快取中然後循環遍歷每個元素會便宜一點。
對於大多數語言而言,數組比映射都會便宜一些。不過在Solidity中,數組是更加昂貴的映射。數組裡面的元素會按照順序排列在存儲器中:
0x290d...e563
0x290d...e564
0x290d...e565
0x290d...e566
但是請記住,對於這些存儲槽的每次訪問實際上就像資料庫中的key-value的查找一樣。訪問一個數組的元素跟訪問一個映射的元素是沒什麼區別的。
思考一下[]uint256
類型,它本質上與mapping(uint256 => uint256)
是一樣的,只不過後者多了一點特徵,讓它看起去就像數組一樣。
length
表示一共有多少個元素- 邊界檢查。當讀取或寫入時索引值大於
length
就會報錯 - 比映射更加複雜的存儲打包行為
- 當數組變小時,自動清除未使用的存儲槽
bytes
和string
的特殊優化讓短數組(小於32位元組)存儲更加高效
簡單數組
看一下保存3個元素的數組:
// c-darray.sol
pragma solidity ^0.4.11;
contract C {
uint256[] chunks;
function C() {
chunks.push(0xAA);
chunks.push(0xBB);
chunks.push(0xCC);
}
}
數組訪問的彙編程式碼難以追蹤,使用Remix調試器來運行合約:
模擬的最後,我們可以看到有4個存儲槽被使用了:
key: 0x0000000000000000000000000000000000000000000000000000000000000000
value: 0x0000000000000000000000000000000000000000000000000000000000000003
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
value: 0x00000000000000000000000000000000000000000000000000000000000000aa
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
value: 0x00000000000000000000000000000000000000000000000000000000000000bb
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e565
value: 0x00000000000000000000000000000000000000000000000000000000000000cc
chunks
變數的位置是0x0
,用來存儲數組的長度(0x3
),哈希變數的位置來找到存儲數組數據的地址:
# position = 0
>>> keccak256(bytes32(0))
'290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563'
在這個地址上數組的每個元素依次排列(0x29..63
,0x29..64
,0x29..65
)。
動態數據打包
所有重要的打包行為是什麼樣的?數組與映射比較,數組的一個優勢就是打包。擁有4個元素的uint128[]
數組元素剛剛好需要2個存儲槽(再加1個存儲槽用來存儲長度)。
思考一下:
pragma solidity ^0.4.11;
contract C {
uint128[] s;
function C() {
s.length = 4;
s[0] = 0xAA;
s[1] = 0xBB;
s[2] = 0xCC;
s[3] = 0xDD;
}
}
在Remix中運行這個程式碼,存儲器的最後看起來像這樣:
key: 0x0000000000000000000000000000000000000000000000000000000000000000
value: 0x0000000000000000000000000000000000000000000000000000000000000004
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
value: 0x000000000000000000000000000000bb000000000000000000000000000000aa
key: 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564
value: 0x000000000000000000000000000000dd000000000000000000000000000000cc
只有三個存儲槽被使用了,跟預料的一樣。長度再次存儲在存儲變數的0x0
位置上。4個元素被打包放入兩個獨立的存儲槽中。該數組的開始地址是變數位置的哈希值:
# position = 0
>>> keccak256(bytes32(0))
'290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563'
現在的地址是每兩個數組元素增加一次,看起來很好!
但是彙編程式碼本身優化的並不好。因為使用了兩個存儲槽,所以我們會希望優化器使用兩個sstore
指令來完成任務。不幸的是,由於邊界檢查(和一些其他因素),所以沒有辦法將sstore
指令優化掉。
使用4個sstore
指令才能完成任務:
/* "c-bytes--sstore-optimize-fail.sol":105:116 s[0] = 0xAA */
sstore
/* "c-bytes--sstore-optimize-fail.sol":126:137 s[1] = 0xBB */
sstore
/* "c-bytes--sstore-optimize-fail.sol":147:158 s[2] = 0xCC */
sstore
/* "c-bytes--sstore-optimize-fail.sol":168:179 s[3] = 0xDD */
sstore
位元組數組和字元串
bytes
和string
是為位元組和字元進行優化的特殊數組類型。如果數組的長度小於31位元組,只需要1個存儲槽來存儲整個數組。長一點的位元組數組跟正常數組的表示方式差不多。
看看短一點的位元組數組:
// c-bytes--long.sol
pragma solidity ^0.4.11;
contract C {
bytes s;
function C() {
s.push(0xAA);
s.push(0xBB);
s.push(0xCC);
}
}
因為數組只有3個位元組(小於31位元組),所以它只佔用1個存儲槽。在Remix中運行,存儲看起來如下:
key: 0x0000000000000000000000000000000000000000000000000000000000000000
value: 0xaabbcc0000000000000000000000000000000000000000000000000000000006
數據0xaabbcc...
從左到右的進行存儲。後面的0是空數據。最後的0x06
位元組是數組的編碼長度。公式是長度=編碼長度/2
,在這個例子中實際長度是6/2=3
。
string
與bytes
的原理一模一樣。
長位元組數組
如果數據的長度大於31位元組,位元組數組就跟[]byte
一樣。來看一下長度為128位元組的位元組數組:
// c-bytes--long.sol
pragma solidity ^0.4.11;
contract C {
bytes s;
function C() {
s.length = 32 * 4;
s[31] = 0x1;
s[63] = 0x2;
s[95] = 0x3;
s[127] = 0x4;
}
}
在Remix中運行,可以看見使用了4個存儲槽:
0x0000...0000
0x0000...0101
0x290d...e563
0x0000...0001
0x290d...e564
0x0000...0002
0x290d...e565
0x0000...0003
0x290d...e566
0x0000...0004
0x0
的存儲槽不再用來存儲數據,整個存儲槽現在存儲編碼的數組長度。要獲得實際長度,使用長度=(編碼長度-1)/2
公式。在這個例子中長度是(0x101 - 1)/2=128
。實際的位元組被保存在0x290d...e563
,並且存儲槽是連續的。
位元組數組的彙編程式碼相當多。除了正常的邊界檢查和數組恢復大小等,它還需要對長度進行編碼/解碼,以及注意長位元組數組和短位元組數組之間的轉換。
為什麼要編碼長度?因為編碼之後,可以很容易的測試出來位元組數組是長還是短。注意對於長數組而言編碼長度總是奇數,而短數組的編碼長度總是偶數。彙編程式碼只需要查看一下最後一位是否為0,為0就是偶數(短數組),非0就是奇數(長數組)。
總結
查看Solidity編譯器的內部工作,可以看見熟悉的數據結構例如映射和數組與傳統程式語言完全不同。
概括:
- 數組跟映射一樣,非高效
- 比映射的彙編程式碼更加複雜
- 小類型(
byte
,uint8
,string
)時存儲比映射高效 - 彙編程式碼優化的不是很好。即使是打包,每個任務都會有一個
sstore
指令
EVM的存儲器就是一個鍵值資料庫,跟git很像。如果你改變了任一東西,根節點的校驗和也會改變。如果兩個根節點擁有相同的校驗和,存儲的數據就能保證是一樣的。
為了體會Solidity和EVM的奇特,可以想像一下在git倉庫里數組裡面的每個元素都是它自己的文件。當你改變數組裡一個元素的值,實際上就相當於創建了一個提交。當你迭代一個數組時,你不能一次性的載入整個數組,你必須要到倉庫中進行查找並分別找到每個文件。
不僅僅這樣,每個文件都限制到32位元組!因為我們需要將數據結構都分割成32位元組的塊,Solidity編譯器的所有邏輯和優化都是很負責的,全部在彙編的時候完成。
不過32位元組的限制是完全任意的。支援鍵值存儲的可以使用key來存儲任意類型的數值。也許未來我們添加新的EVM指令使用key來存儲任意的位元組數組。
不過現在,EVM存儲器就是一個偽裝成32位元組數組的鍵值資料庫。
可以看看ArrayUtils::resizeDynamicArray 來了解一下當恢複數組大小時編譯器的動作。正常情況下數據結構都會作為語言的標準庫來完成的,但是在Solidity中嵌入到了編譯器裡面。
翻譯自 //medium.com/@hayeah/diving-into-the-ethereum-vm-part-2-storage-layout-bc5349cb11b7