ABI 编码是用于对智能合约进行函数调用的数据格式。这也是智能合约在调用其他智能合约时对数据进行编码的方式。
本指南将展示如何解析 ABI 编码的数据、如何计算 ABI 编码,并讲解函数签名与 ABI 编码之间的关系。
让我们开始吧……
Solidity abi.encodeWithSignature 与底层调用
如果我们要对另一个拥有公共函数 foo(uint256 x) 的智能合约发起底层调用(传递 x = 5 作为参数),我们会这样做:
otherContractAddr.call(abi.encodeWithSignature("foo(uint256)", (5));
我们可以通过以下代码查看 abi.encodeWithSignature("foo(uint256)", (5)) 实际返回的数据:
function seeEncoding() external pure returns (bytes memory) {
return abi.encodeWithSignature("foo(uint256)", (5));
}
然后我们会得到以下结果(这是经过 ABI 编码的):
0x2fbebd380000000000000000000000000000000000000000000000000000000000000005
像这样解析和理解这些数据正是本文的目标。
ABI 编码的函数调用的关键组成部分
经过编码的 ABI 函数调用是 function selector(函数选择器)与经过编码的函数参数(如果该函数有参数)的拼接。
函数签名
函数签名是函数名与其参数类型的组合,中间不包含空格。
例如,下面这个函数的函数签名:
function transfer(address _to, uint256 amount) public {
//
}
是 transfer(address,uint256)。请注意,你必须使用完整的参数数据类型,例如使用 uint256 而不是 uint。此外,像 _to 和 amount 这样的变量名不属于函数签名的一部分。同样重要的是,字符串中不能有空格,例如 transfer(address, uint256) 是不正确的。
根据 Solidity 官方文档,在计算函数签名时需要注意一些“边界情况”:
struct被视作tuple处理- payable address、interface 和 contract 类型被视作
address处理 - 忽略
memory和calldata修饰符 enum被视作uint8- 用户定义类型被视作其底层类型处理
函数选择器
function selector 就是 Solidity 用来识别函数的函数签名的 Keccak-256 哈希值的前 4 个字节。例如,我们前面提到的函数签名 transfer(address,uint256) 的 Keccak-256 哈希值是以下十六进制值:
0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b
然而,只有哈希结果的前 4 个字节 0xa9059cbb 会被用来识别函数;这四个字节即为 function selector。
你可以使用 ethers JavaScript 库将 transfer() 的函数签名转换为它的 selector,如下所示:
const ethers = require('ethers'); // Ethers v6
const functionSignature = 'transfer(address,uint256)';
const functionSelector = ethers.id(functionSignature).substring(0, 10)
console.log(functionSelector);
结果将如下所示:

在 Solidity 中,这个函数用于计算 function selector:
function getSelector() public pure returns (bytes4 ret) {
return bytes4(keccak256("transfer(address,uint256)")); // 0xa9059cbb
}
你也可以使用这个 keccak256 转换网站 直接查看转换结果,而无需编写任何代码:

既然我们已经清楚地了解了 function selector 是什么,接下来让我们看看函数调用的 ABI 编码的下一个组成部分——函数的输入或参数。
函数输入或参数
当调用一个不带参数的函数时,仅靠 function selector 就足以构成调用该函数所需的全部编码。例如,函数 play() 将通过其 function selector 0x93e84cd9 标识,这就是所需的全部数据。
但是,如果函数带有参数,例如 transfer(address to, uint256 amount),情况就会变得复杂,此时函数参数必须进行 ABI 编码并拼接到 function selector 之后。
让我们以 transfer(address to, uint256 amount) 为例,来帮助我们理解参数编码是如何完成的:
function transfer(address to, uint256 amount) public {
//
}
用于函数调用的这些数据不会永久存储在函数或合约本身中。相反,它存在于一个名为“calldata”的空间中。你无法修改 calldata 中的数据,因为它是由交易发送者创建的,随后便成为只读状态。
你可以在 Etherscan 中查看交易的 calldata。下面是一张截图,展示了一个示例 ERC-20 代币的 transfer 交易 calldata 示例:

Etherscan 在函数描述下方将 function selector 称为 MethodID(参见下方截图中的红框)。因此,transfer() 的 methodID 是 0xa9059cbb。

紧随其后的是两个长十六进制值,标记为 [0] 和 [1]。这些十六进制值代表了两个输入数据参数:即 address 类型的 _to 和 uint256 类型的 _value。
Etherscan 帮助我们将 calldata 信息拆分并解析为每行 32 字节(64 个字符)。然而,实际的 calldata 会被打包在一起并作为一个长字符串发送,它的外观应该如下所示:
0xa9059cbb000000000000000000000000f89d7b9c864f589bbf53a82105107622b35eaa4000000000000000000000000000000000000000000000028a857425466f800000
你也可以通过点击“View input as”并选择“Original”选项,在 Etherscan 上查看其原始格式的实际完整 calldata,如下所示:

为了更好地理解其底层机制,让我们拆解这个 calldata 并提取该交易的相关信息。
拆分 calldata
让我们看一下下面的 calldata 并识别它的组成部分:
0xa9059cbb0000000000000000000000003f5047bdb647dc39c88625e17bdbffee905a9f4400000000000000000000000000000000000000000000011c9a62d04ed0c80000
首先,我们需要知道函数签名——没有它我们无法解码数据。所以,下面是上述 calldata 的函数签名:
transfer(address,uint256)
然后我们将十六进制符号 (0x) 和 function selector 单独放在一行。function selector 始终是 4 个字节(8 个十六进制字符)。最后,我们将接下来的数据按照每 32 字节划分为独立的一行。正如我们稍后将看到的,Solidity 按照 32 字节的增量对数据进行编码。
0x <---------- 十六进制符号
a9059cbb <---- 函数选择器
0000000000000000000000003f5047bdb647dc39c88625e17bdbffee905a9f44
00000000000000000000000000000000000000000000011c9a62d04ed0c80000
函数选择器
function selector 是 calldata 的前 4 个字节 0xa9059cbb。

Address
transfer(address,uint256) 中的 address 是接下来的 32 字节的值。实际的地址是 20 个字节,但它在左侧填充了前导零,使其变成了 32 字节。
Address:
000000000000000000003f5047bdb647dc39c88625e17bdbffee905a9f440

本质上,接收方地址将是上面的值,但去掉了多余的零填充:即 0x3F5047BDb647Dc39C88625E17BDBffee905A9F44。
Amount
最后,transfer(address,uint256) 中的最后一项是 amount。该 amount (0000000000000000000011c9a62d04ed0c80000) 在左侧填充了前导零以变成 32 字节,如下所示:

这里有一个 Python 代码片段可以帮助你快速将十六进制转换为十进制:
>>> int("0x11c9a62d04ed0c80000", 16)
5250000000000000000000
我们也可以将十进制再转换回十六进制,如下所示:
>>> hex(5250000000000000000000)
0x11c9a62d04ed0c80000
数据类型与填充
我们已经确定了 calldata 中的每个项都被编码为一个 32 字节的字,并且如果该项没有占满整个 32 字节的字,则会用零进行填充。
作为一个规则,每种固定大小的数据类型,如 int、bool 以及所有大小的 uint(uint8-uint256),都会被编码为一个 32 字节的字,并在需要时在左侧填充零。
例如,如果你有一个值为 5 的 uint8,它将被编码为
0x0000000000000000000000000000000000000000000000000000000000000005。
同样,值为 true 的 bool 也会在左侧填充,并编码为 0x0000000000000000000000000000000000000000000000000000000000000001。
然而,动态大小的数据类型 bytes 和 string 是向右填充的。例如,代表 hello 的字节 0x68656c6c6f 将被编码为一个向右填充零的 32 字节字 0x68656c6c6f000000000000000000000000000000000000000000000000000000。
Solidity 中的固定大小数据类型包括:
booluints- 固定大小的
bytes(bytesN) address- 包含固定数据的
tuple、struct - 固定大小的数组
以下是 Solidity 中的动态数据类型:
bytesstring- 动态数组
- 包含动态类型的固定大小数组
- 包含上述任意动态类型的
struct
处理动态 calldata
到目前为止,我们的重点一直放在诸如 address 和 uint256 这样的静态 calldata 参数类型上。虽然静态类型的编码相当简单明了,但由于数组和字符串保存的数据大小不定,它们的编码可能会有些复杂。
我们来考虑一个接收 uint 数组和单个地址的函数。虽然这里函数的具体实现细节并不重要,但函数签名应该是这样的:
transfer(uint256[],address)
现在,我们将重点转移到对数组进行编码上。假设我们将以下数据传递给该 transfer 函数:
transfer([5769, 14894, 7854], 0x1b7e1b7ea98232c77f9efc75c4a7c7ea2c4d79f1)
这是上面示例函数 transfer(uint256[],address) 的 calldata。让我们检查一下,看看每部分是如何按照我们上述描述的模式进行编码的。
首先,我们将从对数组 uint256[] 的“偏移量 (offset)”进行编码开始,但是 offset 是什么?

偏移量
offset 用于在 calldata 中定位特定动态数据开始的位置或能够被找到的位置。
按照我们的示例,我们有一个动态数据类型 uint256[] 和一个静态类型 address。在上述 calldata 中,uint256[] 的 offset 为十六进制的 40(十进制的 64),其编码占据 32 个字节的字。
由于动态数组是该函数的首个参数,该 offset 也就是 calldata 中的第一个 32 字节字:

为了进一步解释 offset 的工作原理,上面的图片突出了数组的 offset 在 calldata 中的位置。每个字节字编号如下:
- 0-31(第一行的 32 个字节)
- 32-63(第二行的 32 个字节)
- 64-95(第三行的 32 个字节)
- 等等
所以 64(十六进制 40)是第三行最左侧的字节(一对十六进制字符),即绿色高亮结束的地方。这就是该 offset 指向的位置。
在这个例子中,offset 是从 function selector 后的第一个字节开始,到动态数据(数组)起始处的距离。然而稍后我们会看到,offset 并不总是意味着“相对于 function selector 后第一个字节的偏移量”。
编码静态数据 — address
下一行是 address,它是一个以静态方式在前导填充零至 32 字节的字。这与我们已经传入的地址相同,因为该地址已经是十六进制格式。

编码动态数据的长度 — 数组
下一行是数组的长度,即数组中元素的数量。正如你所见,我们在数组中有 3 个元素:[5769, 14894, 7854]。数组的长度为 3,如下图所示:

十六进制编码数组元素
到目前为止,我们已经对静态类型、offset 以及数组的长度进行了编码。接下来,我们将对实际的数组元素进行编码。数组的每个元素都将表示为一个十六进制数字,如下图所示:

我们将每个整数转换为它们的十六进制表示形式,并在前面加上前导零。所以,数组项将如下所示:

到此,关于这个 ABI 编码的 calldata 的讨论就结束了。
以下视频总结了我们关于如何对 transfer(uint[], address) 的 calldata 进行编码所学的全部内容:
ABI 编码 string 参数
对字符串的编码很直接,你只需编码以下内容:
- offset
- 字符串的长度
- 字符串的内容(UTF-8 编码)
这里是一个包含字符串参数的函数的例子:
play(string)
当我们传入一个值时:
play("Eze")
其 calldata 将是下面的文本:
0x
718e6302
0000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000003
457a650000000000000000000000000000000000000000000000000000000000
offset 用十六进制的 20 表示,因为字符串编码的位置距离 function selector 后面的 calldata 开头正好是 32 个字节(十进制的 32 即十六进制的 20)。我们也可以看到字符串 (Eze) 的长度为 3,因为该字符串只有 3 个字符,每个字符占一字节(一个字节相当于两个十六进制字符)。

字符串 “Eze” 仅使用 ASCII 字符,每个字符占一个字节(因此长度为 3)。然而,像“好”这样的 unicode 字符占据 3 个字节。一个 utf-8 字符的最大大小可以是 4 个字节。字符串“你好”的长度为 6 个字节。
在 calldata 中编码 struct / tuple
tuple 和 struct 的编码方式完全相同,因为 struct 被映射到了 ABI 的 tuple 类型。
根据 Solidity ABI 编码规范,struct 的编码是其所有成员编码的拼接,其中的静态类型将被填充至 32 个字节。
假设我们有以下合约:
contract C {
struct Point {
uint256 x;
uint256 y;
}
function foo(Point memory point) external pure {
//...
}
}
foo 的函数签名将是 foo((uint256, uint256))。这与将 tuple 作为输入没有任何区别。如果它接收一个动态的点数组(即 struct 数组),函数签名将是 foo((uint256, uint256)[])。
如果 struct 的元素全都是固定大小的数据,我们会将整个 struct 作为静态类型进行编码,这样就不需要使用 offset 了。但是,如果它哪怕有一个字段包含动态大小数据类型,该 struct 的编码方式就会改变。
例如,像下面这样的一个 struct:
RareToken {
uint256 n;
}
send(RareToken,address)
如果我们将如下参数 send( RareToken(1), 0x1b7e1b7ea98232c77f9efc75c4a7c7ea2c4d79f1)) 传递给它,它将被作为静态类型进行编码,如下图所示:

