在上一部分,我们介绍了 EVM
的一些前提知识。在这一部分中,我们将用汇编编写 Box
合约的 retrieve()
和 store()
的函数体。这里是我们在 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;
}
}
你可以将 Solidity
语句与 assembly{ }
块交织在一起,并可以在该代码块中编写内联汇编。如前所述,用于EVM
的汇编语言是Yul。
前置知识
在开始之前,需要先解决一些可能困惑的问题。当你编译一个合同时,它被编译成字节码。字节码不过是一长串的字节,类似于
608060405234801561001....36f6c63430008070033
这代表什么?它只不过是一些微小的、1 字节的指令附加在一起的列表。这些微小的指令被称为操作码。EVM 在执行合约的任何操作时理解这些操作码。
例如,在上面的字节码中,第一条指令是60
(1 字节),这是操作码 PUSH1
的代码。你可以在 evm.code上查找所有的 EVM
操作码。
在编写汇编代码时,在Yul
中,你会注意到Yul
提供的函数,如add
, sload
, store
, mstore
等,与EVM
方言的操作码相似 - ADD
, SLOAD
, SSTORE
, MLOAD
。但是,EVM
方言与Yul
的不完全相同。尽管EVM
方言中的大多数操作码在 Yul
方言中是可用的,但不是全部。例如,PUSH1
操作码没有对应的Yul
函数。你可以在文档中查看所有这些Yul
函数。
尽管在写 Yul
的时候,你得到了细粒度的控制,Yul
仍然管理着一些更底层的东西,比如局部变量和控制流。所以,控制水平的顺序会是这样的。
Solidity < Yul (assembly) < bytecode (opcodes)
如果你想写比 Yul 更底层的代码,你甚至可以为 EVM
编写 bytecode
, 此时就不再需要编译器了。
注:从现在开始,我可以用 "操作码 "一词来指代上下文中与 EVM
操作码相对应的 Yul
函数,因为所有常见的EVM
操作码都由Yul
提供,具有类似的函数签名。
内联汇编
现在,让我们使用内联汇编在汇编中编写 Box
的 retrieve()
和 store()
函数体。我们只需使用 assembly{ }
块。这些块也可以访问外部的局部变量。
首先,查看 retrieve()
函数体。它所做的就是
- 从存储器中读取
_value
状态变量 - 返回读取的值
你想要执行这个操作码。evm.codes 是搜索 EVM
操作码的一个重要文档。现在介绍第一个操作码:sload
。
sload
sload
有一个输入
key
: 是要读取的存储槽编号
并将读取的值返回到调用栈。
读取 slot 0
中的值 (_value
的值存储在 slot 0
中)
assembly {
let v := sload(0)
}
现在如何返回读取的值呢?使用 return
操作码
return
return
有两个输入
offset
:在内存中的起始位置size
:从offset
开始的字节数
但是由 sload
返回的值是在调用栈中,而不是在内存中。所以我们需要先把它移到内存中。使用 mstore
操作码。
mstore
mstore
有两个输入
offset
:内存数组中应该存储值的位置value
:存储的字节数
assembly {
let v := sload(0) // 读取 slot 0 中的值
mstore(0x80, v) // 将 v 存储在内存的 0x80 位置
return(0x80, 32) // 返回内存中 0x80 位置后的32字节的数据
}
就这样,我们把 retrieve()
的主体转换为汇编了。
注:如果你好奇为什么特别选择内存中的 0x80
位置来存储数值,那是因为 Solidity
为特殊目的保留了四个 32 字节的插槽(即 0x00 到 0x7f)。所以可自由使用的内存最初从 0x80
开始。虽然在我们非常简单的情况下,只是把 0x80
用来存储新的变量是可以的,但任何更复杂的操作都需要你跟踪指向空闲内存的指针并管理它。在