当前位置: 首页 > 工具软件 > oat++ > 使用案例 >

ART深入浅出(7) - OAT文件的格式

慕项明
2023-12-01

本文基于Android 7.1,不过因为从BSP拿到的版本略有区别,所以本文提到的源码未必与读者找到的源码完全一致。本文在提供源码片断时,将按照 <源码相对android工程的路径>:<行号> <类名> <函数名> 的方式,如果行号对不上,请参考类名和函数名来找到对应的源码。

了解ART的基本运作原理,就需要了解ART编译出的代码是怎么运行的。我们将使用oatdump得到的代码,进行仔细分析。

从简单示例出发

以一个非常简单的函数为例:
frameworks/base/core/java/android/app/Activity.java:988

988    public void onCreate(@Nullable Bundle savedInstanceState,
989            @Nullable PersistableBundle persistentState) {
990        onCreate(savedInstanceState);
991    }

这个函数,仅仅是调用了同名的onCreate函数的实现。
编译成oat文件后(存放在boot.oat内),用oatdump出来,我找到对应的dex代码

  129: void android.app.Activity.onCreate(android.os.Bundle, android.os.PersistableBundle) (dex_method_idx=2085)
    DEX CODE:
      0x0000: 6e20 2408 1000            | invoke-virtual {v0, v1}, void android.app.Activity.onCreate(android.os.Bundle) // method@2084
      0x0003: 0e00                      | return-void

