一個重入鎖寫在 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、靜態參數與動態類型》,討論呼叫資料如何編碼與解碼。