在本文中,我将使用Remix IDE,并将提供一些带有完整源代码的要点。虽然我将解释本文中使用的每个操作码,但最好阅读文档并在手边准备一个操作码表。
在本文中,我将使用Remix IDE,并将提供一些带有完整源代码的要点。虽然我将解释本文中使用的每个操作码,但最好阅读文档并在手边准备一个操作码表。
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
在本文的范围内,我们将编写一个合约,其中有一个状态变量、一个读取函数、一个写入函数、一个回退函数,以及在写入状态变量时发出的单个事件。
对于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。
现在我不能简单地以这样的线性方式分解,因为有很多的辅助函数。尽管如此,下面是完整的源代码,并会逐行细分。
/// @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术语。
最后,我们将重新访问函数选择器开关中的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篇内容中,寻找思考更具深度、梳理更为系统的内容,以最快的速度同步到中国市场提供决策辅助材料。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!