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

CALL 族對照:CALL、CALLCODE、DELEGATECALL 與 STATICCALL

四個呼叫指令在位元組碼裡只差幾個編號,語意卻決定程式碼跑在誰的上下文、寫誰的儲存、msg.sender 與 address(this) 變成什麼。逐個對照 CALL、CALLCODE、DELEGATECALL 與 STATICCALL,可以看清代理為何依賴 DELEGATECALL、STATICCALL 因何引入。

一筆普通交易進入合約 A,A 用 DELEGATECALL 把執行交給實作合約 B。執行的位元組碼來自 B,但 address(this) 回傳的是 A 的地址,msg.sender 是最初發起交易的帳戶,B 裡對 storage 的寫入落在 A 的槽位上。把 DELEGATECALL 換成 CALL,這幾個值會全部改變;換成 STATICCALL,任何寫操作直接拋異常。

四個呼叫指令在位元組碼層面只差一個編號,語意差別卻貫穿代理升級、函式庫呼叫、唯讀查詢與重入防護。本文逐個對照它們在執行上下文、儲存歸屬、訊息欄位與 gas 上的行為,說明 DELEGATECALL 為什麼成了代理合約的基礎,STATICCALL 為什麼需要一條 EIP 專門引入,CALLCODE 又為什麼從 Frontier 的成員變成被棄用的遺留物。

四個指令的編號與堆疊參數

先把外觀對齊。CALL 是 0xf1,CALLCODE 是 0xf2,DELEGATECALL 是 0xf4,STATICCALL 是 0xfa。四者在失敗時向堆疊壓入 0、成功時壓入 1,都用記憶體偏移量與長度描述輸入輸出區間,都受 1024 層呼叫深度限制,也都在 gas 不足時失敗而不是靜默截斷。

堆疊參數數目把它們分成兩組。CALL 與 CALLCODE 各取 7 個運算元:gas、目標地址、value、輸入偏移、輸入長度、輸出偏移、輸出長度。DELEGATECALL 與 STATICCALL 各取 6 個,沒有 value 一項:DELEGATECALL 沿用父作用域的 msg.value,STATICCALL 把它固定為 0。參數表的差異本身就是理解語意的入口。能自訂 value 的兩個指令,一個真的轉帳(CALL),一個不轉帳(CALLCODE);不能自訂 value 的兩個指令,一個繼承(DELEGATECALL),一個清零(STATICCALL)。

回傳資料的處理方式也值得對照。四者都會把子呼叫的回傳資料寫入呼叫方指定的記憶體區間,但寫入長度受呼叫方給出的 out_size 限制。拜占庭升級引入 RETURNDATASIZE 與 RETURNDATACOPY(EIP-211)之後,呼叫方不必再預先猜出回傳資料大小,可以先讀長度再按需複製,代理與通用轉發合約因此不必預留一個足夠大的輸出區。

逐個對照:程式碼從哪來,狀態寫在哪,訊息欄位是誰

CALL 建立新的執行上下文。程式碼取自目標地址,storage 也屬於目標地址,address(this) 是目標合約,msg.sender 是發起呼叫的當前合約。value 指定的以太幣從當前合約真正轉入目標地址,所以當前合約餘額必須足夠,否則呼叫失敗。

CALLCODE 也把目標地址的程式碼取來執行,但執行落在當前帳戶的上下文裡:storage 是當前合約的,address(this) 是當前合約的地址。它比 CALL 多一個反直覺之處:value 可以是呼叫方指定的任意值,msg.value 因此能被改寫成與父作用域不同的數字,而所謂的價值轉移是當前帳戶轉給自己,不產生實際餘額變化。價值檢查仍然要做:黃皮書對 0xf2 的定義要求呼叫前提是 value 不超過當前帳戶餘額,否則不進入被呼叫程式碼,堆疊上得到 0。CALLCODE 的 msg.sender 是執行它的當前合約,這一點與 CALL 相同,也是它與 DELEGATECALL 最關鍵的分歧。

DELEGATECALL 同樣在當前帳戶的上下文裡執行目標程式碼,storage 與 address(this) 都指向當前合約,但它把父作用域的 msg.sender 與 msg.value 原樣帶進子作用域。EIP-7 的表述是:發送者與價值從父作用域傳播到子作用域,子程式碼裡 CALLER 與 VALUE 的行為與父環境完全一致。這帶來一個直接好處:實作合約可以自由引用 msg.sender 與 msg.value,不需要為了相容代理呼叫而專門重新編譯。EIP-7 的動機部分還列出兩個用法:把實作程式碼拆成多份分段執行,以繞過當時約 300 萬 gas 的呼叫上限;以及用一個可變地址保存程式碼來源,把呼叫透傳給該地址。

