加密日记【007】解构Solidity合约

这篇从Solidity合约出发,一步一步的解析EVM的工作原理。

https://blog.openzeppelin.com/deconstructing-a-solidity-contract-part-i-introduction-832efd2d7737/

基本介绍

智能合约demo

pragma solidity ^0.4.24;

contract BasicToken {
  
  uint256 totalSupply_;
  mapping(address => uint256) balances;
  
  constructor(uint256 _initialSupply) public {
    totalSupply_ = _initialSupply;
    balances[msg.sender] = _initialSupply;
  }

  function totalSupply() public view returns (uint256) {
    return totalSupply_;
  }

  function transfer(address _to, uint256 _value) public returns (bool) {
    require(_to != address(0));
    require(_value <= balances[msg.sender]);
    balances[msg.sender] = balances[msg.sender] - _value;
    balances[_to] = balances[_to] + _value;
    return true;
  }

  function balanceOf(address _owner) public view returns (uint256) {
    return balances[_owner];
  }
}

字节码

为了保持与案例一致,运行compiler是version:0.4.24+commit.e67f0147.Emscripten.clang

这编译返回的字节码,里面包含创建合约和运行时合约。

反汇编字节码

在remix里面部署合约,console会打印出来截图的内容,点击debug

post image

在remix的左边会弹出debugger页面

post image

左下角的就是反汇编字节码

608060405234
//0x60=>PUSH 
//0x80
//0x60=>PUSH
//0x40
//0x52=>MSTORE
//0x34=>CALLVALUE

PUSH、MSTORE、CALLVALUE等叫操作码

https://github.com/ethereum/pyethereum/blob/master/ethereum/opcodes.py 可以通过这个链接查看对应的操作码

创建和运行时

将冗长的反编译字节码进行拆分成小块便于理解,只需要关注JUMPJUMPIJUMPDESTRETURN, and STOP

  • JUMP跳转,对应的操作码事JUMPDEST,它标注的事对应的位置

  • JUMPI指条件跳转,下一行不能为0

  • RETURN指返回,并会从EVM的memory返回一部分数据

  • STOP指停止执行合约

在案例合约中,0-69行,截止到RUTURN STOP的地方,就是合约的creation code(创建合约),也就是构造函数执行的部分,这段代码负责设置已创建合约的初始状态,并返回其运行时代码的副本。正如我们所见,执行流程永远不会到达的其余 497 条指令(70 到 566 条)正是将成为已部署合约一部分的代码。

post image

也就是说,编译合约之后的字节码包含创建合约和运行时合约,当部署合约时,执行的是创建合约字节码,它会设置合约的初始状态,并返回运行时合约代码存放在EVM的ROM中。

creation code

//Free memory pointer
000 PUSH1 80
002 PUSH1 40
004 MSTORE
//PUSH1 只是将一个字节压入栈顶,MSTORE 从栈中取出最后两项并将其中一项存储在内存中:
//Yul-ish code 栈顶从左到右
mstore(0x40, 0x80)
        |      |
        |    What to store.
       Where to store (in memory)
//这基本上将数字 0x80(十进制 128)存储到内存中位置 0x40(十进制 64)
post image
post image
/Non-payable check 这一段是检查交易中发送的eth是否为0
005 CALLVALUE //获取创建交易的 wei 数量(交易中发送的以太币值),并将其推送到栈顶。
006 DUP1 //复制栈顶的元素(此处是 wei 数量)并将副本推送到栈顶
007 ISZERO //检查栈顶元素(wei 数量)是否为零。如果为零,将 1 压入栈顶;否则,将 0 压入栈顶
008 PUSH2 0010 //将两字节值(16 进制表示的 10,十进制表示的 16)压入栈顶
011 JUMPI//从栈顶弹出条件值(由 ISZERO 操作生成),然后弹出目标位置(16)。如果条件值非零(即 wei 数量为零),程序计数器将设置为目标位置(16),实现跳转。否则,程序将继续执行下一个指令
012 PUSH1 00 //将一个字节值(16 进制表示的 00,十进制表示的 0)压入栈顶
014 DUP1 //复制栈顶的元素(此处是 0)并将副本推送到栈顶
015 REVERT //停止执行并撤销所有状态更改。在这里,如果 wei 数量非零,程序将执行 REVERT,撤销合约的执行
//retrieve constructor parameters
016 JUMPDEST //一个标签,表示可跳转到的目标位置。在前面提到的代码片段中,当 wei 数量为零时,程序计数器会跳转到这个位置(十进制表示的 16)
017 POP //从栈顶移除一个元素。在这个例子中,它将移除之前 JUMPI 指令压入的目标位置值(16)
018 PUSH1 40 //将一个字节值(16 进制表示的 40,十进制表示的 64)压入栈顶。这通常用于表示 EVM 内存中的空闲内存指针的位置
020 MLOAD //从内存加载一个字(32 个字节)的数据。这里,它从内存地址 40 处加载空闲内存指针的值
021 PUSH1 20 //将一个字节值(16 进制表示的 20,十进制表示的 32)压入栈顶
023 DUP1 //复制栈顶的元素(此处是 20)并将副本推送到栈顶
024 PUSH2 0235 //将两字节值(16 进制表示的 0235,十进制表示的 565)压入栈顶。这可能是合约代码中的一个位置
027 DUP4 //复制栈中第四个元素(空闲内存指针的值)并将副本推送到栈顶
028 CODECOPY //从栈顶弹出复制长度值,从栈顶弹出源代码的位置值,从栈顶弹出目标内存位置值,从源代码位置开始,复制指定长度的字节到目标内存位置
029 DUP2 //复制栈中第二个元素(80)并将副本推送到栈顶
030 ADD //从栈顶弹出两个元素(空闲内存指针的值和 a0),将它们相加,并将结果推送到栈顶。这将更新空闲内存指针的值
031 PUSH1 40 //将一个字节值(16 进制表示的 40,十进制表示的 64)压入栈顶。这通常用于表示 EVM 内存中的空闲内存指针的位置
033 MSTORE //将栈顶的一个字(32 个字节)数据存储到内存中。这里,它将更新后的空闲内存指针的值存储到内存地址 40 处
034 MLOAD //从内存加载一个字(32 个字节)的数据。这里,它从内存地址 为栈顶显示的字节值

