引介|EVM 深入探討-Part 1

語言: CN / TW / HK

By: [email protected]慢霧安全團隊

導語

在智慧合約世界中,“以太坊虛擬機器(EVM)”及其演算法和資料結構是首要原則。我們建立的智慧合約就是建立在這個基礎之上的。不管是想要成為一名出色的 Solidity 智慧合約開發人員還是安全人員都必須對 EVM 有深入的瞭解。

此係列我們將引介翻譯 noxx 的文章(https://noxx.substack.com/),深入探討 EVM 的基礎知識。

基礎知識 :Solidity → 位元組碼 → 操作碼

在閱讀本篇文章之前,你需要了解一些智慧合約相關基礎知識以及如何將智慧合約程式碼部署到以太坊鏈上。正如我們所知,智慧合約在部署到以太坊網路之前需要先將 Solidity 程式碼編譯成位元組碼,EVM 會根據編譯後的位元組碼執行相應的操作。本篇重點介紹編譯後的位元組碼以及其如何被 EVM 執行的。

智慧合約被部署後編譯生成的位元組碼代表了整個合約的內容,其中存在多個可呼叫的函式。那麼 EVM 是如何知道不同函式所對應的位元組碼是哪個呢?下面我們將通過一個 Solidity 智慧合約及其位元組碼和操作碼來向大家演示 EVM 在執行程式碼時是如何在位元組碼中選擇對應的函式的。

1_Storage.sol Breakdown

我們使用線上 Solidity IDE 工具 Remix 來編譯 Storage 合約。

// SPDX-License-Identifier: GPL-3.0


pragma solidity >=0.7.0 <0.9.0;


/**
* @title Storage
* @dev Store & retrieve value in a variable
*/
contract Storage {


uint256 number;


/**
* @dev Store value in variable
* @param num value to store
*/
function store(uint256 num) public {
number = num;
}


/**
* @dev Return value
* @return value of 'number'
*/
function retrieve() public view returns (uint256){
return number;
}
}

此合約中存在兩個函式 store()retrieve() ,在進行函式呼叫時 EVM 需要判斷我們呼叫的是哪個函式。我們可以通過 remix 看到整個合約編譯後的位元組碼。

下面這段位元組碼是我們需要重點關注的,這段就是 EVM 判斷被呼叫函式的選擇器。與其對應的是 EVM 操作碼及輸入值。

我們可以通過 Ethervm.io 來檢視 EVM 操作碼列表。一個操作碼長度為 1 個位元組(byte),這使得它可以存在 256 種不同的操作碼。但 EVM 僅使用其中的 140 個操作碼。

下面是我們將上述位元組碼解析成與其對應的操作碼。這些操作碼會由 EVM 在呼叫棧上按順序執行。

智慧合約函式呼叫

在深入研究操作碼之前,我們需要快速瞭解如何呼叫合約中的函式。 呼叫智慧合約中的函式有以下方式:

  • abi.encode(...) returns (bytes) :計算引數的 ABI 編碼。

  • abi.encodePacked(...) returns (bytes) :計算引數的緊密打包編碼。

  • abi. encodeWithSelector(bytes4 selector, ...) returns (bytes) :計算函式選擇器和引數的 ABI 編碼。

  • abi.encodeWithSignature(string signature, ...) returns (bytes) :等價於  abi.encodeWithSelector(bytes4(keccak256(signature), ...)。

  • abi.encodeCall( function functionPointer , (...)) returns (bytes memory): 使用 tuple 型別引數 ABI 編碼呼叫 functionPointer() 。執行完整的型別檢查,確保型別匹配函式簽名。結果和  abi.encodeWithSelector(functionPointer.selector, (...)) 一致。

這裡我們以第四種為例,呼叫 store() 並傳入引數 10:

下面 通過   abi.encodeWithSignature ( " store (uint256)" 10 )   編碼後的內容:

這段資料就是編碼後的函式簽名。

我們可以使用線上工具(“https://emn178.github.io/online-tools/keccak_256.html”)來檢視  store(uint256) 和  retrieve() 雜湊後的結果。

也可以通過以太坊函式簽名資料庫(https://www.4byte.directory/signatures/)進行反查。

再回到上面的那組函式簽名資料,其中前 4 個位元組對應的是 store(uint256) 。而剩餘的 32 個位元組則對應的是一個十六進位制的值 “a”,也就是我們呼叫函式時傳入的 uint256 型別的 10。

這裡我們可以得到一個結論,通過  abi.encodeWithSignature()   編碼後得到的資料,共 36 個位元組。這 36 個位元組的資料就是函式簽名,其中前 4 個位元組為函式選擇器,它將指引 EVM 去選擇我們呼叫的目標函式,後 32 個位元組的資料則是我們呼叫函式時傳入的引數。

操作碼和呼叫棧

這裡相信大家已經大致瞭解了智慧合約中函式呼叫的原理了,下面我們將通過解讀每個操作碼的作用及其對棧呼叫的影響。 如果你不熟悉棧資料結構的工作原理,可以觀看此影片來快速入門: https://www.youtube.com/watch?v=FNZ5o9S9prU

我們將得到的位元組碼分解成相對應的操作碼後依次開始分析。

  •  PUSH1 操作,將一個  1 位元組的值壓入棧,它會告訴 EVM 將下一個資料位元組 0x00(也是十進位制的 0) 壓入棧中。

  • 接下來是 CALLDATALOAD,其作用是從訊息資料中讀取 32 個位元組的值,其中使用 “輸入” 值作為偏移量將 calldata 載入到棧中。棧項大小為 32 位元組,但是當前我們的 calldata 有 36 個位元組。推送的值是 msg.data[i:i+ 32 ] 其中 “i” 就是這個輸入值。此操作確保只有 32 個位元組被推送到棧,同時也能保證我們能夠訪問 calldata 中的任何部分。

當前輸入值為 0 也就是沒有偏移量(從棧中彈出的值是前一個 PUSH1 的值 0),因此 calldata 的前 32 個位元組會被推送到呼叫棧。

還記得之前所獲取到的函式簽名嗎?如果要傳入這 36 個位元組,這就意味著後面的 4 個位元組“0000000a”將會丟失。如果想訪問這個 uint256 型別的引數,需要設定 4 的偏移量來省略函式簽名,這樣就可以保證引數的完整性。

  • 第二次進行 PUSH1 的操作將傳入十六進位制的資料 0xe0,也就是十進位制的 224。我們上面提到過,函式簽名是 4 個位元組也就是 32 位。我們載入的 calldata 是 32 個位元組也就是 256 位,而 256 - 32 =224 正好滿足。

  • SHR,是向右移位指令。它從棧中獲取第一項 224 表示要位移的位數,從棧中獲取第二項 (0x6057361d0…00) 表示需要移位的內容。在這個操作之後呼叫棧上有了 4 個位元組的函式選擇器。

如果對於位移的工作原理不熟悉的小夥伴,可以檢視這個影片瞭解:https://www.youtube.com/watch?v=fDKUq38H2jk&t=176s

  • 接下來的操作碼, DUP1,它用來獲取並複製棧頂部的值。

  • PUSH4 將   retrieve() ( 0x2e64cec1 ) 的 4 個 位元組函式簽名推入呼叫棧。

如果你好奇是這個值是如何獲得的,那是因為 solidity 程式碼被編譯成位元組碼中。編譯器可以從位元組碼中獲取所有函式名稱和引數型別的資訊。

  • EQ 用於判斷從棧中彈出的 2 個值,在當前事例中為 0x2e64cec1 和 0x6057361d 並檢查它們是否相等。如果相等,則將 1 推回棧,如果不相等則為 0。

  • PUSH2 將 2 位元組的十六進位制資料 0x003b,十進位制值為 59,推送到呼叫棧中。

呼叫棧中有一個叫做程式計數器的東西,它會指定下一個執行命令在位元組碼中的位置。這裡的 59,是通過 retrieve()   位元組碼的開始位置所得到的。

  • JUMPI 代表“如果條件為真,則跳轉”,它從棧中彈出 2 個值作為輸入,第一個 59 表示的是跳轉位置,第二個 0 是是否應該執行此跳轉條件的布林值。其中 1 為真,0 為假。

如果條件為真,程式計數器將被更新,執行將跳轉到該位置。但我們的例子中條件為假的,程式計數器沒有改變並且繼續執行。

  • 再次進行DUP1。

  • PUSH4 將 store(uint256) ( 0x6057361d ) 的 4 位元組函式簽名推送到呼叫棧上。

  • 再次進行 EQ,但這次結果為真,因為函式簽名相同。

  • PUSH2 推送 2 個位元組的十六進位制資料  0x0059 也就是十進位制的 89, 到 store(uint256) 位元組碼的程式計數器位置。

  • 執行 JUMPI,此次 bool 值為真,執行跳轉。因此會將程式計數器更新為 89,這會將執行移動到位元組碼的不同部分。在這個位置,會有一個 JUMPDEST 操作碼,如果沒有這個操作碼在這裡的話,JUMPI 操作就會失敗。

有了它,在執行此操作碼後,將被帶到 store(uint256)  對應的位元組碼的位置,並且函式的執行將繼續。 雖然這個合約只有 2 個函式,但基礎原理都是相同的。

通過上面的例子我們知道了 EVM 是如何根據合約函式呼叫來確定它需要執行的函式位元組碼的位置。簡單來說就是由合約中每個函式及其跳轉位置所組成的一組簡單的“if 語句”。

EVM Playground

這是一個 EVM Playground(https://www.evm.codes/playground)測試平臺,在平臺上我們可以設定剛剛執行的位元組碼。就能夠通過互動方式來檢視棧的變化,並且傳入 JUMPDEST(注:可能跳轉的目標元資料),可以看到 JUMPI 之後會發生什麼。

EVM Playgrpund 還能有助於我們理解程式計數器的執行,每條命令旁都能看到相對應的註釋以及偏移量所代表的程式計數器的位置,同時在左邊框內還能看到 calldata 的輸入。當點選執行指令,可以通過右上角的箭頭單步除錯每個操作碼例如更改為 retrieve()   呼叫資料 0x2e64cec1 來檢視執行的變化。

敬請期待《EVM 深入探討-Part 2》,讓我們共同探索合約記憶體是什麼以及它在 EVM 下的工作方式。

慢霧導航

慢霧科技官網

https://www.slowmist.com/

慢霧區官網

https://slowmist.io/

慢霧 GitHub

https://github.com/slowmist

Telegram

https://t.me/slowmistteam

Twitter

https://twitter.com/@slowmist_team

Medium

https://medium.com/@slowmist

知識星球

https://t.zsxq.com/Q3zNvvF