第一章:Solidity与EVM底层原理

深入理解EVM架构

EVM,说白了就是一个全球共享的、状态机式的虚拟机。我刚开始接触时觉得它很神秘,后来发现它其实没那么复杂——就是一条链上的“世界计算机”。

EVM的核心架构包含几个关键部分:

  • 栈(Stack):深度1024,每个元素32字节。操作都在栈上完成,像叠盘子一样。
  • 内存(Memory):临时存储,每次调用合约时清空。按字节寻址,可扩展。
  • 存储(Storage):永久存储,写入成本极高。每个合约有独立的存储空间,按256位槽位组织。
  • 调用数据(Calldata):只读,存放交易输入数据。
  • 程序计数器(PC):指向当前执行的指令。

关键点:EVM是单线程的,一次只处理一个交易。但通过区块打包,实现了“伪并行”。

我记得有一次调试一个复杂的DeFi合约,发现Gas消耗异常高。排查了半天,原来是存储布局没优化好。嗯,这里要重点讲一下存储布局。

存储布局:Slot与打包

EVM的存储是按slot(槽位)组织的,每个slot 32字节。Solidity编译器会按声明顺序分配slot,但有个优化规则:

  • 如果多个连续变量能塞进32字节,编译器会打包到一个slot里
  • 打包规则:按类型大小从大到小排列,尽量填满
  • 映射和动态数组会占用一个slot,但实际数据在keccak256计算的位置
// 示例:存储布局
contract StorageLayout {
    uint256 a;      // slot 0
    uint128 b;      // slot 1 (低16字节)
    uint128 c;      // slot 1 (高16字节) — 和b打包在一起
    address d;      // slot 2
    uint8 e;        // slot 2 (和d打包)
    mapping(uint => uint) f;  // slot 3 (占位)
    // f的实际数据在 keccak256(abi.encode(3, key)) 位置
}

我的经验:我在项目中遇到过Gas费爆炸的情况,就是因为存储变量声明顺序不当,导致本可以打包的变量分散到了多个slot。建议声明变量时,把相同大小的类型放一起,能省不少Gas。

ABI编码与解码

ABI(Application Binary Interface)是合约与外部世界通信的协议。说白了,就是定义了你该怎么把参数塞进交易数据里,以及怎么从返回数据里把结果读出来。

ABI编码规则其实不复杂:

  • 静态类型(uint, address, bool等):直接编码,不足32字节左补0
  • 动态类型(string, bytes, 数组):先编码偏移量,再编码实际数据
  • 函数选择器:取函数签名的keccak256前4字节
// 假设函数:transfer(address to, uint256 amount)
// 编码后的calldata:
// 0xa9059cbb (函数选择器)
// + 000000000000000000000000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (to地址,左补0)
// + 0000000000000000000000000000000000000000000000000000000000000064 (amount=100)

你想想看,如果参数顺序搞错了,解码出来的数据就全乱了。我曾经帮一个团队排查过这个问题——他们调用了合约但一直失败,最后发现是ABI编码时参数顺序和合约定义不一致。

避坑指南:我曾经在写批量转账合约时,忘记处理动态数组的偏移量计算,结果所有接收地址都错了。记住:动态类型的偏移量是从calldata起始位置算起的,不是从函数选择器之后算的。

合约部署与调用的底层机制

合约部署,本质上就是向零地址发送一个特殊交易。交易数据包含合约的初始化代码(init code)和构造函数参数。EVM会执行init code,返回合约的运行时字节码(runtime bytecode),然后存储到链上。

合约调用分两种:

  • 外部调用(CALL):从一个账户到另一个合约,会创建新的执行上下文
  • 内部调用(JUMP/JUMPI):合约内部跳转,不创建新上下文
  • 委托调用(DELEGATECALL):使用调用者的存储,执行被调用者的代码
  • 静态调用(STATICCALL):不允许修改状态,用于视图函数
// 底层调用示例(用Yul表示)
assembly {
    // 外部调用
    let success := call(
        gas(),          // 剩余Gas
        target,         // 目标地址
        0,              // 转账金额(wei)
        input,          // 输入数据起始位置
        inputSize,      // 输入数据长度
        output,         // 输出数据存储位置
        outputSize      // 输出数据长度
    )
    
    // 委托调用
    let success2 := delegatecall(
        gas(),
        target,
        input,
        inputSize,
        output,
        outputSize
    )
}

重要区别:CALL和DELEGATECALL的最大区别在于存储上下文。CALL使用被调用者的存储,DELEGATECALL使用调用者的存储。这就是为什么代理合约模式(Proxy Pattern)能实现可升级合约——代理合约用DELEGATECALL把逻辑委托给实现合约,但状态保存在代理合约里。

我记得有一次审计一个代理合约,发现实现合约里有个selfdestruct操作。你想想看,如果通过DELEGATECALL调用这个函数,销毁的是代理合约的存储,而不是实现合约的。这就是个严重的漏洞。

Gas机制与优化

Gas是EVM的燃料,每个操作都有固定的Gas成本。我整理了一个常用操作的Gas消耗表:

操作 Gas消耗 说明
SLOAD 2100(首次)/ 100(重复) 读取存储槽
SSTORE 20000(新值)/ 5000(修改)/ 29000(清零) 写入存储槽
MSTORE 3 写入内存
MLOAD 3 读取内存
CALL 700 + 转账费用 外部调用
LOG0 375 + 每字节8 写日志

优化技巧:我建议在写合约时,尽量把频繁读取的存储变量缓存到内存中。比如在循环里反复读取同一个存储变量,每次SLOAD都要2100 Gas,缓存到内存后每次MLOAD只要3 Gas,差距巨大。

嗯,说到Gas优化,还有一个容易被忽略的点:事件(Event)的Gas消耗。LOG操作虽然比SSTORE便宜,但如果日志数据量太大,Gas也会飙升。我见过有人把整个数组都emit出来,结果Gas费高得离谱。

总结

EVM底层原理是Solidity开发的基石。理解存储布局能帮你写出更省Gas的合约,掌握ABI编码能让你在调试时快速定位问题,熟悉调用机制能避免安全漏洞。说白了,这些知识平时可能用不上,但一旦遇到问题,它们就是你最有力的武器。

下一章我们会深入合约安全,讲讲重入攻击、整数溢出这些经典漏洞。到时候我会分享一些我在审计中遇到的真实案例,保证让你印象深刻。