Ethereum 学习笔记 ---- Solidity 数据位置(Calldata, Memory, Storage)编码与布局全解析

一、EVM 的数据区划分

EVM 运行时有明确分离的几个区域:

stack       栈(操作数)
memory      内存(临时)
storage     持久存储
calldata    调用输入数据
code        合约字节码

可以理解为:

          ┌──────────────┐
          │   calldata   │   ← 交易输入(只读)
          └──────────────┘

          ┌──────────────┐
          │    memory    │   ← 可写临时内存
          └──────────────┘

          ┌──────────────┐
          │    storage   │   ← 链上状态
          └──────────────┘

          ┌──────────────┐
          │    stack     │   ← 运算栈(最大1024深度)
          └──────────────┘

二、理解函数调用中 calldata/memory/storage 参数的传递方式

测试代码

在 foundry 项目中,创建 test/DataLayout.t.sol,代码如下:

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

import "forge-std/Test.sol";
import {console} from "forge-std/Script.sol";

contract Callee {
    // 用于测试 storage 传递的状态变量
    uint256[] public storageData;

    // --- Calldata Section ---
    function public_calldata(
        uint256 a,
        uint256 b,
        uint256[] calldata data
    ) public view {
        console.log("--- Calldata Test ---");
        console.log("Callee.this:", address(this));
        console.log("a:", a);
        console.log("b:", b);

        uint offset;
        uint length;
        uint dataAtOffset;
        assembly {
            offset := data.offset
            length := data.length
            dataAtOffset := calldataload(data.offset)
        }
        console.log("public_calldata data.offset", offset);
        console.log("public_calldata data.length", length);
        console.log("public_calldata calldataload(data.offset)", dataAtOffset);

        console.log("_doInternalCalldata(data):");
        _doInternalCalldata(data);
        console.log("_doInternalCalldata(data[1:]):");
        _doInternalCalldata(data[1:]);
        console.log("_doInternalCalldata(data[2:]):");
        _doInternalCalldata(data[2:]);
    }

    function _doInternalCalldata(uint256[] calldata data) internal pure {
        uint offset;
        uint length;
        uint dataAtOffset;
        assembly {
            offset := data.offset
            length := data.length
            dataAtOffset := calldataload(data.offset)
        }
        console.log("\t calldata offset:", offset);
        console.log("\t calldata length:", length);
        console.log("\t calldata calldataload(data.offset):", dataAtOffset);
    }

    // --- Memory Section ---
    function public_memory(
        uint256 a,
        uint256 b,
        uint256[] memory data
    ) public view {
        console.log("\n");
        console.log("--- Memory Test ---");
        console.log("Callee.this:", address(this));
        console.log("a:", a);
        console.log("b:", b);

        uint ptr;
        uint length;
        uint firstElement;
        assembly {
            ptr := data // memory 变量名在汇编中就是它的内存地址指针
            length := mload(data) // 内存数组的前 32 字节是长度
            firstElement := mload(add(data, 32)) // 长度之后才是第一个元素
        }
        console.log("public_memory memory pointer (addr):", ptr);
        console.log("public_memory memory length:", length);
        console.log("public_memory first element value:", firstElement);

        console.log("_doInternalMemory(data):");
        _doInternalMemory(data);

        // _doInternalMemory(data[1:]); // 这里会有错误提示:Index range access is only supported for dynamic calldata arrays.
    }

    function _doInternalMemory(uint256[] memory data) internal pure {
        uint ptr;
        uint length;
        uint firstElement;
        assembly {
            ptr := data // memory 变量名在汇编中就是它的内存地址指针
            length := mload(data) // 内存数组的前 32 字节是长度
            firstElement := mload(add(data, 32)) // 长度之后才是第一个元素
        }
        console.log("\t memory pointer (addr):", ptr);
        console.log("\t memory length:", length);
        console.log("\t first element value:", firstElement);
    }

    // --- Storage Section ---

    function public_storage(
        uint256 a,
        uint256 b,
        uint256[] memory data
    ) public {
        console.log("\n");
        console.log("--- Storage Test ---");
        console.log("Callee.this:", address(this));
        console.log("a:", a);
        console.log("b:", b);

        delete storageData;
        for (uint i = 0; i < data.length; i++) {
            storageData.push(data[i]);
        }
        _doInternalStorage(storageData);
    }

    function _doInternalStorage(uint256[] storage data) internal view {
        uint256 slot;
        uint256 length;
        uint256 p; // 计算出的起始数据位置
        uint256 firstElement;
        uint256 secondElement;

        assembly {
            // 1. 获取数组定义所在的 Slot (存储长度的地方)
            slot := data.slot
            length := sload(slot)

            // 2. 计算 p = keccak256(slot)
            // 我们需要把 slot 的值存入内存才能进行 keccak256 计算
            // 使用临时内存地址 0x00
            mstore(0x00, slot)
            p := keccak256(0x00, 32)

            // 3. 使用 sload(p) 获取第一个元素
            firstElement := sload(p)
            secondElement := sload(add(p, 1))
        }

        console.log("\t storage slot:", slot);
        console.log("\t storage array length:", length);
        console.log("\t Data Start Path (p = keccak256(slot)):", p);
        console.log(
            "\t keccak256(abi.encode(0)):",
            uint(keccak256(abi.encode(0)))
        );
        console.log("\t p in hex format:");
        console.logBytes32(bytes32(p));
        console.log("\t First Element via sload(p):", firstElement);
        console.log("\t Second Element via sload(add(p, 1)):", secondElement);
    }
}

