ABI 编码详解:函数调用数据是如何打包的
目录
- 一、为什么要懂 ABI 编码
- 二、函数签名与函数选择器
- 三、静态类型的编码
- 四、动态类型的编码:偏移量 + 长度 + 数据
- 五、偏移量的计数规则(最容易搞错的地方)
- 六、完整案例:嵌套数组编码
- 七、字符串与结构体编码
- 八、abi.encode 家族函数对比
- 九、calldata 的 Gas 成本计算
- 十、编码规则总结
- 十一、动手练习项目:手写 ABI 解码器 AbiDecoder
一、为什么要懂 ABI 编码
要理解 delegatecall、代理合约、底层 call,第一步必须搞懂合约调用时那串十六进制 calldata 到底是怎么拼出来的。ABI 编码就是把”调用哪个函数、传什么参数”翻译成 EVM 能读懂的字节序列的标准格式。
一条 calldata 的结构永远是:
[4 字节函数选择器][参数 1 编码][参数 2 编码]...
二、函数签名与函数选择器
2.1 签名怎么写
函数签名 = 函数名 + 参数类型列表,去掉空格和变量名。例如:
function transfer(address to, uint256 amount)
它的签名是:transfer(address,uint256)(注意逗号后无空格,没有 to/amount)。
2.2 选择器怎么算
函数选择器 = 函数签名的 Keccak-256 哈希的前 4 个字节。
以 transfer(address,uint256) 为例:
- 完整哈希:
0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b - 取前 4 字节(8 个十六进制字符):
0xa9059cbb
这 4 字节告诉合约”你要调用的是哪个函数”。
2.3 类型规范化的坑
计算签名时,某些类型要先转换成”规范形式”:
| 写法 | 规范化为 |
|---|---|
| struct 结构体 | tuple 元组 (type1,type2,...) |
| 合约类型、接口、payable address | address |
| enum 枚举 | uint8 |
| user-defined value type | 它的底层类型 |
| memory / calldata 修饰符 | 忽略 |
如果算签名时没规范化(比如把 enum 写成枚举名而不是 uint8),算出来的选择器就是错的,调用会失败。
三、静态类型的编码
固定大小的类型(bool、uintN、bytesN、address、内容是静态的定长数组)一律左侧补零,凑满 32 字节。
例:uint8 值为 5,编码成:
0x0000000000000000000000000000000000000000000000000000000000000005
address(20 字节)也是左补零到 32 字节。所有数据都以 32 字节为一个”字(word)“对齐。
四、动态类型的编码:偏移量 + 长度 + 数据
动态类型(string、bytes、动态数组、含动态内容的数组)不能直接塞,需要三段式:
- 偏移量(offset):一个指针,指明真正的数据从 calldata 的哪个位置开始;
- 长度(length):元素个数或字节数;
- 数据(data):实际内容,右侧补零到 32 字节的整数倍。
例:字符串 “hello”(UTF-8 是 0x68656c6c6f,5 字节)的数据段编码:
0x68656c6c6f000000000000000000000000000000000000000000000000000000
注意是右补零(动态数据),和静态类型的左补零相反。
五、偏移量的计数规则(最容易搞错的地方)
偏移量通常不是从它自己的位置开始数,而是从”描述该层嵌套结构的第一个偏移量”开始数。
换句话说,偏移量是相对于当前这一”层”参数区的起点算的,不是相对于自己。
案例:函数 transfer(uint256[],address),参数 ([5769, 14894, 7854], 0x1b7e...79f1):
| 位置(字节) | 内容 | 说明 |
|---|---|---|
| 0–31 | 0x40(=64) | 第一个参数(数组)的偏移量 |
| 32–63 | 0x1b7e...79f1 左补零 | 第二个参数 address(静态,直接放) |
| 64–95 | 3 | 数组长度(偏移量 0x40=64 正好指到这里) |
| 96–127 | 5769 | 元素 0 |
| 128–159 | 14894 | 元素 1 |
| 160–191 | 7854 | 元素 2 |
关键:偏移量 0x40 = 64,是从**参数区起点(第 0 字节)**数 64 字节,正好指到数组长度处。
六、完整案例:嵌套数组编码
函数 transfer(uint256[][],address[]),参数 ([[123, 456], [789]], [addr1, addr2]),calldata 结构如下:
| # | 内容 | 值 |
|---|---|---|
| 1 | 第一个数组的偏移量 | 0x40 |
| 2 | 第二个数组的偏移量 | 0x140 |
| 3 | 第一个数组长度 | 2 |
| 4 | 子数组 0 的偏移量 | 0x40 |
| 5 | 子数组 1 的偏移量 | 0xa0 |
| 6 | 子数组 0 长度 | 2 |
| 7 | 子数组 0 元素 | 123, 456 |
| 8 | 子数组 1 长度 | 1 |
| 9 | 子数组 1 元素 | 789 |
| 10 | 第二个数组长度 | 2 |
| 11 | address 元素 | addr1, addr2 |
注意第 4、5 行的子数组偏移量(0x40、0xa0)是相对于第一个数组长度字段之后那一层的起点计算的——这正是第五节”从本层起点数”规则的体现。
七、字符串与结构体编码
字符串
play("Eze"):
- 偏移量:0x20(32 字节,因为只有一个参数,数据紧跟在偏移量后)
- 长度:3
- 内容:“Eze” 的 UTF-8,右补零到 32 字节
注意:UTF-8 下一个 ASCII 字符 1 字节,但像 “好” 这样的中文字符占 3 字节,单个 UTF-8 字符最大 4 字节。所以 string.length 算的是字节数不是字符数。
结构体
- 全静态字段的结构体 → 当作静态类型,无偏移量,字段依次平铺;
- 含任意动态字段的结构体 → 当作动态类型,需要偏移量。
例:
struct RareToken {
uint256 n;
string description; // 动态字段
}
因为有 string,整个结构体按动态处理:偏移量 → 长度 → 静态字段 n → description 的偏移量 → description 长度 → 内容。
八、abi.encode 家族函数对比
| 函数 | 作用 | 是否补齐 32 字节 | 含选择器 |
|---|---|---|---|
abi.encode(...) | 标准编码,可被 abi.decode 还原 | 是 | 否 |
abi.encodePacked(...) | 紧凑编码,不补零、省空间 | 否 | 否 |
abi.encodeWithSelector(sel, ...) | 选择器 + 标准编码参数 | 是 | 是(手传选择器) |
abi.encodeWithSignature("f(uint)", ...) | 内部先算选择器再编码 | 是 | 是(传签名字符串) |
abi.encodeCall(Contract.f, (args)) | 类型安全版,编译期检查参数类型 | 是 | 是 |
实战建议:
- 构造 call/delegatecall 的 calldata 用
abi.encodeWithSignature或更安全的abi.encodeCall; abi.encodePacked因为不补零,不同参数拼接可能产生哈希碰撞(如("a","bc")和("ab","c")打包结果相同),用作哈希输入时要小心;- 能用
abi.encodeCall就用它,编译器会帮你检查函数和参数类型匹配,避免选择器写错。
九、calldata 的 Gas 成本计算
calldata 按字节收费:
- 非零字节:每个 16 gas
- 零字节:每个 4 gas
以 68 字节的 0xa9059cbb...(一次 transfer 调用)为例:
- 32 个非零字节 × 16 = 512 gas
- 36 个零字节 × 4 = 144 gas
- 合计 656 gas
计算方法:十六进制字符串长度 ÷ 2 = 字节数,数出非零和零字节分别乘 16 和 4。这也是为什么”地址左补零”那么多零字节其实很省 Gas,以及为什么有人优化合约时追求 calldata 里多产生零字节。
十、编码规则总结
- 所有数据以 32 字节字对齐;
- calldata = 4 字节选择器 + 参数编码;
- 静态类型左补零,动态数据右补零;
- 动态类型用 偏移量 + 长度 + 数据 三段式;
- 嵌套结构里偏移量从本层起点计数,不是从自己位置;
- 数组/字符串的长度字段永远在数据之前。
十一、动手练习项目:手写 ABI 解码器 AbiDecoder
项目目标
不依赖 abi.decode,用底层 calldata 切片和位运算,亲手解析一段 calldata,彻底吃透编码布局。部署到 Sepolia 并用真实交易数据验证。
合约要求
AbiPlayground.sol
getSelector(string calldata signature) external pure returns (bytes4):返回bytes4(keccak256(bytes(signature))),用它验证transfer(address,uint256)→0xa9059cbb;encodeTransfer(address to, uint256 amount) external pure returns (bytes memory):用abi.encodeWithSignature构造 transfer 的 calldata,并和手工拼接的版本(abi.encodePacked(bytes4(0xa9059cbb), bytes32(uint256(uint160(to))), bytes32(amount)))断言相等;decodeUintArrayManually(bytes calldata data) external pure returns (uint256[] memory):不用 abi.decode,手动读取偏移量(前 32 字节)、跳到该位置读长度、再循环读每个元素(用 calldata 切片data[start:start+32]+ 类型转换);calldataGasCost(bytes calldata data) external pure returns (uint256):遍历每个字节,零字节计 4、非零计 16,返回总 Gas,对照第九节的 656 验证。
测试要求(Foundry)
test_Selector:getSelector("transfer(address,uint256)") == 0xa9059cbb;test_EncodeMatchesManual:encodeWithSignature 结果 == 手工 encodePacked 结果;test_DecodeArray:构造abi.encode(uint256[]([5769,14894,7854])),用decodeUintArrayManually解出原数组;test_NestedOffset:编码[[123,456],[789]],手动验证子数组偏移量 0x40/0xa0 的位置;test_GasCost:对 68 字节 transfer calldata 断言返回 656;test_PackedCollision:证明abi.encodePacked("a","bc") == abi.encodePacked("ab","c"),理解碰撞风险。
Sepolia 部署与验证步骤
- 部署 AbiPlayground;
- Etherscan 上调
getSelector("transfer(address,uint256)")确认 0xa9059cbb; - 调
encodeTransfer得到 calldata,复制到decodeUintArrayManually之外——用一个真实 ERC20 transfer 交易的 input data 喂给你的解码函数验证; - 调
calldataGasCost对照 Etherscan 上该交易实际的 calldata gas。
进阶挑战(可选)
- 扩展
decodeUintArrayManually支持解析(uint256[], address)两个参数,处理混合静态/动态布局; - 写注释回答:为什么动态类型右补零、静态类型左补零?如果反过来会发生什么?