这个dex代码看起来也非常简单。
接下来,我们看看编译出的机器码,反汇编后是什么:

    QuickMethodFrameInfo
      frame_size_in_bytes: 48
      core_spill_mask: 0x000040e0 (r5, r6, r7, r14)
      fp_spill_mask: 0x00000000 
      vr_stack_locations:
        ins: v0[sp + #52] v1[sp + #56] v2[sp + #60]
        method*: v3[sp + #0]
        outs: v0[sp + #4] v1[sp + #8]
    CODE: (code_offset=0x0337a6d5 size_offset=0x0337a6d0 size=66)...
      0x0337a6d4: f5ad5c00  sub     r12, sp, #8192
      0x0337a6d8: f8dcc000  ldr.w   r12, [r12, #0]
        StackMap [native_pc=0x337a6dd] (dex_pc=0x0, native_pc_offset=0x8, dex_register_map_offset=0xffffffff, inline_info_offset=0xffffffff, register_mask=0x0, stack_mask=0b000000)
      0x0337a6dc: b5e0      push    {r5, r6, r7, lr}
      0x0337a6de: b088      sub     sp, sp, #32
      0x0337a6e0: 9000      str     r0, [sp, #0]
      0x0337a6e2: f8b9c000  ldrh.w  r12, [r9, #0]  ; state_and_flags
      0x0337a6e6: f1bc0f00  cmp.w   r12, #0
      0x0337a6ea: d10a      bne     +20 (0x0337a702)
      0x0337a6ec: 461d      mov     r5, r3
      0x0337a6ee: 460e      mov     r6, r1
      0x0337a6f0: 4617      mov     r7, r2
      0x0337a6f2: 6808      ldr     r0, [r1, #0]
      0x0337a6f4: f8d004c8  ldr.w   r0, [r0, #1224]
      0x0337a6f8: f8d0e020  ldr.w   lr, [r0, #32]
      0x0337a6fc: 47f0      blx     lr
        StackMap [native_pc=0x337a6ff] (dex_pc=0x0, native_pc_offset=0x2a, dex_register_map_offset=0x0, inline_info_offset=0xffffffff, register_mask=0xe0, stack_mask=0b000000)
          v0: in register (6)   [entry 0]
          v1: in register (7)   [entry 1]
          v2: in register (5)   [entry 2]
      0x0337a6fe: b008      add     sp, sp, #32
      0x0337a700: bde0      pop     {r5, r6, r7, pc}
      0x0337a702: 9103      str     r1, [sp, #12]
      0x0337a704: 9204      str     r2, [sp, #16]
      0x0337a706: 9305      str     r3, [sp, #20]
      0x0337a708: f8d9e2a8  ldr.w   lr, [r9, #680]  ; pTestSuspend
      0x0337a70c: 47f0      blx     lr
        StackMap [native_pc=0x337a70f] (dex_pc=0x0, native_pc_offset=0x3a, dex_register_map_offset=0x3, inline_info_offset=0xffffffff, register_mask=0x0, stack_mask=0b111000)
          v0: in stack (12) [entry 3]
          v1: in stack (16) [entry 4]
          v2: in stack (20) [entry 5]
      0x0337a70e: 9903      ldr     r1, [sp, #12]
      0x0337a710: 9a04      ldr     r2, [sp, #16]
      0x0337a712: 9b05      ldr     r3, [sp, #20]
      0x0337a714: e7ea      b       -44 (0x0337a6ec)

这个代码很长,你也许会奇怪,为什么简单的一句源码,会产生如此多的机器码呢?下面我为大家分段解析。
解析时,请大家注意对照我说的部分

QuickMethodFrameInfo

首先,看到的”QuickMethodFrameInfo”部分,定义了相关代码的信息,包含:frame_size_in_bytes: 这个信息指出堆栈的大小。该例子中的值是48。下面的机器码中(0x0337a6dc-0x0337a6de),我们可以看到push指令和sub指令正好占用了堆栈的48字节。
core_spill_mask: 需要保存的寄存器值。这个值是0x000040e0,对应的就是0x337a6dc的 push {r5, r6, r7, lr} (lr即r14)。
vr_stack_locations: 则定义了dex寄存器在堆栈上的位置。

Code部分

接下来,CODE部分,包含了所有相关的代码。CODE部分包括5部分:
1. 堆栈检测部分:0x0337a6d4-0x337a6d8,检测现在的堆栈是否溢出的,否则将会产生一个堆栈溢出的错误
2. 程序头部分:0x0337a6dc-0x0337a6f0, 主要是保护现场,保存参数,检查停止点等
3. 程序正文部分:0x0337a6f2-0x0337a6fc,dex字节码的实现部分
4. 程序结尾部分:0x0337a6fe-0x0337a700 负责恢复现场,然后返回
5. 执行检查点部分:0x0337a702-0x0337a714: 调用检查点,执行一些任务,比如GC等等。
这些部分,包含了一个程序大部分组成。
第一部分的堆栈检测比较简单,我们不详细说了,从第二部分开始。

程序头部分

这一部分的代码

      0x0337a6dc: b5e0      push    {r5, r6, r7, lr}
      0x0337a6de: b088      sub     sp, sp, #32
      0x0337a6e0: 9000      str     r0, [sp, #0]
      0x0337a6e2: f8b9c000  ldrh.w  r12, [r9, #0]  ; state_and_flags
      0x0337a6e6: f1bc0f00  cmp.w   r12, #0
      0x0337a6ea: d10a      bne     +20 (0x0337a702)
      0x0337a6ec: 461d      mov     r5, r3
      0x0337a6ee: 460e      mov     r6, r1
      0x0337a6f0: 4617      mov     r7, r2

需要注意的是,在ART里面,参数传递是使用r0~r3 (ARM架构),这一点与标准的C stdcall是一样的。不过,r0是被固定为参数ArtMethod*,即目前被调用函数的指针,如果是非static函数,那么r1则是this指针,否则r1就是第一个参数。依次是java函数中声明的其他参数。
如果参数超过了寄存器的数目,则用堆栈传递。关于堆栈传递参数的方法,我们会在后面章节中详细说明。
这里看到,前两条语句是保存寄存器并分配堆栈。第3条语句(0x0337a6e0) 将r0(ArtMethod*)的值放在了栈顶。这是ART的规范要求,所有的java method,必须在堆栈上放入它的函数指针,这样在遍历栈的时候,ART就知道这个栈归属哪个函数。
第4~6句 (0x0337a6e2-0x0337a6ea) 是读取 Thread对象的state_and_flags成员,判断是否有检查点,如果有则转到检查点来执行。
r9参数总是放的当前线程的Thread对象指针。
第7句以后,则是保存参数到寄存器中,以便后面使用(实际上并没有使用,这说明ART的编译器还存在优化的空间)。

调用部分

要调用一个函数,ART必须做的事情是:1. 得到这个函数的入口地址;2. 准备参数;3. 调用

      0x0337a6f2: 6808      ldr     r0, [r1, #0]
      0x0337a6f4: f8d004c8  ldr.w   r0, [r0, #1224]
      0x0337a6f8: f8d0e020  ldr.w   lr, [r0, #32]
      0x0337a6fc: 47f0      blx     lr

在这里,r1是this指针,第0x0337a6f2是取得this的class对象,放到r0,然后从r0中取得被调用的onCreate函数的ArtMethod*指针,再保存到r0中。第三步是从ArtMethod中取得入口地址(放在32偏移处),最后调用。
onCreate(Bundle) 函数需要3个参数:
r0: 指向onCreate(Bundle)的ArtMethod*;
r1: this指针
r2: Bundle对象指针。
我们看到,ART巧妙的将ArtMethod的值取到R0后,就把它作为参数来使用。而r1,r2的值自进入函数后就没有被改变,所以就直接使用了。
最后一步调用blx跳转到函数入口。 blx指令是跳转到寄存器指定的地址,同时把下条指令的地址放入到lr寄存器,方便函数调用后返回。

程序结束部分

这部分很简单,就是恢复堆栈和寄存器。
因为这个函数没有返回值,所以这里省略了返回值的处理。正常情况下,返回值是放在r0,r1寄存器内的。

检查点调用部分

检查点是ART内一个非常重要的功能,ART依靠检查点来实现GC、异常处理等一系列关键功能。
ART为每个线程都分配了一个Thread对象,这个对象中有一个pTestSuspend入口,这个入口就是用来执行检查点函数调用的。

      0x0337a702: 9103      str     r1, [sp, #12]
      0x0337a704: 9204      str     r2, [sp, #16]
      0x0337a706: 9305      str     r3, [sp, #20]
      0x0337a708: f8d9e2a8  ldr.w   lr, [r9, #680]  ; pTestSuspend
      0x0337a70c: 47f0      blx     lr
      0x0337a70e: 9903      ldr     r1, [sp, #12]
      0x0337a710: 9a04      ldr     r2, [sp, #16]
      0x0337a712: 9b05      ldr     r3, [sp, #20]
      0x0337a714: e7ea      b       -44 (0x0337a6ec)

这段代码从0x0337a702-0x0337a712 是调用pTestSuspend入口的,调用入口不需要参数,所以前面它保存了r1, r2, r3三个参数,调用后又恢复了r1,r2,r3参数。至于要保存那些参数,根据实际情况定。
在最后一条语句,则是返回到调用现场,继续运行。这里它没有使用blx这样的调用,而是直接用b指令,目的应该是为了尽量减少寄存器的使用。

了解dex各类指令以及异常的实现方法

接下来,我们了解下各类dex指令的实现。这些指令的实现都和class、object、method、field的实现结构密切相关,但是,在我们具体了解这些结构的具体内容之前,我们先了解下ART是怎样使用它们的,就更容易理解这些结构为什么被设计成那种样子。

上文提到了invoke的实现,这里我们就不再解释

dex指令的实现

const-string

boot内的字符串查找

dex代码:

0x001e: 1b02 5501 0000            | const-string/jumbo v2, " doesn't implement Cloneable" // string@341

对应的机器码反汇编后是

      0x02d7a184: 4a1f      ldr     r2, [pc, #124]  ; 0x6fbf9ef0                                                                     
      0x02d7a186: 4601      mov     r1, r0   
      0x02d7a188: 4607      mov     r7, r0   
      0x02d7a18a: 4690      mov     r8, r2   
      0x02d7a18c: 6808      ldr     r0, [r1, #0] 

其中0x02d7a184就是加载字符串地址的代码。可以看到,读取pc偏移124的值(0x6fbf93f0) 即时字符串对象的地址。
pc + 124得到的值是 0x2D7A202 , 让他对齐到4字节后,就是地址 0x2D7A204:

      0x02d7a204: 9ef0      ldr     r6, [sp, #960]
      0x02d7a206: 6fbf      ldr     r7, [r7, #120] 

注意这里0x2d7a204位置不是代码,而是数据,但是反汇编器没有识别,仍旧当作代码来解析。我们看到,204处是9ef0, 206处是6fbf,合在一起,正是一个4字节地址:0x6fbf93f0 (因为arm是小端)。
这个地址是一个绝对地址,它实际指向的是boot.art映射的内存。我摘取的代码来自boot.oat。因为art会把boot.art加载到一个固定地址,所以,这里的代码可以这样处理。
另外需要注意的是,这里得到的地址,并不是字符串的地址,而是java String对象的地址。因为C语言里面的字符串是字符数组,是无法让java语言处理的。

应用的字符串查找方法

如果是应用程序的oat文件,没有对应的art映射文件,应该如何处理呢?
实际上,ART是通过bss段的数据来实现的。
为了说明这个问题,我们下载一个应用的oat看看。

adb pull /data/dalvik-cache/arm/system@app@Bluetooth@Bluetooth.apk@classes.dex
oatdump --oat-file=system@app@Bluetooth@Bluetooth.apk@classes.dex > bluetooth.txt

首先,我用readelf -a system@app@Bluetooth@Bluetooth.apk@classes.dex 看看这个文件的结构(只摘抄关键的部分):

节头:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
.....
  [ 3] .bss              NOBITS          00f0b000 000000 06b354 00   A  0   0 4096
....
Symbol table '.dynsym' contains 6 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00001000 0x8f3000 OBJECT  GLOBAL DEFAULT    1 oatdata
     2: 008f4000 0x6165a0 OBJECT  GLOBAL DEFAULT    2 oatexec
     3: 00f0a59c     4 OBJECT  GLOBAL DEFAULT    2 oatlastword
     4: 00f0b000 0x6b354 OBJECT  GLOBAL DEFAULT    3 oatbss
     5: 00f76350     4 OBJECT  GLOBAL DEFAULT    3 oatbsslastword

请注意下,“节头”中第3条 .bss,起始位置是 00f0b000,大小是06354, 对应”Symbol table ‘.dynsym’ 中的第4条,即“oatbss”部分。
bss段是用来指定没有初始值的全局和静态变量的,在文件中没有分配空间,但是加载到内存中时,需要分配空间。程序启动时,系统会自动清零。
oatbss段,就被ART用来存放各种全局变量和静态变量,其中就包括这些常量字符串对象。
经过计算,oatbss的范围是 0x00f0b000-0x00f76354 (最后4字节即时oatbsslastword)

下面,我们在看看一个加载的例子。首先,看一下dex的写法:

0x0005: 1b08 e74f 0000            | const-string/jumbo v8, "Ra" // string@20455

这条指令,是加载dex文件中,索引为20455的字符串。
的对应的汇编代码:

      0x008f4366: f6456086  movw    r0, #24198
      0x008f436a: f2c00065  movt    r0, #101
      0x008f436e: 4478      add     r0, pc  
      0x008f4370: 900b      str     r0, [sp, #44]
      0x008f4372: 6802      ldr     r2, [r0, #0]
      0x008f4374: 2a00      cmp     r2, #0  
      0x008f4376: f00081f8  beq.w   +1008 (0x008f476a)                                                                               
      0x008f437a: 9206      str     r2, [sp, #24]

movw是将立即数放入寄存器低16位,movt是放入到高16位。故此,前两条语句,使 r0的值为 0x655e86。
第三条语句含义为r0 = r0 + pc,此时pc的值0x008f4372, 这时,r0的值为 0xF4A1F8。该值正好在 0x00f0b000-0x00f76354 范围内,即是oatbss内部的内容。所以,这是取一个静态的字符串对象的地址。
第6,7条指令(cmp r2, #0; beq.w +1008 ) 则是判断对象是否初始化,没有初始化,就去加载这个字符串。加载字符串的地址是 0x008f476a:

      0x008f476a: 900e      str     r0, [sp, #56]
      0x008f476c: f64470e7  movw    r0, #20455
      0x008f4770: f8d9e154  ldr.w   lr, [r9, #340]  ; pResolveString
      0x008f4774: 47f0      blx     lr
      0x008f4776: 4602      mov     r2, r0  
      0x008f4778: 980e      ldr     r0, [sp, #56]
      0x008f477a: e5fe      b       -1028 (0x008f437a)  

首先,ART保存了r0寄存器(因为r0要作为参数和返回值),然后从pThread (r9) 中取得pResolveString的地址,然后调用。得到的结果,保存在r2中,然后恢复r0值,在跳转到回去,继续执行。

ART为了避免寄存器被污染,所以为每次加载字符串的地方都生成了这种辅助代码。

const-class

根据各种情况,可以分为多种实现

method 加载本身所在类

ArtMethod第一个成员就是declaring_class, 因此这种情况下,直接从ArtMethod指针中获取就可以。比如下面的例子

  5: java.lang.Object java.text.AttributedCharacterIterator$Attribute.readResolve() (dex_method_idx=9453)
    DEX CODE:
      ....
      0x0004: 1c02 8c03                 | const-class v2, java.text.AttributedCharacterIterator$Attribute // type@908 

要加载的就是method所在的类
对应的机器码是

    CODE: (code_offset=0x02d7c6dd size_offset=0x02d7c6d8 size=240)...
...
      0x02d7c6e4: e92d45e0  push    {r5, r6, r7, r8, r10, lr} 
      0x02d7c6e8: b086      sub     sp, sp, #24
      0x02d7c6ea: 9000      str     r0, [sp, #0] 
      0x02d7c6ec: f8b9c000  ldrh.w  r12, [r9, #0]  ; state_and_flags
      0x02d7c6f0: f1bc0f00  cmp.w   r12, #0  
      0x02d7c6f4: d158      bne     +176 (0x02d7c7a8)
      0x02d7c6f6: 4605      mov     r5, r0   
...
      0x02d7c706: 682f      ldr     r7, [r5, #0]                                                                                     
      0x02d7c708: 42b8      cmp     r0, r7   

我保留了入口部分的代码,可以看到,r0是入参,ArtMethod指针,首先r0被保存到r5,然后,取r5 + 0偏移位置的值(即delcaring_class_),就获得了class的地址。

其他情况下取得class的方法

如果要取得一个和当前method没有任何关系的class,就要用到ArtMethod中一个非常重要的成员: dex_cache_resolved_types_ 中。在arm平台,它的偏移是24
比如,下面的例子:

0x0010: 1c02 e400                 | const-class v2, java.lang.Enum // type@228

java.lang.Enum类与当前的method没有任何关联,它的获取是通过
art_method->dex_cache_resolved_types_[228] 获取的。下面的代码如下:(请注意注释)

      0x02da522a: 69b9      ldr     r1, [r7, #24] //r7 是ArtMethod指针,即当前函数, 将 r1 = art_method->dex_cache_resolved_types_                                               
      0x02da522c: f8d11390  ldr.w   r1, [r1, #912]  //取 r1[912]。  912 = 228 * 4
      0x02da5230: 9104      str     r1, [sp, #16]
      0x02da5232: 4288      cmp     r0, r1

一个class地址占用4字节(即使是64位的架构,class也是占用4字节),所以228 * 4 = 912,即是 偏移地址。

需要注意的是,上面例子,我给出的是java.lang.Enum,这是一个基础类,所以可以认为这个类一定是被解析过的,可以直接使用的,如果加载一个任意类,那么ART就不能保证这个类一定被解析过,所以需要检查一下,如果没有加载,就会先去加载。比如下面的例子

0x008c: 1c08 5001                 | const-class v8, android.net.dhcp.DhcpPacket$ParseException // type@336

对应的机器码:

      0x00904da4: f8db1018  ldr.w   r1, [r11, #24]
      0x00904da8: f8d11540  ldr.w   r1, [r1, #1344]
      0x00904dac: 2900      cmp     r1, #0  
      0x00904dae: f0008165  beq.w   +714 (0x0090507c)
      0x00904db2: 9105      str     r1, [sp, #20]

这里,r11是ArtMethod*指针。取得对象后,会检测一下是否为空(0),如果为空,就会跳转到 0x0090507c:

      0x0090507c: f44f70a8  mov.w   r0, #336
      0x00905080: f8d9e150  ldr.w   lr, [r9, #336]  ; pInitializeType
      0x00905084: 47f0      blx     lr
      0x00905086: 4601      mov     r1, r0  
      0x00905088: e693      b       -730 (0x00904db2)

这里,r9就是pThread指针,调用pInitiliazeType函数,参数是class的索引:336。初始化成功后,就再跳回去继续执行。

new-instance

在dex中,new一个对象,被分拆成两个指令:new-instance和invoke-direct。即先创建对象,然后在调用构造函数。我们重点关注的是创建对象
例如

0x0006: 2200 6d01                 | new-instance v0, android.net.metrics.IpConnectivityLog // type@365
      0x00905fe4: 9900      ldr     r1, [sp, #0]
      0x00905fe6: f240106d  movw    r0, #365
      0x00905fea: f8d9e11c  ldr.w   lr, [r9, #284]  ; pAllocObject
      0x00905fee: 47f0      blx     lr

r9是pThread指针,调用pThread->pAllocObject。其中,参数r0是class的index, 参数r1是调用者的ArtMethod。
因为ArtMethod中包含很多信息,所以这个信息是很重要的。
基本上所有的实现都是这样的。

get/put instance field

ART会将所有的field转换为它们在object中的偏移,所以field的存取相对简单很多。

iget

比如:

0x0018: 5432 6b01                 | iget-object v2, v3, Landroid/net/DhcpResults; android.net.dhcp.DhcpClient.mDhcpLease // field@363

它的含义是 v2 = v3.mDhcpLease。
对应的实现

      0x00906872: 6b2a      ldr     r2, [r5, #48]
      0x00906874: 9207      str     r2, [sp, #28]

r5 即对应的v3寄存器对象,#48就是field mDhcpLease在v3中的偏移。v2对应的sp + 28的位置(因为寄存器不够用了),所以,它先被加载到r2寄存器,然后再存入到sp上。
之所以用r2寄存器,是因为r2寄存器马上要作为参数传递给即将调用的函数。这是ART一个优化措施。

iput

iput的实现与iget的实现非常类似,查找field的方法都是一样的。但是iput-object有一个不同的地方:iput-object要同步card-table。
card-table是一个GC相关的数据。简单来说,card-table是记录object引用关系的。
iput-object会导致对象的引用关系发生变化,这时,ART就要更新对应的card-table,让GC能够知道那块内存变脏了,需要重新查找。
直接看机器码:

      0x00906c52: 65fe      str     r6, [r7, #92]
      0x00906c54: f8d90080  ldr.w   r0, [r9, #128]  ; card_table
      0x00906c58: 09f9      lsrs    r1, r7, #7
      0x00906c5a: 5440      strb    r0, [r0, r1]

第一条指令就是实现iput-object的: r7.field = r6。92就是field的offset
剩下的语句,翻译成c伪代码就是:

card_table = pThread->card_table;
card_table[r7 >> 7] = card_table&0xff;

ART中,card_table不是位,而是以字节为单位管理一块内存,一个字节对应128字节的数据,所以r7 >> 7 后就能得到它的索引。如果对应内存脏了,那么对应的字节就会设置为一个特定值。
这个特定值正好是card_table地址的最低字节。
ART这样设计自然是为了效率。

get/put static field

sget, sput的实现与iget, iput非常类似,只是object需要换成class的指针。ART的class对象后面同样放置了static field的值,而且是sget,sput都直接使用偏移来访问。同样的sput-object指令也会更新card-table,方法也是一样的。有兴趣的读者可以自己查阅。

 类似资料: