写点什么

深入理解 JAVA 虚拟机原理之 Dalvik 虚拟机(三)

  • 2021 年 11 月 12 日
  • 本文字数:8175 字

    阅读完需:约 27 分钟

dex 文件

如果我们对比 jar 文件和 dex 文件,就会发现:dex 文件格式相对来说更加的紧凑。


jar 文件以 class 为区域进行划分,在连续的 class 区域中会包含每个 class 中的常量,方法,字段等等。而 dex 文件按照类型(例如:常量,字段,方法)划分,将同一类型的元素集中到一起进行存放。这样可以更大程度上避免重复,减少文件大小。


两种文件格式的对比如下图所示:



dex 文件的完整格式参见这里:Dalvik 可执行文件格式


由于 Dex 文件相较于 Jar 来说,对同一类型的元素进行了规整,并且去掉了重复项。因此通常情况下,对于同样的内容,前者比后者文件要更小。以下是 Google 给出的数据,从这个对比数据可以看出,两者的差距还是很大的。



为了便于开发者分析 dex 文件中的内容,Android 系统中内置了dexdump工具。借助这个工具,我们可以详细了解到 dex 的文件结构和内容。以下是这个工具的帮助文档。在接下来的内容中,我们将借这个工具来反编译出 dex 文件中的 Dalvik 指令。


angler:/ # dexdumpdexdump: no file specifiedCopyright (C) 2007 The Android Open Source Project


dexdump: [-c] [-d] [-f] [-


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


h] [-i] [-l layout] [-m] [-t tempfile] dexfile...


-c : verify checksum and exit-d : disassemble code sections-f : display summary information from file header-h : display file header details-i : ignore checksum failures-l : output layout, either 'plain' or 'xml'-m : dump register maps (and nothing else)-t : temp file name (defaults to /sdcard/dex-temp-*)

Dalvik 指令

Dalvik 虚拟机一共包含两百多条指令。读者可以访问下面这个网址获取这些指令的详细信息:Dalvik 字节码


我们这里不会对每条指令做详细讲解,建议读者大致浏览一下上面这个网页。


下面以一个简单的例子来让读者对 Dalvik 指令有一个直观的认识。


下面是一个 Activity 的源码,在这个 Activity 中,我们定义了一个 sum 方法,进行两个整数的相加。然后在 Activity 的onCreate方法中,在setContentView之后,调用这个sum方法并传递 1 和 2,然后再将结果通过System.out.print进行输出。这段代码很简单,简单到几乎没有什么实际的作用,不过这不要紧,因为这里我们的目的仅仅想看一下我们编写的源码最终得到的 Dalvik 指令究竟是什么样的。


package test.android.com.helloandroid;


import android.app.Activity;import android.os.Bundle;


public class MainActivity extends Activity {


int sum(int a, int b) {return a + b;}


@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);setContentView(R.layout.activity_main);


System.out.print(sum(1,2));}}


将这个工程编译之后获得了 APK 文件。APK 文件其实是一种压缩格式,我们可以使用任何可以解压 Zip 格式的软件对其解压缩。解压缩之后的文件列表如下所示:


├── AndroidManifest.xml├── META-INF│ ├── CERT.RSA│ ├── CERT.SF│ └── MANIFEST.MF├── classes.dex├── res│ ├── layout│ │ └── activity_main.xml│ ├── mipmap-hdpi-v4│ │ ├── ic_launcher.png│ │ └── ic_launcher_round.png│ ├── mipmap-mdpi-v4│ │ ├── ic_launcher.png│ │ └── ic_launcher_round.png│ ├── mipmap-xhdpi-v4│ │ ├── ic_launcher.png│ │ └── ic_launcher_round.png│ ├── mipmap-xxhdpi-v4│ │ ├── ic_launcher.png│ │ └── ic_launcher_round.png│ └── mipmap-xxxhdpi-v4│ ├── ic_launcher.png│ └── ic_launcher_round.png└── resources.arsc


其他的文件不用在意,这里我们只要关注 dex 文件即可。我们可以通过adb push命令将 classes.dex 文件拷贝到手机上,然后通过手机上的dexdump命令来进行分析。


直接输入dexdump classes.dex会得到一个非常长的输出。下面是其中的一个片段:


...Class #40 -Class descriptor : 'Ltest/android/com/helloandroid/MainActivity;'Access flags : 0x0001 (PUBLIC)Superclass : 'Landroid/app/Activity;'Interfaces -Static fields -Instance fields -Direct methods -#0 : (in Ltest/android/com/helloandroid/MainActivity;)name : '<init>'type : '()V'access : 0x10001 (PUBLIC CONSTRUCTOR)code -registers : 1ins : 1outs : 1insns size : 4 16-bit code unitscatches : (none)positions :0x0000 line=6locals :0x0000 - 0x0004 reg=0 this Ltest/android/com/helloandroid/MainActivity;Virtual methods -#0 : (in Ltest/android/com/helloandroid/MainActivity;)name : 'onCreate'type : '(Landroid/os/Bundle;)V'access : 0x0004 (PROTECTED)code -registers : 5ins : 2outs : 3insns size : 20 16-bit code unitscatches : (none)positions :0x0000 line=140x0003 line=150x0008 line=170x0013 line=18locals :0x0000 - 0x0014 reg=3 this Ltest/android/com/helloandroid/MainActivity;0x0000 - 0x0014 reg=4 savedInstanceState Landroid/os/Bundle;#1 : (in Ltest/android/com/helloandroid/MainActivity;)name : 'sum'type : '(II)I'access : 0x0000 ()code -registers : 4ins : 3outs : 0insns size : 3 16-bit code unitscatches : (none)positions :0x0000 line=9locals :0x0000 - 0x0003 reg=1 this Ltest/android/com/helloandroid/MainActivity;0x0000 - 0x0003 reg=2 a I0x0000 - 0x0003 reg=3 b Isource_file_idx : 455 (MainActivity.java)...


从这个片段中,我们看到了刚刚编写的 MainActivity 类的详细信息。包括每一个方法的名称,签名,访问级别,使用的寄存器等信息。


接下来,我们通过dexdump -d classes.dex来反编译代码段,以查看方法实现逻辑所对应的 Dalvik 指令。


通过这个命令,我们得到 sum 方法的指令如下:


[019f98] test.android.com.helloandroid.MainActivity.sum:(II)I0000: add-int v0, v2, v3


为了看懂add-int指令的含义,我们可以查阅 Dalvik 指令的说明文档:



B: first source register or pair 8bitsC: second source register or pair 8bits | Perform the identified binary operationon the two source registers,storing the result in the destination register. |


这段说明文档的含义是:add-int是一个需要两个操作数的指令,其指令格式是:add-int vAA, vBB, vCC。其指令的运算过程,是将后面两个寄存器中的值进行(加)运算,然后将结果放在(第一个)目标寄存器中。


很显然,对应到add-int v0, v2, v3就是将 v2 和 v3 两个寄存器的值相加,并将结果存储到 v0 寄存器上。这正好是对应了我们所写的代码:return a + b;


下面,我们再看一下稍微复杂一点的onCreate方法其对应的 Dalvik 指令:


[019f60] test.android.com.helloandroid.MainActivity.onCreate:(Landroid/os/Bundle;)V0000: invoke-super {v3, v4}, Landroid/app/Activity;.onCreate:(Landroid/os/Bundle;)V // method@00010003: const/high16 v0, #int 2130903040 // #7f030005: invoke-virtual {v3, v0}, Ltest/android/com/helloandroid/MainActivity;.setContentView:(I)V // method@03180008: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@02e0000a: const/4 v1, #int 1 // #1000b: const/4 v2, #int 2 // #2000c: invoke-virtual {v3, v1, v2}, Ltest/android/com/helloandroid/MainActivity;.sum:(II)I // method@0319000f: move-result v10010: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.print:(I)V // method@02d70013: return-void


同样,通过查阅指令的说明文档,我们可以知道这里牵涉到的几条指令含义如下:


  • invoke-super: 调用父类中的方法

  • const/high16: 将指定的字面值的高 16 位拷贝到指定的寄存器中,这是一个 16bit 的操作

  • invoke-virtual: 调用一个 virtual 方法

  • sget-object: 获取类中 static 字段的对象,并存放到指定的寄存器上

  • const/4: 将指定的字面值拷贝到指定的寄存器中,这是一个 32bit 的操作

  • move-result: 该指令紧接着 invoke-xxx 指令,将上一条指令的结果移动到指定的寄存器中

  • return-void: void 方法返回


