---
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/blog/evm-compatibility-explained)
- [《冲突热点与工作负载：并行 EVM 何时真的变快》](/zh/blog/parallel-evm-workload-hotspots)
- [《Bitroot 乐观并行化机制：检测、重执行与确定性》](/zh/blog/bitrootevm-)