STATICCALL 建立的執行上下文與 CALL 類似:程式碼與 storage 都屬於目標地址,address(this) 是目標合約,msg.sender 是呼叫方,msg.value 取 0。區別在於它給子作用域打上靜態標記,執行期間禁止任何狀態修改。EIP-214 列出的禁止項包括 CREATE、CREATE2、LOG0 到 LOG4、SSTORE、SELFDESTRUCT,以及攜帶非零 value 的 CALL;遇到這些操作直接拋異常,而不是執行修改。規範保留了一個例外:CALLCODE 即使攜帶非零 value 也不被算作狀態修改,因為按 CALLCODE 的語意那筆價值並未離開當前帳戶。

一張對照表

下表中的訊息欄位指被呼叫程式碼內部觀察到的值,address(this) 指目標程式碼執行時 ADDRESS 壓棧的結果。

指令編號堆疊參數程式碼來源storage 歸屬address(this)msg.sendermsg.value可寫狀態
CALL0xf17(含 value)目標地址目標地址目標地址當前合約指定值,實際轉帳允許
CALLCODE0xf27(含 value)目標地址當前合約當前合約當前合約指定值,需通過餘額檢查但不實際轉帳允許
DELEGATECALL0xf46目標地址當前合約當前合約繼承父作用域繼承父作用域允許
STATICCALL0xfa6目標地址目標地址目標地址當前合約固定為 0禁止

Gas:基價、存取成本與 63/64 保留規則

四個指令的 gas 由幾部分疊加:基礎成本、目標地址的存取成本、記憶體擴展成本,以及實際轉交給子呼叫的額度。轉給子呼叫的部分若未被用完會退回,因此它更接近額度而不是支出。

目標地址的存取成本在柏林升級(EIP-2929)後改為冷熱計價:同一筆交易裡首次存取是冷存取,收 2600 gas;再次存取是暖存取,收 100 gas。此前 EIP-150 把 CALL、CALLCODE、DELEGATECALL 的基礎成本統一提到 700 gas,柏林之後呼叫族改為按冷熱計價,2600 與 100 取代了固定的 700,計價發生在計算可用 gas 之前。地址集合在同一筆交易內共享,若某一層執行回退,該層新增的存取記錄也隨之回退。想省這筆錢的呼叫方可以在交易裡附帶 EIP-2930 的存取清單(access list),預先宣告要碰的地址與槽位,代價是按項支付固定費用。

價值與帳戶建立會額外加價。CALL 攜帶非零 value 時加收 9000 gas;若接收方是 dead 帳戶(不存在或為空)、因而需要在狀態樹裡新建,再加 25000。CALLCODE 攜帶非零 value 時同樣加收 9000,只是它的價值轉給當前帳戶,不涉及新建帳戶。DELEGATECALL 與 STATICCALL 沒有 value 參數,也就沒有這兩項。

2300 gas 的 stipend 是另一個容易記錯的點。攜帶非零 value 的 CALL 與 CALLCODE 會讓子呼叫額外拿到 2300 gas;黃皮書費率表把這筆額度記為「從 G_callvalue 中減去」,也就是說它包含在 9000 的價值轉移附加費之內,並非在附加費之外另有補貼。它也是 transfer() 與 send() 的 gas 上限來源:這兩個方法只轉發 2300 gas,一旦接收方需要寫 storage 或打日誌就會失敗。Solidity 文件已經把 send() 與 transfer() 標為不推薦並計劃移除,建議改用 CALL 並自行檢查回傳值。

EIP-150 還引入「保留 1/64」規則:當請求轉發的 gas 超過呼叫方剩餘可用 gas 的 63/64 時,實際只轉發 63/64,父作用域始終留一份 gas 用於處理失敗回傳。這條規則把早期基於呼叫深度的攻擊面換成了基於 gas 的限制,也解釋了為什麼子呼叫即使耗盡 gas,父呼叫仍能拿到失敗標誌並繼續執行,而不是整筆交易一起耗盡。

DELEGATECALL 是代理合約的基礎,代價是 storage layout 約束

