本文是EVM内部原理系列文章的第二部分,深入探讨了Solidity中的payable、fallback和receive函数,详细解释了calldata如何到达EVM,以及EVM如何解析calldata并分发函数调用。此外,文章还介绍了CALL、DELEGATECALL、STATICCALL和CALLCODE等底层操作码的区别,以及内部调用和外部调用的差异,并深入探讨了ABI编码和Revert机制。
这是多部分系列文章中的第二篇:“每个区块链开发者都应该了解的关于 EVM 内部原理的知识。”
在第 1 部分中,我们奠定了基础:我们探讨了 gas 的工作原理、什么是智能合约,以及 EVM 如何处理堆栈、内存、存储和 calldata 等核心组件。我们还研究了高级 Solidity 代码如何编译成字节码并在 EVM 内部作为操作码执行。
在这篇文章中,我们将以此为基础,探讨以下主题:
1. Payable、Fallback 和 Receive
2. 当 calldata 到达 EVM 时,实际发生了什么
3. 底层操作码:
CALL
,DELEGATECALL
,STATICCALL
,CALLCODE
4. 内部与外部函数调用
5. ABI 编码深度解析
6. Revert 如何工作(以及如何向上冒泡)
在本文结束时,你将清楚地了解智能合约调用在底层是如何运作的 —— 尤其是在执行变得复杂、嵌套或意外失败时。
Payable(可支付的)
在 Solidity 中,payable
关键字将函数或地址标记为能够接收 Ether。如果没有它,函数或地址无法接受 ETH,任何尝试向其发送 ETH 的交易都会自动 revert(回滚)。
payable
是一种安全功能。它可以防止合约意外接受 ETH 或与意外的价值转移进行交互,这在可升级合约或代理中尤其有用。
示例:
// 对于函数
function deposit() external payable {
// msg.value 包含随调用一起发送的 ETH
}
// 对于地址
address payable recipient = payable(someAddress);
recipient.transfer(1 ether);
Receive(接收)
一个合约最多可以定义一个 receive()
函数,如下所示:
receive() external payable { ... }
这里发生了什么:
external
和 payable
。.send()
或 .transfer()
。receive()
函数但存在 payable fallback()
函数,则使用该 fallback 函数代替。注意:当以这种方式发送 Ether 时,可用的 gas 限制为 2300,这足以发出事件,但不足以写入存储、调用其他合约或发送 ETH。
示例:
contract MyContract{
event Received(address sender, uint amount);
receive() external payable {
emit Received(msg.sender, msg.value);
}
}
Fallback(回退)
一个合约也可以定义一个 fallback()
函数,如下所示:
fallback() external [payable] { ... }
// 或具有 calldata 访问权限和返回值:
fallback(bytes calldata input) external [payable] returns (bytes memory)
调用时机:
receive()
函数时将其标记为 payable
以接收 ETH。可以访问 msg.data
(calldata) 并返回原始字节。
示例:
contract Example {
fallback() external payable {
// 在未知的函数调用或空 calldata 上触发(如果不存在 `receive()`)
}
}
总结
如果同时存在 receive()
和 payable fallback()
:
receive()
fallback()
没有这两者的合约无法通过 .send()
或 .transfer()
接收普通的 ETH。
Ether 仍然可以通过以下方式到达:
selfdestruct(address)
coinbase
)但合约不会做出反应,没有代码会运行。
更多信息可以在这里找到。
当在 EVM 上调用智能合约时,EVM 看不到 Solidity 函数名称和参数。相反,交易中的 data
字段以原始的 十六进制编码字节的形式传递,EVM 将其加载到名为 calldata
的特殊只读区域中。
本节将分解 EVM 在 calldata
到达时_真正_做的事情。这是一个简单的合约,它使用 solidity 编译器版本 0.4.25 编译,没有优化到最小化,并且具有我们可以轻松涵盖的基本操作码:
pragma solidity 0.4.25;
contract Example {
uint data;
function set(uint x) public {
data = x;
}
function get() public view returns (uint) {
return data;
}
}
完整的编译后的智能合约字节码和操作码:
bytecode: "0x608060405234801561001057600080fd5b5060df8061001f6000396000f3006080604052600436106049576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff1680632e64cec114604e5780636057361d146076575b600080fd5b348015605957600080fd5b50606060a0565b6040518082815260200191505060405180910390f35b348015608157600080fd5b50609e6004803603810190808035906020019092919050505060a9565b005b60008054905090565b80600081905550505600a165627a7a7230582078de35703c2e2e542f27462c54cc554bfaebcea8f777f7df9e1eb1a59a3628660029"
opcodes: "PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0xDF DUP1 PUSH2 0x1F PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x4 CALLDATASIZE LT PUSH1 0x49 JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV PUSH4 0xFFFFFFFF AND DUP1 PUSH4 0x2E64CEC1 EQ PUSH1 0x4E JUMPI DUP1 PUSH4 0x6057361D EQ PUSH1 0x76 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST CALLVALUE DUP1 ISZERO PUSH1 0x59 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x60 PUSH1 0xA0 JUMP JUMPDEST PUSH1 0x40 MLOAD DUP1 DUP3 DUP2 MSTORE PUSH1 0x20 ADD SWAP2 POP POP PUSH1 0x40 MLOAD DUP1 SWAP2 SUB SWAP1 RETURN JUMPDEST CALLVALUE DUP1 ISZERO PUSH1 0x81 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x9E PUSH1 0x4 DUP1 CALLDATASIZE SUB DUP2 ADD SWAP1 DUP1 DUP1 CALLDATALOAD SWAP1 PUSH1 0x20 ADD SWAP1 SWAP3 SWAP2 SWAP1 POP POP POP PUSH1 0xA9 JUMP JUMPDEST STOP JUMPDEST PUSH1 0x0 DUP1 SLOAD SWAP1 POP SWAP1 JUMP JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 PUSH25 0xDE35703C2E2E542F27462C54CC554BFAEBCEA8F777F7DF9E1E 0xb1 0xa5 SWAP11 CALLDATASIZE 0x28 PUSH7 0x290000000000"
接下来会发生什么:
我们将跳过样板指令,重点关注合约执行过程中调度和执行所涉及的关键 操作码。
PUSH1 0x80
PUSH1 0x40
MSTORE
这些初始指令初始化内存。EVM 使用位置 0x40
作为空闲内存指针。它在那里存储 0x80
,作为未来动态内存分配的起始偏移量。但是为什么要将 0x80
存储在 0x40
呢?这是 Solidity 中“ 空闲内存指针 ”的约定。内存位置 0x40
保留用于存储指向内存中下一个可用空闲空间的指针。在执行开始时,Solidity 将此指针设置为 0x80
,以跳过较低的内存区域(通常用于临时变量或内置变量)。因此,当未来的操作想要分配内存时,它们会查看 mload(0x40)
,获取 0x80
,并从那里开始写入。
CALLVALUE ; 将 msg.value 推送到堆栈
DUP1 ; 复制它(以便我们可以使用两次)
ISZERO ; 检查 msg.value == 0
PUSH2 0x0010 ; 如果为零,则跳转到实际逻辑(偏移量 0x10)
JUMPI ; 条件跳转(如果值为零,则没问题)
PUSH1 0x00 ; 设置 revert 数据的偏移量 = 0
DUP1 ; 设置长度 = 0(没有错误消息)
REVERT ; 中止交易,不带消息
它存在的原因:
此模式由 Solidity 编译器为所有非 payable 函数自动生成,并出现在函数调度逻辑的最开始。
... ; [这里不会介绍,不是那么重要]
PUSH1 0x4 ; [顶部] 推送常量 4 - 最小选择器长度
CALLDATASIZE ; [下一个] 推送 calldata 的大小
LT ; 比较:calldataSize < 4 吗?
PUSH1 0x49 ; 如果为 true,则跳转到 fallback
JUMPI
CALLDATALOAD ; 将 calldata 的前 32 个字节加载到堆栈上
PUSH29 0x100000000000000000000000000000000000000000000000000000000 ; 位掩码:隔离前 4 个字节(calldata 的前 4 个字节)
SWAP1 ; 交换掩码和 calldata,以便掩码位于顶部
DIV ; 将选择器向下移动 28 个字节(删除尾随零)
PUSH4 0xffffffff ; 0xFFFFFFFF 掩码,以确保仅保留前 4 个字节
AND ; 最终清理以准确获取 4 字节选择器
正在发生的事情:
DIV
向下移动选择器,AND
清理其余部分。这确保了合约可以安全且精确地识别要执行的函数。
DUP1 ; 复制选择器
PUSH4 0x2E64CEC1 ; 合约中的第一个函数选择器
EQ ; 是否相等?
PUSH1 0x4E
JUMPI ; 如果是,则跳转到该函数的代码
DUP1 ; 堆栈上仍然是相同的选择器
PUSH4 0x6057361D ; 第二个函数选择器
EQ
PUSH1 0x76
JUMPI ; 如果匹配,则跳转到其他函数
JUMPDEST ; 如果没有匹配的...
PUSH1 0x00
DUP1
REVERT ; Revert:未找到函数
... ; [这里不会介绍]
这是函数调度器,它就像字节码中用于函数路由的手动 switch-case
。这确保了无效或未知的函数调用被安全地拒绝。
结论
在本节中,我们解开了当在 EVM 上调用合约函数时真正发生的事情,从如何解析和路由 calldata,到 ABI 如何构造数据,再到在嵌套和失败的调用期间发生的事情。有了这个基础,你将能够更加自信地调试、推理甚至优化底层的智能合约行为。
CALL
, DELEGATECALL
, STATICCALL
, CALLCODE
Call(调用)
在新上下文中执行来自另一个合约(address
)的代码。
上下文行为:
msg.sender
→ 变为调用合约。msg.value
→ 通过调用传递的值。存储:使用被调用合约的存储。
它为什么有用:发送 ETH,调用外部函数,完全的灵活性。
典型用法:底层函数调用(.call{...}(calldata)
),ETH 转移。
常见风险:重入 —— 因为完全控制权传递给了外部代码。
Delegate Call(委托调用)
在你自己的执行上下文中运行来自另一个合约的代码。
上下文行为:
msg.sender
和 msg.value
→ 保留(与原始 tx 相同)。存储:使用调用者的存储。
它为什么有用:启用可升级合约和代理模式。
典型用法:透明代理合约将逻辑委托给共享实现。
常见风险:如果存储布局不匹配,状态可能会损坏。
Static Call(静态调用)
调用另一个合约,只读。
上下文行为:
msg.sender
和 msg.value
→ 保留。不允许写入:任何存储变异都会导致 revert。
它为什么有用:安全的外部 view 调用 —— 没有状态更改的风险。
典型用法:View/pure 函数,只读聚合器,链上验证器。
常见限制:无法调用写入状态的函数 —— 即使是间接的。
Call Code(代码调用)
执行来自另一个合约的代码,但写入到你自己的存储。
上下文行为:
msg.sender
和 msg.value
→ 设置为直接调用者(不保留)。存储:修改调用者的存储。
它为什么有风险:msg.sender
和存储上下文之间的不匹配导致了 bug。
现代状态:已弃用,取而代之的是 DELEGATECALL
—— 避免使用。
在使用智能合约时,理解内部和外部函数调用之间的区别至关重要,因为它们会影响执行上下文、gas 效率和安全性。
内部调用
当合约中的一个函数调用同一合约内的另一个函数或从继承的函数调用时,会发生内部调用。这些调用由 EVM 通过 JUMP
或 JUMPDEST
操作码直接处理,而不是通过消息调用处理,这使得它们在 gas 方面更有效。
msg.sender
和 msg.value
示例:
contract MyContract {
function publicCaller() public {
internalFunction(); // 内部调用
}
function internalFunction() internal {
// 这里的逻辑
}
}
外部调用
外部调用 涉及从合约外部调用函数,即使它是对同一合约地址的调用。这是使用消息调用完成的,并通过 CALL
、DELEGATECALL
或 STATICCALL
等操作码处理。
interface Token {
function transfer(address to, uint256 amount) external returns (bool);
}
contract Caller {
function paySomeone(address token, address recipient, uint256 amount) external {
// 外部调用:ABI 编码的 calldata 被发送到 token 合约
Token(token).transfer(recipient, amount);
}
}
以下是底层发生的事情:
Token(token)
将原始地址转换为类型化的接口。Solidity 编译器为 transfer(address,uint256)
生成 ABI 编码的 calldata:
keccak256("transfer(address,uint256)")
)recipient
,以及 32 字节编码的 amount
。CALL
操作码用于将此数据传递到 token
合约。
该调用在隔离的子上下文中执行:
msg.sender
变为 Caller
(不是原始交易发送者)。storage
和 code
来自目标合约(token 合约)。在 Ethereum 中,应用程序二进制接口 (ABI) 定义了在与智能合约交互时如何编码和解码数据结构和函数。无论你是手动调用函数、分析事务 calldata 还是使用底层 call
,理解 ABI 编码都是必不可少的。
让我们使用一个比简单的 uint256
函数稍微复杂的例子来探索 ABI 编码:一个带有自定义结构体的合约。
pragma solidity 0.8.12;
contract Storage {
struct my_storage_struct {
uint256 number;
string owner;
}
my_storage_struct my_storage;
function store(my_storage_struct calldata new_storage) public {
my_storage = new_storage;
}
function retrieve() public view returns (my_storage_struct memory){
return my_storage;
}
}
输出 ABI:
[
{
"inputs": [
{
"components": [
{
"internalType": "uint256",
"name": "number",
"type": "uint256"
},
{
"internalType": "string",
"name": "owner",
"type": "string"
}
],
"internalType": "struct Storage.my_storage_struct",
"name": "new_storage",
"type": "tuple"
}
],
"name": "store",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "retrieve",
"outputs": [
{
"components": [
{
"internalType": "uint256",
"name": "number",
"type": "uint256"
},
{
"internalType": "string",
"name": "owner",
"type": "string"
}
],
"internalType": "struct Storage.my_storage_struct",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
}
]
调用 store(...)
当调用 store({ number: 123456789, owner: "bob"})
时,ABI 编码过程的工作方式如下:
函数选择器(4 字节):
它是函数签名的 Keccak-256 哈希的前 4 个字节。
签名:"store((uint256,string))"
哈希:0xddd456b3...
选择器:0xddd456b3
参数编码(元组):
由于我们传递的是带有字段(uint256 number,string
)的结构体,因此 ABI 将该值编码为填充的 32 字节字:
2.1. 数字编码:0x00000000000000000000000000000000000000000000000000000000075bcd15
2.2. 字符串“bob”的编码:
字符串在 ABI 编码中是动态大小的。它们被编码为指向偏移量的指针,后跟字符串长度和 UTF-8 字节。
0x40
(十进制的 64),因为字符串在 2 个字(32 字节 * 2)之后开始。0x0000000000000000000000000000000000000000000000000000000000000040
0x0000000000000000000000000000000000000000000000000000000000000003
0x626f62
("bob"
的 ASCII 码),填充到 32 字节0x626f620000000000000000000000000000000000000000000000000000000000
3. 最终 calldata:
0xddd356b3
0000000000000000000000000000000000000000000000000000000000000020
00000000000000000000000000000000000000000000000000000000075bcd15
0000000000000000000000000000000000000000000000000000000000000040
0000000000000000000000000000000000000000000000000000000000000003
626f620000000000000000000000000000000000000000000000000000000000
调用 retrieve()
当调用函数 retrieve() public view returns (my_storage_struct memory) 时,你将收到一个ABI 编码为元组的返回值:(uint256 number, string owner)
从智能合约角度来看的解码看起来像:
(uint256 number, string memory owner) = abi.decode(returnData, (uint256, string));
// 你会得到
number = 123456789
owner = "bob"
在 EVM 中,当出现问题时,revert 是智能合约安全地撤消状态更改的方式。
当执行 REVERT
操作码时:
revert 可以手动调用:
require(x > 0, "x 必须为正数");
// 或者
revert("自定义错误消息");
当一个合约调用另一个合约(通过 CALL
、DELEGATECALL
等)并且被调用者 revert 时,revert 会 “向上冒泡” 到调用者:
try/catch
捕获,它也会自动向上冒泡并 revert 调用者。示例:
function outer() public {
inner(); // 如果 `inner()` revert,那么 `outer()` 也会 revert
}
function inner() public {
require(false, "fail");
}
或者可以捕获它:
try otherContract.doSomething() {
// 成功
} catch Error(string memory reason) {
// reason 是 revert 消息
} catch {
// 捕获所有(例如,无效的操作码)
}
每当一个合约通过 call
、delegatecall
或 staticcall
调用另一个合约时,EVM 就会启动一个新的调用上下文,就像一个新的迷你 EVM,它有自己的堆栈、内存和 msg.sender/value(取决于调用类型)。
如果该调用失败,它可以 revert 自己的状态更改并将错误返回(“向上冒泡”)给其调用者,除非通过 try/catch
处理。这种机制允许 Solidity 构建复杂的、可组合的系统,同时仍然保持强大的故障安全性。
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!