但是,如果 struct 中包含动态类型,我们就必须将该 struct 编码为动态类型。让我们以这个为例:
RareToken {
uint256;
string;
}
send(RareToken)
如果我们将以下数据传递给它 send(RareToken(50,"Eze")),上述函数的编码将是下图中的代码。该 struct 包含一个动态类型和一个静态类型,如图所示:

ABI 编码多个 struct 参数
现在,假设我们的函数 send() 接收 3 个 struct 作为参数,而不是 1 个。calldata 将如下所示:

前三个 32 字节的字是 offset,因为该函数接收 3 个参数作为输入,并且所有这些参数都是动态数据类型(带有动态类型字段的 struct)。
左侧的 0x00, 0x20, …, 0x1c0 这一列展示了 offset 是如何指向 calldata 中的位置的。请注意,offset 的计算是从第一个 offset 开始的,而不是从该 offset 本身所在的位置开始的。当我们研究嵌套动态数据时,将更深入地探讨 offset。
编码包含静态类型的固定大小数组
固定大小数组的编码取决于数组中的内容。如果固定大小数组包含动态类型,那么该固定大小数组将被编码为动态类型。如果它仅包含静态类型,它将被视作静态类型并按其进行编码。这与上述章节中包含动态数据的 struct 所遵循的逻辑是一样的。
我们先从一个长度为 3、仅含静态类型的固定大小数组开始:
play(uint256[3])
并把这个数据传递给它:
play([1,2,3])
它的数组编码如下:

