EVM 的整个空间被逻辑上划分为不同的区域,分别为栈(Stack)、内存(Memory)、存储(Storage)、代码(Code)和常量区域。
栈区域(Stack)区域
- 特点:
- 栈是一个 固定大小 的数据区域,最大为 1024 个字(每个字 32 字节)。
- 用于存储操作数、临时值,以及函数调用的返回地址。
- 数据只能通过 push 和 pop 操作访问。
- 作用: 函数内部的临时变量和操作数首先存储在栈中。
内存 (Memory)区域
- 功能: 用于临时存储数据,生命周期仅在交易或调用期间有效。
- 地址范围: 逻辑地址从
0
开始,可以按需扩展,理论上可以扩展到2^256 - 1
。 - 特点:
- 按 32 字节(256 位)对齐。
- 初始化时为空(零值)。
- 按需分配时会增加 gas 消耗。
- 作用: 用于存储函数的局部变量、动态数组、字符串、函数参数等。
数据调用区域(Calldata)
- 特点:
- 是不可修改的只读区域,用于传递外部函数调用的输入数据。
- 效率高,Gas 消耗低。
- 作用:效率高,Gas 消耗低。
存储 (Storage)区域
- 地址范围:存储区域的地址范围是从 0 到 2^256 - 1。
- 存储单元:每个存储槽(storage slot)大小为 32 字节(256 位)。
- 访问方式:存储按照 键值对的形式 存储,每个键为 256 位的存储槽地址,每个值为 256 位的存储内容。
- 作用:存储合约的全局状态。
Storage Layout (存储布局)
存储状态布局涉及 合约的存储变量如何映射到 EVM 的存储空间。EVM 的存储空间是一个巨大的 256 位地址空间,采用键值对形式。每个存储槽(slot)存储 32 字节数据。
EVM 的存储区域采用 哈希映射结构 存储数据:
- 简单变量:直接按照顺序存储,每个变量占用一个或部分存储槽。
- 复杂数据结构:
- 数组:第一个槽存储长度,数据存储在
keccak256(slot)
地址开始的连续存储槽中。 - 映射:数据存储在
keccak256(key, slot)
地址处,key 是映射键,slot 是映射变量在合约中的存储槽编号。 - 结构体:将结构体的成员顺序存储,每个成员分配单独的存储槽。
- 数组:第一个槽存储长度,数据存储在
存储分配规则
基础规则
- 变量顺序存储: 每个状态变量按声明顺序分配存储槽,优先填充当前槽剩余空间。
- 一个槽的大小为 32 字节。
- 如果变量类型大小超过槽大小(如 uint256),则占据一个完整槽。
- 较小变量(如 uint8、bool)在同一槽中紧密打包,但不能跨槽。
映射(Mapping)
- 映射的键值对(key-value)不会紧密打包在槽中。
- 每个键(key)对应的值(value)存储在一个唯一的槽中,其地址由
keccak256(key, slot)
计算 - 键(key)不会存储在存储区域中,键值(key-value)对中的“键(key)”并没有单独存储的槽。所以无法直接遍历Mapping
动态数组与字符串
- 元数据存储:动态数组的主槽(主存储槽)中存储的是数组的长度。
- 数据存储:数组的实际数据存储在与主槽分开的连续存储槽中。
- 数据槽的起始地址由 keccak256(slot) 计算得出,slot 是动态数组的主槽编号。
- 数组中的每个元素按照顺序依次存储在数据槽中。
- 字符串与动态数组处理相似。
结构体(Struct)
- 结构体的变量按顺序存储。
- 结构体变量紧密打包(类似单个槽中存储的变量)。
1 | pragma solidity ^0.8.0; |
执行命令 solc --storage-layout --transient-storage-layout StateLayoutExample.sol
查看合约存储布局
1 | { |
storage
描述每个状态变量的存储位置:
- slot
: 存储槽号。
- offset
: 偏移量,表示变量在槽内的起始位置。
- type
: 变量的类型标识,与 types 部分中的描述对应。
types
定义每种数据类型的详细信息:
- encoding
: 数据的存储方式(如 inplace 或 dynamic_array)。
- numberOfBytes
: 占用的字节数。
- members
: 如果是结构体,列出所有成员及其存储位置。
- base
: 如果是数组,表示基础类型。
Memory Layout (内存布局)
在 Solidity 中,内存(Memory) 是 EVM 提供的一种线性存储结构,主要用于在函数调用过程中存储临时变量、局部变量和中间计算结果。内存是按需分配的,不同于存储(Storage),内存是 瞬时的,在调用结束后会被释放,且读写成本较低。
内存是一个 按字节寻址的线性空间,地址范围从 0x00 到无限大(理论上为 2^256 - 1)。但在实际操作中,内存的分配是动态增长的,EVM 会根据需要分配内存,并且以 32 字节(一个字)为单位扩展。
- 每个内存地址存储一个字节。
- 每 32 个字节组成一个字(word),这是 EVM 操作的基本单位。
内存布局的设计遵循以下原则:
- 按需动态分配:
- Solidity 会在需要时从内存中分配新的空间。
- 起始空闲内存地址存储在 0x40,可通过 mload(0x40) 获取。
- 按 32 字节对齐:
- 内存分配是以 32 字节为单位对齐的,即使是 bool 等小数据类型也会占用 32 字节。
- 变量存储位置:
- 固定大小类型直接存储其值。
- 动态类型存储一个指针,指向实际数据的内存地址。
固定分配区域
Solidity保留了四个32字节的插槽,具体的字节范围(包括端点)使用如下:
- 0x00 ~ 0x3f(64Byte)
- 用于哈希方法的临时空间
- 临时空间可以在语句之间使用(即在内联汇编之中)。
- 0x40 ~ 0x5f (Free memory pointer 32Byte):
- mstore(0x40, …) 用于指向当前空闲内存的起始地址。
- Solidity 使用内存时,会从该地址开始分配。
- 开发者不需要手动管理,但在使用汇编时,需要注意维护 0x40 的正确值。
- 0x60 ~ 0x7f(Zero pointer 32Byte):
- 保留一块全零的内存区域,通常用于返回零值。
- 0值插槽则用来对动态内存数组进行初始化,且永远不会写入数据 (因而可用的初始内存指针为 0x80)。
Solidity 总会把新对象保存在空闲内存指针的位置, 所以这段内存实际上从来不会空闲(在未来可能会修改这个机制)。
动态分配区域
Solidity 的局部变量、动态数组和字符串都从 0x80 开始动态分配,并按照实际需要扩展。
分配规则:
- 内存分配以 32 字节为单位对齐。
- 临时变量直接写入内存对应的位置。
- 动态类型需要额外存储元数据(如长度和起始地址)。
内存中的数据类型布局
固定大小的类型
- 包括 uint256、address、bool 等。
- 直接按 32 字节对齐存储,例如:
- uint256 占用 32 字节。
- bool 也占用 32 字节(尽管实际值只需 1 字节)。
动态类型
动态类型(如 string、bytes 和动态数组)会分为两个部分:
- 指针部分:主变量存储的是指向实际数据的偏移量(相对于起始地址 0x80 的偏移量)。
- 数据部分:存储动态类型的元数据(如长度)和实际数据。
1 | pragma solidity ^0.8.0; |
byteArray
:- 主变量存储数据偏移量,例如 0x80。
- 数据存储从 0x80 开始:
- 长度(10 字节):存储在 0x80。
- 实际内容(10 字节):从 0xA0 开始。
str
:- 主变量存储数据偏移量,例如 0xE0。
- 数据存储从 0xE0 开始:
- 长度(14 字节):存储在 0xE0。
- 实际内容(”Hello, Memory!”):从 0x100 开始。
State Layout 和 Memory Layout 的区别
特性 | 状态布局 (State Layout) | 内存布局 (Memory Layout) |
---|---|---|
存储位置 | 持久化存储在 EVM 存储 (Storage) 中 | 函数调用时临时存储在 EVM 内存 (Memory) 中 |
生命周期 | 持久化,合约销毁之前一直存在 | 临时,函数执行结束即释放 |
成本 | 写入存储需要高 Gas 成本 | 使用内存成本较低 |
用途 | 存储合约的持久化数据 | 处理临时计算或函数的中间结果 |
Gas 优化建议
- 状态变量优化
- 紧密打包变量,减少存储槽的使用。
- 避免在循环中频繁读写存储。
- 内存优化
- 使用临时变量代替状态变量进行中间计算。
- 适时释放不再使用的内存。
了解 Solidity 的状态与内存布局,能帮助开发者高效设计合约,同时优化 Gas 成本。