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 实测数据 ,深入探讨 calldata、memory 与 storage 的编码逻辑。
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 = 100或0x64)。 - 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 的存储遵循以下公式:
- Slot n:存储数组的长度 。
- 数据基地址 p:p = keccak256(n) 。
- 元素 i 的地址:Slot(p + i) 。
3.2 实验数据验证
- 插槽定位:由于
storageData是合约首个变量,其占用的slot为 0 。 - 哈希计算:
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 切片是最高效的选择。
浙公网安备 33010602011771号