EVM架構簡析和源碼分析
EVM爲以太坊虛擬機。以太坊底層通過EVM模塊支持智能合約的執行和調用,調用時根據合約的地址獲取到代碼,生成具體的執行環境,然後將代碼載入到EVM虛擬機中運行。通常目前開發智能合約的高級
語言爲Solidity,在利用solidity實現智能合約邏輯後,通過編譯器編譯成元數據(字節碼)最後發佈到以
坊上。
EVM架構概述
EVM本質上是一個堆棧機器,它最直接的的功能是執行智能合約,根據官方給出的設計原理,EVM的主
要的設計目標爲如下幾點:
- 簡單性
- 確定性
- 空間節省
- 爲區塊鏈服務
- 安全性保證
- 便於優化
EVM存儲系統
機器位寬
EVM機器位寬爲256位,即32個字節,256位機器字寬不同於我們經常見到主流的64位的機器
字寬,這就標明EVM設計上將考慮一套自己的關於操作,數據,邏輯控制的指令編碼。目前主流的處理
器原生的支持的計算數據類型有:8bits整數,16bits整數,32bits整數,64bits整數。一般情況下寬字
節的計算將更加的快一些,因爲它可能包含更多的指令被一次性加載到pc寄存器中,同時伴有內存訪問
次數的減少。目前在X86的架構中8bits的計算並不是完全的支持(除法和乘法),但基本的數學運算大概
在幾個時鐘週期內就能完成,也就是說主流的字節寬度基本上處理器能夠原生的支持,那爲什麼EVM要採
用256位的字寬。主要從以下兩個方面考慮:
- 時間,智能合約是否能執行得更快
- 空間,這樣是否整體字節碼的大小會有所減少
- gas成本
時間上主要體現在執行的效率上,我們以兩個整形數相加來對比具體的操作時間消耗。32bits相加的X86
的彙編代碼
mov eax, dword [9876ABCD] //將地址9876ABCD中的32位數據放入eax數據寄存器
add eax, dword [1234DCBA] //將1234DCBA地址指向32位數和eax相加,結果保存在eax中
64bits相加的X86彙編代碼
mov rax, qword [123456789ABCDEF1] //將地址指向的64位數據放入64位寄存器
add rax, qword [1020304050607080] //計算相加的結果並將結果放入到64位寄存器中
下面我們看一下在64bits機器上如何完成256bits的加法mov rax, qword [9876ABCD]
add qword [1234DCBA], rax
mov rax, qword [9876ABCD+8]
adc qword [1234DCBA+8], rax//這裏應用adc帶進位的加法指令,影響進位標記CF
mov rax, qword [9876ABCD+16]
adc qword [1234DCBA+16], rax
mov rax, qword [9876ABCD+24]
adc qword [1234DCBA+24], rax
由上面的的彙編指令我們可以看出256位操作要比系統原生支持的要複雜的多,從時間上考慮採用256位這樣的字節寬度,實際的收益並不大。
空間上,由上面的彙編操作(在實際的EVM中操作類似)我們不難看到,如果直接對地址進行操
作似乎是一種快速的方式,並減少了操作數,進而操作碼也有所減少,相應的智能合約的字節流
大小就會小很多,gas花費也會有所下降。但是從另外一個層面來講,支持寬字節的數據類型勢必
會造成在處理低字節寬度的數據時候帶來存儲上的浪費(如添加標識用來區分類型)或者添加額外
的操作來進行數據的compact。從時間和空間角度來看,僅支持256字節寬度的選擇有利有弊,具
體還要看以太坊智能合約的具體應用。可能的幾點原因如下:
- 256位的寬度方便進行密碼學方面的計算(sha256),但是成本有些高,場景比較少
- 僅支持256位的比要支持其他類型的操作要少,單一,實現簡單可控
- 和gas的計算相關,僅支持一種,方便計算,同時也考慮到了安全問題
內存分配
EVM中數據可以在三個地方進行存儲,分別是棧,臨時存儲,永久存儲。由於EVM是基於棧的虛擬機,
因此基本上所有的操作都是在棧上進行的,並且EVM中沒有寄存器的概念,這樣EVM對棧的依賴就更大,
雖然這樣的設計使實現比較簡單且易於理解,但是帶來的問題就是需要更多數據的相關操作。在EVM
中棧是唯一的免費(幾乎是)存放數據的地方。棧自然有深度的限制,目前的限制是1024
static constexpr int64_t stackLimit = 1024;
因爲棧的限制,因此棧上的臨時變量的使用會受限制。臨時內存存儲在每個VM實例中,並在合約執行完
後消失永久內存存儲在區塊鏈的狀態層。
EVM代碼簡析
本文分析EVM c++的代碼, github地址爲https://github.com/ethereum/cpp-ethereumEVM c++實現了同go版本中的核心的功能,對於快速理解EVM的設計思想比較受用,對於熟悉C++的
可以先通過閱讀C++版本的實現,然後對標go版本的實現來進一步瞭解EVM的設計思路。
EVM代碼overview
主要代碼路徑爲cpp-ethereum/libevm和libethereum,代碼量不大,以下爲主要代碼。EVMC.cpp
ExtVMFace.cpp
Instruction.cpp
interpreter.h
LegacyVMCalls.cpp
VM.cpp
VMCalls.cpp
VMFactory.cpp
VMOpt.cpp
VMSIMD.cpp
VmValidate.cpp
LegacyVM.cpp
Executive.cpp
ExtVm.cpp
下圖爲EVM中主要類的類圖:以太坊中調用EVM相關代碼的入口存在於不同的階段,我們主要從以下幾個角度來看它是如何運作的</br>
- 執行環境創建
- 新合約的創建
- 合約執行調用、
- 執行後結果返回
- 運行過程中的gas消費
執行環境的創建
以太坊虛擬中涉及到的虛擬機執行的關鍵類爲Executive(位於目錄libethereum)中,我們先從創建運行環境入手,關鍵入口函數call入手,函數聲明爲:
bool call(Address const& _receiveAddress, //接收者地址
Address const& _txSender, //發送者地址
u256 const& _txValue, //transaction的值
u256 const& _gasPrice, //gas的價格
bytesConstRef _txData, //具體的transaction數據
u256 const& _gas); //提供的gas值
bool call(CallParameters const& _cp, //運行參數,由上個函數的相關變量創建
u256 const& _gasPrice, //gas的價格
Address const& _origin); //源地址
兩個函數的功能是一樣的,只是第一個函數會將參數包裝成CallParameters然後在調用第二個函數,現在看
一下call函數的流程圖如下:
上述函數的關鍵點在於在不是預編譯合約(以太坊中內部定義好的合約多爲簽名計算),生成ExtVM
實例,這個對象將在函數go中返回重要的作用。注意函數最後會更改狀態指向函數transferBalance:
m_s.transferBalance(_p.senderAddress, _p.receiveAddress, _p.valueTransfer);
新合約的創建
如果調用的合約地址在系統中還沒有出存在,這個適合涉及到合約的創建,關鍵函數爲create:
bool create(Address const& _txSender, //目的地址
u256 const& _endowment, //花費的值
u256 const& _gasPrice, //gas的價錢
u256 const& _gas, //花費的gas數量
bytesConstRef _init, //傳入的字節碼
Address const& _origin) //源地址
函數將調用createOpcode函數,該函數在獲得sender的nonce值後生成合約地址,並且調用函數
executeCreate來完成最後的創建。
u256 nonce = m_s.getNonce(_sender);
//注意地址保存在m_newAddress中,這個是否足夠靈活
m_newAddress = right160(sha3(rlpList(_sender, nonce)));
return executeCreate(_sender, _endowment, _gasPrice, _gas, _init, _origin);
下面看關鍵的的函數executeCreate,其流程圖如下所示:
我們從代碼中不難看出,最後和call函數相同生成ExtVM對象,將要執行的字節碼放入其中。
這裏需要注意的是執行transferBalance時候如果沒有賬戶將創建一個新的賬戶。
合約執行調用
合約的執行的起始點在函數Executive::go中,函數的流程圖如下:
從流程圖中我們看到如果m_ext(create或者call函數中創建)不爲空的時候通過工廠方法創建VM,
這裏要正確的理解m_ext和vm的區別,我們先看工廠方法的實現:
std::unique_ptr<VMFace> VMFactory::create(VMKind _kind)
{
switch (_kind)
{
#ifdef ETH_EVMJIT
case VMKind::JIT:
return std::unique_ptr<VMFace>(new EVMC{evmjit_create()});
#endif
#ifdef ETH_HERA
case VMKind::Hera:
return std::unique_ptr<VMFace>(new EVMC{evmc_create_hera()});
#endif
case VMKind::Interpreter:
return std::unique_ptr<VMFace>(new EVMC{evmc_create_interpreter()});
case VMKind::DLL:
return std::unique_ptr<VMFace>(new EVMC{g_dllEvmcCreate()});
case VMKind::Legacy:
default:
return std::unique_ptr<VMFace>(new LegacyVM);
}
}
從代碼中我們不難看出,會根據虛擬機的類型創建對應的虛擬機對象,無參函數create將默認生成類型爲VMKind::Legacy。在函數create和call中生成的m_ext是虛擬機和外部相關狀態進行交互的。
調用VM的exec函數完成虛擬機的執行。LegacyVM的exec函數關鍵代碼如下:
// trampoline to minimize depth of call stack when calling out
m_bounce = &LegacyVM::initEntry;//初始化m_bounce
do
(this->*m_bounce)();
while (m_bounce);
初始化m_bounce的函數使initEntry,我們看下initEntry函數中實現的功能。
m_bounce = &LegacyVM::interpretCases;//設置m_bounce爲函數interpreCases
initMetrics();//初始化操作,參數和返回值,gas花費矩陣,
optimize();//優化,主要針對跳轉的優化,後續再做補充,目前程序彙總關閉
經過上面的代碼處理,m_bounce設置爲下一步將要運行的函數。初始化操作以及花費說明的矩陣,
矩陣關聯的Instruction部分相關信息如下:
static const std::map<Instruction, InstructionInfo> c_instructionInfo =
{ // Add, Args, Ret, GasPriceTier
{ Instruction::STOP, { "STOP", 0, 0, 0, Tier::Zero } },
{ Instruction::ADD, { "ADD", 0, 2, 1, Tier::VeryLow } },
{ Instruction::SUB, { "SUB", 0, 2, 1, Tier::VeryLow } },
{ Instruction::MUL, { "MUL", 0, 2, 1, Tier::Low } },
{ Instruction::DIV, { "DIV", 0, 2, 1, Tier::Low } },
{ Instruction::SDIV, { "SDIV", 0, 2, 1, Tier::Low } },
{ Instruction::MOD, { "MOD", 0, 2, 1, Tier::Low } },
經過上面的相關初始化,再運行循環將執行m_bounce指向的interpretCases函數。函數interpretCase
相對來說比較複雜一些,如下所示:
void LegacyVM::interpretCases()
{
INIT_CASES
DO_CASES
{
CASE(CREATE2)
{
ON_OP();
if (!m_schedule->haveCreate2)
throwBadInstruction();
m_bounce = &LegacyVM::caseCreate;
}
BREAK
CASE(CREATE)
........
此處省略很多的case
.......
NEXT
CASE(INVALID)
DEFAULT
{
throwBadInstruction();
}
}
WHILE_CASES
}
上述代碼中對應了很多宏,,如果不對宏進行展開,直觀理解就是對每種操作執行對應的函數,並將
結果進行中間存儲,EVM中定義了相關宏開關,其中一些宏開關,都對應了上述代碼中不同的
宏展開,同時對應了不同的代碼組織形式。參見文件VMConfing.h,如下代碼爲相關的宏開關:
EIP_615 - 子程序和靜態跳轉的方式
EIP_616 - 單執行多數據流的方式
EVM_OPTIMIZE - 優化開關,當值爲false時,所有的優化全部關掉
EVM_SWITCH_DISPATCH - 通過loop和switch執行代碼
EVM_JUMP_DISPATCH - 跳轉通過一個跳錶來實現,只針對gcc
EVM_USE_CONSTANT_POOL - 應用靜態數據並在棧上直接賦值操作
EVM_REPLACE_CONST_JUMP - 帶預確認的跳轉來保證運行時的循環
EVM_TRACE - 提供不同等級的trace操作
下面我們來看一下源代碼中是如何設置這些宏開關
首先EIP_615和EIP_616均爲關閉狀態
#ifndef EIP_615
#define EIP_615 false
#endif
#ifndef EIP_616
#define EIP_616 false
#endif
//--------------------------------------------------------------------------
//如果沒有定義EVM_JUMP_DISPATCH的情況下,如果是GNU的gcc,則打開EVM_JUMP_DISPATCH
//我們一般在linux編譯,則這裏我們可以得到的結論是開關EVM_JUMP_DISPATCH開關打開
ifndef EVM_JUMP_DISPATCH
#ifdef __GNUC__
#define EVM_JUMP_DISPATCH true
#else
#define EVM_JUMP_DISPATCH false
#endif
#endif
#if EVM_JUMP_DISPATCH
#ifndef __GNUC__
#error "address of label extension available only on Gnu"
#endif
#else
#define EVM_SWITCH_DISPATCH true
#endif
//---------------------------------------------------------------------------
//從下面的宏定義EVM_OPTIMIZE的開關是關閉的,因此開關EVM_REPLACE_CONST_JUMP</br>
//EVM_USE_CONSTANT_POOL EVM_DO_FIRST_PASS_OPTIMIZATION均爲false的狀態
#ifndef EVM_OPTIMIZE
#define EVM_OPTIMIZE false
#endif
#if EVM_OPTIMIZE
#define EVM_REPLACE_CONST_JUMP true
#define EVM_USE_CONSTANT_POOL true
#define EVM_DO_FIRST_PASS_OPTIMIZATION \
(EVM_REPLACE_CONST_JUMP || EVM_USE_CONSTANT_POOL)
#endif
綜上宏開關的說明,最後開關EVM_JUMP_DISPATCH是打開的,其他的均處於關閉狀態。在開關EVM_JUMP_DISPATCH打開的情況下對每一個宏展開進行說明,便於理解實際是如何運
行的。具體的參見文件VMConfig.h
宏INIT_CASES在開關EVM_JUMP_DISPATCH打開的情況下該宏展開爲初始化一個靜態的跳轉表,代碼片段如下:
#define INIT_CASES \
\
static const void* const jumpTable[256] = { \
&&STOP, /* 00 */ \
&&ADD, \
&&MUL, \
&&SUB, \
&&DIV, \
&&SDIV, \
&&MOD, \
&&SMOD, \
&&ADDMOD, \
其中的ADD等定義在文件Instruction.h中,其中枚舉了所有的虛擬機執行過程中的字節操作碼。
/// Virtual machine bytecode instruction.
enum class Instruction: uint8_t
{
STOP = 0x00, ///< halts execution
ADD, ///< addition operation
MUL, ///< mulitplication operation
SUB, ///< subtraction operation
DIV, ///< integer division operation
SDIV, ///< signed integer division operation
.......
.......
宏DO_CASES
在開關EVM_JUMP_DISPATCH打開的情況下該宏展開爲如下代碼片段:
#define DO_CASES \
fetchInstruction(); \
goto* jumpTable[(int)m_OP];
由上面的代碼可知操作符定義爲一個字節,函數fetchInstruction執行後m_OP中存儲了當前要執行的操作,接着switch將根據具體的操作來執行分支中的內容。
宏CASES
在開關EVM_JUMP_DISPATCH打開的情況下CASES展開的內容如下 :
#define CASE(name) \
name:
宏NEXT在開關EVM_JUMP_DISPATCH打開的情況下NEXT展開的內容如下 :
#define NEXT \
++m_PC; \
fetchInstruction(); \
goto* jumpTable[(int)m_OP];
宏CONTINUE在開關EVM_JUMP_DISPATCH打開的情況下CONTINUE展開的內容如下 :
#define CONTINUE \
fetchInstruction(); \
goto* jumpTable[(int)m_OP];
其他的幾個控制分支跳轉的宏展開如下:#define BREAK return;
#define DEFAULT
#define WHILE_CASES
關鍵的執行操作的宏ON_OP展開如下:
#define ON_OP() onOperation()
通過上面的宏的展開我們可以看出過程主要依賴jumpTable和函數fetchInstructio來完成執行過程。首先我們看一下函數fetchInstruction的具體執行流程,整個執行將從這裏開始
//初始化時m_PC爲0,這裏先獲取第一個操作符
m_OP = Instruction(m_code[m_PC]);
//獲取操作相關的參數,gas花費等信息
const InstructionMetric& metric = c_metrics[static_cast<size_t>(m_OP)];
//設置SP爲最後返回值所在的位置,同時檢查參數出棧和返回值入棧沒有超過棧的邊界
adjustStack(metric.args, metric.ret);
//計算運行的費用
m_runGas = toInt63(
m_schedule->tierStepGas[static_cast<unsigned>(metric.gasPriceTier)]
);
m_newMemSize = m_mem.size();
m_copyMemSize = 0;
下面我們看程序是如何利用跳錶和onOpearion來完成程序的執行,下面我們以ADD操作
爲例來進行說明,在進行ADD說明前,先簡單介紹一下EVM中棧工作的原理,例如如果我
們要執行一
加法操作,則通常表示爲c = a + b,把它翻譯成EVM中棧的操作序列僞操作碼序列如下:
push a into stack
push b into stack
pop a and b then cal a+b
push a+b into stack
我們先看一下ADD的前置操作PUSH是如何實現的,首先注意如下代碼:
CASE(PUSH2)
.........
CASE(PUSH23)
CASE(PUSH24)
CASE(PUSH25)
CASE(PUSH26)
CASE(PUSH27)
CASE(PUSH28)
CASE(PUSH29)
CASE(PUSH30)
CASE(PUSH31)
CASE(PUSH32)
{
ON_OP();
updateIOGas();
int numBytes = (int)m_OP - (int)Instruction::PUSH1 + 1;
m_SPP[0] = 0;
for (++m_PC; numBytes--; ++m_PC)
//這裏主要是處理256位寬的的情況
m_SPP[0] = (m_SPP[0] << 8) | m_code[m_PC];
}
CONTINUE
EVM中對PUSH1的其他操作均採用上面的代碼,PUSH3的含義是將三個輸入的字節序列中的寬度
爲32字節的數據壓入到棧中,如下代碼:
m_SPP[0] = (m_SPP[0] << 8) | m_code[m_PC];
上面這段代碼主要是將傳入的字節流數據轉化爲256的數據,並放入棧上,同時我們的棧的定義如下:
u256 m_stack[1024];
這裏的設計其實有可以改進的地方,主要是以下幾點:- 即使編譯器部分做了數據緊湊的優化,但是執行的過程中又做了放大,失去了原來的意義。
- 棧的寬度爲256位,拋開加密算法的影響(目前幾乎沒有棧上運算),實際有些浪費。
- 從實際角度講可以選擇小的位寬的棧,數據更加緊密,但是可能會造成操作增加。
通過執行完PUSH操作後,棧上的已經放好了我們要做加法的數據,現在我們看一下ADD操作是如何的。
如下爲ADD操作的代碼片段:
CASE(ADD)
{
ON_OP();
updateIOGas();
//pops two items and pushes their sum mod 2^256.
m_SPP[0] = m_SP[0] + m_SP[1];
}
NEXT
上面的代碼通過宏展開後代碼如下:
ADD:
{
onOperation();
updateIOGas();
m_SPP[0] = m_SP[0] + m_SP[1];
}
++m_PC;
fetchInstruction();
goto* jumpTable[(int)m_OP];
現在step by step的看一下這部分是如何進行處理,首先是opOperaion函數,如下爲onOperation
函數的實現:
if (m_onOp)
(m_onOp)(++m_nSteps, m_PC, m_OP,
m_newMemSize > m_mem.size() ? (m_newMemSize - m_mem.size()) / 32 : uint64_t(0),
m_runGas, m_io_gas, this, m_ext);
代碼中的m_onOp爲提供的回調函數,其中其中m_onOp的類型爲OnOpFunc,聲明如下如下所示:
using OnOpFunc = std::function<void(uint64_t /*steps*/,
uint64_t /* PC */,
Instruction /*instr*/,
bigint /*newMemSize*/,
bigint /*gasCost*/,
bigint /*gas*/,
VMFace const*,
ExtVMFace const*)>;
函數設計主要是在虛擬執行的時候提供一個可以回調的接口,方便進行處理,比如做最簡單的tracing接下來執行函數updateIOGas(),該函數功能主要是查看目前的gas消耗是否已經大於提供的gas,
如果大於,則拋出異常,虛擬機停止運行。代碼片段如下:
if (m_io_gas < m_runGas)
throwOutOfGas();
m_io_gas -= m_runGas;
注意函數中的m_runGas在函數updateGas中被修改,會根據內存的使用來進行計算和消耗。接下來執
行的代碼爲m_SPP[0] = m_SP[0] + m_SP[1];主要功能是完成兩個數的相加計算結果會存儲在m_SPP[0]
中,m_SPP的含義是“指向下一個棧中可用的位置”,這裏指向m_SP[0]運行初始化開始時m_SPP = m_SP。
接着執行++m_PC,這裏將指向代碼字節流中的下一個操作。接着執行數fetchInstruction(),這個時候
變量m_OP中存儲的是新的操作,繼續執行goto* jumpTable[(int)m_OP],這個時候將跳到下一個操作的
label去執行。
綜上,EVM虛擬機就是藉助棧和基本的操作來完成一個合約字節流的運行的。執行後的結果返回
在操作序列的RETURN和REVERT操作,將棧上的結果數據拷貝到內存中去。以下爲RETURN部分代碼CASE(RETURN)
{
ON_OP();
m_copyMemSize = 0;
updateMem(memNeed(m_SP[0], m_SP[1]));
updateIOGas();
uint64_t b = (uint64_t)m_SP[0];
uint64_t s = (uint64_t)m_SP[1];
//m_output中存儲的就是最後返回的結果
m_output = owning_bytes_ref{std::move(m_mem), b, s};
m_bounce = 0;
}
BREAK
運行過程中的Gas的消費
上面描述ADD和所涉及到的PUSH操作的時候我們就已經看到會調用updateIOGas函數來計算gas
的消費。從代碼中我們可以看到不同的操作對應不同的gas消費具體如下:
no gas | IO gas 1 | IO gas 2 | mem gas |
---|---|---|---|
CREATE2 | RETURN | REVERT | RETURN |
CREATE | SUICIDE | STOP | REVERT |
DELEGATECALL | MLOAD | MSTORE | MLOAD |
STATICCALL | MSTORE8 | SHA3 | MSTORE |
CALL | LOG0 | LOG1 | MSTORE8 |
CALLCODE | LOG2 | LOG3 | SHA3 |
JUMPTO | EXP | ADD | LOG1 |
JUMPIF | MUL | SUB | LOG2 |
JUMPV | DIV | SDIV | LOG3 |
JUMPSUB | MOD | SMOD | CALLDATACOPY |
JUMPSUBV | NOT | LT | |
RETURNSUB | GT | SLT | |
BEGINSUB | SGT | EQ | |
BEGINDATA | ISZERO | AND | |
GETLOCAL | OR | XOR | |
PUTLOCAL | BYTE | SHL | |
SHR | SAR | ||
ADDMOD | MULMOD | ||
SIGNEXTEND | ADDRESS | ||
ORIGIN | BALANCE | ||
CALLER | CALLVALUE | ||
CALLDATALOAD | CALLDATASIZE | ||
RETURNDATASIZE | CODESIZE | ||
EXTCODESIZE | CALLDATACOPY | ||
RETURNDATACOPY | RETURNDATACOPY | ||
CODECOPY | CODECOPY | ||
GASPRICE | BLOCKHASH | ||
COINBASE | TIMESTAMP | ||
NUMBER | DIFFICULTY | ||
GASLIMIT | POP | ||
PUSH | JUMP | ||
DUP | SWAP | ||
SLOAD | SSTORE | ||
PC | MSIZE | ||
GAS | JUMPDEST | ||
INVALID |
小結
EVM如果精確的定位應該是一個基於棧的自定義字節碼解釋器,實現上並不複雜,代碼過程中遇到
的主要問題有以下幾點:
- 字節寬度的設計,目前看主要是出於實現難度和實際需求(如SHA3的操作)
- 設計上每次都會生成新的VM對象,出於安全和目前需求這樣的設計和實現是可以接受的,數據
共享和多線程處理在智能合約的層面目前還不需要。
- EVM整體上實現了基本的操作,但是還需要和語言編譯器去結合來做具體的優化,JIT是一個方向。