也来谈一谈以太坊虚拟机EVM的缺陷与不足

qtum 发布在 链圈子 0 2226

202512982227217556

首先对作者做个简单的自我介绍,Jordan Earls是Qtum量子链的联合创始人之一,目前担任Qtum量子链全球首席工程师。Qtum目前采用了以太坊虚拟机(Ethereum Virtual Machine, 以下简称EVM)并将其运用于非以太坊的区块链中(当然Qtum项目还包含很多其他工作,因与本文不相关,故不赘述)。因为Qtum中采用了EVM,因此笔者在项目执行过程中对其进行了比较深入的了解和学习。知道我学习EVM后最大的感受是什么吗?说实话我真的不喜欢EVM,因为在我个人看来,它无论从设计上还是实现上都有不少缺陷。利益相关:笔者计划在Qtum中建立一个新的虚拟机,并试图解决本文中提到的大部分问题。

那么接下来让我们直接进入主题。首先,EVM的设计初衷是什么?它为什么被设计成目前我们看的样子呢?根据以太坊官方提供的设计原理说明,EVM的设计目标主要针对以下方面:
  1. 简单性(Simplicity)
  2. 确定性(Determinism)
  3. 节省空间的bytecode
  4. 专为区块链设计
  5. 更加简单的安全性保证
  6. 容易优化
如果读者浏览一下这个文档,会发现EVM的设计看上去都非常的合理。那么问题在哪里呢?问题就出在它和目前主流的技术以及设计范例都格格不入。EVM如果作为一个毫无限制的非现实世界中的设计确实很不错。接下来笔者会围绕EVM各个方面的问题逐一进行描述,首先从笔者最不能忍受的一点开始。 
256bit整数目前大多数的处理器主要有以下4种选择来实现快速的数学运算:
  1. 8bit整数
  2. 16bit整数
  3. 32bit整数
  4. 64bit整数
当然,虽然在一些情况下32bit比16bit要快,以及在x86架构中8bit数学运算并不是完全支持(无原生的除法和乘法支持),但基本上如果你采用以上的任意一种,都可以保证数学运算在若干个时钟周期中完成,并且这个过程非常迅速,往往是纳秒级的。因此,我们可以说,这些位长的整数是目前主流处理器能够“原生地”支持的,不需要任何额外的操作。EVM出于所谓运算速度和效率方面考虑,采用了非主流的256bit整数。

让我们通过对比x86汇编码来看看它的表现。

 

首先是两个32bit整数相加的x86汇编码(也就是大多数PC的处理器采用的):

mov eax, dword [number1]

add eax, dword [number2]

然后是2个64bit整数相加,这里假设采用64位处理器:

mov rax, qword [number1]

add rax, qword [number2]

接下来是在32位x86计算机上两个256bit整数相加:

mov eax, dword [number]add dword [number2], eaxmov eax, dword [number1+4]

adc dword [number2+4], eax

mov eax, dword [number1+8]

adc dword [number2+8], eax

mov eax, dword [number1+12]

adc dword [number2+12], eax

mov eax, dword [number1+16]

adc dword [number2+16], eax

mov eax, dword [number1+20]

adc dword [number2+20], eax

mov eax, dword [number1+24]

adc dword [number2+24], eax

mov eax, dword [number1+28]

adc dword [number2+28], eax

当然还有在64位x86计算机上两个256bit整数相加:
mov rax, qword [number]add qword [number2], raxmov rax, qword [number1+8]

adc qword [number2+8], rax

mov rax, qword [number1+16]

adc qword [number2+16], rax

mov rax, qword [number1+24]

adc qword [number2+24], rax

通过以上比较足以说明采用256bit整数远比采用处理器原生支持的整数长度要复杂。EVM之所以选择这种设计,主要是因为仅支持256bit整数会比增加额外的用于处理其他位宽整数的opcodes来的简单得多。仅有的非256bit操作是一系列的push操作,用于从memory中获取1-32字节的数据,以及一些专门针对8bit整数的操作。 

