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

Storage Layout:Solidity 狀態變數如何落到 slot

狀態變數按宣告順序落在 32 位元組的槽位裡:小類型共用槽位,映射與動態陣列靠雜湊派生位置,繼承順序還會整體平移槽位編號。這些定位規則決定了升級代理為什麼必須繞開編譯器會分配的槽位。

一個只改了自己 owner 欄位的實作合約,能把代理合約的管理員地址寫成垃圾值。這類事故的根源通常是 Solidity 存放狀態變數的方式。合約裡沒有「變數名到儲存位置」的執行時期索引,編譯器按宣告順序把變數依次放進 32 位元組(32 bytes)的槽位(slot)。delegatecall 讓實作合約的程式碼在代理的儲存上執行,一旦兩邊對槽位的分配理解不一致,寫操作就落到對方的資料上。

槽位既是儲存的定址單位,也是執行層記錄一次狀態存取時的最小粒度。變數落在哪個槽位、打包後是否共用槽位、陣列與映射的元素如何由鍵推導位置,決定了兩個很實際的問題:一次呼叫會改壞代理的哪個欄位,以及一筆交易的狀態讀寫集到底有多大。

變數按宣告順序線性佔用 32 位元組槽位

根據 Solidity 官方文件《Layout of State Variables in Storage and Transient Storage》,除動態陣列與映射之外,狀態變數從第一個宣告開始連續存放,第一個變數落在 slot 0。每個變數按型別決定位元組數,連續的、總計不足 32 位元組的變數會被裝進同一個槽位,規則有五條:槽位內第一個條目存放在低位(lower-order aligned);值型別只佔用它實際需要的位元組;裝不進當前槽位剩餘空間時移到下一個槽位;結構體與陣列資料總是從新槽位開始;結構體或陣列之後的變數也從新槽位開始。

兩個容易被忽略的例外會影響槽位編號計算。constant 變數不佔用儲存槽位,其值在使用處行內展開;immutable 變數編碼在部署位元組碼裡,執行時期不讀儲存。以官方文件的範例合約 C 為例,常數 c 與不可變變數 d 都不參與布局,若把它們計入宣告序號,之後所有變數的槽位編號都會偏移。暫時儲存(transient storage)是另一套獨立布局,規則相同但空間完全分離,因此同一個合約裡普通狀態變數與暫時變數可以隨意交錯而不互相影響。

打包省的是槽位,未必省 gas

小類型共用同一個槽位,稱為打包(packing)。下面兩段宣告只差變數的排列順序,佔用的槽位數差一個:

// 佔用 3 個槽位
uint128 a; // slot 0, offset 0
uint256 b; // 32 位元組裝不進 slot 0 的剩餘空間,落到 slot 1
uint128 c; // slot 1 已滿,落到 slot 2

// 佔用 2 個槽位
uint128 a; // slot 0, offset 0
uint128 b; // slot 0, offset 16
uint256 c; // slot 1

官方文件明確指出,使用小於 32 位元組的元素可能提高 gas 消耗:EVM 按 32 位元組為單位運算,處理小類型需要額外的截斷或移位操作。打包的實際收益在於「一次存取一個槽位就能拿到多個值」,讀寫次數按槽位計價。它的反面同樣成立:如果一段邏輯只寫其中一個變數,EVM 必須先讀出整個槽位,修改對應位元組,再整體寫回,否則會覆蓋同槽位的其他變數。把不常同時存取的變數塞進同一個槽位,會把一次寫入變成一次讀加一次寫。

這裡已經埋下一個與並行相關的觀察點:以槽位為單位看,打包把若干邏輯上無關的變數綁定成了同一個可寫物件。

定長陣列行內,動態陣列與映射靠雜湊派生位置

定長陣列(如 uint256[3])的元素按順序行內存放在槽位裡,與逐個宣告的變數沒有區別;總位元組數超過 32 時自然跨多個槽位。結構體同理,只是它以及它之後的變數都必須從新槽位開始。

動態陣列與映射無法行內,因為大小不可預知。它們採取的做法是:在布局中只佔用一個槽位 p,槽位本身不存放資料,資料位置由 keccak256 計算。

動態陣列的槽位 p 存放陣列長度,元素從 keccak256(p) 開始,按定長陣列的規則連續排列,元素不超過 16 位元組時仍可共用槽位。嵌套動態陣列遞迴套用同一規則:對型別為 uint24[][]、宣告在槽位 p 的 x,元素 x[i][j] 所在槽位為 keccak256(keccak256(p) + i) + floor(j / floor(256 / 24))。

映射的槽位 p 保持為空,但必須保留,正是這個保留槽位保證了相鄰兩個映射的資料不會重疊。鍵 k 對應的值位於 keccak256(h(k) . p),其中 . 表示拼接,h 對鍵做型別相關處理:值型別按與記憶體儲存相同的方式補齊到 32 位元組;string 與 bytes 型別的鍵不做補齊。文件給出的嵌套範例可以直接驗證這一點:對 uint x; mapping(uint => mapping(uint => S)) data;,data[4][9].c 的槽位是 keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1,末尾的 +1 來自結構體成員 c 在 S 內的槽位偏移,而 a、b 兩個 uint16 已被打包進同一個槽位。

