Solidity
是迄今为止 Ethereum
区块链上最常用的智能合约语言。它是一种高级语言,抽象了各种底层的琐碎细节(如若干个重要的安全检查)。
但有时你可能想深入到 EVM 的细粒度控制,不管是为了节省 gas,还是为了编写优化库等,幸运的是你可以在你的合约中使用汇编语言(或者整个合约代码都是汇编编写的)。
使用的汇编语言被称为 Yul ,是你访问 EVM 细粒度控制的的入口。需要注意的是,使用汇编绕过了Solidity
的许多重要检查,你应该只在需要的时候使用,并且了解你所编写的汇编做了什么。
在继续之前,假设你已经知道 Ethereum
和 Solidity
语言的基础知识。
我们将从下面的经典 Box
合约开始,逐步将这个合约转换为纯汇编。除了学习 Solidity
汇编,你还将学习操作代码如何工作,当你部署一个合约或进行外部函数调用时,引擎下会发生什么,以及数据存储的不同位置和时间。
在这一部分,我们将简要地关注 Solidity
在执行过程中如何在不同的地方处理和存储变量。这将是理解 Solidity
汇编如何工作的关键。我们将对 4 个数据位置进行介绍:存储、内存、调用栈和 calldata
。
存储
存储是状态变量被存储和永久保存的地方。这种存储的结构可以看作是简单的键-值对。键的长度为 32 字节或 256 比特,而且最多有个不同的键值对
Slot# (key) Value
-----------------------
0 -----> 123
1 -----> 456
.
.
这里的 key
也可以被认为是槽号--范围从 0 到。因此,知道槽号,你就可以读取存储在该槽中的数据。
内存
内存是 Solidity
代码中函数执行过程中临时变量的存储地。内存通常被用来存储复杂的类型,如数组。你一定在 Solidity
代码中定义数组时遇到过内存关键字,像下面这样:
function f() public {
uint256[] memory arr = new uint256[](10);
// ..
}
内存可以被可视化为一个字节数组,数据可以以 32 字节或 1 字节为单位写入,并以 32 字节为单位读取。
-----------------------------
1B | 32B | 32B | ..
-----------------------------
调用栈
像内存一样,调用栈也是一个用于存储一些数据的地方。在编写 solidity
的时候,你可以操作 Storage
和Memory
,调用栈则被自动利用。它不能用普通的 solidity
代码来访问,而是通过汇编。调用栈是用来存储立即使用的值,不需要为以后使用而存储。
那么,它与内存有什么不同呢?Solidity
代码实际上会被编译成了一个操作码列表。你可以把这些操作码看作是底层函数,它可以接受输入并给出输出。这些操作码接收参数的方式是从调用栈中弹出与它所接受的参数数量相同的值。而且,如果有输出,则会把返回的值推到调用栈。
例如,当你写一个状态变量时,SSTORE
操作码被执行。现在,SSTORE
需要两个输入,要写入的槽号和要写入的值。所以,SSTORE
所做的是它从调用栈中弹出两个值,并将它们作为输入。PUSH1
操作码可以用来先把这些输入推到堆栈。
opcode | call stack
-----------------------------
PUSH1 0x05 | 0x5
|
-----------------------------
PUSH1 0x00 | 0
| 0x5
-----------------------------
SSTORE | -
上面的例子显示了在执行了相应的操作码后调用栈的状态。SSTORE
将值 0x05
(十进制的 5)存储到存储槽的槽0x00
(十进制的 0),通过从调用栈中弹出两个值并将它们作为输入。
这就是为什么 EVM
有时被称为基于栈的虚拟机的原因。
你可能会问,为什么不使用内存去代替调用栈?嗯,因为对于原始或简单类型来说,使用栈比内存效率更高、成本更低。实际上 EVM
并不是第一个这样做的--许多静态类型语言,如 Rust
也使用栈。
calldata
Calldata
类似于内存,但它是一个用于存储函数参数的特殊的数据位置,只对外部函数调用可用。根据文档:
Calldata 是一个不可修改的、非持久的区域,用于存储函数参数,其行为主要类似于内存。
Calldata
可能看起来像这样:
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
这里的前 4 个字节(6057361d
)是函数 selector
(函数的标识符),其余是传递给函数的输入参数。
EVM
决定执行哪个函数的方式是通过匹配合约中的函数 selector
。我们之后在编写纯汇编合约时将手动实现这一点。
所以,你可以认为一个外部函数调用只不过是一个长的十六进制数据 calldata
被发送到一个特定的合同地址。
Solidity 合约
现在有了所有需要的基本知识来更好地理解 Solidity
汇编。让我们选择经典的 Box
合约作为我们的例子。
pragma solidity ^0.8.7;
contract Box {
uint256 private _value;
event NewValue(uint256 newValue);
function store(uint256 newValue) public {
_value = newValue;
emit NewValue(newValue);
}
function retrieve() public view returns (uint256) {
return _value;
}
}
Box
是一个简单的合约,它有一个状态变量 _value
,根据状态变量布局规则,它被存储在 Storage
的槽 0 中。此外,它有两个函数来获取/设置这个值。这里并不复杂。
在下一部分中,我们将部分地用汇编(即使用内联汇编)来编写下面这个简单的Box
合约