contract Caller is Test {
    Callee callee;

    function setUp() public {
        callee = new Callee();
    }

    function test_all_data_locations() public {
        uint256[] memory data = new uint256[](5);
        data[0] = 10;
        data[1] = 20;
        data[2] = 30;
        data[3] = 40;
        data[4] = 50;

        // 1. Calldata 测试
        callee.public_calldata(1, 2, data);

        // 2. Memory 测试
        callee.public_memory(3, 4, data);

        // 3. Storage 测试
        callee.public_storage(5, 6, data);
    }
}

执行结果

$ forge test --match-test test_all_data_locations -vvv
[⠊] Compiling...
[⠆] Compiling 1 files with Solc 0.8.33
[⠰] Solc 0.8.33 finished in 286.97ms
Compiler run successful!

Ran 1 test for test/Memory.t.sol:Caller
[PASS] test_all_data_locations() (gas: 197860)
Logs:
  --- Calldata Test ---
  Callee.this: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
  a: 1
  b: 2
  public_calldata data.offset 132
  public_calldata data.length 5
  public_calldata calldataload(data.offset) 10
  _doInternalCalldata(data):
         calldata offset: 132
         calldata length: 5
         calldata calldataload(data.offset): 10
  _doInternalCalldata(data[1:]):
         calldata offset: 164
         calldata length: 4
         calldata calldataload(data.offset): 20
  _doInternalCalldata(data[2:]):
         calldata offset: 196
         calldata length: 3
         calldata calldataload(data.offset): 30
  

  --- Memory Test ---
  Callee.this: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
  a: 3
  b: 4
  public_memory memory pointer (addr): 128
  public_memory memory length: 5
  public_memory first element value: 10
  _doInternalMemory(data):
         memory pointer (addr): 128
         memory length: 5
         first element value: 10
  

  --- Storage Test ---
  Callee.this: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
  a: 5
  b: 6
         storage slot: 0
         storage array length: 5
         Data Start Path (p = keccak256(slot)): 18569430475105882587588266137607568536673111973893317399460219858819262702947
         keccak256(abi.encode(0)): 18569430475105882587588266137607568536673111973893317399460219858819262702947
         p in hex format:
  0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
         First Element via sload(p): 10
         Second Element via sload(add(p, 1)): 20

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 444.54µs (214.71µs CPU time)