bytes 與 string 的編碼需要單獨說明,因為它們不是 bytes1[] 的簡單封裝。當資料不超過 31 位元組時,資料本身與長度存放在同一個槽位內:資料左對齊放在高位位元組,最低位元組存放 length * 2。當資料達到或超過 32 位元組時,槽位 p 存放 length * 2 + 1,真正的資料放在 keccak256(p) 開始的區域。兩種情況透過最低位區分:短資料該位為 0,長資料為 1。

組合型別的定位遵循同樣的遞迴。按上述規則推導,uint8[4] 作為動態陣列的元素時,4 個 1 位元組值正好打包進一個槽位;uint[3][] 的每個元素是佔 3 個槽位的定長陣列,元素之間按 3 個槽位為間隔依次排列,x[i] 的起點是 keccak256(p) + 3 * i。這兩個例子是規則的特例推演,官方文件只給了 uint24[][] 的嵌套範例,沒有逐字給出這兩個。

這一切有一個直接後果:陣列元素與映射值的槽位位置依賴執行時期的鍵或下標,編譯期無法枚舉,只讀原始碼的靜態分析也推不出完整集合。

布局是編譯器的輸出,可以被匯出與比對

槽位編號不必靠推理猜測。Solidity 的標準 JSON 介面能匯出合約的儲存布局,輸出包含 storage 與 types 兩個鍵:storage 陣列的每一項給出 astId、contract、label、offset、slot、type,types 則描述每種類型的編碼方式,取值 inplace 表示行內排布,mapping 與 dynamic_array 表示基於 keccak256 派生,bytes 表示按長度在單槽位與雜湊區域之間二選一。slot 的數值可能非常大,在 JSON 裡以字串表示。文件同時提醒這套輸出格式仍被視為實驗性的,可能在 Solidity 的非破壞性版本中變化,因此它適合當作一次性核對工具,不宜作為長期依賴的介面。

繼承順序與槽位邊界決定升級的可行性

使用繼承的合約,狀態變數順序由合約的 C3 線性化(C3-linearized)順序決定,從繼承鏈最基端的合約開始排列。允許打包時,來自不同合約的變數仍會共用同一個槽位,基底類別與衍生類別的變數可以共處一槽位。

這條規則解釋了兩類升級事故。一類是在基底類別新增變數:只要子類別已經宣告了自己的變數,基底類別新增的變數就會頂掉子類別原有變數的槽位位置,把舊資料當成新變數讀出來。另一類是在已有變數之前插入變數或改變變數型別,效果相同。OpenZeppelin 的升級文件把這條約束寫得很直接:新變數只能加在末尾;若從末尾刪除變數,儲存不會被清空,之後新增的同位置變數會讀到歷史殘留值。

對於希望主動控制布局的場景,Solidity 允許在合約上宣告自訂布局起點。官方文件的範例寫作 pragma solidity ^0.8.29; 與 contract C is A, B layout at 42,繼承樹中所有靜態變數的槽位編號整體平移;文件沒有說明該能力自哪個編譯器版本引入,這裡也不做版本斷言。這份宣告只作用於該繼承樹,A、B 單獨部署時布局仍從 slot 0 開始。需要注意這只是平移起點,動態陣列與映射的資料位置會因為基準槽位的變化而連帶改變。

代理升級:storage collision 為什麼必然發生,保留槽位如何規避

升級代理(proxy)的機制是:代理持有儲存與餘額,透過 delegatecall 執行實作合約的程式碼。由於 delegatecall 保留呼叫者的儲存上下文,實作合約眼中的 slot 0、slot 1 就是代理的 slot 0、slot 1。如果代理自己也宣告一個 address public admin;,它會佔據 slot 0,而實作合約的第一個狀態變數同樣佔據 slot 0,任何一方寫入都會覆蓋另一方。這就是 storage collision(儲存衝突)。問題不出在某一方寫錯程式碼:共享儲存加上兩邊各自獨立編譯,這個組合本身就會產生衝突。

EIP-1967 的解法是不使用編譯器分配的槽位,而是固定一組約定槽位:

用途槽位位置推導方式
實作合約地址0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbcbytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
信標合約地址0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)
管理員地址0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)

這些數字的參數選擇有明確理由。槽位位置取自某個字串的 keccak256,而該字串不以儲存下標開頭,因此不會與編譯器從 0 開始遞增分配的槽位重合;數值本身很大,進一步遠離常規變數區間。末尾減 1 則讓雜湊的原像不可知,避免有人構造一個映射鍵,透過 keccak256(h(k) . p) 恰好寫到該槽位。EIP-1967 同時建議任何改寫這些槽位的函式都發出對應事件,因為鏈上監控很難直接追蹤任意槽位的變化。

