avatar
转载2022/05/11

【译】Solidity 内联汇编 - Part 1

AI 总结

译文:延续第零篇的 EVM 基础,本文进入实战阶段,使用 Yul 汇编语言重写 Box 合约的 store() 和 retrieve() 函数,深入讲解 sstore 与 sload 操作码的用法、函数选择器的手动解析方式,以及如何在内联汇编中处理参数读取与返回值编码。

在上一部分,我们介绍了 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 用来存储新的变量是可以的,但任何更复杂的操作都需要你跟踪指向空闲内存的指针并管理它。在文档中阅读更多内容。

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 操作码,它的前两个参数是内存中的偏移量和数据的大小。

因此首先用 mstore 将 newValue 移动到内存的 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 合约

参考资料

点击播放