EVM-存储模型概述


图片来源

以太坊上状态的变化,实际上由运行在虚拟机上的智能合约在交易的触发情况下改变的。
这个改变的底层逻辑,将涉及到 EVM 的存储模型。


1. 概述

在此之前,先对 EVM 做一整体了解。如下图所示:

图片来源

我们可以看到,PC / Stack / Memory 这些组件构成了基本的基于栈结构的计算引擎运行环境。GAS Available 是是否可以继续计算和存储的基本条件,也解决了无限循环问题(gas 一旦耗尽,将终止运行)。EVM Code 为编译后的合约代码;Storage 持久化存储状态数据。以上,就组成了 一个完整的基于以太坊区块链的 “准” (gas耗尽终止执行)图灵完备的虚拟机 — EVM.

以下,是一张更加详细的模型图:


图片来源

由此可见,EVM 内部,更像是一个 “单线程“ 的受(内/外部)调用触发的状态机。而调用,是经过签名/授权后,任何人(或者合约)都可以调用的。状态数据的有效性和完整性,是靠底层区块链技术支持的。


2. Calldata

对应着交易中的 data 数据。是交易中合约调用的负载。
一个 Transaction 中,data 字段不为空,代表存在合约调用;value 字段不为空,代表存在 eth 转移。而 data 的内容,就是 ABI 编码后的方法调用。

  • calldatasize(): 该操作符代表了 Transactiondata 的长度;
  • calldataload(p): 从 p 位置开始,获取 32 个字节并入栈;
  • calldatacopy(t, f, s): 该操作符将 data 拷贝进 memory 中。从 f 开始,从 calldata 中拷贝 s 个字节到 memoryt 位置。

这些操作符,在应用层面主要应用于内联汇编方法中,具体操作将在下文做进一步分析。


3. Stack

引擎运行环境,是栈。入栈,出栈,运算,就构成 EVM 的计算引擎。
栈深 1024,超出将会导致栈溢出,交易失败。这实际上限制了函数的调用深度。
虽然栈深 1024,但是允许访问的却只有顶端的 16 个。
只能 访问(access) 顶端 16 个元素的限制,可能会导致 “Stack Too Deep” 的异常。

It has a maximum size of 1024 elements and contains words of 256 bits. Access to the stack is limited to the top end in the following way: It is possible to copy one of the topmost 16 elements to the top of the stack or swap the topmost element with one of the 16 elements below it.

来源

更多 EVM 操作符


4. Memory

memory 中的数据,存续于函数执行期间。
每个合约调用,都拥有它自己的 memory 空间,且这个空间在初始时是 clean 的。在合约调用完成之后,memory 空间又被整体释放。

如上图示:

Memory is linear and can be addressed at byte level, but reads are limited to a width of 256 bits, while writes can be either 8 bits or 256 bits wide.

来源

在 Solidity 中保留了 4 个 32-byte 的槽位,这些槽位具有特定的 byte 范围:

  • 0x00 - 0x3f(64 bytes): 保存方法 hash 的临时空间。
  • 0x40 - 0x5f(32 bytes): 当前已经分配的 memory 的大小(或者说,是还未分配的可以使用的 memory 的指针)。
  • 0x60 - 0x7f(32 bytes): 0值槽位。

因此,memory 中的数据,实际上是从 0x80 开始的。数据的内容,可能来自 calldata,也可以是在函数内定义的变量。
每当申请新的 memory 空间时,新申请的空间总是在原空间上线性递增的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Memsize {
function foo(uint _its) pure public returns (uint) {
uint ms;
uint[] memory a;
for (uint i = 0; i < _its; ++i) {
a = new uint[](100);
delete a;
}
assembly{
ms := msize()
}
return(ms);
}
}

代码来源

测试结果:

1
2
3
foo(1) 3392
foo(2) 6656
foo(3) 9920

