@Rays
2018-11-16T10:59:45.000000Z
字数 14507
阅读 4119
本文是“深入区块链”系列文章之一,意在深入介绍以太坊等区块链的内部工作机制。LinkChain博客前期曾翻译并分享了该系列文章的其中一篇“四十种智能合约支持平台(完全版)”,其它文章如下:
Getting Deep Into Geth: Why Syncing Ethereum Node Is Slow
Getting Deep Into Ethereum: How Data Is Stored In Ethereum?
本文将详细深入介绍EVM的核心机制,涉及智能合约建立机制、消息调用机制,并介绍存储、内存、CALLDATA和栈(STACK)这四类数据管理机制。
要理解本文内容,读者最好具有基础的以太坊知识。如果有必要,推荐阅读下面这篇博客:
5 resources to get started with ethereum
文中使用的所有例子和演示,均开源提供在该代码库中,读者可以克隆代码、运行npm install,进而查看运行结果。
在深入了解并通过代码例子查看EVM的工作机制之前,我们先阐述EVM最适用于以太坊之处,以及EVM的组成。在看到下面给出的复杂结构图时,请不要心生恐惧。一旦读完本文,你自然会理解这些它们。
下图给出了EVM是如何匹配以太坊运行的:
下图给出了EVM的基本架构:
下图给出了EVM各组成部分间的相互作用机制,由此实现了以太坊的神奇功能。
至此,我们对EVM有了一个整体上的了解。下面,我们将深入介绍各部分在以太坊运行中的重要作用。
智能合约就是一种计算机程序。以太坊合约可以称为运行在EVM上的智能合约。EVM是一种“沙箱”运行时,它为智能合约在以太坊中的运行提供了完全独立的环境。这也意味着,运行在EVM中的每个智能合约不能访问网络,也不能访问运行EVM主机上的任何进程。
我们知道,以太坊具有两种类型的账户,合约账户(contract account)和外部账户(external account)。每个账户由一个地址唯一标识,所有账户共享同一地址空间。EVM可处理的地址长度为160比特。
每个账户由余额(balance)、nonce、字节码(bytecode)和存储数据(即存储,storage)组成。以太坊的两类账户间存在着一些差异之处。例如,外部账户的字节码和存储为空,而合约账户中存储了字节码和整个状态树的默克尔根哈希值。此外,外部地址对应一个私钥,而合约账户则没有。对于合约账户,除了对每个以太坊交易做正常的加密认证外,所有其余动作均由其所持有的字节码控制。
合约是以交易的形式创建的。交易中的接收者地址为空,数据域则包含了要创建合约的编译后字节码(需注意,一个合约可以创建另一个合约)。下面给出一个例子。打开练习一的目录,其中可以看到一个名为“MyContract”的合约。该合约的代码如下:
pragma solidity ^0.4.21;
contract MyContract {
event Log(address addr);
function MyContract() public {
emit Log(this);
}
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
}
运行命令truffle develop
,以开发模式打开一个Truffle终端。一旦终端启动完成,使用下面命令在MyContract
中部署实例:
truffle(develop)> compile
truffle(develop)> sender = web3.eth.accounts[0]
truffle(develop)> opts = { from: sender, to: null, data: MyContract.bytecode, gas: 4600000 }
truffle(develop)> txHash = web3.eth.sendTransaction(opts)
下面检查合约是否成功部署。运行如下代码:
truffle(develop)> receipt = web3.eth.getTransactionReceipt(txHash)
truffle(develop)> myContract = new MyContract(receipt.contractAddress)
truffle(develop)> myContract.add(10, 2)
{ [String: ‘12’] s: 1, e: 1, c: [ 12 ] }
我们深入查看一下上面的工作。当一个新的合约部署到以太坊区块链上时,首先完成的是创建合约对应的账户¹。在上例中我以看到,构造函数(constructor)中记录了合约的地址。为进一步确认,可查看receipt.logs[0].data
中保存的就是合约生成的32个字节的地址,receipt.logs[0].topics
中保持的是“Log(address)
”字符串的Keccak-256哈希(即SHA3)。
K
图 调用合约中函数时,后台的运行结构图
下一步,随交易发送的数据将以字节码方式执行。它初始化存储中的状态变量,并确定所创建的合约体。该过程在合约的全生命周期中仅执行一次。初始化的代码并非存储在合约中,它实际上生成了要存储的字节码作为其返回值。谨记,一旦合约账户创建完成,就无法更改合约的代码²。
鉴于初始化过程返回了合约体中将要存储的字节码,因此从构造函数的逻辑无法访问此代码,这点具有意义的。下面以练习一中的Impossible
合约为例:
contract Impossible {
function Impossible() public {
this.test();
}
function test() public pure returns(uint256) {
return 2;
}
}
如果我们尝试编译该合约,那么就会得到警告,指出在构造函数中引用了 this
(即“referencing this
within the constructor function”)。该警告并不影响编译的继续进行。但是一旦部署新实例,就会出现终止执行并还原状态(revert)。这是因为运行尚未存储的代码是毫无意义的³。另一方面,因为账户已经创建,我们可以访问合约的地址,但是其中并未存储任何代码。
此外,代码执行还会产生另一种情况,例如更改了存储、创建更多账户、做更多的消息调用等。下面以AnotherContract
合约代码为例:
contract AnotherContract {
MyContract public myContract;
function AnotherContract() public {
myContract = new MyContract();
}
}
在Truffle终端中运行下面命令,查看合约的运行情况:
truffle(develop)> compile
truffle(develop)> sender = web3.eth.accounts[0]
truffle(develop)> opts = { from: sender, to: null, data: AnotherContract.bytecode, gas: 4600000 }
truffle(develop)> txHash = web3.eth.sendTransaction(opts)
truffle(develop)> receipt = web3.eth.getTransactionReceipt(txHash)
truffle(develop)> anotherContract = AnotherContract.at(receipt.contractAddress)
truffle(develop)> anotherContract.myContract().then(a => myContractAddress = a)
truffle(develop)> myContract = MyContract.at(myContractAddress)
truffle(develop)> myContract.add(10, 2)
{ [String: ‘12’] s: 1, e: 1, c: [ 12 ] }
另一方面,鉴于Solidity结构最终也将编译为指令码,因此合约也可以使用CREATE
指令码(opcode)创建。上面介绍的两种合约创建方式的工作机制相同。
下面,我们将介绍消息调用的工作机制。
合约间可以通过消息调用实现相互调用。一个Solidity合约在每次调用另一个合约的函数时,就会生成一次消息调用。每次调用具有发送者、接受者、二进制内容(payload)、值以及GAS数量。限制消息调用的深度为不大于1024层。
Solidity为地址类型提供了原生调用方法,工作如下:
address.call.gas(gas).value(value)(data)
其中,gas
是要传递的GAS数量,address
是调用的地址,value
是要传递的以太币wei数,data
是要发送的二进制内容。谨记,value
和gas
是可选参数,使用应谨慎。因为默认情况下,低层调用将会将发送者几乎全部剩余的GAS发送出去。
图 GAS消费结构图
从上图可见,合约可以确定每次调用中要传递的GAS数量。每次调用都会因为“GAS耗尽”(OOG,out-of-gas)异常而终止执行。为避免出现安全问题,调用中至少会保留发送者GAS数量的1/64。这使得发送者可以处理调用的OOG异常、完成执行而不会耗尽GAS,进而也不会触发异常。
图 产生异常的结构图
下面以练习二中的Caller
合约为例:
contract Implementation {
event ImplementationLog(uint256 gas);
function() public payable {
emit ImplementationLog(gasleft());
assert(false);
}
}
contract Caller {
event CallerLog(uint256 gas);
Implementation public implementation;
function Caller() public {
implementation = new Implementation();
}
function () public payable {
emit CallerLog(gasleft());
implementation.call.gas(gasleft()).value(msg.value)(msg.data);
emit CallerLog(gasleft());
}
}
其中,Caller
合约只有一个回调函数,实现所有接收到的调用重定向到Implementation
实例。该实例只是通过每个接收到的调用上的assert(false)
抛出,调用将消费所有提供的GAS,进而在传递调用给Implementation
之前和之后,将GAS数量记录到Caller
中。下面启动一个Truffle终端运行如下命令,查看运行情况:
truffle(develop)> compile
truffle(develop)> Caller.new().then(i => caller = i)
truffle(develop)> opts = { gas: 4600000 }
truffle(develop)> caller.sendTransaction(opts).then(r => result = r)
truffle(develop)> logs = result.receipt.logs
truffle(develop)> parseInt(logs[0].data) //4578955
truffle(develop)> parseInt(logs[1].data) //71495
如结果所示,71495大体上构成了4578955的第64个部分。该例子清晰地验证了,代码处理了内部调用抛出的OOG异常。
Solidity还提供了call
操作码,支持在内联汇编(inline assembly)中管理调用:
call(g, a, v, in, insize, out, outsize)
其中,g
是要传递的GAS数量,a
是被调用地址,v
是要传递的以太币wei数,in
指定了保存调用数据的insize
字节的内存地址,out
和outsize
指定了返回数据的内存存储地址。汇编调用与函数二者的唯一不同之处在于,汇编调用支持我们处理返回数据,而函数只会返回1或0指示函数处理成功与否。
EVM支持一类特殊的消息调用变体,称为“delegatecall
”。同上,Solidity在提供内联汇编版本的同时,还提供了内建的地址方法。二者的不同之处在于,对于低层调用,目标代码在调用合约的上下文内执行,而msg.sender
和msg.value
并非如此。⁴
更好地理解delegatecall
的工作机制,我们对下面的例子进行分析。首先给出Greeter
合约的代码:
contract Greeter {
event Thanks(address sender, uint256 value);
function thanks() public payable {
emit Thanks(msg.sender, msg.value);
}
}
如上,Greeter
合约只定义了一个thanks
函数,发出一个承载了msg.value
和msg.sender
数据的事件。在Truffle终端中使用如下命令运行该方法:
truffle(develop)> compile
truffle(develop)> someone = web3.eth.accounts[0]
truffle(develop)> ETH_2 = new web3.BigNumber(‘2e18’)
truffle(develop)> Greeter.new().then(i => greeter = i)
truffle(develop)> opts = { from: someone, value: ETH_2 }
truffle(develop)> greeter.thanks(opts).then(tx => log = tx.logs[0])
truffle(develop)> log.event //Thanks
truffle(develop)> log.args.sender === someone //true
truffle(develop)> log.args.value.eq(ETH_2) //true
运行结果确认了该函数的功能。注意Wallet
合约的代码:
contract Wallet {
Greeter internal greeter;
function Wallet() public {
greeter = new Greeter();
}
function () public payable {
bytes4 methodId = Greeter(0).thanks.selector;
require(greeter.delegatecall(methodId));
}
}
该合约只定义了一个回调函数,通过delegatecall
执行Greeter#thanks
方法。下面通过Wallet
合约调用Greeter#thanks
合约,在Truffle终端查看运行情况:
truffle(develop)> Wallet.new().then(i => wallet = i)
truffle(develop)> wallet.sendTransaction(opts).then(r => tx = r)
truffle(develop)> logs = tx.receipt.logs
truffle(develop)> SolidityEvent = require(‘web3/lib/web3/event.js’)
truffle(develop)> Thanks = Object.values(Greeter.events)[0]
truffle(develop)> event = new SolidityEvent(null, Thanks, 0)
truffle(develop)> log = event.decode(logs[0])
truffle(develop)> log.event // Thanks
truffle(develop)> log.args.sender === someone // true
truffle(develop)> log.args.value.eq(ETH_2) // true
从结果中可以看到,delegatecall
函数保持了msg.value
和msg.sender
。
这意味着合约可以在运行时从不同的地址动态地加载代码。存储、当前地址和余额依然指向调用合约,只有代码是取自于被调用地址。这意味着可在Solidity中实现“软件库”⁵。
关于delegatecalls
,我们还需要了解一件事情。如上所述,被调用合约的存储是可以被所执行代码访问的。下面查看Calculator
合约的代码:
contract ResultStorage {
uint256 public result;
}
contract Calculator is ResultStorage {
Product internal product;
Addition internal addition;
function Calculator() public {
product = new Product();
addition = new Addition();
}
function add(uint256 x) public {
bytes4 methodId = Addition(0).calculate.selector;
require(addition.delegatecall(methodId, x));
}
function mul(uint256 x) public {
bytes4 methodId = Product(0).calculate.selector;
require(product.delegatecall(methodId, x));
}
}
contract Addition is ResultStorage {
function calculate(uint256 x) public returns (uint256) {
uint256 temp = result + x;
assert(temp >= result);
result = temp;
return result;
}
}
contract Product is ResultStorage {
function calculate(uint256 x) public returns (uint256) {
if (x == 0) result = 0;
else {
uint256 temp = result * x;
assert(temp / result == x);
result = temp;
}
return result;
}
}
其中,Calculator
合约只有两个函数,即add
和product
。Calculator
合约并不知道如何执行相加或相乘运算,而是将相应的调用分别代理(delegate)给Addition
和Product
合约。所有这些合约共享相同的状态变量结果,并存储每次计算的结果。下面在Turffle终端运行命令,查看运行情况:
truffle(develop)> Calculator.new().then(i => calculator = i)
truffle(develop)> calculator.addition().then(a => additionAddress=a)
truffle(develop)> addition = Addition.at(additionAddress)
truffle(develop)> calculator.product().then(a => productAddress = a)
truffle(develop)> product = Product.at(productAddress)
truffle(develop)> calculator.add(5)
truffle(develop)> calculator.result().then(r => r.toString()) // 5
truffle(develop)> addition.result().then(r => r.toString()) // 0
truffle(develop)> product.result().then(r => r.toString()) // 0
truffle(develop)> calculator.mul(2)
truffle(develop)> calculator.result().then(r => r.toString()) // 10
truffle(develop)> addition.result().then(r => r.toString()) // 0
truffle(develop)> product.result().then(r => r.toString()) // 0
可以确认,我们使用了Calculator
合约的存储。此外,所执行的代码存储在Product
合约和Addition
合约中。
对于call函数,同样存在Solidity汇编操作码版本的delegatecall
。下面给出Delegator
合约的代码,注意其中对delegatecall
的调用方式:
contract Implementation {
event ImplementationLog(uint256 gas);
function() public payable {
emit ImplementationLog(gasleft());
assert(false);
}
}
contract Delegator {
event DelegatorLog(uint256 gas);
Implementation public implementation;
function Delegator() public {
implementation = new Implementation();
}
function () public payable {
emit DelegatorLog(gasleft());
address _impl = implementation;
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
}
emit DelegatorLog(gasleft());
}
}
这里,我们使用了内联汇编去执行delegatecall
。你可能注意到,其中没有值参数,这是因为msg.value
不会发生更改。你可能会有疑问,为什么这里我们加载的是0x40
地址?calldatacopy
和calldatasize
是什么?我们将在本系列的下一篇文章中详细介绍。下面,我们可以在Truffle终端中运行同上的命令,验证合约的行为。
再次强调,重要的是要清楚理解delegatecall
的工作机制。每此触发的调用将被从当前合约发送,而非代理调用的合约。此外,所执行代码可以读写调用者合约的存储。如果合约并未正确地实现,甚至是存在非常小的错误,都可能导致上百万美元的损失。下文列出了以太坊历史上一些最严重的错误:
HackPedia: 16 Solidity Hacks/Vulnerabilities, their Fixes and Real World Examples
该文列举了Solidity被破解和漏洞的完全列表、修复情况,以及一些真实世界的破解实例。
EVM根据不同的应用场景,采用不同的方式管理不同类型的数据。除了合约代码之外,合约所管理的数据大体可分为四类:栈(Stack)、调用数据(calldata)、内存和存储。
EVM本身就是一种栈机器。也就是说,EVM的操作并非基于注册函数,而是基于虚拟栈。栈的最大规模是1024,其中栈条目(item)的大小是256比特。事实上,EVM是一种256比特的字(word)机器,这种设计便于Keccak256哈希模式和椭圆曲线密码(elliptic-curve)的计算。下图给出了大部分操作码输入参数的来源。
EVM提供了多种操作码,用于直接操作栈。其中包括:
调用数据是只读的字节地址编码空间,用于存储交易或调用的数据参数。不同于栈,要使用调用数据,必须要准确地指定字节偏移量和要读取的字节数。
调用数据的EVM操作码包括:
Solidity为上述操作码提供了内联编译版本,分别是calldatasize
、calldataload
和calldatacopy
。其中,calldatacopy
需要指定三个参数(t, f, s)
,将f
地址处的调用数据拷贝s
个字节到t
地址。此外,Solidity支持通过msg.data
访问调用数据。
你可能注意到,我们在以前文章的一些例子中使用了部分操作码。下面,我们再看一下delegatecall
的内联汇编代码块:
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
}
要将调用代理给_impl
地址,我们必须提交msg.data
。鉴于delegatecall
操作码在内存中操作数据,我们首先需要将调用数据拷贝到内存中。这里,我们使用calldatacopy
,将所有的调用数据拷贝到指定内存指针处。注意,我们使用了calldatasize
。
下面,我们看一下另一个使用调用数据的例子。在练习三的目录中,可以看到Calldata
合约的代码如下:
contract Calldata {
function add(uint256 _a, uint256 _b) public view
returns (uint256 result)
{
assembly {
let a := mload(0x40)
let b := add(a, 32)
calldatacopy(a, 4, 32)
calldatacopy(b, add(4, 32), 32)
result := add(mload(a), mload(b))
}
}
}
上面的代码将返回由参数传递而来的两个数字的相加运算结果。注意,这里我们再一次加载了从0x40
处读取的内存指针,原因将在本文稍后给出解释。我们在变量a
中存储内存指针,并在变量b
中存储a
的32个字节之后的位置。然后我们使用calldatacopy
将首个参数存储在a
中。你应该注意到,数据是从调用数据的第四个位置处拷贝的,而非首个位置处。这是因为调用数据的头四个字节保存了被调用函数的签名,这里是bytes4(keccak256("add(uint256,uint256)"))
。这是EVM用于识别调用时需要的函数。然后,我们存储b
中第二个参数,拷贝调用数据随后32个字节。最后,我们只需要计算加载在内存中两个值的和。
在Truffle终端中运行下面命令,测试运行结果:
truffle(develop)> compile
truffle(develop)> Calldata.new().then(i => calldata = i)
truffle(develop)> calldata.add(1, 6).then(r => r.toString()) // 7
内存是一种易失性、字节可寻址的读写空间,用于在合约执行期间存储数据,主要是将参数传递给内部函数。鉴于内存是易失性区域,因此在每次消息调用开始,都要执行清除内存操作。所有位置的最初定义为零。对于调用数据,内存可采用字节级别寻址,但一次只能读取32字节。
一旦一个字写入了一块以前从未使用的内存,我们就称之为内存“扩展”了。除了内存写入需要一定代价外,内存扩展也是有代价的,前724个字节的扩展代价是线性的,之后的扩展代价呈二次方增长。
EVM提供了三个操作内存区域的操作码:
Solidity同样对这些操作码提供了相应的内联汇编版本。
关于内存,我们还需要了解另一个关键点。Solidity总是在位置“0x40”处存储空闲内存指针,即对存储器中第一个未使用的字的引用。这就是为什么我们在操作内联汇编时需要加载这个字。这是因为头64字节内存是为EVM保留的,这样可以确保不会覆盖Solidity内部使用的内存。例如,在上面给出的delegatecall
例子中,我们加载此指针,存储给定的调用数据,实现数据转发。这是因为内联汇编操作码delegatecall
需要从内存中获取其负载。
此外,如果查看Solidity编译器的字节码输出,那么我们就会发现所有字节码是以0x6060604052…
开始的,这表示了:
PUSH1 : EVM操作码0x60
0x60 : 自由内存指针。
PUSH1 : EVM操作码0x60。
0x40 : 自由内存指针的内存位置。
MSTORE : EVM操作码0x52。
在汇编层级操作内存必须要谨慎,因为存在覆盖保留区域的风险。
存储是一种持久的、字可寻址的读写空间,是合约存储其中持久信息的地方。不同于内存,存储是持久性区域,只能使用字作为地址。它是2²⁵⁶个槽的键值映射,其中每个槽32字节。除了合约自身的存储,合约既不能读取也不能写入其它任何存储。所有位置最初定义为零。
在EVM的所有操作中,将数据保存到存储是需GAS数量最高的操作之一。这笔费用并非一成不变的。将存储槽从零值修改为非零值需要2万个GAS。存储相同的非零值或将非零值设置为零时需要5千个GAS。但是,对于后一种应用场景,即将非零值设置为零时,会提供15000个GAS的返还款。
EVM提供了两个操作存储的操作:
同样,Solidity内联编译也支持这些操作码。
Solidity自动将合约中每个已定义的状态变量映射到存储的相应插槽中。映射策略非常简单:固定大小的变量(即除映射和动态数组之外的所有变量)从存储的位置0开始连续布局。
对于动态数组,p
槽位存储数据长度,数组数据将由p
哈希(即keccak256(p)
)确定槽位数。
对于映射,不使用槽位,对应于键k
的值由keccak256(k,p)
定位。谨记,keccak256
的参数(k
和p
)总是填充为32字节。
为解释其中的工作机制,我们分析下面给出代码例子。在练习三合约目录中,提供了Storage
合约的代码如下:
contract Storage {
uint256 public number;
address public account;
uint256[] private array;
mapping(uint256 => uint256) private map;
function Storage() public {
number = 2;
account = this;
array.push(10);
array.push(100);
map[1] = 9;
map[2] = 10;
}
}
打开一个Truffle终端,测试合约的存储结构。首先,编译并创建一个新的合约实例:
Now, let’s open a truffle console to test its storage structure. First, we will compile and create a new contract instance:
truffle(develop)> compile
truffle(develop)> Storage.new().then(i => storage = i)
确保地址0保存数值2,地址1保存了合约的地址:
truffle(develop)> web3.eth.getStorageAt(storage.address, 0) // 0x02
truffle(develop)> web3.eth.getStorageAt(storage.address, 1) // 0x..
检查存储位置2保存了数组长度:
truffle(develop)> web3.eth.getStorageAt(storage.address, 2) // 0x02
最后,检查存储位置3是未使用的,映射值的存储方式如我们上面所介绍:
truffle(develop)> web3.eth.getStorageAt(storage.address, 3)
// 0x00
truffle(develop)> mapIndex = ‘0000000000000000000000000000000000000000000000000000000000000003’
truffle(develop)> firstKey = ‘0000000000000000000000000000000000000000000000000000000000000001’
truffle(develop)> firstPosition = web3.sha3(firstKey + mapIndex, { encoding: ‘hex’ })
truffle(develop)> web3.eth.getStorageAt(storage.address, firstPosition)
// 0x09
truffle(develop)> secondKey = ‘0000000000000000000000000000000000000000000000000000000000000002’
truffle(develop)> secondPosition = web3.sha3(secondKey + mapIndex, { encoding: ‘hex’ })
truffle(develop)> web3.eth.getStorageAt(storage.address, secondPosition)
// 0x0A
很好,上面演示了Solidity存储策略,正如我们所理解的!要了解更多Solidity是如何映射状态变量到存储中,可参阅官方文档.
希望本文有助于大家更好地理解EVM在以太坊架构中的功能。
¹ 以太坊黄皮书中提出,“新账户地址定义为:对仅包含发送者和帐户nounce的结构做RLP编码,对所得到编码求Keccak哈希值,取该哈希值最右边开始的160位。”
² zeppelin_os的支柱之一就是合同的可升级性。 Zeppelin一直在探索实施这一目标的不同策略。 在这里阅读更多相关信息。
³ Solidity在调外部函数前,会先验证该函数的地址中是否具有字节码。否则,终止函数执行并还原状态。
Vaibhav Saini是一家由MIT Cambridge 创新中心孵化的初创企业TowardsBlockchain的联合创始人。Saini也是一名高级区块链开发人员,具有Ethereum、Quorum、EOS、Nano、Hashgraph、IOTA等多种区块链平台的开发经验。他目前是德里印度理工学院(IIT Delhi)的一名大二学生。