那么对于所有操作都采用这种低效的整数位宽的设计初衷是什么呢?

“4字节或8字节字长限制了更大的内存寻址和复杂的密码学运算,同时无限制的值将很难实现安全的gas模型”

关于地址,我必须承认,能够仅用单个操作实现两个地址的比较确实很酷。但是,在x86机器上采用32bit整数实现相同功能也并不复杂(无SSE和任何其他优化):

mov esi, [address1]mov edi, [address2]mov ecx, 32 / 4

repe cmpsd

jne not_equal

; if reach here, then they’re equal

假设address1和address2 都是确定的地址,仅需要6+5+5=16字节的opcodes,而如果地址都在栈上,则仅需要6+3+3=12字节的opcode。关于另一个理由“复杂的密码学运算”,笔者从几个月前第一次看到这个理由,直到现在都没有看到过一个不涉及地址或哈希值比较的256bit整数的应用实例。密码学运算如果在区块链上运行显然过于昂贵了。笔者在github上搜索了一个多小时,试图找到一个在solidity合约中用到密码学运算的实例,结果却一无所获。几乎所有的密码学运算对于目前的计算机来说都是复杂的,所以在以太坊公有链上进行这种运算是非常昂贵的(必须消耗大量的gas,更不用说把密码学算法用solidity实现所需要的工作量)。当然,如果是一条私有链,gas消耗可能不是问题。但如果你是这条链的拥有者,你应该也不会选择用低效的EVM智能合约来实现密码学运算,而会选择采用C++,Go,或者其他一些编程语言实现。综上所述,EVM仅支持256bit整数的理由完全不成立。笔者认为这是EVM最根本也是最明显的问题,除此之外,EVM还有不少问题,下面我们一一道来。

EVM的内存分配模型

EVM中主要有3个用于存储数据的地方:

  1. 栈(Stack)
  2. 临时内存(Temporary memory)
  3. 永久内存(Permanent memory)
栈存储有许多限制,所以有时候你必须使用临时内存(永久内存比较昂贵)。在EVM没有allocate或类似的操作,所以必须通过直接写数据来获取内存空间。这看起来非常智能,但实际上却有不少问题。比如,如果你需要寻址到0×10000,你的合约将分配64K字长(也就是64K的256bit的word)的内存并且你需要支付64K字长对应的gas。有个比较简单的变通方法,就是你可以跟踪你上一次被分配的内存,当你需要时可以继续使用未使用的内存。这是有很有效的方法,直到你需要的内存超过了剩余可用的内存。我们假设你写个某个算法,需要100字长的内存。你分配并使用了该内存,支付了100字长内存对应的gas,然后退出了这个函数。之后你回到了另一个函数中,它只需要1字长的内存,系统又重新分配了另外1字长的内存,这样你总共使用了101字长的内存。EVM中没有办法释放内存。理论上你可以通过记录最后使用的内存地址,实现内存的释放和复用,但这仅在你能确定这段内存不会再被引用的前提下才具有可行性。如果在这100个word中你需要用到第50和第90个word,那么你必须先把他们拷贝到其他地方(比如栈上)然后再释放原来的内存。EVM并没有为此提供相关的工具。是否对智能合约中函数对分配内存的使用进行检查完全取决于你,如果你决定复用这些内存,但又没有检测出异常情况,那么你的智能合约将面临潜在的重大bug。所以你要么承担复用内存带来的风险,要么支付足够多的gas以获取安全的内存分配。 

除此之外,分配内存所需要花费的gas并不是线性的。比如你分配了100字长的内存,之后又分配1字长内存,这最后1字长内存的花费将明显高于你一开始就只分配1字长内存的花费。这又大大增加了保证内存安全所需的花费。

 

既然如此,那为什么非要使用内存呢?为什么不使用栈?实际上EVM中栈有明显的限制。EVM中的栈

EVM是一个基于栈的虚拟机。这就意味着对于大多数操作都使用栈,而不是寄存器。基于栈的机器往往比较简单,且易于优化,但其缺点就是比起基于寄存器的机器所需要的opcode更多。

