EVM本质上是一个堆栈机器,它最直接的的功能是执行智能合约,根据官方给出的设计原理,EVM的主
要的设计目标为如下几点:
EVM机器位宽为256位,即32个字节,256位机器字宽不同于我们经常见到主流的64位的机器
字宽,这就标明EVM设计上将考虑一套自己的关于操作,数据,逻辑控制的指令编码。目前主流的处理
器原生的支持的计算数据类型有:8bits整数,16bits整数,32bits整数,64bits整数。一般情况下宽字
节的计算将更加的快一些,因为它可能包含更多的指令被一次性加载到pc寄存器中,同时伴有内存访问
次数的减少。目前在X86的架构中8bits的计算并不是完全的支持(除法和乘法),但基本的数学运算大概
在几个时钟周期内就能完成,也就是说主流的字节宽度基本上处理器能够原生的支持,那为什么EVM要采
用256位的字宽。主要从以下两个方面考虑:
时间上主要体现在执行的效率上,我们以两个整形数相加来对比具体的操作时间消耗。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位操作要比系统原生支持的要复杂的多,从时间上考虑采用
的操作来进行数据的compact。从时间和空间角度来看,仅支持256字节宽度的选择有利有弊,具
体还要看以太坊智能合约的具体应用。可能的几点原因如下:
EVM中数据可以在三个地方进行存储,分别是栈,临时存储,永久存储。由于EVM是基于栈的虚拟机,
因此基本上所有的操作都是在栈上进行的,并且EVM中没有寄存器的概念,这样EVM对栈的依赖就更大,
虽然这样的设计使实现比较简单且易于理解,但是带来的问题就是需要更多数据的相关操作。在EVM
中栈是唯一的免费(几乎是)存放数据的地方。栈自然有深度的限制,目前的限制是1024
static constexpr int64_t stackLimit = 1024;
因为栈的限制,因此栈上的临时变量的使用会受限制。临时内存存储在每个VM实例中,并在合约执行完
后消失永久内存存储在区块链的状态层。
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中主要类的类图:
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#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
.......
.......
在开关EVM_JUMP_DISPATCH打开的情况下该宏展开为如下代码片段:
#define DO_CASES \
fetchInstruction(); \
goto* jumpTable[(int)m_OP];
由上面的代码可知操作符定义为一个字节,函数fetchInstruction执行后m_OP中存储了当前要
#define CASE(name) \
name:
宏NEXT
在开关EVM_JUMP_DISPATCH打开的情况下NEXT展开的内容如下 :
#define NEXT \
++m_PC; \
fetchInstruction(); \
goto* jumpTable[(int)m_OP];
宏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来完成
//初始化时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];
这里的设计其实有可以改进的地方,主要是以下几点:
通过执行完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虚拟机就是借助栈和基本的操作来完成一个合约字节流的运行的。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
上面描述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如果精确的定位应该是一个基于栈的自定义字节码解释器,实现上并不复杂,代码过程中遇到
的主要问题有以下几点:
共享和多线程处理在智能合约的层面目前还不需要。