0x00 到 0x40 之间的内存中有什么。没有什么。它只是 Solidity 为计算哈希保留的一块内存,我们很快就会看到,这对于映射和其他类型的动态数据是必需的。

038 PUSH1 00 
040 DUP2 
041 DUP2 
042 SSTORE 
sstore(0x00, 0x2710) 
        |     |
        |    value.
       key
043 CALLER //获取当前函数调用的发起者(即交易发送者)的地址,并将其推送到栈顶。在 EVM 中,这通常用于检查权限或记录交易的发起者
044 DUP2 
045 MSTORE 
046 PUSH1 01
048 PUSH1 20 
050 MSTORE 
051 SWAP2 
052 SWAP1 
053 SWAP2 
054 SHA3 //从栈顶弹出两个元素,计算哈希值的memory位置和要哈希的字节数,进行Keccak-256哈希计算,将结果推送到栈顶
055 SSTORE //从栈顶弹出两个元素,将第二个元素作为键,第一个元素作为值,将其存储到智能合约的存储空间
sstore(hash..., 0x2710)
        |        |
        |        value.
       key.
056 PUSH2 01d1 
059 DUP1 
060 PUSH2 0046 
063 PUSH1 00 
065 CODECOPY //从栈顶弹出三个元素,将第三个元素视为代码的起始位置,第二个元素视为内存的起始位置,第一个元素视为复制字节的数量。将代码的一部分复制到内存中
066 PUSH1 00 
//实现下面的函数内容
totalSupply_ = _initialSupply;
balances[msg.sender] =  _initialSupply;

copycode

post image

第一个元素是内存的位置,第二个元素是字节码起始的位置,第三个是字节码复制的长度。

80 217 20,单位是字节,217转换1070位,20字节是64位

post image

runtime code

post image

点击totalSupply会出现10000,是我们刚刚部署时输入进去的数字,在console里面会出现一条新的记录,点击debug

每次EVM调用的时候相当于生成了一个新的实例对象,总是会在调用中做任何其他事情之前做的事情:在内存中保存一个位置以备后用。这个时候stack和memory是清空的,storage保留创建合约时存储的内容

//Free memory pointer
000 PUSH1 80
002 PUSH1 40
004 MSTORE
005 PUSH1 04
007 CALLDATASIZE //calldata的大小为4个字节
008 LT //从栈顶弹出两个元素,并比较它们。如果第一个元素小于第二个元素,则推送1到栈顶,否则推送0。在这个例子中,它将比较调用数据的大小与4
009 PUSH2 0056 
012 JUMPI //从栈顶弹出目标位置值和条件值。如果条件值为非零,则将程序计数器设置为目标位置值(86行),此处如果calldatasize小于4就会跳转
013 PUSH4 ffffffff 
018 PUSH29 0100000000000000000000000000000000000000000000000000000000
048 PUSH1 00 
050 CALLDATALOAD
051 DIV //从栈顶弹出两个元素,将它们相除,并将结果推送到栈顶,这里剔除了参数值,只留下函数签名
052 AND //确保签名哈希恰好是八个字节长
053 PUSH4 18160ddd - LINE 3
058 DUP2 
059 EQ //从栈顶弹出两个元素,并比较它们。如果它们相等,则推送1到栈顶,否则推送0
060 PUSH2 005b 
063 JUMPI 
064 DUP1 
065 PUSH4 
070 EQ 

function Selector

calldata是一个编码的十六进制数字块组成,其中包含有关我们要调用的合约函数及其参数或数据的信息。它由一个“函数 ID”组成,通过散列函数签名(截断为前四个字节)后跟打包参数数据生成的。

totalSupply()的calldata是0x18160ddd,当调用CALLDATASIZE 时,它只是将第二个 4 压入堆栈。

函数选择器在合约中充当集线器或各种开关。它位于合约的入口点,并将执行重定向到调用者想要运行的合约的匹配函数。如果调用 totalSupply 函数,执行将重定向到位置 91,balanceOf 到 130,等等。

