Bitroot部落格
返回官網 ↗
© 2026 Bitroot · 本站內容僅供一般參考,不構成財務、投資、法律或稅務建議。
編輯標準返回官網
← 全部文章
EVM 基礎·2026/09/22·約 13 分鐘

三類儲存別搞混:Memory、Storage 與 Transient Storage

同一份 32 位元組資料放在 memory、storage 還是 transient storage,gas 成本能差兩個數量級,生命週期也完全不同。下面沿歸屬、壽命與計價三條線拆開三類儲存,說明重入鎖、臨時陣列與狀態變數各自該放在哪裡。

一個重入鎖寫在 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 以帳戶加交易為單位。

維度MemoryStorageTransient 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/

延伸閱讀

樂觀並行控制(OCC)入門:從資料庫到鏈上執行效能指標詞典:TPS、BPS、確認延遲、最終性、衝突率為什麼單執行緒 EVM 會卡住 TPS:從歷史壅塞到執行模型

本篇屬於 EVM 基礎系列,同系列另有 0.15《ABI 精讀:selector、靜態參數與動態類型》,討論呼叫資料如何編碼與解碼。

← 上一篇堆疊機解剖:256-bit 字、1024 堆疊深度與執行迴圈下一篇 →Gas 機制精讀:計量單位、費用市場與執行中止
本文目錄
生命週期:框架、帳戶與交易是三個不同尺度Memory:按字擴充,擴充成本是二次的Storage:寫進帳戶的 storage trie,寫比讀貴一個量級Transient Storage:跨內部呼叫有效,交易結束即清空誤用的四種典型後果邊界與不確定的地方資料來源延伸閱讀
閱讀設定