avatar
转载2022/05/12

【译】Solidity 内联汇编 - Part 2

AI 总结

译文:系列终章,挑战用纯 Yul 汇编语言编写完整的 Box 合约,从函数分发器(Dispatcher)到具体函数体完整实现,深刻体会 EVM 底层的执行机制,对理解合约字节码、Gas 优化与安全漏洞分析具有重要价值。

在上一部分中,我们了解了一些关于 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)
            }
        }
    }
}

参考资料

点击播放