Ran 1 test suite in 88.37ms (444.54µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

三、深入底层:Solidity 数据位置(Calldata, Memory, Storage)编码与布局全解析

在以太坊虚拟机(EVM)中,理解数据在不同位置(Data Location)的物理布局是进行 Gas 优化和底层开发的必修课。本文将基于 Foundry 实测数据 ,深入探讨 calldatamemorystorage 的编码逻辑。


1. Calldata 布局:双重偏移与高效切片

calldata 是一个只读、临时的存储区域,存放着交易的入参数据 。对于动态类型数组,ABI 编码采用“头尾分离”的原则 。

1.1 ABI 编码字节段分解

以函数 public_calldata(uint256 a, uint256 b, uint256[] calldata data) 为例 ,当 a=1, b=2, data=[10, 20, 30, 40, 50] 时,编码格式如下:

字节范围 数据值 (Hex) 字段说明
0x00 - 0x03 0xb5834606 Function Selector: bytes4(keccak256("public_calldata(uint256,uint256,uint256[])"))
0x04 - 0x23 ...0001 参数 a: 1
0x24 - 0x43 ...0002 参数 b: 2
0x44 - 0x63 ...0060 Array Offset: 数组内容相对参数起始位置 (0x04) 的偏移量 (96 字节)
--- 数据区 ---
0x64 - 0x83 ...0005 Array Length: 数组长度 (5)
0x84 - 0xa3 ...000a data[0]: 第一个元素 (10) —— 这是 data.offset 指向的位置
0xa4 - 0xc3 ...0014 data[1]: 第二个元素 (20) —— 这是 data.offset+32 指向的位置

1.2 底层原理解析

  • 偏移量逻辑:位于 0x44 的值 0x60(即 96 字节)表示从参数起始位置(0x04)向后偏移 96 字节到达数组的“长度”字段(即 0x04 + 96 = 1000x64)。
  • Assembly 指针:在汇编中,data.offset 返回的是数组第一个元素在整个 calldata 中的绝对位置 。实测值为 132 (100 + 32) 。
  • 切片 (Slicing):执行 data[1:] 时,底层仅需将 offset 变量增加 32 字节(变为 164),同时将 length 减 1 。这一过程不涉及内存拷贝,极度节省 Gas 。

2. Memory 布局:线性增长的寻址

memory 是合约运行时的内存空间,在汇编层面表现为指向内存地址的指针 。

2.1 内存结构图

[ 0x80 ] : 0x00000005 (Array Length)
[ 0xa0 ] : 0x0000000a (data[0])
[ 0xc0 ] : 0x00000014 (data[1])
...

2.2 输出解读

  • Pointer (ptr):在测试中,memory pointer (addr)128 (0x80) 。这是 Solidity 默认分配动态对象的起始位置(紧跟在 64 字节的暂存空间 + 32 字节的空闲内存指针 + 32 字节零槽 之后)。
  • 访问机制:与 calldata 类似,前 32 字节是长度 。但 memory 数组名本身即代表其长度字段所在的内存地址 。
  • 限制memory 数组不支持切片操作 。

3. Storage 布局:基于插槽的哈希映射

storage 数据持久化在状态树中,动态数组的存储位置通过哈希计算分散在不同的插槽(Slot)中 。

3.1 存储寻址逻辑

动态数组 uint256[] storageData 的存储遵循以下公式:

  1. Slot n:存储数组的长度
  2. 数据基地址 p:p = keccak256(n) 。
  3. 元素 i 的地址:Slot(p + i) 。

3.2 实验数据验证

  • 插槽定位:由于 storageData 是合约首个变量,其占用的 slot0
  • 哈希计算p = keccak256(0),计算结果为 0x290decd9...e563
  • 数据验证:通过汇编执行 sload(p) 成功读取到 10(数组第一个元素),执行 sload(p + 1) 读取到 20

3.2.1 补充说明:Memory 和 Storage 的寻址

内存(Memory)是按字节寻址的,而存储(Storage)是按插槽(Slot)寻址的。

3.3.1.1 内存 (Memory):字节级寻址 (Byte-addressed)

EVM 内存(Memory)被设计为一个线性的字节数组。在内存中,每一个地址增量代表 1 个字节 (8 bits)

  • 一个 uint256 类型占用 32 字节。
  • 如果第一个元素存储在地址 ptr,那么为了跳过这 32 字节并指向下一个元素的起始位置,必须在地址指针上增加 32
  • 示例代码中:firstElement := mload(add(data, 32)),此处加 32 是为了跳过存储长度的第一个 32 字节块。
3.2.1.2 存储 (Storage):插槽级寻址 (Slot-addressed)

EVM 存储(Storage)被设计为一个巨大的键值对映射,其中的“键”被称为 插槽索引 (Slot Index)

  • 每一个插槽(Slot)的大小固定为 32 字节
  • sload(k) 指令的作用是:从第 k 个插槽中完整读取 32 字节的数据。
  • 插槽在逻辑上是连续排列的。第 n 个插槽之后紧跟着就是第 n+1 个插槽。
  • 当通过 p := keccak256(0x00, 32) 计算出动态数组的数据起始位置时,得到的结果 p 本身就是一个 插槽编号
  • 为了访问数组的下一个 32 字节元素,只需要将插槽编号加 1
  • 示例代码中:secondElement := sload(add(p, 1))

4. 核心对比总结

维度 Calldata Memory Storage
汇编底层值 整个输入区的绝对字节偏移量 线性内存空间的绝对地址指针 状态树中的插槽 (Slot) 索引
长度存放 作为独立变量存在于栈中 存放在 ptr 指向的首个 32 字节 存放在 slot 索引对应的位置
首元素定位 calldataload(data.offset) mload(ptr + 32) sload(keccak256(slot))
主要优势 切片零拷贝 读写速度快 数据持久化存储

通过分析可以发现,Solidity 在设计这三种位置时充分权衡了 Gas 消耗与访问灵活性。在处理大规模只读数据时,优先使用 calldata 切片是最高效的选择。

posted on 2026-03-19 12:38  HorseShoe2016  阅读(3)  评论(0)    收藏  举报