---
id: 27
title: "ABI 精读：selector、静态参数与动态类型"
slug: 0-15-abi-internals
date: 2026/10/02
summary: 从裸 calldata 出发拆解 ABI：4 字节 selector 由规范签名截断而来，静态类型直接占满一个字，动态类型靠偏移量与长度二级寻址；事件把 indexed 参数写进 topics，换来可检索性。ABI 不自描述，解码与升级因此都受约束。
keywords: ABI编码,函数选择器,calldata解码,事件日志,topics
heroImage: /images/articles/photos/0-15-abi-internals.jpg
---

一笔 ERC-20 转账的 calldata 通常以 `0xa9059cbb` 开头，后面跟着两个 32 字节的字，第一个是收款地址，第二个是金额。链上没有任何字段说明这串字节对应 `transfer(address,uint256)`，EVM（Ethereum Virtual Machine，以太坊虚拟机）也不认识函数名。它只按部署时编译进字节码的分发逻辑，比较 calldata 的前 4 个字节，跳进对应的代码段。

这 4 个字节从哪来，参数为什么有的直接摆在开头、有的要绕到后面去找，以及为什么拿到一段 calldata 却没有接口定义就解不开，是理解合约交互的三件事。本文先讲清 ABI（Application Binary Interface，应用二进制接口）的编码规则，再按规范文档给出的示例 calldata 手工走一遍解码，最后说明事件日志为什么把参数拆进 topics 与 data 两个位置。

## selector 是规范签名的哈希截断，不是函数名本身

Solidity ABI 规范对函数选择器（function selector）的定义很短：calldata 的前 4 个字节，取自函数签名 Keccak-256 哈希的最高 4 个字节。这里的签名取规范形式，与源码里的写法并不等同：函数名加上括号包裹的参数类型列表，类型之间用单个逗号分隔，不带空格，不带参数名，也不带 `memory`、`calldata`、`storage` 这类数据位置修饰符。