EIP-1967 之外還有兩種常見做法。較早的方案是儲存間隙(storage gap),在基底類別末尾宣告一個定長陣列,例如 uint256[49] __gap;,為未來的變數預留槽位;基底類別新增變數時同步縮小 __gap。OpenZeppelin 的文件指出這不會增加 gas 消耗,但它的失效方式也很具體:忘記縮小間隙,或者在子類別已有變數時向基底類別加入變數,都會重新引入衝突。較新的方案是 ERC-7201 的命名空間儲存布局,把一組變數放進結構體,並用 @custom:storage-location erc7201:<NAMESPACE_ID> 標註,位置由 keccak256(keccak256(id) - 1) & ~0xff 計算;末尾的 & ~0xff 把命名空間按 256 個槽位對齊,文件給出的理由是這可作為未來的最佳化,Verkle 狀態樹遷移後可能出現 256 個槽位一起變熱的 gas 規則。OpenZeppelin Contracts 5.0 起的可升級版本採用了這一約定。

最後要補一個前提:Solidity 文件把儲存布局視為語言外部介面的一部分,原因是儲存指標可以傳給函式庫函式,任何布局規則的改動都算破壞性變更。按槽位寫死地址的代理方案之所以可行,建立在布局規則長期穩定這一承諾之上。

讀寫集的最小單位是槽位

把上面的規則合起來看,就存取計價而言,執行層能觀察到的最小狀態存取單位是地址加槽位。EIP-2929 是最直接的證據:它為每筆交易維護 accessed_addresses 與 accessed_storage_keys 兩個集合,後者的元素型別是 Set[Tuple[Address, Bytes32]],並按該粒度計價。首次存取某個儲存槽位收取 COLD_SLOAD_COST,即 2100 gas;已存取過的槽位收取 WARM_STORAGE_READ_COST,即 100 gas;首次存取某個帳戶地址收取 COLD_ACCOUNT_ACCESS_COST,即 2600 gas。冷熱計價的單位是槽位,說明執行層本身就以槽位為存取對象。

由此可以推出三點與並行相關的工程推斷。

EIP-2929 規定的是存取計價的最小單位,規範並沒有定義平行執行時衝突判定該用多大粒度。下面這點是從計價粒度出發的推斷:打包把小變數綁進同一個槽位,意味著以槽位為粒度記錄讀寫集時,寫同一個槽位內不同變數的兩筆交易仍然衝突,若要消除這類偽衝突,偵測就必須細化到槽位內偏移與位元組差異,代價是每次比較的開銷上升。

動態陣列與映射的槽位位置依賴鍵值,讀寫集在編譯期不可知,只能在執行過程中記錄、在執行後校驗。樂觀並行控制(optimistic concurrency control,OCC)之所以需要讀集與寫集,無法照搬資料庫那種由開發者預先宣告的方式,原因也在這裡(參見本站《樂觀並行控制(OCC)入門》與《衝突熱點與工作負載:平行 EVM 何時真的變快》)。

槽位級讀寫集是上界而非精確集合。一筆交易可能只改某槽位的一個位元組,卻記錄為寫了整個槽位;也可能存取了某個槽位,卻因為後續路徑回退而沒有實際生效。把讀寫集直接當作衝突判準,會得到偏高的衝突率,需要透過回退與重新執行來消化。

邊界同樣要說清楚。如果實作合約完全採用 ERC-7201 命名空間布局,原則上不再與代理的槽位發生衝突,代價是任意槽位編號不再能從原始碼順序讀出,只認原始碼順序的工具(部分靜態分析器、區塊瀏覽器)會失效。此外,行內組譯中的 sstore 可以寫入編譯器布局之外的任意槽位,任何只解析 AST 的布局工具都無法覆蓋這類寫入,這也是分析合約儲存行為時反覆出現的不確定性來源。

資料來源

  • Solidity 文件,Layout of State Variables in Storage and Transient Storage:https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
  • EIP-1967,Proxy Storage Slots:https://eips.ethereum.org/EIPS/eip-1967
  • ERC-7201,Namespaced Storage Layout:https://eips.ethereum.org/EIPS/eip-7201
  • EIP-2929,Gas cost increases for state access opcodes:https://eips.ethereum.org/EIPS/eip-2929
  • OpenZeppelin 文件,Writing Upgradeable Contracts:https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable

延伸閱讀

為什麼單執行緒 EVM 會卡住 TPS:從歷史壅塞到執行模型前置閱讀衝突熱點與工作負載:平行 EVM 何時真的變快相關閱讀樂觀並行控制(OCC)入門:從資料庫到鏈上執行相關閱讀Bitroot 樂觀平行化機制:偵測、重新執行與確定性機制細節
← 上一篇CALL 族對照:CALL、CALLCODE、DELEGATECALL 與 STATICCALL
本文目錄
變數按宣告順序線性佔用 32 位元組槽位打包省的是槽位,未必省 gas定長陣列行內,動態陣列與映射靠雜湊派生位置布局是編譯器的輸出,可以被匯出與比對繼承順序與槽位邊界決定升級的可行性代理升級:storage collision 為什麼必然發生,保留槽位如何規避讀寫集的最小單位是槽位資料來源延伸閱讀
閱讀設定