有了 DELEGATECALL,代理模式才成立:代理合約持有狀態與資產,把呼叫轉發給實作合約,實作在代理的上下文裡讀寫代理的 storage,升級時只改實作地址,狀態原地保留。這也是可升級合約在 EVM 上的標準做法。一個最小轉發函式的寫法大致如下,實作地址作為參數傳入,刻意不佔用 storage,避免與實作合約的布局衝突。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Forwarder {
    function _delegate(address impl) external payable {
        assembly {
            calldatacopy(0, 0, calldatasize())
            // 在呼叫方(代理)的上下文裡執行 impl 程式碼,storage 與 msg.sender 都留在代理側
            let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch ok
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

代價是儲存布局(storage layout)必須對齊。DELEGATECALL 不重新命名也不遷移槽位,程式碼按槽位編號或編譯器分配的偏移讀寫。如果代理自己宣告了變數,而實作合約恰好在同一槽位宣告了變數,兩者會互相覆蓋。EIP-1967 因此把實作地址寫在 bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1) 算出的槽位,也就是 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc,這個位置在機率上不會被編譯器分配給業務變數。同理,實作合約新增狀態變數只能追加在末尾,刪除或重排變數會讓升級後的讀寫整體錯位。

建構函式(constructor)在代理模式下也有專門約束。部署實作合約時,建構函式在自己的上下文裡執行,寫的是實作合約自己的 storage,不會寫入代理的狀態。因此代理的初始化必須透過一個顯式的初始化函式完成,並且要有只執行一次的標記,否則任何人都可以重新初始化並奪取控制權。

還有一類風險來自被呼叫程式碼本身。DELEGATECALL 讓目標程式碼擁有代理的全部權限:可以寫任意槽位、轉走餘額、呼叫其他合約,甚至透過 selfdestruct 影響代理的可執行性。實作地址若可被任意修改,或者引入了未經審計的函式庫,等於把代理的控制權整體交出去。Solidity 函式庫(library)的公開函式正是用 DELEGATECALL 呼叫,函式庫程式碼因此不能宣告自己的狀態變數,否則會與呼叫方的布局衝突;函式庫的非 view、非 pure 函式被設計為只能透過 DELEGATECALL 呼叫,它的執行時期程式碼帶有呼叫保護,直接對函式庫地址發起 CALL 會在執行期 revert(呼叫 view 或 pure 函式時不受這層保護)。

STATICCALL 把狀態修改變成異常

STATICCALL 由 EIP-214 引入,隨拜占庭(Byzantium)升級啟用。EIP-214 只給出操作碼與語意,沒有寫分叉名,分叉歸屬來自把 EIP-214 列入拜占庭包含清單的 EIP-609。動機是:普通 CALL 之後,呼叫方無法假定被呼叫合約的狀態沒有變化,重入類問題因此很難在局部推理。EIP-214 給出的目標是,靜態呼叫前後所有帳戶的狀態保持一致,這次呼叫可以看成只回傳輸出、不產生副作用的純函式。

實作方式是在 EVM 裡增加一個 STATIC 標誌。該標誌預設為 false,進入子呼叫時通常原樣複製,只有 STATICCALL 會把它置為 true,返回後恢復。禁止項在上一節已經列出,關鍵點是這個標誌隨呼叫鏈傳播:靜態呼叫裡再發起 CALL,子呼叫同樣處於靜態模式,無法靠多繞一層繞開限制。

工程上有兩類收益。唯讀查詢可以安全組合,價格視圖與多合約聚合呼叫不必擔心被呼叫方改寫狀態;重入防護可以更早收口,把外部唯讀呼叫放進靜態模式後,重入寫入會在 EVM 層直接失敗。Solidity 0.5.0 起,呼叫非函式庫的 view 與 pure 函式預設編譯成 STATICCALL,編譯期約束之外多了一層執行期兜底。函式庫的 view 函式是例外,它仍然使用 DELEGATECALL,因為 EVM 沒有同時具備靜態語意與委託語意的指令,所以函式庫的唯讀函式沒有執行期狀態保護,這一點在審計時需要單獨確認。

邊界同樣要寫清。STATICCALL 只禁止修改狀態,不禁止讀取,也不禁止回呼,它阻斷了重入的寫入路徑,不等於重入問題整體消失。它也不能替代 view / pure 的編譯期檢查,兩者覆蓋的失敗模式不同。靜態呼叫同樣消耗 gas,目標合約也可以故意 revert 把呼叫方的手續費變成攻擊成本;靜態模式只保證狀態不變,不保證呼叫成功或結果可信。

CALLCODE 的歷史地位與退場

CALLCODE 是 Frontier 版本就存在的指令,EIP-7 的動機部分直接把它當作 DELEGATECALL 的對照物。它的設計目標與 DELEGATECALL 接近,都是「借用別人的程式碼、在當前帳戶上執行」,但訊息欄位的處理不同:msg.sender 被改成當前合約,msg.value 可以被任意指定。需要透傳原始發送者與價值時,它無法勝任,EIP-7 用傳播發送者與價值的方式補上了這一點。

價值語意的混亂加速了它的退場。帶非零 value 的 CALLCODE 要求當前帳戶餘額足夠,卻把價值轉給自身地址,子呼叫裡讀到改寫的 msg.value,帳面上不發生任何轉移,這類行為很難用直覺推理。至於實際使用情況,EIP-2488 的作者認為 CALLCODE 從未被真正使用過,代理模式的大規模普及又在更晚(DELEGATECALL 隨 Homestead 上線,EIP-7 給出的主網啟用區塊是 1,150,000);這兩點屬於基於公開時間線與作者陳述的判斷,缺少可量化的呼叫統計。

退場過程分兩步。Solidity 從 0.5.0 起不再允許 callcode,只剩內嵌組譯仍可發出 0xf2 指令。協議層沒有真正移除該操作碼,EIP-2488 提議讓 CALLCODE 在某個區塊高度後恆回傳失敗,理由是直接刪掉操作碼會讓遇到它的合約異常中止,回傳失敗則給了合約感知並恢復的機會;該提案至今停在停滯(Stagnant)狀態。結論是:CALLCODE 在語言層面已經退場,在位元組碼層面仍是一個需要實作方正確處理的遺留語意,兩者不要混為一談。

呼叫上下文決定了狀態寫入落在哪個槽位

四個指令最終回答同一個問題:這段程式碼在誰的上下文裡執行,寫進誰的儲存。CALL 與 STATICCALL 把狀態寫進被呼叫合約,DELEGATECALL 與 CALLCODE 寫進發起呼叫的合約。對單執行緒執行而言,這個區別只影響正確性;對平行執行而言,它決定衝突偵測的輸入。排程器要判斷兩筆交易是否會互相干擾,必須知道它們最終寫哪些「地址加儲存槽」的組合,而呼叫上下文正是決定這裡的地址取代理還是取實作的關鍵:代理模式下一次轉發寫的是代理的槽位,不是實作合約的槽位。衝突偵測如何圍繞同一儲存槽展開,留到後續討論儲存布局與讀寫集時再展開。

資料來源

  • EIP-7《DELEGATECALL》,操作碼 0xf4、發送者與價值傳播、Homestead 啟用區塊 1,150,000 與歷史動機:https://eips.ethereum.org/EIPS/eip-7
  • EIP-214《New opcode STATICCALL》,靜態標誌、禁止操作清單、CALLCODE 例外:https://eips.ethereum.org/EIPS/eip-214
  • EIP-609《Hardfork Meta: Byzantium》,把 EIP-211 與 EIP-214 列入拜占庭包含清單的元提案:https://eips.ethereum.org/EIPS/eip-609
  • EIP-211《New opcodes: RETURNDATASIZE and RETURNDATACOPY》,動態回傳資料與 BYZANTIUM_FORK_BLKNUM:https://eips.ethereum.org/EIPS/eip-211
  • EIP-150《Gas cost changes for IO-heavy operations》,呼叫基價 700 與 63/64 保留規則:https://eips.ethereum.org/EIPS/eip-150
  • EIP-2929《Gas cost increases for state access opcodes》,冷存取 2600、暖存取 100 與存取集合:https://eips.ethereum.org/EIPS/eip-2929
  • ERC-1967(原 EIP-1967)《Proxy Storage Slots》,實作地址槽位 keccak256("eip1967.proxy.implementation") - 1:https://eips.ethereum.org/EIPS/eip-1967
  • EIP-2488《Deprecate the CALLCODE opcode》,恆回傳失敗的動機、狀態為停滯(Stagnant)且未啟用:https://eips.ethereum.org/EIPS/eip-2488
  • Ethereum Yellow Paper,附錄 H 的 CALL / CALLCODE / DELEGATECALL / STATICCALL 定義與操作碼表(含 CALLCODE 的 value ≤ 餘額 前提)、附錄 G 費率表:https://ethereum.github.io/yellowpaper/paper.pdf
  • Solidity 0.5.0 破壞性變更(callcode 不再允許)、合約文件中 view / pure 使用 STATICCALL、函式庫的 view 函式使用 DELEGATECALL、函式庫的呼叫保護與 send() / transfer() 不再推薦的說明:https://docs.soliditylang.org/en/v0.5.0/050-breaking-changes.html 、https://docs.soliditylang.org/en/latest/contracts.html
  • ethereum.org EVM 操作碼參考與 wolflo/evm-opcodes 動態 gas 表,價值轉移 9000、新建帳戶 25000、2300 stipend:https://ethereum.org/en/developers/docs/evm/opcodes/ 、https://github.com/wolflo/evm-opcodes/blob/main/gas.md

延伸閱讀

EVM 相容意味著什麼:位元組碼、預編譯、JSON-RPC 與工具鏈衝突熱點與工作負載:平行 EVM 何時真的變快Bitroot 樂觀平行化機制:偵測、重新執行與確定性
← 上一篇ABI 精讀:selector、靜態參數與動態類型
本文目錄
四個指令的編號與堆疊參數逐個對照:程式碼從哪來,狀態寫在哪,訊息欄位是誰一張對照表Gas:基價、存取成本與 63/64 保留規則DELEGATECALL 是代理合約的基礎,代價是 storage layout 約束STATICCALL 把狀態修改變成異常CALLCODE 的歷史地位與退場呼叫上下文決定了狀態寫入落在哪個槽位資料來源延伸閱讀
閱讀設定