EVM(以太坊虚拟机)的存储本质上是一个巨大的键值映射:mapping(uint256 => uint256)。具体来说:
这种设计让每个合约理论上能访问高达 2^256 个存储槽,每个槽位(slot)可存储32字节的数据。初始状态下,所有未使用的槽值均为0。
与内存(Memory)不同,存储是持久化的,会永久保存在区块链上,直到合约通过 selfdestruct 被销毁。这也解释了为何存储操作成本高昂——你实际上是在为永久存储付费。
举个栗子🌰:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StorageExample {
uint256 a = 1; // 占用槽0
address b = 0xbbbbbbbbbbbbbbbbbbbb; // 占用槽1
bool c = true; // 占用槽2
// ...依次类推
}
在合约中,每个状态变量都会被编译器分配到一个或多个存储槽。对于基本数据类型,编译器通常按声明顺序,从槽0开始依次分配。
EVM提供了两个基本操作码来操作存储槽:
SLOAD(slot):读取指定槽的值,slot 是槽编号。SSTORE(slot, value):向指定槽写入值。在Yul(内联汇编)中,这两个操作码可以这样使用:
assembly {
let slot := 0x0 // 假设访问槽0
let value := sload(slot) // 读取
sstore(slot, value) // 写入
}
我们来验证一下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StorageExample {
uint256 public a = 1; // 占用槽0
address public b = 0xbbbbbbbbbbbbbbbbbbbb; // 占用槽1
bool public c = true; // 占用槽2
// 输入槽号,返回对应值
function getStorageSlot(uint256 slot) public view returns (uint256) {
uint256 value;
assembly {
value := sload(slot)
}
return value;
}
}
在这个例子中,getStorageSlot 函数允许你输入槽号并返回对应值。通过调用它,你可以轻松验证存储槽的内容。
基础数据类型包括 uint8 到 uint256、address 和 bool。仍以上面合约为例:
contract StorageExample {
uint256 a = 1; // 占用槽0
address b = 0xbbbbbbbbbbbbbbbbbbbb; // 占用槽1
bool c = true; // 占用槽2
}
你会发现,每个变量都占用了整整一个槽。但这其实很浪费:bool 只需1字节,address 只需20字节,剩下的字节就被白白占用了。有没有办法优化呢?答案是有的,接下来看看存储槽的打包机制。
Solidity编译器会自动将多个小于32字节的变量打包到同一槽中,以节省Gas:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract OptimizedStorage {
bool a; // 占用槽0的1字节
uint64 b; // 占用槽0的8字节
address c; // 占用槽0的20字节
uint256 d; // 占用槽1的32字节(无法再打包)
}
我们用Yul(内联汇编)验证一下打包效果:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StoragePacking {
bool a; // 占用槽0的1字节
uint64 b; // 占用槽0的8字节
address c; // 占用槽0的20字节
uint256 d; // 占用槽1的32字节
function verifyPacking() public view returns (bytes32, bytes32) {
bytes32 slot0;
bytes32 slot1;
assembly {
slot0 := sload(0) // 读取槽0
slot1 := sload(1) // 读取槽1
}
return (slot0, slot1);
}
function setValues(bool _a, uint64 _b, address _c, uint256 _d) public {
a = _a;
b = _b;
c = _c;
d = _d;
}
}
调用 setValues(true, 123456789, 0x1234...5678, 9999) 后,再调用 verifyPacking(),你会发现 slot0 包含了 a、b、c 的打包值,而 slot1 只存储了 d。
简单来说,mapping 的存储位置是通过键和槽号计算得出的,不会直接占用声明的槽。
mapping 是Solidity中最独特的数据结构之一:
mapping(address => uint256) public balances; // 槽n
假设它分配到槽n,访问 balances[addr] 时,实际存储位置是:
keccak256(abi.encode(addr, uint256(n)))
也就是说,槽n本身只是一个“入口点”,不存数据(值为0),键值对分散存储在哈希计算出的位置。
我们来验证一下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StorageExample {
mapping(address => uint256) public balances; // 槽0
function setBalance(address user, uint256 amount) external {
balances[user] = amount;
}
function getBalance(address user) public view returns (uint256) {
uint256 userBalance;
assembly {
// 计算存储位置
// 加载到暂存空间(scratch space) 0x00-0x3F
mstore(0, user) // 在内存起始位置存储用户地址
mstore(32, 0) // 在内存中存储mapping的槽位(0)
let slot := keccak256(0, 64) // 计算实际存储位置
userBalance := sload(slot) // 加载数据 注:balance在Yul中是语法关键词,不能用作变量名
}
return userBalance;
}
// 查看槽0中的内容,啥也没有,值为0
function viewSlot0() public view returns (bytes32) {
bytes32 slotContent;
assembly {
slotContent := sload(0)
}
return slotContent;
// 返回0x0000000000000000000000000000000000000000000000000000000000000000
}
}
动态数组的存储位置基于槽号和索引计算,也不直接用槽n存数据。它的结构是:
keccak256(n) 开始顺序存储。uint256[] public values; // 槽n
例如:
keccak256(n) + 0、keccak256(n) + 1 等。我们来验证一下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StorageExample {
uint256[] public values; // 槽0
function pushValue(uint256 value) public {
assembly {
let slot := 0 // 数组在槽0
let length := sload(slot) // 读取长度
// 计算数组数据起始位置
mstore(0, slot)
let dataSlot := keccak256(0, 32)
// 存储新元素
sstore(add(dataSlot, length), value)
// 更新数组长度
sstore(slot, add(length, 1))
}
}
// 查看槽0中的内容,存储的是数组的长度
function viewSlot0() public view returns (bytes32) {
bytes32 slotContent;
assembly {
slotContent := sload(0)
}
return slotContent;
// 调用pushValue方法两次后,
// 返回0x0000000000000000000000000000000000000000000000000000000000000002
}
}
结构体在存储中占用连续槽位,从声明位置开始:
struct Config {
uint256 maxValue; // 32字节
address owner; // 20字节
uint64 timestamp; // 8字节
bool active; // 1字节
}
Config public config; // 槽n
布局如下:
maxValue 占槽n。owner、timestamp 和 active 打包在槽n+1。我们来验证一下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StorageExample {
struct Config {
uint256 maxValue; // 32字节
address owner; // 20字节
uint64 timestamp; // 8字节
bool active; // 1字节
}
Config public config; // 槽0
// 设置配置信息
// 假设传入:1,0x1234567890123456789012345678901234567890,1311768467463790320,true
function setConfig(uint256 _maxValue, address _owner, uint64 _timestamp, bool _active) external {
config.maxValue = _maxValue;
config.owner = _owner;
config.timestamp = _timestamp;
config.active = _active;
}
// 添加一个辅助函数来显示原始存储值
function debugStorage() public view returns (bytes32, bytes32) {
bytes32 slot0;
bytes32 slot1;
assembly {
slot0 := sload(0)
slot1 := sload(1)
}
return (slot0, slot1);
// 0x0000000000000000000000000000000000000000000000000000000000000001
// 0x00000001123456789abcdef01234567890123456789012345678901234567890
// 分析:
// 槽0: 0x0000000000000000000000000000000000000000000000000000000000000001 - 这是maxValue的值,即1
// 槽1: 0x00000001123456789abcdef01234567890123456789012345678901234567890 - 这是打包的owner、timestamp和active
// 我们可以看到Solidity编译器是如何打包这些值的:
// 最右边(低位)是owner: 0x1234567890123456789012345678901234567890
// 中间是timestamp: 0x123456789abcdef0 即 1311768467463790320
// 最左边(高位)是active: 0x00000001(true)
}
// 用Yul读取配置,但使用与Solidity相同的存储布局
function getConfig() public view returns (uint256, address, uint64, bool) {
uint256 maxValue;
address owner;
uint256 timestampRaw;
bool active;
assembly {
// 读取第一个成员 - maxValue
maxValue := sload(0)
// 读取打包的成员
let packedValue := sload(1)
// 从debugStorage我们可以看到owner在最低位,占20字节
owner := and(packedValue, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
// timestamp在owner之后,占用8字节,需要右移160位(20字节)
timestampRaw := and(shr(160, packedValue), 0xFFFFFFFFFFFFFFFF)
// active在最高位,占用1字节,需要右移224位(28字节)
active := and(shr(224, packedValue), 0x1)
}
// 在assembly块外进行类型转换
uint64 timestamp = uint64(timestampRaw);
return (maxValue, owner, timestamp, active);
}
}
小结:mapping 靠哈希分散存储,数组和结构体则更规律,分别基于计算位置和连续槽位。
存储操作是Gas消耗大户,规则如下:
注意:退款在交易结束时结算,且不超过总Gas消耗的50%。这些规则基于 EIP-2929,未来可能随升级调整。
看看这个例子:
contract Wasteful {
struct Tiny {
uint8 a;
bool b;
}
Tiny[] public records;
function addRecord(uint8 _a, bool _b) public {
records.push(Tiny(_a, _b));
}
}
每次 addRecord 需20,000 Gas,却只存9位数据(8位uint8 + 1位bool),非常浪费。
优化版本:
contract Efficient {
uint256[] public packedRecords;
function addRecord(uint8 _a, bool _b) public {
// 将a和b打包成一个uint256
uint256 packed = (uint256(_a) << 1) | (uint256(_b ? 1 : 0));
packedRecords.push(packed);
}
function getRecord(uint256 index) public view returns (uint8 a, bool b) {
uint256 packed = packedRecords[index];
a = uint8(packed >> 1);
b = (packed & 1) == 1;
}
}
优化后,一个槽能存更多数据,Gas消耗大幅降低。
合理排序变量能优化布局:
// 未优化
contract Unoptimized {
uint128 a; // 槽0
uint256 b; // 槽1
uint128 c; // 槽2
}
// 优化后
contract Optimized {
uint128 a; // 槽0前16字节
uint128 c; // 槽0后16字节
uint256 b; // 槽1
}
原则:小型变量放一起,方便打包。
除非必要,避免用大类型:
// 浪费
contract Wasteful {
uint256 smallNumber; // 只存0-100
}
// 优化
contract Efficient {
uint8 smallNumber; // 够存0-255
}
但注意:内存和栈上的小类型可能因对齐增加Gas,优化主要针对存储。
constant 和 immutable 不占槽:
contract Constants {
uint256 constant MAX_USERS = 1000; // 不占用存储槽,在编译时直接替换为值
address immutable owner = msg.sender; // 不占用存储槽,部署时确定并嵌入代码中
}
代理模式下注意冲突:
contract LogicV1 {
uint256 public value; // 槽0
}
contract LogicV2 {
address public admin; // 槽0,覆盖value!
uint256 public value; // 槽1
}
解决:用存储槽继承或OpenZeppelin的Storage库。
推荐工具:
hardhat-storage-layout 插件。-storage-layout 选项。掌握存储槽是写高效合约的第一步。合理的布局设计能降低Gas成本,还能规避安全隐患。
希望这篇干货对你有帮助,快去优化代码吧!有问题或心得,欢迎留言告诉我~
基于全文检索与主题相似度
关注我,带你转型Web3开发,分享我从2018年入行区块链以来的开发经验(DeFi/GameFi/NFT/DAO/OTC/钱包/Trading Bot/MEME Launch) 说在前面 内存管理在智能合约开发中可是个关键角色,直接决定着gas成本和代码效率。 这篇文章,我想带你走进EVM内存的世界
说在前面 想象一下,你正在搞一个NFT交易平台,写了个合约来记录交易细节,比如: 写着写着,你可能冒出个想法:能不能再加个indexed参数,比如交易时间?结果发现不行,Solidity直接告诉你:事件最多只能有3个indexed参数。 这是怎么回事呢?别急,这篇文章我将带你搞清楚这个限制的来龙去脉
关注我,带你转型Web3开发,分享我从2018年入行区块链以来的开发经验(DeFi/GameFi/NFT/DAO/OTC/钱包/Trading Bot/MEME Launch),免费领取我私下整理的“智能合约学习”资料一份,帮你少走弯路! 说在前面 在Solidity开发中,搞懂数据存储位置就像掌握