所以EVM有许多特有的操作,大多数都只在栈上使用。比如SWAP和DUP系列操作等,具体请参见EVM文档。现在我们试着编译如下合约:

pragma solidity ^0.4.13;contract Something{

 

function foo(address a1, address a2, address a3, address a4, address a5, address a6){

address a7;

address a8;

address a9;

address a10;

address a11;

address a12;

address a13;

address a14;

address a15;

address a16;

address a17;

}

}

你将看到如下错误:CompilerError: Stack too deep, try removing local variables.这个错误是因为当栈深超过16时发生了溢出。官方的“解决方案”是建议开发者减少变量的使用,并使函数尽量小。当然还有其他几种变通方法,比如把变量封装到struct或数组中,或是采用关键字memory(不知道出于何种原因,无法用于普通变量)。既然如此,让我们试一试这个采用struct的解决方案:

pragma solidity ^0.4.13;contract Something{

struct meh{

address x;

}

function foo(address a1, address a2, address a3, address a4, address a5, address a6){

address a7;

address a8;

address a9;

address a10;

address a11;

address a12;

address a13;

meh memory a14;

meh memory a15;

meh memory a16;

meh memory a17;

}

}

结果呢?CompilerError: Stack too deep, try removing local variables.
我们明明采用了memory关键字,为什么还是有问题呢?关键在于,虽然这次我们没有在栈上存放17个256bit整数,但我们试图存放13个整数和4个256bit内存地址。这当中包含一些Solidity本身的问题,但主要问题还是EVM无法对栈进行随机访问。据我所知,其他一些虚拟机往往采用以下两种方法之一来解决这个问题:

  1. 鼓励使用较小的栈深,但可以很方便地实现栈元素和内存或其他存储(比如.NET中的本地变量)的交换;
  2. 实现pick或类似的指令用于实现对栈元素的随机访问;

然而,在EVM中,栈是唯一免费的存放数据的区域,其他区域都需要支付gas。因此,这相当于鼓励尽量使用栈,因为其他区域都要收费。正因为如此,我们才会遇到上文所述的基本的语言实现问题。

 

bytecode大小

在EVM设计文档中,设计者声称他们的目标是使得EVM的bytecode既简单又高度压缩。然而,这就像是试图写出既详尽又简洁的代码一样,实际上两者是存在一定矛盾。要实现一个简单的指令集就需要尽量限制操作的种类,并保持每种操作的尽量简单;然而,要实现高度压缩的bytecode则需要引入拥有丰富操作的指令集。

即使是“高度压缩的bytecode”这一目标也没有在EVM中实现,他们更加侧重于实现易于生成gas模型的指令集。我并不是说这是错的,只是想表明作为官方声明的EVM最重要的目标之一最终并没有实现这一事实。同时,EVM设计文档中给出了一个数据:C语言实现的“Hello World”简单程序生成4000字节的bytecode。这一结果并不正确,很大程度取决于编译环境以及优化程度。在他们所述的C程序中,应该同时包含了ELF数据,relocation数据以及alignment优化等。笔者尝试编译了一个非常简单的C程序(只有一个程序骨架),只需要46字节的x86机器码;同时还用C语言写了一个简单的greeter type程序(Solidity示例程序),最终生成大约700字节bytecode,而同样的Solidity示例程序则需要1000字节bytecode。

我当然明白简化指令集是出于某些安全性因素考虑,但这显然会导致区块链更加臃肿。如果EVM智能合约的bytecode尽可能小的话确实是有害的。我们完全可以通过增加标准库或是支持可以批处理某些基本操作的opcode来减小bytecode。

 

256bit整数(补充)

