點擊閱讀:EVM 深入探討Part 1
導語
在第1 部分中,我們探討了EVM 如何通過被調用的合約函數知道需要運行哪個字節碼,其中我們了解了調用棧、calldata、函數簽名和EVM 操作碼指令。
在第2 部分中,我們將開啟內存之旅,全面了解合約的內存以及它在EVM 上的工作方式。
此系列我們將引介翻譯noxx 的文章(https://noxx.substack.com/)深入探討EVM 的基礎知識。
內存之旅
我們依然使用第1 部分中在remix 上為大家演示的示例代碼。
第1 部分中我們根據合約編譯後生成的字節碼研究了與功能選擇相關的部分。在本文中,我們將注意力放在字節碼的前5 個字節。
這5 個字節表示初始化“空閒內存指針” 操作。要完全理解這些字節碼的作用,首先需要理解管理支配合約內存的數據結構。
1、內存數據結構
合約內存是一個簡單的字節數組,其中數據存儲可以使用32 字節(256 位)或1 字節(8 位)的數據塊存儲數據,但是讀取時每次只能讀取固定大小的32 字節(256 位)的數據塊。下面的圖片說明了此結構以及合約內存的讀/寫功能。
這個功能是由操作內存的3 個操作碼決定的。
-
MSTORE (x, y):從內存位置“x” 開始存儲一個32 字節(256 位)的“y” 值。
-
MLOAD (x):從內存位置“x” 開始將32 字節(256 位)加載到調用棧上。
-
MSTORE8 (x, y):在內存位置“x” 存儲一個1 字節(8 位)的值“y”(32 字節棧值的最低有效字節)。
你可以將內存位置簡單地看作是開始寫入/讀取數據的數組索引。如果想寫入/讀取超過1 個字節的數據,只需繼續從下一個數組索引寫入或讀取。
2、EVM Playground
EVM Playground 有助於鞏固我們這3 個操作碼的運行原理、作用以及內存位置的理解。單擊Run 和右上角的箭頭進行調試來查看堆棧和內存是如何更改的。 (操作碼上方有註釋來描述每個部分的作用)
可能會注意到一些奇怪的現象,我只添加了1 個字節,為什麼多了這麼多零呢?
3、內存擴展
當合約寫入內存時,需要為寫入的字節數支付Gas,也就是擴大內存的開銷。如果我們正在寫入一個以前沒有寫入過的內存區域,那麼第一次使用它會產生額外的內存擴展開銷。
寫入之前未觸及的內存空間時,內存以32 字節(256 位)為增量擴展。前724 個字節,內存擴展呈線性增長,之後呈二次方增長。 (由以太坊黃皮書公式326 擴大內存的Gas 開銷得出,公式為:
,擴展內存時為每個額外的字的開銷。其中a 是合約調用中寫入的最大內存位置,以32 字節字為單位。用1024 字節內存為例,那麼a = 32 。 )
在位置32 處寫入1 個字節之前,我們的內存是32 個字節。此時我們開始往未觸及的內存空間寫入內容,結果,內存增加了32 個字節,增加到64 個字節。內存中所有位置的都初始被定義為0,這也是為什麼我們會看到 2200000000000000000000000000000000000000000000000000000000000000 被添加到內存中的原因。
4、內存是一個字節數組
調試過程中,我們可能注意到的第二件事發生在我們從內存位置33 (0x21) 運行MLOAD 時。我們將以下值返回到調用棧。
3300000000000000000000000000000000000000000000000000000000000000
內存讀取可以從一個非32 字節元素開始。
內存是一個字節數組,這意味著可以從任何內存位置開始讀取(和寫入)。我們不限於32 的倍數。內存是線性的,可以在字節級別進行尋址。內存只能在函數中新建。它可以是新實例化的複雜類型,如數組/結構(例如,通過新建一個int[…])或從存儲引用的變量中復制。
現在我們對數據結構已有了一定的了解了,接下來讓我們來看空閒內存指針。
5、空閒內存指針
空閒內存指針只是一個指向空閒內存開始位置的指針。它確保智能合約可以跟踪到哪些內存位置已寫入,哪些未寫入。這可以防止合約覆蓋已分配給另一個變量的某些內存。當一個變量被寫入內存時,合約將首先引用空閒內存指針來確定數據應該存儲在哪裡。然後,它通過記錄要寫入新位置的數據量來更新空閒內存指針。這兩個值的簡單相加將產生新的空閒內存開始的位置。
空閒內存指針的位置+ 數據的字節大小= 新空閒內存指針的位置
6、字節碼
就像我們之前所提到的,空閒內存指針是通過這5 個操作碼在運行時字節碼的定義的。
這些操作碼聲明空閒內存指針位於內存中字節0x40(十進制中的64)處,值為0x80(十進制中的128)。
Solidity 的內存佈局保留了4 個32 字節的插槽:
-
0x00 – 0x3f (64 bytes):暫存空間,可用於語句之間,即內聯彙編和哈希散列方法。
-
0x40 – 0x5f (32 bytes):空閒內存指針,當前分配的內存大小,空閒內存的起始位置,初始化為0x80。
-
0x60 – 0x7f (32 bytes):插槽0,用作動態內存數組的初始值,永遠不應寫入。
我們可以看到,0x40 是空閒內存指針的預定義位置。而值0x80 只是在4 個32 字節保留值插槽之後可寫入的第一個內存字節。
7、合約中的內存
為了鞏固我們到目前為止所學到的知識,接下來將看看內存和空閒內存指針是如何在Solidity 代碼中更新的。
我們創建MemoryLane 合約來進行演示。合約的 memoryLane() 定義了兩個長度分別為5 和2 的數組,並將uint256 類型的1 賦值給 b[0]。
要查看合約代碼在EVM 中執行的詳細信息可以將其複製到Remix IDE 中編譯並部署合約。調用 memoryLane() 後進入DeBug 模式來逐步執行操作碼(以上操作可以參考:
https://remix-ide.readthedocs.io/en/latest/tutorial_debug.html)。
將簡化版操作碼提取到EVM Playground 中,可通過這個鏈接查看具體的操作碼及註釋信息(https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-d6b#:~:text=version%20into%20an-,EVM%20Playground,-and%20will%20run)。
這裡將操作碼分成6 個不同的部分依次解讀,刪除了JUMP 以及與內存操作無關的操作碼同時將註釋添加了進去方便查看當前在執行什麼操作。
1)空閒內存指針初始化(EVM Playground 操作碼代碼1-15 行)
首先,0x80(十進制為128)先入棧,這是由Solidity 內存佈局規定的值,當前內存中沒有任何東西。
最後,我們調用MSTORE,它將第一項從棧0x40 彈出以確定在內存中寫入的位置,並將第二個值0x80 作為寫入的內容。這樣留下了一個空棧,但已經填充了一部分到內存中。內存由十六進製字符表示,其中每個字符代表4 位。例如:在內存中有192 個十六進製字符,這意味著我們有96 個字節(1 字節= 8 位= 2 個十六進製字符)。如果我們回顧Solidity 的內存佈局會發現,前64 個字節將被分配為暫存空間,接下來的32 個字節將用於空閒內存指針。
2)內存分配變量“a” 和空閒內存指針更新(EVM Playground 第16-34 行)
接下來的部分,我們將跳到每個部分的結束狀態,並簡潔概述。
首先,為變量“a”(bytes32[5])分配下一個內存,並更新空閒內存指針。編譯器將通過數組大小和默認數組元素大小確定需要多少空間。 Solidity 中內存數組中的元素都是佔據32 字節的倍數(這同樣適用於 bytes1[],但bytes 和string 不適用)。當前需要分配的內存為5 * 32 字節,表示為160 或0xa0(16 進制的160)。我們可以看到它被壓入棧中並添加到當前空閒內存指針0x80(十進制中的128)來獲取新的空閒內存指針值。這將返回0x120(十進制的288 = 128 + 160),我們可以看到它已被寫入空閒內存指針位置。調用棧將變量“a” 的內存位置保存在棧0x80 上,以便以後可以在需要時引用它。 0xffff 代表一個JUMP(無條件跳轉) 位置,可以忽略,因為它與內存操作無關。
3)內存初始化變量“a”(EVM Playground 第35-95 行)
已經分配好了內存並且更新了空閒內存指針,接下來需要為變量“a” 初始化內存空間。由於該變量只是被聲明並沒有被賦值,它將被初始化為零值。
EVM 通過使用了 CALLDATACOPY(複製消息數據)操作碼來進行操作,其中存在3 個變量。
-
memoryOffset/destOffset(將數據複製到的內存位置)
-
calldataOffset/offset(需要復制的calldata 中的字節偏移量)
-
size/length(要復制的字節大小)
-
表達式:
memory[destOffset:destOffset+length] = msg.data[offset:offset+length]
在這個例子中,memoryOffset(destOffset) 是變量“a”(0x80)的內存位置。 calldataOffset(offset) 是實際calldata 的大小,因為並不需要復制任何calldata,所以初始化內存為零。最後,傳入的變量為0xa0(十進制的160)。
這是可以看到我們的內存已經擴展到288 字節(這包括插槽0),並且調用棧再次保存了變量的內存位置和以及棧上的JUMP 地址。
這與變量“a” 的內存分配和空閒內存指針更新相同,只是這次是針對“bytes32[2] memory b”。內存指針更新為0x160(十進制為352),等於先前的空閒內存指針288 加上新變量的大小64(以bytes 64 為單位)。空閒內存指針已在內存中更新為0x160,那麼現在在棧上就擁有變量“b”(0x120)的內存位置。
與變量“a” 的內存初始化相同。現在內存已增加到352 字節,棧內仍然保存2 個變量的內存位置。
最後,我們開始為數組“b” 索引0 賦值。代碼指出 b[0] 的值應該為1。該值被壓入棧0x01。接下來發生向左移位,但是移位的輸入為0,這意味著我們的值不會改變。接下來,要寫入0x00 的數組索引位置被壓入堆棧,並檢查該值是否小於數組0x02 的長度。如果不是,則執行跳轉到處理此錯誤狀態的字節碼的不同部分。 MUL(乘法)和ADD(加法) 操作碼用於確定需要將值寫入內存中的哪個位置以使其對應於正確的數組索引。
0x20 (10 進制為32) * 0x00 (10 進制為0) = 0x00
需要記住,內存數組是32 字節的元素,因此該值表示數組索引的起始位置。鑑於我們正在寫入索引0,沒有偏移量,也就是從0x00 開始寫入。
0x00 + 0x120 = 0x120 (10 進制為288)
ADD 用於將此偏移值添加到變量“b” 的內存位置。偏移量為0,直接將數據寫入分配的內存位置。最後, MSTORE 將值0x01 存儲到這個內存位置0x120。
下圖顯示了函數執行結束時的系統狀態。所有棧項都已彈出。請注意,實際上在remix 中還有一些項目留在堆棧上,一個JUMP 位置和函數簽名,但是它們與內存操作無關,因此在EVM playground 中被省略了。
內存已更新為包含 b[0] = 1 賦值,在我們內存的倒數第三行,0 值變成了1。可以驗證該值位於正確的內存位置,b[0] 應佔用位置0x120 – 0x13f(bytes 289 – 320)。
我們現在對合約內存的工作原理有了一定程度的了解。在後續需要編寫代碼時,將為我們提供很好理解與幫助。當你跳過一些合同操作碼,看到某些內存位置不斷彈出(0x40) ,現在就知道他們的確切含義了。
在本系列下一篇文章中,我們將在EVM 深入探討系列第3 部分深入探討合約存儲的工作原理,了解存儲插槽包裝(slot packing),揭開存儲插槽的神秘面紗。