本文介绍了 Vyper 编译器如何建模和维护EVM内存,解释了Vyper函数的内存布局,变量如何分配和释放,以及调用约定如何与内存分配交织。它可以帮助开发者理解如何构建合约以节省gas,以及如何防止与DynArrays
分配相关的某些DoS场景。同时,对于研究Vyper编译器的人来说,这是一份有用的资料,文中包含了许多对Vyper代码库的引用。
作者:cyberthirst
本文介绍 Vyper 编译器如何建模和维护内存(如 EVM 内存位置)。它解释了 Vyper 函数的内存布局、变量的分配和释放如何进行,以及调用约定如何与内存分配交织在一起。
它可以帮助开发者了解如何构建他们的合约以节省 gas,以及如何防止与 DynArrays
分配相关的某些 DoS 场景。此外,对于任何有兴趣研究 Vyper 编译器的人来说,它都是有用的材料 - 本文包含许多对 Vyper 代码库的引用。
编译器维护一个上下文,用于变量管理、内存分配、作用域处理或常量跟踪等目的。它使用对内存分配器的引用进行初始化。分配器分配/释放内存,检查边界,并强制对齐。上下文除其他外,还具有用于创建/删除变量的 API,并且在底层,这些操作与分配和释放交互。
当前版本(v0.4.1)默认情况下不执行复杂的内存分析算法(尽管 --experimental-codegen
提供了越来越多的优化),因此它不会在变量不再活动时立即释放内存(但会执行基于作用域的释放),也不会执行例如别名分析。
在我们分析具体的分配策略和场景之前,让我们看看分配在 EVM 中实际上是什么样的。
Vyper 内存分配器抽象地对 EVM 的内存进行建模。它为每个变量分配一定的内存范围(请注意,与 Solidity 相比,它默认使用内存而不是堆栈)。这意味着该变量表示为指向其起始地址的指针,然后分配器保证下一个变量将在 free_mem_ptr
(与 Solidity 中的概念不同)处分配。因此,在 EVM 级别,变量表示为数字。
考虑这个简单的例子:
if True:
a: uint256 = 1
分配器为变量 a
分配一个地址,例如 200。对于此地址,编译器将生成 mstore 200 1
来完成赋值。要加载此变量,我们将执行 mload 200
。EVM 没有分配的概念 - 它会自动根据我们访问的指针扩展内存。请注意,在 EVM 中,不存在释放的概念,EVM 内存无法缩小。
当编译器需要创建一个新变量时,它通过 Context 类来实现。分配函数检查变量的类型需要多少 bytes
,并将通过内存分配器 类保留内存范围。
例如,要分配 array
,编译器将保留 352
字节(32 用于 len + 10*32):
def foo():
array: DynArray[uint256, 10] = [1, 2]
不同类型的变量具有不同的作用域(见下文)。在变量的作用域完成后,将释放该变量。释放意味着内存分配器将停止保留变量的内存范围,并且该内存范围可以重用。
变量是在内存中分配的 - 这也是 Vyper 不会遭受堆栈溢出错误的原因。我们获取变量的类型并检索所需的最大内存字节数,并分配此字节数。这仅发生在编译器中的抽象内存分配器中。为了实际在 EVM 中分配内存,我们必须实际访问相应的地址。因此,如果我们从未在运行时访问这些地址,则该变量仅在抽象上保持分配状态。
为每种类型分配最大内存字节数的一个令人惊讶的副作用是,对于 DynArrays[typ, size]
,我们将始终分配足够的字节来容纳“最坏情况”,即运行时大小与类型定义中的 size
匹配的情况。
以下段落讨论 Vyper 的作用域规则:
块作用域(if、else、for)局部变量在块作用域结束时释放。
if True:
a: uint256 = 0
b: uint256 = 1
c: uint256 = a + b
# <--- dealloc a,b, c
else:
pass
每个语句都有自己的作用域,用于内部变量管理。内部变量是编译器在底层生成的变量;它们是实现的细节。例如,slice
内置函数分配一个内部变量,切片的结果将存储在该变量中。
def foo(s: String[32]) -> String[5]:
# 0. 为 `s` 分配缓冲区
# 1. 为 slice(s, 4, 5) 的结果分配内部缓冲区
# 2. 将内部缓冲区分配给 `s`
# 3. 在编译 assign 语句后,释放内部缓冲区(但保持 s 不变)
s: uint256 = slice(s, 4, 5)
return s
可以看出,还有优化的空间 - 内部变量不是绝对必要的,并且分配是冗余的(在示例中,我们可以将 slice
的结果直接存储到 s
中)。消除内存是在 Venom 管道中要添加的进一步优化的一个领域。
它们不会;每个函数都是静态分配的,并且其函数帧在整个消息调用期间保持不变。
当编译一个函数(外部和内部)时,会创建一个新的上下文。该上下文也使用 MemoryAllocator 进行初始化。这有一个特殊的转折 - 内存分配器使用它开始分配的地址进行初始化(例如,它可能从地址 500 开始)。以下几个段落将回答以下问题:如何获取内存分配器初始化的地址?
函数堆栈帧是合约使用的数据结构(由编译器创建),用于在函数执行期间跟踪信息。在传统语言中,堆栈帧通常随着函数调用动态分配(编译器管理堆栈指针 SP)。在 Vyper 中,它们不是;它们是静态的 - 这是因为 Vyper 不允许递归。递归需要为函数调用动态创建堆栈帧,因为实际的调用图可能依赖于运行时值,即在编译时无法计算。
在 Vyper 的上下文中,堆栈帧只是一个静态内存范围(一个静态字节缓冲区),函数将其用于内存操作。
在执行分配和释放时,内存分配器会记录到目前为止分配的最高内存指针。因此,即使在释放后指针减小,我们仍然需要分配最大值以适应先前的分配。此最大值是函数堆栈帧的大小。
一个合约可以有多个函数 - 那么各个堆栈帧如何映射到内存?Vyper 中的函数调用不能形成循环。调用链总是从一个外部函数开始,该函数可以潜在地调用其他内部函数。
为了分配内存,我们获取调用树的叶子并为其分配内存。然后我们删除叶子并递归地进行分配......依此类推,直到我们命中初始的外部函数。因此,外部函数将使用最高的内存地址。
因此,为了回答关于如何初始化内存分配器的原始问题 - 我们获取被调用者帧的大小并计算 max
:
## 计算起始帧
callees = func_t.called_functions
## 我们从最大的被调用者帧开始我们的函数帧
max_callee_frame_size = 0
for c_func_t in callees:
frame_info = c_func_t._ir_info.frame_info
max_callee_frame_size = max(max_callee_frame_size, frame_info.frame_size)
帧大小在哪里设置?在我们完成编译一个函数(内部和外部)后,我们调用 tag_frame_info。该函数查询内存分配器以获取当前内存大小,并将其设置为帧大小。有点令人困惑的是,帧大小不是 memory_size-frame_start
,而是包括直到 frame_start
的地址。
首先,让我们讨论外部函数参数。它们最初位于 calldata
中。对于每个参数,我们计算 calldata
指针,指向它的起始位置(calldata
的不同部分对应于不同的参数)。如果该参数需要验证,我们会创建一个新的内部变量,并将该参数的验证版本复制到其中。否则,该变量表示为 calldata
指针。
哪些类型不需要验证?那些其编码与 Vyper 的内部编码匹配的类型 - 静态数组/元组,其元素不需要验证,(u)int256(该值不能太大/太小 - 32B 始终是此类型的有效值)和其他类型(请参阅 needs_clamp 例程以获取完整列表)。
对于内部函数,调用者分配返回缓冲区。调用者由 Call
表达式表示。返回缓冲区由一个内部变量表示(内部表示“实现”细节),该变量是在解析 Call 表达式 时分配的。然后,返回缓冲区的地址传递给被调用者,被调用者在执行 return
语句时填充它。
Vyper 的调用约定是按值传递,这意味着所有参数都会被复制。当为函数分配内存时,所有参数都会在函数帧的开头分配。因此,在调用期间,参数会复制到此预先分配的位置。复制的 IR 由 make_setter 编译器例程创建。
编译器的前端会为赋值发出一个副本。反过来,副本意味着必须有一个用于复制的目标缓冲区(反过来需要分配),并且目标缓冲区的大小始终由给定类型的最大可能大小参数化。优化器,尤其是 Venom,能够消除其中一些副本。
例如,以下合约将强制为 a
和 b
分配缓冲区(在编译器的前端):
def foo() -> DynArray[uint256, 1000]:
a: DynArray[uint256, 1000] = [1, 2, 3]
b: DynArray[uint256, 1000] = a
return b
优化器的工作是优化掉这些无用代码。
对于局部变量,会分配数组的完整潜在大小。对于以下数组 array
,将在内存中保留 32+1000*32
字节:
def bar():
array: DynArray[uint256, 1000] = []
如果数组来自 calldata
(即,变量是外部函数的参数),则它会立即复制到内存中,并且我们不再使用 calldata
指针,而是使用内部内存变量。这很方便,因为它使其独立于可变的 abi 编码,并允许我们使用静态指针。
那么 dynamic 代表什么?数组的长度是动态的(但受类型中声明的大小限制),但是动态大小的数组位于静态分配的内存缓冲区中(该缓冲区始终足够大以包含最大长度)。
我们展示了 Vyper 如何管理其函数帧 - 它们是如何构造和分配的。还详细讨论了局部变量分配 - 我们展示了变量如何表示以及如何定义它们的活跃度。我们还讨论了 DynArray
分配是如何发生的。
未涵盖的是如何在编译器的后期阶段进一步优化内存操作。我们专注于概述编译器的前端如何生成 IR,但是此 IR 得到了进一步的优化,并且稍后会删除许多效率低下的代码。
进一步涵盖的有趣主题是内存别名(更笼统地说是指针分析)、将变量从内存提升到堆栈或如何将内存操作融合在一起等概念。其中一些已经在 Venom 管道中实现,另一些则正在计划中。
- 原文链接: blog.vyperlang.org/posts...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!