在上一部分中,我们了解了一些关于 solidity编译器
的事实,并使用内联汇编来编写 Box
合约的函数。这一部分将用纯汇编编写 Box
合约。下面是上一部分中使用内联汇编的 Box
合约。
pragma solidity ^0.8.7;
contract Box {
uint256 public value;
function retrieve() public view returns(uint256) {
assembly {
// load value at slot 0 of storage
let v := sload(0)
// store into memory at 0
mstore(0x80, v)
// return value at 0 memory address of size 32 bytes
return(0x80, 32)
}
}
function store(uint256 newValue) public {
assembly {
// store value at slot 0 of storage
sstore(0, newValue)
// emit event
mstore(0x80, newValue)
log1(0x80, 0x20, 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}
}
前置知识
你已经通过内联汇编体验了汇编的魅力。接下来将使用之前函数主体类似的操作码来执行操作。注意,当 Box
合约用 Yul
编写时,它将不再是一个 Solidity
合约。solidity
编译器只识别带有assembly { }
块的内联汇编。所以,如果你在 Remix IDE 上编写,一定要选择 Yul
编译器。
在进入汇编之前,先解释一下合约到底是如何被 EVM
初始化的。我们的 Box
合约没有构造函数,但这并不意味着不会有任何初始设置的执行。合约编译后字节码有两部分:
初始化字节码
这一部分字节码包含设置任何初始状态(合约构造器逻辑)的指令。同时初始化字节码还要在合约部署期间将运行时字节码的复制到EVM
。在 EVM
收到运行时字节码后,它将其保存到链上并与一个地址相关联。因此,最终部署在链上的字节码只有运行时字节码。初始化字节码在合约的部署过程中只执行一次,并不存储在链上。
例如,下面的字节码
600a600c600039600a6000f3602a60505260206050f3
前面的 600a600c600039600a6000f3
是初始化字节码,其余是运行时字节码
由于我们将用纯汇编编写,任何初始化操作现在都需要手动执行。Box
合约没有任何构造函数,所以没有初始状态变量将被设置。唯一要做的是复制运行时代码并将其返回给 EVM。剩下的部分将由 EVM
本身自动处理。
运行时字节码
运行时字节码包括合约中的任何东西(方法、事件、常量等),但除了构造函数代码。而且其由合约被交互时的所包含执行逻辑组成。
当合约被调用方法交互时,一个经过编码的 calldata
被发送到该合约中。合约提取这个 calldata
的前 4 个字节,也就是函数selector
,然后运行一系列的 if/else
语句来决定哪个函数与之匹配并执行
例如,下面的 calldata
。
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
其中 6057361d
是函数选择器--它将匹配 Box
的 store()
函数,并在匹配后执行。
Yul 基本结构
Yul
代码由 "块 "和 "子块 "组成,由 object
关键字定义。使用 code
和 data
节点进行分组。data
节点是一些静态数据。而code
节点是对象的可执行代码。在code
节点内,你可以用循环、if-else
语句、操作码等编写逻辑。
object "ExampleObject" {
code {
// some logic code
}
data "ExampleData" hex"123"
object "ExampleSubObject" {
code {
// some other logic code
}
}
}
查看示例
纯汇编实现 Box 合约
合约由一个带有子对象的单一对象组成,子对象代表要部署的(运行时)代码。code
结点是一个对象的可执行代码。例如,我们定义/构造 Box 合同如下。
object "Box" {
code {
// initialization code
}
object "runtime" {
code {
// runtime code within sub-object
}
}
}
先关注初始化部分,然后再转到运行时部分。
初始化代码
如前所述,初始化代码必须返回运行时代码。为此,我们需要确定运行时代码在合约字节码中的偏移量以及它的大小。然后把这个字节码复制到内存中,这样就可以返回了。
Yul
语言实际上提供了三个具体的函数来做这件事:
datasize(x)
返回运行时部分的大小dataoffset(x)
返回运行时部分的偏移量datacopy(t, f, l)
与EVM
的codecopy
相似。从代码的偏移量拷贝一定长度的数据。
最后用用 return
操作码返回
object "Box" {
code {
let runtime_size := datasize("runtime")
let runtime_offset := dataoffset("runtime")
datacopy(0, runtime_offset, runtime_size)
return(0, runtime_size)
}
object "runtime" {
code {
// runtime code within sub-object
}
}
}
运行时代码
使用 Yul
,我们可以像以前使用内联汇编那样编写函数。困难的是确定在收到 calldata
后执行哪个函数。记得之前讨论过,这是通过提取函数 selector
, 也就是 calldata
的前四个字节,并与合约中定义的函数相匹配来完成。我们如何做到这一点呢?让我们来看看!
在解决如何进行函数 selector
的事情之前,首先需要定义 retrieve
和 store
函数。
object "Box" {
code {
// ..
}
object "runtime" {
code {
function retrieve() -> memloc {
let val := sload(0)
memloc := 0x80
mstore(memloc, val)
}
function store(val) {
sstore(0, val)
mstore(0x80, val)
log1(0x80, 0x20,0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}
}
}
store()
函数与之前的内联汇编基本相同。然而,retrieve()
不是预先返回值,而是将值存储在内存中,并返回值所存储的内存位置。原因在下文说明。
接下来处理从 calldata
中提取函数 selector
以匹配要执行的函数。
首先从 calldata
中计算出 selector
, 除了函数 selector
之外,calldata
还可能包括编码后的调用参数。calldata
使用 calldataload
操作码来提取,其需要一个输入:读取 calldata
的 偏移值,并返回从偏移值开始后固定的 32 个字节长度的数据。
例如,调用 store(10)
(10 = 0xa),calldata
看起来像下面这样
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
如果调用 calldataload(0)
会返回从位置 0 往后的 32 个字节长度的数据
6057361d00000000000000000000000000000000000000000000000000000000
那如何只提取前 4 个字节呢,可以用 div
操作码进行除法
div(
calldataload(0),
0x100000000000000000000000000000000000000000000000000000000
)
// gives first four bytes of calldataload(0) i.e. 6057361d
为这么这种方法会有效呢。用 10 进制数字来举例,如果 9876543
是 calldata
, 用除法运算取前 4 位数字,可以除以什么数呢,答案是 1000
9876543 / 1000 = 9876 // 只考虑整数部分
同样的,为了得到 32 个字节长度(64 个十六进制字符)的前 4 个字节的十六进制,我们用 0x1000000000000000000000000000000000000000000(56 个 0)除以它。
在此基础上,可以写一个辅助函数,从 calldata
中提取函数选择器。
object "Box" {
code { .. }
object "runtime" {
code {
function retrieve() -> memloc { .. }
function store(val) { .. }
function selector() -> s {
s := div(calldataload(0),
0x100000000000000000000000000000000000000000000000000000000)
}
}
}
}
现在剩下唯一的事情就是根据提取到的 selector
,去调用匹配的函数。
当合约被编译成字节码的时候,每个函数的 selector
也都被预先计算并附加到字节码中的。因此我们也需要预先计算 store()
和 retrieve()
的函数 selector
// retrieve function
bytes4(keccak256("retrieve()")); // 0x2e64cec1
// store function
bytes4(keccak256("store(uint256)")); // 0x6057361d
接下来就可以编写 switch
语句了
object "Box" {
code { .. }
object "runtime" {
code {
switch selector()
// 匹配的是 retrieve 函数
case 0x2e64cec1 {
let memloc := retrieve()
return(memloc, 32)
}
// 匹配的是 store 函数
case 0x6057361d {
store(calldataload(4))
}
// revert if no match
default {
revert(0, 0)
}
function retrieve() -> memloc { .. }
function store(val) { .. }
function selector() -> s {
s := div(calldataload(0),
0x100000000000000000000000000000000000000000000000000000000)
}
}
}
}
还记得我们从 retrieve()
函数中返回内存位置而不是值吗?是因为我们可以用 return
操作码来使用它
另一个需要注意的是传递给 store()
的参数,通过 calldataload(4)
读取,也就是跳过前 4 个字节,读取其后的 32 个字节数据。从前面调用 store(10)
的 calldata
例子来看:
0x6057361d000000000000000000000000000000000000000000000000000000000000000a
calldataload(4)
会返回
000000000000000000000000000000000000000000000000000000000000000a
这 16 进制中表示 10
最终,纯汇编编写的 Box
合约就是下面这样的
object "Box" {
code {
let runtime_size := datasize("runtime")
let runtime_offset := dataoffset("runtime")
datacopy(0, runtime_offset, runtime_size)
return(0, runtime_size)
}
object "runtime" {
code {
switch selector()
// retrieve function match
case 0x2e64cec1 {
let memloc := retrieve()
return(memloc, 32)
}
// store function match
case 0x6057361d {
store(calldataload(4))
}
// revert if no match
default {
revert(0, 0)
}
function retrieve() -> memloc {
let val := sload(0)
memloc := 0
mstore(memloc, val)
}
function store(val) {
sstore(0, val)
mstore(0, val)
log1(0x80, 0x20,0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
function selector() -> s {
s := div(calldataload(0),
0x100000000000000000000000000000000000000000000000000000000)
}
}
}
}