了解EVM
EVM是以太坊协议的一部分,用于控制智能合约的部署和执行,可以想象成一台超级计算机,拥有数百万个可执行的东西,也就是合约,每一个东西都有自己的永久存储。
Solidity代码需要先编译成字节码,才能部署到以太坊网络,字节码代表着一系列用于EVM的Opcode
ByteCode
当一个合约被编译的时候,大部分合约都会被编译成三个部分:
- Contract Creation Code
- Runtime Code
- Metadata
在中间会有INVALID
来分割。
当看到CODECOPY-39
的时候一般就是合约的创建部分
OpCode
实际上就是对Stack Memory Storage做一些事情。
Stack
EVM是堆栈的,深度游1024个items,每个item都有256bit也就是32字节。因为是堆栈的所有符合后进先出。
Opcodes使用堆栈元素作为input,始终使用栈顶的元素进行操作
Memory
在EVM中,Memory可以被认为是一个可拓展的通过字节寻址的一维数组,开始时是空的,读取写入和扩展都需要花费Gas。同时内存的成本是和使用的成比例上升的,所以即使memory理论上有$2^{256}$ 个elements但是一般也用不到那么多。同时还有Gas限制,所以更用不到了。
Calldata类似,但是Calldata无法扩展或者覆盖,并且充当合约调用的输入
Memory和Calldata不是持久的,在交易结束后就会被丢弃,同时几乎所有的从内存中的读取操作都是以32字节为单位的。
Gas Cost
在智能合约执行期间,可以使用操作码访问内存。当首次访问偏移量(读取或写入)时,内存可能会触发扩展,这会消耗gas。
当访问的字节偏移量(模 32)大于之前的偏移量时,可能会触发内存扩展。如果发生内存扩展的较大偏移量触发,则会计算访问较高偏移量的成本,并将其从当前调用上下文中可用的总 Gas 中删除。
一般总成本的计算方式如下:
memory_size_word = (memory_byte_size + 31) / 32
memory_cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word)
当触发内存扩展时,只需支付额外的内存字节费用。因此,特定操作码的内存扩展成本为:
memory_expansion_cost = new_memory_cost - last_memory_cost
memory_byte_size
可以通过操作码 MSIZE 获得。 MSIZE 触发的内存扩展成本呈二次方增长,通过使更高的偏移量成本更高来抑制内存的过度使用。任何访问内存的操作码都可能触发扩展(例如 MLOAD、RETURN 或 CALLDATACOPY)。
Memort Data Structure
合约内存是一个简单的字节数组,其中数据可以用32字节(256位)或者1字节(8位)进行存储,但是读取是32字节的读取。
关于存储的操作主要有三个Opcode:
-
MSTORE(x,y)
- 从内存位置X存储32字节的Y -
MLOAD(x)
- 将内存位置x开始的32字节加载到stack上 -
MSTORE8(x,y)
- 将1字节的y存储到位置x上
Memory是一个数组,也就是我们能从任何位置读取并且返回32字节的数据,Memory是线性的,可以在字节级别进行寻址。
Free Memory Pointer
Memory
中的布局是这样的:
Free Memory Pointer
只是执行空闲内存开始位置的指针,能够确保智能合约跟踪那些内存已经被写入,哪些没有被写入。
Free Memory Pointer
可以防止合约覆盖已分配给另外一个变量的内存
当一个变量写入内存的时候,合约将首先引用Free memory pointer
来确定数据应该存储在哪里。
然后,它通过记录要写入新位置的数据量来更新空闲内存指针。这两个值的简单相加将产生新的可用内存的开始位置。
freeMemoryPointer + dataSizeBytes = newFreeMemoryPointer
一开始空闲指针的定义是:
60 80 = PUSH1 0x80
60 40 = PUSH1 0x40
52 = MSTORE
这些有效地表明空闲内存指针位于内存中的字节 0x40
(十进制 64
),其值为 0x80
(十进制 128
)。
那为什么说是从0x80
开始空闲呢?这是因为Solidity 的内存布局保留了 4
个 `32
字节槽:
-
0x00
-0x3f
(64 bytes):scratch space
暂存空间- 暂存空间可用于语句之间,即内联汇编内和散列方法。
-
0x40
-0x5f
(32 bytes):free memory pointer
空闲内存指针- 空闲内存指针,当前分配的内存大小,空闲内存的起始位置,初始为
0x80
。
- 空闲内存指针,当前分配的内存大小,空闲内存的起始位置,初始为
-
0x60
-0x7f
(32 bytes):zero slot
零槽- 零槽用作动态内存阵列的初始值,并且永远不应该被写入。
接下来让我们来看看EVM是如何操作Memory的:
Operate Memory
Struct
虽然到这里我们还没有提到Storage中的slot,但是先记住,在Memory中Data不是和Storage一样Packed,哪怕没有32字节大小,比如可能是一个uint32或者address,他依然会占据一个32字节的槽。E.g:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract MemBasic {
struct Point {
uint256 x;
uint32 y;
uint32 z;
}
function read() public pure returns (uint256 x,uint256 y,uint256 z) {
Point memory p = Point(1,2,3);
}
}
我们首先定义了一个struct,然后在memory中初始化了他,现在我想要使用mload来读取memory中的x,y,z我们该怎么做呢:
assembly {
x := mload(0x80) // 空闲指针初始化的位置
y := mload(add(0x80,0x20)) // 往后加32字节
z := mload(add(0xa0,0x20)) // 再加32字节
}
这是读取,下面我们再来看看如何写入struct数据:
Point memory p;
assembly {
mstore(0x80,1)
mstore(add(0x80,0x20),2)
mstore(add(0xa0,0x20),3)
}
这时候如果想看一下Free memory pointer指向哪里我们可以使用:
Free_memory_pointer := mload(0x40) //因为这是存储指针的位置
Fixed size Array
和Struct一样,只需要记住在Memory中不管大小有多大,都是32字节的,不会打包
contract MemFixedArray {
function test_read()
public
pure
returns (uint256 a0, uint256 a1, uint256 a2)
{
// arr is loaded to memory starting at 0x80
// Each array element is stored as 32 bytes
uint32[3] memory arr = [uint32(1), uint32(2), uint32(3)];
assembly {
a0 := mload(0x80)
a1 := mload(0xa0)
a2 := mload(0xc0)
}
}
function test_write()
public
pure
returns (uint256 a0, uint256 a1, uint256 a2)
{
uint32[3] memory arr;
assembly {
// 0x80
mstore(arr, 11)
// 0xa0
mstore(add(arr, 0x20), 22)
// 0xc0
mstore(add(arr, 0x40), 33)
}
a0 = arr[0];
a1 = arr[1];
a2 = arr[2];
}
}
DynamicArray
一样,直接贴代码:
contract MemDynamicArray {
function test_read()
public
pure
returns (bytes32 p, uint256 len, uint256 a0, uint256 a1, uint256 a2)
{
uint256[] memory arr = new uint256[](5);
arr[0] = uint256(11);
arr[1] = uint256(22);
arr[2] = uint256(33);
arr[3] = uint256(44);
arr[4] = uint256(55);
assembly {
p := arr
// 0x80
len := mload(arr)
// 0xa0
a0 := mload(add(arr, 0x20))
// 0xc0
a1 := mload(add(arr, 0x40))
// 0xe0
a2 := mload(add(arr, 0x60))
}
}
function test_write() public pure returns (bytes32 p, uint256[] memory) {
uint256[] memory arr = new uint256[](0);
assembly {
p := arr
// Store length of arr
mstore(arr, 3)
// Store 1, 2, 3
mstore(add(arr, 0x20), 11)
mstore(add(arr, 0x40), 22)
mstore(add(arr, 0x60), 33)
// Update free memory pointer
mstore(0x40, add(arr, 0x80))
}
// Data will be ABI encoded when arr is returned to caller
return (p, arr);
}
}
Storage
在Ethereum中所有的合约账户能够将数据持久的存储在Storage
中,Storage
的成本要比Memory
贵很多,因为交易执行后,所有的以太坊都要更新合约的Storage。
我们可以将Storage视作一个天文数字般的数组,最初充满了零。数组中每个值都是32字节,有$2^{256}$这样的值,智能合约可以在任何位置读取或者写入值
首先我们先记住几个基本概念:
- 每个智能合约都有 $ 2^{256} $ 个32 字节值的数组形式存储,全部初始化为零。
- 当我们设置状态变量的值时,他会将其分配在slot中。
- 0不是显式存储的
- Solidity将固定大小的值定位在slot中从slot0开始,比如uint256,address....
- Solidity 利用存储的稀疏性和哈希输出的均匀分布来安全地定位动态大小的值。
我们来看一个例子:
contract StorageTest {
uint256 a;
uint256[2] b;
struct Entry {
uint256 id;
uint256 value;
}
Entry c;
}
在上面的代码中:
-
a
会被存储在slot0中 - b存储在slot1和2中,因为b是一个数组,同时长度是定好的2,所以会分配两个slot
- c会从slot3开始,并且消耗两个slot,因为Entry结构存储了两个32字节的值
我们可以使用Foundry的inspect来查看布局:
forge inspect StorageTest storage
{
"storage": [
{
"astId": 47406,
"contract": "src/Test.sol:StorageTest",
"label": "a",
"offset": 0,
"slot": "0",
"type": "t_uint256"
},
{
"astId": 47410,
"contract": "src/Test.sol:StorageTest",
"label": "b",
"offset": 0,
"slot": "1",
"type": "t_array(t_uint256)2_storage"
},
{
"astId": 47418,
"contract": "src/Test.sol:StorageTest",
"label": "c",
"offset": 0,
"slot": "3",
"type": "t_struct(Entry)47415_storage"
}
],
"types": {
"t_array(t_uint256)2_storage": {
"encoding": "inplace",
"label": "uint256[2]",
"numberOfBytes": "64",
"base": "t_uint256"
},
"t_struct(Entry)47415_storage": {
"encoding": "inplace",
"label": "struct StorageTest.Entry",
"numberOfBytes": "64",
"members": [
{
"astId": 47412,
"contract": "src/Test.sol:StorageTest",
"label": "id",
"offset": 0,
"slot": "0",
"type": "t_uint256"
},
{
"astId": 47414,
"contract": "src/Test.sol:StorageTest",
"label": "value",
"offset": 0,
"slot": "1",
"type": "t_uint256"
}
]
},
"t_uint256": {
"encoding": "inplace",
"label": "uint256",
"numberOfBytes": "32"
}
}
}
对于不同的类型,Solidity的规范如下:
Kind | Declaration | Value | Location |
---|---|---|---|
Simple Variable | T v | v | v's slot |
Fixed-size array | T[10] v | v[n] | (v's slot) + n * (size of T) |
Dunamic array | T[] v | v[n] v.length |
keccak256(v's slot) + n * (size of T) v's slot |
Mapping | Mapping(T1 => T2) v | v[key] | keccak256(key . (v's slot)) |
Slot Packing
Solidity 编译器知道它可以在存储槽中存储 32 字节的数据。当我们定了一个uint32类型的value1的时候,value1仅占用 4 个字节存储在槽slot0 时,编译器读取下一个变量时会查看是否可以将其打包到当前存储槽中。所以如果value2是uint128的话也会打包保存在slot0。
比如如下的代码:
contract StorageTest {
uint32 value1;
uint32 value2;
uint64 value3;
uint128 value4;
}
他的存储布局会是什么样的?
contract StorageTest {
uint32 value1; // 4 bytes slot0
uint32 value2; // 4 bytes slot0
uint64 value3; // 8 bytes slot0
uint128 value4;// 16 bytes slot0
}
因为一个Slot占据了32个bytes如果可以打包的话,就会进行打包。
Storage Opcodes
关于Storage
的Opcode
主要有两个SSTORE
和SLOAD
SSTORE:
它从调用堆栈中获取 32 字节key和 32 字节value,并将该 32 字节value存储在该 32 字节Key指代的位置。e.g:
Input | |
---|---|
1 | 0 |
2 | 0xFFFF |
Storage结果:
Storage key after input 1 | Storage value |
---|---|
0 | 0xFFFF |
SLOAD:
它从Stack
中获取 32 字节key
,并将存储在该 32 字节key
位置的 32 字节Value
推送到Stack
上。e.g:
假设目前的Storage如下:
Storage key | Storage Value |
---|---|
0 | 46 |
那么结果就是:
Input | Output |
---|---|
0 | 46 |
CallData
Calldata
也就是我们发送给函数的编码参数。也就是发送给EVM的数据。
每个calldata长度为32字节或者64个字符,一般calldata分为两种:
- Static
- Dynamic
如何编码
如果对类型进行编码,直接传递到abi.encode()
中来生成原始调用数据
如果对特定接口函数,我们需要使用abi.encodeWithSelector(selector, parameters)
interface A {
function transfer(uint256[] memory ids, address to) virtual external;
}
contract B {
function a(uint256[] memory ids, address to) external pure returns(bytes memory) {
return abi.encodeWithSelector(A.transfer.selector, ids, to);
}
}
如何解码
如果使用abi.encode编码的,我们可以使用abi.decode来解码:
(uint256 a, uint256 b) = abi.decode(data, (uint256, uint256))
Static Variables
假如我们在和下面的合约交互:
pragma solidity 0.8.17;
contract Example {
function transfer(uint256 amount, address to) external;
}
我们想要传递的参数是:
amount: 1300655506
address: 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
那将会生成一段:0x000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45
我们该怎么阅读呢,其实我们只需要吧0x删去,然后以32字节为一组分开就好:
0x
// uint256
000000000000000000000000000000000000000000000000000000004d866d92
// address
00000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45
Function
我们都知道函数选择器是函数加上参数然后进行keccak256
哈希之后的前四个字节,所以如果我们想抗药调用transfer(uint256,address)
,我们就需要先:
keccak256("transfer(uint256,address)");
然后将结果的前四个字节b7760c8f
拼接到calldata中:
0xb7760c8f000000000000000000000000000000000000000000000000000000004d866d9200000000000000000000000068b3465833fb72a70ecdf485e0e4c7bd8665fc45
Dynamic Variables
动态变量是非固定大小的类型,包括 bytes
、 string
和动态数组 <T>[]
,以及固定数组 <T>[N]
。
动态类型的结构始终以偏移量开始,偏移量是动态类型开始位置的十六进制表示。例如,十六进制 20
表示 32-bytes
。一旦到达偏移量,就会有一个较小的数字表示类型的长度。也就是说第一个32字节是偏移量,第二个32字节是长度,其余为元素
我们用一个例子形象的表明一下,比如一个字符串string
类型,值是Hello World!
:
0x
0000000000000000000000000000000000000000000000000000000000000020
000000000000000000000000000000000000000000000000000000000000000c
48656c6c6f20576f726c64210000000000000000000000000000000000000000
第一个表明偏移量是20也就是十进制的32,所以我们跳过直接去下一行,值是0c,十进制的12,表明我们的字符串有12个字符,最后一行就是元素。
Refer
https://noxx.substack.com/p/evm-deep-dives-the-path-to-shadowy-3ea?s=r