Q1ngying

今朝梦醒与君别,遥盼春风寄相思

0%

Yul内联汇编入门

Yul 入门

参考来源:https://learnblockchain.cn/article/6064

Yul概述

Yul 是一种中间编程语言,可以用来在智能合约中编写汇编语言。了解 Yul 可以提高智能合约的水平。了解Solidity底层发生的事情,帮助节省 Gas 费用。可以使用assembly代码块来在智能合约中标识 Yul:

1
2
3
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

一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
function addOneAndTwo() external pure returns(uint256) {
uint256 ans;

assembly{
// Yul 中为变量赋值
let one := 1
let two := 2
// 加法
ans := add(one, two)
}
return ans;
}

For 循环和 if 语句

例子:计算一个系列中多少个数字是偶数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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字节),这就是uint256bytes32的名字由来。所有的变量都会被转换为十六进制。如果一个变量,例如 uint128 使用时,不会用整个槽来存储该变量。相反,它的左边是用 0 填充的。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
// 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。 请注意,0x0000000000000000000000000000020x000000000000000000000000000001 完全吻合同一个槽。这是因为它们都占用了16个字节(一半的槽)。

更多关于 Yul 的内容:

操作说明 解释
sload(p) 从 storage 中加载槽 p 中的变量
sstore(p,v) 分配给 Storage 槽 p 一个 v 值
v.slot 返回变量 v 的存储槽
v.offset 返回变量 v 在存储槽中开始位置的序列(以字节为单位)。变量从右到左排列

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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。

下面这个函数可以帮助我们查看每个插槽正在存储的内容

1
2
3
4
5
6
7
8
9
function getValInHex(uint256 y) external view returns (bytes32) {
bytes32 x;

assembly {
x := sload(y)
}

return x;
}

读取动态数组的值

接下来是一些更复杂的数据结构:

1
uint128[4] var6 = [0,1,2,3];

当使用静态数组时,EVM 知道要为我们的数据分配多少个槽位。特别是这个数组,我们在每个槽中打包2个元素。所以如果你调用 getValInHex(4),它将返回 0x0000000000000000000000000000000100000000000000000000000000000000。正如我们所期望的,从右到左读,我们看到的是值0和值1。槽5包含0x0000000000000000000000000000000300000000000000000000000000000002

接下来我们要看一下动态数组。

1
2
// slot 6
uint256[] var7;

尝试调用getValInHex(6)。你会看到它返回 0x00。由于 EVM 不知道需要分配多少个存储槽,我们不能在这里存储数组。相反,当前存储槽(槽6)的keccak256 哈希值被用来作为数组的起始索引。从这里开始,我们需要做的就是添加所需元素的索引来检索值。

接下来是的例子,演示如何查找一个动态数组的第一个元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 值

映射的行为类似于动态数组,只是我们将槽和键一起散列。

1
2
// slot7
mapping(uint256 => uint256) var8;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 值:

1
2
// slot 8
mapping(uint256 => mapping(uint256 => uint256)) var9;

这个例子中,设置了映射值var9[0][1]=2。下面是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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,所以可以尝试这样做:

1
2
3
4
5
6
7
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()操作:

1
2
3
4
5
6
7
8
9
10
11
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()

1
2
3
4
5
6
7
8
9
10
11
12
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(非激活态):

1
2
3
4
5
6
7
8
9
10
11
12
13
function getXor() external pure returns(bytes32) {

bytes32 randVar = 0x00000000000000000000000000000000000000000000000000000000000000ff;
bytes32 mask = 0xffffffffffffffffffffffff00000000000000000000000000000000000000ff;

bytes32 ans;

assembly {
ans := xor(mask, randVar)
}

return ans;
}

输出是 0xffffffffffffffffffffffff0000000000000000000000000000000000000000。当0x000xff对齐时,我们才能看到输出 1 ,区别还是很明显的。

shl()shr()

shl()shr()的操作非常类似,二者都是讲输入按位移位。shl()按左移位,shr()按右移位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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);
}

输出:

ans10x0000ffff00000000000000000000000000000000000000000000000000000000

ans20x00000000000000000000000000000000000000000000000000000000ffff0000

首先先看以下ans1。我们按位 16 位(2 个字节)执行 shr() 可以看到最后两个字节从0xffff变为0x0000,前两个字节向右移了两个字节。ans2所发生的就是比特位移到左边而不是右边。