由此,我们便能看懂这段指令的含义了。甚至我们已经具备了阅读任何 Dalvik 代码的能力,因为无非就是明白每个指令的含义罢了。


单纯的阅读指令的说明文档可能很枯燥,也不容易记住。建议读者继续写一些复杂的代码然后通过反编译方式查看其对应的虚拟机指令来进行学习。或者对已有的项目进行反编译来查看其机器指令。也许一些读者觉得,开发者根本不必去阅读这些原本就不准备给人类阅读的机器指令。但实际上,对于底层指令越是熟悉,对底层机制越是了解,往往能让我们写出越是高效的程序来,因为一旦我们深刻理解机制背后的运行原理,就可以避过或者减少一些不必要的重复运算。再者,具备对于底层指令的理解能力,也为我们分析解决一些从源码层无法分析的问题提供了一个新的手段。


最后笔者想提醒一下,即便在 ART 虚拟机时代,这里学习的 Dalvik 指令和反编译手段仍然是没有过时的。因为这种分析方式是依然可用的。这也是为什么我们要讲解 Dalvik 虚拟机的原因。

Dalvik 启动过程

注:自 Android 5.0 开始,Dalvik 虚拟机已经被废弃,其源码也已经被从 AOSP 中删除。因此想要查看其源码,需要获取 Android 4.4 或之前版本的代码。本小节接下来贴出的源码取自 AOSP 代码 TAG android-4.4_r1


Dalvik 虚拟机的源码位于下面这个目录中:


/dalvik/vm/


在其他的文章(这里, 还有这里)中,我们讲解了系统的启动过程,并且也介绍了 zygote 进程。我们提到 zygote 进程会启动虚拟机,但是却没有深入了解过虚拟机是如何启动的,而这正是本文接下来要讲解的内容。


zygote 进程是由 app_process 启动的,我们来回顾一下 app_process main函数中的关键代码:


// app_process.cpp


int main(int argc, char* const argv[]){...if (zygote) {runtime.start("com.android.internal.os.ZygoteInit", args, zygote);} else if (className) {runtime.start("com.android.internal.os.RuntimeInit", args, zygote);} else {fprintf(stderr, "Error: no class name or --zygote supplied.\n");app_usage();LOG_ALWAYS_FATAL("app_process: no class name or --zygote supplied.");return 10;}}


这里通过runtime.start方法指定入口类启动了虚拟机。虚拟机在启动之后,会以入口类的 main 函数为起点来执行。


runtimeAppRuntime类的对象,start方法是在AppRuntime类的父类AndroidRuntime中定义的方法。该方法中的关键代码如下:


// AndroidRuntime.cpp


