Yul内联汇编入门
Yul 入门
参考来源:https://learnblockchain.cn/article/6064
Yul概述
Yul 是一种中间编程语言,可以用来在智能合约中编写汇编语言。了解 Yul 可以提高智能合约的水平。了解Solidity
底层发生的事情,帮助节省 Gas 费用。可以使用assembly
代码块来在智能合约中标识 Yul:
assembly {
// do stuff
}
变量赋值、运算、评估
简单操作,Yul 中有:+ - * / % ** < > =
。在 Yul 中不存在>= 和 <=
。此外,评估不是等于真或假,而是分别等于 1 或 0。
操作说明 | 解释 |
---|---|
let | 这是在定义变量之前需要的,因为所有值都是字节,所以不需要分配值类型 |
:= | 等价于 Solidity 中的 x = y |
add(x,y) | 等价于 Solidity 中的 x + y |
sub(x,y) | 等价于 Solidity 中的 x - y |
mul(x,y) | 等价于 Solidity 中的 x * y |
div(x,y) | 等价于 Solidity 中的 x / y (或 0 如果 y 等于 0) |
mod(x,y) | 等价于 Solidity 中的 x % y (或 0 如果 y 等于0) |
lt(x,y) | 等价于 Solidity 中的 x < y |
gt(x,y) | 等价于 Solidity 中的 x > y |
eq(x,y) | 等价于 Solidity 中的 x == y |
iszero(x) | 等价于 Solidity 中的 x == 0 |
一个简单的例子:
function addOneAndTwo() external pure returns(uint256) {
uint256 ans;
assembly{
// Yul 中为变量赋值
let one := 1
let two := 2
// 加法
ans := add(one, two)
}
return ans;
}
For 循环和 if 语句
例子:计算一个系列中多少个数字是偶数:
function howManyEvens(uint256 startNum, uint256 endNum) external pure returns(uint256) {
uint256 ans;
// for 循环的语法
assembly {
for { let i := startNum } lt(i, add(endNum, 1) ) { i := add(i,1) }
{
// if i == 0,跳过此处迭代
if iszero(i) {
continue
}
// 检查是否 i % 2 == 0
// 可以使用 iszero,但是接下来展示 eq()
if eq( mod( i, 2), 0) {
ans := add(ans, 1)
}
}
}
return ans;
}
if
语句的语法和 Solidity 非常相似,但是,我们不需要用圆括号来包裹条件。对于 for
循环,注意我们在声明i
和增加i
时使用了括号,但在评估条件时没有使用括号。此外,我们使用了continue
来跳过循环的一次迭代。我们也可以在 Yul 中使用 break
语句。
存储
在深入了解 Yul 的工作原理之前,我们需要了解智能合约中的存储工作原理。存储是由一系列的槽组成的。一个智能合约有2²⁵⁶个槽位。在声明变量时,我们从槽0开始,然后从那里递增。每个槽的长度为256 比特(32字节),这就是uint256
和bytes32
的名字由来。所有的变量都会被转换为十六进制。如果一个变量,例如 uint128
使用时,不会用整个槽来存储该变量。相反,它的左边是用 0 填充的。
例子:
// slot 0
uint256 var1 = 256;
// slot 1
address var2 = 0x9ACc1d6Aa9b846083E8a497A661853aaE07F0F00;
// slot 2
bytes32 var3 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
// slot 3
uint128 var4 = 1;
uint128 var5 = 2;
var1
:由于uint256
变量等于32字节,var1
占据了整个0槽。下面是0号槽中存储的内容: 0x00000000000000000000000000000000000000000000000000000000000000000000000000000100
。
var2
:地址稍微复杂一些。由于它们只占用20个字节的存储空间,地址的左边被填充了0。下面是存储在槽1中的内容: 0x00000000000000009acc1d6aa9b846083e8a497a661853aae07f0f00
。
var3
:这个看起来很简单,槽位2被bytes32
变量的全部内容所消耗。
var4
& var5
:还记得我提到的uint128
被填充了0吗?那么,如果我们对变量进行排序,使它们的存储量之和小于32字节,我们就可以把它们一起放入一个槽中!这叫做变量打包!这就是所谓的变量打包,它可以为你节省Gas。让我们来看看3号槽中存储的内容: 0x0000000000000000000000000000000200000000000000000000000000000001
。 请注意,0x000000000000000000000000000002
和 0x000000000000000000000000000001
完全吻合同一个槽。这是因为它们都占用了16个字节(一半的槽)。
更多关于 Yul 的内容:
操作说明 | 解释 |
---|---|
sload(p) | 从 storage 中加载槽 p 中的变量 |
sstore(p,v) | 分配给 Storage 槽 p 一个 v 值 |
v.slot | 返回变量 v 的存储槽 |
v.offset | 返回变量 v 在存储槽中开始位置的序列(以字节为单位)。变量从右到左排列 |
例子:
function readAndWriteToStorage() external returns (uint256, uint256, uint256) {
uint256 x;
uint256 y;
uint256 z;
assembly {
// 获得 var5 的槽位置
let slot := var5.slot
// 获得 var5 的槽偏移
let offset := var5.offset
// 赋值给 Solidity 中的变量
x := slot
y := offset
// 在槽 0 上保存 1
sstore(0,1)
// 加载槽 0 的值赋值给 z
z := sload(0)
}
return (x, y, z);
}
x
= 3. 这是有道理的,因为我们知道 var5 被装入槽3。y
= 16. 这也是合理的,因为我们知道var4
占据了3号槽的一半。由于变量是从右到左打包的,我们得到字节16作为var5
的起始索引。z
= 1. sstore()
是将0号槽的值赋给1。然后,我们用 sload()
将0号槽的值分配给z。
下面这个函数可以帮助我们查看每个插槽正在存储的内容:
function getValInHex(uint256 y) external view returns (bytes32) {
bytes32 x;
assembly {
x := sload(y)
}
return x;
}
读取动态数组的值
接下来是一些更复杂的数据结构:
uint128[4] var6 = [0,1,2,3];
当使用静态数组时,EVM 知道要为我们的数据分配多少个槽位。特别是这个数组,我们在每个槽中打包2个元素。所以如果你调用 getValInHex(4)
,它将返回 0x0000000000000000000000000000000100000000000000000000000000000000
。正如我们所期望的,从右到左读,我们看到的是值0和值1。槽5包含0x0000000000000000000000000000000300000000000000000000000000000002
。
接下来我们要看一下动态数组。
// slot 6
uint256[] var7;
尝试调用getValInHex(6)
。你会看到它返回 0x00
。由于 EVM 不知道需要分配多少个存储槽,我们不能在这里存储数组。相反,当前存储槽(槽6)的keccak256 哈希值被用来作为数组的起始索引。从这里开始,我们需要做的就是添加所需元素的索引来检索值。
接下来是的例子,演示如何查找一个动态数组的第一个元素:
function getValFromDynamicArray(uint256 targetIndex) external view returns (uint256) {
uint256 slot;
assembly {
slot := var7.slot
}
bytes32 startIndex = keccak256(abi.encode(slot));
uint256 ans;
assembly {
ans := sload( add(startIndex, targetIndex) )
}
return ans;
}
这里我们检索数组的槽,然后执行add()
操作和sload()
来获得我们想要的数组元素的值。
你可能会问,如何防止我们与另一个变量的槽发生碰撞?这是完全可能的,但是,由于2²⁵⁶是一个非常大的数字,所以可能性极小。
读取 Map 值
映射的行为类似于动态数组,只是我们将槽和键一起散列。
// slot7
mapping(uint256 => uint256) var8;
function getMappedValue(uint256 key) external view returns(uint256) {
uint256 slot;
assembly {
slot := var8.slot
}
// hashs the key and uint256 value of slot
bytes32 location = keccak256(abi.encode(key, slot));
uint256 ans;
assembly {
ans := sload(location)
}
return ans;
}
读取一个嵌套的 Map 值:
// slot 8
mapping(uint256 => mapping(uint256 => uint256)) var9;
这个例子中,设置了映射值var9[0][1]=2
。下面是代码:
function getMappedValue(uint256 key1, uint256 key2) external view returns(uint256) {
// get the slot of the mapping
uint256 slot;
assembly {
slot := var9.slot
}
// hashs the key and uint256 value of slot
bytes32 locationOfParentValue = keccak256(abi.encode(key1, slot));
// hashs the parent key with the nested key
bytes32 locationOfNestedValue = keccak256(abi.encode(key2, locationOfParentValue));
uint256 ans;
// loads storage slot of location and returns ans
assembly {
ans := sload(locationOfNestedValue)
}
return ans;
}
我们首先得到第一个键(0)的哈希值。然后我们用第二个键的哈希值(1)来计算。最后,我们从存储空间加载槽,得到我们的值。
读取和写入打包的变量
假如想要将 var5
改为 4,我们知道 var5
位于 slot 3,所以可以尝试这样做:
function writeVar5(uint256 newVal) external {
assembly {
sstore(3, newVal)
}
}
接下来使用getValInHex(3)
,我们可以看到 slot 3 被改写为 0x0000000000000000000000000000000000000000000000000000000000000004
。这是一个问题,因为现在 var4
被已经被修改为了0。这一部分将讨论如何读写打包的变量。首先学习更多的 Yul 语法
操作说明 | 解释 |
---|---|
and(x, y) | x 和 y 的按位“与” |
or(x, y) | x 和 y 的按位“或” |
xor(x, y) | x 和 y 的按位“异或” |
shl(x, y) | y 逻辑左移 x 位 |
shr(x, y) | y 逻辑右移 x 位 |
and()
取两个bytes32
类型的值尝试and()
操作:
function getAnd() external pure returns(bytes32) {
bytes32 randVar = 0x0000000000000000000000009acc1d6aa9b846083e8a497a661853aae07f0f00;
bytes32 mask = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
bytes32 ans;
assembly {
ans := and(mask, randVar)
}
return ans;
}
查看输出结果,得到:0x000000000000000000009acc1d6aa9b846083e8a497a661853aae07f0f00
。这是因为and()
所做的是看两个输入的每一个位,并比较它们的值。如果两个位都是 1 (二进制方式考虑),那么保存这个位的状态,否则将其设置位 0。
or()
function getOr() external pure returns (bytes32) {
bytes32 randVar = 0x000000000000000000000000ffffffffffffffffffffffffffffffffffffffff;
bytes32 mask = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;
bytes32 ans;
assembly {
ans := or(mask, randVar)
}
return ans;
}
这一次的输出是 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
, 这是因为它看的是否有一个位处于1 状态((激活态))。让我们看看如果我们把掩码变量改为 0x00ffffffffffffffffffffff0000000000000000000000000000000000000000
会怎样。你可以看到输出变为 0x00ffffffffffffffffffffff9acc1d6aa9b846083e8a497a661853aae07f0f00
。 注意第一个字节是0x00
,因为两个输入第一个字节都没有 1 。
xor()
xor()
有一些不同。他要求一个位是 1 (激活态),另一个位是 0(非激活态):
function getXor() external pure returns(bytes32) {
bytes32 randVar = 0x00000000000000000000000000000000000000000000000000000000000000ff;
bytes32 mask = 0xffffffffffffffffffffffff00000000000000000000000000000000000000ff;
bytes32 ans;
assembly {
ans := xor(mask, randVar)
}
return ans;
}
输出是 0xffffffffffffffffffffffff0000000000000000000000000000000000000000
。当0x00
和0xff
对齐时,我们才能看到输出 1 ,区别还是很明显的。
shl()
和shr()
shl()
和shr()
的操作非常类似,二者都是讲输入按位移位。shl()
按左移位,shr()
按右移位。
function shlAndShr() external pure returns(bytes32, bytes32) {
bytes32 randVar = 0xffff00000000000000000000000000000000000000000000000000000000ffff;
bytes32 ans1;
bytes32 ans2;
assembly {
ans1 := shr(16, randVar)
ans2 := shl(16, randVar)
}
return (ans1, ans2);
}
输出:
ans1
:0x0000ffff00000000000000000000000000000000000000000000000000000000
ans2
:0x00000000000000000000000000000000000000000000000000000000ffff0000
首先先看以下ans1
。我们按位 16 位(2 个字节)执行 shr()
可以看到最后两个字节从0xffff
变为0x0000
,前两个字节向右移了两个字节。ans2
所发生的就是比特位移到左边而不是右边。
读取var4
和var5
的值
在我们写到 var5
之前,我们先写一个函数,先读var4
和var5
function readVar4AndVar5() external view returns (uint128, uint128) {
uint128 readVar4;
uint128 readVar5;
bytes32 mask = 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff;
// 用于掩码操作,保留变量的低 128 位,将高 128 位置零。
assembly {
let slot3 := sload(3)
// the and() operation sets var5 to 0x00
readVar4 := and(slot3, mask)
// 将 slot3 和 mask 进行按位与操作,即保留 slot3 的低 128 位,将其余位置零,结果存储在 readVar4 中。
// we shift var5 to var4 to 0x00
// var5's old position becomes 0x00
readVar5 := shr( mul( var5.offset, 8), slot3)
// mul(var5.offset, 8) 的作用是将偏移量转换为实际的位移数值(每个存储槽的大小是 32 字节,所以乘以 8 将槽位数转换为字节)
// 然后使用 shr(右移)指令来根据计算出的位移数值,从 slot3 中获取相应的数据并存储在 readVar5 中。
}
return (readVar4, readVar5);
}
输出结果是1和2,符合预期。对于检索var4
,我们只需要使用一个掩码,将其值设置为0x0000000000000000000000000000000000000000000000000000000000000001
。然后我们返回一个设置为1的uint128
。当读取var5
时,我们需要将var4
向右移位。这样我们就有了0x0000000000000000000000000000000000000000000000000000000000000002
,用来返回。需要注意的是,有时你必须将移位和掩码结合起来,以读取一个有2个以上变量的存储槽的值。
修改var5
的值:
function writeVar5(uint256 newVal) external {
assembly {
// load slot3
let slot3 := sload(3)
// mask for clearing var5
let mask := 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff
// isolate var4
let clearedVar5 := and(slot3, mask)
// format new value into var5 position
let shiftedVal := shl( mul( var5.offset, 8), newVal)
// combine new value with isolated var4
let newSlot3 := or(shiftedVal, clearedVar5)
// store new value to slot3
sstore(3, newSlot3)
}
}
第一步是加载存储槽3。接下来,我们需要创建一个掩码。与我们读取var4
时类似,我们要将数值隔离为 0x0000000000000000000000000000000000000000000000000000000000000001
。下一步是格式化我们的新值,使其在var5
的槽位上,所以它看起来像这样的0x0000000000000000000000000000000400000000000000000000000000000000
。与我们读取var5
时不同,这次我们要将我们的值向左移动。最后,我们将使用or()
将我们的值合并成32字节的十六进制,并将该值存储到槽3。我们可以通过调用getValInHex(3)
来检查我们的工作。这将返回0x0000000000000000000000000000000400000000000000000000000000000001
,这就是我们期望看到的。
内存
内存的行为与 Storage 不同。内存是不持久的,这意味着一旦函数执行完毕,所有的变量都会被清除。内存与其它语言中的堆相当,但没有垃圾收集器。内存(memory)比存储(storage)要便宜得多,前 22 个字的内存成本是线性计算的,但要小心,因为之后的内存成本会变成二次方的。内存是以32个字节的序列排布的。
之后我们会对此有更好的理解,但现在要理解0x00
-0x22
是一个序列。Solidity 分配0x00
-0x40
作为scratch空间
。这个区域的内存不保证是空的,它被用于某些操作。0x40
-0x60
存储的是所谓的free memory pointer(自由空闲指针)
的位置,用于向内存写入新的东西。0x60
-0x80
是空的,作为一个间隙(gap)。0x80
是我们开始工作的地方。内存不合并打包数值。从存储器中获取的值将被存储现在他们自己的 32 字节序列中(即0x80
-0xa0
)。
内存被用于以下操作:
- 外部调用的返回值
- 为外部调用设置函数值
- 从外部调用获取值
- 用一个错误字符串进行还原
- Log 信息(事件)
- 用
keccak256()
进行哈希运算 - 创建其他智能合约
一些关于内存的 Yul 指令:
操作说明 | 解释 |
---|---|
mload(p) | 类似于sload() ,但是是加载 p 之后接下来的 32 个字节 |
mstore(p, v) | 类似于sstore() ,但是是将值 v 存储在 p 中加上 32 个字节 |
mstore8(p, v) | 类似于mmstore() ,但是仅适用于访问单个字节 |
msize() | 返回最大访问的内存索引 |
pop(x) | 丢弃值 x |
return(p, s) | 结束执行,并从内存位置 p -v 返回数据 |
revert(p, s) | 结束执行,不保存状态更改,并从内存位置 p -v 返回数据 |
接下来我们来看一些更多的数据结构:
结构体和固定长度数组的行为实际上是一样的:
struct Var10 {
uint256 subVar1;
uint256 subVar2;
}
这没什么不一样的地方,只是一个简单的数据结构。
function getStructValue() external pure returns (uint256, uint256) {
// initialize struct
Var10 memory s;
s.subVar1 = 32;
s.subVar2 = 64;
assembly {
return(0x80, 0xc0)
}
}
这里我们将s.subVar1
设置为内存位置0x80
-0xa0
,s.subVar2
设置为内存位置0xa0
-0xc0
。这就是为什么我们要返回0x80
- 0xc0
。下面是一个交易结束前的内存布局表:
从中我们可以看到:
0x00
-0x40
是空的 scratch 空间0x40
给了我们空闲的内存指针- Solidity 为
0x60
留了一个空隙(gas) 0x80
和0xa0
用于存储结构的值0xc0
是新的空闲内存指针
动态数组在内存(memory)中工作
接下来展示动态数组是如何下内存中工作的。在这个例子中,把[0, 1, 2, 3]
作为参数arr
传递。这个例子,我们将向数组中添加一个额外元素。在实际中要注意,因为可能会覆盖一个不同的内存变量
function getDynamicArray(uint256[] memory arr) external view returns (uint256[] memory) {
assembly {
// where array is stored in memory (0x80)
let location := arr
// length of array is stored at arr (4)
let length := mload(arr)
// gets next available memory location
let nextMemoryLocation := add( add( location, 0x20), mul( length, 0x20) )
// stores new value to memory
mstore(nextMemoryLocation, 4)
// increment length by 1
length := add( length, 1)
// store new length valur
mstore(location, length)
// update free memory pointer
mstore(0x40, 0x140)
return ( add( location, 0x20), mul (length, 0x20))
}
}
我们在这里所做的是获得数组在内存中的存储位置。然后,我们得到数组的长度,它被存储在数组的第一个内存位置。为了看到下一个可用的位置,我们在该位置上添加32个字节(跳过数组的长度),并将数组的长度乘以32个字节。这将使我们前进到数组后的下一个内存位置。在这里,我们将存储我们的新值(4)。接下来,我们将数组的长度增加1。之后,我们要更新空闲内存指针。最后,我们返回数组。
让我们再看一次内存布局。
合约调用
这里,我们将讨论合约调用在 Yul 中是如何工作的
操作说明 | 解释 |
---|---|
gas() | 仍可用于执行的 Gas |
gasPrice() | 交易的 gas 价格 |
address() | 当前合约的地址 |
balance(a) | 地址 a 的以太币余额(以 wei 为单位) |
selfbalance() | 与调用balance(address()) 相同,但稍微便宜一些 |
caller() | 调用合约的地址(msg.sender) |
origin() | 发起交易的地址(tx.origin) |
callvalue() | 在合约调用中发送的以太币数量(以 wei 为单位)(msg.sender) |
calldatasize() | 调用数据的大小,以字节为单位 |
calldataload(p) | 从位置 p 开始的调用数据(仅 32 字节数据) |
calldatacopy(t, f, s) | 调用数据复制到内存位置 t 。从 calldata 中的位置 f 开始,复制 s 个字节的数据 |
extcodesize(a) | 地址 a 处代码的大小 |
returndatasize() | 最后返回数据的大小 |
returndatacopy(t, f, s) | 将 s 个字节从位置 f 处的返回数据复制到位置 t 处的 memory |
timestamp() | 区块的当前时间戳(以秒为单位) |
number() | 当前区块号 |
call(g, a, v, in, insize, out, outsize) | 使用 gas g 调用地址 a 处的合约,传递 v wei作为 msg.value,从内存位置 in - insize 传递 tx.data,并将返回数据存储在内存位置 out - outsize。如果调用成功,return 1,否则返回 0 |
delegatecall(g, a, in, insize, out, outsize) | 与 call() 类似,主要区别在于 delegatecall() 更新调用合约中的状态变量。 通常用于代理合同。 需要注意的是,存储布局必须与您调用的合约相同,以避免覆盖意外的存储变量。请注意,参数 v 丢失。您无法使用delegatecall() 发送以太币 |
staticcall(g, a, in, insize, out, outsize) | 与 call() 类似。除了它不能用于调用改变区块链状态的合约(即标记为 pure 和 view 的函数)。注意参数 v 丢失。您不能使用 staticcall() 发送以太币 |
接下来,我们看一下我们将调用的合约:
pragma solidity ^0.8.17;
contract CallMe {
uint256 public var1 = 1;
uint256 public var2 = 2;
function a(uint256 _var1, uint256 _var2) external payable returns(uint256, uint256) {
// requires 1 ether was sent to contract
require(msg.value >= 1 ether);
// updates var1 & var2
var1 = _var1;
var2 = _var2;
// returns var1 & var2
return (var1, var2);
}
function b() external view returns(uint256, uint256) {
return (var1, var2);
}
}
这个合约有两个存储变量var1
和var2
,分别存储在 slot 0 和 slot 1 中。函数a()
要求用户至少发送 1 个以太币给合约,否则它就会回滚。接下来,函数a()
更新var1
和var2
并返回他们。函数b()
简单地读取var1
和var2
并返回。
在我们调用CallMe
合约之前,需要花一分钟时间来理解函数选择器。让我们看看下面这个交易0x773d45e000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002
的调用数据。Calldata的前4个字节是所谓的函数选择器(0x773d45e0
)。这就是EVM如何知道你想调用什么函数。我们通过获取函数签名的字符串哈希值的前4个字节来得出函数选择器。所以函数a()
的签名是a(uint256,uint256)
。从这个字符串的哈希值可以得到0x773d45e097aa76a22159880d254a5f1db8365bc2d0f0987a82bda7dfd3b9c8aa
。看一下前4个字节,我们看到它等于0x773d45e0
。注意签名中没有空格。这很重要,因为添加空格会给我们一个完全不同的哈希值
我们先看一下存储布局:
uint256 public var1;
uint256 public var2;
bytes4 selectorA = 0x773d45e0;
bytes4 selectorB = 0x4df7e3d0;
注意var1
和var2
的布局与合约CallMe
相同。这样才能使delegatecall()
正常工作。我们满足了这些需求,并且能够拥有其他的变量(selectorA
和selectorB
),只要我们的新变量被附加到最后。这可以防止任何存储碰撞。
staticcall()
function getVars(address _callMe) external view returns(uint256, uint256) {
assembly {
// load slot 2 from memory
let slot2 := sload(2)
// shift selectorA off 右移位隔离 A
let funcSelector := shr( 32, slot2)
// store selectorB to memory location 0x80
mstore(0x00, funcSelector)
// static call CallMe
let result := staticcall(gas(), _callMe, 0x1c, 0x20, 0x80, 0xc0)
// check if call was successful, else revert
if iszero(result) {
revert(0,0)
}
// return values from memory
return (0x80, 0xc0)
}
}
首先从存储空间中获取b()
的函数选择器。通过加载 slot2
来获取,但是由于两个函数选择器都存储在 slot2
,接下来我们可以右移 4 个字节(32 位)来提取selectorB
。接下来把函数选择器存储在内存的scratch
空间内。现在我们就可以进行静态调用了。在这些例子中,我们传入了gas()
,当然可以指定 Gas 的数量。我们传入参数_callMe
为合约地址。0x1c
和0x20
说的是我们要把存储的最后 4 个字节传到 scratch 空间。因为函数选择器是 4 个字节,但内存是以 32 个字节为一个系列工作的(同样,记住我们是从右往左存储的)。staticcall()
的最后两个参数指定我们要将返回数据存储在内存位置0x80
- 0xc0
。接下来,我们检查函数调用是否成功,否则 revert。记住,成功的调用将返回 1。最后,从内存中返回数据,并得到数值 1 和 2。
call()
从 CallMe
中调用函数a()
。调用该函数需要向合约发送一个 Ether。在这个例子中,将 3 和 4 作为_var1
和_var2
传入
function callA(address _callMe, uint256 _var1, uint256 _var2) external payable returns (bytes memory) {
assembly {
// load slot 2
let slot2 := sload(2)
// isolate selectorA
let mask := 0x00000000000000000000000000000000000000000000000000000000ffffffff
let funcSelector := and(mask, slot2)
0x00000000000000000000000000000000000000000000000000000000ffffffff
0x00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
// store function selectorA
mstore(0x80, funcSelector)
// copies calldata to memory location 0xa0
// leaves out function selector and _callMe
calldatacopy(0xa0, 0x24, sub( calldatasize(), 0x24) )
// call contract
let result := call(gas(), _callMe, callvalue(), 0x9c, 0xe0, 0x100, 0x140)
// check if call was successful, else revert
if iszero(result) {
revert(0,0)
}
// return values from memory
return (0x100, 0x140)
}
}
好的,与我们上一个例子类似,我们必须加载slot2。但是这一次,我们将屏蔽selectorB
以隔离selectorA
。现在我们将把选择器存储在0x80
。由于我们需要来自calldata的参数,我们将使用calldatacopy()
。我们告诉calldatacopy()
在内存位置0xa0
存储我们的calldata。我们还告诉calldatacopy()
跳过前36个字节。前4个字节是callA()
的函数选择器,接下来的32个字节是callMe
的地址(我们将在一分钟内使用它)。我们告诉calldatacopy()
的最后一件事是存储calldata的大小减去36字节。
现在我们已经准备好进行合约调用。像上次一样,我们传入gas()
和_callMe
。然而,这次我们从0x9c
(0x80
内存系列的最后4个字节)-0xe0
传入我们的调用数据,并将数据存储在内存位置0x100
-0x120
。再次,检查调用是否成功并返回我们的输出。如果我们检查合约CallMe
,我们看到值被成功更新为3和4。
为了进一步说明正在发生的事情,这里是我们返回之前的内存布局。
delegatecall()
最后来看 delegatecall()
合约代码看起来几乎是一样的,只有一个变化:
function delegatecall(address _callMe, uint256 _val1, uint256 _val2) external payable returns (bytes memory) {
assembly {
// load slot2
slot2 := sload(2);
// isolate selectorA
let mask := 0x00000000000000000000000000000000000000000000000000000000ffffffff
let funcSelector := and(mask, slot2)
// store function selectorA
mstore(0x80, funcSelector)
// copies calldata to memory location 0xa0
// leaves out function selector and _callMe
calldatacopy(0xa0, 0x24, sub( calldatasize(), 0x24 ) )
// call contract
let result := delegatecall(gas(), _callMe, 0x9c, 0xe0, 0x100, 0x120)
// check if call was successful, else revert
if iszero(result) {
revert(0,0)
}
// return values from memory
return (0x100, 0x120)
}
}
我们所做的唯一改变是将call()
改为delegatecall()
并删除callvalue()
。我们不需要callvalue()
,因为委托调用是在它自己的状态中执行CallMe
的代码。因此,a()
中的require()
语句是在检查以太币是否被发送到我们的Caller
合约。如果我们检查CallMe
中的var1
和var2
,我们看到没有变化。然而,我们的Caller
合约中的var1
和var2
被成功更新。