---
id: 28
title: "CALL 族對照：CALL、CALLCODE、DELEGATECALL 與 STATICCALL"
slug: 0-17-call-family
date: 2026/10/04
summary: 四個呼叫指令在位元組碼裡只差幾個編號，語意卻決定程式碼跑在誰的上下文、寫誰的儲存、msg.sender 與 address(this) 變成什麼。逐個對照 CALL、CALLCODE、DELEGATECALL 與 STATICCALL，可以看清代理為何依賴 DELEGATECALL、STATICCALL 因何引入。
keywords: CALL,DELEGATECALL,STATICCALL,CALLCODE,代理合約
heroImage: /images/articles/photos/0-17-call-family.jpg
---

一筆普通交易進入合約 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.sender | msg.value | 可寫狀態 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| CALL | 0xf1 | 7（含 value） | 目標地址 | 目標地址 | 目標地址 | 當前合約 | 指定值，實際轉帳 | 允許 |
| CALLCODE | 0xf2 | 7（含 value） | 目標地址 | 當前合約 | 當前合約 | 當前合約 | 指定值，需通過餘額檢查但不實際轉帳 | 允許 |
| DELEGATECALL | 0xf4 | 6 | 目標地址 | 當前合約 | 當前合約 | 繼承父作用域 | 繼承父作用域 | 允許 |
| STATICCALL | 0xfa | 6 | 目標地址 | 目標地址 | 目標地址 | 當前合約 | 固定為 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，避免與實作合約的布局衝突。

```solidity
// 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 與工具鏈》](/zh-Hant/blog/evm-compatibility-explained)
- [《衝突熱點與工作負載：平行 EVM 何時真的變快》](/zh-Hant/blog/parallel-evm-workload-hotspots)
- [《Bitroot 樂觀平行化機制：偵測、重新執行與確定性》](/zh-Hant/blog/bitrootevm-)
