1. 三种低级调用方式对比

调用方式 是否切换上下文(storage/msg.sender/msg.value) 是否能改状态 特点与用途
call ✅ 切换到被调用合约 最通用的外部调用,可带 ETH,可调用任意函数
delegatecall ❌ 保持当前合约上下文 代理模式核心,让当前合约执行别人的代码
staticcall ✅ 切换到被调用合约 安全读取外部数据,不改状态

记忆口诀:

  • call:切场景、能改状态。
  • delegatecall:不切场景、能改状态。
  • staticcall:切场景、不能改状态。

2. 原理解析

在 EVM 中,外部调用本质是一次 CALL 指令:

CALL(gas, to, value, in_offset, in_size, out_offset, out_size)
  • gas:给被调用者的剩余 gas。
  • to:目标地址。
  • value:转账的 wei 数量。
  • in_offset / in_size:内存中输入数据的位置和长度(ABI 编码后)。
  • out_offset / out_size:输出数据存放位置和长度。

delegatecallcall 的主要区别是:

  • delegatecall 不会更改 msg.sendermsg.value
  • 存储上下文(storage slot)不切换,直接写到当前合约。

staticcall 的底层指令是 STATICCALL,它会禁止在调用期间修改状态。


3. call 示例

被调用合约:Callee.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Callee {
    uint256 public value;

    event ValueSet(uint256 newValue);

    function setValue(uint256 _v) external payable {
        value = _v;
        emit ValueSet(_v);
    }

    function getValue() external view returns (uint256) {
        return value;
    }
}

调用方合约:Caller.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Caller {
    // 通过 call 调用 setValue
    function callSetValue(address _callee, uint256 _v) external payable {
        (bool success, ) = _callee.call(
            abi.encodeWithSignature("setValue(uint256)", _v)
        );
        require(success, "call failed");
    }

    // 通过 staticcall 调用 getValue
    function callGetValue(address _callee) external view returns (uint256) {
        (, bytes memory data) = _callee.staticcall(
            abi.encodeWithSignature("getValue()")
        );
        return abi.decode(data, (uint256));
    }
}

4. delegatecall 示例(代理模式)

逻辑合约:Logic.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Logic {
    uint256 public value;

    function setValue(uint256 _v) external {
        value = _v;
    }
}

代理合约:Proxy.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Proxy {
    uint256 public value;

    function delegateSetValue(address _logic, uint256 _v) external {
        (bool success, ) = _logic.delegatecall(
            abi.encodeWithSignature("setValue(uint256)", _v)
        );
        require(success, "delegatecall failed");
    }
}

注意LogicProxy 必须有完全一致的存储布局,否则变量会错位(Storage Collision)。


5. Foundry 测试

test/LowLevelCall.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/Callee.sol";
import "../src/Caller.sol";
import "../src/Logic.sol";
import "../src/Proxy.sol";

contract LowLevelCallTest is Test {
    Callee callee;
    Caller caller;
    Logic logic;
    Proxy proxy;

    function setUp() public {
        callee = new Callee();
        caller = new Caller();
        logic = new Logic();
        proxy = new Proxy();
    }

    function testCallSetValue() public {
        caller.callSetValue(address(callee), 42);
        assertEq(callee.value(), 42);
    }

    function testStaticCallGetValue() public {
        caller.callSetValue(address(callee), 99);
        uint256 v = caller.callGetValue(address(callee));
        assertEq(v, 99);
    }

    function testDelegateCall() public {
        proxy.delegateSetValue(address(logic), 123);
        assertEq(proxy.value(), 123);
        assertEq(logic.value(), 0); // Logic 本身不变
    }
}

执行测试命令:

➜  counter git:(main) ✗ forge test --match-path test/LowLevelCall.t.sol -vvv
[⠊] Compiling...
[⠊] Compiling 2 files with Solc 0.8.29
[⠒] Solc 0.8.29 finished in 1.91s
Compiler run successful!

Ran 3 tests for test/LowLevelCall.t.sol:LowLevelCallTest
[PASS] testCallSetValue() (gas: 39545)
[PASS] testDelegateCall() (gas: 41920)
[PASS] testStaticCallGetValue() (gas: 41426)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 11.37ms (7.34ms CPU time)

Ran 1 test suite in 616.04ms (11.37ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

6. 常见陷阱

  • call 未检查返回值
      addr.call(data); // ❌ 忽略 success
    

    必须:

      (bool success, bytes memory ret) = addr.call(data);
      require(success, "call failed");
    
  • delegatecall 存储错乱:如果 Logic 的第一个状态变量是 address ownerProxyuint256 value,那么写入会覆盖错误的 slot。
  • call 触发重入攻击:外部调用前先更新状态(Checks-Effects-Interactions 模式)。
  • staticcall 不能修改状态:调用改状态的函数会直接 revert。

7. 最佳实践

场景 推荐方式 原因
调用外部合约并可能携带 ETH call 灵活,可同时发送数据和 ETH
代理模式 / 可升级合约 delegatecall 保持存储一致,执行外部逻辑
只读查询外部合约数据 staticcall 只读,避免误改状态

8. 总结

  • call:像跨合同打电话,带钱和信息。
  • delegatecall:让别人用你的钱包执行代码。
  • staticcall:借别人的计算器算一算,不动任何钱。

低级调用是合约开发的“裸金属编程”,没有编译器的保护网,一旦出错,可能是重入漏洞资金丢失数据错乱

最重要的建议

  • 始终检查 success
  • 先修改状态再外部调用
  • 代理模式要保持存储一致

孟斯特

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。
Author: mengbin
blog: mengbin
Github: mengbin92
腾讯云开发者社区:孟斯特