当前位置: 首页 > 工具软件 > diving > 使用案例 >

[译】Diving Into The Ethereum VM

窦国源
2023-12-01
Solidity提供了许多高级语言抽象,但是这些特性使我很难理解当我的程序运行时发生了什么。 阅读Solidity文档仍然让我对基本的东西感到困惑。

字符串,字节32,字节[],字节之间有什么区别?

  • 什么时候用?
  • 当我将字符串转换为字节时发生了什么? 我可以投到byte []吗?
  • 他们花多少钱?

EVM如何存储映射?

  • 为什么我不能删除映射?
  • 我可以有映射的映射吗? (是的,但这是如何工作的?)
  • 为什么有存储映射,但没有内存映射?

编译的合同如何看待EVM?

  • 合同是如何创建的?
  • 什么是构造函数,真的吗?
  • 什么是后备功能?

我认为了解像Solidity这样的高级语言如何在以太坊VM(EVM)上运行是一项很好的投资。 由于几个原因。

  1. 坚定不是硬道理。 更好的EVM语言即将到来。 (可以吗?)
  2. EVM是一个数据库引擎。 要理解智能合约如何以任何EVM语言工作,您必须了解数据的组织,存储和操作方式。
  3. 知道如何成为贡献者。 以太坊工具链还很早。 很好地了解EVM将帮助您为自己和其他人制作出令人敬畏的工具。
  4. 智力挑战。 EVM为您在加密,数据结构和编程语言设计的交叉点上提供了一个很好的借口。

在一系列文章中,我想解构简单的Solidity合约,以便了解它如何用作EVM字节码。

我希望学习和写作的概述:

  • EVM字节码的基础知识。
  • 如何表示不同的类型(映射,数组)。
  • 新合同创建时发生了什么。
  • 当一个方法被调用时发生了什么。
  • ABI如何桥接不同的EVM语言。

我的最终目标是能够理解整个编译的Solidity合同。 首先阅读一些基本的EVM字节码!

EVM指令集表格将是一个有用的参考。

简单的合同

我们的第一份合同有一个构造函数和一个状态变量:

 // c1.sol 
 杂注扎实0.4.11;
合同C { 
  uint256 a;
函数C(){ 
  a = 1; 
  } 
  }

solc编译这个合同:

 $ solc --bin --asm c1.sol
 ======= c1.sol:C ======= 
  EVM组件: 
  / *“c1.sol”:26:94合约C {... * / 
  mstore(0x40,0x60) 
  / *“c1.sol”:59:92 function C(){... * / 
  jumpi(tag_1,iszero(callvalue)) 
 为0x0 
  DUP1 
 还原 
  TAG_1: 
  TAG_2: 
  / *“c1.sol”:84:85 1 * / 
 为0x1 
  / *“c1.sol”:80:81 a * / 
 为0x0 
  / *“c1.sol”:80:85 a = 1 * / 
  DUP2 
  swap1 
  sstore 
 流行的 
  / *“c1.sol”:59:92 function C(){... * / 
  tag_3: 
  / *“c1.sol”:26:94合约C {... * / 
  tag_4: 
 数据尺寸(sub_0) 
  DUP1 
  dataOffset(sub_0) 
 为0x0 
  codecopy 
 为0x0 
 返回 
 停止
 sub_0:程序集{ 
  / *“c1.sol”:26:94合约C {... * / 
  mstore(0x40,0x60) 
  TAG_1: 
 为0x0 
  DUP1 
 还原
 auxdata:0xa165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029 
  }
二进制: 
  60606040523415600e57600080fd5b5b60016000819055505b5b60368060266000396000f30060606040525b600080fd00a165627a7a72305820af3193f6fd31031a0e0d2de1ad2c27352b1ce081b4f3c92b5650ca4dd542bb770029

数字6060604052...是EVM实际运行的字节码。

在婴儿步骤

一半的编译程序集样板文件在大多数Solidity程序中都是相似的。 我们稍后再看看。 现在,让我们来看看我们合约的独特部分,简单的存储变量赋值:

 a = 1

这个任务由字节码6001600081905550表示。 让我们把它分解成每行一条指令:

 60 01 
  60 00 
  81 
  90 
  55 
  50

EVM基本上是一个从上到下执行每条指令的循环。 让我们使用相应的字节码来标注汇编代码(在标签tag_2下缩进),以更好地了解它们的关联方式:

 TAG_2: 
  // 60 01 
 为0x1 
  // 60 00 
 为0x0 
  // 81 
  DUP2 
  // 90 
  swap1 
  // 55 
  sstore 
  // 50 
 流行的

请注意,汇编代码中的0x1实际上是push(0x1)的简写push(0x1) 该指令将数字1推入堆栈。

盯着它看看发生了什么仍然很难。 不要担心,很容易一行一行地模拟EVM。

模拟EVM

EVM是一个堆栈机器。 指令可能使用堆栈中的值作为参数,并将值作为结果推送到堆栈。 让我们考虑操作add

假设堆栈中有两个值:

 [1 2]

当EVM看到add ,它将前两项添加到一起,并将答案推回到堆栈上,导致:

 [3]

在下面的内容中,我们将用[]注释堆栈:

 //空的堆栈 
 堆栈:[] 
  //堆叠三个项目。 最上面的项目是3.最下面的项目是1。 
 堆叠:[3 2 1]

并用{}标记合约存储空间:

 //存储空间不存在 
 商店:{} 
  //值0x1存储在位置0x0。 
 存储:{0x0 => 0x1}

现在我们来看看一些真正的字节码。 我们将按照EVM模拟字节码序列6001600081905550 ,并在每条指令后打印机器状态:

 // 60 01:将1推入堆栈 
 为0x1 
 堆栈:[0x1]
 // 60 00:将0推入堆栈 
 为0x0 
 堆栈:[0x0 0x1]
 // 81:复制堆栈中的第二个项目 
  DUP2 
 堆栈:[0x1 0x0 0x1]
 // 90:交换前两个项目 
  swap1 
 堆栈:[0x0 0x1 0x1]
 // 55:将值0x1存储在位置0x0 
  //这条指令消耗前两项 
  sstore 
 堆栈:[0x1] 
 存储:{0x0 => 0x1}
 // 50:流行(丢掉顶级商品) 
 流行的 
 堆栈:[] 
 存储:{0x0 => 0x1}

结束。 堆栈是空的,并且存储中有一个项目。

值得注意的是, uint256 a决定将状态变量uint256 a存储在位置0x0 其他语言可以选择在别处存储状态变量。

在伪代码中,EVM为6001600081905550的实质上是:

 // a = 1 
  sstore(0x0,0x1)

仔细看,你会发现dup2,swap1,pop是多余的。 汇编代码可能更简单:

为0x1 
 为0x0 
  sstore

您可以尝试模拟上述3条指令,并确保它们确实导致相同的机器状态:

堆栈:[] 
 存储:{0x0 => 0x1}

两个存储变量

我们添加一个相同类型的额外存储变量:

 // c2.sol 
 杂注扎实0.4.11;
合同C { 
  uint256 a; 
  uint256 b;
函数C(){ 
  a = 1; 
  b = 2; 
  } 
  }

编译,重点关注tag_2

 $ solc --bin --asm c2.sol
 // ...更多的东西被省略
 TAG_2: 
  / *“c2.sol”:99:100 1 * / 
 为0x1 
  / *“c2.sol”:95:96 a * / 
 为0x0 
  / *“c2.sol”:95:100 a = 1 * / 
  DUP2 
  swap1 
  sstore 
 流行的 
  / *“c2.sol”:112:113 2 * / 
  0X2 
  / *“c2.sol”:108:109 b * / 
 为0x1 
  / *“c2.sol”:108:113 b = 2 * / 
  DUP2 
  swap1 
  sstore 
 流行的

伪代码中的程序集:

 // a = 1 
  sstore(0x0,0x1) 
  // b = 2 
  sstore(0x1,0x2)

我们在这里学到的是两个存储变量0x0定位,位置为0x0 ,位置为0x1

存储包装

每个插槽存储可以存储32个字节。 如果一个变量只需要16个字节,那么使用全部32个字节是浪费的。 如果可能,通过将两个较小的数据类型打包到一个存储插槽中,Solidity可优化存储效率。

让我们改变ab使它们每个只有16个字节:

杂注扎实0.4.11;
合同C { 
  uint128 a; 
  uint128 b;
函数C(){ 
  a = 1; 
  b = 2; 
  } 
  }

编制合同:

 $ solc --bin --asm c3.sol

生成的程序集现在更复杂:

 TAG_2: 
  // a = 1 
 为0x1 
 为0x0 
  DUP1 
 为0x100 
  EXP 
  DUP2 
  SLOAD 
  DUP2 
  0xffffffffffffffffffffffffffffffff 
  MUL 
 不 
 和 
  swap1 
  dup4 
  0xffffffffffffffffffffffffffffffff 
 和 
  MUL 
 要么 
  swap1 
  sstore 
 流行的
 // b = 2 
  0X2 
 为0x0 
 为0x10 
 为0x100 
  EXP 
  DUP2 
  SLOAD 
  DUP2 
  0xffffffffffffffffffffffffffffffff 
  MUL 
 不 
 和 
  swap1 
  dup4 
  0xffffffffffffffffffffffffffffffff 
 和 
  MUL 
 要么 
  swap1 
  sstore 
 流行的

上述汇编代码将这两个变量一起打包在一个存储位置( 0x0 )中,如下所示:

 [b] [a] 
  [16字节/ 128位] [16字节/ 128位]

打包的理由是因为目前最昂贵的操作是存储使用情况:

  • sstore花费20000瓦斯首先写入新的位置。
  • sstore花费5000瓦斯用于后续写入现有位置。
  • sload 500个天然气。
  • 大多数指令需要3〜10个气体。

通过使用相同的存储位置,Solidity为第二个存储变量支付5000而不是20000,为我们节省了15000个气体。

更多优化

应该可以将两个128位的数字一起打包在内存中,然后使用一个sstore存储它们,从而节省额外的5000个气体,而不是使用两个单独的sstore指令来存储ab

您可以通过打开optimize标志来让Solidity进行优化:

 $ solc --bin --asm --optimize c3.sol

它生成仅使用一个sload和一个sstore的汇编代码:

 TAG_2: 
  / *“c3.sol”:95:96 a * / 
 为0x0 
  / *“c3.sol”:95:100 a = 1 * / 
  DUP1 
  SLOAD 
  / *“c3.sol”:108:113 b = 2 * / 
  0x200000000000000000000000000000000 
  not(sub(exp(0x2,0x80),0x1)) 
  / *“c3.sol”:95:100 a = 1 * / 
  swap1 
  swap2 
 和 
  / *“c3.sol”:99:100 1 * / 
 为0x1 
  / *“c3.sol”:95:100 a = 1 * / 
 要么 
  sub(exp(0x2,0x80),0x1) 
  / *“c3.sol”:108:113 b = 2 * / 
 和 
 要么 
  swap1 
  sstore

字节码是:

 600080547002000000000000000000000000000000006001608060020a03199091166001176001608060020a0316179055

并将字节码格式化为每行一条指令:

 //按下0x0 
  60 00 
  // dup1 
  80 
  // sload 
  54 
  // push17将下一个17字节作为32字节的数字 
  70 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
 / * not(sub(exp(0x2,0x80),0x1))* / 
  //推送0x1 
  60 01 
  //按0x80(32) 
  60 80 
  //按0x80(2) 
  60 02 
  // exp 
  0A 
  //子 
  03 
  //不是 
  19
 // swap1 
  90 
  // swap2 
  91 
  //和 
  16 
  //推送0x1 
  60 01 
  // 要么 
  17
 / * sub(exp(0x2,0x80),0x1)* / 
  //推送0x1 
  60 01 
  //按下0x80 
  60 80 
  //推送0x02 
  60 02 
  // exp 
  0A 
  //子 
  03
 //和 
  16 
  // 要么 
  17 
  // swap1 
  90 
  // sstore 
  55

汇编代码中使用了四个魔术值:

  • 0x1(16字节),使用较低的16字节
 //在字节码中表示为0x01
 16:32 0x00000000000000000000000000000000 
  00:16 0x00000000000000000000000000000001
  • 0x2(16字节),使用更高的16字节
 //在字节码中表示为0x200000000000000000000000000000000
 16:32 0x00000000000000000000000000000002 
  00:16 0x00000000000000000000000000000000
  • not(sub(exp(0x2, 0x80), 0x1))
 //高16位字节的位掩码
 16:32 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 
  00:16 0x00000000000000000000000000000000
  • sub(exp(0x2, 0x80), 0x1)
 //低16位字节的位掩码
 16:32 0x00000000000000000000000000000000 
  00:16 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF

该代码执行一些比特 - 使用这些值进行混洗以达到期望的结果:

 16:32 0x00000000000000000000000000000002 
  00:16 0x00000000000000000000000000000001

最后,这个32字节的值被存储在位置0x0

燃气使用

60008054700 200000000000000000000000000000000 6001608060020a03199091166001176001608060020a0316179055

请注意, 0x200000000000000000000000000000000嵌入在字节码中。 但是编译器也可以选择用指令exp(0x2, 0x81)来计算值,这会导致较短的字节码序列。

但事实证明, 0x200000000000000000000000000000000exp(0x2, 0x81)便宜。 我们来看看所涉及的天然气费用:

  • 对于交易的每个零字节的数据或代码支付4种气体。
  • 对于交易的每个非零字节的数据或代码,有68个气体。

让我们来比较一下在天然气中的代表费用。

  • 字节码为0x200000000000000000000000000000000 它有很多零,价格便宜。

(1 * 68)+(16 * 4)= 196。

  • 字节码608160020a 更短,但没有零。

5 * 68 = 340。

更多零的更长序列实际上更便宜!

概要

EVM编译器不会针对字节码大小,速度或内存效率进行精确优化。 相反,它优化了天然气使用量,这是一个间接的层面,可以激励以太坊区块链可以有效地进行计算。

我们已经看到了EVM的一些古怪的方面:

  • EVM是一款256位机器。 以32字节的块操作数据是最自然的。
  • 持久存储非常昂贵。
  • Solidity编译器做出了有趣的选择,以最大限度地减少燃气使用。

天然气成本有些任意设定,未来可能会发生改变。 随着成本的变化,编译器会做出不同的选择。


在这篇关于EVM的文章系列中,我写到:



https://blog.qtum.org/diving-into-the-ethereum-vm-6e8d5d2f3c30

 类似资料: