面向开发者:Yul 与 Solidity 合约比较

在本文中,我将使用Remix IDE,并将提供一些带有完整源代码的要点。虽然我将解释本文中使用的每个操作码,但最好阅读文档并在手边准备一个操作码表。

1.jpg

概述

在本文中,我将使用Remix IDE,并将提供一些带有完整源代码的要点。虽然我将解释本文中使用的每个操作码,但最好阅读文档并在手边准备一个操作码表。

Yul

Yul是EVM操作码语言上的一个薄抽象层,可以在独立的Yul合约文件中使用,也可以通过一个assembly块在Solidity合约文件中使用。

function readWithYul() public view returns (uint256 data) {
   assembly {
        data := sload(0)

  }

}

Yul的赋值操作符是:=,而不是Solidity中的=。每个操作码都有一组唯一的参数,这些参数可以通过操作码表找到。

然而,Yul不是一个可执行的二进制文件,这意味着它仍然需要编译才能在EVM中运行。在写这篇文章的时候,Yul可以编译成EVM操作码语言和eWASM, eWASM是未来几个月提出的用于ETH 2.0的操作码语言。

字节>类型

理解EVM使用32字节是很重要的,而Solidity类型只是在这些之上的一个抽象。Yul只定义了u256类型,表示256位无符号整数或32字节的字。

函数选择器

一个函数可以通过它的选择器访问。当我们通过区块链客户端库(如web3.js或ether .js)调用合约上的函数时,在底层会发生一些事情。

首先,生成函数签名。这是一个字符串,包含函数名称,后跟参数类型,逗号分隔,括在括号中。

// function declaration
function myFunc(uint256 param1, address param2) {}

// function signature
string signature = "myFunc(uint256,address)"

接下来,使用keccak256对函数签名进行哈希处理,并将其剪切到最左边的4个字节,以生成函数选择器。

// selector = 0x67adc20f bytes4 selector = bytes4(keccak256("myFunc(uint256,address)"));

因此,如果我们要调用这个函数,将数字42作为第一个参数,并将地址0x5B38…c4(缩短)作为第二个参数,那么编码后的有效负载可能看起来像是这样:

0x67adc20f0000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4

如果我们把它拆开,我们会看到:

// hexadecimal prefix
0x

// selector for myFunc(uint256,address)
67adc20f

// number 42, padded to 32 bytes
0000000000000000000000000000000000000000000000000000000000000002a

// 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 padded to 32 bytes
0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4

这在以后访问不同的函数时非常重要。

调用数据、堆栈、内存和存储

接下来,我们应该理解调用数据、堆栈、内存和存储。

当调用一个函数时,如上所述,编码的有效负载就是调用数据。我们可以访问calldata大小,即calldatasize(),我们可以阅读32个字节调用数据,calldataload(offset)其中offset是要读取calldata 中的起始位置,我们可以将调用数据复制到内存里,calldatacopy(destOffset, offset, size)其中destOffset是要复制内存中的起始位置,offset是要复制调用数据中的起始位置,复制的调用size数据中的字节数。

该堆栈包含32个字节字,堆栈深度为1024,函数与任何其他堆栈一样。我们可以像在任何其他堆栈上一样,推送、弹出、交换和执行算术运算。Yul为我们处理了很多这方面的工作,因此通过push和pop操作码直接与堆栈交互超出了本文的讨论范围。

内存是线性的,可以暂时存储数据,但在消息调用之间会被清除。可以使用mstore8(offset, value),以 8 位为增量写入内存,其中offset是内存中的起始位置,value是要存储的 8 位值,或者以 256 位为增量,使用mstore(offset, value),其中offset是内存中的起始位置,value是要存储的 256 位值。想从内存中读取,可以通过内存中的mload(offset),其中offset是起始位置。从内存中读取总是会返回到从偏移量开始的256位。与内存交互的gas成本与存储在内存中的数据的大小成二次方关系。

存储在函数调用之间是持久的,也称为状态。存储由键/值对组成,其中键和值都是256位字。写入状态是通过sstore(key, value)完成的,其中key是存储的位置,value是存储在给定键位置的256位值。从存储中读取数据可以用sload(key)来完成,其中key是存储所需值的位置。从gas的角度来说,读取和写入存储是最昂贵的。更多的细节可以在下面的EVM操作码链接中找到。

链接

交互式操作码表:https://www.evm.codes/

Yul Doc:https://docs.soliditylang.org/en/v0.8.11/yul.html

合约

在本文的范围内,我们将编写一个合约,其中有一个状态变量、一个读取函数、一个写入函数、一个回退函数,以及在写入状态变量时发出的单个事件。

SolidityContract.sol

对于Solidity开发人员来说,这将是不言自明的,但是为了清晰起见,我们将简要地回顾一下代码。像往常一样,这里首先是完整的源代码,我们将在下面进行分解。

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
/// @title Simple read-write contract written in Solidity/// @author jtriley.eth/// @notice This is designed to be functionally identical to ./YulContract.sol/// This is for educational purposes only, do not use in production.contract SolidityContract {
    /// @notice emitted when _myNumber is set    /// @param setter Indexed address of the number setter    /// @param oldNumber Last number stored    /// @param newNumber New number stored    event MyNumberSet(        address indexed setter,        uint256 oldNumber,        uint256 newNumber);        /// @notice internal state variable    uint256 internal _myNumber;        /// @notice Reads _myNumber from state    /// @return _myNumber state variable    function read() public view returns (uint256) {        return _myNumber;    }
    /// @notice Sets _myNumber, emits MyNumberSet    /// @param newNumber New number to be set    function set(uint256 newNumber) public {        uint256 oldNumber = _myNumber;        _myNumber = newNumber;        emit MyNumberSet(msg.sender, oldNumber, newNumber);    }        /// @notice Fallback, always returns 42 params not used    /// @return The number 42 encoded in bytes    fallback(bytes calldata) external returns (bytes memory) {        return abi.encode(42);    }
}

首先,我们有一个事件定义,MyNumberSet。只有在状态变量_myNumber被更新时才会被触发。它将记录setter、旧号码和新号码。

接下来,我们有内部状态变量_myNumber,它是一个256位无符号整数。

接下来是read()函数,它将从状态读取myNumber并将其返回给调用者。

接下来,有一个set(uint256)函数,它接受一个无符号的256位整数作为参数,将其分配为新的_myNumber值,并发出MyNumberSet事件,记录所有相关信息。

最后,我们有一个回退函数。当对这个实际上并不存在的合约上调用一个函数时,会遇到回退函数。我们的回退函数像往常一样接受一个bytes calldata类型的参数,但是在这个例子中我们没有给它赋值一个名字,因为我们不打算使用它,我们只会返回一个bytes memory变量。在本例中,我们将在调用回退函数时返回数字42。

YulContract.sol

现在我不能简单地以这样的线性方式分解,因为有很多的辅助函数。尽管如此,下面是完整的源代码,并会逐行细分。

/// @title Simple read-write contract written in Yul/// @author jtriley.eth/// @notice This is designed to be functionally identical to ./SolidityContract.sol/// This is for educational purposes only, do not use in production.object "YulContract" {    code {        /// @dev Deploys the contract        datacopy(0, dataoffset("runtime"), datasize("runtime"))        return (0, datasize("runtime"))    }    object "runtime" {        code {            // ----- ----- -----            // VALUE CHECKER            // ----- ----- -----            /// @dev reverts if msg.value is greater than 0            if iszero(iszero(callvalue())) {                revert(0, 0)            }
            // ----- ----- -----            // SELECTOR SWITCH            // ----- ----- -----            /// @dev switch evaluating the function selector            switch extractSelector()                        // bytes4(keccak256("read()"))            case 0x57de26a4 {                returnUint(read())            }
            // bytes4(keccak256("set(uint256)"))            case 0x60fe47b1 {                set(extractUint(0))            }
            // fallback()            default {                returnUint(42)            }
            // ----- ----- -----            // EXTERNAL            // ----- ----- -----            /// @notice Reads myNumber from state slot 0            /// @return _myNumber The number read from storage            function read() -> _myNumber {                _myNumber := sload(myNumberSlot())            }
            /// @notice Writes new number to state            /// @dev emits MyNumberSet            /// @param newNumber to write to storage            function set(newNumber) {                let oldNumber := sload(myNumberSlot())                sstore(myNumberSlot(), newNumber)                emitMyNumberSet(caller(), oldNumber, newNumber)            }
            // ----- ----- -----            // CALLDATA DECODERS            // ----- ----- -----
            /// @dev extracts 4 byte selector from calldata            function extractSelector() -> _selector {                _selector := shr(0x28, calldataload(0))            }
            /// @dev extracts uint256 from calldata            /// offset = parameter index, starting at zero            function extractUint(offset) -> _value {                let position := add(4, mul(offset, 0x20))                if lt(calldatasize(), add(position, 0x20)) {                    revert(0, 0)                }                _value := calldataload(position)            }
            // ----- ----- -----            // CALLDATA ENCODER            // ----- ----- -----            /// @dev Stores parameter in memory, calls return on first            /// 32 bytes of memory            function returnUint(value) {                mstore(0,  value)                return(0, 0x20)            }
            // ----- ----- -----            // STORAGE LAYOUT            // ----- ----- -----            /// @dev Returns storage slot for myNumber            function myNumberSlot() -> _slot {                _slot := 0            }
            // ----- ----- -----            // EVENT EMITTER            // ----- ----- -----            /// @notice emits MyNumberSet            /// @dev Uses log2 with signatureHash and setter (indexed) as topics            /// @param setter Indexed address of number setter            /// @param oldNumber Not indexed old number            /// @param newNumber Not indexed new number            function emitMyNumberSet(setter, oldNumber, newNumber) {                // keccak256("MyNumberSet(address,uint256,uin256)");                let signatureHash := 0x29ad1af1374ae5de3c5b3f510eb08a7d10028e9dfe5aff6570c701c4177c76cb                mstore(0, oldNumber)                mstore(0x20, newNumber)                log2(0, 0x40, signatureHash, setter)            }        }    }
}

首先,让我们来看看最高级别的。我们有一个带有字符串文字的对象声明。

object "YulContract" {}

顶部对象声明中的字符串文字是我们的合约名。Yul中的对象可以包含代码块、数据和子对象。在YulContract对象内部,我们有一个代码块和一个名为runtime的子对象。

code {
    // ...
}object "runtime" {
    // ...
}

在runtime这种情况下,是我们要部署的合约代码,而code是部署我们的runtime. 在code块内部,我们首先会看到一个datacopy调用。

datacopy(0, dataoffset("runtime"), datasize("runtime"))

这些不是标准的EVM操作码,而是Yul的一部分(不要担心,这样的代码并不多)。datacopy(destOffset, offset, size)操作码将字节码拷贝到内存中,其中destOffset是要拷贝到内存中的起始位置,offset是要复制字节码的起始位置,size是要拷贝字节码的一边。在本例中,offset由dataoffset操作码设置,它采用与对象关联的字符串文字,datasize操作码也是如此。在本例中,对象是runtime,也就是合约逻辑所在的地方。

接下来,我们有了runtime对象,其中包含了丰富的合约逻辑。

值检查器

由于此合约中没有支付功能,因此如果调用者尝试将以太币发送到此合约,我们应该返还。我们可以通过访问msg.value来做到这一点。我们可能在Solidity中是熟悉的。这可以通过callvalue()操作码来访问。

if iszero(iszero(callvalue())) {
revert(0, 0)
}

注意我们有iszero(iszero(callvalue()))。

从中心开始,我们得到callvalue(),我们用iszero()检查它是否为零。

如果参数为0,则iszero(value) 操作码返回0x01,否则返回0x00。记住,布尔类型只是一个字节抽象,其中true == 1, false == 0。因此,我们可以使用iszero(iszero(value))来反转结果。这意味着以下内容在功能上是相同的。

// solidity
if (msg.value != 0) {}// yul
if iszero(iszero(callvalue())) {}

因此,如果调用值不是0,我们应该调用revert(offset, size),其中offset是期望返回数据在内存中的起始位置,size是期望返回数据在内存中的大小。因为我们不想返回任何数据,所以我们将offset和size都设置为0。

选择器开关

现在,进入真正酷的部分。当调用合约时,将提取4字节函数选择器,并根据合约上的所有现有函数选择器进行检查。Yul很方便地为我们提供了一个switch语句。

switch extractSelector()// bytes4(keccak256("read()"))
case 0x57de26a4 {
    // read logic
}// bytes4(keccak256("set(uint256)"))
case 0x60fe47b1 {
    // write and event logic
}default {
    // fallback logic
}

不要担心本节中的extractSelector(),这是一个获取选择器的自定义函数。现在,只需看看switch语句如何遍历我们定义的每个函数选择器,如果没有找到,则默认使用回退函数。每种情况下需要执行的任何逻辑都将在各自的块中。

这一点确实超出了本文的范围,但值得一提的是,UUPS Proxy模式依赖于回退函数将函数调用中继到底层逻辑合约。

接下来,我们将暂时浏览case块的内容来读取辅助函数。

调用数据解码器

我们定义的第一个调用数据解码器将是在switch语句中看到的extractSelector()函数。

function extractSelector() -> _selector {
_selector := shr(0x28, calldataload(0))
}

这个函数将返回一个名为_selector的值,因此我们使用-> _selector语法来表示。在函数块中,最内层的操作码是calldataload(offset),其中offset是从calldata加载下一个32字节的起始位置。们指定0是因为选择器始终是前 4 个字节。接下来是shr(shift, value),它将一个32字节的value按shift所指示的量进行移位。我们将calldata的前32个字节向右移动28个字节,丢弃最右边的28个字节,只留下4个字节的函数选择器。

// full calldata
0x60fe47b100000000000000000000000000000000000000000000000000000000000000001

// first 32 bytes of calldata
0x60fe47b100000000000000000000000000000000000000000000000000000000

// first 32 bytes of calldata shifted right 28 bytes
0x60fe47b1

第二个calldata编码器接受所需参数的索引或起始位置,并返回32字节值,表示为uint256或32字节值。在本例中,在set(uint256)中永远不会有一个以上的参数,但这个函数更模块化和可重用。

function extractUint(index) -> _value {
let position := add(4, mul(index, 0x20)) 

 if lt(calldatasize(), add(position, 0x20)) { 

     revert(0, 0)
}

_value := calldataload(position)

}

我们将返回一个_value,它表示来自calldata的4字节选择器后面的前32字节参数,我们用-> _value这样的声明函数。

我们使用let来声明一个新变量,它的位置将是我们想要提取的参数的起始位置。为了计算这个,让我们再次从最内层的语句开始,mul(a, b)乘以a和b,在本例中,这是参数和0x20的索引,或者用数字表示,32。结果加上4。为什么?因为前四个字节是函数选择器,所以后面的每个参数都包含32个字节。

selectorOffset = 0 // 0

firstParamOffset = 4 + (0 * 32) // 4

secondParamOffset = 4 + (1 * 32) // 36 ...

接下来是另一个if语句。在这种情况下,我们检查calldatasize()是否小于add(position, 0x20),如果不是,我们 revert到 (0, 0)。这个检查确保calldata的长度足够长,可以从参数的起始位置提取一个完整的32字节的字。

最后,我们使用calldataload(position)将接下来32个字节的calldata加载到内存中,从位置开始,也称为我们的newNumber参数。

返回值编码器

我们需要定义一个函数,该函数设计为read()函数和fallback()函数返回uint256给消息调用者。这是通过return(offset, size)操作码完成的,其中offset是所需返回数据在内存中的起始位置,size是所需返回数据在内存中的字节数。然而,在从内存读取数据之前,数据需要首先存储在内存中。

function returnUint(value) {
mstore(0, value) 

 return(0, 0x20)
}

由于我们返回的是一个256位的无符号整数,我们将它从内存中的位置0开始存储,它将占用32个字节。要从内存中返回数据,我们只需要指定起始位置0和结束位置32或0x20。

存储布局

在Solidity中,状态变量槽是按声明顺序排序的。考虑以下示例Solidity合约。

contract MyCon {
uint256 myNum; 

address myAddr;
}

myNum变量存储在slot 0,而myAddr存储在slot 1。有些优化是对不占用完整的256位字的值进行的,比如相邻的8位整数。

虽然我们在Yul中没有以这种直接的方式声明状态变量,但我们可以创建一组函数,为所需的变量返回适当的存储槽。在我们的合约中,我们有一个状态变量_myNumber,如果在Solidity合约中声明,它将存储在slot 0中。我们可以这样指定它。

function myNumberSlot() -> _slot {

_slot := 0

}

这似乎是多余的,为什么不直接在需要的地方硬编码槽号呢?这是因为编写一个纯函数来返回槽值更具可读性,且 gas 开销最小。

事件发起器

简单地说,在我们深入研究事件发起器之前,让我们考虑一些事情。Solidity事件可以有3个索引参数作为“主题”。虽然可以在事件日志中访问其他参数,但它们不是主题。考虑以下。

event MyEvent(
address indexed sender, 

address indexed receiver, 

uint256 indexed itemId, 

uint256 quantity
);

我们可能认为这个事件有3个主题,因为有3个已索引的事件参数,但实际上它有4个。这是因为第一个主题是事件签名的完整keccak256哈希,在这种情况下事件签名是MyEvent(address,address,uint256,uint256)。