正如你所见,calldata 中没有 offset。这仅仅是数组中元素的编码。
编码包含动态类型的固定大小数组
让我们考虑一个固定大小数组中包含动态数据的场景。下面的函数将包含一个包含两个字符串的数组。
plays(string[2])
如果我们将以下字符串传递给它:
play(["Eze","Sunday"])
编码后我们将得到这个 calldata:

因为该固定大小数组是一个具有动态数据类型的数组,所以它的整体作为动态数组进行了编码,唯一的区别是并没有对数组长度进行编码,因为函数签名已经把它定义成了长度为 2 的固定数组。如果我们考虑相同的函数但采用动态长度:
plays(string[])
我们会注意到该数组的长度同样也会被编码:

calldata 中的多个数组参数与嵌套数组
在 calldata 中处理多个数组和嵌套数组可能有些复杂和棘手。不过,其总体模式仍然相似。在这一节中,我们将学习如何对嵌套数组进行编码和解码,从而对 offset 的工作原理获得更深入的直观认识。
我们将使用以下函数签名作为示例:
transfer(uint256[][],address[])
同样,让我们将以下数据作为参数传递给该函数:
transfer([[123, 456], [789]], [0x5B38Da6a701c568545dCfcB03FcB875f56beddC4, 0x7b38da6a701c568545dcfcb03fcb875f56bedfb3])
因此,该函数及参数的 calldata 将是下面的十六进制数值:
0x7a63729a
0000000000000000000000000000000000000000000000000000000000000040
0000000000000000000000000000000000000000000000000000000000000140
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000040
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000002
000000000000000000000000000000000000000000000000000000000000007b
000000000000000000000000000000000000000000000000000000000000007b
0000000000000000000000000000000000000000000000000000000000000001
000000000000000000000000000000000000000000000000000000000000007b
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc4
0000000000000000000000007b38da6a701c568545dcfcb03fcb875f56bedfb3
我们的 transfer() 函数的新特点是它包含两个数组,并且其中一个数组拥有两个子数组。
下面从宏观层面展示嵌套多重数组的 calldata 结构是如何构建的:
- Offset(偏移量)。它首先会为数组的不同位置定义 offset。假设有多个数组参数,会先对这些数组的 offset 进行编码定义。对我们的例子而言,会有两个 offset。
- 指向第一个动态类型的 offset
- 指向第二个动态类型的 offset
- 指向第
n个动态类型的 offset(如果适用)
- 接着是第一个数组参数的长度(在我们的例子中是 2:
[[123, 456], [789]]),它位于第一个 offset 所指向的位置。这是整个数组的长度。在开始处理各个数组之前,都要先定义其长度。因此对于每个子数组,你也都会定义它们的长度(在之后的 ABI 编码中)。 - 接下来,对第一个数组参数的子数组进行编码
- 指向第一个子数组 (
[123, 456]) 的 offset - 指向第二个子数组 (
[789]) 的 offset- 第一个子数组的长度(在我们的例子中是 2)
- 第一个子数组的第一个元素 (
123) - 第一个子数组的第二个元素 (
456)
- 第一个子数组的第一个元素 (
- 第二个子数组的长度(在我们的例子中是 1)
- 第二个子数组的第一个元素 (
789)
- 第二个子数组的第一个元素 (
- 第一个子数组的长度(在我们的例子中是 2)
- 指向第一个子数组 (
- 一旦你完成了第一个数组参数所有元素的处理,就开始编码下一个数组参数
- 第二个参数数组的长度(在我们的例子中是 2 个地址)
- 以及第二个参数的元素(在我们的例子中是这些地址),如果第二个数组参数也包含子数组,则按照上面所述的相同模式进行。
现在,让我们将示例的 calldata 可视化。
首先,为了便于阅读,除了 0x 和 function selector 之外,按每行 32 个字节(64 个字符)对其进行排列。函数签名和 calldata 位于图片的顶部:

第一个数组参数的 offset
calldata 字符串的首个 32 字节字是 offset,指示了第一个数组参数数据开始的位置。其视觉表现如下:

第二个数组参数的 offset
下一步是对第二个数组参数的 offset 进行编码。
正如我们在多个动态 struct 示例中所看到的,该 offset 并不是从当前 offset 所在的位置“开始计算”的,而是从第一个 offset 处开始的。
通常情况下,offset 不会从它们当前的位置“开始计算”,而是从描述嵌套数据结构“该层级”的第一个 offset 处开始。随着我们研究子数组,这个概念将会变得更加清晰。
下图高亮展示了第二个 offset 是如何指向第二个参数数据(地址数组)的。请注意,因为第二个参数是一个包含两个地址的数组,所以该 offset 指向的是一个“2”。

既然每个字节字依次编号如下:
- 0-31
- 32-63
- 64-95
- 等等
320 个字节(十六进制为 140)对应于高亮结束那一行的最左侧的 0。
第一个数组的长度
接下来,我们需要对第一个数组的长度进行编码。子数组项包括 [123, 456] 和 [789],它们组成了 [[123, 456], [789]]。因为包含两个嵌套数组,所以长度为 2。长度在 calldata 中的表示如下图所示:

第一个数组参数中各个子数组的 offset
在数组的长度之后,是展示这些数组内容存储位置的 offset。由于有两个子数组:[123, 456] 和 [789],因此这里会有两个 offset。
第一个子数组的 offset
第一个子数组([123,456])的 offset 是右侧方框指向的 40。它们都是从定义数组长度之后的第一个字“开始计算”的。再一次提醒注意,它指向的是一个包含 2 的字,因为 [123,456] 的长度为 2。

第二个子数组的 offset
第二个子数组([789])的 offset 并不是从 calldata 开头计算的。它位于 a0 处(下图中最后的红色高亮),从声明第一个子数组 offset 的位置算起,距离其为 160 个字节(十进制)。
回想一下,通常 offset 不是从它当前的位置“开始计算”,而是从描述嵌套数据结构“该层级”的第一个 offset 开始算起。我们现在处于嵌套数组的第一层深度,所以我们的首个 offset 是下方被紫色高亮的 40。这个 offset 不从它自身所在位置开始计算:

第一个子数组的长度
接下来是第一个子数组的长度。第一个子数组包含 2 个元素,由下方黄色高亮部分标识:

第一个子数组的元素
接下来的两个字是第一个子数组中两个元素的十六进制表示,如下图所示:

第二个子数组的长度
然后我们来看第二个子数组的长度,它仅包含一个元素:

第二个子数组的元素
第二个数组只有一个元素,这从它的长度可以看出。我们同样在接下来的 32 字节中表示该单一元素的值(315),即 789 的十六进制值,如下所示。

第二个数组的长度
最后,我们到了表示第二个参数(地址数组)长度的地方。我们有 2 个地址,所以长度是 2;这两个参数就是该图表中表示的地址。

第二个数组中的 address 元素
这就是我们将函数 transfer([[123, 123], [123]], [0x5b38da6a701c568545dcfcb03fcb875f56beddc4,0x7b38da6a701c568545dcfcb03fcb875f56bedfb3]) 转换为供 EVM 使用的十六进制表示的全过程。

以下视频对本节示例中的 calldata 进行了总结:
三层嵌套数组的动画
下方展示了一个视频,演示了三维的 uint 数组是如何进行 ABI 编码的:f(uint[][][] memory data):
Calldata 的长度与 gas 成本
作为一名 Solidity 开发者,你的核心关注点之一就是节省 gas。不仅如此,处理 calldata 会带来额外的成本——calldata 中的每个字节都会消耗 gas。
为了确定 calldata 的成本,我们首先需要通过计算字节数来算出 calldata 的长度。让我们以前面出现的 calldata 字符串为案例来进行分析:
0xa9059cbb0000000000000000000000003f5047bdb647dc39c88625e17bdbffee905a9f4400000000000000000000000000000000000000000000011c9a62d04ed0c80000
我们首先会去掉 0x,因为这只是一个方便我们理解这是以太坊相关十六进制数值的前缀。去掉后,我们将剩下:
a9059cbb0000000000000000000000003f5047bdb647dc39c88625e17bdbffee905a9f4400000000000000000000000000000000000000000000011c9a62d04ed0c80000
这个字符串的长度为 136 个十六进制数字,这代表 68 个字节。在 calldata 字符串中,每个字节由两个字符(十六进制数字)表示。因此,我们可以通过将 136 除以 2 等于 68 来计算它的长度。
calldata 中的每个非零字节消耗 16 gas,而零字节消耗 4 gas。所以,我们需要将它们分离开来以继续我们的计算。
我们得到:
calldata 的总 gas 成本 。
既然零字节更便宜,一些开发者就会挖掘拥有多个前导零字节的地址或智能合约地址,因为这可以减少在将该地址作为参数传递时的 gas 消耗。
结论
在本指南中,我们学习了函数调用 ABI 编码的基础知识和经 ABI 编码的函数调用的核心组成部分,并对 calldata 获得了更加详细的理解。我们还探索了如何计算 calldata 的 gas 成本,甚至进一步探讨了更复杂的 calldata 编解码练习以帮助巩固这些知识,希望你觉得它有用。为了进一步加强你在本文中所学到的内容,我建议阅读更多关于 Ethereum ABI encoding spec 的信息,并完成下一节中的练习题。
编码愉快!
练习题
- 调用
foo(uint16 x)的calldata中有多少个字节? - 当传入
(2, [5, 9])时,foo(uint256 x, uint256[])的 ABI 编码是什么? foo(S[] memory s)的 ABI 编码是什么?其中S是包含字段uint256 x; uint256[] a;的struct(灵感来源于此 tweet)。
Capture the Flag 练习
RareSkills Solidity Riddles: Forwarder
DamnVulnerableDeFi: ABI Smuggling
作者信息
本文由 Eze Sunday 与 RareSkills 合作撰写。
首发于 5 月 29 日