一、EVM 架构:一台跑在链上的全球计算机
以太坊虚拟机,说白了就是一个去中心化的状态机。我经常跟团队里新人说:你把它想象成一台单线程的全球计算机,每个节点都跑着同样的代码,得出同样的结果。
EVM 不是跑在物理硬件上的,它是个软件层。嗯,这里要注意:它跟 JVM 有点像,但更轻量。EVM 执行的是字节码,不是 Solidity 源码。你写的合约,先编译成字节码,再部署到链上。
1.1 核心组件
EVM 架构里,我个人最关注这几个部分:
- 栈(Stack):256 位宽,深度 1024。所有运算都在栈上完成。我见过不少漏洞,就是栈操作顺序搞反了。
- 内存(Memory):字节数组,按需扩展。每次扩展都要花钱,所以能省则省。
- 存储(Storage):256 位键值对,永久保存。这是最贵的操作,没有之一。
- 程序计数器(PC):指向当前执行的指令。
- Gas 计数器:实时追踪还剩多少 Gas。
关键点:栈是 LIFO(后进先出),内存是临时存储,存储是永久存储。这三者的 Gas 成本天差地别。
1.2 EVM 执行流程
我画了个流程图,帮你理解 EVM 怎么跑起来的:
你看,流程其实不复杂。但坑都在细节里。比如 Gas 不够时,所有状态变更都会回滚——但 Gas 不会退给你。我曾经有个项目,用户调用一个复杂函数,Gas 设少了,交易失败但 Gas 照扣,气得直骂娘。
二、存储模型:三块地盘,三种玩法
EVM 的存储模型,我习惯用「三块地盘」来理解:
| 存储区域 | 生命周期 | Gas 成本 | 访问方式 |
|---|---|---|---|
| Storage | 永久 | 极高(SSTORE: 20000 Gas) | 键值对,256 位 |
| Memory | 单次调用 | 中等(MSTORE: 3 Gas + 扩展费) | 字节数组,线性 |
| Stack | 指令级 | 免费(操作本身收费) | LIFO,深度 1024 |
2.1 Storage:最贵,但最持久
Storage 是合约的「硬盘」。每次写入都要花 20000 Gas,读取花 800 Gas。为什么这么贵?因为所有节点都要存这份数据。
避坑指南:我曾经审计过一个项目,开发者在循环里反复写 Storage,一次交易花了 200 万 Gas。嗯,这种写法基本等于烧钱。记住:能用 Memory 就别碰 Storage。
2.2 Memory:临时工作区
Memory 是合约的「内存」。每次调用函数时重新分配,用完就丢。它的 Gas 成本跟扩展大小有关:
// Memory 扩展成本公式
cost = 3 + (size / 32) * 3 + (size / 32)^2 / 512
// 实际例子:分配 256 字节
// 成本 = 3 + 8*3 + 64/512 = 27 Gas
你看,小数据用 Memory 很便宜。但如果你要处理 1MB 数据,那 Gas 就爆炸了。我建议:能用 Memory 解决的问题,别碰 Storage。
2.3 Stack:最底层的工作台
Stack 是 EVM 的「寄存器」。所有运算都在栈上完成。但栈深度只有 1024,而且不能随机访问——你只能操作栈顶的几个元素。
为什么会这样?因为 EVM 设计者想保持简单。但这也带来了麻烦:
- 栈溢出:递归调用太深,超过 1024 层
- 栈下溢:从空栈里取数据
- 栈顺序搞反:DUP 和 SWAP 用错位置
我的经验:写汇编时,我习惯先画个栈的草图。左边是栈底,右边是栈顶。每次操作前,确认一下栈里有什么。这习惯救了我好几次。
三、Gas 机制:为什么你的交易会失败?
Gas 是 EVM 的「燃料」。每执行一条指令,都要消耗 Gas。如果 Gas 不够,交易就回滚——但 Gas 不退。
我见过太多新手犯这个错了:调用合约时 Gas 设少了,交易失败,白白浪费手续费。所以Gas 估算很重要。
3.1 Gas 成本速查表
| 操作 | Gas 成本 | 说明 |
|---|---|---|
| ADD/SUB | 3 | 算术运算 |
| MUL/DIV | 5 | 乘除法更贵 |
| SLOAD | 800 | 读取 Storage |
| SSTORE | 20000 | 写入 Storage(新值) |
| SSTORE | 5000 | 写入 Storage(修改已有值) |
| BALANCE | 700 | 查询余额 |
| CALL | 700 | 调用其他合约 |
| CREATE | 32000 | 创建新合约 |
3.2 Gas 优化技巧
做审计这么多年,我总结了几条 Gas 优化的铁律:
- 能用 uint256 就别用 uint8:EVM 是 256 位机器,小类型反而要额外操作
- 变量打包:把多个小变量塞进一个 slot,省 Storage 空间
- 循环里别写 Storage:先写到 Memory,最后一次性写回 Storage
- 用 unchecked 块:确定不会溢出的地方,关掉溢出检查
// ❌ 错误写法:循环里写 Storage
for (uint i = 0; i < 100; i++) {
total += amounts[i]; // 每次都要 SLOAD + SSTORE
}
// ✅ 正确写法:先算好再写 Storage
uint temp;
for (uint i = 0; i < 100; i++) {
temp += amounts[i]; // 只在 Memory 里操作
}
total = temp; // 只写一次 Storage
核心原则:Gas 就是钱。每省下一笔 Gas,用户就少花一笔手续费。我见过有些项目,优化后 Gas 省了 60%,用户满意度直接翻倍。
四、ABI 编码规则:合约间的通信协议
ABI(Application Binary Interface)是合约之间、合约与外部之间通信的「语言」。说白了,就是怎么把函数调用编码成字节流。
4.1 函数选择器
每个函数都有一个 4 字节的「签名」:
// 函数选择器 = keccak256("函数名(参数类型)") 的前 4 字节
// 例如:transfer(address,uint256)
// keccak256("transfer(address,uint256)") = 0xa9059cbb...
// 前 4 字节:0xa9059cbb
调用合约时,前 4 字节就是函数选择器。EVM 根据这个找到要执行的函数。我曾经审计过一个项目,函数选择器碰撞导致调用错了函数——嗯,这概率极低,但确实发生过。
4.2 参数编码规则
参数编码遵循以下规则:
- 静态类型(uint, address, bool):直接编码,32 字节对齐
- 动态类型(string, bytes, 数组):先编码偏移量,再编码实际数据
- 嵌套类型:递归编码,先外层后内层
// 调用:foo(uint256 x, address a, uint256[] memory arr)
// 编码结果:
// 0x 函数选择器(4字节)
// x 的 32 字节
// a 的 32 字节(左补零)
// arr 的偏移量(指向数据区)
// 数据区:arr 长度 + arr 元素
调试技巧:我经常用 ethers.js 的 defaultAbiCoder 来手动编码/解码,验证 ABI 是否正确。遇到奇怪的调用失败,先检查 ABI 编码对不对。
4.3 常见 ABI 陷阱
做审计时,我遇到过这些 ABI 相关的问题:
- 类型不匹配:Solidity 里 uint256 和 uint8 的 ABI 编码不同,但 EVM 不检查类型
- 动态数组越界:手动构造的 ABI 数据,长度字段与实际数据不符
- 结构体编码:结构体在 ABI 里是元组,编码顺序要跟定义一致
避坑指南:我曾经审计过一个 DeFi 项目,攻击者通过构造恶意的 ABI 数据,让合约把地址解析成了 uint256,然后利用这个漏洞盗走了 50 万美元。所以永远不要信任外部传入的 ABI 数据,一定要做边界检查。
五、总结:EVM 原理的核心要点
好了,EVM 原理这部分,我帮你梳理了四个核心模块:
- 架构:栈 + 内存 + 存储,三层结构各司其职
- 存储模型:Storage 最贵,Memory 适中,Stack 免费但有限
- Gas 机制:每步操作都要花钱,优化 Gas 就是省钱
- ABI 编码:合约通信的「语言」,编码错误会导致严重漏洞
这些是 EVM 的基础,也是你写安全合约的必修课。我建议你动手写几个小合约,用 Remix 调试,看看每条指令消耗多少 Gas。实践出真知,光看文档是不够的。
记住:理解 EVM,你才能真正理解以太坊安全。很多漏洞,追根溯源都是对 EVM 理解不够深。
公众号:蓝海资料掘金营,微信deep3321