深入以太坊虚拟机 Part1 — 汇编与字节码
原文:Diving Into The Ethereum Virtual Machine | by Howard | Aug 6, 2017
Solidity 提供了许多高级语言抽象,但是这些特性使得人们很难理解程序运行时到底发生了什么。阅读 Solidity 文档仍然使我对非常基本的事情感到困惑。
string
,bytes32
,byte[]
,bytes
有什么区别?
- 我该使用哪一个?何时使用?
- 当我把
string
转换为 bytes
时会发生什么?能转换为 byte[]
吗?
- 它们费用是多少?
EVM 中如何存储映射(mapping)?
- 为什么不能删除一个映射?
- 可以使用映射的映射吗?(可以,但它是如何工作的?)
- 为什么有存储映射(storage mapping)但没有内存映射(memory mapping)?
编译后的合约对 EVM 来说是怎样的?
- 合约是如何创建的?
- 什么是
constructor
?真的吗?
- 什么是
fallback
函数?
我认为学习像 Solidity 这样的高级语言如何在以太坊 VM(EVM) 上运行是一项很好的投资。出于几个原因。
- Solidity 不是最后的语言。更好的 EVM 语言将会到来。
- EVM 是一个数据库引擎。要了解智能合约如何以任何一门 EVM 语言工作,必须理解数据是如何组织、存储和操纵的。
- 知道如何成为贡献者。以太坊工具链还很早。深入了解 EVM 将会帮助您为自己和他人制作出色的工具。
- 智力挑战。EVM 为在密码学、数据结构和编程语言设计的交叉点上发挥作用提供了一个很好的机会。
在一系列文章中,我想解构简单的 Solidity 合约,以了解它是如何作为 EVM 字节码工作的。
希望学习和写的内容的大纲:
- EVM 字节码的基础知识
- 如何表示不同的类型(映射(mapping)、数组(array))
- 创建新合约时发生了什么
- 方法被调用时发生了什么
- ABI 如何桥接不同的 EVM 语言
我的最终目标是能够完整地理解编译好的 Solidity 合约。让我们从阅读一些基本的 EVM 字节码开始吧!
这个 EVM 指令集 将是一个有用的参考。
A Simple Contract
我们的第一个合约有一个构造函数和一个状态变量:
(注:当前 Solidity 已使用 constructor
关键字声明构造函数)
用 solc
编译这个合约:
数字 6060604052...
是 EVM 实际运行的字节码。
In Baby Steps
编译后的汇编语言中一半是样板(boilerplate),在大多数 Solidity 程序中都是相似的。我们稍后会来回顾。现在,让我们检查一下合约的独特部分,即不起眼的存储变量赋值:
此赋值由字节码 6001600081905550
表示。让我们将其分解为每行一条指令:
EVM 基本上是一个循环,从上到下执行每条指令。让我们用相应的字节码注释汇编代码(在标签 tag_2
下缩进)以更好地了解它们是如何关联的:
请注意,汇编代码中的 0x1
实际上是 push(0x1)
的简写。该指令将数字 1 压入堆栈。
只是盯着它仍然很难理解发生了什么。不过不用担心,逐行模拟 EVM 很简单。
Simulating The EVM
EVM 是一个堆栈机器(stack machine)。指令可能使用堆栈上的值作为参数,并将值作为结果压入堆栈。让我们考虑 add
操作。
假设堆栈上有两个值:
当 EVM 看到 add
时,它将顶端的 2 个项加起来,并将结果 push 到堆栈顶端,结果就是:
在下文中,我们将使用 []
标记堆栈:
并使用 {}
标注合约存储:
现在让我们看一些真正的字节码。我们将像 EVM 一样模拟字节码序列 6001600081905550
并在每条指令之后打印机器状态:
结束。堆栈是空的,并且有一个项在存储中。
值得注意的是,Solidity 决定将状态变量 uint256 a
存储在位置 0x0
。其他语言完全有可能选择将状态变量存储在其他地方。
在伪代码中,EVM 对 6001600081905550
所做的基本上是:
仔细看,你会发现 dup2, swap1, pop 都是多余的。汇编代码可以更简单。
你可以尝试模拟上面 3 条指令,并确信它们确实会导致相同的机器状态:
Two Storage Variables
让我们添加一个相同类型的额外存储变量:
编译,重点关注 tag_2
:
伪代码形式的汇编:
我们在这里了解到的是,两个存储变量一个接一个地定位,a
位于 0x0
位置,b
位于 0x1
位置。
Storage Packing
每个槽存储(slot storage)可以存储 32 个字节。如果一个变量只需要 16 个字节,那么使用所有 32 个字节是很浪费的。如果可能,Solidity 通过将两种更小的数据类型打包(pack)到一个存储槽中来优化存储效率。
让我们改变 a
和 b
,使它们每个都只有 16 个字节:
编译合约:
生成的汇编更加复杂:
上面的汇编代码将两个变量打包到一个存储位置(0x0
),就像这样:
打包的原因是因为到目前为止最昂贵的操作是存储:
sstore
首次写入新位置需要 20000 gas
sstore
需要 5000 gas 用于后续写入已有位置
sload
花费 500 gas
- 大多数指令花费 3~10 gas
通过使用相同的存储位置,Solidity 为第二个存储变量支付 5000 而不是 20000,从而为我们节省了 15000 gas。
More Optimization
与其用两个单独的 sstore
指令存储 a
和 b
,不如将两个 128 位数字一起打包到内存中,然后只使用一个 sstore
存储它们,从而节省额外的 5000 gas。
您可以通过打开 optimize
标志让 Solidity 进行此优化:
生成的汇编代码只使用一个 sload
和一个 sstore
:
字节码是:
将字节码格式化为每行一条指令:
汇编代码中使用了四个魔法值(magic values):
not(sub(exp(0x2, 0x80), 0x1))
sub(exp(0x2, 0x80), 0x1)
该代码对这些值进行了一些位操作以达到所需的结果:
最后,这个 32 字节的值存储在位置 0x0
。
Gas Usage
注意,字节码中嵌入了 0x200000000000000000000000000000000
。但编译器也可以选择使用指令 exp(0x2, 0x81)
来计算值,这会产生更短的字节码序列。
但事实证明,0x200000000000000000000000000000000
比 exp(0x2, 0x81)
更便宜。让我们看一下所涉及的 gas 费用:
- 为交易的每个零字节数据或代码支付 4 gas
- 交易的每个非零字节数据或代码需要 68 gas
让我们比较一下两种表示在 gas 中的成本。
- 字节码
0x200000000000000000000000000000000
有很多 0,很便宜:(1 * 68) + ( 16 * 4) = 196
- 字节码
608160020a
更短但没有 0:5 * 68 = 340
具有更多零的较长序列实际上更便宜!
Summary
EVM 编译器并未针对字节码大小或速度或内存效率进行精确优化。相反,它优化了 gas 使用,这是一层间接(indirection)以激励以太坊区块链可以有效进行的计算。
我们已经看到了 EVM 的一些古怪方面:
- EVM 是一个 256 位的机器。以 32 字节为单位处理数据是最自然的做法。
- 持久性存储非常昂贵。
- Solidity 编译器做出了有趣的选择,以尽量减少 gas 使用。
Gas 成本的设定有些武断,未来很可能会发生变化。随着成本的变化,编译器会做出不同的选择。
Other Parts
在这一系列文章中,我翻译了 Howard 的 Diving Into The Ethereum VM 系列文章。译文链接如下: