KVM的执行引擎--栈和帧 、指令集

来源:互联网 发布:淘宝优惠卷群怎么赚钱 编辑:程序博客网 时间:2024/05/17 23:53
   接下来的两篇将介绍在KVM中字节是如何执行的,这是KVM中比较核心的内容,分为两部分来讲,本篇先介绍虚拟机中的栈和帧是如何实现的。

    首先来看一些全局指针,在头文件kvm/vmcommon/h/interpret.h中定义有以下结构:

struct GlobalStateStruct { 
    BYTE
*         gs_ip; /* Instruction pointer (program counter) */
    cell
*         gs_sp; /* Execution stack pointer */
    cell
*         gs_lp; /* Local variable pointer */
    FRAME         gs_fp; 
/* Current frame pointer */
    CONSTANTPOOL  gs_cp; 
/* Constant pool pointer */
};

    这五个变量就像CPU中的寄存器一样,在KVM的运行过程中起到非常基础性的作用。它们分别是程序计数器、执行栈指针、局部变量指针、当前帧指针和当前常量池指针。

    Java虚拟机为每一个线程开设一个栈,栈中存储的数据以“帧”为单位,虚拟机在调用一个新的方法时,会向栈中压入一个新帧,帧内数据是这个方法的运行状态,Java字节码的执行总是在当前帧内进行,方法运行结束时这个帧会被弹出。所以这个栈可以称为“方法栈”,帧可以称为“方法帧”。

    按照Java虚拟机的规范,一个帧应由三个部分组成:局部变量区,操作数栈和帧数据区。每个帧的局部变量区和操作数栈的大小都可能不一样,要依方法本身的庞大程度而定,但在调用一个方法时,可以根据这个方法的字节码计算出所需要的局部变量区和操作数栈的大小。规范对帧数据区的大小没有规定,帧数据区的大小和内容可由虚拟机实现来决定。

    局部变量区:

    局部变量区一般会位于帧中最前面(即地址最小)的位置,它包含了对应方法的参数和局部变量,一般情况下,它的大小是向4字节对齐的,每4字节是一个“字”,变量以“字”为单位来存入。在它的最前面顺序存放的是对应方法的参数,类型为int、float、reference和returnAddress的参数占一个“字”,类型为byte、short和char类型的参数对被转化为int型,所以也占一个字;long和double类型的值要占用两个字。当然,“字长”选为多少是由虚拟机实现自己来决定的,不是一定要选4字节为一个字,如果选8字节构成一个字的话,所有值都只占一个字,更加整齐,但是浪费了很多空间。

    如果方法不是静态的,那么虚拟机会自动将方法所在对象的句柄存在局部变量区中索引为0的位置,真正的参数从位置1开始存;而如果方法是静态的,它就与具体的对象没有关系,所以不必存放对象句柄,参数从位置0开始存放。

    在局部变量区接下来的空间中,虚拟机可以按照任意的方式来存贮方法内的局部变量。

    操作数栈:

    操作数栈的作用相当于CPU中的通用寄存器,由于Java虚拟机是一台虚拟的机器,它没有真正的寄存器,而Java虚拟机也没有选择与CPU相似的方式来模拟通用寄存器,而是选择了另一种方法 — 使用栈,Java指令所使用的操作数都从操作数栈中得到。

    某方法在被调用的时候,同样可计算出它需要多大的操作数栈,所以在一个帧中,操作数栈的大小也是固定,而它的位置可以由实现来决定,不过在接下来KVM的实例中我们会发现,把操作数栈放在帧的最后面(地址最大)的地方是一个好办法。

    帧数据区:

    帧数据是由虚拟机实现任意设计的,通常它都被用来实现常量池解析和异常处理等等。

    下面来看一看,在KVM中如何实现栈和帧。

    数据结构:

    在头文件kvm/vmcommon/h/frame.h中定义了栈和帧的结构:

/* STACK */
struct stackStruct {
    STACK    next;
    
short    size;
    
short    xxunusedxx; /* must be multiple of 4 on all platforms */
    cell     cells[STACKCHUNKSIZE];
};
typedef 
struct stackStruct*         STACK;
      每一个stackStruct结构体的变量就是一个Java栈或Java栈的一部分,因为每一个stackStruct结构的大小是固定的,如果不够用,可以得用next指针来扩展成链表。size是本结构体的大小,xxunusedxx是剩余空间,cells则是实际的存贮空间。

    每一个线程开始的时候都会生成一个新的stackStruct,在每一次压入新帧的时候会查看剩余空间是否够用,如果不够用,还会再生成新的stackStruct.

    frameStruct这个结构的大小是固定的,它并不是一个帧,而只是“帧数据区”,前面说过,由于局部变量区和操作数栈的大小都不固定,所以整个帧的大小也是不固定的。帧的空间是在调用方法的时候临时计算出来的,然后在当前线程的栈中申请,frameStruct结构的指针占据其中的一个字,其余空间都给局部变量区和操作数栈用。

    KVM中栈和帧的模型如下:(为理解方便,暂不考虑栈要扩展的情况)

    当栈中只有一个帧时,栈的结构如图所示:

 

    当栈中只有一个帧时,帧的低字节区是局部变量,接下来会有一个字(4字节)指向帧数据区结构体,再接下来的空间就是操作数栈。

    只有一帧时,帧中各部分的结构很明晰,但如果多于一帧时,情况就会有些复杂,下面看当再压入一帧时的图示:

   这个图或许跟想像中的不一样,两帧数据之间出现了重叠。图中画出了一条虚线,这条虚线的位置是上一帧结束的位置,但是却没有成为新的一帧开始的位置,新的一帧在这之前就开始了。重叠的区域究竟是什么,可以让两帧共用呢?

    当一个方法在执行时,如果一个指令需要参数,解释器会到操作数栈里去装载参数,如果这时的指令是调用一个方法的话(比如invokevirtual或invokestatic)待调用方法的参数应已经顺序存在于操作数栈中,在执行调用指令的时候,这些参数被弹出,成为调用指令的参数,由于操作数栈在帧的最后面,所以这些参数后面再没有本帧的有效数据。这些参数在当前帧的操作数栈中的排列顺序与在新帧的局部变量区中的排列顺序是一样的,而且在新帧中,局部变量区在新帧的最前面,参数列表又在局部变量区的最前面,所以这部分数据是可以重用的,不会丢失有用的信息。

 

程序实现:

压入帧和弹出帧的函数在源文件kvm/vmcommon/src/frame.c中:

void pushFrame(METHOD thisMethod);

void popFrame();

pushFrame()函数的一些关键代码如下:

1    int thisFrameSize = thisMethod->frameSize;
2    int thisArgCount = thisMethod->argCount;
3    int thisLocalCount = thisFrameSize - thisArgCount;
4    …
5    cell* prev_sp = getSP() - thisArgCount; /* Very volatile! */
6    …
7    newFrame = (FRAME)(getSP() + thisLocalCount + 1);
8    …
9    /* Initialize info needed for popping the stack frame later on */
10    newFrame->previousSp = prev_sp;
11    newFrame->previousIp = getIP();
12    newFrame->previousFp = getFP();
13    …
14    /* Initialize the frame to execute the given method */
15    newFrame->thisMethod = thisMethod;
16    newFrame->syncObject = NIL; /* Initialized later if necessary */
17    …
18    /* Change virtual machine registers to execute the new method */
19    setFP(newFrame);
20    setSP((cell*)(newFrame + 1- 1);
21    setIP(thisMethod->u.java.code);
22    setCP(thisMethod->ofClass->constPool);
23    ...

L1-L3分别读出帧的大小、参数列表的大小和本帧实际申请空间的大小(从帧中减去与上一帧复用的部分);

sp是当前栈内的指针,也是操作数的指针,在新的一帧压入之前,sp应指向操作数栈中最后一个参数的位置,所以L5prev_sp所取得的是上一帧中函数参数列表的首地址,也就是新帧开始的位置,以后新方法返回的时候,新帧被弹出,这里应是操作数栈的当前位置,也就是sp的位置,函数的返回值要存放到这里;

L7为新帧申请了空间;

L10-L12为保存调用之前的寄存器状态;

L19-L22为寄存器赋新值。

popFrame()函数比较简单,主要就是调用了下面这个宏来恢复调用前寄存器的值:

#define POPFRAMEMACRO                                                          
    setSP(getFP()
->previousSp);    /* Restore previous stack pointer */        
    setIP(getFP()
->previousIp);    /* Restore previous instruction pointer */  
    setFP(getFP()
->previousFp);    /* Restore previous frame pointer */        
    setLP(FRAMELOCALS(getFP()));   
/* Restore previous locals pointer */       
    setCP(getFP()
->thisMethod->ofClass->constPool);

 

/* FRAME (allocated inside execution stacks of threads) */
struct frameStruct {
    FRAME    previousFp; 
/* Stores the previous frame pointer */
    BYTE
*    previousIp; /* Stores the previous program counter */
    cell
*    previousSp; /* Stores the previous stack pointer */
    METHOD   thisMethod; 
/* Pointer to the method currently under execution */
    STACK    stack;      
/* Stack chunk containing the frame */
    OBJECT   syncObject; 
/* Holds monitor object if synchronized method call */
};
typedef 
struct frameStruct*            FRAME;

指令集是虚拟机中最底层也是最核心的部分,Java程序中的变量赋值、函数调用等所有操作最后都要被转化为一条条的指令来执行。

指令集是在Java虚拟机规范中定义的,各种虚拟机实现要给予精确的实现,下面就来介绍一下指令集的分类以及在KVM中是如何实现的。

在头文件kvm/vmcommon/h/interpret.h中有如下对指令集种类的定义:

typedef enum {
        NOP         
= 0x00,
        ACONST_NULL 
= 0x01,
        ICONST_M1   
= 0x02,
……
        LASTBYTECODE          
= 0xDF
} ByteCode ;

以及每条指令的名字:

#define BYTE_CODE_NAMES {              
    
"NOP",              /*  0x00 */  
    
"ACONST_NULL",      /*  0x01 */  
"ICONST_M1",        /*  0x02 */  
……
"CUSTOMCODE"            /*  0xDF */ }

Java虚拟机的指令集非常多,大概有200种左右,本篇不详细介绍每一条指令的功能和参数,只选取几个典型的指令作为例子,介绍它们是如何实现的。

KVM中,所有指令的实现都放在kvm/vmcommon/src/bytecodes.c中,每一条指令都遵从如下的形式:

SELECT(指令号)
    {operations}
DONE(跳转位置)

注:
#define SELECT(l1)                      case l1: {
#define SELECT2(l1, l2)                 case l1: case l2: {
#define SELECT3(l1, l2, l3)             case l1: case l2: case l3: {
#define SELECT4(l1, l2, l3, l4)         case l1: case l2: case l3: case l4: {
#define SELECT5(l1, l2, l3, l4, l5)     case l1: case l2: case l3: case l4: case l5: {
#define SELECT6(l1, l2, l3, l4, l5, l6) case l1: case l2: case l3: case l4: case l5: case l6: {
#define DONE(n)    } goto next##n;
#define DONEX      }
#define DONE_R     } goto reschedulePoint;

{operations}部分是该指令的具体实现。

整个bytecodes.c文件其实是一个switch分支结构中的cases部分,这个文件中定义了所有的case。这个文件会被源文件kvm/vmcommon/src/execute.c所包含,execute.c中定义有一个方法

void SlowInterpret(ByteCode token);

它是解释执行Java指令的主要函数,参数token就是一条指令,在本函数中会有一个switch()结构来选择token的执行路径:

函数结尾处的几个标签是指令完成后会跳转到的地方。

 

