三個抽屜,三種壽命
把一份智慧合約想像成一位坐在辦公桌前的職員,只有當一筆交易呼叫它時才會醒來。為了完成工作,這位職員有三個截然不同的地方可以放資料。桌子後面有一個上了鎖的檔案櫃——只要他歸檔進去,下次再來時東西還在,就算過了好幾年也一樣,但要打開它、往裡頭寫東西既慢又昂貴。桌上擺著一本便條紙——拿來打草稿很方便,但只要職員一回去睡覺,它就會被掃進垃圾桶。還有人從門縫底下塞進一個密封的信封——職員可以讀裡面的信,卻不准在上面寫字。這就是 EVM 的三個資料位置:storage、memory 與 calldata。
真正把它們區分開來的,是壽命。storage 是唯一能讓內容從一筆交易延續到下一筆交易的地方:它是帳戶永久狀態的一部分,永遠被記錄在世界狀態裡。memory 與堆疊則在每次呼叫開始時嶄新地(全為零)誕生,並在呼叫返回時消失。calldata 只在呼叫進行的期間存在,而且永遠無法被修改。上一篇談過堆疊——第四個區域,算術真正發生的地方;這一篇要談的是資料本身存在哪裡。
Storage:合約永久的檔案櫃
每個合約帳戶都擁有自己的 storage:一張巨大的鍵—值對應表,有 2²⁵⁶ 個可能的鍵,稱為 slot(儲存槽),每一個槽存放一個 32 位元組的值,而且所有槽一開始都是零。要碰它,正好只有兩個操作碼——`SLOAD(key)` 讀取某個槽的值,`SSTORE(key, value)` 寫入一個值。這是 EVM 中唯一能比交易活得更久的資料;寫它,等於實際改變了區塊鏈的狀態。
一個 Solidity 變數又是怎麼變成一個槽的呢?編譯器只是依序發配槽位:第一個狀態變數拿到 slot 0,下一個拿到 slot 1,以此類推。為了省空間,它會把放得下的小型變數打包(pack)進同一個 32 位元組的槽——兩個 `uint128` 共用一個槽,一個 `address`(20 位元組)加上一個 `bool` 也能並排塞在一起。
contract Layout {
uint256 a; // slot 0 fills the whole 32-byte slot
uint128 b; // slot 1, bytes 0..15 packed together
uint128 c; // slot 1, bytes 16..31 into one slot with b
address owner; // slot 2, bytes 0..19
bool paused; // slot 2, byte 20 packs in beside owner
}打包不只是整齊而已——它就是錢。因為計價的單位是「槽」,讀取或寫入兩個共用同一個槽的變數,可能只碰一次 storage,而不是兩次。要是你隨便排列變數,可能會在不知不覺間讓 gas 帳單翻倍。
為什麼寫 storage 是合約最昂貴的動作
以下是真實的數字。用 `SSTORE` 寫一個全新的(先前為零的)槽要花 20,000 gas——若把一筆交易中第一次碰到某個槽時所收的 2,100(也就是冷存取)也算進去,則是 22,100。把一個既有的非零值改成另一個值要花 5,000。用 `SLOAD` 讀取,冷存取要 2,100,之後每次 100(這時它已經變熱了)。相較之下,一個 `ADD` 只要 3 gas,往 memory 寫一次 `MSTORE` 也只要 3。而整筆交易的基礎成本是 21,000——所以寫入一個新的 storage 槽,花費大約等同於從頭發送一整筆交易。
為什麼這麼狠?因為這次寫入是永久且全域的。地球上每一個全節點都必須永遠保存那 32 位元組的值,並把它摺進整條鏈的狀態裡。具體來說,`SSTORE` 會更動世界狀態——一棵 Merkle-Patricia 樹——這會改變每個節點都要重新計算、且每個區塊標頭都承諾過的狀態根。你不是在寫入一台電腦,而是永久地寫入成千上萬台,而 gas 價格正反映了這個外部性。
Memory:打草稿的便條紙
Memory 是一個揮發性、可逐位元組定址的陣列,在每個呼叫框架開始時整片都是零,並在那次呼叫返回的瞬間被丟棄。它的操作碼是 `MLOAD`/`MSTORE`(在某個位元組偏移處讀寫 32 位元組)與 `MSTORE8`(單一位元組)。凡是塞不進一個堆疊字組的東西,EVM 都在這裡組裝:一個記憶體中的陣列或結構、你餵給 `keccak256` 的位元組、一個函式要回傳的資料。可以把它想成在每次呼叫之間都會被清空的 RAM。
每次存取都很便宜——3 gas——但你得為「擴張」memory 付費。曾經碰過 `a` 個 32 位元組字組的 memory,其成本是 `3·a + ⌊a²/512⌋` gas。線性項讓小緩衝區幾乎免費;二次項則讓巨大的配置變得極為昂貴。這條曲線是刻意設計的:因為 memory 從不被保存,唯一能阻止一份合約索求好幾 GB(並耗盡節點的 RAM)的,就是 gas 計量本身。下一篇會把 gas 徹底拆解。
memory_cost(words) = 3 * words + floor(words^2 / 512) 32 words (1 KB): 3*32 + 32^2/512 = 96 + 2 = 98 gas 320 words (10 KB): 3*320 + 320^2/512 = 960 + 200 = 1,160 gas 3200 words (100 KB): 3*3200 + 3200^2/512 = 9,600 + 20,000 = 29,600 gas
Calldata:唯讀的信封
Calldata 是這次呼叫的輸入——`msg.data`——一個由呼叫者交進來的唯讀位元組陣列。對一次合約呼叫而言,它是一個 4 位元組的函式選擇器,後面接著經過 ABI 編碼的引數(那套編碼如何運作,是本階最後一篇談位元組碼與 ABI 的主題)。你用 `CALLDATALOAD`、`CALLDATASIZE` 與 `CALLDATACOPY` 來讀它;沒有任何操作碼能寫它,因為它屬於呼叫者,不屬於你。
因為 calldata 從不被複製或配置,直接從中讀取一個參數是所有選項裡最便宜的。在 Solidity 裡,把一個函式引數宣告為 `calldata` 而非 `memory`,可以省掉那次複製——對於大型陣列與字串,這是實實在在的節省。代價正是它「安全」的另一面:calldata 不可變,所以一旦你需要修改這份資料,就必須先把它複製進 memory,並為那次複製付費。
// `calldata`: read the array in place, no copy — cheapest.
function total(uint256[] calldata xs) external pure returns (uint256 s) {
for (uint256 i; i < xs.length; ++i) s += xs[i];
}
// `memory`: the array is copied in first — only needed if you mutate it.
function zeroFirst(uint256[] memory xs) internal pure returns (uint256[] memory) {
xs[0] = 0; // legal: memory is writable
return xs;
}映射與動態陣列究竟存在哪裡
「依計數器配發槽位」對固定大小的變數行得通,但一個映射或動態陣列並沒有固定大小——它無法塞進單一個槽。於是 EVM 用 Keccak-256 把它們的條目撒遍 2²⁵⁶ 的槽位空間。對一個宣告在 slot `p` 的映射,鍵 `k` 對應的值被存放在槽 `keccak256(k ++ p)`,其中鍵與槽號各自補滿到 32 位元組後串接起來。
// mapping(address => uint256) balances; declared at slot 1 // where is balances[0x00..AbC] stored? slot = keccak256( pad32(0x00..AbC) ++ pad32(1) ) // 64 bytes in, 32 bytes out // dynamic array uint256[] xs; declared at slot p: // slot p holds xs.length // keccak256(p) + i holds xs[i]
由此直接帶出兩個推論。第一,你無法在鏈上列舉一個映射的所有鍵:那棵樹只儲存槽位與其值,從不記錄是哪個鍵產生了哪個槽,因此光憑 storage 是還原不出原始鍵的。第二,兩個不同的映射基本上永不相撞——Keccak 讓兩個鍵落到同一個槽的機率小到近乎天文數字。一個存在映射裡的結構,只是從那個雜湊值開始佔用連續的數個槽而已。
把它們組起來——以及第四個區域
這裡有一個小金庫,在單一個函式裡用上了全部三個位置。追蹤一下每塊資料分別住在哪裡:
contract Vault {
uint256 total; // storage, slot 0
mapping(address => uint256) balances; // storage, slot 1
function deposit(uint256[] calldata amounts) external {
uint256 sum; // a stack local (one 256-bit value)
for (uint256 i; i < amounts.length; ++i)
sum += amounts[i]; // read each item straight from calldata
balances[msg.sender] += sum; // SLOAD then SSTORE at keccak256(sender ++ 1)
total += sum; // SLOAD then SSTORE at slot 0
}
}一行一行走過去。`amounts` 抵達於 calldata,並就地讀取——不複製。`sum` 是一個值型別的區域變數,所以它住在堆疊上(上一篇的領域),而不在 memory 裡;只有當我們組建一個新陣列或對某些位元組做雜湊時,memory 才會登場。最後那兩行是僅有的昂貴動作:各做一次 `SLOAD` 再一次 `SSTORE` 來操作 storage——`balances[msg.sender]` 位於 `keccak256(sender ++ 1)`,`total` 位於 slot 0——而它們正是真正更動世界狀態的地方。
- Storage——跨交易存活;世界狀態中一張 32 位元組槽的對應表;`SLOAD`/`SSTORE`;遠遠是最昂貴的(寫一個全新的槽約 20,000 gas)。
- Memory——每次呼叫都嶄新且歸零,返回即消失;一個位元組陣列;`MLOAD`/`MSTORE`;便宜,但成本隨著它擴張而以二次方增長。
- Calldata——唯讀的呼叫輸入(`msg.data`);`CALLDATALOAD`/`CALLDATACOPY`;所有之中最便宜,但不可變。
- Transient storage——像 storage 一樣是鍵—值,但在交易結束時被清空;`TSTORE`/`TLOAD`;便宜,用於單筆交易內的跨呼叫狀態。