Android 5.0的ART是如何为方法调用开辟栈空间的?

在Dalvik虚拟机中,在解释方法之前,会调用Stack.cpp中的dvmPushInterpFrame方法进行方法栈的开辟,然后把方法的变量和参数入栈,然后开始dvmInterpretPortable解释执行。那么在ART中是在什么时候进行方法栈的开辟呢。具体是在哪个方法中。ART在编译文件的时候是否只是给出栈空间一个数值,等到运行的时候才开辟呢?
关注者
74
被浏览
1591
既然题主指定了版本,那么参考的源码就是:lollipop-release版
本回答只针对Android 5.0讨论。后面的版本(Android 6.0以及最新的AOSP dev版里)情况已经有了一些变化,但是我不想写在这个回答里了。

首先要留意的是,Android 5.0 "Lollipop"版里的Android Runtime(ART)的执行引擎只有两种工作模式:

AOT编译器的情况

由Quick编译器以AOT编译模式生成的机器码,其实就跟C或C++编译出来的机器码一样,直接在native stack(或者叫“C stack”)上分配栈帧空间。
从Linaro做的一组演示稿引用一个例子:

演示稿:HKG15-300: Art's Quick Compiler: An unofficial overview(打不开请自备工具)
上图中,A64 Assembly的部分就是Quick编译器的AArch64后端对例子中的Java程序(Dex字节码)编译生成的机器码的汇编形式。可以看到,其中第一条指令
sub sp, sp, #48
就是在分配栈帧空间。
这个Java方法正好是个很小的叶子方法(leaf method)——不调用其它方法的方法,所以编译出来的机器码在方法入口处的处理比较简单,直接分配栈帧就好了。一般的Java方法的入口会有一系列更复杂的处理,详细可以参考上述演示稿的第7到第17页,写得非常详细。其中第12页演示register spills的部分就包含了分配栈帧空间的指令。

具体这些指令在Android 5.0的Quick编译器里是如何生成出来的,请参考代码中的GenEntrySequence()函数,它负责在Quick编译器中把代码形式从MIR转换为LIR时生成方法入口的处理逻辑:

解释器的情况

如上文所述,Android 5.0中的ART的解释器有两个版本,都是用C++实现的。两个版本间有共享部分代码。
这解释器在执行的时候,每当要新调用一个由解释器执行的方法,实际要分配两部分栈帧:
  • native stack:既然解释器自身是用C++实现的,而且每新调用解释器执行一个方法就会新调用一次C++的解释器入口函数,这里必然会涉及C++代码的栈帧的分配。这就跟一般C++函数调用一样,没啥特别的。
  • shadow stack:这是所谓的“解释器栈”。逻辑上说,解释器为每个线程维护了一个专门用于解释器的栈,其中每个栈帧的内容就是Dalvik VM(或者说Dex字节码)所关心的状态,包括Dex层面的虚拟寄存器数组、执行方法对象的指针、当前执行到的虚拟PC(Dex PC)、上一个栈帧的指针,等等。这个解释器栈叫做“shadow stack”,其中每个栈帧的结构叫做ShadowFrame

上面这两部分栈帧都可以在EnterInterpreterFromInvoke()这个解释器入口函数得到体现。看看其部分源码:
void EnterInterpreterFromInvoke(Thread* self, ArtMethod* method, Object* receiver,
                                uint32_t* args, JValue* result) {
  // ...

  // Set up shadow frame with matching number of reference slots to vregs.
  ShadowFrame* last_shadow_frame = self->GetManagedStack()->GetTopShadowFrame();
  void* memory = alloca(ShadowFrame::ComputeSize(num_regs));
  ShadowFrame* shadow_frame(ShadowFrame::Create(num_regs, last_shadow_frame, method, 0, memory));
  self->PushShadowFrame(shadow_frame);

  // ...
  self->PopShadowFrame();
}
首先,调用这个C++函数就很自然会在native stack上创建这个函数的native部分的栈帧;然后,这个函数调用alloca()函数来在native栈帧上额外分配出一块空间来存ShadowFrame栈帧。于是两者就联系起来了:一个Java方法被解释器执行时,其解释器栈帧(ShadowFrame)是被包含在其native栈帧里的。
当解释器完成执行一个Java方法后,其ShadowFrame的空间就随着解释器的native栈帧的空间一起被释放。

======================================

从题主问题揣摩一些情况:
在Dalvik虚拟机中,在解释方法之前,会调用Stack.cpp中的dvmPushInterpFrame方法进行方法栈的开辟,然后把方法的变量和参数入栈,然后开始dvmInterpretPortable解释执行。
这个问法说明,题主
  • 读的Dalvik VM源码是比较新的版本——已经从C变成“C风格的C++”了
  • 读的是Dalvik VM中的“可移植版”解释器:vm/mterp/portable/entry.cpp,而不是实际部署的Android中通常用的“汇编版”解释器mterp。
Dalvik也是在native stack之外有个独立的解释器栈,栈帧的单元是StackSaveArea+被调用方法的vreg数量。其结构可以参考vm/interp/Stack.h
Dalvik VM虽然原本计划在后续发展中把解释器栈融合到native stack中,形成所谓的“mixed stack”(HotSpot VM用的就是这样的mixed stack,解释器栈与native stack融合在同一个栈里),但还没做到那一步Dalvik VM就被ART给替代了。

题主阅读Dalvik VM的源码时要留意mterp与JIT在操纵栈时做法与portable解释器都不完全一样的喔。例如说ARMv5TE版的mterp在分配解释器栈帧时是这样做的:
/*
 * Given a frame pointer, find the stack save area.
 *
 * In C this is "((StackSaveArea*)(_fp) -1)".
 */
#define SAVEAREA_FROM_FP(_reg, _fpreg) \
    sub     _reg, _fpreg, #sizeofStackSaveArea

.LinvokeArgsDone: @ r0=methodToCall
    ldrh    r9, [r0, #offMethod_registersSize]  @ r9<- methodToCall->regsSize
    ldrh    r3, [r0, #offMethod_outsSize]  @ r3<- methodToCall->outsSize
    ldr     r2, [r0, #offMethod_insns]  @ r2<- method->insns
    ldr     rINST, [r0, #offMethod_clazz]  @ rINST<- method->clazz
    @ find space for the new stack frame, check for overflow
    SAVEAREA_FROM_FP(r1, rFP)           @ r1<- stack save area
    sub     r1, r1, r9, lsl #2          @ r1<- newFp (old savearea - regsSize)
    SAVEAREA_FROM_FP(r10, r1)           @ r10<- newSaveArea

另外dvmPushInterpFrame()只在从VM调用解释器时会经过;如果是一个解释执行的Java方法要调用另一个解释执行的Java方法,则不会经过该函数。