一个重入锁写在 storage 里,冷槽首次从 0 写到 1 要付 2100 的冷访问附加费加 20000 的 Gsset,同一交易内再写回 0 再付 100,毛消耗约 22200 gas;这笔写入同时产生 19900 gas 退款,而退款上限是整笔交易消耗的 1/5。换成一枚 transient storage 变量,两次写入各 100 gas,且不依赖退款计数器。功能完全一致,毛成本相差两个数量级上下。
EVM 里有三类可写数据区:memory(内存)、storage(持久化存储)与 transient storage(瞬态存储,EIP-1153)。它们都用 32 字节字读写,都能在合约里被赋值,归属单位、存活时间与计价规则却互不相同。放错位置通常不会报错,只会让状态在预期之外消失,或者让 gas 账单高出十倍。要回答的问题是:三类存储各自由谁拥有、活多久、按什么规则计价,以及各自适合承载什么。
生命周期:帧、账户与交易是三个不同尺度
Memory 归执行帧(execution frame)所有。一次 CALL、DELEGATECALL、STATICCALL 或 CREATE 进入的上下文就是一个帧,帧进入时内存从零开始,帧返回或回滚后整块丢弃。父帧的内存不会被子帧看到,子帧的内存也不会回传给父帧;要在两帧之间传数据,只能走 calldata 与 returndata。
Storage 归账户所有。它以 256 位槽位到 256 位值的映射挂在账户名下,写进去的内容进入账户的 storage trie,成为世界状态的一部分,跨交易、跨区块长期存在。零值不会被写入 trie,所以把槽位清零会移除对应节点,这一点直接决定了退款规则的设计。
Transient storage 同样归账户所有,但作用域是一条交易。同一笔交易内,该账户的所有帧共享同一份瞬态存储:内层调用写入的值,外层调用读得到。帧回滚时,该帧范围内的写入一并回滚,行为与 storage 一致;而帧正常返回时不会回滚,这一点与 memory 相反。交易结束时,所有瞬态存储无条件清零。归属规则还有一处例外:DELEGATECALL 与 CALLCODE 的瞬态存储归调用方(发起指令的合约),CALL 与 STATICCALL 则归被调用方。
三个尺度可以这样记:内存以帧为单位,storage 以账户加永久为单位,transient storage 以账户加交易为单位。
| 维度 | Memory | Storage | Transient Storage |
|---|---|---|---|
| 归属 | 执行帧 | 账户 | 账户 |
| 存活时间 | 帧进入时创建,帧结束丢弃 | 持久,写入世界状态 | 交易结束清零 |
| 寻址 | 字节地址,按 32 字节字扩展 | 256 位槽位 | 256 位槽位 |
| 跨内部调用 | 不共享 | 共享 | 同账户所有帧共享 |
| 帧回滚 | 随帧丢弃 | 回滚该帧写入 | 回滚该帧写入 |
| 单次写入成本 | 3 gas 加扩展费 | 100 至 20000 加冷访问费 | 固定 100 gas |
Memory:按字扩展,扩容成本是二次的
Memory 按字节寻址,但分配以 32 字节字为粒度。访问一个尚未触及的字会触发扩展,扩展费按总占用计算,公式是 C_mem(a) = 3a + ⌊a² / 512⌋,a 是字数;实际扣费为扩展后的 C_mem 减去扩展前的 C_mem。MSIZE 只增不减,帧内无法释放已分配的内存。
这个式子前一半是线性项,后一半是二次项。a < 23(即 704 字节)时二次项被向下取整为 0,成本看着温和;越过这个点之后增长加快。下面两个绝对数值都按黄皮书式 (328) 推算,基准是单帧从空内存一次性扩展到位,不含 MLOAD、MSTORE 的基础成本:32 KB 内存对应 a = 1024,扩展费 3 × 1024 + 1024² / 512 = 5120 gas;1 MB 内存对应 a = 32768,扩展费 98304 + 2097152 ≈ 219 万 gas。单个帧仅凭扩展内存就能消耗掉数百万 gas,这通常会成为帧内堆大缓冲区的硬约束。
MLOAD 与 MSTORE 的基础成本是 3 gas(Gverylow),扩展费另计。读取从未写过的内存会返回 0,但仍然要为新触及的字支付扩展费,因为分配动作已经发生。这也是稀疏写入高地址特别贵的原因:只写一个值到很远的偏移,中间所有字都按已分配计费。
临时数组、ABI 编解码缓冲、哈希函数的输入都放在 memory 里。Solidity 中的 memory 变量、memory 数组与 memory 结构体都落在这块区域。下面这段代码把长度一次分配好,避免在循环中反复触碰新字:
// 一次性扩展到 n 个字,之后写入不再产生扩展费
uint256[] memory buf = new uint256[](n);
for (uint256 i = 0; i < n; ++i) {
buf[i] = i;
}
边界很清楚:memory 通过 CALL 传递给子帧的是内容副本,指针本身只在当前帧内有效。把跨帧共享的中间量放进 memory,等于假设子帧能看到父帧的内存,而这个假设不成立。
实际工程里更常见的选择是绕开 memory。大块数据用 calldata 传入、用 returndata 传出,成本按字节计价且不占用本帧内存;只有需要在帧内反复读写、或需要构造哈希输入时,才有必要把它展开到内存。这个取舍解释了为什么很多合约把计算放在外部脚本或子调用里,再通过返回值汇总,让单帧内的缓冲区保持小规模。
Storage:写进账户的 storage trie,写比读贵一个量级
世界状态里每个账户挂一棵 storage trie,槽位是 256 位键,值是 256 位字,零值不入树。读取走 SLOAD。根据 EIP-2929(柏林升级,区块 12,244,000,2021 年 4 月 15 日)引入的冷热访问机制,首次访问某个 (地址, 槽位) 对属于冷访问,收 2100 gas(Gcoldsload);已在本次交易中访问过的属于热访问,收 100 gas(Gwarmaccess)。冷热集合是交易作用域的,作用域回滚时集合也回滚。
写入走 SSTORE,成本由 EIP-2200 的净计量规则决定,同时看三个值:槽位在本交易开始时的原始值、当前值、即将写入的新值。柏林之后的常数是:冷槽位额外收 2100;原始值等于当前值(本交易尚未改过该槽)时,从 0 写成非零收 Gsset = 20000,从非零写成另一个值或写成 0 收 Gsreset = 2900;槽位已被本交易改过(原始值不等于当前值)时,只收一次热访问的 100 gas;新值等于当前值的空写也收 100。
退款规则被 EIP-3529(伦敦升级,区块 12,965,000,2021 年 8 月 5 日)收紧了。非零改写为零的退款从 15000 降到 4800(SSTORE_RESET_GAS 加 ACCESS_LIST_STORAGE_KEY_COST),SELFDESTRUCT 的退款被取消,单笔交易的总退款上限压到 gas_used // 5。原始值为 0、本交易内先写非零再写回 0 的模式仍然产生 19900 gas 退款(20000 减 100),但同样受 1/5 上限约束。
| 操作(柏林 / 伦敦口径) | Gas |
|---|---|
| SLOAD,冷访问 / 热访问 | 2100 / 100 |
| SSTORE,原始值等于当前值,0 写非零 | 20000,另加冷访问 2100 |
| SSTORE,原始值等于当前值,非零改非零或改零 | 2900,另加冷访问 2100;改零时退款 4800 |
| SSTORE,槽位已被本交易改过 | 100 |
| 重入锁 0 → 1 → 0(同一交易) | 毛 22200,退款 19900 |
跨交易要保持的东西放在 storage:余额、所有权、配置、累计计数器。代价有两层,一层是 gas,另一层是状态膨胀,所有全节点都要长期保存这些槽位。退款容易造成“写回 0 就免费”的错觉,实际口径是:退款只在交易结束后结算,且最多抵掉总消耗的 20%。一笔仅消耗 30000 gas 的交易,理论上只能拿回 6000 gas 的退款。
Transient Storage:跨内部调用有效,交易结束即清空
EIP-1153 在坎昆升级(区块 19,426,587,2024 年 3 月 13 日)引入 TLOAD(0x5c)与 TSTORE(0x5d)。寻址方式与 SLOAD、SSTORE 相同:32 字节地址指向 32 字节值。两者每次操作的固定成本都是 100 gas,没有冷热区分,没有退款,也不需要为将来的清除预留成本,因为在规范里它们从不落盘。
行为上与 storage 的差异集中在三处。时间尺度上,交易结束即清零,值不会被序列化到任何持久结构里。回滚语义上,帧回滚会回滚该帧的写入,这与 storage 一致,与 memory 不同(memory 在帧返回或回滚时整体丢弃)。上下文限制上,TSTORE 在 STATICCALL 中会抛异常,TLOAD 允许。另外,EIP-1153 明确豁免了 EIP-2200 对 SSTORE 的限制:TSTORE 不要求 gasleft 大于 2300 的调用补贴。
这套设计直接冲着“帧间通信”而来。在 EIP-1153 之前,合约之间传递临时状态要么走 CALL 的入参与返回值(中间的不可信合约可能篡改),要么走 storage 写入(贵,且依赖退款)。退款在 EIP-3529 被压到 gas_used 的 1/5 之后,小额交易基本收不回成本。一次 0 → 1 → 0 的锁写入向退款计数器累加 19900 gas,按 gas_used // 5 的上限反算,整笔交易需要约 99500 gas 才能把退款全额拿回;这个 99500 是按 EIP-3529 上限公式推算的整笔 gas_used,不是 EIP 原文给出的数值。EIP-1153 正文从另一个角度给出作者估计,原话是交易需在“其他操作”上花约 80k gas,才能拿到一把重入锁的全额退款。两个数字口径不同但互相吻合:99500 减去这把锁自身约 22200 gas 的毛消耗约为 77300,与作者对“其他操作”的 80k 估计属同一量级。瞬态存储不参与退款计数器,因而绕开了这个门槛。
重入锁、单交易授权、回调结束时的余额平衡检查、代理合约向下游传递元数据,都适合放在这里。Solidity 从 0.8.28 起支持 transient 值类型状态变量,EVM 版本需要设为 cancun;引用类型(数组、映射、结构体)以及局部变量、参数都不支持,需要手写内联汇编。下面是最小形态的重入锁:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28; // EVM 版本需为 cancun
contract TransientLock {
uint256 transient entered;
modifier nonReentrant() {
require(entered == 0, "reentrant");
entered = 1; // TSTORE,100 gas
_;
entered = 0; // 同一交易内的后续调用会读到 0
}
function withdraw() external nonReentrant {}
}
如果编译器版本或目标链不满足条件,可以直接用汇编,语义完全一致:
assembly {
tstore(0, 1) // 槽 0 写 1,100 gas
let v := tload(0) // 读回,100 gas
}
误用的四种典型后果
需要跨交易读取的状态放进 memory 或 transient storage,症状是“状态丢了”。交易结束后值清零,下一次调用读到默认值 0,代码不报错,业务逻辑却已经错了。这类 bug 在本地测试里常常看不出来,因为测试往往在同一笔交易或同一个模拟环境里完成。
只在单笔交易内有效的中间量放进 storage,症状是成本失控,而且成本取决于交易规模。重入锁与临时授权在功能上可以用 storage 实现,经济上却要等交易足够大才能收回退款;交易越小,实际净成本越接近毛成本。
跨调用共享的中间量放进 memory,症状是子帧读不到。CALL 会切换执行帧,子帧拿到的是独立的、从零开始的内存;父帧写入的内容不会出现在子帧里。唯一合法的传递路径是 calldata 与 returndata。
用 transient storage 替代 memory 里的映射,症状是重入时出现意外行为。EIP-1153 在安全考量里专门提醒过这一点:瞬态存储不会在调用返回时被丢弃,如果把它当内存映射用,同一交易内的重入调用会看到上一轮遗留的值。除了语义问题,单次 100 gas 的成本也远高于内存写入。
还有一种容易忽略的误用是忘记清零。重入锁写入 1 之后如果在某些分支上提前返回而不写回 0,同一交易内的后续调用会被这个锁永久挡住。EIP-1153 的规范说得很直白:只有在这些槽位确实要被交易内的后续调用使用时,才应该留下非零值。
边界与不确定的地方
正文所有 gas 数字都标了口径:柏林升级(2021 年 4 月 15 日,区块 12,244,000)之后的冷热访问价格、伦敦升级(2021 年 8 月 5 日,区块 12,965,000)之后的退款规则、坎昆升级(2024 年 3 月 13 日,区块 19,426,587)引入的瞬态存储。EVM 不是冻结规格,跨链部署前需要逐个确认目标链实现了哪些 EIP:停在伦敦或更早的 EVM 兼容链上,TLOAD 与 TSTORE 会直接作为非法操作码中止执行。
Solidity 的瞬态存储支持存在一个已修复的编译器缺陷:0.8.28 到 0.8.33 在启用 IR 管线(--via-ir)时,如果同一编译单元里既用 delete 清理瞬态变量,又存在对同值类型的持久化 storage 清理,生成的 Yul 清理辅助函数会因同名而被复用,从而发出错误的操作码(该用 TSTORE 时用了 SSTORE,或相反),0.8.34 修复。缺陷只影响 IR 管线,legacy 管线不受影响。用到瞬态变量且走 via-ir 的项目,编译版本需要落在修复区间之外。
有两件事没有公开的时间表,只能标为不确定。瞬态存储的 100 gas 是 EIP-1153 定下的当前值,未来硬分叉是否调整,目前没有公开排期,也没有已进入流程的提案。状态树的实现(例如 Verkle 树的推进)会改变 storage 读取的真实成本基础,EIP-2929 的动机里也写明,重设计数据库布局、让客户端直读存储会进一步压低最坏处理时间;但计价常量仍由 EIP-2929 与 EIP-3529 给定,两者之间可能长期存在偏差。这两点都给不出可引用的量化结论,只能作为方向性判断保留。
资料来源
- EIP-1153: Transient storage opcodes,https://eips.ethereum.org/EIPS/eip-1153
- EIP-2929: Gas cost increases for state access opcodes,https://eips.ethereum.org/EIPS/eip-2929
- EIP-3529: Reduction in refunds,https://eips.ethereum.org/EIPS/eip-3529
- EIP-2200: Structured Definitions for Net Gas Metering,https://eips.ethereum.org/EIPS/eip-2200
- Ethereum Yellow Paper,附录 G 费率表、式 (328) 内存计价函数,https://ethereum.github.io/yellowpaper/paper.pdf
- ethereum.org 操作码参考(TLOAD、TSTORE 各 100 gas),https://ethereum.org/en/developers/docs/evm/opcodes/
- Solidity 0.8.28 发布说明(transient 值类型状态变量支持),https://www.soliditylang.org/blog/2024/10/09/solidity-0.8.28-release-announcement/
- Solidity 文档:Transient Storage(EVM 版本要求 cancun、引用类型与局部变量暂不支持),https://docs.soliditylang.org/en/latest/contracts.html#transient-storage
- Solidity 瞬态存储清理辅助函数冲突缺陷(0.8.28 至 0.8.33,0.8.34 修复),https://www.soliditylang.org/blog/2026/02/18/transient-storage-clearing-helper-collision-bug/
- ethereum.org 网络升级历史(各分叉区块高度与日期),https://ethereum.org/en/history/
延伸阅读
本篇属于 EVM 基础系列,同系列另有 0.15《ABI 精读:selector、静态参数与动态类型》,讨论调用数据如何编码与解码。