一笔普通交易进入合约 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,避免与实现合约的布局冲突。
// 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