读取var4var5的值

在我们写到 var5之前,我们先写一个函数,先读var4var5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 返回数据

接下来我们来看一些更多的数据结构:

结构体和固定长度数组的行为实际上是一样的

1
2
3
4
struct Var10 {
uint256 subVar1;
uint256 subVar2;
}

这没什么不一样的地方,只是一个简单的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
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-0xa0s.subVar2设置为内存位置0xa0-0xc0。这就是为什么我们要返回0x80 - 0xc0。下面是一个交易结束前的内存布局表:

img

从中我们可以看到:

  • 0x00 - 0x40 是空的 scratch 空间
  • 0x40 给了我们空闲的内存指针
  • Solidity 为 0x60 留了一个空隙(gas)
  • 0x800xa0用于存储结构的值
  • 0xc0是新的空闲内存指针

动态数组在内存(memory)中工作

接下来展示动态数组是如何下内存中工作的。在这个例子中,把[0, 1, 2, 3]作为参数arr传递。这个例子,我们将向数组中添加一个额外元素。在实际中要注意,因为可能会覆盖一个不同的内存变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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。之后,我们要更新空闲内存指针。最后,我们返回数组。

让我们再看一次内存布局。

img

合约调用

这里,我们将讨论合约调用在 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() 类似。除了它不能用于调用改变区块链状态的合约(即标记为 pureview 的函数)。注意参数 v 丢失。您不能使用 staticcall() 发送以太币

接下来,我们看一下我们将调用的合约:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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);
}

}

这个合约有两个存储变量var1var2,分别存储在 slot 0 和 slot 1 中。函数a()要求用户至少发送 1 个以太币给合约,否则它就会回滚。接下来,函数a()更新var1var2并返回他们。函数b()简单地读取var1var2并返回。

在我们调用CallMe合约之前,需要花一分钟时间来理解函数选择器。让我们看看下面这个交易0x773d45e000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002 的调用数据。Calldata的前4个字节是所谓的函数选择器0x773d45e0)。这就是EVM如何知道你想调用什么函数。我们通过获取函数签名的字符串哈希值的前4个字节来得出函数选择器。所以函数a()的签名是a(uint256,uint256)。从这个字符串的哈希值可以得到0x773d45e097aa76a22159880d254a5f1db8365bc2d0f0987a82bda7dfd3b9c8aa。看一下前4个字节,我们看到它等于0x773d45e0。注意签名中没有空格。这很重要,因为添加空格会给我们一个完全不同的哈希值

我们先看一下存储布局:

1
2
3
4
uint256 public var1;
uint256 public var2;
bytes4 selectorA = 0x773d45e0;
bytes4 selectorB = 0x4df7e3d0;

注意var1var2的布局与合约CallMe相同。这样才能使delegatecall()正常工作。我们满足了这些需求,并且能够拥有其他的变量(selectorAselectorB),只要我们的新变量被附加到最后。这可以防止任何存储碰撞。

staticcall()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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为合约地址。0x1c0x20说的是我们要把存储的最后 4 个字节传到 scratch 空间。因为函数选择器是 4 个字节,但内存是以 32 个字节为一个系列工作的(同样,记住我们是从右往左存储的)。staticcall()的最后两个参数指定我们要将返回数据存储在内存位置0x80 - 0xc0。接下来,我们检查函数调用是否成功,否则 revert。记住,成功的调用将返回 1。最后,从内存中返回数据,并得到数值 1 和 2。

call()

CallMe 中调用函数a()。调用该函数需要向合约发送一个 Ether。在这个例子中,将 3 和 4 作为_var1_var2传入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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。然而,这次我们从0x9c0x80内存系列的最后4个字节)-0xe0传入我们的调用数据,并将数据存储在内存位置0x100-0x120。再次,检查调用是否成功并返回我们的输出。如果我们检查合约CallMe,我们看到值被成功更新为3和4。

为了进一步说明正在发生的事情,这里是我们返回之前的内存布局。

img

delegatecall()

最后来看 delegatecall() 合约代码看起来几乎是一样的,只有一个变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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中的var1var2,我们看到没有变化。然而,我们的Caller合约中的var1var2被成功更新。