依据Java虚拟机规范,虚拟机指令可以分为装载和存储指令、运算指令、类型转换指令、对象创建与操纵指令、操作数栈管理指令、控制转移指令、方法调用和返回指令、抛出和处理异常指令、实现finally指令和同步指令等10类,下面从中选取几个简单的指令来看一看它们是如何设计的:

1ICONST_0

说明:

无参数,向操作数栈中压入int型常量0

实现代码:

SELECT(ICONST_0)       /* Push integer constant 0 onto the operand stack */
        pushStack(
0);
DONE(
1)
宏经适当展开后为:
case ICONST_0: {
    
*++GlobalState.gs_sp = 0;
goto next1;

GlobalState.gs_sp是当前帧内操作数栈的指针,ICONST_0指令要做的只是把指针向后移动一个字(注意是“字”而不是“字节”),然后给新字赋值为0;最后程序计数器ip自加1,表明没有跳转,接着执行下一条指令。

 

2DSTORE

说明:

本指令带有一个字节的参数offset,作用是从操作数栈中读取一个double型的值(双字)并存放到局部变量区中的offsetoffset+1位置。

实现代码:

SELECT(DSTORE)           /* Store double into local variable */
        unsigned 
int index = ip[1];
        lp[index
+1= popStack();
        lp[index]   
= popStack();
DONE(
2)
宏展开为:
case DSTORE: {
        unsigned 
int index = GlobalState.gs_ip[1];
        GlobalState.gs_lp[index
+1= *GlobalState.gs_sp --;
        GlobalState.gs_lp[index]   
= *GlobalState.gs_sp --;
goto next2;

首先从程序计数器的下一个字节中取出目标位置的偏移量index,然后从操作数栈中弹出两个字分别作为double型数的底位和高位存入局部变量lp所指向的区域中的合适位置。

3I2L

说明:

无参数,将操作数栈中的当前操作数由int型转换为long型。

实现代码:

SELECT(I2L)                              /* Convert integer to long */
        
long value = *(long *)sp;
#if BIG_ENDIAN
        ((
long *)sp)[1= value;
        ((
long *)sp)[0= value >> 31;
#elif LITTLE_ENDIAN || !COMPILER_SUPPORTS_LONG
        ((
long *)sp)[1= value >> 31;
#else
        SET_LONG(sp, value);
#endif
        getSP()
++;
DONE(
1)

由于longint表示的范围大,所以在扩展时多出来的高位只是用于符号扩展。先从操作数栈中取出int型整数并把它作为一个long型,如果定义了宏BIG_ENDIAN,说明操作数栈中的存储规则是高字节在前,这时要把value的值向后移一个字作为低字来用,高字用于作符号扩展;如果操作数栈中是低位在前的话,原位置中的字不用动,只要把下一个字作符号扩展即可。最近,由于当前操作数由一个字变为两个字,所以sp要自加1

4LMUL

说明:

无参数;从栈中弹出两个long型数,相乘,然后将所得long型结果压回栈。

实现代码:

SELECT(LMUL)                             /* Mul long */
        long64 rvalue 
= GET_LONG(sp - 1);
        long64 lvalue 
= GET_LONG(sp - 3);
        SET_LONG(sp 
- 3, ll_mul(lvalue, rvalue));
        getSP() 
-= 2;
DONE(
1)

 

先从操作数栈中分别取出两个双字长的长整型数,使用ll_mul()宏把它们相乘(这个宏的实现是依赖于操作系统的),然后再把相乘的结果写入栈中。整个操作从栈中弹出了四个字而压入两个,在过程中指针sp都没有变过,所以最后要把sp向前移两个字。

 

以下作为例子的都是一些简单的指令,但并不是所有指令都这样简单,像对象操作、异常处理和方法调用几类指令都十分的复杂,本篇只是演示指令的原理,所以不介绍太复杂的指令。

 

void SlowInterpret(ByteCode token) {

switch (token) {

#include 
"bytecodes.c"

next3:  ip
++;
next2:  ip
++;
next1:  ip
++;
next0:
reschedulePoint:
    
return;
}
原创粉丝点击