256bit整数确实令人头疼,所以这里再做一些补充。最令人费解的是256bit整数被用到了一些根本没必要的地方。比如,我们根本不可能在合约中使用超过4B(32bit)单位的gas,那么你猜在EVM中采用什么长度的整数来作为gas的计量呢?没错,当然是256bit。内存使用也非常昂贵,那内存大小的计量呢?自然也是256bit,当你的合约需要用到比宇宙中原子数量还多的地址时这个数字或许真的能派上用场。虽然我不认同在寻址或是永久内存的变量中使用256bit整数,但不得不说它使得计算某些数据的hash时能够避免冲突,因此这还能勉强接受。但对于任一个instance,本可以采用任何整数长度,EVM还是使用了256bit。甚至JUMP也使用256bit,但他们限制了最大的JUMP地址为0x7FFFFFFFFFFFFFFF,相当于限制在64bit整数范围内。最后,以太坊中的币值当然也采用了256bit数来计算。ETH的最小单位是wei,所以总的币的数量(单位为wei)为1000000000000000000 * 200000000 (200M只是估计值,目前仅有约92M)。而2^256约为1.157920892373162e+77,这足以表示所有已存在的所有ETH外加比全宇宙原子数还多的wei……归根结底,256bit整数在EVM所设计的大多数应用中都没有必要。

 

缺少标准库

如果你曾经开发过Solidity智能合约的话,你应该也会碰到这个问题,因为Solidity中根本就没有标准库。如果你想比较两个字符串,Solidity中根本就没有类似strcmp或memcmp的标准库函数供你调用,你必须自己用代码实现或在网上拷贝代码来实现。Zeppin项目使这一情况得到一定改善,他们提供了一个可供合约使用的标准库(通过将代码包含在合约中或是调用外部合约)。然而,这种方式的限制也很明显,主要是在gas消耗方面。比如判断字符串是否相等,进行两次SHA3操作然后比较hash值显然要比循环比较每个字符所要花费的gas要少。如果存在预编译好的标准库,并设定合理的gas价格,这将更加有利于整个智能合约生态的发展。目前的情况是,人们只能不断的从一些开源软件中复制黏贴代码,首先这些代码的安全性无法保证,再加上人们会为了更小的gas消耗而不断修改代码,这就有可能对他们的合约引入更严重的安全性问题。

 

gas经济模型中的博弈论

我打算写一篇新的博客单独阐述这个主题。EVM不仅使写出好的代码变得很困难,还令其变得非常昂贵。比如,在区块链上存储数据需要耗费大量的gas。这意味着在智能合约中缓存数据的代价会非常大,因此往往在每次合约运行时重新计算数据。随着合约被不断执行,越来越多的gas和时间都被花在了重复计算完全相同的数据上。实际上单纯通过交易在区块链上存储数据并不会消耗太多的gas,因为这并不会直接增加区块的大小(不管以太坊还是Qtum都是如此)。真正花费比较大的其实是那些发送给合约的数据,因为这将直接增加区块的大小。在以太坊中,通过交易在区块链上记录32byte的数据比在合约中存储相同的数据消耗的gas要少一些,而如果是64byte的数据,则消耗的数据就少得多了(29,704 gas v.s. 80,000gas)。在合约中储存数据会有“virtual”的花费,但比大多数人想象的要少得多。基本上就是遍历区块链上数据库的花费。Qtum和以太坊采用的RLP和LevelDB数据库系统在这方面非常高效,但持续的成本并不是线性的。

EVM鼓励这种低效率的代码的另一原因就是其不支持直接调用智能合约中某个具体的函数。这当然是出于安全性考虑,如果允许直接调用在ERC20代币合约中的withdraw函数,结果确实会是灾难性的。但是这在标准库调用中将会非常高效。目前EVM中要么执行智能合约的所有代码,要么一点也不执行,完全不可能只执行其中部分代码。程序总是从头开始运行,无法跳过Solidity ABI引导代码。所以这导致的结果就是一些小函数被不断复制(因为通过外部调用将更加昂贵),并且鼓励开发者在同一个合约中包含尽量多的函数。调用一个100bytes的合约并不比调用10000bytes的合约昂贵,尽管所有代码都必须加载到内存中。

