DALVIK JIT 入口分析

本文主要讲解dalvik jit的框架,至于更深层次的内容,例如编译的方法、寄存器的分配等问题,将在以后讨论。请先阅读 dalvik VM的解释器分析


Dalvik JIT的简要介绍

Dalvik JustInTime技术,是在DVM解释器工作过程中,识别和分析出热点代码,然后将其编译为机器码,然后运行的过程。

jit的基本原理比较简单,我用下面的伪代码来解释其模型:对于一个特定的指令入口dalvik PC


  
  
  1.  
  2. if (dPC被执行次数 > 阀值 ) {
  3. jit_addr = GetJitAddr (dPC ) ;
  4. if (jit_addr != NULL ) {
  5. jit_addr ( ) ; //进入jit代码
  6. } else {
  7. 收集 dPC 所指代码到一组JitTraceRun中 ;
  8. AddCompileWorkQueue (jitTraceRuns ) ;
  9. }
  10. }

CompileWorkQueue有一个编译线程,负责编译,编译过程是


  
  
  1. while ( 1 ) {
  2. JitRuns jitRuns = GetFromWorkQueue ( ) ;
  3. addr = compile (jitRuns ) ;
  4. SetJitAddr (addr ) ;
  5. }

JIT总体可以分成3个重要的课题:

  1. JIT的入口、Trace和代码管理;内容包括1.1 如何从解释代码进入到JIT过程;1.2 DVM如何收集将要被JIT的代码;1.3 DVM是如何分配和保存代码数据的;

  2. JIT的编译过程,JIT如何将如何编译

  3. JIT与解释模式的相互调用。

因为内容比较多,因此,我将文章分为3篇,本篇文章主要介绍JIT的入口,JIT的主要运行框架;其余两篇文章,将介绍JIT的编译与JIT编译后的代码调用。

 Dalik代码中,哪些数据和重要的函数是和JIT有关的?通过Dalvik源代码,我们很容易看到,所有被WITH_JIT宏包围的代码,都是和JIT相关的。

Thread中的JIT数据结构


  
  
  1. struct Thread {
  2. ....
  3. #ifdef WITH_JIT
  4. struct JitToInterpEntries jitToInterpEntries ; //jit代码跳转到解释模式的桥接口,配合jit编译出代码使用的
  5. /*
  6.   * Whether the current top VM frame is in the interpreter or JIT cache:
  7.   * NULL : in the interpreter
  8.   * non-NULL: entry address of the JIT'ed code (the actual value doesn't
  9.   * matter)
  10.   */
  11. void * inJitCodeCache ; //当前jit的入口地址代码。用于解释到jit的跳转
  12. unsigned char * pJitProfTable ; //这是一个以davik PC为key的hash表,记录对应代码的执行次数
  13. int jitThreshold ;
  14. const void * jitResumeNPC ; // Translation return point 这些是用来jit暂停再重启要保存的数据
  15. const u4 * jitResumeNSP ; // Native SP at return point
  16. const u2 * jitResumeDPC ; // Dalvik inst following single-step
  17. JitState jitState ; //用于jit trace的状态信息保存
  18. int icRechainCount ;
  19. const void * pProfileCountdown ;
  20. const ClassObject * callsiteClass ; //invoke子对象时必须的数据
  21. const Method * methodToCall ;
  22. #endif
  23. ....
  24. }

比较重要的数据是pJitProfTable,它是一个hash表,以dalvik PC为key,通过dvmJitHash函数算出索引。索引所指是一个unsgind char数据,一开始,数据被写入255,然后直至递减至0,表示可以开始代码的Trace了。

gDvmJit以及dvmJitGetTraceAddr

gDvmJit是一个维护Jit信息的全局对象。其中最重要的是维护jit代码入口的hash表。gDvmJit.pJitEntryTable是JitEntry的hash表。这是一个桶hash对象。通过dvmJitGetTraceAddr和dvmJitGetTraceAddrThread等函数获取到dalvik pc对应的代码地址。

JIT的入口

