写在前面
刚开始学 Solidity 的时候,我觉得 Storage、Memory、Calldata 不就是"变量放哪儿"的区别嘛,能有多复杂?直到我写了一个 Token 合约,改了个用户的余额,链上查的时候发现余额纹丝不动——代码没报错,测试也过了,但数据就是没写进去。
后来排查了半天才发现:我把 storage 写成了 memory,改的是副本,链上的数据根本没动。
这件事之后我才认真去搞 EVM 的存储模型到底是怎么回事。这篇文章就是那时候的笔记,把踩过的坑和学到的东西都记下来。
一、先搞清楚三者的本质
| 维度 | Storage | Memory | Calldata |
|---|---|---|---|
| 生命周期 | 永久写入区块链 | 函数执行期间临时存在 | 交易数据,只读不可修改 |
| Gas 成本(写) | 20,000 | 3/字 | 不可写 |
| Gas 成本(读) | 2,100 | 几乎免费 | 几乎免费 |
| 典型用途 | 状态变量、用户余额 | 临时变量、函数内部计算 | 外部函数的数组/字符串参数 |
一句话总结:Storage 是硬盘,Memory 是内存,Calldata 是只读缓存。记住这个,后面选起来就清楚了。
二、Storage:写进去就是钱
任何需要跨交易保留的数据,只能放 Storage。没有替代方案。
状态变量天然在 Storage 里:
contract Bank {
// 这些状态变量自动存在 Storage 里,交易结束数据还在
mapping(address => uint256) public balances;
address public owner;
uint256 public totalDeposits;
function deposit() external payable {
balances[msg.sender] += msg.value; // 写 Storage,20000 Gas,肉疼但必须花
totalDeposits += msg.value;
}
}
用户存了钱,下次来必须还能查到余额——这就是 Storage 的价值。
但反过来,不要把临时计算结果往 Storage 里塞:
contract BadExample {
uint256 public tempResult; // ❌ 这玩意儿没必要永久存链上
function calculate(uint256 a, uint256 b) external {
tempResult = a + b; // ❌ 每次调用花 20000 Gas 存一个临时结果
}
}
tempResult 就是个中间结果,没有跨交易保留的必要。改成 pure 函数,一分钱 Storage 费用都不用花:
contract GoodExample {
// ✅ 纯计算,不碰 Storage,Gas 几乎为零
function calculate(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
}
三、Memory:函数结束就消失
Memory 用于函数执行期间的临时数据。函数返回,数据消失,链上不留痕迹。
我踩的那个坑
就是开头说的那个事儿,简化一下大概长这样:
contract BadToken {
struct User {
uint256 balance;
uint256 lastDeposit;
}
mapping(address => User) public users;
function updateLastDeposit(address user) external {
// 这里用 memory,创建的是 Storage 数据的副本
User memory u = users[user];
// 改的是副本!链上的 users[user] 纹丝不动
// 代码能跑,测试能过(如果你没重新读 Storage 验证的话)
// 但数据就是没写进去——静默错误,最恶心
u.lastDeposit = block.timestamp;
}
}
修复方法:把 memory 换成 storage,变成引用:
contract GoodToken {
struct User {
uint256 balance;
uint256 lastDeposit;
}
mapping(address => User) public users;
function updateLastDeposit(address user) external {
// storage 引用,u 就是 users[user] 本身
User storage u = users[user];
u.lastDeposit = block.timestamp; // ✅ 直接改链上数据
}
}
这个区分一定要记住:
storage= 引用,修改直接影响链上memory= 副本,修改只在函数内有效- 不写关键字时,结构体/数组默认看上下文——状态变量是
storage,局部变量是memory
四、Calldata:最便宜但只读
Calldata 是交易数据的存储区域,只读、不可修改。它是三种位置中最便宜的。
外部函数的数组参数,默认就该用 calldata:
contract BatchTransfer {
// calldata 直接引用交易数据,不复制,省 Gas
// 传 100 个地址进来,省掉 100 次 Memory 分配
function batchSend(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
// 直接读 calldata,不占 Memory
}
}
}
如果写成 memory,交易数据会被完整复制一份。外部函数只读不改,这份复制纯粹浪费钱:
contract BadBatch {
// ❌ memory 会把整个数组复制一份到 Memory
// 100 个地址 = 3200 字节,白白多花的 Gas
function batchSend(
address[] memory recipients,
uint256[] memory amounts
) external {
}
}
当然,如果你需要修改输入数据,那只能复制到 Memory:
contract SortExample {
// calldata 不可修改,要排序只能复制到 memory
function sort(uint256[] memory data) external pure returns (uint256[] memory) {
for (uint256 i = 0; i < data.length; i++) {
for (uint256 j = i + 1; j < data.length; j++) {
if (data[i] > data[j]) {
(data[i], data[j]) = (data[j], data[i]);
}
}
}
return data;
}
}
五、Storage 引用的 Gas 优化
搞明白 storage 引用之后,我发现这不只是正确性的问题,还能省不少 Gas。
一次定位 vs 多次定位
contract MultiUser {
struct User {
uint256 balance;
bool isActive;
}
mapping(address => User) public users;
function updateUser(address user, uint256 newBalance, bool active) external {
// 一次 storage 定位(2100 Gas),后续字段修改都在这一次定位内完成
User storage u = users[user];
u.balance = newBalance; // ✅ 不用再花 Gas 定位
u.isActive = active; // ✅ 同上
}
}
如果不引用,每次都重新定位:
contract Inefficient {
mapping(address => uint256) public balances;
function update(address user) external {
balances[user] += 10; // ❌ 第一次定位
balances[user] += 20; // ❌ 第二次定位
balances[user] += 30; // ❌ 第三次定位
// 三次独立的 Storage 读写,每次都花 Gas
}
}
更好的做法是缓存到 Memory 再写回:
contract Efficient {
mapping(address => uint256) public balances;
function update(address user) external {
uint256 temp = balances[user]; // ✅ 只读一次 Storage
temp += 10;
temp += 20;
temp += 30;
balances[user] = temp; // ✅ 只写一次 Storage
}
}
六、决策流程
遇到一个新变量,按这个顺序判断就行:
- 需要跨交易保留? → Storage
- 只是函数内部临时用? → Memory
- 外部传入的只读数组/字符串? → Calldata
- 需要修改外部传入的数据? → Memory(复制后修改)
本文代码基于 Solidity 0.8.x 版本。