EVM 对象格式(EOF)引入了结构化,更改了控制流逻辑,增加了部署时的约束,并更新了一些关键指令,以优化交易执行、改进编译器基础设施和静态分析。
EVM 对象格式(EOF)引入了结构化,增加了部署时的约束,并更新了一些关键指令,以优化交易执行、改进编译器基础设施和静态分析。
虽然对 EVM 的大多数更改对于使用高级语言的工程师来说不会直接可见,但部署成本、运行时成本和字节码大小的改进将立即可见,而工具和外围改进将随之而来。
EVM 合约字节码是非结构化和未经验证的,也就是说,字节码被盲目地视为有效合约,逐条解释指令,没有任何执行保证。这导致了显著的运行时开销,例如跳转验证,其中 jump
或 jumpi
指令的目标索引必须是 0x5B
(jumpdest
),并且它不能是另一条指令的立即字节(immediate byte
),例如 push1 0x5B
或堆栈验证,其中任何推送或弹出堆栈值的指令都需要检查堆栈不会溢出或下溢。
EOF 智能合约在部署时一次性验证,以确保 EOF 容器结构合规性、跳转目标有效性、所有代码路径上的堆栈深度有效性,并在头部包含一个版本,以便在版本之间启用不兼容的更改。一个这样的结构示例如下。
├── header
├── code_section_0
├── code_section_1
├── data_section_0
├── data_section_1
└── initcontainer_0
├──header
├── code_section_0
└── deploy_container_0
├── header
├── code_section_0
└── data_section_0
EVM 智能合约包含两个用于控制流管理的指令和一个用于有效性检查的指令。jump
和 jumpi
指令通过将程序计数器更新到字节码中的另一个索引来“跳转”,前者无条件地执行,而后者则在其操作数(或输入)之一为非零时执行。有一个指令 jumpdest
,它本身不执行任何操作,而是仅作为 jump
或 jumpi
正在将程序计数器更新到“有效”索引的检查。这个检查仅存在于 jump
和 jumpi
是“动态绝对”跳转的情况下,也就是说,覆盖程序计数器的索引是堆栈上的一个值,而不是嵌入在字节码中的“静态”值。现代高级智能合约编译器通常不会生成跳转索引真正动态的代码,这源于用户输入或环境变量,但 EVM 的语义并未明确禁止这一点,因此 EVM 解释器必须每次都检查每个跳转。以下是这种跳转行为的示例。
// // stack:
push0 // [0] |
calldataload // [cd_word0] |
jumpdest // [cd_word0] +<--+
push1 0x01 // [1, cd_word0] | |
swap1 // [cd_word0, 1] | |
sub // [new_cd_word0] | |
dup1 // [new_cd_word0, new_cd_word0] | |
push1 0x02 // [2, new_cd_word0, new_cd_word0] | |
jumpi // [new_cd_word0] +---+
stop // [new_cd_word0] X
上述代码从 calldata 中读取第一个字,然后递减直到为零。如果这个数字是 25,那么 EVM 解释器会检查:
markdown
| instruction | condition | times |
| -------------- | -------------------------- | ----- |
| `push0` | overflow? | 1 |
| `calldataload` | underflow? | 1 |
| `push1 0x01` | overflow? | 25 |
| `swap1` | underflow? | 25 |
| `sub` | underflow? | 25 |
| `dup1` | underflow? overflow? | 25 |
| `push1 0x02` | overflow? | 25 |
| `jumpi` | underflow? valid jumpdest? | 25 |
EOF 智能合约包含三个用于简单控制流管理的指令和三个用于函数控制流管理的指令。rjump
和 rjumpi
指令的“跳转”方式类似于之前的 jump
和 jumpi
指令,但跳转索引是“静态相对”值,也就是说,索引必须作为常量嵌入在紧随指令之后的字节码中,并且它不会直接覆盖程序计数器,而是添加到程序计数器中。正整数增加程序计数器,而负整数减少程序计数器。然而,关键区别在于,由于跳转索引在部署时已知,因此所有代码路径在部署时都是可知的。每个代码路径都可以检查堆栈有效性,如果有一个无效,字节码在部署期间被拒绝。这消除了在运行时检查跳转和堆栈有效性的需要。一个与上述 EVM 示例相当的示例如下:
请注意,这里为了简洁省略了 EOF 合约所需的头部,我们仅演示简单控制流指令的功能。
// // stack:
push0 // [0] |
calldataload // [cd_word0] |
push1 0x01 // [1, cd_word0] +<--+
swap1 // [cd_word0, 1] | |
sub // [new_cd_word0] | |
dup1 // [new_cd_word0, new_cd_word0] | |
rjumpi -0x05 // [new_cd_word0] +---+
stop // [new_cd_word0] X
上述代码与前一个示例相同,但注意省略了 jumpdest
。rjumpi -0x05
指令将程序计数器减少五个,并在条件值为非零时继续在 push1 0x01
处执行。然而,一个显著的优势是,所有检查都在部署时进行,因此在运行时不进行检查,EOF 解释器信任部署逻辑以保证有效的堆栈状态和跳转目标。
此外,EOF rjumpv
指令启用原生跳转表,其中跳转条件可以是多个值之一。在 EVM 中执行功能上相当的操作需要多个 jumpi
指令或构建更复杂的逻辑来构建内存或字内跳转表。请考虑以下 Yul 示例。
switch calldataload(0)
case 1 {
stop()
}
case 2 {
return(0, 32)
}
default {
revert(0, 32)
}
上述代码创建了三个可能的情况,取决于 calldata 的第一个字,第一个没有返回数据地停止,第二个返回内存的第一个字,第三个用内存的第一个字恢复。
以下是上述代码的 EVM 表示。
// // stack:
push0 // [0] |
calldataload // [cd_word0] |
dup1 // [cd_word0, cd_word0] |
push1 0x01 // [one, cd_word0, cd_word0] |
eq // [is_one, cd_word0] |
push1 0x15 // [dest_one, is_one, cd_word0] |
jumpi // [cd_word0] +---+
dup1 // [cd_word0, cd_word0] | |
push1 0x02 // [two, cd_word0, cd_word0] | |
eq // [is_two, cd_word0] | |
push1 0x17 // [dest_two, is_two, cd_word0] | |
jumpi // [cd_word0] +-------+
pop // [0] | | |
push1 0x20 // [revert_len] | | |
push0 // [revert_ptr, revert_len] | | |
revert // [] X | |
// | |
jumpdest // [cd_word0] +<--+ |
stop // [cd_word0] X |
// |
jumpdest // [cd_word0] +<------+
pop // [] |
push1 0x20 // [return_len] |
push0 // [return_ptr, return_len] |
return // [] X
EOF 表示如下。
// // stack:
push0 // [0] |
calldataload // [cd_word0] |
rjumpv 0x02000506 // [] +---+---+
push1 0x20 // [revert_len] | | |
push0 // [revert_ptr, revert_len] | | |
revert // [] X | |
// | |
stop // [] X---+ |
// |
push1 0x20 // [return_len] +-------+
push0 // [return_ptr, return_len] |
return // [] X
EOF 表示还可以省略运行时检查,因为 rjumpv
情况必须在部署时可知。
最后,EOF 包含函数指令,callf
调用一个函数,*retf
跳回到其相应 callf
执行的函数,jumpf
* 跳转到一个已知在所有情况下都会停止执行的函数。这使得小的、可重用的代码块可以在本地验证和包含。
// code seciton 0: // stack:
push1 0x01 // [1]
push1 0x02 // [2, 1]
callf 0x01 // [sum]
push0 // [0, sun]
mstore // []
push1 0x20 // [return_len]
push0 // [return_ptr, return_len]
return // []
// code section 1: // [a, b]
add // [sum]
retf // [sum]
除了 pushn
和 pop
指令外,EVM 智能合约有两个仅更新栈的指令,swap\*
和 dup\*
。swap 指令范围从 swap1
到 swap16
,它将栈的第一个元素与指令指定的值交换;所以 swap1
将第一个元素与第一个后续元素交换,swap2
将第一个元素与第二个后续元素交换,等等。duplicate 指令范围从 dup1
到 dup16
,它在栈上复制其相应的值。虽然 EVM 的最大栈深度为 1024 个元素,但 swap\*
和 dup\*
指令一次最多只能达到 16 个元素,因此 Solidity 编译器中出现了臭名昭著的“栈太深”错误。Vyper 通过将所有局部变量存储在内存中来缓解这个问题,从而简化字节码,但代价是灵活性和内存扩展。
EOF 智能合约仍然使用 swap\*
和 dup\*
指令,但也引入了 swapn
、dupn
和 exchange
指令。swapn
和 dupn
指令包含一个立即字节,使交换或复制深度达到 255,这是单字节或八位值的最大值。这适用于更多情况,任何需要更多的情况可以依赖其他现代技术来缓解栈溢出。此外,exchange
取两个值(打包成一个字节),这使得更复杂的栈调度算法成为可能。这提高了字节码在大小和运行时成本优化方面的整体效率。
每个指令的简单演示如下。
// // stack:
push0 // [0]
push1 0x01 // [1, 0]
push1 0x02 // [2, 1, 0]
dup1 // [2, 2, 1, 0]
swap2 // [1, 2, 2, 0]
swapn 0x03 // [0, 2, 2, 1]
dupn 0x04 // [1, 0, 2, 2, 1]
exchange 0x12 // [1, 2, 0, 2, 1]
stop
虽然这些更改是面向编译器的,但高级语言用户将看到的好处是减少了“栈太深”错误、更便宜的智能合约执行和更小的字节码。
EVM 合约包括将本地和外部代码加载到内存中的指令,分别是 codecopy
和 extcodecopy
。这些指令没有边界约束或检查。此外,没有指令可以直接将单个字直接加载到栈上;这需要先复制到内存,然后从内存加载。
EOF 合约移除了 codesize
、codecopy
、extcodesize
和 extcodecopy
,但增加了 dataload
、dataloadn
、datasize
和 datacopy
。通过容器化数据部分并限制对新指令的访问,我们消除了所有数据部分越界访问的未定义行为,并且在 dataloadn
的情况下,我们在部署时检查以确保其后的立即字节是一个在界内的读取。我们还通过 dataload
和 dataloadn
指令获得了直接的数据到栈的读取。
EVM 合约包括两个创建新合约的指令,*create*
和 *create2*
,后者包括一个“salt”参数,它影响部署地址。EVM 创建指令将新合约部署为内存中的一系列未结构化字节。如上所述,在部署时,这些字节不会被验证,并假设它们是有效的,直到某个指令发出信号表明否则。
EOF 合约用 *eofcreate*
和 *returncontract*
以及一个称为“initcontainer”的结构化子容器替换了这些指令。initcontainer 本身是一个 EOF 容器,可以包含任意有限的子容器,并在创建时递归验证。在创建时,initcontainer 的第一个代码部分被执行,它必须要么停止,要么使用 *returncontract*
,这会将 initcontainer 内的子容器部署为合约的运行时字节码。
以下示例是一个分为三个部分的 EOF 工厂合约。注意,为简洁起见,省略了头部,大小(0x0c, 0x07)不包括省略的头部,它们的实际值将包括头部大小。
// Factory code section: 0
push1 0x0c // [initcontainer_len]
push0 // [initcontainer_ptr, initcontainer_len]
push0 // [salt, initcontainer_ptr, initcontainer_len]
push0 // [value, salt, initcontainer_ptr, initcontainer_len]
eofcreate 0x00 // [deployed_address]
// Factory initcontainer: 0
// initcontainer code section: 0
push1 0x07 // [subcontainer_len]
push0 // [subcontainer_ptr, subcontainer_len]
returncontract 0x01 // []
// initcontainer code section: 1
calldatasize // [cd_len]
push0 // [cd_ptr, cd_len]
push0 // [mem_ptr, cd_ptr, cd_len]
calldatacopy // []
calldatasize // [mem_len]
push0 // [mem_ptr, mem_len]
return // []
第一部分是工厂逻辑,处理 initcontainer 的部署。第二部分是第一个 initcontainer 代码部分,它是构造函数的入口点。第三部分是已部署合约的运行时代码。从功能上讲,这不应改变 Solidity 或 Vyper 的高级语法,尽管 Yul 可能会受到某种影响。
EVM 智能合约静态分析,包括反编译、自动漏洞检测、符号执行和 gas 分析,历史上要么针对高级语言的抽象语法树(冗长、明确且经过验证的源代码形式),要么针对未结构化的字节码。后者非常困难,因为泛化意味着不能对编译器和版本做出假设,因此必须处理诸如代码混淆工具注入误导性编译器元数据或动态跳转目标直接从 calldata 派生等边缘情况。
EOF 智能合约静态分析,相反,可以确定性地将每个代码路径映射到控制流图中,这是分析程序行为的重要步骤。漏洞检测工具可以跟踪每个代码路径并寻找不变性破坏或不受信任的调用委托。
窥孔优化,直接解析字节码以在算术和控制流中找到优化,可能会利用更复杂的技术,因为所有代码块之间的所有关系都是显而易见的。字节码的反编译,无论是为了应对漏洞还是通过所有软件的自由和开源化来拆解专有软件工业体系,都变得更加可行,因为反编译器开发人员可以将更多资源集中在高层语法的重构上,而不是控制流图和符号执行的构建上。
EVM 对象格式为 EVM 提供了显著的改进,从而改进了静态分析和编译器基础设施。虽然 EOF 的 EIP 集合中还有其他实现细节,但大多数通常是建立在或支持堆栈和控制流管理升级的基础上;合约代码和数据容器化组织代码和数据以实现高效访问和验证,内省抽象将常见的指令组合固定下来,以简化字节码并减少对 EVM 执行细节的依赖,这些细节在未来可能会发生变化,头版本控制简化了指令的弃用和引入,合约创建的变化使合约工厂的任意深度嵌套成为可能,从而强制执行 EOF 系统的不变量。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!