我们知道,DVM解释器的入口是dvmMterpStdRun ,在这个函数入口处,有一个common_updateProfile的标签(汇编函数),这个标签下的代码,就是解释模式进入到JIT世界的入口。

dalvik大部分都运行在arm设备上,因此对应的源码是vm/mterp/out/InterpAsm-armv7-a-neon.S.

common_updateProfile代码分析

为了方便大家阅读,我将对应的汇编码翻译成对等的C++代码


  
  
  1.  
  2. common_updateProfile :
  3. /* 对等C++代码*/
  4. int idx = dvmJitHash (rPC ) ;
  5. if ( --thread - >pJitProfTable [idx ] ! = 0 ) {
  6. goto_op_code (ip ) ; //这是跳转到对应的解释器入口处。
  7. }
  8.  
  9. thread - >pJitProfTable [idx ] = thread - >jitThreshold ;
  10.  
  11. EXPORT_PC ( ) //save dPC到StackArea中,这是解释器要保存的事情
  12. if ( (thread - >inJitCodeCache = dvmJitGetTraceAddrThread (dPC, thread ) ) ! = NULL ) {
  13. thread - >inJitCodeCache ( ) ; //调用jit入口
  14. }
  15. //其他情况,调用common_selectTrace,后续在详细讨论

其中common_selectTrace是负责开始trace代码的。

这里面比较难以理解的部分,是还要reset counter,似乎看起来是延长了进入jit的次数。

DVM进入common_updateProfile的点

DVM有很多地方都会进入到common_updateProfile中,这些包括:

  • dvmMterpStdRun入口处;

  • invoke函数,准备好参数后

  • IF指令处

  • GOTO指令

  • SWITCH指令

  • JIT指令转移到解释模式后,也有机会再次进入到JIT代码。这种情况下,jit会考虑将两者连在一起,在jit中直接跳转。

这些说明,JIT是以一段连续的没有跳转的代码为单位进行编译的。

Trace代码

Trace代码的功能,是收集将要被编译的代码,然后将结果放入到编译队列中。

Davik完成这个工作,是一边继续执行解释代码,一边收集要被编译的代码的。这个过程是通过一个巧妙的方法实现的。

为了完成这个工作,Dalvik用一个简单的状态机来实现过程控制。其中Thread.jitState是用来保存状态的。分别有5个状态:

  1. kJitNone : 初始状态,此时没有开始jit

  2. kJitTSelectRequest/kJitTSelectRequestHot:请求代码收集。是在common_updateProfile被调用后发现对应code还没有

  3. kJitTSelect : 开始收集代码的阶段,当遇到分支跳转代码、return代码、throw代码后,就会转入Select

  4. kJitTSelectEnd : 完成收集代码的阶段,会调用dvmCompilerWorkEnqueue,将字节码加入队列

  5. kJitDone : 完成编译,并调用dvmJitSetCodeAddr,将编译后代码的地址保存起来。

除了jitState之外,Thread还有一种subMode,来表示当前解释器运行在那种状态下。subMode由枚举值ExecutionSubModes定义。ExecutionSubModes有很多,但是,我只列出重要的几个:

  1. kSubModeNormal : 正常状态,没有活动的子模式

  2. kSubModeJitTraceBuild : Trace状态,现在以Trace状态运行。

kSubModeJitTraceBuild是我们关注的子模式。那么,kSubModeJitTraceBuild这种子模式究竟有什么作用呢?与Trace代码和jitState的状态变化有什么关系?

下面我们回答这个问题。

SubMode的秘密

进入kSubModeJitTraceBuild子模式

我们知道,当调用完common_updateProfile后,如果尚未获取jit代码,就会调用common_selectTrace。那么,common_selectTrace要做什么呢?为了简化代码,我还是将其翻译成对应的C++代码:对等的C++代码:


  
  
  1. if (thread - >subMode & kSubModeJitTraceBuild ! = 0 ) {
  2. thread - >jitState = new_jitState ; //(即kJitTSelectRequest);
  3. EXPORT_PC ( ) ;
  4. SAVE_PC_FP_TO_SELF ( ) ; //这两行是保存PC和FP信息的,与trace关系不大,忽略
  5. dvmJitCheckTraceRequest (self ) ; //(1)
  6. }
  7. FETCH_INST ( ) ; //取下个指令
  8. int opcode = GetInstOpcode (inst ) ;
  9. goto thread - >curHandlerTable + opcode * 64 ; //(2)