规范形式还会做类型归一化。`uint` 与 `int` 必须写成 `uint256` 与 `int256`，`address payable` 与合约类型都写成 `address`，枚举（enum）写成 `uint8`，用户定义值类型写成它的底层类型。结构体（struct）写成括号包裹的组件类型，也就是元组（tuple）：若 `S` 有两个 `uint256` 字段，`function f(S memory s)` 的签名是 `f((uint256,uint256))`，选择器取自 `keccak256("f((uint256,uint256))")` 的前 4 字节。返回值不参与签名，这与 Solidity 的重载解析一致：调用方只按参数区分目标函数，返回值差异无法用来消歧。签名里每个字符都会影响结果，多一个空格或把 `uint` 写成 `uint256`，选择器就完全不同。

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract SelectorDemo {
    function selectorOfTransfer() external pure returns (bytes4) {
        // 引号内是规范签名：无空格、无参数名、uint 归一化为 uint256
        return bytes4(keccak256("transfer(address,uint256)"));
    }
}
```

32 位空间带来的直接后果是碰撞并非理论假设。截至 2026 年 9 月，4byte.directory 上 `0xa9059cbb` 这一个选择器列出 6 条候选签名，除 `transfer(address,uint256)`，还有 `many_msg_babbage(bytes1)`、`workMyDirefulOwner(uint256,uint256)` 以及 `func_2093253501(bytes)` 这类占位名；该库的条目可以自行提交，数量与内容会随时间变化。两个不同函数共用选择器时，calldata 前缀本身无法区分调用意图，只能靠目标合约的代码或接口定义来判断。

Solidity 官方文档没有单列这条限制，但 solc 在实现层对同一合约（含继承链）内的选择器重复有检查：两个函数选择器相同时，编译会以 `Function signature hash collision` 失败（可用一段最小合约复现），跨合约与跨版本不存在这层检查。这也说明只靠 4byte 类数据库反查函数名并不可靠：数据库给出的是候选集合，碰撞时无法确定唯一答案。

## 静态类型直接占满一个字

编码从第 5 个字节开始。规则先把参数分成静态与动态两类：静态类型就地编码，直接写进参数字节流；动态类型把实际内容放到后面，原位只留一个偏移量。

EVM 的字长是 32 字节，静态类型一律占满一个字。`uint<M>` 与 `int<M>` 按大端写入，高位填充：无符号数补零，有符号数按符号扩展补 `0xff` 或 `0x00`。`bool` 等价于 `uint8`，`true` 编码为 1，`false` 为 0。`address` 等价于 `uint160`，20 字节的地址因此落在 32 字节字的低位，前面补 12 个零字节。定长字节类型 `bytes<M>` 的填充方向相反：值靠左对齐，右侧补零到 32 字节。同为静态类型，`uint32(1)` 与 `bytes4(0x00000001)` 落在同一个字里但位置不同，前者在最后 4 个字节，后者在最前 4 个字节，这是读十六进制转储时最容易看错的地方。

定长数组 `<type>[M]` 在元素类型也是静态时同样归为静态，编码时按元组展开，M 个元素依次排开，不写长度。结构与元组同理递归展开。规范把静态类型定义为“除 `bytes`、`string`、`T[]`、元素为动态类型的 `T[k]` 以及含动态成员元组之外的全部类型”，这条定义是判断“直接读值还是跟指针”的唯一依据。

calldata 本身也有 gas 成本，且与 ABI 的 32 字节对齐并不一致。EIP-2028 把交易数据里非零字节的代价从 68 gas 降到 16 gas，零字节仍为 4 gas，这段成本属于交易固有 gas。`uint256` 传一个很小的数值仍会占满 32 字节，其中绝大多数是零字节，成本低但并非为零；反过来，把多个小整数按 `bytes32` 紧凑打包能省 gas，代价是链上失去了可读的参数边界，解码侧必须知道这份自定义布局。

## 动态类型靠偏移量与长度前缀二级寻址

`bytes`、`string`、`T[]`，以及元素为动态类型的定长数组与元组，都属于动态类型。它们的长度取决于运行时的值，无法在编译期固定，所以不能在参数字节流里占据固定宽度。

ABI 采用头尾分离（head/tail）布局。所有参数的“头”先依次排开：静态类型的头就是它自己的编码，动态类型的头是一个 32 字节偏移量。偏移量的基准是参数块起点，也就是选择器之后的第一个字节，而不是整段 calldata 的起点，这是手工解码时最常见的错位来源。头的长度只取决于参数类型，与参数值无关。头之后依次放置各动态参数的“尾”，尾的第一项是长度，单位按类型区分：`bytes` 与 `string` 是字节数，数组是元素个数，长度之后才是内容。

`string` 的长度写的是 UTF-8 编码后的字节数，不是字符数，中文与 emoji 因此会占多个字节。内容之后补零到 32 字节的整数倍。数组元素递归套用同一套规则，嵌套数组（如 `uint256[][]`）里每一层都有自己的长度与偏移量，偏移量始终相对于它所在那一层编码块的起点。规范把这条设计的目标写得很明确：最坏情况下访问某个值的读取次数不超过它在参数结构中的嵌套深度，并且任一元素的数据可以整体搬运而不失效，因为它只依赖相对地址。

一个容易被忽略的细节是偏移量并不要求最小，也不要求数据区互不重叠。规范定义的严格编码模式（strict encoding mode）要求头部紧凑、无空洞，Solidity 编码器始终输出严格模式，但解码器不强制检查。换言之，同一段语义相同的调用可能存在多种字节布局，解码实现要么接受带空洞的偏移量，要么显式拒绝；两种选择在跨客户端对比时会产生差异。

## 手工解码一段 calldata

解码示例直接取自《Contract ABI Specification》的 Examples 一节：调用 `sam(bytes,bool,uint256[])`，参数为 `"dave"`、`true`、`[1,2,3]`，共 292 字节，按 32 字节换行如下。

```text
0xa5643bf2
0000000000000000000000000000000000000000000000000000000000000060
0000000000000000000000000000000000000000000000000000000000000001
00000000000000000000000000000000000000000000000000000000000000a0
0000000000000000000000000000000000000000000000000000000000000004
6461766500000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000001
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
```

解码步骤可以严格按规范定义复现：

1. 前 4 字节 `0xa5643bf2` 是选择器，对应签名 `sam(bytes,bool,uint256[])`。注意签名里的 `uint` 必须写成 `uint256`。
2. 参数块共有 3 个头，每个 32 字节。第 1 个字是 `0x60`，即 96，是第 1 个参数 `bytes` 的偏移量；第 2 个字是 `0x01`，`bool` 为 `true`；第 3 个字是 `0xa0`，即 160，指向第 3 个参数 `uint256[]`。
3. 头共 96 字节，所以第 1 个参数的尾从参数块第 96 字节开始，`0x60` 自洽。尾的第 1 个字是 `0x04`，表示 `bytes` 有 4 字节；随后 4 字节是 `64617665`，按 ASCII 读作 `dave`；再补零到 32 字节。
4. 第 1 个参数的尾占两个字共 64 字节，从 96 到 160，于是第 2 个动态参数的偏移量为 `0xa0`。该处第 1 个字是 `0x03`，说明数组有 3 个元素；随后 3 个字分别是 1、2、3。

同样的过程可以写成一个最小解码器。下面这段 Python 只处理上面这一种签名，用来说明偏移量的单位是字节，以及长度前缀的位置。

```python
# 输入为选择器之后的参数块，按 32 字节切字
def word(data, i):
    return int.from_bytes(data[i * 32:(i + 1) * 32], "big")

