跳到主要内容

【译】 Solidity 内联汇编 - Part 1

· 阅读需 8 分钟

原文链接:https://mirror.xyz/0xB38709B8198d147cc9Ff9C133838a044d78B064B/PpA5KdQhrE_2Bf-USfKePROJ5tE-raL7_VGBR8HE39E

在上一部分,我们介绍了 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提供,具有类似的函数签名。

内联汇编

现在,让我们使用内联汇编在汇编中编写 Boxretrieve()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 用来存储新的变量是可以的,但任何更复杂的操作都需要你跟踪指向空闲内存的指针并管理它。在文档中阅读更多内容。

function retrieve() public view returns(uint256) {
assembly {
// 读取 slot 0 中的值
let v := sload(0)

// 将 v 存储在内存的 0x80 位置
mstore(0x80, v)

// 返回内存中 0x80 位置后的32字节的数据
return(0x80, 32)
}
}

store() 函数的主体也可以用类似的方法来写。

function store(uint256 newValue) public {
assembly {
// 在 slot 0 存储 newValue
sstore(0, newValue)

// 触发实现
mstore(0x80, newValue)
log1(0x80, 0x20, 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd)
}
}

使用 sstore 操作码来存储 newValue,在 _value状态变量被存储的同一个存储槽(slot 0)。因此,用新值覆盖了旧值。

为了触发带有值的事件,使用了 log1 操作码,它的前两个参数是内存中的偏移量和数据的大小。

因此首先用 mstorenewValue 移动到内存的 0x80 位置。然后将 0x80 作为偏移量 0x20(十进制的 32)作为大小传递给 log1操作码。现在,你可能想知道我们传递给 log1的第三个参数是什么。该值是事件签名的哈希值。

bytes32(keccak256("NewValue(uint256)"))

// 0xac3e966f295f2d5312f973dc6d42f30a6dc1c1f76ab8ee91cc8ca5dad1fa60fd

最终,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 合约

参考资料