接下来,我们使用内联汇编做另外一种应用分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function() payable public {
address _impl = implementation();
require(_impl != address(0));

assembly {
// 获取当前 0x40 所指向的空余 memory 所在的地址
let ptr := mload(0x40)
// 将 0 到 calldatasize 的 calldata 数据,拷贝到 ptr 所指向的 memory 空余空间
calldatacopy(ptr, 0, calldatasize)
// result 即执行是否成功
// delegatecall(g, a, in, insize, out, outsize)
// https://docs.soliditylang.org/en/latest/yul.html#yul-call-return-area
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
// 获取返回值的大小
let size := returndatasize
// 将 0 到 size 的 returndata 返回到 ptr 所指向的 memory 空余空间。注意,这里会覆盖 calldata,但是 calldata 已经没有用了。
returndatacopy(ptr, 0, size)

switch result
case 0 {revert(ptr, size)}
default {return (ptr, size)}
}
}

再来看一个示例,以下这种方式,是主动使用 ABI 接口,进行汇编调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function getValue() public returns (uint256) {
bytes memory data = abi.encodeWithSignature("modValue(uint256,uint256)", 52, 3);

assembly {
let pointer := mload(0x40)

if iszero(delegatecall(not(0), sload(_delegate.slot), add(data,32), mload(data), pointer, 0x20)) {
revert(0, 0)
}

let size := returndatasize()
returndatacopy(pointer, 0, size)
return(pointer,size)
}
}

需要注意的是,add(data,32),地址前移了 32 个字节,原因在于 data 的前 32 个字节,实际上是 data 数组的长度。

Solidity always places new objects at the free memory pointer and memory is never freed (this might change in the future).


5. Storage

Storage 主要用于持久化存储,数据内容不会随着合约调用的结束而遗失。

我们可以看到,Storage 中的数据是以 key/value 的形式存储的。其中,每个 slot 为 32 字节。对于 key 来说,不同的数据类型的定义又是不一样的。
对于整型/整型结构体/定长数据的存储,key 是该变量在合约中首次操作的位置。

对于 map 类型,key 是 sha3(map 的key + position),然后递增。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract MyContract {
uint256 a;
uint256 b;

mapping(uint256 => uint256) c;
uint256 d;

function func1() public {
a = 1;
b = 2;
c[3] = 0xAABB;
c[9] = 0xCCDD;
d = 5;
}
}

对于数组,key 是 sha3(position),然后递增。

比如

1
2
3
4
5
6
7
8
9
10
11
uint256 foo;
uint256 bar;
uint256[] items;

function allocate() public {
require(0 == items.length);

items.length = 2;
items[0] = 12;
items[1] = 42;
}

为什么要采用这种通过 slot index 来定义存储位置的方式呢?我个人的看法是:一个是因为 2^256 空间足够大,大到已经忽略了碰撞;另外,也会考虑保持 EVM 设计的简单性,它并不具有一个 GC 回收器。

这种机制下,同样也影响着代理模式的使用。


存储碰撞

存储碰撞发生在逻辑合约和代理合约中。在调用 delegatecall 时。
比如,代理模式中,Proxy 合约的 Fallback 函数中,通常是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
assembly {
let ptr := mload(0x40)

// (1) copy incoming call data
calldatacopy(ptr, 0, calldatasize)

// (2) forward call to logic contract
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
let size := returndatasize

// (3) retrieve return data
returndatacopy(ptr, 0, size)

// (4) forward return data back to caller
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}

但是,一旦 Proxy 合约和 Implementation 合约中槽位不一致,就可能会发生碰撞的情况。如下所示:

此时,Proxy 中的 _implementation 就和 Implementation 中的 _owner 就发生了碰撞。其结果,就是在 Proxy 通过 delegatecall 访问 Implementation 中的方法时,如果方法中包含了 _owner,那么,其实这个值是 Proxy 中的 _implementation

1
2
3
4
5
6
7
8
9
10
11
contract Implementation {
address _owner ;
...

function proxy() public {
// 此时 _owner 实际上是 _implementation
if(_owner == msg.sender){
...
}
}
}

6. 小结

EVM 的出现,让每一个 以太坊 节点,变成了一个可以运行智能合约的且被以太坊区块链承认其运算结果的 “计算机”。



参考链接
存储碰撞
Unboxing EVM storage
Ethereum EVM illustrated
Solidity 汇编

10 EVM Design Mistakes
Solidity 中的 memory 为什么从来不用释放?
Knowsec Blockchain Lab | Depth understanding of EVM storage mechanism and security issues
Collisions of Solidity Storage Layouts

© 2024 YueGS