(1) dvmJitCheckTraceRequest指令,就是要将jitState由kJitNone转到kJitTSelectRequest状态的函数。另外,该函数将调用dvmEnableSubMode函数,完成解释器模式的转变;

(2) goto 语句是伪代码,表示现在跳转到thread→curHandlerTable + opcode * 64的指令处执行。

实际上,这是dalvik一个非常精巧的一个手段。

首先,curHandlerTable的值,是可以通过dvmEnableSubMode改变的,也就是说,当我们需要开始收集要编译指令时,需要进入kSubModeJitTraceBuild子模式,而模式的切换,是通过改变curHandlerTable的值实现的;

其次,curHandlerTable是什么呢? 从代码中,我们可以看到:


  
  
  1. newValue. ctl. curHandlerTable = (newValue. ctl. breakFlags )
  2. ? thread - >altHandlerTable : thread - >mainHandlerTable ;

当进入kSubModeJitTranceBuild时,newValue.ctl.BreakFlags标记就会被设置上,因此,这种情况下,curHandlerTable的值是altHandlerTable。而平常情况下,则是mainHandlerTable。

mainHandlerTable的巧妙设计

mainHandlerTable其实就是dvmAsmInstructionStart。dvmAsmInstructionStart这个符号,是定义在InterpAsm-armv7-a-neon.S中,是这样定义的:


  
  
  1. . global dvmAsmInstructionStart
  2. . type dvmAsmInstructionStart, %function
  3. dvmAsmInstructionStart = . L_OP_NOP
  4. . text
  5. ....

从这里,我们可以看出两个信息:

  1. 所有的dalvik字节码指令,都有对应的一段汇编码实现,而且每段汇编码的命名是.L_OP_XXX,XXX表示opcode的名字;

  2. dvmAsmInstructionStart是这组汇编码的首地址。也就是说,curHandlerTable其实是一组与dalvik字节码对应的,用于解释字节码的汇编码的数组。因此,curHandlerTable + opcode *64就是找到对应的.L_OP_opcode 代码地址,然后调用这段汇编码。

那么,为什么要乘以64呢? 因为每个.L_OP_XXX标签前面,都有一个 .align 64的伪汇编指令。他告诉汇编器,这些标签必须以64字节为对齐。所以,所有的.L_OP_XXX代码,全部被安排在以dvmAsmInstructionStart为开始的连续空间内,并以64字节对齐。

换句话说,每个OPCODE,它的编码必须是连续的,且每个汇编码的实现,不能超出64字节。如果超出了怎么办?那就用一个branch指令,跳转到一个不受限制的空间内执行。

那么,altHandlerTable又是谁呢?

altHandlerTable的秘密

altHandlerTable,对应的代码入口是dvmAsmAltInstructionStart,他是一组.L_ALT_OP_XXX入口的标签。与mainHandlerTable一样,它也是一组数组。

那么,.L_ALT_OP_XXX与.L_OP_XXX有什么不同呢?

我们以.L_ALT_OP_MOVE为例。