这很重要,因为在我们的示例中,我们需要发出哈希的事件签名,address indexed setter参数,但是我们如何处理非索引的参数呢?

EVM指定了5个日志操作码:log0、log1、log2、log3和log4。中的每一个都以offsetandsize作为其前两个参数,指示内存中要记录的非索引返回数据的起始位置和大小。每个日志都有许多与其编号相对应的附加参数。

log0(offset, size)

log1(offset, size, topic1)

log2(offset, size, topic1, topic2)

log3(offset, size, topic1, topic2, topic3)

log4(offset, size, topic1, topic2, topic3, topic4)

对于我们的事件,我们需要使用log2,其中第一个topic是事件签名,第二个topic是索引的setter地址,内存偏移量和大小需要考虑uint256 oldNumber和uint256 newNumber,占64字节(或0x40字节)。

function emitMyNumberSet(setter, oldNumber, newNumber) {    
    // keccak256("MyNumberSet(address,uint256,uint256)")    
    let signatureHash := 0x29ad1af1374ae5de3c5b3f510eb08a7d10028e9dfe5aff6570c701c4177c76cb    
    mstore(0, oldNumber)    
    mstore(0x20, newNumber)    
    log2(0, 0x40, signatureHash, setter)
 }

请注意,在调用log2并传入内存参数之前,我们首先将oldNumber和newNumber分别存储在内存中的位置0和32。

外部

现在我们已经完成了所有的辅助程序,让我们来看看实际的函数实现。

我们定义了一个read()函数,它使用存储辅助函数从存储中加载_myNumber。

function read() -> _myNumber {
    _myNumber := sload(myNumberSlot())
}

sload(key)操作码接受一个键并返回存储在该键处的值,因为myNumberSlot()返回为0,所以我们是从0号槽位加载myNumber。

接下来,我们定义一个set(newNumber)函数来为_myNumber设置一个新值,然后发出一个带有setter地址、旧号码和新号码的MyNumberSet事件。

function set(newNumber) {    
    let oldNumber := sload(myNumberSlot())    
    sstore(myNumberSlot(), newNumber)    
    emitMyNumberSet(caller(), oldNumber, newNumber)
}

因此,我们首先加载旧的myNumber值,因为我们需要它作为事件日志。我们使用sload(myNumberSlot())来实现这一点,就像上面所做的那样。接下来,我们将新值与sstore(myNumberSlot(), newNumber)存储在同一个槽位。最后,我们使用上面定义的函数发出事件。我们使用caller()来获取消息调用者或msg.sender的Solidity术语。

选择开关 2

最后,我们将重新访问函数选择器开关中的case块。

第一个case表示我们的read函数,它应该返回myNumber状态变量给调用者。

case 0x57de26a4 {

returnUint(read())

}

虽然我们的read()函数从它返回到_myNumber,但我们需要使用我们的returnUint(value)函数将值实际返回给消息调用者。

下一种情况表示我们的set函数,我们需要提取calldata中编码的uint256 newNumber参数,并将它传递给我们的set(newNumber)函数。

case 0x60fe47b1 {

set(extractUint(0))

}

我们将0传递给我们的extractUint(index)函数,以指示newNumber参数应该是calldata中编码的第一个参数。

最后,我们有默认或回退函数。

default {
    returnUint(42)
}

所有我们需要做的是表明我们想返回一个uint256,在我们的实现中,将始终是42。

结论

用Yul写合约不是一件容易的事,简单的读/写合约也可能很复杂。希望这能对 Yul 有所启发,并促进对低级 Solidity 的更深入理解。

Source:https://medium.com/@jtriley15/yul-vs-solidity-contract-comparison-2b6d9e9dc833

关于

ChinaDeFi - ChinaDeFi.com 是一个研究驱动的DeFi创新组织,同时我们也是区块链开发团队。每天从全球超过500个优质信息源的近900篇内容中,寻找思考更具深度、梳理更为系统的内容,以最快的速度同步到中国市场提供决策辅助材料。

本文首发于:https://mp.weixin.qq.com/s/33cy2ZDzrIZI_xr_b_Tn2A

点赞 0
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
ChinaDeFi 去中心化金融社区
ChinaDeFi 去中心化金融社区
ChinaDeFi.com 是一个研究驱动的DeFi创新组织,同时我们也是区块链开发团队。每天从全球超过500个优质信息源的近900篇内容中,寻找思考更具深度、梳理更为系统的内容,以最快的速度同步到中国市场提供决策辅助材料。