最后一点,就是EVM中无法直接获取合约中存储的数据。合约代码必须先被完全加载并执行,并且包含你所请求的数据,最终通过合约调用返回值的形式返回数据(还得保证没有多个返回值)。同时,当你不确定你需要的是哪个数据,需要来来回回地调用合约时,第二次调用合约所需要的gas并没有任何折扣(不过至少合约还在缓存中,对节点来说第二次调用稍微便宜一些)。实际上完全可以在不加载整个外部合约的基础上访问其数据,这其实和获取当前合约的存储数据没什么两样,为什么偏要采用如此昂贵且低效的方式呢?

 

难以调试和测试

这个问题不仅仅是由于EVM的设计缺陷,也和其实现方式有关。当然,有一些项目正在做相关工作使整个过程变得简单,比如Truffle项目。然而EVM的设计又使这些工作变得很困难。EVM唯一能抛出的异常就是“OutOfGas”,并且没有调试日志,也无法调用外部代码(比如test helpers和mock数据),同时以太坊区块链本身很难生成一条测试网络的私链,即使成功,私链的参数和行为也与公链不同。Qtum至少还有regtest模式可用,而在EVM中使用mock数据等进行测试则真的非常困难。据我所知目前还没有任何针对Solidity的调试器,虽然有一款我知道的EVM assembly调试器,但其使用体验极差。EVM和Solidity都没有创建用于调试的符号格式或是数据格式,并且目前没有任何一个EIP提出要建立像DWARF一样标准的调试数格式。

 

不支持浮点数

对于那些支持EVM不需要浮点数的人来说,最常用的理由就是“没有人会在货币中采用浮点数”。这其实是非常狭隘的想法。浮点数有很多应用实例,比如风险建模,科学计算,以及其他一些范围和近似值比准确值更加重要的情况。这种认为智能合约只是用于处理货币相关问题的想法是非常局限的。

 

不可修改的代码

智能合约在设计时需要考虑的重要问题之一就是是可升级性,因为合约的升级是必然的。在EVM中代码是完全不可修改的,并且由于其采用哈佛计算机结构,也就不可能将代码在内存中加载并执行,代码和数据是被完全分离的。目前只能够通过部署新的合约来达到升级的目的,这可能需要复制原合约中的所有代码,并将老的合约重定向到新的合约地址。给合约打补丁或是部分升级合约代码在EVM中是完全不可能的。

 

小结

不可否认,EVM作为第一个区块链虚拟机存在诸多问题,这和绝大多数新生事物一样(比如Javascript)。并且由于它的设计比较非主流,我认为不会有主流的编程语言能够移植到EVM上。这种设计可以说对于近50年来的大多数编程范例来说都不太友好。比如JUMPDEST使得jump table优化更加困难,不支持尾递归,诡异且不灵活的内存模型,栈的限制,当然还有256bit整数等等。这种种问题都使得移植主流编程语言的代码变得困难重重。我想这就是目前EVM只能支持专门定制的开发语言的原因。这是在是件令人遗憾的事。

 

笔者写这篇文章并不是想要攻击EVM的设计者,只是就事论事的讨论。事后诸葛亮总是看上去很容易,实际上我知道EVM设计者们已经意识到某些方面的不足,并因此感到懊悔。我并不想指责他们(虽然看起来我就是在吐槽),我真正的目的是想通过指出这些问题来引起整个区块链开发者社区的重视,从而我们不会重蹈覆辙,同时相信也能解答诸如“为什么我在Solidity中不能实现blabla功能”等问题。EVM的设计比较复杂,我们都还在学习这种设计带来的好处以及弊端。我们可以确信的是,目前的智能合约还远没有达到我们对它的期望,在未来它的功能会变得更加强大。EVM是这个领域的开拓者,通过它我们可以不断加深对智能合约的认识,并从中总结出最合理的设计。路漫漫其修远兮,吾将上下而求索。

发文时比特币价格 ¥29100.2
作者:Jordan Earls 版权声明: by nc" sa 作者保留权利。文章为作者独立观点,不代表巴比特立场。

评论:0

您需要登录后才可以回复 登录|注册