void AndroidRuntime::start(const char* className, const char* options){...


/* start the virtual machine /JniInvocation jni_invocation;jni_invocation.Init(NULL);JNIEnv env;if (startVm(&mJavaVM, &env) != 0) { ①return;}onVmCreated(env);


/*


  • Register android functions.*/if (startReg(env) < 0) { ②ALOGE("Unable to register all android natives\n");return;}


...


char* slashClassName = toSlashClassName(className);jclass startClass = env->FindClass(slashClassName); ③if (startClass == NULL) {ALOGE("JavaVM unable to locate class '%s'\n", slashClassName);/* keep going /} else {jmethodID startMeth = env->GetStaticMethodID(startClass, "main","([Ljava/lang/String;)V");if (startMeth == NULL) {ALOGE("JavaVM unable to find main() in '%s'\n", className);/ keep going */} else {env->CallStaticVoidMethod(startClass, startMeth, strArray); ④


#if 0if (env->ExceptionCheck())threadExitUncaughtException(env);#endif}}free(slashClassName);


ALOGD("Shutting down VM\n"); ⑤if (mJavaVM->DetachCurrentThread() != JNI_OK)ALOGW("Warning: unable to detach main thread\n");if (mJavaVM->DestroyJavaVM() != 0)ALOGW("Warning: VM did not shut down cleanly\n");}


这段代码主要逻辑如下:


  1. 通过startVm方法启动虚拟机

  2. 通过startReg方法注册 Android Framework 类相关的 JNI 方法

  3. 查找入口类的定义

  4. 调用入口类的main方法

  5. 处理虚拟机退出前执行的逻辑


接下来我们先看startVm方法的实现,然后再看startReg方法。


AndroidRuntime::startVm方法有三百多行代码。但其逻辑却很简单,因为这个方法中的绝大部分代码都是在确定虚拟机的启动参数的值。这些值主要来自于许多的系统属性,这个方法中读取的属性以及这些属性的含义如下表所示:



注:Android 系统中很多服务都有类似的做法,即:通过属性的方式将模块的配置参数外化。这样外部只要设置属性值即可以改变这些模块的内部行为。


这些属性的值会被读取并最终会被组装到initArgs中,并以此传递给JNI_CreateJavaVM函数来启动虚拟机:


// AndroidRuntime.cpp


if (JNI_CreateJavaVM(pJavaVM, pEnv, &initArgs) < 0) {ALOGE("JNI_CreateJavaVM failed\n");goto bail;}


JNI_CreateJavaVM函数是虚拟机实现的一部分,因此该方法代码已经位于 Dalvik 中。具体的在这个文件中:/dalvik/vm/Jni.pp。


JNI_CreateJavaVM方法中的关键代码如下所示:


// Jni.cpp


jint JNI_CreateJavaVM(JavaVM** p_vm, JNIEnv** p_env, void* vm_args) {const JavaVMInitArgs* args = (JavaVMInitArgs*) vm_args;...


memset(&gDvm, 0, sizeof(gDvm));


JavaVMExt* pVM = (JavaVMExt*) calloc(1, sizeof(JavaVMExt));pVM->funcTable = &gInvokeInterface;pVM->envList = NULL;dvmInitMutex(&pVM->envListLock);


UniquePtr<const char*[]> argv(new const char*[args->nOptions]);memset(argv.get(), 0, sizeof(char*) * (args->nOptions));


...


JNIEnvExt* pEnv = (JNIEnvExt*) dvmCreateJNIEnv(NULL);


gDvm.initializing = true;std::string status =dvmStartup(argc, argv.get(), args->ignoreUnrecognized, (JNIEnv*)pEnv);gDvm.initializing = false;


...


dvmChangeStatus(NULL, THREAD_NATIVE);p_env = (JNIEnv) pEnv;p_vm = (JavaVM) pVM;ALOGV("CreateJavaVM succeeded");return JNI_OK;}


在这个函数中,会读取启动的参数值,并将这些值设置到两个全局变量中,它们是:


// Init.cpp


struct DvmGlobals gDvm;struct DvmJniGlobals gDvmJni;


DvmGlobals这个结构体的定义非常之大,总计有约 700 行,其中存储了 Dalvik 虚拟机相关的全局属性,这些属性在虚拟机运行过程中会被用到。而gDvmJni中则记录了 Jni 相关的属性。


JNI_CreateJavaVM函数中最关键的就是调用dvmStartup函数。很显然,这个函数的含义是:Dalvik Startup。因此这个函数负责了 Dalvik 虚拟机的初始化工作,由于虚拟机本身也是有很多子模块和组件构成的,因此这个函数中调用了一系列的初始化方法来完成整个虚拟机的初始化工作,这其中包含:虚拟机堆的创建,内存分配跟踪器的创建,线程的启动,基本核心类加载等一系列工作,在这之后整个虚拟机就启动完成了。


这些方法是与 Dalvik 的实现细节紧密相关的,这里我们就不深入了,有兴趣的读者可以自行去学习。


虚拟机启动完成之后就可以用了。但对于 Android 系统来说,还有一些工作要做,那就是 Android Framework 相关类的 JNI 方法注册。我们知道,Android Framework 主要是 Java 语言实现的,但其中很多类都需要依赖于 native 实现,因此需要通过 JNI 将两种实现衔接起来。例如,在第 1 章我们讲解 Binder 机制中的 Parcel 类就是既有 Java 层接口也有 native 层的实现。除了 Parcel 类,还有其他类也是类似的。并且,Framework 中的类是几乎每个应用程序都可能会被用到的,为了减少每个应用程度单独加载的逻辑,因此虚拟机在启动之后直接就将这些类的 JNI 方法全部注册到虚拟机中了。完成这个逻辑的便是上面我们看到的startReg方法:


/*


  • Register android functions.*/if (startReg(env) < 0) {ALOGE("Unable to register all android natives\n");return;}


这个函数是在注册所有 Android Framework 中类的 JNI 方法,在 AndroidRuntime 类中,通过gRegJNI这个全局组数进行了记录了这些信息。这个数组包含了一百多个条目,下面是其中的一部分:


static const RegJNIRec gRegJNI[] = {REG_JNI(register_android_debug_JNITest),REG_JNI(register_com_android_internal_os_RuntimeInit),REG_JNI(register_android_os_SystemClock),REG_JNI(register_android_util_EventLog),REG_JNI(register_android_util_Log),REG_JNI(register_android_util_FloatMath),REG_JNI(register_android_text_format_Time),REG_JNI(register_android_content_AssetManager),REG_JNI(register_android_content_StringBlock),REG_JNI(register_android_content_XmlBlock),REG_JNI(register_android_emoji_EmojiFactory),REG_JNI(register_android_text_AndroidCharacter),REG_JNI(register_android_text_AndroidBidi),REG_JNI(register_android_view_InputDevice),REG_JNI(register_android_view_KeyCharacterMap),REG_JNI(register_android_os_Process),REG_JNI(register_android_os_SystemProperties),REG_JNI(register_android_os_Binder),REG_JNI(register_android_os_Parcel),...};


这个数组中的每一项包含了一个函数,每个函数由 Framework 中对应的类提供,负责该类的 JNI 函数注册。这其中就包含我们在第二章提到的 Binder 和 Parcel。


我们以 Parcel 为例来看一下:register_android_os_Parcel函数由 android_os_Parcel.cpp 提供,代码如下:


int register_android_os_Parcel(JNIEnv* env){jclass clazz;


clazz = env->FindClass(kParcelPathName);LOG_FATAL_IF(clazz == NULL, "Unable to find class android.os.Parcel");


gParcelOffsets.clazz = (jclass) env->NewGlobalRef(clazz);gParcelOffsets.mNativePtr = env->GetFieldID(clazz, "mNativePtr", "I");gParcelOffsets.obtain = env->GetStaticMethodID(clazz, "obtain","()Landroid/os/Parcel;");gParcelOffsets.recycle = env->GetMethodID(clazz, "recycle", "()V");


return AndroidRuntime::registerNativeMethods(env, kParcelPathName,gParcelMethods, NELEM(gParcelMethods));}


这段代码的最后是调用AndroidRuntime::registerNativeMethods对每个 JNI 方法进行注册,gParcelMethods包含了 Parcel 类中的所有 JNI 方法列表,下面是其中一部分:


static const JNINativeMethod gParcelMethods[] = {{"nativeDataSize", "(I)I", (void*)android_os_Parcel_dataSize},{"nativeDataAvail", "(I)I", (void*)android_os_Parcel_dataAvail},{"nativeDataPosition", "(I)I", (void*)android_os_Parcel_dataPosition},{"nativeDataCapacity", "(I)I", (void*)android_os_Parcel_dataCapacity},{"nativeSetDataSize", "(II)V", (void*)android_os_Parcel_setDataSize},{"nativeSetDataPosition", "(II)V", (void*)android_os_Parcel_setDataPosition},{"nativeSetDataCapacity", "(II)V", (void*)android_os_Parcel_setDataCapacity},...}


总结起来这里的逻辑就是:


  • Android Framework 中每个包含了 JNI 方法的类负责提供一个register_xxx方法,这个方法负责该类中所有 JNI 方法的注册

  • 类中的所有 JNI 方法通过一个二维数组记录

  • gRegJNI中罗列了所有 Framework 层类提供的register_xxx函数指针,并以此指针来完成调用,以使得整个 JNI 注册过程完成


至此,Dalvik 虚拟机的启动过程我们就讲解完了,下图描述了完整的 Dalvik 虚拟机启动过程:


程序的执行:解释与编译

程序员通过源码的形式编写程序,而机器只能认识机器码。从编写完的程序到在机器上运行,中间必须经过一个转换的过程。这个转换的过程由两种做法,那就是:解释编译

评论

发布
暂无评论
深入理解JAVA虚拟机原理之Dalvik虚拟机(三)