首先,我们看看其源代码是如何编写的:


  
  
  1. /* ------------------------------ */
  2. . balign 64
  3. . L_ALT_OP_MOVE : /* 0x01 */
  4. /* File: armv5te/alt_stub.S */
  5. /*
  6.  * Inter-instruction transfer stub. Call out to dvmCheckBefore to handle
  7.  * any interesting requests and then jump to the real instruction
  8.  * handler. Note that the call to dvmCheckBefore is done as a tail call.
  9.  * rIBASE updates won't be seen until a refresh, and we can tell we have a
  10.  * stale rIBASE if breakFlags==0. Always refresh rIBASE here, and then
  11.  * bail to the real handler if breakFlags==0.
  12.  */
  13. ldrb r3, [rSELF, #offThread_breakFlags]
  14. adrl lr, dvmAsmInstructionStart + ( 1 * 64 ) //(1)
  15. ldr rIBASE, [rSELF, #offThread_curHandlerTable]
  16. cmp r3, #0
  17. bxeq lr //(2) @ nothing to do - jump to real handler
  18. EXPORT_PC ( )
  19. mov r0, rPC @ arg0
  20. mov r1, rFP @ arg1
  21. mov r2, rSELF @ arg2
  22. b dvmCheckBefore //(3) @ (dPC,dFP,self) tail call

这是一个很精巧的设计。首先我把它翻译成对等的C++代码:


  
  
  1. . L_ALT_OP_MOVE :
  2. real_code_addr = dvmAsmInstrunctionstart + ( 1 * 64 ) ; //其实就是.L_OP_MOVE的地址,.L_OP_MOVE是第二条指令的实现
  3. if (thread - >breakFlags ! = 0 ) {
  4. dvmCheckBefore (dPC, dFP, self ) ;
  5. }
  6. goto real_code_addr ; //即goto .L_OP_MOVE

这里的关键是使用了lr寄存器保存.L_OP_MOVE的地址。

当代码执行(2)时,如果breakFlags == 0,就直接跳转到.L_OP_MOVE了。如果不等于0,就会调用dvmCheckBefore函数。

但是,一般情况下,我们都使用bl (branch link)来调用函数,而不是B。 bl的作用是,当调用函数时,将bl下条指令的地址,存放在lr寄存器内。

当函数返回时,使用 bx lr这样的指令,或者直接将lr的值赋值给pc,就可以返回了。

但是,这里(3) 使用了b,而没有使用bl,这样,lr的值就一直是 .L_OP_MOVE的地址。当dvmCheckBefore函数返回后,就会直接调用.L_OP_MOVE了。

由此可以看出,altHandlerTable其实就是在调用每条指令前,先调用dvmCheckBefore函数。

如何获取Trace代码

dvmCheckBefore函数会完成很多任务,其中也包括TraceBuild。因为我们只关心Trace部分,因此我们只找和Trace相关的代码。Trace代码的任务,主要由dvmCheckJit完成。

JitTraceRun

JitTraceRun是保存代码结构的。所有要编译的代码信息,就保存在这里。首先看该结构的定义:


  
  
  1. /*
  2.  * Element of a Jit trace description. If the isCode bit is set, it describes
  3.  * a contiguous sequence of Dalvik byte codes.
  4.  */
  5. struct JitCodeDesc {
  6. unsigned numInsts : 8 ; // Number of Byte codes in run
  7. unsigned runEnd : 1 ; // Run ends with last byte code
  8. JitHint hint : 7 ; // Hint to apply to final code of run
  9. u2 startOffset ; // Starting offset for trace run
  10. } ;
  11.  
  12. struct JitTraceRun {
  13. union {
  14. JitCodeDesc frag ;
  15. void * meta ;
  16. } info ;
  17. u4 isCode : 1 ;
  18. u4 unused : 31 ;
  19. } ;

JitTraceRun不是单独存在,必须以一个数组的方式存在。JitTraceRun.isCode表示是否是代码。当是代码时,JitCodeDesc frag结构被使用。

如果遇到 isCode==1 && info.frag.runEnd == 1就表示结束。

当isCode为1时,frag变量的每个成员含义是:

  • numInsts: 当前被trace的指令个数

  • startOffset: 当前指令开始偏移。这个偏移是相对于指令所在method的偏移。偏移从0开始,表示第一条指令。

  • hint : 忽略,本阶段不考虑

  • runEnd: 必须为0,表示有效的地址。

当isCode == 0时,meta数据被使用,这个定义很宽泛,可以是任意值。但是这里,它们被用作标记一个函数调用(invoke指令)。

要标记一个函数调用,必须用4个连续的JitTranceRun,其中前3个是meta数据,最后一个是isCode。为了方便,我们用表格列出如下

索引 isCode meta/frag 数据 说明
0 False meta = thisClass callee 函数的this指针
1 False meta = thisClass→classLoader thisClass的loader
2 False meta = calleeMethod 被调用函数对象
3 True frag.startOffset = resultPC 函数返回后,要继续运行的dalvik PC偏移

当Trace完成后,我们就会得到这样一组JitTraceRun的数组,从而让我们能够完成对代码的进一步处理。

dvmJitCheck

dvmJitCheck是在每次指令完成后调用的。他根据jitState作出不同的动作。JitTraceRun数组是放在Thread.trace对象中的。为了逻辑清晰,我用伪代码给出,不再引用他的源码了。有兴趣的同学可以自己查看。

当jitState为kJitTSelect状态时,表示正在进行收集指令工作


  
  
  1. //这段伪代码将字节码执行的逻辑也放进来,让大家能够清楚宏观上的结构
  2. u2 * lastPC == 0 ;
  3. currentTraceRun = 0 ;
  4. while ( 1 ) { //解释器的最外层循环
  5. inst = GetCurrentInst (curPC ) ; //获取当前指令
  6. if (lastPC == 0 ) {
  7. lastPC = curPC ; //第一次收集
  8. thread - >trace [currentTraceRun ]. info. frag. numInsts = 1 ;
  9. thread - >trace [currentTraceRun ]. info. frag. startOffset = lastPC - method - >insns ; //取得地址偏移
  10. thread - >trace [currentTraceRun ]. isCode = true ;
  11. } else {
  12. if (inst is Invoke ) //函数调用
  13. {
  14. //把 callee的this, classloader和method对象放入
  15. insertClassMethodInfo ( ) ;
  16. currentTraceRun + = 3 ;
  17. //把返回地址放入
  18. insertMoveResult ( ) ;
  19. currentTraceRun ++ ;
  20. } else if (inst is Branch or Goto or Throw or return ) {
  21. insertLastPC (lastPC ) ; //把最后的pc放入到trace中
  22. jitState = kJitTSelectEnd ; //结束
  23. break ;
  24. } else {
  25. thread - >trace [currentTraceRun ]. info. frag. numInsts ++ ; //增加指令个数
  26. }
  27. }
  28. lastPC = curPC ;
  29. /* 解释执行inst */
  30. ....
  31. }

通过这个简单的模型,我们可以看出,当遇到分支(if/else, switch/case) goto指令,throw和return指令后,trace就会结束。

另外,dalvik会尽量的将连续的代码放在一个run内,保证内存使用精简。

几乎所有用到的数据都要保存在Thread对象中。因为Thread对象是这个线程内运行的全局变量。

当进入kJitTSelectEnd后,就要调用dvmCompilerWorkEnqueue函数了。jit将所有的run对象拷贝一份到对象JitTraceDescription中,然后放入队列中,然编译线程继续运行。

编译及code代码存储

编译线程

在dalvik中,执行编译任务的是线程compilerThreadStart。这个线程由函数dvmCompilerStartup来创建。真正执行编译任务的是 dvmCompilerDoWork→dvmCompileTrace。

dvmCompileTrace是真正执行编译任务的函数。

编译完成后,得到的新地址会被保存到gDvmJit中。通过dvmJitSetCodeAddr调用实现。

详细的编译过程将另开辟一个文章来说明。因为编译过程实在是太复杂了。

编译出的代码内存如何分配

我曾经以为jit代码会在整个系统内共享,但是实际上,每个进程有自己的内存空间。函数dvmCompilerSetupCodeCache完成内存开辟的任务。内存首先会开辟一个gDvmJit.codeCacheSize大小(在arm下,是1500 * 1024大小)。并命名为dalvik-jit-code-cache.我们可以通过查看/proc//maps中找对应的段,就知道了。

当需要jit代码后,每次就从这个缓冲区获取一块。因为代码总是只分配不回收,所以分配方法非常简单。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值