EVM内部知识 (2)

在本文结束时,你将对智能合约调用在底层的行为有一个清晰的认识——尤其是在执行变得复杂、嵌套或意外失败时。

EVM内部知识 (2)
一键发币: SUI | SOL | BNB | ETH | BASE | ARB | OP | POLYGON | 用AI学区块链开发

在第1部分中,我们打下了基础:我们探讨了gas的工作原理,什么是智能合约,以及EVM如何处理栈、内存、存储和calldata等核心组件。我们还看了高级Solidity代码是如何被编译成字节码并作为操作码在EVM中执行的。

在本文中,我们将在此基础上进一步探讨以下主题:

  1. 可支付(Payable)、回退(Fallback)和接收(Receive)
  2. 当calldata进入EVM时到底发生了什么
  3. 低级操作码: CALL, DELEGATECALL, STATICCALL, CALLCODE
  4. 内部与外部函数调用
  5. ABI编码深入解析
  6. 如何实现回滚(reverts)(以及如何上浮)

在本文结束时,您将对智能合约调用在底层的行为有一个清晰的认识——尤其是在执行变得复杂、嵌套或意外失败时。

1、Payable、Fallback和Receive

1.1 可支付(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);

1.2 接收(Receive)

一个合约可以定义最多一个 receive() 函数,写法如下:

receive() external payable { ... }

这里会发生什么:

  • 必须是 externalpayable
  • 不能接受参数或返回值。
  • 发送以太币且calldata为空时触发——比如 .send().transfer()
  • 如果没有 receive() 函数但有 payable fallback() 函数,则使用该回退函数。
  • 如果两者都没有,交易会回滚,以太币会返还给发送者。

注意:当以这种方式发送以太币时,可用的gas限制为 2300,足够发出事件,但* 足以写入存储、调用其他合约或发送ETH。

示例:

contract MyContract{  
    event Received(address sender, uint amount);  

    receive() external payable {  
        emit Received(msg.sender, msg.value);  
    }  
}

1.3 回退(Fallback)

合约还可以定义一个 fallback() 函数,如:

fallback() external [payable] { ... }  
// 或带有calldata访问和返回:  
fallback(bytes calldata input) external [payable] returns (bytes memory)

触发条件:

  • 没有匹配的函数
  • 如果calldata为空且没有 receive() 函数

将其标记为 payable 以接收ETH。可以访问 msg.data(calldata)并返回原始字节。

示例:

contract Example {  
    fallback() external payable {  
        // 当未知函数调用或没有 `receive()` 时触发  
    }  
}

总结

如果同时存在 receive()payable fallback()

  • 空calldata → 使用 receive()
  • 非空calldata(即使未知函数)→ 使用 fallback()

一个没有这两者的合同无法通过 .send().transfer() 接收纯以太币。

以太币仍可以通过以下方式到达:

  • selfdestruct(address)
  • 块奖励(coinbase

但合约不会做出反应,因为没有代码运行。更多信息请参见此处

2、当calldata进入EVM时到底发生了什么

当一个智能合约在EVM上被调用时,Solidity中的函数名和参数不是EVM看到的内容。相反,交易中的 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 作为未来动态内存分配的起始偏移量。但为什么在 0x40 存储 0x80?这是 Solidity 约定的“免费内存指针。内存位置 0x40保留 的,用于存储内存中下一个可用空闲空间的指针。在执行开始时,Solidity 将此指针设置为 0x80 以跳过较低的内存区域(通常用于临时变量或内置项)。因此,当未来的操作需要分配内存时,它们会查看 mload(0x40),得到 0x80,并从那里开始写入。

CALLVALUE       ; 将 msg.value 推送到堆栈  
DUP1            ; 复制它(以便两次使用)  
ISZERO          ; 检查是否 msg.value == 0  
PUSH2 0x0010    ; 如果为零,跳转到实际逻辑(偏移量 0x10)  
JUMPI           ; 条件跳转(如果价值为零,我们没问题)  

PUSH1 0x00      ; 设置偏移量 = 0 用于回滚数据  
DUP1            ; 设置长度 = 0(无错误消息)  
REVERT          ; 中止交易,无消息

为什么存在:

  • 确保非可支付函数在发送任何 ETH 时 回滚
  • 防止由于错误调用导致的意外 ETH 损失
  • 保持合同行为可预测和安全

这种模式由 Solidity 编译器为所有非可支付函数自动生成,并出现在 函数分发逻辑的最开始

...                   ; [不会在这里涵盖,不重要]  
PUSH1 0x4            ; [顶部] 推送常量 4 — 最小选择器长度  
CALLDATASIZE          ; [下一步] 推送 calldata 的大小  
LT                    ; 比较:calldataSize < 4?  
PUSH1 0x49            ; 如果是,跳转到回退  
JUMPI  

CALLDATALOAD          ; 将 calldata 的前 32 字节加载到堆栈中  
PUSH29 0x100000000000000000000000000000000000000000000000000000000  ; 位掩码:隔离前 4 字节(calldata 的前 4 字节)  
SWAP1                 ; 交换掩码和 calldata,使掩码位于顶部  
DIV                   ; 将选择器向下移动 28 字节(删除尾随零)  
PUSH4 0xffffffff      ; 0xFFFFFFFF 掩码以确保只有前 4 字节保留  
AND                   ; 最终清理以获取精确的 4 字节选择器

发生了什么:

  • EVM 检查是否有至少 4 字节的 calldata(函数选择器的最小长度)。
  • 如果没有 → 跳转到回退逻辑。
  • 否则 → 提取选择器的方式是:
  • 加载 calldata 的前 32 字节,
  • 使用 DIV 将选择器向下移动,
  • AND 清理其余部分。

这确保了合同可以安全且准确地识别要执行的函数。

DUP1                   ; 复制选择器  
PUSH4 0x2E64CEC1       ; 合同中的第一个函数选择器  
EQ                     ; 是否相等?  
PUSH1 0x4E  
JUMPI                  ; 如果是,跳转到该函数的代码  

DUP1                   ; 仍然相同的选择器在堆栈上  
PUSH4 0x6057361D       ; 第二个函数选择器  
EQ  
PUSH1 0x76  
JUMPI                  ; 如果匹配,跳转到另一个函数  

JUMPDEST               ; 如果没有匹配...  
PUSH1 0x00  
DUP1  
REVERT                 ; 回滚:找不到函数  
...                    ; [不会在这里涵盖]

这是函数 调度器,它就像一个手动的 switch-case 用于字节码中的函数路由。这确保了无效或未知的函数调用被安全拒绝。

结论

在本节中,我们剖析了当一个合同函数在EVM上调用时到底发生了什么,从calldata的解析和路由,到ABI结构化数据,再到嵌套和失败调用时发生的事情。有了这个基础,您将能够更自信地调试、推理甚至优化低级智能合约行为。

3、CALL, DELEGATECALL, STATICCALL...

3.1 Call

在新的上下文中执行另一个合同(address)的代码。

上下文行为:

  • msg.sender → 变为调用合同。
  • msg.value → 通过调用传递的值。

存储:使用被调用合同的存储。

为什么有用:发送ETH,调用外部函数,完全灵活。

典型用法:低级函数调用(.call{...}(calldata)),ETH转账。

常见风险:重入 —— 因为完全控制权交给了外部代码。

3.2 Delegate Call

自己的执行上下文中运行另一个合同的代码。

上下文行为:

  • msg.sendermsg.value → 保留(与原始交易相同)。

存储:使用调用者的存储。

为什么有用:启用可升级合同和代理模式。

典型用法:透明代理合同将逻辑委托给共享实现。

常见风险:如果存储布局不匹配,状态可能会损坏。

3.3 Static Call

调用另一个合同,只读

上下文行为:

  • msg.sendermsg.value → 保留。

不允许写入:任何存储修改会导致回滚。

为什么有用:安全的外部视图调用 —— 没有状态更改的风险。

典型用法:view/pure函数,只读聚合器,链上验证器。

常见限制:不能调用会写入状态的函数 —— 即使是间接的。

3.4 Call Code

执行另一个合同的代码,但写入自己的存储。

上下文行为:

  • msg.sendermsg.value → 设置为直接调用者(不是保留的)。

存储:调用者的存储被修改。

为什么危险:msg.sender 和存储上下文之间的不匹配导致了错误。

现代状态:已被 DELEGATECALL 取代 —— 避免使用。

4、内部与外部函数调用

在处理智能合约时,理解内部外部函数调用之间的区别至关重要,因为它们会影响执行上下文、gas效率和安全性。

4.1 内部调用

内部调用发生在合约中的一个函数调用同一合约内的另一个函数,或者从继承的合约中调用。这些调用通过 JUMPJUMPDEST 操作码直接由EVM处理,而不是消息调用,因此在gas方面更高效

  • 不涉及calldata
  • 通过直接控制流(跳转)执行
  • 保留 msg.sendermsg.value

示例:

contract MyContract {  
    function publicCaller() public {  
        internalFunction(); // 内部调用  
    }  

    function internalFunction() internal {  
        // 逻辑在这里  
    }  
}

4.2 外部调用

外部调用涉及调用外部合约的函数,即使它是指向同一合同的地址。这是通过消息调用完成的,并通过 CALLDELEGATECALLSTATICCALL 等操作码处理。

  • calldata 是 ABI 编码并传入 EVM
  • 被视为新的交易上下文
  • gas 成本较高,因为上下文切换
  • 可以与其他合同交互
  • 上下文变化如前所述
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:

  • 前4字节:函数选择器(keccak256("transfer(address,uint256)")
  • 然后是:32字节编码的 recipient,和32字节编码的 amount

使用 CALL 操作码将此数据传递给 token 合约。

调用在隔离的子上下文中执行:

  • msg.sender 变为 Caller(不是原始交易发送者)。
  • storagecode 来自目标合约(即 token)。

5、ABI 编码深度解析

在以太坊中,应用二进制接口(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 编码过程如下:

  1. 函数选择器(4字节):
    它是函数签名的 Keccak-256 哈希的前4字节。
    签名:"store((uint256,string))"
    哈希:0xddd456b3...
    选择器:0xddd456b3
  2. 参数编码(元组):
    由于我们传递了一个带有字段的结构体(uint256 number,string),ABI 会将值编码为填充的32字节单词:
    2.1. 编码数字:0x00000000000000000000000000000000000000000000000000000000075bcd15
    2.2. 编码字符串 “bob”:
    字符串在 ABI 编码中是动态大小的。它们被编码为一个指向偏移量的指针,然后是字符串长度和 UTF-8 字节。
  • 偏移量:相对于参数块的开始。0x40(十进制64),因为字符串在2个字(32字节 × 2)之后开始。
    0x0000000000000000000000000000000000000000000000000000000000000040
  • “bob”的长度:3
    0x0000000000000000000000000000000000000000000000000000000000000003
  • 字节:0x626f62(ASCII 表示 "bob"),填充到32字节
    0x626f620000000000000000000000000000000000000000000000000000000000
  1. 最终的 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"

6、如何实现回滚(reverts)

在EVM中,回滚(reverts)是智能合约在出现问题时安全地撤销状态更改的方式。

当执行 REVERT 操作码时:

  • 所有在调用期间对存储的更改都会被回滚(即撤销)。
  • 剩余的gas不会被消耗
  • 会返回一个可选的回滚原因(revert reason),以ABI编码的数据形式。

可以手动调用回滚:

require(x > 0, "x must be positive");  
//或者  
revert("custom error message");

当一个合同通过 CALLDELEGATECALL 等调用另一个合同时,如果被调用方回滚,回滚**会“上浮”**到调用者:

  • 由调用者决定处理传播该错误。
  • 在Solidity中,如果没有通过 try/catch 捕获,它会自动上浮并导致调用者也回滚。

示例:

function outer() public {  
    inner(); // 如果 `inner()` 回滚,那么 `outer()` 也会回滚  
}  

function inner() public {  
    require(false, "fail");  
}

或者它可以被捕获:

try otherContract.doSomething() {  
    // 成功  
} catch Error(string memory reason) {  
    // reason 是回滚信息  
} catch {  
    // 捕获所有(例如,无效操作码)  
}

6、结束语

每当一个合同通过 calldelegatecallstaticcall 调用另一个合同时,EVM会启动一个新的调用上下文,像一个新鲜的迷你EVM,有自己的栈、内存和msg.sender/value(取决于调用类型)。

如果该调用失败,它可以回滚自己的状态更改并将错误“上浮”回调用者,除非通过 try/catch 处理。这种机制允许Solidity构建复杂、可组合的系统,同时保持强大的失败安全性。


原文链接:What Every Blockchain Developer Should Know About EVM Internals – Part 2

DefiPlot翻译整理,转载请标明出处

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。