Ruby VM 堆栈和帧布局
本文档解释了 Ruby VM 堆栈架构,包括值堆栈 (SP) 和控制帧 (CFP) 如何共享一个连续的内存区域,以及单个帧是如何构建的。
VM 堆栈架构
Ruby VM 使用一个具有两个相互增长的区域的单个连续堆栈 (ec->vm_stack)。要理解这一点,需要区分整体架构(CFP 和值如何共享一个堆栈)和单个帧的内部结构(值如何为一个单独的帧组织)。
High addresses (ec->vm_stack + ec->vm_stack_size)
↓
[CFP region starts here] ← RUBY_VM_END_CONTROL_FRAME(ec)
[CFP - 1] New frame pushed here (grows downward)
[CFP - 2] Another frame
...
(Unused space - stack overflow when they meet)
... Value stack grows UP toward higher addresses
[SP + n] Values pushed here
[ec->cfp->sp] Current executing frame's stack pointer
↑
Low addresses (ec->vm_stack)
“未使用的空间”表示可用于新帧和值的可用空间。当这个间隙消失(CFP 遇到 SP)时,就会发生堆栈溢出。
堆栈增长方向
控制帧 (CFP)
-
从
ec->vm_stack + ec->vm_stack_size(高地址)开始 -
随着帧的压入,**向下**朝低地址增长
-
每个新帧都在
cfp - 1(低地址)处分配 -
rb_control_frame_t结构本身向下移动
值堆栈 (SP)
-
从
ec->vm_stack(低地址)开始 -
随着值的压入,**向上**朝高地址增长
-
每个帧的
cfp->sp指向其值堆栈的顶部
堆栈溢出
当递归调用压入太多帧时,CFP 向下增长,直到与向上增长的 SP 发生冲突。VM 通过 CHECK_VM_STACK_OVERFLOW0 检测到这一点,该宏计算 const rb_control_frame_struct *bound = (void *)&sp[margin]; 并如果 cfp <= &bound[1] 则抛出异常。
理解单个帧的值堆栈
每个帧都有其在整体 VM 堆栈中的一部分,称为其“VM 值堆栈”或简称为“值堆栈”。当创建帧时,此空间会被预先分配,其大小由以下决定:
-
local_size- 局部变量空间 -
stack_max- 执行期间临时值的最大深度
帧的值堆栈从其基地址(self/参数/局部变量所在的位置)向上增长,朝向 cfp->sp(临时值的当前顶部)。
可视化帧在 VM 堆栈中的布局
左侧显示了整体 VM 堆栈,其中 CFP 元数据与帧值分开。右侧放大了单个帧的值区域,揭示了其内部结构。
Overall VM Stack (ec->vm_stack): Zooming into Frame 2's value stack:
High addr (vm_stack + vm_stack_size) High addr (cfp->sp)
↓ ┌
[CFP 1 metadata] │ [Temporaries]
[CFP 2 metadata] ─────────┐ │ [Env: Flags/Block/CME] ← cfp->ep
[CFP 3 metadata] │ │ [Locals]
──────────────── │ ┌─┤ [Arguments]
(unused space) │ │ │ [self]
──────────────── │ │ └
[Frame 3 values] │ │ Low addr (frame base)
[Frame 2 values] <────────┴───────┘
[Frame 1 values]
↑
Low addr (vm_stack)
检查单个帧的值堆栈
现在,让我们通过一个具体的 Ruby 程序来了解单个帧的值堆栈是如何在内部组织的。
def foo(x, y) z = x.casecmp(y) end foo(:one, :two)
首先,在参数被评估并且在调用 send 到 foo 之前
┌────────────┐
putself │ :two │
putobject :one 0x2 ├────────────┤
putobject :two │ :one │
► send <:foo, argc:2> 0x1 ├────────────┤
leave │ self │
0x0 └────────────┘
put* 指令已将 3 个项目压入堆栈。现在是时候为 foo 添加一个新的控制帧了。以下是执行 foo 中的一条指令后堆栈的形状:
cfp->sp=0x8 at this point.
0x8 ┌────────────┐◄──Stack space for temporaries
│ :one │ live above the environment.
0x7 ├────────────┤
getlocal x@0 │ < flags > │ foo's rb_control_frame_t
► getlocal y@1 0x6 ├────────────┤◄──has cfp->ep=0x6
send <:casecmp, argc:1> │ <no block> │
dup 0x5 ├────────────┤ The flags, block, and CME triple
setlocal z@2 │ <CME: foo> │ (VM_ENV_DATA_SIZE) form an
leave 0x4 ├────────────┤ environment. They can be used to
│ z (nil) │ figure out what local variables
0x3 ├────────────┤ are below them.
│ :two │
0x2 ├────────────┤ Notice how the arguments, now
│ :one │ locals, never moved. This layout
0x1 ├────────────┤ allows for argument transfer
│ self │ without copying.
0x0 └────────────┘
考虑到局部变量的地址低于 cfp->ep,那么 getlocal 在 insns.def 中使用 val = *(vm_get_ep(GET_EP(), level) - idx); 是有道理的。当访问即时作用域中的变量时(此时 level=0),它基本上是 val = cfp->ep[-idx];。
请注意,此 EP 相对索引的基准与反汇编列表中的“@”后面的索引不同。“@”索引相对于第 0 个局部变量(本例中为 x)。
问答
问:似乎接收者相对于 EP 总是有一个偏移量,就像局部变量一样。我们不能使用 EP 来访问它,而不是使用 cfp->self 吗?
答:并非所有调用都将 self 放入被调用者堆栈中。例如,Proc#call,其中接收者是 Proc 对象,但被调用者内部的 self 是 Proc#receiver,而 yield,其中接收者在参数被压入堆栈之前并没有被推入堆栈。
问:为什么要使用 cfp->ep,而似乎所有内容都在 cfp->sp 的下方?
答:在示例中,cfp->ep 指向堆栈,但它也可以指向 GC 堆。块可以捕获其环境并将其疏散到堆中。