完整字节码结构在https://gists.rawgit.com/ajsantander/23c032ec7a722890feed94d93dff574a/raw/a453b28077e9669d5b51f2dc6d93b539a76834b8/BasicToken.svg

post image

Function Wrappers

totalSupply从91行开始,

092 CALLVALUE //eth的数量
093 DUP1 
094 ISZERO 
095 PUSH2 0067 //十进制103
098 JUMPI //因为这里是非支付,所以callvalue为0,会发生跳转
099 PUSH1 00 
101 DUP1 
102 REVERT 
103 JUMPDEST //直接到这里
104 POP 
105 PUSH2 0070 
108 PUSH2 00f5 //十进制245
111 JUMP //跳转到245,下次返回到112

245 JUMPDEST 
246 PUSH1 00 
248 SLOAD // totalSupply的值从storage load回来
249 SWAP1 //将之前留存的0x70(112)放在栈顶
250 JUMP // 返回112 
post image

由于 totalSupply 和 balanceOf 都返回一个 uint256 值,因此从堆栈中获取一个 uint256 值并通过内存返回一个 uint256 的代码块是相同的,并且可以重复使用。 Solidity 编译器可能会注意到为这两个包装器生成的部分代码是相同的,并决定重用代码以节省 gas。好吧,它实际上就是这样做的,如果我们在编译合约时没有启用优化,我们就不会观察到这一点。我们将这个被重用的结构称为“包装器的 uint256 内存返回器”。

144行指令push了112的位置,在144到175之间

168 PUSH1 04 // push字节 0x04
170 CALLDATALOAD // load calldata中第4个字节之后的数据

函数包装器的工作不仅是重定向到函数体,并为用户打包从函数体返回的任何内容,而且还为函数体打包来自用户的内容以供使用

Function Bodies

在解包传入的调用数据之后,函数体正是函数包装器绕道而行的地方。当一个函数体被执行时,函数的参数应该舒适地坐在堆栈中(如果数据是动态的,则在内存中),急于使用。让我们看看 balanceOf(address) 函数的实际效果。此函数应接收一个地址并返回该地址对应的 uint256 余额

251 JUMPDEST 
252 PUSH20 ffffffffffffffffffffffffffffffffffffffff 
273 AND 
274 PUSH1 00 
276 SWAP1 
277 DUP2 
278 MSTORE 
279 PUSH1 01 
281 PUSH1 20 
283 MSTORE 
284 PUSH1 40 
286 SWAP1 
287 SHA3 //计算哈希值的memory位置和要哈希的字节数,得到的数将是SLOAD的slot位置
288 SLOAD //从合约存储中加载一个字(32个字节)的数据。它从栈顶弹出一个槽位(slot)地址,然后从该槽位加载数据并将其推送到栈顶。在这个例子中,它从SHA3计算得到的槽位地址加载数据
289 SWAP1 
290 JUMP

SHA3

post image

Stack栈顶位置的数据是可以立即被使用的,所以当数据在memory或者storage,或者不是在栈顶要通过一系列转换使它转移到栈顶而被使用。

Metadata Hash

421 STOP
422 LOG1 //LOG0 到 LOG4 操作码用于在以太坊区块链中记录事件
423 PUSH6 627a7a723058
430 SHA3
431 INVALID
432 INVALID
433 SWAP10
434 DELEGATECALL
435 GASLIMIT
436 SWAP7
437 TIMESTAMP
438 DUP8
439 INVALID
440 INVALID
441 INVALID
442 SWAP4
443 LOG4
444 SWAP1
445 JUMPI
446 INVALID
447 CALLVALUE
448 INVALID
449 BLOCKHASH
450 INVALID
451 SWAP2
452 INVALID
453 INVALID
454 INVALID
455 INVALID
456 CALLCODE
457 SWAP13
458 DUP9
459 INVALID
460 INVALID
461 RETURN
462 INVALID
463 STOP
464 INVALID

代码可能会跳过 STOP 操作码。但是,它后面没有 JUMPDEST 指令,因此排除了任何执行通过 JUMP 到达字节码的这一部分的可能性。前面构造函数的参数附加到字节码的末尾。该代码也不应该由 EVM 执行;它只是作为一种 硬编码来存储合约的初始化值以供在构造函数中使用。

Metadata Hash是与编译后的智能合约相关的一段信息,它提供了关于合约源代码、编译器版本、编译选项等的详细信息。元数据哈希通过对合约元数据进行Keccak-256哈希运算得到。

solidity使用一种称为 CBOR 编码的编码类型,不仅存储哈希值,还存储所使用的具体去中心化存储系统和版本。在这种情况下,它使用 Swarm 的零版本 bzz:// URL 方案,这就是结构包含字符“b”、“z”、“z”、“r”、“0”的原因。或者,它可以使用诸如“i”、“p”、“f”、“s”、“r”、“0”之类的东西,表示该结构对 IPFS URL 方案进行编码。这使得它与使用哪个存储系统无关。它可以在未来改变,或者我们甚至可以选择我们希望字节码在编译时引用哪个存储系统。

字节码结构

post image