所在位置: 图书 -> 在线试读 -> Android内核剖析
第9章 Framework的启动过程
9.3 zygote的启动
前面小节介绍了Framework的运行环境,以及Dalvik虚拟机的相关启动方法,zygote进程是所有APK应用进程的父进程,接下来就详细介绍zygote进程的内部启动过程。
9.3.1 在init.rc中配置zygote启动参数
init.rc存在于设备的根目录下,读者可以使用adb pull /init.rc ~/Desktop命令取出该文件,文件中和zygote相关的配置信息如下:
service zygote /system/bin/app_process -Xzygote /system/bin --zygote --start-system-server
socket zygote stream 666
onrestart write /sys/android_power/request_state wake
onrestart write /sys/power/state on
onrestart restart media
onrestart restart netd
首先第一行中使用service指令告诉操作系统将zygote程序加入到系统服务中,service的语法如下:
service service_name 可执行程序的路径 可执行程序自身所需的参数列表
此处的服务被定义为zygote,理论上讲该服务的名称可以是任意的。可执行程序的路径正是/system/bin/app_process,也就是前面所讲的app_process,参数一共包含四个,分别如下:
— -Xzygote,该参数将作为虚拟机启动时所需要的参数,是在AndroidRuntime.cpp类的startVm()函数中调用JNI_CreateJavaVM()时被使用的。
— /system/bin,代表虚拟机程序所在目录,因为app_process完全可以不和虚拟机在同一个目录,而在app_process内部的AndroidRuntime类内部需要知道虚拟机所在的目录。
— --zygote,指明以ZygoteInit类作为虚拟机执行的入口,如果没有--zygote参数,则需要明确指定需要执行的类名。
— --start-system-server,仅在指定--zygote参数时才有效,意思是告知ZygoteInit启动完毕后孵化出第一个进程SystemServer。
接下来的配置命令socket用于指定该服务所使用到的socket,后面的参数依次是名称、类型、端口地址。
onrestart命令指定该服务重启的条件,即当满足这些条件后,zygote服务就需要重启,这些条件一般是一些系统异常条件。
9.3.2 启动Socket服务端口
当zygote服务从app_process开始启动后,会启动一个Dalvik虚拟机,而虚拟机执行的第一个Java类就是ZygoteInit.java,因此接下来的过程就从ZygoteInit类的main()函数开始说起。main()函数中做的第一个重要工作就是启动一个Socket服务端口,该Socket端口用于接收启动新进程的命令。
启动Socket服务端口是在静态函数registerZygoteSocket()中完成的,如以下代码所示:
private static void registerZygoteSocket() {
if (sServerSocket == null) {
int fileDesc;
try {
String env = System.getenv(ANDROID_SOCKET_ENV);
fileDesc = Integer.parseInt(env);
... ...
try {
sServerSocket = new LocalServerSocket(
createFileDescriptor(fileDesc));
... ...
}
}
在该段代码中,首先调用System.getenv()获取系统为zygote进程分配的Socket文件描述符号,然后再调用createFileDescriptor()创建一个真正的文件描述符,最后以该描述符为参数,构造了一个LocalServerSocket对象。
关于Socket编程的基础知识,读者可以参考《UNIX Networking Programming》一书,作者为W.Richard Stevens。这里要说明的是,在Linux系统中,所有的系统资源都可以看成是文件,甚至包括内存和CPU,因此,像标准的磁盘文件或者网络Socket自然也被认为是文件,这就是为什么LocalServerSocket构造函数的参数是一个文件描述符。
Socket编程中有两种方式去触发Socket数据读操作。一种是使用listen()监听某个端口,然后调用read()去从这个端口上读数据,这种方式被称为阻塞式读操作,因为当端口没有数据时,read()函数将一直等待,直到数据准备好后才返回;另一种是使用select()函数将需要监测的文件描述符作为select()函数的参数,然后当该文件描述符上出现新的数据后,自动触发一个中断,然后在中断处理函数中再去读指定文件描述符上的数据,这种方式被称为非阻塞式读操作。LocalServerSocket中使用的正是后者,即非阻塞读操作。
当LocalServerSocket端口准备好后,main()函数中调用runSelectLoopMode()进入非阻塞读操作,该函数中首先将sServerSocket加入到被监测的文件描述符列表中,如以下代码所示:
然后在while(true)循环中将该文件描述符添加到select的列表中,并调用ZygoteConnection类的runOnce()函数处理每一个Socket接收到的命令,如以下代码所示:
try {
fdArray = fds.toArray(fdArray);
index = selectReadable(fdArray);
} catch (IOException ex) {
throw new RuntimeException("Error in select()", ex);
}
if (index < 0) {
throw new RuntimeException("Error in select()");
} else if (index == 0) {
ZygoteConnection newPeer = acceptCommandPeer();
peers.add(newPeer);
fds.add(newPeer.getFileDesciptor());
} else {
boolean done;
done = peers.get(index).runOnce();
if (done) {
peers.remove(index);
fds.remove(index);
}
}
selectReadable()函数的返回值有三种。一种是-1,代表着内部错误;第二种是0,代表着没有可处理的连接,因此会以Socket服务端口重新建立一个ZygoteConnection对象,并等待客户端的请求;第三种是大于0,代表着还有没有处理完的连接请求,因此需要先处理该请求,而暂时不需要建立新的连接等待。
runOnce()函数的核心代码是基于zygote进程孵化出新的应用进程,如以下代码所示:
关于folk的概念将在后面小节中介绍。
以上介绍的是Socket的服务端,而在SystemServer进程中则会创建一个Socket客户端,具体的实现代码是在Process.java类中,而调用Process类是在AmS类中的startProcessLocked()函数中,如以下代码所示,关于该函数的调用实际参照第10章AmS原理。
而start()函数内部又调用了静态函数startViaZygote(),该函数的实体正是使用一个本地Socket向zygote中的Socket发送进行启动命令,其执行流程如图9-2所示。
图9-2 startViaZygote()函数的执行过程
该流程的主要过程就是将startViaZygote()的函数参数转换为一个ArrayList<String>列表,然后再构造出一个LocalSocket本地Socket接口,并通过该LocalSocket对象构造出一个BufferedWriter对象,最后通过该对象将ArralyList<String>列表中的参数传递给zygote中的LocalServerSocket对象,而在zygote端,就会调用Zygote.forkAndSpecialize()函数孵化出一个新的应用进程。
9.3.3 加载preload-classes
在ZygoteInit类的main()函数中,创建完Socket服务端后还不能立即孵化新的进程,因为这个“卵”中还没有必须“核酸”,这个“核酸”就是指预装的Framework大部分类及资源。
预装的类列表是在framework.jar中的一个文本文件列表,名称为preload-classes,该列表的原始定义在frameworks/base/preload-classes文本文件中,而该文件又是通过frameworks/base/tools/preload/
WritePreloadedClassFile.java类生成的。产生preload-classes的方法是在Android根目录下执行以下命令:
$java -Xss512M -cp /path/to/preload.jar WritePreloadedClassFile /path/to/.compiled
1517 classses were loaded by more than one app.
Added 147 more to speed up applications.
1664 total classes will be preloaded.
Writing object model...
Done!
在该命令中,/path/to/preload.jar是指out/host/darwin-x86/framework/preload.jar,该Jar是由frameworks/base/tools/preload子项目编译而成的。
/path/to/.compiled/是指frameworks/base/tools/preload目录下的那几个.compiled文件。
参数-Xss用于执行该程序所需要的Java虚拟机栈大小,此处使用512MB,默认的大小不能满足该程序的运行,会抛出java.lang.StackOverflowError错误信息。
WritePreloadedClassFile是要执行的具体类。
执行完以上命令后,会在frameworks/base目录下产生preload-classes文本文件。从该命令的执行情况来看,预装的Java类信息包含在.compiled文件中,而这个文件却是一个二进制文件,尽管我们目前能够确知如何产生preload-classes,但却无法明确这个.compiled文件是如何产生的,一个可能的假设如下:
在Android项目组内部可能会存在一个测试项目,该项目一旦运行,就会装载一些Java类。当然,这些Java类是测试项目中的程序代码主动装载的,而这些程序代码被认为是大多数Android程序运行时都会执行的代码。一旦该运行环境建立后,Dalvik虚拟机内存中就记录了所有被装载的Java类,然后该测试项目会使用一个特别的工具从虚拟机内存中读取所有装载过的类信息,并生成compiled文件。当然,这只是一种假设。
在Android源码编译的时候,会最终把preload-classes文件打包到framework.jar中,关于其详细过程参见本书Android源码编译的相关章节。
有了这个列表后,ZygoteInit中通过调用preloadClasses()完成装载这些类。装载的方法很简单,就是读取preload-classes列表中的每一行,因为每一行代表了一个具体的类,然后调用Class.forName()装载目标类,如以下代码所示。在装载的过程中,忽略以#开始的目标类,并忽略换行符及空格。
9.3.4 加载preload-resources
preload-resources是在frameworks/base/core/res/res/values/arrays.xml中被定义的,包含两类资源,一类是drawable资源,另一类是color资源,如以下代码所示:
<array name="preloaded_drawables">
<item>@drawable/sym_def_app_icon</item>
... ...
</array>
<array name="preloaded_color_state_lists">
<item>@color/hint_foreground_dark</item>
... ...
</array>
而加载这些资源是在preloadResources()函数中完成的,该函数中分别调用preloadDrawables()和preloadColorStateLists()加载这两类资源。加载的原理很简单,就是把这些资源读出来放到一个全局变量中,只要该类对象不被销毁,这些全局变量就会一直保存。
保存Drawable资源的全局变量是mResources,该变量的类型是Resources类,由于该类内部会保存一个Drawable资源列表,因此,实际上缓存这些Drawable资源是在Resources内部;保存Color资源的全局变量也是mResources,同样,Resources类内部也有一个Color资源的列表。
关于Resources内部如何保存这些资源,请参照资源访问章节。
9.3.5 使用folk启动新的进程
folk是Linux系统的一个系统调用,其作用是复制当前进程,产生一个新的进程。新进程将拥有和原始进程完全相同的进程信息,除了进程id不同。进程信息包括该进程所打开的文件描述符列表、所分配的内存等。当新进程被创建后,两个进程将共享已经分配的内存空间,直到其中一个需要向内存中写入数据时,操作系统才负责复制一份目标地址空间,并将要写的数据写入到新的地址中,这就是所谓的copy-on-write机制,即“仅当写的时候才复制”,这种机制可以最大限度地在多个进程中共享物理内存。
第一次接触folk的读者可能觉得奇怪,为什么要复制进程呢?在大家熟悉的Windows操作系统中,一个应用程序一般对应一个进程,如果说要复制进程,可能的结果就是从计算器程序复制出一个Office程序,这听起来似乎很不合理。要立即复制进程就需要首先了解进程的启动过程。
在所有的操作系统中,都存在一个程序装载器,程序装载器一般会作为操作系统的一部分,并由所谓的Shell程序调用。当内核启动后,Shell程序会首先启动。常见的Shell程序包含两大类,一类是命令行界面,另一类是窗口界面,Windows系统中Shell程序就是桌面程序,Ubuntu系统中的Shell程序就是GNOME桌面程序。Shell程序启动后,用户可以双击桌面图标启动指定的应用程序,而在操作系统内部,启动新的进程包含三个过程。
第一个过程,内核创建一个进程数据结构,用于表示将要启动的进程。
第二个过程,内核调用程序装载器函数,从指定的程序文件读取程序代码,并将这些程序代码装载到预先设定的内存地址。
第三个过程,装载完毕后,内核将程序指针指向到目标程序地址的入口处开始执行指定的进程。当然,实际的过程会考虑更多的细节,不过大致思路就是这么简单。
在一般情况下,没有必要复制进程,而是按照以上三个过程创建新进程,但当满足以下条件时,则建议使用复制进程:即两个进程中共享了大量的程序。
举个例子,去澳大利亚看袋鼠和去澳大利亚看考拉,这是两个进程,但完成这两个进程的大多数任务都是相同的,即先订机票,然后带照相机,再坐地铁到首都机场,最后再坐14个小时的飞机到澳大利亚,到了之后唯一不同就是看考拉和袋鼠。为了更有效地完成这两个任务,可以先雇佣一个精灵进程,让它订机票、带相机、坐地铁、乘飞机,一直到澳大利亚后,从这个精灵进程中复制出两个进程,一个去看考拉,另一个去看袋鼠。如果你愿意,还可以去悉尼歌剧院,这就是进程的复制,其好处是节省了大量共享的内存。
由于folk()函数是Linux的系统调用,Android中的Java层仅仅是对该调用进行了JNI封装而已,因此,接下来以一段C代码来介绍folk()函数的使用,以便大家对该函数有更具体的认识。
/**
*FileName: MyFolk.c
*/
#include <sys/types.h>
#include <unistd.h>
int main(){
pid_t pid;
printf("pid = %d, Take camera, by subway, take air!
", getpid());
pid = folk();
if(pid > 0){
printf("pid=%d, 我是精灵!
", getpid());
pid = folk();
if(!pid) printf("pid=%d, 去看考拉!
", getpid());
}
else if (!pid) printf("pid=%d, 去看袋鼠!
", getpid());
else if (pid == -1) perror("folk");
getchar();
}
以上代码的执行结果如下:
$ ./MyFolk.bin
pid = 3927, Take camera, by subway, take air!
pid=3927, 我是精灵!
pid=3929, 去看袋鼠!
pid=3930, 去看考拉!
folk()函数的返回值与普通函数调用完全不同。当返回值大于0时,代表的是父进程;当等于0时,代表的是被复制的进程。换句话说,父进程和子进程的代码都在该C文件中,只是不同的进程执行不同的代码,而进程是靠folk()的返回值进行区分的。
由以上执行结果可以看出,第一次调用folk()时复制了一个“看袋鼠”进程,然后在父进程中再次调用folk()复制了“看考拉”的进程,三者都有各自不同的进程id。
zygote进程就是本例中的“精灵进程”,那些“拿相机、坐地铁、乘飞机”的操作就是zygote进程中加载的preload-classes类具备的功能。
ZygoteInit.java中复制新进程是通过在runSelectLoopMode()函数中调用ZygoteConnection类的runOnce()函数完成的,而该函数中则调用了以下代码用于复制一个新的进程。
forkAndSpecialize()函数是一个native函数,其内部的执行原理和上面的C代码类似。
当新进程被创建好后,还需要做一些“善后”工作。因为当zygote复制新进程时,已经创建了一个Socket服务端,而这个服务端是不应该被新进程使用的,否则系统中会有多个进程接收Socket客户端的命令。因此,新进程被创建好后,首先需要在新进程中关闭该Socket服务端,并调用新进程中指定的Class文件的main()函数作为新进程的入口点。而这些正是在调用forkAndSpecialize()函数后根据返回值pid完成的,如以下代码所示:
pid等于0时,代表的是子进程,handleChildProc()函数中的关键代码如下,首先是关于Socket服务端。
接着从指定Class文件的main()函数处开始执行,如以下代码所示:
至此,新进程就完全脱离了zygote进程的孵化过程,成为一个真正的应用进程。