8.控制流(Control Flow)指令
以太坊虚拟机(EVM)中的控制流指令用于控制程序执行的流程。这类指令不涉及算术计算或内存操作,而是改变程序计数器(Program Counter,pc)的值,从而跳转到新的位置继续执行。
以下是常用的 5 个控制流指令及其详细说明(包含源码级别原理和 Gas 消耗):
🛑 1. STOP(0x00)
功能:
终止当前执行,正常返回。
源码实现:
func opStop(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
return nil, errStopToken
}行为:
- 不消耗任何栈、内存等资源。
- 直接停止程序执行。
Gas 消耗:
- 0 Gas
🔁 2. JUMP(0x56)
功能:
跳转到某个位置执行。
前提:
跳转目标必须是 JUMPDEST 指令的地址,否则异常。
栈行为:
栈顶:跳转地址源码实现:
func opJump(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
if interpreter.evm.abort.Load() {
return nil, errStopToken
}
pos := scope.Stack.pop()
if !scope.Contract.validJumpdest(&pos) {
return nil, ErrInvalidJump
}
*pc = pos.Uint64() - 1 // pc will be increased by the interpreter loop
return nil, nil
}validJumpdest会检查该地址是否是JUMPDEST(0x5b)字节。- 由于
pc在每条指令之后自动加 1,所以此处设置为dest - 1。
Gas 消耗:
- 8 Gas
❓ 3. JUMPI(0x57)
功能:
条件跳转,只有条件非零才跳转。
栈行为:
栈顶:condition
次栈顶:destination源码实现:
func opJumpi(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
dest, cond := scope.Stack.pop(), scope.Stack.pop()
if cond.IsZero() {
return nil, nil // 不跳转
}
if !interpreter.validJumpdest(dest.Uint64(), scope.Contract.Code) {
return nil, ErrInvalidJump
}
*pc = dest.Uint64() - 1
return nil, nil
}行为说明:
- 如果
cond == 0,什么也不做。 - 如果
cond != 0,执行跳转到dest。
Gas 消耗:
- 10 Gas
🎯 4. JUMPDEST(0x5b)
功能:
跳转目标的标记,标志合法的 JUMP/JUMPI 可跳转的位置。
源码实现:
func opJumpdest(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
return nil, nil
}行为:
- 什么都不做,就是个占位符。
- 编译器在生成代码时会在跳转目标位置插入
JUMPDEST。
Gas 消耗:
- 1 Gas
📍 5. PC(0x58)
功能:
将当前指令的程序计数器 pc 压入栈。
栈行为:
push 当前 PC源码实现:
func opPc(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
scope.Stack.push(new(uint256.Int).SetUint64(*pc))
return nil, nil
}用途:
- 常用于合约内部的程序构造(如 inline jump)。
- 与
JUMP配合使用时可构造动态跳转。
Gas 消耗:
- 2 Gas
🔍 总结对比表:
| 指令 | 含义 | 栈操作 | Gas 消耗 | 说明 |
|---|---|---|---|---|
STOP | 停止执行 | 无 | 0 | 不返回值,正常终止 |
JUMP | 跳转 | 弹出目标地址 | 8 | 目标地址必须是 JUMPDEST |
JUMPI | 条件跳转 | 弹出地址和条件 | 10 | 条件非零跳转 |
JUMPDEST | 跳转目标标记 | 无 | 1 | 合法跳转的目标 |
PC | 获取当前指令地址 | 压入当前 pc | 2 | 可用于构造 jump 表 |
9.查询区块信息
以太坊虚拟机(EVM)中用于查询区块信息的 9 个指令的详细说明,包括其功能、Gas 消耗以及简要的源码实现原理。
📦 区块信息相关指令一览
| 指令名 | 操作码 | 功能描述 | Gas 消耗 | 栈操作(输入 → 输出) |
|---|---|---|---|---|
BLOCKHASH | 0x40 | 获取指定区块号的哈希(最近 256 个区块) | 20 | blockNumber → blockHash |
COINBASE | 0x41 | 当前区块的矿工地址 | 2 | → address |
TIMESTAMP | 0x42 | 当前区块的时间戳 | 2 | → timestamp |
NUMBER | 0x43 | 当前区块号 | 2 | → blockNumber |
PREVRANDAO | 0x44 | 上一个区块的随机数(Beacon 链) | 2 | → randomness |
GASLIMIT | 0x45 | 当前区块的 Gas 上限 | 2 | → gasLimit |
CHAINID | 0x46 | 当前链的 Chain ID | 2 | → chainId |
BASEFEE | 0x48 | 当前区块的 Base Fee | 2 | → baseFee |
BLOBBASEFEE | 0x4A | 当前区块的 Blob Base Fee(EIP-7516) | 2 | → blobBaseFee |
🔍 指令详解与源码实现概述
1. BLOCKHASH(0x40)
- 功能:获取指定区块号的哈希,仅限于最近 256 个区块。
- Gas 消耗:20
源码实现概述:
- 从栈中弹出区块号。
- 调用
GetHash(blockNumber)方法获取对应的区块哈希。 - 将结果压入栈中。
2. COINBASE(0x41)
- 功能:返回当前区块的矿工地址。
- Gas 消耗:2
源码实现概述:
- 直接从区块上下文中读取
Coinbase字段。 - 将结果压入栈中。
- 直接从区块上下文中读取
3. TIMESTAMP(0x42)
- 功能:返回当前区块的时间戳。
- Gas 消耗:2
源码实现概述:
- 从区块上下文中读取
Time字段。 - 将结果压入栈中。Solidity Developer
- 从区块上下文中读取
4. NUMBER(0x43)
- 功能:返回当前区块号。
- Gas 消耗:2
源码实现概述:
- 从区块上下文中读取
Number字段。 - 将结果压入栈中。
- 从区块上下文中读取
5. PREVRANDAO(0x44)
- 功能:返回上一个区块的随机数(适用于以太坊 2.0)。
- Gas 消耗:2
源码实现概述:
- 从区块上下文中读取
PrevRandao字段。 - 将结果压入栈中。
- 从区块上下文中读取
6. GASLIMIT(0x45)
- 功能:返回当前区块的 Gas 上限。
- Gas 消耗:2
源码实现概述:
- 从区块上下文中读取
GasLimit字段。 - 将结果压入栈中。Ethereum Stack Exchange
- 从区块上下文中读取
7. CHAINID(0x46)
- 功能:返回当前链的 Chain ID。
- Gas 消耗:2
源码实现概述:
- 从链配置中读取
ChainID。 - 将结果压入栈中。GitHub+4以太坊+4GitHub+4
- 从链配置中读取
8. BASEFEE(0x48)
- 功能:返回当前区块的 Base Fee。
- Gas 消耗:2
源码实现概述:
- 从区块上下文中读取
BaseFee字段。 - 将结果压入栈中。Secure Contracts+2以太坊+2ZKsync Docs+2
- 从区块上下文中读取
9. BLOBBASEFEE(0x4A)
- 功能:返回当前区块的 Blob Base Fee(EIP-7516)。
- Gas 消耗:2
源码实现概述:
- 从区块上下文中读取
BlobBaseFee字段。 - 将结果压入栈中。Abstract+6以太坊+6GitHub+6
- 从区块上下文中读取
10.账户 Account 相关
EVM 中与 账户 Account 相关的 4 个指令分别是:
BALANCE (0x31)EXTCODESIZE (0x3B)EXTCODECOPY (0x3C)EXTCODEHASH (0x3F)
在以太坊的 Geth 实现中,每个账户(外部账户或合约账户)在执行期间被建模为一个 stateObject,保存在 StateDB.stateObjects 这个 map 中,其 key 是账户地址,value 就是 *stateObject
🔍 stateObject 结构字段详解
👉 基本信息字段
| 字段 | 类型 | 说明 |
|---|---|---|
db | *StateDB | 关联的状态数据库,提供持久化和状态读取功能 |
address | common.Address | 当前账户地址(20 字节) |
addrHash | common.Hash | 当前地址的 keccak256 哈希,作为 MPT 中的键 |
origin | *types.StateAccount | 初始状态副本(还没被当前 block 修改的快照) |
data | types.StateAccount | 当前状态数据,包含变更后余额、nonce、storage root、code hash |
其中,types.StateAccount 是这样定义的:
type StateAccount struct {
Nonce uint64
Balance *uint256.Int
Root common.Hash // storage trie 的根哈希
CodeHash []byte // 合约字节码的哈希值
}👉 存储相关字段(Storage Tries)
以太坊合约的每个 storage 都是一个单独的 Merkle Patricia Trie。
| 字段 | 类型 | 说明 |
|---|---|---|
trie | Trie 接口 | 当前账户的 storage trie(延迟加载,首次访问时才初始化) |
originStorage | Storage(map[Hash]Hash) | 当前区块中曾读取过的存储项,原始值 |
dirtyStorage | Storage | 当前交易中被写入的存储项(临时更改) |
pendingStorage | Storage | 当前区块中写入但尚未 commit 的存储项 |
uncommittedStorage | Storage | Byzantium 之后,追踪自 block 开始以来的所有修改(辅助合约重入和 Gas Refund) |
这些字段的存在是为了:
- 加速访问:减少 trie 查询频率
- 支持事务隔离:交易提交前不影响其他交易
- 支持 fork 条件下状态回滚与恢复
⚠️ 特别注意:只有 commit 之后,才会写入 storage trie,并最终持久化到 disk 上的 LevelDB。👉 合约代码缓存相关
| 字段 | 类型 | 说明 |
|---|---|---|
code | []byte | 缓存的合约字节码(由 CodeHash 指向) |
dirtyCode | bool | 合约代码是否在当前交易中被修改过(用于判断是否需要更新 CodeHash) |
- 合约代码是不可变的,因此通常只在合约部署时写入一次。
- 读取时会从 CodeHash 中查找缓存或 LevelDB 中的代码。
👉 Self-Destruct 与合约创建状态
| 字段 | 类型 | 说明 |
|---|---|---|
selfDestructed | bool | 是否在当前交易中触发了 SELFDESTRUCT,但还没彻底清理 |
newContract | bool | 是否是新部署的合约(用于 EIP-6780 控制 selfdestruct 规则) |
selfDestructed 为 true 的账户在交易内仍然可访问,但交易结束后会被移除(余额归属给指定地址)。
newContract 是为了解决 EIP-6780 提出的新规则 —— 并非所有合约都能 selfdestruct。
📌 stateObject 的作用概括
| 作用 | 说明 |
|---|---|
| 状态建模 | 描述一个账户的完整状态(余额、nonce、storage、code) |
| 执行隔离 | 支持事务级别的变更记录、撤销和写入 |
| 持久化桥梁 | 从内存(变更)到 MPT(trie 节点)再到 LevelDB |
| Gas 计算 | 通过状态变更辅助计算 Gas(如 storage 修改、code 更新) |
| Fork 支持 | 支持区块级与交易级的状态快照、回滚和恢复 |
🧠 额外说明:Storage 的生命周期
| 阶段 | 操作 | 写入位置 |
|---|---|---|
| 读取 | SLOAD | originStorage |
| 写入 | SSTORE | dirtyStorage(交易级)→ pendingStorage(区块级) |
| 提交 | tx commit | 把 pendingStorage 提交到 storage trie(trie) |
| 区块 commit | block commit | 把所有账户的 trie 根更新到 global state trie 的节点 |
下面我将分别给出它们在 Go-Ethereum 中的源码实现(关键代码段)、功能、Gas 消耗和行为解释。
✅ 1. BALANCE (0x31)
📌 功能
返回指定地址的账户余额(以 wei 为单位)
⛽ Gas 消耗
- Cold access: 2600(EIP-2929)
- Warm access: 100
🧠 Go 源码
func opBalance(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
slot := scope.Stack.peek()
address := common.Address(slot.Bytes20())
slot.Set(interpreter.evm.StateDB.GetBalance(address))
return nil, nil
}
func (s *StateDB) GetBalance(addr common.Address) *uint256.Int {
stateObject := s.getStateObject(addr)
if stateObject != nil {
return stateObject.Balance()
}
return common.U2560
}
func (s *stateObject) Balance() *uint256.Int {
return s.data.Balance
}
type stateObject struct {
db *StateDB
address common.Address // address of ethereum account
addrHash common.Hash // hash of ethereum address of the account
origin *types.StateAccount // Account original data without any change applied, nil means it was not existent
data types.StateAccount // Account data with all mutations applied in the scope of block
...
}
type StateAccount struct {
Nonce uint64
Balance *uint256.Int
Root common.Hash // merkle root of the storage trie
CodeHash []byte
}🔍 说明
scope.Stack是当前 EVM 执行上下文的栈。peek()获取栈顶元素(并不弹出)。- 通过
Bytes20()将其强制转换成 20 字节(以太坊地址)。然后包装为common.Address。 getStateObject会尝试从状态树或缓存中获取地址对应的账户(stateObject)。- 如果存在,调用
Balance()。 - 如果地址不存在(合约或账户从未出现过),则余额视为 0(
common.U2560是uint256的 0 值)。
- 如果存在,调用
stateObject.data是当前账户的状态快照(类型StateAccount)。其中
Balance是个*uint256.Int指针,存储账户余额。
✅ 2. EXTCODESIZE (0x3B)
📌 功能
返回某个地址的合约字节码长度
⛽ Gas 消耗
- Cold access: 2600(EIP-2929)
- Warm access: 100
🧠 Go 源码
func opExtCodeSize(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
slot := scope.Stack.peek()
slot.SetUint64(uint64(interpreter.evm.StateDB.GetCodeSize(slot.Bytes20())))
return nil, nil
}🔍 说明
- 从
StateDB.GetCodeSize(addr)获取代码字节数组长度
✅ 3. EXTCODECOPY (0x3C)
📌 功能
将指定地址的合约代码复制到内存中(用于读取部分代码)
⛽ Gas 消耗
- Base: 100
- Per word copied: 3 × ceil(size / 32)
- Cold access penalty: +2600(如果是冷访问)
🧠 Go 源码
func opExtCodeCopy(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
var (
memOffset = scope.Stack.pop()
codeOffset = scope.Stack.pop()
length = scope.Stack.pop()
)
uint64CodeOffset, overflow := codeOffset.Uint64WithOverflow()
if overflow {
uint64CodeOffset = math.MaxUint64
}
codeCopy := getData(scope.Contract.Code, uint64CodeOffset, length.Uint64())
scope.Memory.Set(memOffset.Uint64(), length.Uint64(), codeCopy)
return nil, nil
}🔍 说明
从 EVM 操作数栈中依次弹出:
memOffset: 写入内存的起始偏移位置codeOffset: 要拷贝的代码段的起始位置length: 要拷贝的字节数
- 代码偏移量从大整数转换为
uint64,若超出范围就使用最大值。防止恶意代码传入极大值导致 panic。 - 从目标账户的代码中提取指定区间的字节(类似切片截取)
- 如果越界了,EVM 要求 padding 为 0(填零)。
append(..., make(...))是为了避免 panic 并补足返回值 用于代理合约(Proxy)判断目标地址是否有代码。
- 用于合约间交互时动态读取合约逻辑。
- 用于合约内通过代码检查是否是合约地址(如 EIP-1167 的 minimal proxy)。
✅ 4. EXTCODEHASH (0x3F)
📌 功能
返回指定地址代码的 keccak256(code),如果该地址没有代码,则返回 0
⛽ Gas 消耗
- Cold access: 2600(EIP-1052)
- Warm access: 100
🧠 Go 源码
func opExtCodeHash(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
slot := scope.Stack.peek()
address := common.Address(slot.Bytes20())
if interpreter.evm.StateDB.Empty(address) {
slot.Clear()
} else {
slot.SetBytes(interpreter.evm.StateDB.GetCodeHash(address).Bytes())
}
return nil, nil
}🔍 说明
- 从
StateDB获取该账户的 代码哈希(通常是 Keccak256),并设置到栈顶。
📊 总结对比表
| 指令 | 功能 | Gas(Cold/Warm) | 依赖 StateDB 方法 |
|---|---|---|---|
BALANCE | 账户余额 | 2600 / 100 | GetBalance, AccessAccount |
EXTCODESIZE | 合约代码长度 | 2600 / 100 | GetCode, AccessAccount |
EXTCODECOPY | 复制合约代码到内存 | 2600 + 3/word | GetCode, AccessAccount |
EXTCODEHASH | 合约代码哈希 | 2600 / 100 | GetCodeHash, HasCode |
11.交易相关
EVM 中与 交易上下文(Transaction Context) 相关的 4 个核心指令包括:
| 指令 | 功能 |
|---|---|
ADDRESS | 当前合约地址 |
ORIGIN | 外部交易发起方地址(EOA) |
CALLER | 当前调用者地址 |
CALLVALUE | 调用时附带的 msg.value 数值 |
下面将详细从 最新 Geth 源码(v1.14.x) 层面解析这些指令的逻辑实现,以及它们的 Gas 消耗。
🔹 1. ADDRESS
✅ 功能
返回当前执行合约的地址(address(this))
📘 源码实现
func opAddress(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
scope.Stack.push(new(uint256.Int).SetBytes(scope.Contract.Address().Bytes()))
return nil, nil
}解释:
scope.Contract是当前正在执行的合约实例。Address()返回其地址,20 字节压入栈顶。
⛽ Gas 消耗
| 操作 | Gas |
|---|---|
| ADDRESS | 2 |
🔹 2. ORIGIN
✅ 功能
返回最初交易发起者地址(一个 EOA),即 tx.origin
📘 源码实现
func opOrigin(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
scope.Stack.push(new(uint256.Int).SetBytes(interpreter.evm.Origin.Bytes()))
return nil, nil
}解释:
interpreter.evm.Origin在执行上下文初始化时设置。- 通常为最初的 EOA(Externally Owned Account)地址。
⛽ Gas 消耗
| 操作 | Gas |
|---|---|
| ORIGIN | 2 |
🔹 3. CALLER
✅ 功能
返回当前消息调用者(可以是合约或EOA),即 msg.sender
📘 源码实现
func opCaller(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
scope.Stack.push(new(uint256.Int).SetBytes(scope.Contract.Caller().Bytes()))
return nil, nil
}解释:
- 如果合约 A 调用合约 B,则
msg.sender为 A。 scope.Contract.Caller()即当前调用者地址。
⛽ Gas 消耗
| 操作 | Gas |
|---|---|
| CALLER | 2 |
🔹 4. CALLVALUE
✅ 功能
返回当前调用时附带的以 wei 为单位的金额,即 msg.value
📘 源码实现
func opCallValue(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
scope.Stack.push(new(uint256.Int).Set(scope.Contract.value))
return nil, nil
}解释:
scope.Contract.value表示此次消息调用的 ETH 附带值。- 是一个 *uint256.Int 表示的数值。
⛽ Gas 消耗
| 操作 | Gas |
|---|---|
| CALLVALUE | 2 |
✅ 汇总对比表格
| 指令 | 功能 | 栈操作 | 来源字段 | Gas |
|---|---|---|---|---|
ADDRESS | 当前合约地址 | push(address) | scope.Contract.Address() | 2 |
ORIGIN | 交易原始发起者(EOA) | push(tx.origin) | interpreter.evm.Origin | 2 |
CALLER | 调用者地址(msg.sender) | push(msg.sender) | scope.Contract.Caller() | 2 |
CALLVALUE | 附带 ETH 值(msg.value) | push(msg.value) | scope.Contract.value | 2 |
12.日志相关
EVM 中与日志(Log)相关的指令是:
| 指令名 | 说明 |
|---|---|
| LOG0 | 不带 topic 的日志 |
| LOG1 | 1 个 topic |
| LOG2 | 2 个 topic |
| LOG3 | 3 个 topic |
| LOG4 | 4 个 topic(最多) |
这些指令由智能合约调用 LOGx 指令时触发,最终写入 TransactionReceipt 的 logs 字段中。
📦 Golang 源码位置
路径:core/vm/instructions.go
以 LOG0 为例,实际实现是共用一个函数:
func makeLog(size int) executionFunc {
return func(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) {
if interpreter.readOnly {
return nil, ErrWriteProtection
}
topics := make([]common.Hash, size)
stack := scope.Stack
mStart, mSize := stack.pop(), stack.pop()
for i := 0; i < size; i++ {
addr := stack.pop()
topics[i] = addr.Bytes32()
}
d := scope.Memory.GetCopy(mStart.Uint64(), mSize.Uint64())
interpreter.evm.StateDB.AddLog(&types.Log{
Address: scope.Contract.Address(),
Topics: topics,
Data: d,
// This is a non-consensus field, but assigned here because
// core/state doesn't know the current block number.
BlockNumber: interpreter.evm.Context.BlockNumber.Uint64(),
})
return nil, nil
}
}在初始化指令集时,会这样注册:
LOG0: {
execute: makeLog(0),
dynamicGas: makeGasLog(0),
minStack: minStack(2, 0),
maxStack: maxStack(2, 0),
memorySize: memoryLog,
},
LOG1: {
execute: makeLog(1),
dynamicGas: makeGasLog(1),
minStack: minStack(3, 0),
maxStack: maxStack(3, 0),
memorySize: memoryLog,
},
LOG2: {
execute: makeLog(2),
dynamicGas: makeGasLog(2),
minStack: minStack(4, 0),
maxStack: maxStack(4, 0),
memorySize: memoryLog,
},
LOG3: {
execute: makeLog(3),
dynamicGas: makeGasLog(3),
minStack: minStack(5, 0),
maxStack: maxStack(5, 0),
memorySize: memoryLog,
},
LOG4: {
execute: makeLog(4),
dynamicGas: makeGasLog(4),
minStack: minStack(6, 0),
maxStack: maxStack(6, 0),
memorySize: memoryLog,
},🔍 详细解析
❶ mStart, mSize:
从合约内存中提取数据的起始位置和长度。
❷ topics:
根据 LOGx 中的 x,从栈中弹出对应数量的 topic。
❸ data:
从合约内存中 GetCopy 取出 mSize 字节数据,这就是事件的 data 部分。
❹ 调用 StateDB.AddLog:
这会将日志加入 logs 中,最终附着在交易回执(TransactionReceipt)中,供事件监听器或前端读取。
⛽ Gas 消耗
在 params\protocol_params.go 中定义:
LogGas uint64 = 375 // Per LOG* operation.🔢 LOGx 的 Gas 消耗计算方式:
Gas = memoryGasCost(mem, memorySize) + 375(基础) + 375 * topic 数 + 8 * data 长度(按字节)举例:
LOG2,有 2 个 topic,日志数据长 100 字节:Gas = memoryGasCost(mem, memorySize) + 375 + 375×2 + 8×100 = 375 + 750 + 800 = 1925
✅ 总结表格
| 指令 | Topics 数 | 栈项数 | 功能 | Gas 构成 |
|---|---|---|---|---|
| LOG0 | 0 | 2 | 写入 data | memoryGasCost(mem, memorySize) + 375 + 8×dataLen |
| LOG1 | 1 | 3 | 写入 1 topic + data | memoryGasCost(mem, memorySize) + 375 + 375×1 + 8×dataLen |
| LOG2 | 2 | 4 | 写入 2 topics + data | memoryGasCost(mem, memorySize) + 375 + 375×2 + 8×dataLen |
| LOG3 | 3 | 5 | 写入 3 topics + data | memoryGasCost(mem, memorySize) + 375 + 375×3 + 8×dataLen |
| LOG4 | 4 | 6 | 写入 4 topics + data | memoryGasCost(mem, memorySize) + 375 + 375×4 + 8×dataLen |
🧠 实战补充
合约中通常使用:
event Transfer(address indexed from, address indexed to, uint256 amount);
emit Transfer(msg.sender, to, value);EVM 将此编译为 LOG3(事件签名 hash + 2 indexed topics)。使用LOG3是因为函数选择器会被默认作为一个topic
LOG0 的事件是无法被事件过滤器(filter)快速索引的,记录不需要索引的调试信息或原始数据
| 事件声明 | indexed 数量 | EVM LOG 指令 | 实际 topic 数量(包含 selector) |
|---|---|---|---|
| event RawData(bytes data) anonymous; | 0 | LOG0 | 0 |
event A(uint256 value); | 0 | LOG1 | 1(只有事件 selector) |
event B(address indexed from); | 1 | LOG2 | 2(selector + from) |
event C(address indexed a, address indexed b); | 2 | LOG3 | 3(selector + a + b) |
event D(a indexed, b indexed, c indexed); | 3 | LOG4 | 4(selector + a + b + c) |
📌 布隆过滤器在 Ethereum 中的作用
🔍 主要用于:加速事件日志的检索
当你在使用 Web3.js、ethers.js 或者通过区块浏览器(例如 Etherscan)查询事件时,底层会:
- 查询区块头中的布隆过滤器(
logsBloom字段)。 - 检查事件的
topic是否可能出现在该区块中。
如果布隆过滤器中没命中,就一定没有这个事件,就不用再查日志详情了;
如果命中了,再去读取区块中完整的日志,逐条精确匹配。
🧠 举个例子
你查:
contract.filters.Transfer(address1, null)想查找某个地址是否发送过 Transfer 事件。
查询每个区块的 logsBloom:
- 如果 logsBloom 中没有这个地址(作为 topic 编码),那就不用继续查这个区块了。
- 如果有,再去深入查 logs(日志项),看有没有匹配的 Transfer 事件。
📦 EVM 的布隆过滤器结构
每个区块头中有个字段:logsBloom,大小是:
- 256 字节 = 2048 bit
- 为每个日志的 topic 和地址计算哈希(Keccak256),提取若干 bit 位置标记为 1
布隆过滤器构造时大致流程(简化):
bloom := [256]byte{} // 2048 bits
// 对每个 topic/address 做 keccak256,提取 3 个位置置 1
positions := getBloomBits(keccak256(data))
for pos in positions {
bloom[pos] = 1
}✅ 总结:Ethereum 中日志用布隆过滤器的目的
| 功能 | 描述 |
|---|---|
| 🌱 过滤加速 | 快速跳过不包含目标事件的区块,节省读取时间 |
| ⚡ 快速索引 | 只要 logsBloom 未命中,就无需解析区块体 |
| 📊 控制开销 | 减少节点为每个请求都加载全量日志的成本 |
| 🧪 概率保障 | 不会漏查存在的事件,但可能多查几个误判的区块(少量) |
评论 (0)