def decode_sam(args: bytes):
    bytes_off = word(args, 0)          # 第 1 个字：bytes 参数相对参数块起点的字节偏移
    flag = word(args, 1) != 0          # 第 2 个字：bool
    arr_off = word(args, 2)            # 第 3 个字：uint256[] 的偏移

    n = word(args, bytes_off // 32)    # 偏移换算成字索引后，读长度前缀
    s = args[bytes_off + 32: bytes_off + 32 + n].decode()

    k = word(args, arr_off // 32)      # 数组同样先读元素个数
    items = [word(args, arr_off // 32 + 1 + j) for j in range(k)]
    return s, flag, items              # ("dave", True, [1, 2, 3])
```

## ABI 不自描述，schema 是链下必需件

规范的开篇就写明：这套编码不是自描述的，解码必须有 schema。这句话的工程含义比它读起来要重。

EVM 收到的只有字节，函数名、参数名、类型信息在编译后全部消失。分发靠比较 4 字节选择器，跳转目标是编译器生成的 switch；返回数据同样只是字节流，调用方要读取动态长度返回值，需要 `RETURNDATASIZE` 与 `RETURNDATACOPY` 这类操作（EIP-211 提出，拜占庭升级激活）。链上没有任何位置存放 ABI JSON，它只能作为编译产物随源码一起发布。由此产生几条具体约束。

区块浏览器要显示“调用了哪个函数、参数是什么”，必须先拿到已验证的源码与 ABI。未验证的合约只能显示原始 calldata，或者退而用 4byte 类数据库反查，而反查得到的是候选签名集合，碰撞时无法确定。索引器与数据管道必须在部署时就知道 ABI，否则无法为事件和调用建索引。接口演进也没有字段编号这类兼容机制：给函数增加、删除或重排参数都会改变选择器，对调用方是彻底的接口破坏，因此升级通常只能新增函数，而不是修改旧函数。代理合约可以在实现层新增函数，但选择器必须保持稳定，任何改变签名的“重构”都会让既有调用落到 fallback 分支，而 fallback 只能拿到原始 calldata，无法还原调用方的意图。

另外两个点常被误解。选择器相同不等于编码可互换：`transfer(address,uint256)` 与 `many_msg_babbage(bytes1)` 共享 `0xa9059cbb`，但参数长度与语义完全不同，工具在碰撞时必须结合合约代码或 calldata 长度判断。自定义错误（custom error）的选择器同样取自错误签名的前 4 字节，任意合约都可以返回一段匹配某错误签名的数据，规范因此明确提醒调用方不要把错误数据当成来源可靠的信息，它只适合作为提示。

## 事件把可检索性放进 topics，把可读性留在 data

事件的编码规则与函数调用同源，落点却不同。一条日志由合约地址、最多 4 个主题（topic）和一段任意长度数据组成。非匿名事件的 `topics[0]` 固定是事件签名的 Keccak-256 哈希，不做 4 字节截断，这也是用 `eth_getLogs` 过滤事件时条件写的是签名哈希而不是事件名的原因。事件签名同样使用规范形式，`uint` 会归一化为 `uint256`。

其余参数按 `indexed` 与否分成两路。带 `indexed` 的值类型参数直接编码进 `topics[1]` 到 `topics[3]`：整数高位填充，定长字节右侧填充，地址取低 20 字节。非匿名事件的 indexed 参数最多 3 个，加上签名主题正好用满 4 个；声明为 `anonymous` 的事件不写签名主题，indexed 参数最多 4 个，代价是失去按签名过滤的能力。不带 `indexed` 的参数按 ABI 规则编码进 `data`，这段数据内部同样有头尾结构，动态参数在里面依旧靠偏移量寻址。

差异集中体现在 indexed 的复杂类型上。数组、`string`、`bytes` 与结构体一旦被标记为 `indexed`，写入 topic 的是这段编码的 Keccak-256 哈希。哈希不可逆，所以这类参数可以被精确检索：预先算出目标值的哈希，就能用它作为过滤条件命中日志；但无法从日志里还原原值，只能确认匹配与否。规范给出的对策是同时声明两个参数保存同一个值，一个 indexed 用于检索，一个不 indexed 用于读取，代价是日志体积与 gas 上升。哈希编码规则本身也留了歧义：结构体包含多个动态数组时，拼接后的字节序列可能不唯一，规范提醒不要仅凭 indexed 参数的检索结果断定事件含义。

最后要区分“可检索”与“可解码”。值类型 indexed 参数能直接读出，动态类型 indexed 参数只能命中不能读出；不 indexed 参数能读出但不能按值检索。日志把 topics 与 data 拆开，是在日志体积、检索能力与解码能力之间取折中，任何一类参数都无法同时拿到全部属性。

## 资料来源

- Solidity 文档《Contract ABI Specification》，函数选择器、头尾布局、严格编码模式、事件与 indexed 参数编码规范、`sam` 解码示例：https://docs.soliditylang.org/en/latest/abi-spec.html
- 4byte.directory 上 `0xa9059cbb` 的候选签名列表（查询于 2026 年 9 月，条目可自行提交）：https://www.4byte.directory/signatures/?bytes4_signature=0xa9059cbb
- EIP-609《Hardfork Meta: Byzantium》，把 EIP-211 与 EIP-214 列入拜占庭包含列表的元提案：https://eips.ethereum.org/EIPS/eip-609
- EIP-2028《Transaction data gas cost reduction》，非零 calldata 字节从 68 gas 降到 16 gas：https://eips.ethereum.org/EIPS/eip-2028
- EIP-211《New opcodes: RETURNDATASIZE and RETURNDATACOPY》，动态返回数据读取与 `BYZANTIUM_FORK_BLKNUM`：https://eips.ethereum.org/EIPS/eip-211
- Ethereum Yellow Paper，附录 G 费率表中 `G_txdatazero` 4 gas 与 `G_txdatanonzero` 16 gas：https://ethereum.github.io/yellowpaper/paper.pdf
- ethereum.org EVM 操作码参考与 wolflo/evm-opcodes 动态 gas 表，交易固有 gas 的零 / 非零字节计价：https://ethereum.org/en/developers/docs/evm/opcodes/ 、https://github.com/wolflo/evm-opcodes/blob/main/gas.md

## 延伸阅读

- [《EVM 兼容意味着什么：字节码、预编译、JSON-RPC 与工具链》](/zh/blog/evm-compatibility-explained)
- [《Bitroot 乐观并行化机制：检测、重执行与确定性》](/zh/blog/bitrootevm-)

调用指令与上下文的关系，参见 0.17《CALL 族对照：CALL、CALLCODE、DELEGATECALL 与 STATICCALL》。
