ABI 编码详解:函数调用数据是如何打包的

深入理解 abi.encode、encodePacked、函数选择器与静态/动态类型的字节布局规则

8 分钟阅读
ABI 编码详解:函数调用数据是如何打包的

ABI 编码详解:函数调用数据是如何打包的

目录


一、为什么要懂 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 addressaddress
enum 枚举uint8
user-defined value type它的底层类型
memory / calldata 修饰符忽略

如果算签名时没规范化(比如把 enum 写成枚举名而不是 uint8),算出来的选择器就是错的,调用会失败。

三、静态类型的编码

固定大小的类型(booluintNbytesNaddress、内容是静态的定长数组)一律左侧补零,凑满 32 字节

例:uint8 值为 5,编码成:

0x0000000000000000000000000000000000000000000000000000000000000005

address(20 字节)也是左补零到 32 字节。所有数据都以 32 字节为一个”字(word)“对齐。

四、动态类型的编码:偏移量 + 长度 + 数据

动态类型(stringbytes、动态数组、含动态内容的数组)不能直接塞,需要三段式:

  1. 偏移量(offset):一个指针,指明真正的数据从 calldata 的哪个位置开始;
  2. 长度(length):元素个数或字节数;
  3. 数据(data):实际内容,右侧补零到 32 字节的整数倍。

例:字符串 “hello”(UTF-8 是 0x68656c6c6f,5 字节)的数据段编码:

0x68656c6c6f000000000000000000000000000000000000000000000000000000

注意是右补零(动态数据),和静态类型的左补零相反。

五、偏移量的计数规则(最容易搞错的地方)

偏移量通常不是从它自己的位置开始数,而是从”描述该层嵌套结构的第一个偏移量”开始数。

换句话说,偏移量是相对于当前这一”层”参数区的起点算的,不是相对于自己。

案例:函数 transfer(uint256[],address),参数 ([5769, 14894, 7854], 0x1b7e...79f1)

位置(字节)内容说明
0–310x40(=64)第一个参数(数组)的偏移量
32–630x1b7e...79f1 左补零第二个参数 address(静态,直接放)
64–953数组长度(偏移量 0x40=64 正好指到这里)
96–1275769元素 0
128–15914894元素 1
160–1917854元素 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
11address 元素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

  1. getSelector(string calldata signature) external pure returns (bytes4):返回 bytes4(keccak256(bytes(signature))),用它验证 transfer(address,uint256)0xa9059cbb
  2. encodeTransfer(address to, uint256 amount) external pure returns (bytes memory):用 abi.encodeWithSignature 构造 transfer 的 calldata,并和手工拼接的版本(abi.encodePacked(bytes4(0xa9059cbb), bytes32(uint256(uint160(to))), bytes32(amount)))断言相等;
  3. decodeUintArrayManually(bytes calldata data) external pure returns (uint256[] memory)不用 abi.decode,手动读取偏移量(前 32 字节)、跳到该位置读长度、再循环读每个元素(用 calldata 切片 data[start:start+32] + 类型转换);
  4. calldataGasCost(bytes calldata data) external pure returns (uint256):遍历每个字节,零字节计 4、非零计 16,返回总 Gas,对照第九节的 656 验证。

测试要求(Foundry)

  1. test_SelectorgetSelector("transfer(address,uint256)") == 0xa9059cbb
  2. test_EncodeMatchesManual:encodeWithSignature 结果 == 手工 encodePacked 结果;
  3. test_DecodeArray:构造 abi.encode(uint256[]([5769,14894,7854])),用 decodeUintArrayManually 解出原数组;
  4. test_NestedOffset:编码 [[123,456],[789]],手动验证子数组偏移量 0x40/0xa0 的位置;
  5. test_GasCost:对 68 字节 transfer calldata 断言返回 656;
  6. test_PackedCollision:证明 abi.encodePacked("a","bc") == abi.encodePacked("ab","c"),理解碰撞风险。

Sepolia 部署与验证步骤

  1. 部署 AbiPlayground;
  2. Etherscan 上调 getSelector("transfer(address,uint256)") 确认 0xa9059cbb;
  3. encodeTransfer 得到 calldata,复制到 decodeUintArrayManually 之外——用一个真实 ERC20 transfer 交易的 input data 喂给你的解码函数验证;
  4. calldataGasCost 对照 Etherscan 上该交易实际的 calldata gas。

进阶挑战(可选)

  • 扩展 decodeUintArrayManually 支持解析 (uint256[], address) 两个参数,处理混合静态/动态布局;
  • 写注释回答:为什么动态类型右补零、静态类型左补零?如果反过来会发生什么?

💬 评论