1. 数据存储布局
1. ⭐️EVM结构
以太坊中,EVM的结构如下图所示
其中,各组件的内容以及交互如下:
- 程序计数器(
Program Counter
): 用于标识当前操作指令(opcode
)的位置,控制程序的执行流程。
-
合约字节码(
EVM code
):solidity
编译后的代码。每次调用合约时候,从这里读取逐行执行。 -
操作指令(
operations
):EVM的指令集,如:ADD
、MLOAD
、SSTORE
、CALL等
,每条指令都需要消耗相应的 gas。 -
可用
gas
(Gas Avaliable
):当前交易还剩下多少Gas
可用,每条指令都会从这里扣除gas
。 -
操作数栈(
stacks
):所有的操作都需要从这里读取和写入。例如ADD
指令会弹出两个值,计算后再压入一个结果。 -
临时可变内存(
Memory
):用于存储合约运行时的临时数据处理,例如引用类型的局部变量(基本类型在栈上)、ABI
编码、函数参数处理、动态数组等。用完释放,写入内存消耗较低gas
,读取免费。 -
账户存储(
Storage
):用于持久化合约的状态变量,每个合约都有一份自己的storage
。是一个key-value
类型的映射,可以类比与数据库。底层采用MPT
树来进行存储。读写都需要gas
,写操作更昂贵。
2. ☀️栈中内存布局
以太坊栈是一种先进先出的结构。
- 每个元素单位是 32byte
(256bit
),最大深度为 1024
个元素。
- 栈中只有最上面的
16
个元素能被访问到(超过16
个会出现stack too deep
错误),因为EVM
是通过DUP
复制和SWAP
交换 这两个操作码来进行栈内存交互的,最大只能访问到栈顶16
个。
contract StackTooDeepDemo {
function tooManyVariables() public pure returns (uint256) {
uint256 a = 1;
uint256 b = 2;
uint256 c = 3;
uint256 d = 4;
uint256 e = 5;
uint256 f = 6;
uint256 g = 7;
uint256 h = 8;
uint256 i = 9;
uint256 j = 10;
uint256 k = 11;
uint256 l = 12;
uint256 m = 13;
uint256 n = 14;
uint256 o = 15;
uint256 p = 16;
// 添加一个额外变量,就触发 stack too deep(取决于编译器)
uint256 q = 17;
return a + b + c + d + e + f + g + h + i + j + k + l + m + n + o + p + q;
}
}
3.🐬 合约状态变量存储布局
-
在
EVM
中,合约的状态变量存储通过key-value
的形式在EVM
的外部,即一棵MPT
的树形结构中。 -
每个状态变量都分配一个槽(
slot
),一个槽的大小为32byte
(256bit
) -
多个连续小变量会被打包进一个槽中(内存对齐)
- 总共有
2e256
个槽,这是一个非常大的数值,所以并不用担心容量不够的情况出现(更担心的是不够钱付gas
)
1. 🐟定长类型
在 solidity
中,值类型所占存储是固定的。有 bool
、uint
、int
等。在编译合约时,编译器会严格按照我们编写的顺序进行设定槽位,并不会给我们自动优化。
contract NoPacking {
uint128 a = 1; // 16 字节
uint64 b = 2; // 8 字节
uint128 c = 3; // 16 字节
}
上面代码一个占用了三个槽位。由于每个槽位只能存储32字节的数据,但是由于 uint128
和 uint64
类型不同,并不能做到内存的合并。
contract WithPacking {
uint128 a = 1; // 16 字节
uint128 c = 3; // 16 字节
uint64 b = 2; // 8 字节
}
如果是这样写,可以优化到占用 2
个槽位。变量 a
和变量 b
会被打包合并在一个槽位进行存储。相对于第一种写法,节省了一个槽位的空间。这就是 gas 优化的重要方向。
2. 🤡动态数据
1. 👬🏻字符串
- 字符串类型属于动态类型,如果这个字符串的大小整体 <
31
字节(最后一个字节保存长度),则会被直接存储在某个slot
中。例如:
string public s = "hello world";
- 但如果这个字符串的大小 >
31
字节,则:- 在这个固定的
slot
中,存储这个字符串的长度 + 动态标识
。 - 实际数据存在
keccak256(这个槽的下标)
这个slot
中。
- 在这个固定的
2. 🪐动态数组
- 动态数组和字符串的存储类似
- 固定的
slot
中,保存动态数组的长度
- 真是数据存储在
keccak256(这个槽的下标)
以及后续的连续 slots
中
- 固定的
uint[] public arr = [1, 2, 3];
3. 🌛字典Mapping
对于字典 Mapping
类型,solidity
是不会存储 key
的!!!而对于 mapping
类型所占用的槽位来说,是通过以下方式来进行计算:
1. 一个 mapping
占用一个 slot
,称为 base_slot
,但这个 slot
是空的,不存储任何信息。
2. 真实的 mapping
对应的信息是通过 keccak(abi.encode(key, base_slot))
来确定这个 key
对应的 value
所在的真实槽位。
contract Demo {
mapping(uint => uint) public data; // 假设 data 占用 slot 0
}
在存储data[5] = 123
的时候,底层:
slot = keccak256(abi.encode(5, uint256(0))) // 是一个storage中的地址
storage[slot] = 123; // 通过地址保存数据
4. ❓为什么字典Mapping不能遍历
- 因为
solidity
考虑到gas
成本,在mapping
的实现中,不保存map
的key
,而是通过hash
函数来通过key
来直接存储和获取value
的。所以mapping
结构无法进行遍历操作。
slot = keccak256(abi.encode(5, uint256(0))) // 是一个storage中的地址
storage[slot] = 123; // 通过地址保存数据
- 如果需要实现类似遍历的效果,需要手动维护一个数组来实现。
5. ❓动态类型使用 keccak256 不怕碰撞导致数据覆盖吗?
keccak256
哈希函数,256
位的输出,碰撞概率极低,可以近似认为不会出现碰撞的情况。(与私钥的碰撞类似,近似认为不会发生碰撞)- 对于
mapping
类型,即使key
相同,slot
不同,也不会发生冲突。因为在使用keccak256()
时,需要传入key
和slot
来进行hash
。
2. 💰Gas费优化思路
1.🚀 Gas成本高的操作
- 读写在合约存储的状态变量
- 外部函数调用
- 循环操作
2.📈 优化方案
1. 减少状态变量的使用。
状态变量 storage 在 gas 的消耗上,远高于 memory 内存的消耗。
- 将永久数据存储在内存中。
- 减少修改的次数:通过将中间状态保存在内存中,最后一次性保存到状态变量上。
2. 变量打包(内存对齐)
如我们上面提到的,我们对状态变量的编写方式,会影响到槽位的占用。我们可以通过手动内存对齐的方式,来降低 slot 的占用,达到节省 gas 费的效果。
3. 数据类型优化
一个变量可以有多种数据类型来表示,例如 uint8 和 uint256。由于我们 EVM 采用的是 256bit 来操作数据的。所以在 EVM 的内部,还会进行一次的单位转化。所以,在不考虑变量打包的情况下,uint256 所消耗的 gas 甚至还会更低一点(但如果进行变量打包, 那变量打包的操作所节省的 gas 会更优)
4. 使用固定大小变量
一般来说, 固定类型的变量消耗的 gas 会比动态类型来得低。如果数据可以控制在 32 字节内,建议使用 bytes32 来替代 bytes或者strings。尽量选择最小的数据类型来存储数据。
5. 数组和映射
映射在大多数情况下,gas 费会更低。如果在数组和映射都可以存储数据的情况下,建议优先选择数组来存储数据。
6. calldata和memory
函数参数中的变量可以声明为calldata和memory。memory为可变的参数,而calldata只读参数。如果这个参数在函数中不会发生变动,优先使用calldata会更加节省gas费。
7. constant 和 immutable
constant 是常量,只能在声明时赋值。immutable是不可更改变量,只能在声明时赋值或者构造函数上赋值,一旦赋值,不可改动。两者都会写入到合约的字节码上,相对于memory和storage中的数据,消耗的gas费更低。在允许的情况下,优先使用两者。