深入以太坊虚拟机 总结
本文是 Diving Into The Ethereum Virtual Machine 系列文章的总结篇,更加简洁地给出了该系列文章的知识要点。您也可以在 Other Parts 部分查看每篇文章的译稿。
1. 汇编与字节码
智能合约在编译后的汇编代码中有一半都是样板(boilerplate)。另一半则是完成独有功能的汇编操作码指令。你可以从以太坊黄皮书(第30页左右的位置)了解所有的操作码。EVM 基本上是一个循环,从上到下地执行每条指令。比如 EVM 对 6001600081905550
所做的基本上是:sstore(0x0, 0x1)
,即把值 0x1
放在 0x0
的存储位置。
EVM 是一个 256 位的堆栈机器(stack machine),指令可能使用堆栈上的值作为参数,并将值作为结果压入堆栈。在汇编代码中,单独的数值比如 0x1
实际上是 push(0x1)
的简写,表示将值 push 到堆栈顶端。
以太坊的存储中,每个存储槽(storage slot)可以存储 32 个字节。因此 EVM 以 32 字节为单位处理数据是最自然的做法。如果可能,Solidity 通过将更小的数据类型打包(pack)到一个存储槽中来优化存储效率。打包的原因是目前为止最昂贵的操作是存储相关的操作:sstore
首次写入和后续写入、以及 sload
需要花费几百至几千 gas,而大多数指令只花费 3~10 gas。Opcodes for the EVM 和 Appendix - Dynamic Gas Costs 给出了所有操作码需要消耗的 gas 开销。
可以在编译时通过打开 optimize
标志让 Solidity 进行优化:solc --bin --asm --optimize xx.sol
。编译器在打包时会通过一些位操作来尽量减少存储相关指令的使用,从而节省 gas 费。
交易中每个零字节数据或代码需要支付 4 gas,每个非零字节数据需要 68 gas。你可能会在编译生成的字节码中看到很长的 0,其实使用短的字节码也有可能达到同样的效果,但由于字节码普遍非零,因此不一定更省 gas。
2. 固定长度数据类型的表示
在 EVM 语言中,了解数据类型的低级别表示非常重要,因为访问存储非常昂贵。sstore
比基本算术指令贵约 5000 倍,sload
比基本算数指令贵约 100 倍。因此运行和使用合约的成本很可能由 sstore
和 sload
主导。
合约的 EVM 存储就像一个(几乎)无限长的磁带,长度为 2^256(相当于 256 位的寻址空间),磁带的每个插槽(slot)都保存 32 个字节。存储最初是空白的,默认为 0。拥有无限长的磁带不会花费任何费用。存储变量的声明不需要任何费用,因为不需要初始化。Solidity 为声明的存储变量保留了位置,并且只有在其中存储某些内容时才需要支付费用。
不仅可以在存储中的任何位置写入,还可以立即从任何位置读取。从未初始化的位置读取只会返回 0x0
。
对于结构体来说,在结构体的实例中,字段按照结构体声明的顺序依次排布。同样的,未使用的结构体字段仅保留了位置而不需要支付费用,只有字段存储值的时候才需要支付费用。
如果声明一个固定长度的数组,由于编译器确切地知道有多少变量,因此可以简单地将数组元素一个一个地放在存储中,仅保留位置而不支付费用,就像存储变量和结构体所做的一样。
对于类似的代码,定长数组与结构体和状态变量有相同的存储布局(比如都是存储 6 个 uint 类型变量),但生成的汇编代码不同。原因是 Solidity 为数组访问生成边界检查。数组边界检查会使代码更安全,但同时也会干扰编译器优化,从而使固定长度数组的效率远低于存储变量或结构体。
存储很昂贵,因此一项关键优化是将尽可能多的数据打包到一个 32 字节的存储槽中。比如在构造函数中有 4 个连续的 64 位整数初始化,那么可以直接打包为一个 sstore
命令。但如果分别使用两个函数调用(每个函数调用完成 2 个变量的初始化)来完成构造函数的话,优化就不再有效,此时会使用 2 个 sstore
。因为优化器不会跨标签进行优化。(此结论在 Solidity 0.4.13 版本时正确,在 0.8.17 版本中是否已经有更好的解决方案本人还不确定,您可以直接在 The Optimizer 部分查看)
总结来说,Solidity 会为存储变量一个一个地保留位置,放在存储中。如果可能的话,编译器会将数据紧密打包成 32 字节的块。对于存储变量和结构体字段,打包行为是可行的;但是对于定长数组,很可能会由于边界检查而破坏优化。在编写合约时,进行小型实验并检查程序集以了解编译器是否正确优化可能很有用。
3. 动态数据类型的表示
Solidity 提供的动态类型主要有三个:
- 映射:
mapping(bytes32 => uint256)
,mapping(address => string)
,等等
- 动态数组:
uint256[]
,address[]
,等等
- 字节数组,只有两种:
string
,bytes
动态数组和字节数组只是具有更高级特性的映射。
映射
可以将 EVM 存储视为一个键值数据库,每个键限制为存储 32 个字节。对于映射类型,实际的实现是先使用 keccak256
哈希函数得到 32 字节的哈希值,然后将值存储在这个 32 字节哈希值对应的位置。
对于只有一个映射的情况:
在计算 0xC0FEFE
键对应的哈希的时候,需要使用到 items
变量的位置信息,在这个合约中,items
的位置为 0x0
(第一个存储变量)。因此要获取值的地址,就分别将键和位置补齐到32字节,然后拼接起来并计算哈希值:
对于有两个映射的情况:
这里,itemsA
的位置是 0
,那么键 0xAAAA
对应的值 0xCCCC
的存储位置就是 keccak256(bytes32(0xAAAA) + bytes32(0)) = 8396...33f3
。itemsB
的位置是 1
,那么键 0xBBBB
对应的值 0xDDDD
的存储位置就是 keccak256(bytes32(0xBBBB) + bytes32(1)) = 34cb...d395
。(在线 Keccak256 计算)
如果使用的键是变量,就不会在编译的时候预先计算键的地址,而需要使用汇编代码进行哈希计算。mstore
指令在内存中写入 32 个字节(内存读写只需 3 gas)。通过分别将键和位置加载到相邻的内存块中来“连接”键和位置:
然后使用 keccak
指令对该内存区域中的数据进行哈希处理。keccak
指令的费用取决于哈希的数量:指令本身需要 30 gas,每处理 32 字节需要 6 gas,因此这里需要 30 + 6 * 2 = 42 gas。
对于以结构体为值的映射来说,结构体值的位置由上面的计算方式得到,值中各个字段依次排布,与结构体部分所描述的方式相同。即对于合约:
三个字段的地址分别是:
映射不会打包(Mappings don’t pack)。考虑到映射的设计方式,您为每项支付的最小存储量是 32 字节,即使只存储 1 个字节。如果一个值大于 32 字节,则以 32 字节为增量支付存储费用。
动态数组
对于大多数语言,数组比映射便宜。然而对于 Solidity,数组是更昂贵版本的映射。数组的项将在存储中按顺序排列。但对这些存储槽的每次访问实际上都是在数据库中进行键值查找。访问一个数组元素与访问映射元素没什么不同。
比如 uint256[]
类型,本质上与 mapping(uint256 => uint256)
相同,并添加了使其“类似数组”的特性:
length
表示有多少个项;
- 边界检查,读取和写入大于长度的索引时抛出错误(error);
- 比映射更复杂的存储打包行为;
- 缩小数组时自动清零未使用的存储槽;
- 对
bytes
和 string
进行特殊优化,使短数组(小于 31 字节)的存储效率更高。
对于合约:
chunks
数组的位置(position)在 0x0
。在存储中,0x0
位置存储的值是当前数组的长度。而实际存储数组数据的位置通过 keccak256(bytes32(position))
计算得出,在这里也就是从 290d...e563
到 290d...e565
(keccak256(bytes32(0)) = 290d...e563
)。
数组优于映射的一个优点是可以使用打包,比如 uint128[]
数组的两个项正好适合两个存储槽(还有一个位置为 0 的存储槽用于存储长度)。
字节数组
bytes
和 string
是分别针对字节(bytes)和字符(characters)进行优化的特殊数组类型。如果数组的长度小于 31 字节,则只使用一个存储槽来存储整个字节数组。比如合约是下面这样:
此时只占用一个存储槽:
如果数组大于 31 字节,则字节数组类似于 []byte
。此时存储槽 0x0
不再存储数据,而是存储编码的数组长度(encodedLength
),要获得实际长度需要计算:length = (encodedLength - 1) / 2
。实际字节存储在 0x290d...e563
的位置,并且依次存储在后面的插槽中。
数组有成员变量 length
,返回数组的长度(定长数组固定,动态数组变化),该成员变量是只读函数,无法主动更改。动态存储数组和 bytes
(不包括 string
)有成员函数:push()
、push(x)
和 pop()
。
字节数组的汇编代码很大。除了正常的边界检查和数组大小调整之外,还需要对长度进行编码/解码,并注意在长字节数组和短字节数组之间进行转换。长数组的编码长度总是奇数,短数组则是偶数。汇编代码只需要查看变量位置处的最后一位,零表示偶数(短),一表示奇数(长)。
4. 智能合约外部方法调用
对于合约的外部调用交易,智能合约将交易的数据 data
字段解释为对合约的函数调用。data
字段的前 4 个字节始终是方法选择器(method selector),其余的数据是 32 字节为单位的方法参数。如果没有参数那么 data
字段就只有 4 个字节。如果参数没有 32 字节长就用 0 补齐。方法选择器是方法签名的 keccak256
哈希。对于一个简单合约:
函数调用 setAB(1, 2)
的 data
字段为 0x016aa20b00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002
,分别是 setAB(uint256,uint256)
的函数选择器,以及两个 32 字节的参数。
智能合约可以通过结构化方式处理输入数据来模拟方法调用。合约应用程序二进制接口(ABI)指定了一个通用的编码方案。
现在看看编译后的合约如何处理原始输入数据以进行方法调用。
此处我们只看比较重要的两部分:
- 匹配选择器并跳转到方法。
- 加载参数,执行方法,并从方法返回。
使用伪代码表示汇编代码逻辑:
Solidity 编译器如何为具有多种方法的合约生成汇编代码?使用更多的 if-else
分支就可以了。
ABI 编码规范 详细说明了如何对更复杂类型的参数进行编码,但阅读起来可能会非常痛苦。学习 ABI 编码的另一个策略是使用 pyethereum 的 ABI 编码函数 来研究不同类型的数据是如何编码的。
首先导入 encode_abi
函数:
对于有不同类型参数的方法,查看其对应的编码参数:
对于动态数组,ABI 引入了一个间接层(layer of indirection)来编码动态数组,遵循称为 头尾编码(head-tail encoding) 的方案。思想是动态数组的元素被打包在交易 calldata 的尾部。参数(“head”)是对数组元素所在的 calldata 的引用,指向尾部包含的三个动态数组的实际数据。示例如下:
可以混合使用动态和静态参数,两种参数会分别按照上述的两种方法进行编码。例如:
字符串和字节数组也是头尾编码的。唯一的区别是字节被紧密地打包成 32 个字节的块。对于每个字符串/字节数组,前 32 个字节编码了长度,紧跟着是字节。如果字符串大于 32 字节,则使用多个 32 字节块。
嵌套数组的每个嵌套都有一个间接寻址。
交易中的每个零字节数据或代码需要支付 4 gas,而每个非零字节数据或代码需要支付 68,因此使用零填充并不像看起来那么糟。同时应该注意到,对于有符号整数的负值来说,填充该值会使用 1
,这会花费非常多的 gas。
5. 智能合约创建过程
将合约编译后的字节码放在交易的 data
字段, to
字段留空,设置合适的 gas limit 和 gas price 后,发送这样的交易就可以创建一个合约。没有特殊的 RPC 调用或交易类型来创建合约。 在处理此交易时,EVM 会将输入数据作为代码执行,然后合约就诞生了。
对于一个简单合约(此处仍使用 0.4.11 版本编译器的编译结果进行分析,与 0.8.17 版本的编译结果稍有差异):
0.4.11 版本编译器编译后的字节码是:
字节码可分为三个部分:
- 部署代码在创建合约时运行。
- 合约代码在合约创建后其方法被调用时运行。
- (可选)辅助数据是源代码的加密指纹,用于验证。只是数据,从未由 EVM 执行。
部署代码负责:1)运行构造函数,并设置初始存储变量,2)计算合约代码,并将其返回给 EVM。第 2 部分中,部署代码将字节码 60606040525b600080fd00
加载到内存中,然后将其作为合约代码返回。
在部署代码运行并返回合约代码之后会发生什么?详细过程可以查看 Go-Ethereum 的方法实现 evm.Create。总的来说,该方法会执行如下过程:
- 检查调用者是否有足够的余额进行转账;
- 从调用者的地址生成(derive)新合约的地址(使用调用者的地址和
nonce
);
- 使用生成的合约地址创建新的合约账户(更改“世界状态(word state)” StateDB);
- 将初始 Ether 捐赠(endowment)从调用者转移到新合约;
- 将输入数据设置为合约的部署代码,然后使用 EVM 执行;
- 检查错误。如果合约代码太大,则失败;否则收取用户 gas,并设置合约代码。
上面部署代码对应的汇编是:
跟踪执行上述部署汇编以返回合约代码:
dataSize(sub_0)
和 dataOffset(sub_0)
不是真正的指令。它们实际上是将常量放入堆栈的 PUSH 指令。codecopy
指令可以将交易的输入数据复制到内存。两个常量 0x1C
(28) 和 0x36
(54) 指定一个字节码子串作为合约代码返回。
部署代码汇编做的事情大致对应如下的 Python3 代码:
部署代码执行结束后,结果内存内容是:
此时内存中的内容对应于合约代码和辅助数据:
上面以 sub_0
为标签的汇编代码对应于字节码 60606040525b600080fd00
,正是合约代码本身。从区块链浏览器也可以看到部署的合约代码的内容:
除了返回合约代码之外,部署代码的另一个目的是运行构造函数进行设置。如果有构造函数参数,部署代码需要以某种方式从某个地方加载参数数据。Solidity 约定在交易的字节码末尾附加 ABI 编码的参数值,以传递构造函数的参数("data": hexencode(compiledByteCode + encodedParams)
)。 比如下面的构造函数带有参数的合约:
使用值 66
创建合约时,交易的 calldata
数据为:
字段数据中,最后是编码为 32 字节的数字。
为了处理构造函数中的参数,部署代码将 ABI 参数从 calldata
的末尾复制到内存中,然后从内存复制到堆栈中。
创建合约的合约
FooFactory
合约可以通过调用 makeNewFoo
创建新的 Foo
实例:
该合约的完整汇编在 This Gist 中。编译器输出的结构比较复杂,它是这样组织的:
FooFactoryContractCode
基本上是复制 tag_8
中 Foo
的字节码,然后跳转回 tag_7
以执行 create
指令。create
指令类似于发送交易的 RPC 调用,提供了一种在 EVM 内创建新合约的方法。有关 go-ethereum 源代码,请参见 opCreate。该指令调用 evm.Create
来创建一个合约。
辅助数据 auxdata
是一个哈希值,可以用它来获取有关已部署合约的元数据,其格式为:
总的来说,合约被创建的方式类似于自解压软件安装程序的工作方式。当安装程序运行时,会配置系统环境,然后通过读取其程序包将目标程序提取到系统中。这对应于 calldata
中的部署代码和合约代码。智能合约可以使用和交易相同的过程来创建其他智能合约。构造函数如果有参数的话会将用于部署合约的参数放在字节码的最后。data
的字节码分布像这样:
Other Parts
在这一系列文章中,我翻译了 Howard 的 Diving Into The Ethereum VM 系列文章。译文链接如下: