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)

值堆栈 (SP)

堆栈溢出

当递归调用压入太多帧时,CFP 向下增长,直到与向上增长的 SP 发生冲突。VM 通过 CHECK_VM_STACK_OVERFLOW0 检测到这一点,该宏计算 const rb_control_frame_struct *bound = (void *)&sp[margin]; 并如果 cfp <= &bound[1] 则抛出异常。

理解单个帧的值堆栈

每个帧都有其在整体 VM 堆栈中的一部分,称为其“VM 值堆栈”或简称为“值堆栈”。当创建帧时,此空间会被预先分配,其大小由以下决定:

帧的值堆栈从其基地址(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)

首先,在参数被评估并且在调用 sendfoo 之前

┌────────────┐
  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,那么 getlocalinsns.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 对象,但被调用者内部的 selfProc#receiver,而 yield,其中接收者在参数被压入堆栈之前并没有被推入堆栈。

问:为什么要使用 cfp->ep,而似乎所有内容都在 cfp->sp 的下方?

答:在示例中,cfp->ep 指向堆栈,但它也可以指向 GC 堆。块可以捕获其环境并将其疏散到堆中。