首页
Search
1
yamux: how to work?
79 阅读
2
The Art of Memory Allocation: Malloc, Slab, C++ STL, and GoLang Memory Allocation
70 阅读
3
How to receive a network packet in Linux
63 阅读
4
Maps and Memory Leaks in Go
54 阅读
5
C++ redis connection pool
52 阅读
测试
Wireguard
K8s
Redis
C++
Golang
Libcurl
Tailscale
Nginx
Linux
web3
Uniswap V2
Uniswap V3
EVM
security
solidity
openzeppelin
登录
Search
标签搜索
web3
solidity
web3 security
c++
uniswapV3
redis
evm
uniswap
性能测试
k8s
wireguard
CNI
http
tailscale
nginx
linux
设计模式
Jericho
累计撰写
51
篇文章
累计收到
13
条评论
首页
栏目
测试
Wireguard
K8s
Redis
C++
Golang
Libcurl
Tailscale
Nginx
Linux
web3
Uniswap V2
Uniswap V3
EVM
security
solidity
openzeppelin
页面
搜索到
3
篇与
的结果
2025-05-24
About the EVM Part III
13.Return指令🚀 1. RETURN 指令✅ 功能终止执行,并返回内存中一段数据作为调用结果。🔍 实现源码路径:go-ethereum/core/vm/instructions.gofunc opReturn(pc *uint64, evm *EVM, contract *Contract, stack *Stack) ([]byte, error) { offset, size := stack.pop(), stack.pop() ret := contract.GetMem(int64(offset.Uint64()), int64(size.Uint64())) return ret, nil }📦 Gas 消耗指令本身消耗:0(RETURN 没有额外指令成本)访问 memory 的成本:根据 offset+size 的访问范围来收取内存扩展费用(memoryGasCost)func memoryGasCost(memSize uint64) uint64 { // linear + quadratic cost function // linear: memSize * 3 // quadratic: (memSize^2) / 512 }💡 特点调用成功返回数据(用于 CALL, DELEGATECALL 等)常用于函数正常返回🚨 2. REVERT 指令✅ 功能回滚状态,同时返回错误数据(不会消耗所有 gas)。🔍 实现func opRevert(pc *uint64, evm *EVM, contract *Contract, stack *Stack) ([]byte, error) { offset, size := stack.pop(), stack.pop() ret := contract.GetMem(int64(offset.Uint64()), int64(size.Uint64())) evm.interpreter.returnData = ret return ret, ErrExecutionReverted }📦 Gas 消耗指令本身:0内存访问费用:同样受 memoryGasCost 限制保留剩余 gas,不销毁💡 特点引入自 Byzantium(EIP-140)与 INVALID、ASSERT 不同,它会“温柔地失败”,gas 不全耗尽,便于合约进行 try/catch💣 3. SELFDESTRUCT 指令(旧称 SUICIDE)✅ 功能销毁当前合约,并将余额发送给指定地址。🔍 实现func opSelfDestruct(pc *uint64, evm *EVM, contract *Contract, stack *Stack) ([]byte, error) { beneficiary := common.Address(stack.pop().Bytes20()) // handle refund and deletion evm.StateDB.SelfDestruct(contract.Address()) // transfer balance to beneficiary evm.Transfer(contract.Gas, contract.Address(), beneficiary, contract.Balance()) contract.SelfDestruct = true return nil, nil }📦 Gas 消耗固定成本:5000(EIP-150 后)若首次销毁:+24000(删除合约 refund)若 beneficiary 是新地址:再加 25000// In gas_table.go (EIP150): SelfDestruct: 5000, SelfDestructRefund: 24000,💡 特点合约立即从状态树中移除(实际上是标记为删除,block 结束后才真正移除)balance 强制转移,但无法返回自定义数据(不是直接返回 data)🔁 总结对比表指令名功能描述返回数据Gas 行为出现版本RETURN正常返回执行结果✅memory + 0初始版本REVERT回滚并返回错误信息✅memory + 0Byzantium (EIP-140)SELFDESTRUCT销毁合约,发送余额❌高消耗+可能 refund初始版本(EIP-6 优化)14.CALL、DELEGATECALL、STATICCALL📘 总览指令名是否改变调用者是否转移 msg.value是否可变更 storageCALL✅ 改变 msg.sender✅ 支持转账✅ 支持状态更改DELEGATECALL❌ 保持调用上下文❌ 不支持转账✅ 支持状态更改STATICCALL❌ 保持调用上下文❌ 不支持转账❌ 禁止状态更改✅ 1. opCall —— 普通合约调用核心逻辑ret, returnGas, err := interpreter.evm.Call( scope.Contract.Address(), // caller toAddr, // callee args, // calldata gas, // gas &value, // msg.value )实际调用函数:func (evm *EVM) Callcode(caller Address, addr Address, input []byte, gas uint64, value *big.Int) ([]byte, uint64, error)实现入口:core/vm/evm.go func (evm *EVM) Call(...) { return evm.call(...) // 里面处理合约创建、状态快照、gas 计算、revert 回滚等 }✅ 2. opDelegateCall —— 保留 caller/value 的“委托调用”核心逻辑ret, returnGas, err := interpreter.evm.DelegateCall( scope.Contract.Caller(), // 委托原始 caller scope.Contract.Address(), // 委托当前调用合约 toAddr, // 被委托的目标合约 args, gas, scope.Contract.value, // 保留 value )实际函数:func (evm *EVM) DelegateCall(caller, address Address, codeAddr Address, input []byte, gas uint64, value *big.Int) ([]byte, uint64, error)在 call.go 中构造新的 Contract 时,会设置:contract.CallerAddress = caller (保留 msg.sender)contract.value = value (保留 msg.value)使用被调用者 toAddr 的代码✅ 3. opStaticCall —— 只读调用核心逻辑ret, returnGas, err := interpreter.evm.StaticCall( scope.Contract.Address(), // caller toAddr, // callee args, gas, )实际函数:func (evm *EVM) StaticCall(caller, addr Address, input []byte, gas uint64) ([]byte, uint64, error)StaticCall 会在创建子合约时加上只读标记 contract.SetReadOnly(true),任何写状态的行为将触发 ErrWriteProtection 错误。⛽️ 四、Gas 消耗机制Gas 在 EVM 中高度复杂,调用相关的 gas 计算规则如下(均实现于 core/vm/gas_table.go 与 core/vm/gas.go):1. 通用机制在这几条指令中,gas 的计算由 interpreter.evm.callGasTemp 暂存,实际 gas 是前置算好的,计算时考虑以下因素:项描述BaseCost固定开销(通常为 700)Transfer如果转账了 value,增加 CallStipend = 2300(用于 fallback gas)MemoryGas计算传入传出数据的 memory access 成本(线性+平方增长)ColdAccessEIP-2929 引入冷账户调用要额外加 gas2. 详细举例:CALLgas = baseGas + memoryCost + (isColdAccount ? ColdAccessCost : 0) if value != 0 { gas += CallStipend }3. STATICCALL 特别限制:不允许非零 value(否则报错)状态写入全部禁止(storage、selfdestruct 等都会报 ErrWriteProtection)15.create、create2CREATE指令CREATE指令是EVM中用于创建新合约的基本指令,它使用调用者的地址和nonce作为合约地址的计算因子。在go-ethereum中,CREATE指令的实现主要在core/vm/instructions.go文件中:opCreate函数分析主要逻辑流程权限检查:if interpreter.readOnly { return nil, ErrWriteProtection }检查EVM是否处于只读模式,如果是则不允许创建合约。参数获取:value := scope.Stack.pop() // 转账金额 offset, size := scope.Stack.pop(), scope.Stack.pop() // 内存位置和大小 input := scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) // 初始化代码 gas := scope.Contract.Gas // 可用gasEIP150调整:if interpreter.evm.chainRules.IsEIP150 { gas -= gas / 64 }根据EIP150规则保留1/64的gas以防调用深度过大。执行创建:res, addr, returnGas, suberr := interpreter.evm.Create(scope.Contract.Address(), input, gas, &value)调用底层Create方法实际执行合约创建。结果处理:根据不同链规则处理ErrCodeStoreOutOfGas错误将结果地址压入栈中处理剩余的gas返还返回数据设置:如果执行被revert,保存返回数据否则清空返回数据缓冲区关键点使用调用者地址和nonce计算新合约地址处理Homestead和Frontier规则下的不同错误处理逻辑实现了EIP150的gas调整规则将创建结果(地址或错误)压入堆栈CREATE指令的gas消耗包括以下几个部分:基础创建费用:32,000 gas (params.CreateGas)内存扩展费用:如果操作需要扩展内存,按内存扩展规则计算代码存储费用:200 gas/字节(合约部署后存储的字节码)初始化代码执行费用:执行初始化代码时消耗的gasCREATE2指令CREATE2是EVM中更灵活的合约创建指令,允许调用者控制合约地址的计算方式,通过添加"salt"参数使得地址不依赖于nonce。Golang实现同样在core/vm/instructions.go中:opCreate2函数分析主要逻辑流程权限检查:if interpreter.readOnly { return nil, ErrWriteProtection }同样检查是否只读模式。参数获取:endowment := scope.Stack.pop() // 转账金额 offset, size := scope.Stack.pop(), scope.Stack.pop() // 内存位置和大小 salt := scope.Stack.pop() // 盐值 input := scope.Memory.GetCopy(offset.Uint64(), size.Uint64()) // 初始化代码 gas := scope.Contract.Gas // 可用gasEIP150调整:gas -= gas / 64同样保留1/64的gas。执行创建:res, addr, returnGas, suberr := interpreter.evm.Create2(scope.Contract.Address(), input, gas, &endowment, &salt)调用Create2方法,传入盐值。结果处理:如果有错误清空栈值否则将地址压入栈中处理剩余的gas返还返回数据设置:处理revert情况下的返回数据关键点使用盐值(salt)参与地址计算,使地址可预测不需要nonce,地址计算方式为keccak256(0xff ++ sender ++ salt ++ keccak256(init_code))[12:]错误处理比CREATE简单,不区分特殊错误情况同样实现EIP150的gas调整规则CREATE2的Gas消耗CREATE2的gas消耗与CREATE类似,但有以下区别:基础创建费用:32,000 gas (与CREATE相同)额外的哈希计算费用:CREATE2需要计算Keccak256哈希,每次哈希计算需要支付30 gas内存扩展费用:与CREATE相同代码存储费用:200 gas/字节(与CREATE相同)初始化代码执行费用:与CREATE相同关键区别地址计算方式:CREATE: keccak256(rlp([sender, nonce]))[12:]CREATE2: keccak256(0xff ++ sender ++ salt ++ keccak256(init_code))[12:]可预测性:CREATE地址依赖于nonce,无法提前预测CREATE2地址可以通过salt和init_code预先计算重放保护:CREATE自动增加nonce防止重放CREATE2需要应用层通过salt防止重放最新变化在最新的以太坊升级中(如伦敦升级、柏林升级等),CREATE和CREATE2的gas计算有一些调整:EIP-2929:增加了状态访问的gas成本,影响了合约创建时的状态访问操作EIP-3860:对init code长度收取额外gas,限制过长的init code这些变化使得合约创建的gas计算更加复杂,但基本框架保持不变。实际应用建议如果需要可预测的合约地址(如状态通道、合约工厂等),使用CREATE2如果不需要地址预测,使用CREATE更简单在gas估算时,一定要考虑初始化代码的执行成本和最终的代码存储成本对于复杂的合约部署,可以考虑使用代理模式减少部署成本以上代码和分析基于go-ethereum最新稳定版本,具体实现可能会随着版本更新而变化。16.GAS在EVM中,交易和执行智能合约需要消耗计算资源。为了防止用户恶意的滥用网络资源和补偿验证者所消耗的计算能源,以太坊引入了一种称为Gas的计费机制,使每一笔交易都有一个关联的成本。在发起交易时,用户设定一个最大Gas数量(gasLimit)和每单位Gas的价格(gasPrice)。如果交易执行超出了gasLimit,交易会回滚,但已消耗的Gas不会退还。以太坊上的Gas用gwei衡量,它是ETH的子单位,1 ETH = 10^9 gwei。一笔交易的Gas成本等于每单位gas价格乘以交易的gas消耗,即gasPrice * gasUsed。gas价格会随着时间的推移而变化,具体取决于当前对区块空间的需求。gas消耗由很多因素决定,并且每个以太坊版本都会有所改动,下面总结下:calldata大小:calldata中的每个字节都需要花费gas,交易数据的大小越大,gas消耗就越高。calldata每个零字节花费4 Gas,每个非零字节花费16 Gas(伊斯坦布尔硬分叉之前为 64 个)。内在gas:每笔交易的内在成本为21000 Gas。除了交易成本之外,创建合约还需要 32000 Gas。该成本是在任何操作码执行之前从交易中支付的。opcode固定成本:每个操作码在执行时都有固定的成本,以Gas为单位。对于所有执行,该成本都是相同的。比如每个ADD指令消耗3 Gas。opcode动态成本:一些指令消耗更多的计算资源取决于其参数。因此,除了固定成本之外,这些指令还具有动态成本。比如SHA3指令消耗的Gas随参数长度增长。内存拓展成本:在EVM中,合约可以使用操作码访问内存。当首次访问特定偏移量的内存(读取或写入)时,内存可能会触发扩展,产生gas消耗。比如MLOAD或RETURN。访问集成本:对于每个外部交易,EVM会定义一个访问集,记录交易过程中访问过的合约地址和存储槽(slot)。访问成本根据数据是否已经被访问过(热)或是首次被访问(冷)而有所不同。Gas退款:SSTORE的一些操作(比如清除存储)可以触发Gas退款。退款会在交易结束时执行,上限为总Gas消耗的20%(从伦敦硬分叉开始)。GAS 指令的功能作用:将当前合约调用剩余的 Gas 压入操作数栈,供后续指令使用。操作码:0x5A(参见 EVM 操作码列表)。Gas 消耗:固定 2 Gas(基础成本)。opGas 函数实现解析func opGas(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { scope.Stack.push(new(uint256.Int).SetUint64(scope.Contract.Gas)) return nil, nil }关键组件:scope.Contract.Gas当前合约调用的剩余 Gas 值,存储在 Contract 结构体中,随指令执行动态更新。scope.Stack.push将数据压入 EVM 的操作数栈(后进先出结构),栈的每个槽位为 uint256 类型。uint256.Int以太坊自定义的 256 位无符号整数库,用于处理 EVM 的 256 位数据宽度。执行流程:读取剩余 Gas:从 scope.Contract.Gas 获取当前剩余 Gas。转换为 uint256:通过 SetUint64 将 Gas 值封装为 256 位整数。压入栈顶:调用 scope.Stack.push 将 Gas 值存入栈中。返回空结果:GAS 指令不修改内存或存储,故返回 nil。EVM 执行上下文与 Gas 管理Gas 的生命周期交易初始化:用户为交易设置 GasLimit,例如 21000 Gas(简单转账)。合约执行:每条指令消耗 Gas(如 ADD 消耗 3 Gas,SSTORE 动态计算)。动态扣除:执行指令前,EVM 检查剩余 Gas 是否足够,不足则回滚。// 伪代码:EVM 执行指令前的 Gas 检查 if remainingGas < op.GasCost { return ErrOutOfGas } remainingGas -= op.GasCostContract 结构体中的 Gastype Contract struct { Gas uint64 // 剩余 Gas Code []byte // 合约字节码 Scope *ScopeContext // 当前执行上下文 // ... 其他字段 }Gas 字段:在合约调用开始时初始化为 GasLimit - 已消耗 Gas,逐条指令递减。为什么需要 GAS 指令?应用场景Gas 估算:合约可通过 GAS 指令动态检查剩余 Gas,避免操作中途耗尽。// Solidity 示例:检查剩余 Gas function doSomething() public { uint256 remainingGas = gasleft(); // 编译为 `GAS` 指令 require(remainingGas > 1000, "Insufficient gas"); }优化 Gas 消耗:复杂逻辑中根据剩余 Gas 调整执行路径(如循环次数)。与 gasleft() 的关系Solidity 的 gasleft() 函数会被编译为 GAS 操作码,二者等价。EVM 指令执行的全流程以执行 GAS 指令为例:指令调度:EVM 根据 pc(程序计数器)从合约字节码中读取 0x5A。调用 opGas:通过指令表跳转到对应的处理函数。栈操作:将剩余 Gas 压入栈顶,更新 pc(*pc++)。Gas 扣除:执行前已扣除 GAS 指令的基础成本(2 Gas)。关键设计思想显式 Gas 管理:EVM 要求所有操作明码标价,防止无限循环或资源滥用。栈式虚拟机:通过操作数栈传递参数和结果,GAS 指令符合这一模式。状态隔离:GAS 指令仅读取当前合约的 Gas,不影响其他上下文。扩展:其他 Gas 相关指令指令操作码功能GASPRICE0x3A获取当前交易的 Gas 单价GASLIMIT0x45获取当前区块的 Gas 上限总结opGas 的本质:将当前剩余 Gas 作为 uint256 压入栈顶,无副作用。EVM 的 Gas 机制:每条指令执行前扣除 Gas,不足则回滚。实用意义:智能合约可通过 gasleft() 动态优化执行逻辑。
2025年05月24日
3 阅读
0 评论
1 点赞
2025-05-22
About the EVM Part II
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目标地址必须是 JUMPDESTJUMPI条件跳转弹出地址和条件10条件非零跳转JUMPDEST跳转目标标记无1合法跳转的目标PC获取当前指令地址压入当前 pc2可用于构造 jump 表9.查询区块信息以太坊虚拟机(EVM)中用于查询区块信息的 9 个指令的详细说明,包括其功能、Gas 消耗以及简要的源码实现原理。📦 区块信息相关指令一览指令名操作码功能描述Gas 消耗栈操作(输入 → 输出)BLOCKHASH0x40获取指定区块号的哈希(最近 256 个区块)20blockNumber → blockHashCOINBASE0x41当前区块的矿工地址2→ addressTIMESTAMP0x42当前区块的时间戳2→ timestampNUMBER0x43当前区块号2→ blockNumberPREVRANDAO0x44上一个区块的随机数(Beacon 链)2→ randomnessGASLIMIT0x45当前区块的 Gas 上限2→ gasLimitCHAINID0x46当前链的 Chain ID2→ chainIdBASEFEE0x48当前区块的 Base Fee2→ baseFeeBLOBBASEFEE0x4A当前区块的 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 Developer4. NUMBER(0x43)功能:返回当前区块号。Gas 消耗:2源码实现概述:从区块上下文中读取 Number 字段。将结果压入栈中。5. PREVRANDAO(0x44)功能:返回上一个区块的随机数(适用于以太坊 2.0)。Gas 消耗:2源码实现概述:从区块上下文中读取 PrevRandao 字段。将结果压入栈中。6. GASLIMIT(0x45)功能:返回当前区块的 Gas 上限。Gas 消耗:2源码实现概述:从区块上下文中读取 GasLimit 字段。将结果压入栈中。Ethereum Stack Exchange7. CHAINID(0x46)功能:返回当前链的 Chain ID。Gas 消耗:2源码实现概述:从链配置中读取 ChainID。将结果压入栈中。GitHub+4以太坊+4GitHub+48. BASEFEE(0x48)功能:返回当前区块的 Base Fee。Gas 消耗:2源码实现概述:从区块上下文中读取 BaseFee 字段。将结果压入栈中。Secure Contracts+2以太坊+2ZKsync Docs+29. BLOBBASEFEE(0x4A)功能:返回当前区块的 Blob Base Fee(EIP-7516)。Gas 消耗:2源码实现概述:从区块上下文中读取 BlobBaseFee 字段。将结果压入栈中。Abstract+6以太坊+6GitHub+610.账户 Account 相关EVM 中与 账户 Account 相关的 4 个指令分别是:BALANCE (0x31)EXTCODESIZE (0x3B)EXTCODECOPY (0x3C)EXTCODEHASH (0x3F)在以太坊的 Geth 实现中,每个账户(外部账户或合约账户)在执行期间被建模为一个 stateObject,保存在 StateDB.stateObjects 这个 map 中,其 key 是账户地址,value 就是 *stateObject🔍 stateObject 结构字段详解👉 基本信息字段字段类型说明db*StateDB关联的状态数据库,提供持久化和状态读取功能addresscommon.Address当前账户地址(20 字节)addrHashcommon.Hash当前地址的 keccak256 哈希,作为 MPT 中的键origin*types.StateAccount初始状态副本(还没被当前 block 修改的快照)datatypes.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。字段类型说明trieTrie 接口当前账户的 storage trie(延迟加载,首次访问时才初始化)originStorageStorage(map[Hash]Hash)当前区块中曾读取过的存储项,原始值dirtyStorageStorage当前交易中被写入的存储项(临时更改)pendingStorageStorage当前区块中写入但尚未 commit 的存储项uncommittedStorageStorageByzantium 之后,追踪自 block 开始以来的所有修改(辅助合约重入和 Gas Refund)这些字段的存在是为了:加速访问:减少 trie 查询频率支持事务隔离:交易提交前不影响其他交易支持 fork 条件下状态回滚与恢复⚠️ 特别注意:只有 commit 之后,才会写入 storage trie,并最终持久化到 disk 上的 LevelDB。👉 合约代码缓存相关字段类型说明code[]byte缓存的合约字节码(由 CodeHash 指向)dirtyCodebool合约代码是否在当前交易中被修改过(用于判断是否需要更新 CodeHash)合约代码是不可变的,因此通常只在合约部署时写入一次。读取时会从 CodeHash 中查找缓存或 LevelDB 中的代码。👉 Self-Destruct 与合约创建状态字段类型说明selfDestructedbool是否在当前交易中触发了 SELFDESTRUCT,但还没彻底清理newContractbool是否是新部署的合约(用于 EIP-6780 控制 selfdestruct 规则)selfDestructed 为 true 的账户在交易内仍然可访问,但交易结束后会被移除(余额归属给指定地址)。newContract 是为了解决 EIP-6780 提出的新规则 —— 并非所有合约都能 selfdestruct。📌 stateObject 的作用概括作用说明状态建模描述一个账户的完整状态(余额、nonce、storage、code)执行隔离支持事务级别的变更记录、撤销和写入持久化桥梁从内存(变更)到 MPT(trie 节点)再到 LevelDBGas 计算通过状态变更辅助计算 Gas(如 storage 修改、code 更新)Fork 支持支持区块级与交易级的状态快照、回滚和恢复🧠 额外说明:Storage 的生命周期阶段操作写入位置读取SLOADoriginStorage写入SSTOREdirtyStorage(交易级)→ pendingStorage(区块级)提交tx commit把 pendingStorage 提交到 storage trie(trie)区块 commitblock 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: 100Per 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 / 100GetBalance, AccessAccountEXTCODESIZE合约代码长度2600 / 100GetCode, AccessAccountEXTCODECOPY复制合约代码到内存2600 + 3/wordGetCode, AccessAccountEXTCODEHASH合约代码哈希2600 / 100GetCodeHash, HasCode11.交易相关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 消耗操作GasADDRESS2🔹 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 消耗操作GasORIGIN2🔹 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 消耗操作GasCALLER2🔹 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 消耗操作GasCALLVALUE2✅ 汇总对比表格指令功能栈操作来源字段GasADDRESS当前合约地址push(address)scope.Contract.Address()2ORIGIN交易原始发起者(EOA)push(tx.origin)interpreter.evm.Origin2CALLER调用者地址(msg.sender)push(msg.sender)scope.Contract.Caller()2CALLVALUE附带 ETH 值(msg.value)push(msg.value)scope.Contract.value212.日志相关EVM 中与日志(Log)相关的指令是:指令名说明LOG0不带 topic 的日志LOG11 个 topicLOG22 个 topicLOG33 个 topicLOG44 个 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 构成LOG002写入 datamemoryGasCost(mem, memorySize) + 375 + 8×dataLenLOG113写入 1 topic + datamemoryGasCost(mem, memorySize) + 375 + 375×1 + 8×dataLenLOG224写入 2 topics + datamemoryGasCost(mem, memorySize) + 375 + 375×2 + 8×dataLenLOG335写入 3 topics + datamemoryGasCost(mem, memorySize) + 375 + 375×3 + 8×dataLenLOG446写入 4 topics + datamemoryGasCost(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是因为函数选择器会被默认作为一个topicLOG0 的事件是无法被事件过滤器(filter)快速索引的,记录不需要索引的调试信息或原始数据事件声明indexed 数量EVM LOG 指令实际 topic 数量(包含 selector)event RawData(bytes data) anonymous;0LOG00event A(uint256 value);0LOG11(只有事件 selector)event B(address indexed from);1LOG22(selector + from)event C(address indexed a, address indexed b);2LOG33(selector + a + b)event D(a indexed, b indexed, c indexed);3LOG44(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 未命中,就无需解析区块体📊 控制开销减少节点为每个请求都加载全量日志的成本🧪 概率保障不会漏查存在的事件,但可能多查几个误判的区块(少量)
2025年05月22日
4 阅读
0 评论
2 点赞
2025-05-21
About the EVM Part I
一、EVM 工作原理概述EVM 是一个 基于栈的图灵完备虚拟机,每次交易或合约调用都会在 EVM 中创建一个执行上下文,用于处理指令(opcode)。基本流程:接收交易:用户发起交易或合约调用,EVM 收到包含输入数据和 gas 的执行请求。读取字节码:合约代码会被编译成字节码(bytecode),并由 EVM 逐条解释执行。执行字节码:字节码指令在 EVM 中执行,通过操作栈、内存、存储、程序计数器(PC)等来完成逻辑。消耗 gas:每执行一条指令都会消耗 gas,gas 不足会导致回滚。更新状态:执行结束后,将结果写入状态树(State Trie)或回滚。二、EVM 中的三种关键存储结构1. 堆栈(Stack)大小固定:最大深度为 1024。操作方式:后进先出(LIFO),类似于汇编里的寄存器。所有算术逻辑操作都必须通过堆栈完成,如 ADD, MUL, CALLDATALOAD 等。只能操作栈顶元素(通过 PUSH, POP, DUP, SWAP 控制)。示例:PUSH1 0x02 PUSH1 0x03 ADD执行后:堆栈中留下 0x05。2. 内存(Memory)临时存储空间:生命周期只在当前合约调用过程中,调用结束就释放。是一个线性字节数组,按需扩展,初始化全为 0。可读写,适合处理中间数据,如函数参数、返回值、字符串、数组等。操作指令包括:MLOAD, MSTORE, MSTORE8。成本: 访问越高地址,gas 越贵(因为内存要扩容)。3. 存储(Storage)永久存储空间:存储在区块链状态中,合约变量的最终值都保存在这里。结构类似一个巨大的 key-value 映射(256-bit 到 256-bit)。极其昂贵(读比内存贵很多,写更贵),所以要谨慎使用。操作指令包括:SLOAD, SSTORE。示例: Solidity 中的 uint a; 在 EVM 中会映射到 storage[0],a = 5 相当于 SSTORE 0 5。三、EVM 执行模型简图:四、执行环境(Execution Context)Program Counter(PC):指向当前执行的字节码位置。Gas:限制执行资源,防止死循环。Call Data:外部调用传入的数据,只读。Code:当前执行的合约字节码。Logs:用于触发事件。返回值区域:放置 RETURN 指令返回的数据。五、与 Solidity 的映射关系Solidity 结构EVM 存储位置局部变量Stack / Memory状态变量Storage函数参数Memory / Stack数组 / 映射Storage / Memory(视情况而定)六、Opcodes1.分类Opcodes可以根据功能分为以下几类:堆栈(Stack)指令: 这些指令直接操作EVM堆栈。这包括将元素压入堆栈(如PUSH1)和从堆栈中弹出元素(如POP)。算术(Arithmetic)指令: 这些指令用于在EVM中执行基本的数学运算,如加法(ADD)、减法(SUB)、乘法(MUL)和除法(DIV)。比较(Comparison)指令: 这些指令用于比较堆栈顶部的两个元素。例如,大于(GT)和小于(LT)。位运算(Bitwise)指令: 这些指令用于在位级别上操作数据。例如,按位与(AND)和按位或(OR)。内存(Memory)指令: 这些指令用于操作EVM的内存。例如,将内存中的数据读取到堆栈(MLOAD)和将堆栈中的数据存储到内存(MSTORE)。存储(Storage)指令: 这些指令用于操作EVM的账户存储。例如,将存储中的数据读取到堆栈(SLOAD)和将堆栈中的数据保存到存储(SSTORE)。这类指令的gas消耗比内存指令要大。控制流(Control Flow)指令: 这些指令用于EVM的控制流操作,比如跳转JUMP和跳转目标JUMPDEST。上下文(Context)指令: 这些指令用于获取交易和区块的上下文信息。例如,获取msg.sender(CALLER)和当前可用的gas(GAS)。2.堆栈(Stack)指令栈结构的定义路径:go-ethereum/core/vm/stack.go//栈结构是一个uint256的切片 type Stack struct { data []uint256.Int }push确保了切片的长度最多1024func (st *Stack) push(d *uint256.Int) { // NOTE push limit (1024) is checked in baseCheck st.data = append(st.data, *d) } func (st *Stack) pop() (ret uint256.Int) { ret = st.data[len(st.data)-1] st.data = st.data[:len(st.data)-1] return }PUSH 指令族:PUSH1 ~ PUSH32作用:将后面紧跟的 1~32 字节常量压入栈顶。✅ 举例:PUSH1 0x60 // 相当于把十六进制 0x60 推入栈顶 PUSH2 0x1234 // 把 0x1234(2字节)推入栈顶源码实现位置:makePush 函数是 go-ethereum 中 EVM 对 PUSH1~PUSH32 指令族的统一处理函数,作用是生成一个具体的 PUSHn 执行函数。size:表示这个操作码整体长度是多少个字节(即 1 + pushByteSize,1 字节的 opcode + n 字节数据)pushByteSize:要从字节码中读取的字节数(即 PUSHn 中的 n)该函数返回一个 executionFunc,这个函数会在 EVM 执行该指令时被调用。🔍 函数体详解:func makePush(size uint64, pushByteSize int) executionFunc { return func(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { var ( codeLen = len(scope.Contract.Code) start = min(codeLen, int(*pc+1)) end = min(codeLen, start+pushByteSize) ) a := new(uint256.Int).SetBytes(scope.Contract.Code[start:end]) // Missing bytes: pushByteSize - len(pushData) if missing := pushByteSize - (end - start); missing > 0 { a.Lsh(a, uint(8*missing)) } scope.Stack.push(a) *pc += size return nil, nil } }Step 1️⃣ — 获取 PUSH 数据的范围codeLen := len(scope.Contract.Code) start := min(codeLen, int(*pc+1)) // 第一个立即数字节位置 end := min(codeLen, start+pushByteSize) // 最后一个字节的位置*pc 当前指向 PUSHn 操作码(1 字节)所以 *pc+1 才是数据开始的地址因为 EVM 执行过程中可能读取到结尾,需要用 min 防止越界🧠 例子:假设字节码为:0x60 0x0A // PUSH1 0x0A则:*pc = 0,opcode = 0x60 (PUSH1)start = 1end = 2scope.Contract.Code[1:2] = [0x0A]Step 2️⃣ — 转为 uint256.Inta := new(uint256.Int).SetBytes(scope.Contract.Code[start:end])将 start:end 范围内的字节转换为一个 256-bit 整数(高位补 0)。Step 3️⃣ — 补齐不足的位数(重要)if missing := pushByteSize - (end - start); missing > 0 { a.Lsh(a, uint(8*missing)) // 左移补0 }为什么要这么处理?因为如果字节码写错、数据不足,比如字节码是 0x62 0xFF(本来是 PUSH2 0xFF??,但只写了一个字节),那读取结果是:pushByteSize = 2实际读取到的只有 1 字节所以我们就需要左移 8 位(1 字节 = 8 bits)补 0,相当于将 0xFF 变成 0xFF00这是 符合 EVM 规范的行为。Step 4️⃣ — 压入栈scope.Stack.push(a)Step 5️⃣ — 更新程序计数器*pc += sizesize = 1 + pushByteSize,跳过当前 opcode 及其立即数。DUP 指令族:DUP1 ~ DUP16作用:将栈顶向下第 N 个元素复制一份,压到栈顶。例如:stack: [0x01 0x02 0x03] // 栈顶是 0x03 执行 DUP2 后 stack: [0x01 0x02 0x03 0x02] // 把第 2 个 0x02 复制到栈顶源码实现:func makeDup(size int64) executionFunc { return func(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { scope.Stack.dup(int(size)) return nil, nil } }SWAP 指令族:SWAP1 ~ SWAP16作用:将栈顶元素与第 N 个元素交换。例如:stack: [0x01 0x02 0x03] // 栈顶是 0x03 执行 SWAP2 后 stack: [0x03 0x02 0x01]源码实现:// core\vm\instructions.go func opSwap1(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { scope.Stack.swap1() return nil, nil } // core\vm\stack.go func (st *Stack) swap1() { st.data[st.len()-2], st.data[st.len()-1] = st.data[st.len()-1], st.data[st.len()-2] }Gas 消耗指令Gas CostPOP2PUSHn3DUPn3SWAPn33.算术(Arithmetic)指令EVM 中的 ADD、MUL、SUB、DIV 是最基本的 4 个算术操作指令,它们都以 栈操作 的方式工作 —— 取出操作数、计算结果、再压回栈顶。EVM 中除了最基本的 ADD、MUL、SUB、DIV 运算指令外,还支持各种运算操作,包括 模运算、位运算、比较运算、溢出安全运算、签名数运算 等等源码的实现中做了些优化, 少一次 push 少一次 popx := scope.Stack.pop()弹出栈顶(操作数1)y := scope.Stack.peek()获取栈顶的下一个元素(操作数2),但不弹出y.Add(&x, y)用 x + y 的结果就地更新 y 所在的栈位置✅ 也就是说,它相当于 y = x + y,结果直接写回原来的 y 的位置。func opAdd(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.Add(&x, y) return nil, nil } func opSub(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.Sub(&x, y) return nil, nil } func opMul(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.Mul(&x, y) return nil, nil } func opDiv(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.Div(&x, y) ⛽ Gas 消耗对比(EIP-150 后常规执行成本):指令操作码GasADD0x013MUL0x025SUB0x033DIV0x0454.比较(Comparison)指令比较的指令实现也比较简单 从栈中弹出两个操作数 x 和 y,比较它们的大小,并将结果(1 或 0)写回栈顶。然后做了优化操作数y只取出值然后将比较的结果覆盖yfunc opLt(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() if x.Lt(y) { y.SetOne() } else { y.Clear() } return nil, nil } func opGt(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() if x.Gt(y) { y.SetOne() } else { y.Clear() } return nil, nil } func opSlt(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() if x.Slt(y) { y.SetOne() } else { y.Clear() } return nil, nil } func opSgt(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() if x.Sgt(y) { y.SetOne() } else { y.Clear() } return nil, nil } func opEq(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() if x.Eq(y) { y.SetOne() } else { y.Clear() } return nil, nil } func opIszero(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x := scope.Stack.peek() if x.IsZero() { x.SetOne() } else { x.Clear() } return nil, nil }🔍 比较运算指令概览指令名操作码描述操作数结果Gas 消耗LT0x10无符号小于比较a, ba < b3GT0x11无符号大于比较a, ba > b3SLT0x12有符号小于比较a, ba < b3SGT0x13有符号大于比较a, ba > b3EQ0x14等于比较a, ba == b3ISZERO0x15判断是否为零aa == 035.位运算(Bitwise)指令🔧 8 个位级运算指令及其含义指令操作码含义AND0x16位与OR0x17位或XOR0x18位异或NOT0x19位取反BYTE0x1A提取字节SHL0x1B左移(无符号)SHR0x1C右移(无符号)SAR0x1D右移(有符号)📁对应源码实现(go-ethereum)以 go-ethereum 为例,所有这些位运算函数都定义在:core/vm/instructions.gofunc opAnd(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.And(&x, y) return nil, nil } func opOr(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.Or(&x, y) return nil, nil } func opXor(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y := scope.Stack.pop(), scope.Stack.peek() y.Xor(&x, y) return nil, nil } func opByte(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { th, val := scope.Stack.pop(), scope.Stack.peek() val.Byte(&th) return nil, nil } func opAddmod(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y, z := scope.Stack.pop(), scope.Stack.pop(), scope.Stack.peek() z.AddMod(&x, &y, z) return nil, nil } func opMulmod(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { x, y, z := scope.Stack.pop(), scope.Stack.pop(), scope.Stack.peek() z.MulMod(&x, &y, z) return nil, nil } // opSHL implements Shift Left // The SHL instruction (shift left) pops 2 values from the stack, first arg1 and then arg2, // and pushes on the stack arg2 shifted to the left by arg1 number of bits. func opSHL(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { // Note, second operand is left in the stack; accumulate result into it, and no need to push it afterwards shift, value := scope.Stack.pop(), scope.Stack.peek() if shift.LtUint64(256) { value.Lsh(value, uint(shift.Uint64())) } else { value.Clear() } return nil, nil } // opSHR implements Logical Shift Right // The SHR instruction (logical shift right) pops 2 values from the stack, first arg1 and then arg2, // and pushes on the stack arg2 shifted to the right by arg1 number of bits with zero fill. func opSHR(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { // Note, second operand is left in the stack; accumulate result into it, and no need to push it afterwards shift, value := scope.Stack.pop(), scope.Stack.peek() if shift.LtUint64(256) { value.Rsh(value, uint(shift.Uint64())) } else { value.Clear() } return nil, nil } // opSAR implements Arithmetic Shift Right // The SAR instruction (arithmetic shift right) pops 2 values from the stack, first arg1 and then arg2, // and pushes on the stack arg2 shifted to the right by arg1 number of bits with sign extension. func opSAR(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { shift, value := scope.Stack.pop(), scope.Stack.peek() if shift.GtUint64(256) { if value.Sign() >= 0 { value.Clear() } else { // Max negative shift: all bits set value.SetAllOne() } return nil, nil } n := uint(shift.Uint64()) value.SRsh(value, n) return nil, nil } ⛽ 三、Gas 消耗(来自 gas_table.go)大部分位运算的 Gas 是 固定的低成本操作(3 gas),属于非常轻量的计算。指令Gas 成本说明AND3params.VeryLowGasOR3 XOR3 NOT3实际在 EIP-145 前不支持BYTE3提取某个字节SHL3EIP-145: 位移操作SHR3 SAR3 6.内存(Memory)指令在 EVM 中,内存操作指令主要有以下 4 个:指令含义操作码MSTORE写入 32 字节0x52MSTORE8写入 1 字节0x53MLOAD读取 32 字节0x51MSIZE查询当前内存大小0x59🔧 源码实现(来自 Geth)➡️core\vm\memory.gomemory对象实现的方式type Memory struct { store []byte lastGasCost uint64 }EVM 的 Memory 是一个临时的、线性地址空间的内存,用于执行合约时的数据操作(比如 MSTORE, MLOAD 等)。它是一个自动扩容的 []byte 数组:store []byte:存储所有内存数据。lastGasCost:记录上一次内存扩容所花费的 gas(用于避免重复计算 gas)。♻️ 内存池管理(内存复用)var memoryPool = sync.Pool{ New: func() any { return &Memory{} }, }为什么用 sync.Pool?内存对象会被频繁创建和销毁(每个合约执行都需要一块 Memory),直接频繁分配和回收会增加 GC 压力。用 sync.Pool 可以避免重复申请,提高性能。func NewMemory() *Memory { return memoryPool.Get().(*Memory) } go复制编辑func (m *Memory) Free() { const maxBufferSize = 16 << 10 // 最大 16KB if cap(m.store) <= maxBufferSize { m.store = m.store[:0] m.lastGasCost = 0 memoryPool.Put(m) } }只回收「不大」的内存(防止泄漏大对象)。重置后放回池中。🧱 内存写入:Set, Set32Set(offset, size, value)func (m *Memory) Set(offset, size uint64, value []byte)写入从 offset 开始、size 大小的数据。要求必须在写入前先调用 Resize 否则 panic。用于 MSTORE, MSTORE8 等指令。Set32(offset, val)func (m *Memory) Set32(offset uint64, val *uint256.Int)写入一个 32 字节整型值(如 uint256)。使用 val.PutUint256(m.store[offset:]) 写入 32 字节。用于 MSTORE 指令。📐 内存读取:GetCopy, GetPtrGetCopy(offset, size) []byte返回内存中 [offset, offset+size) 的 拷贝副本。cpy := make([]byte, size) copy(cpy, m.store[offset:offset+size])安全:防止外部修改原始内存。GetPtr(offset, size) []byte返回原始 store 的切片指针(没有拷贝,性能高)。return m.store[offset : offset+size]🪜Resize:内存扩容func (m *Memory) Resize(size uint64)如果现有 store 长度不足,就扩容。实际通过 append(make([]byte, size-delta)) 实现。Go 的 append 虽然可以触发扩容,但这是不可控的自动行为。EVM 需要 开发者主动指定目标大小每次 Memory 扩容都需要支付额外 gas,以防止攻击者用 memory 拖垮执行资源。memory 中未写入的部分默认值是 0。⚠️ 所有读写操作前都 必须手动调用 Resize,否则会 panic!🧪 Copyfunc (m *Memory) Copy(dst, src, len uint64)将内存从 src 拷贝 len 字节到 dst。可能会发生重叠(支持 memmove)。⚠️ 不会自动扩容,调用前需确保 Resize 过。📊 辅助方法func (m *Memory) Len() int func (m *Memory) Data() []byteLen():当前内存长度(不是容量)。Data():返回整个 store。⚙️使用示例(例如 MSTORE)EVM 执行 MSTORE:// 假设 offset = 0x40,val = 0xdeadbeef... memory.Resize(offset + 32) // 先扩容 memory.Set32(offset, val) // 写入32字节⛽ Gas 计费:由内存增长触发EVM 的内存使用不是免费:每 32 字节为单位扩容时需要支付 gas,如下(在调用 Resize 的地方):memoryGasCost = newMemorySizeWords^2 / 512 - oldSize^2 / 512这也是为什么 lastGasCost 字段存在——为了记住上一次内存扩容的 gas 开销,避免重复计算。➡️ core/vm/instructions.go✅ 1. MSTORE — 写入 32 字节(256bit)func opMstore(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { offset, val := scope.Stack.pop(), scope.Stack.pop() interpreter.memory.Set32(offset.Uint64(), &val) return nil, nil }说明:从栈中取出 offset 和 val,将 val 写入到 memory[offset : offset+32]函数内部调用:memory.Set32(off, val) 是内部封装的 32 字节写入函数✅ 2. MSTORE8 — 写入 1 字节(最低位)func opMstore8(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { off, val := scope.Stack.pop(), scope.Stack.pop() scope.Memory.store[off.Uint64()] = byte(val.Uint64()) return nil, nil }说明:将 val 的最低有效字节(byte 31)写入到内存 offset 处,适合 byte 操作Byte(31) 取的是最后一个字节(高位在前,符合 big endian)✅ 3. MLOAD — 从内存读取 32 字节func opMload(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { v := scope.Stack.peek() offset := v.Uint64() v.SetBytes(scope.Memory.GetPtr(offset, 32)) return nil, nil }说明:从 memory[offset : offset+32] 读取 32 字节,并 push 到栈中✅ 4. MSIZE — 当前内存大小(以字节为单位)func opMsize(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { scope.Stack.push(new(uint256.Int).SetUint64(uint64(len(interpreter.memory)))) return nil, nil }说明:直接把当前 memory 长度 push 到栈顶,单位是 字节⛽ Gas 消耗(来自 gas_table.go)🧮 Gas 成本相关规则:指令基础 Gas 消耗说明MLOAD3VeryLow(不包括内存扩展成本)MSTORE3同上MSTORE83同上MSIZE2BaseGas⏫ 内存扩展依照扩展字节数,增加额外 Gas,见 memoryGasCost 逻辑 示例片段:VeryLowGas = 3 BaseGas = 2 ... {Op: MLOAD, Gas: VeryLowGas, Exec: opMload}, {Op: MSTORE, Gas: VeryLowGas, Exec: opMstore}, {Op: MSTORE8, Gas: VeryLowGas, Exec: opMstore8}, {Op: MSIZE, Gas: BaseGas, Exec: opMsize},此外,EVM 内存是动态扩展的,每次写入可能导致内存增长,增长的部分会按比例增加 gas 消耗(在 memoryGasCost.go 中实现)。📘 总结对比指令栈操作内存操作Gas 消耗MSTOREpop(offset), pop(value)memory[offset : offset+32] ← value3 + mem扩展MSTORE8pop(offset), pop(value)memory[offset] ← value[31]3 + mem扩展MLOADpop(offset) → push(value)value ← memory[offset : offset+32]3 + mem扩展MSIZEpush(memory size)无直接操作27.存储(Storage)指令在 EVM 中,存储(Storage) 是合约的持久化数据存储区域,数据在交易结束后仍然保留在链上。两条指令用于操作存储:SLOAD: 加载一个存储槽的数据SSTORE: 将一个值写入存储槽🔧 源码级别的实现原理📥 SLOAD 实现从合约状态中读取指定位置的数据:func opSload(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { loc := scope.Stack.peek() // 读取存储槽索引 hash := common.Hash(loc.Bytes32()) // 转为 32 字节 hash 作为 slot key val := interpreter.evm.StateDB.GetState(scope.Contract.Address(), hash) loc.SetBytes(val.Bytes()) // 设置读取值到栈顶(栈中原来的 slot 被改为值) return nil, nil }📌 实现要点:栈顶元素是要读取的 slot 索引(比如 slot 0x00, 0x01...)合约地址 + slot 构成唯一的 key,访问 StateDB修改原始 slot 元素值为读取结果(避免 pop 再 push)📤 SSTORE 实现将一个值写入合约状态:func opSstore(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { addr := scope.Contract.Address() loc := scope.Stack.pop() // slot val := scope.Stack.pop() // value key := common.Hash(loc.Bytes32()) // 设置 storage 新值(实际存储延迟提交) interpreter.evm.StateDB.SetState(addr, key, val.Bytes32()) return nil, nil }📌 实现要点:两个参数:slot 和 value,顺序是 value 在上,slot 在下StateDB.SetState 会记录这个 slot 的修改(可以用于追踪变更)实际写入状态树会在交易完成后统一提交💰Gas 消耗逻辑(重点)✅ 1. SLOAD gas 消耗固定费用:100 gas不考虑新旧值关系,直接从状态树加载 slot 值GasSload = 100✅ 2. SSTORE gas 消耗(复杂)根据旧值 / 新值 / 初始值不同,收费策略不同。规则详解(London 升级后 EIP-3529):情况Gas 消耗说明把非 0 改为 05000,并 返还 4800 gas"清除" 存储,EVM 喜欢把 0 改为非 020000增加状态大小,最贵把非 0 改为非 0(值变了)5000一般修改操作把值改为原始值(回滚)100无实际变更(无效写)原始值指合约开始执行时 slot 的值(用于判断是否真正变更)💡 状态修改追踪: StateDBEVM 中通过 StateDB 记录:originalValue:交易开始时的状态dirtyValue:当前修改但未提交的值这让 SSTORE 能判断:if new == old { // 无修改 } else if original == old { // 真正变更,走 expensive path } else { // 被多次修改,不重复计费 }在 EVM(以太坊虚拟机)中,StateDB 是管理合约账户、合约代码和存储(Storage)的核心组件,承担着读写全局状态的职责。我们来从源码和功能层面详细拆解 StateDB 的实现:在以太坊中,每个账户有:账户余额(balance)随机数(nonce)合约代码(如果是合约账户)存储(键值对:key => value)StateDB 提供了统一的接口来读取和写入这些数据,并在交易结束时统一提交或丢弃状态变化。StateDB 的关键接口和结构体StateDB 接口定义go复制编辑type StateDB interface { GetBalance(addr common.Address) *big.Int GetNonce(addr common.Address) uint64 GetCode(addr common.Address) []byte GetCodeSize(addr common.Address) int GetCodeHash(addr common.Address) common.Hash GetState(addr common.Address, key common.Hash) common.Hash SetState(addr common.Address, key, value common.Hash) AddBalance(addr common.Address, amount *big.Int) SubBalance(addr common.Address, amount *big.Int) SetNonce(addr common.Address, nonce uint64) SetCode(addr common.Address, code []byte) Suicide(addr common.Address) bool HasSuicided(addr common.Address) bool Snapshot() int RevertToSnapshot(int) Commit(deleteEmptyObjects bool) error }这表示它支持账户基本信息的读写、合约状态管理、回滚和提交等。实现结构体 stateObject每个账户在 StateDB 中以 stateObject 的形式存在:type stateObject struct { address common.Address balance *big.Int nonce uint64 code []byte storage Trie dirtyStorage map[common.Hash]common.Hash ... }内部管理一份账户的状态,包括 storage Trie(Merkle Patricia Trie 结构),并记录脏数据用于延迟写入。Storage 的底层结构:Merkle Patricia Trie(MPT)每个合约的 storage 是一个 MPT。它的特点:可验证(Merkle 结构)支持路径压缩(Patricia)Key 为 32 字节哈希,Value 为 32 字节哈希更新存储时,EVM 会记录 dirty 数据,等交易提交时统一将这些数据写入 MPT 中,并生成新的 stateRoot。🚀 总结对比指令类型功能Gas 消耗是否持久SLOAD读读取 slot 值100 gas否SSTORE写设置 slot 值100 ~ 20,000 gas(看旧值)是(写入区块链)
2025年05月21日
8 阅读
0 评论
1 点赞