
1. 为什么以太坊需要一个专门的虚拟机我第一次接触EVM的时候满脑子都是疑问合约代码到底跑在哪里矿工打包交易时合约是怎么被执行的为什么合约一旦部署就不能修改这些问题绕来绕去最后都会指向同一个答案——EVM以太坊虚拟机Ethereum Virtual Machine。很多人把它类比成Java虚拟机JVM这个类比有帮助但也有误导。JVM运行的是Java字节码跑在本地电脑的Java运行时环境里EVM运行的是以太坊字节码Ethereum Bytecode跑在每一个以太坊节点的本地环境中——也就是说它不是一台虚拟机而是无数台虚拟机的集合。每当你部署一个合约或者调用一个合约函数网络中每个全节点都会在自己的EVM里重新执行一遍同样的代码最终通过共识机制确认同一份结果。这种设计听起来极度浪费算力但正是它的核心价值所在EVM通过让所有节点执行相同的计算达成对世界状态的统一认知。更深一层看EVM是一个状态转换函数。时刻t的区块链世界状态哪些账户有多少余额、哪些合约存了什么数据记作σ_t当一笔交易T被打包进区块EVM执行这笔交易后世界状态变成σ_{t1}。用公式表达就是σ_{t1} Υ(σ_t, T)其中Υ大写希腊字母Upsilon就是EVM本身。这个输入旧状态 交易输出新状态的模型和比特币那种UTXO花费模型有本质区别。比特币的账本是钱花了没花的集合以太坊的账本是整个世界是什么样的状态机。如果你刚开始学EVM我建议先把虚拟机这个词放一放换成状态转换规则来理解。EVM不是一个漂浮在云端的抽象机器它就是一套明文定义的、几乎所有节点都会遵守的计算规则。谁不遵守这套规则产出的区块就会被其他节点拒绝。这一篇是学习笔记的第一部分我会从状态机器的视角逐步拆解EVM的账户体系、存储结构、Gas机制、执行环境和字节码ABI。内容不会太深但足够把骨架搭起来后续再往里填肉。2. EVM的世界状态账户不是钱包是状态对象2.1 两类账户的本质区别EVM管理的是账户Account而不是地址。以太坊有两类账户所有状态都挂在这两类账户上外部账户EOAExternally Owned Account由私钥控制可以发起交易。转账、调用合约都是EOA在驱动。合约账户Contract Account由合约代码控制不能主动发起交易只能被调用后执行内部逻辑。这个区别是很多新手第一个搞混的地方。我那时候一直以为合约账户也能主动干活后来才想明白合约账户是被动的。它就像一个装在盒子里的程序盒子外面的人EOA往里面投硬币交易程序才开始运转运转的结果会改变盒子的状态但它自己永远不会主动跳出来做任何事。2.2 账户的四个组成部分每个账户在EVM的世界状态里都包含四个字段字段说明nonce对于EOA是已发出的交易数量对于合约账户是合约被创建的数量常见为0或1balance账户持有的Wei余额1 ETH 10^18 WeistorageRoot合约存储数据的Merkle Patricia Trie根哈希EOA为空codeHash合约代码的哈希EOA为空字符串的哈希我第一次看这个结构时最不理解的是nonce。后来在测试环境里反复签名交易才体会到nonce是防重放攻击的关键。每笔交易带上nonce节点按nonce顺序执行同一nonce的交易只能被确认一次。没有nonce机制的话别人截获你签过名的交易原样再广播一次你账户里的钱就被转走两次了。storageRoot和codeHash这两个字段只有合约账户才有意义。codeHash是合约字节码的哈希一旦部署成功代码内容不可更改storageRoot指向合约的持久化存储里面存的是Solidity里的状态变量。这个存储布局比较复杂我会在第5部分单独展开。2.3 世界状态不是存在EVM里的是存在节点数据库里的这里还有一个重要的认知点EVM本身不保存状态它只是读取和修改状态。状态数据存在每个节点本地的KV数据库比如LevelDB或RocksDB里。EVM执行完交易后会把存储变更写入数据库同时更新Merkle Patricia Trie的根哈希。新的状态根哈希被写进区块头其他节点验证区块时会用自己的EVM重复执行交易检查算出来的状态根哈希是否与区块头里的一致。一致说明这笔交易执行正确不一致说明有人在状态上造假或者执行过程有差异。这种重复执行 根哈希校验的机制保证了不管你在全球哪个角落运行节点EVM算出来的结果都是一模一样的。这就是确定性Determinism同样的输入任何节点执行必然得到同样的输出。3. 树结构下的账户存储为什么EVM用Merkle Patricia Trie3.1 不是存一个JSON而是一棵加密树如果你以前做传统开发脑海里对状态的想象可能是数据库里一行记录字段是balance、nonce、code更新了就直接UPDATE。以太坊不能这么做原因是区块链需要多方对状态达成共识——如果每个节点用不同的方式组织存储数据那状态根哈希就对不上。EVM的世界状态被组织成一个巨大的Merkle Patricia TrieMPT。这棵树的叶子节点是账户地址的哈希映射每个叶子节点里编码了nonce、balance、storageRoot、codeHash这四个字段。树的根节点哈希就是当前世界状态的指纹一旦任何账户的任何字段发生变化根哈希就会变。这个设计解决了两个关键问题轻节点验证轻节点不下载全量状态只保存区块头里的状态根。要验证某一笔交易的结果只需要向全节点请求一条Merkle证明路径就能验证某个账户的余额是不是真的存在于当前状态中。状态一致性检测共识节点执行完交易后必须得出和区块打包者完全相同的新状态根。稍微差一个字节整条链的验证就不通过。3.2 storageRoot 是状态里的状态合约账户的storageRoot指向另一棵独立的MPT这棵树存储的就是合约的持久化存储。Solidity里的状态变量storage变量最终会映射到这里。这里有一个容易踩坑的点EVM的存储槽是256位宽的。简单理解合约存储是一个长度极大的数组每个槽slot可以存32字节256位的数据。Solidity编译器负责把状态变量映射到具体的槽位。比如contract StorageExample { uint256 public number; // 通常占用 slot 0 address public owner; // 通常占用 slot 1 mapping(address uint256) public balances; // 占用 slot 2数据存在另一个存储区域 }静态变量按照声明顺序占用从0开始的槽位但mapping和动态数组的布局就复杂得多。它们会通过keccak256哈希计算具体槽位。很多合约漏洞就是因为开发者对存储布局理解不透导致代理合约和逻辑合约的存储槽位冲突典型的如可升级合约中的变量覆盖问题。我在做合约安全审计时第一步经常就是先摊开合约的存储布局看看有没有槽位冲突的隐患。这个在后面写EVM存储细节时可以单独开一篇这里先记住一句话EVM的存储是一棵哈希树不是简单的键值数据库所有修改最终都要反映到状态根上。4. Gas是这个虚拟机存在的基石也是入门墙4.1 为什么要为计算付费我见过太多人学EVM时被Gas机制劝退。但如果没有GasEVM根本无法在去中心化环境下安全运行。想象一下如果没有Gas任何人部署一个合约里面写了个死循环while(true){}然后全网所有节点都去执行这个合约结果就是所有节点永远卡死整个链瘫痪。这称为停机问题Halting Problem——在通用计算模型里无法提前判断一个程序是否会永远运行下去。Gas机制给出的解法很直观让每一操作都明码标价执行前先付钱钱用完就强制终止。每条EVM指令都有对应的Gas消耗比如ADD加法是3 GasSSTORE写存储是20000 Gas首次写入冷存储是22100。程序执行过程中每一步都从预先支付的Gas中扣费。一旦Gas耗尽整个交易的状态变更全部回滚但矿工收取的手续费不会退还。4.2 Gas Limit、Gas Price与交易费我第一次发起交易时对gasLimit和gasPrice这两个参数极其困惑。两者的关系可以用一个很生活化的类比解释Gas Limit就像你给加油站说最多加500块钱的油。超过这个额度加油站就停止加油你之前加的油能不能归你还是另一回事在EVM里已经烧掉的Gas不会退。Gas Price是每单位Gas多少钱单位是gwei1 gwei 10^9 wei。交易费 实际使用的Gas×Gas Price。你提交交易时Gas Limit设得高Gas Price给得高矿工就越愿意优先打包你的交易。Gas Limit设低了交易执行到一半Gas用完状态回滚但手续费照样扣——我现在每次调用复杂合约前都会先用eth_estimateRPC预估一下Gas再留出20%的余量不然真容易踩坑。4.3 EIP-1559之后基础费与小费2021年伦敦升级EIP-1559改变了费用计算方式现在的交易费结构是总费用 实际Gas × (基础费 baseFee 优先费 priorityFee)基础费由网络根据区块的Gas使用量动态调整。区块使用率超过50%时基础费上涨低于50%基础费下降。基础费会被直接销毁Burn。优先费相当于你给矿工的小费用来提高交易被打包的优先级。从EVM的角度看EIP-1559改变的是交易费的分配方式但Gas的计量逻辑没有变。指令消耗的Gas数值依然写死在EVM规范里节点执行时逐条扣费。4.4 为什么大多数开发者需要关注Gas你可能觉得自己只是在测试网写个简单的合约Gas优化离自己很远。但实测下来以下几点对合约开发很有实际影响存储是最贵的操作。SSTORE首次写入冷存储消耗22100 Gas读取是2100 Gas热存储访问是100 Gas。这比任何算术运算都贵一到两个数量级。所以能用内存计算就不要存到链上。合约大小受EIP-170限制部署的合约字节码不能超过24576字节24KB。超过这个限制合约无法部署。所以大量重复代码要用库合约、继承、修饰器来精简而不是直接堆代码。ERC-20转账大概消耗5万Gas左右。以太坊每区块Gas上限约3000万意味着一个区块最多打包几百笔简单代币转账。这就是拥堵的根本原因。Gas机制是EVM里最反直觉但又最合理的部分。用一次就会明白它的存在不是为了收税而是为了让所有资源计算、存储、带宽都有价格价格会驱动参与者优化自己的行为。5. EVM执行环境代码不只是代码是机器码5.1 从Solidity到字节码你写的Solidity代码不会直接在EVM上运行。编译器solc会把它编译成EVM字节码Bytecode这是由一串十六进制编码的指令组成的序列0x608060405234801561001057600080fd5b5061013f8061002060003960...上面这串东西就是一小段合约的creation code创建代码。平时在链上浏览器看到的是runtime code运行时代码两者略有不同创建代码里包含构造函数逻辑和运行时代码本体部署时先执行创建代码执行后回存的codeHash对应的是运行时代码。5.2 操作码EVM的最小计算单元EVM字节码的每个操作码Opcode占1字节所以最多有256个操作码。实际定义了约140多个常用的大概几十个。每一个操作码对应一个确定性的操作格式如下PUSH1 0x60 // 把0x60压入栈 PUSH1 0x40 // 把0x40压入栈 MSTORE // 把栈顶两个值取出写入内存这串字节码是Solidity编译器的标准开头0x608060405234...意思是把0x60写入内存0x40位置——这是Solidity定义的空闲内存指针的初始值标记可用内存的起始位置。对于刚学EVM的人直接读字节码是很劝退的。我的建议是用evm.codes这个网站它有可视化操作码查询和调试器点击每条指令可以看到栈、内存、存储的实时变化。我学操作码时几乎天天泡在这个站上比干啃黄皮书Ethereum Yellow Paper效率高十倍。5.3 三种数据区域栈、内存、存储EVM执行环境里包含三种数据存储区域很多人把它们混为一谈实际区别非常大区域特点代价栈Stack后进先出最多1024层每个元素32字节存放操作数免费指令消耗很少Gas内存Memory线性字节数组可随机读写每次访问32字节按字节扩容收费扩展成本线性/指数增长存储Storage持久化的键值存储256位键到256位值极贵SSTORE 20000 Gas栈是EVM的工作台。大多数指令ADD、SUB、MUL、LT、EQ等都是在栈上取操作数再把结果压回栈顶。Solidity编译器会生成一堆PUSH/DUP/SWAP来管理栈上的临时数据。当栈深度超过1024时会直接抛异常。老版本Solidity有Stack Too Deep错误本质就是函数内的局部变量太多栈放不下了——新版编译器的处理方式是引入内存变量代价是Gas增加。内存是合约执行的草稿纸临时存储计算结果。函数参数如果是引用类型数组、字符串、结构体默认存在内存里。内存访问比存储便宜得多所以计算密集型逻辑尽量在内存里完成最后再把结果一次性写入存储。存储是合约的数据库数据持久化在状态树里。它是最昂贵的资源也是合约状态的核心。我在实际写合约时养成了习惯能用calldata就别用memory能省一次拷贝循环内部绝不写存储批量更新时做完所有计算再一次性写入存储频繁读取的存储变量先在函数开头缓存到内存结束前写回5.4 调用上下文与call data除了这三种数据区域每次调用还有一个重要的输入Calldata。它包含了调用参数是只读的。EVM通过CALLDATALOAD指令按偏移量读取通过CALLDATACOPY拷贝到内存。Solidity里msg.data就是整个calldatamsg.sender是调用者地址msg.value是随交易发送的以太币数量。理解calldata的结构对于手写汇编和优化Gas非常重要。6. 一条DeFi借贷交易的完整流转6.1 从EOA签名到EVM执行前面部分比较抽象这一部分我结合实际场景走一遍一笔DeFi借贷交易在EVM里发生了什么。假设Alice想通过Aave借出一笔USDC她的操作路径是Alice的EOA构造一笔交易调用Aave协议LendingPool合约的withdraw函数参数是资产地址、数量和接收地址。交易被签名后广播到网络中矿工开始执行。执行开始时EVM加载当前世界状态Alice的账户nonce、balanceLendingPool合约的存储状态全部从状态树中读取。交易进入LendingPool合约的calldataEVM解析函数选择器前四个字节的哈希跳转到对应函数入口。这个过程有一个关键点EOA到合约的调用是直接调用。合约内部如果还需要调用其他合约比如USDC合约、WETH合约会触发内部消息调用Message Call。内部调用的执行环境是独立的——有自己的Gas限制、自己的栈和内存但共享世界的存储状态。6.2 嵌套调用中的Gas传递内部调用最经典的问题是Gas传递。Solidity里有两种调用方式// 方式一底层call可以传递所有剩余Gas (bool success, bytes memory data) address(target).call{value: 0}(payload); // 方式二Solidity高级调用只传递当前交易Gas的63/64 target.foo();EIP-150规定任何内部调用最多只能获取父调用剩余Gas的63/64。这种设计是为了防止恶意合约通过嵌套调用无限套娃耗尽区块Gas。这意味着子调用结束后即使子调用因为Gas不足失败父调用还有至少1/64的Gas来处理失败逻辑。为什么说这是DeFi最危险的地方因为很多合约的漏洞逻辑是子调用失败不检查返回值然后继续执行后续操作。我在审计经历里见过不下十次这种情况——借贷协议没有检查内部调用返回值清算人利用这个漏洞把账户余额归零了。6.3 事件日志合约的打印语句交易执行过程中合约可以通过LOG0到LOG4指令发出事件Event。事件会记录交易日志Logs存储在区块的收据Receipt里。日志不影响状态转换只作为链上的痕迹存在。前端DApp能实时显示转账通知靠的就是监听这些日志。你可以把Event理解成合约的打印语句但它比打印更强的一点是索引主题Topic可以高效检索。比如Transfer事件的第一个主题就是交易双方的地址开发者可以按地址快速查到某账户的历史转账记录。这一点常被忽略但它其实非常重要事件不是可有可无的装饰它对链上数据索引和用户交互是不可或缺的基础设施。7. 读合约状态 vs 写合约状态为什么有的操作不花Gas7.1 eth_call与交易执行的本质区别初学EVM时有个我花了很久才真正搞明白的问题为什么调用合约的函数有的要Gas费有的完全免费如果你调用的函数被标记为view或pureSolidity编译出来的字节码中对应的调用路径不会包含任何SSTORE、LOG、CALL这类写操作指令只会包含SLOAD读存储、算术、内存读写等只读指令。这样的函数可以通过eth_call在本地模拟执行不需要广播交易不改变状态所以不花Gas。这不是开发者出于好心让你免费查询而是以太坊客户端支持在本地构建一个模拟EVM环境跑一次完整的合约执行然后把结果返回给你但不写入状态树。7.2 为什么view函数有时还是花Gas这里有个很反直觉的坑如果你在一个非view函数内部调用了另一个合约的view函数view函数仍会被编译到执行路径中此时它也会消耗Gas。因为只要交易最终会广播上链链上会真实执行这段代码每一步都要付Gas。另一个坑是查看函数里如果包含block.timestamp、block.number这类上下文相关数据在不同区块高度调用可能得到不同的结果。我在开发一个链上期权协议时就遇到过前端调view函数拿到的报价和实际链上执行得到的报价不一致的情况。原因就是block.timestamp在两个时点不同。后来我养成了习惯前端显示报价时顺便展示一下依赖的区块高度。7.3 状态可变性的分类Solidity为函数提供了四种状态可变性修饰符直接影响编译出的字节码修饰符可写状态可读状态可发事件执行成本pure否否否最省Gasview否是否次之nonpayable是是是正常payable是是是需处理msg.value编译器做了静态分析如果你在view函数里写storage变量编译会直接报错在pure里读storage也会报错。这些编译期约束是EVM安全模型的重要组成部分它让合约的只读保证不只是编码规范而是几乎由链本身强制执行。8. 初识EVM最常见的几个认知误区与实战避坑8.1 误区一合约代码是存在EVM里的不是。合约代码runtime code作为状态数据的一部分存储在节点的状态数据库里由codeHash字段索引。EVM只是根据codeHash找出代码逐条执行。代码本身不是EVM的一部分EVM只是执行指令的规则。实际影响是合约一旦部署任何人都无法修改它的runtime code。如果你要用代理模式升级合约本质上是换一个合约地址来指向新的实现合约而不是修改原合约的代码。8.2 误区二Gas是某个特定账户支付的Gas从触发交易的EOA扣但Gas的实际消耗发生在执行过程中。如果是内部调用子调用的Gas也是从父调用的Gas余额里扣除的。而且内部调用里即使出现了异常已消耗的Gas也不会退还。8.3 误区三栈、内存能随便用EVM的栈上限1024层而且合约执行中栈上的一条数据在任意函数调用完成后不会自动消失——编译器需要显式清理。内存扩展成本在高位区间呈平方级增长用得多单价越高。真实场景中我写过一个解析长字符串的合约内存索引越界导致Gas暴增最后整笔交易失败。8.4 实战避坑清单我从自己的学习过程里总结了几个可以直接用的实操建议学习EVM不一定非要读黄皮书。推荐顺序是先读完这篇文章建立的框架然后用evm.codes逐条执行一段简单合约字节码最后高频使用Remix的Debugger观察栈、内存、存储变化。在一个测试网上亲手走一遍合约交互流程。用Hardhat写一个测试发一笔交易在控制台里看transactionReceipt.gasUsed对比一下你预估的Gas感受一下Gas的实际消耗量级。用hardhat-tracer或者forge test -vvvv观察每一次CALL的调用深度和Gas传递。这在调试DeFi类合约时几乎必备。多读优秀合约编译出来的字节码接口不要试图逐条读字节码但要学会用solc --asm查看汇编级别的调用逻辑这样对Gas消耗和存储布局的感知会非常清晰。8.5 学习路径建议整个EVM的学习路径我目前是这样规划的阶段目标实战方式第一阶段本篇建立状态机认知框架理解账户、状态、Gas、执行环境第二阶段读懂字节码和汇编用evm.codes逐条执行第三阶段理解存储布局用solc、Foundry测试存储槽第四阶段手写Yul/内联汇编优化Gas、实现高级功能这篇文章是第一阶段的起点。等到下一篇我会深入到EVM的存储布局——把Solidity变量、mapping、动态数组如何映射到存储槽讲透同时附上实操示例。EVM的学习曲线确实陡但它值得花时间理解EVM的程度直接决定了你写出的合约是能跑还是能安全高效地跑。前者是很多初学者的目标后者才是专业开发者的分水岭。