Android编译系统简要介绍和学习计划

2278次阅读  |  发布于5年以前

Android编译系统简要介绍和学习计划

在Android源码环境中,我们开发好一个模块后,再写一个Android.mk文件,就可通过m/mm/mmm/make等命令进行编译。此外,通过make命令还可制作各种系统镜像文件,例如system.img、boot.img和recovery.img等。这一切都得益于Android编译系统,它为我们处理了各种依赖关系,以及提供各种有用工具。本文对Android编译系统进行简单介绍以及制定学习计划。

在研究Android编译系统之前,我们首先需要了解Linux系统的make命令。在Linux系统中,我们可以通过make命令来编译代码。Make命令在执行的时候,默认会在当前目录找到一个Makefile文件,然后根据Makefile文件中的指令来对代码进行编译。也就是说,make命令执行的是Makefile文件中的指令。Makefile文件中的指令可以是编译命令,例如gcc,也可以是其它命令,例如Linux系统中的shell命令cp、rm等等。理解这一点非常重要,因为虽然通常我们说make命令是可以编译代码的,但是它实际上可以做任何事情。

看到这里,有的小伙伴可能会说,在Linux系统中,直接通过shell命令也可以做很多事情啊,它和make命令有什么区别呢?通过前面的介绍可以知道,make命令事实也是通过shell命令来完成任务的,但是它的神奇之处是可以帮我们处理好文件之间的依赖关系。我们通常都有会这样的一个需求,假设有一个文件T,它依赖于另外一个文件D,要求只有当文件D的内容发生变化,才重新生成文件T。这种需求在编译系统中表现得尤其典型,当一个.c文件include的.h文件发生变化时,需要重新编译该*.c文件,或者当一个模块A所引用的模块B发生变化时,重新编译模块B。正是由于编译系统中存在这种典型的文件依赖需求,而make命令又是专门用来解决这种文件依赖问题的,因此我们通常认为make命令是用来编译代码的。

Make命令是怎么知道两个文件之间存在依赖关系,以及当被依赖文件发生变化时如何处理目标文件的呢?答案就在前面提到的Makefile文件。Makefile文件实际上是一个脚本文件,就像普通的shell脚本文件一样,只不过它遵循的是Makefile语法。Makefile文件最基础的功能就是描述文件之间的依赖关系,以及怎么处理这些依赖关系。例如,假设有一个目录文件target,它依赖于文件dependency,并且当文件dependency发生变化时,需要通过command命令来重新生成文件T,这时候我们就可以在Makefile编写以下语句:

target: dependency
    <tab>command -o target -i dependency

我们假设命令command的-o选项指定的是输出文件,而-i选项指定的是输入文件。此外,命令command必须是另起一行,并且以tab键开头。

这就是最基础也是最主要的Makefile文件语法。当然,Makefile文件还有很多其它的语法,这里不可能一一描述。老罗推荐一本书《GNU make中文手册》,里面非常详细地介绍了make以及Makefile文件语法。

通常我们在一个源代码工程中,包含有非常多模块,模块之间保持相对独立。我们说相对独立,是指模块之间只在接口上存在依赖,但是在实现上是相全独立的。例如,我们有一个SO模块A,它引用了另一个SO模块B的一个导出函数F。只要函数F的原型不变,那么模块B就可以独立于模块A来实现,而模块A只需要一个包含有函数F原型声明的头文件即可对模块B进行引用。在一个源代码工程中,使得模块之间保持上述的相对独立非常重要,否则的话,当模块多了之后,就会很容易造成混乱。

当一个源代码工程被划分成很多个相对独立的模块之后,我们就很直觉地想到,为每一个模块都编译写一个Makefile文件,使得它们可以独立编译,然后再在工程的根目录下编写一个Makefile文件来递归执行这些Makefile文件。事实上,这种做法是错误的。正确的做法无论工程有多少个模块,都应该只有一个Makefile文件。注意,即使整个工程只有一个Makefile文件,但是我们仍然是要求对工程进行模块划分,并且需要保持这些模块之间的相对独立性,否则的话,就会同样引发混乱。在整个工程只有一个Makefile文件的情况下,我们要求工程的各个模块拥有自己的Makefile片段。这些Makefile片段统统直接或者间接地通过Makefile语法中的include指令包含在工程的Makefile文件中,然后再进行编译。

为什么我们要求整个工程只有一个Makefile文件,而不是工程的每一个模块都拥有一个Makefile文件呢?直觉告诉我们,每一个模块都拥有一个Makefile文件就会显得更加独立。事实的确如此,每一个模块都拥有自己的Makefile文件可以使得它更加独立。但是这种方法在处理模块之间的依赖关系时会显得力不从心,导致make命令要么是做得太少(do too little),要么是做得太多(do too much)。Make命令做得太少意味着编译结果不正确,而做得太多意味着编译速度慢。注意,出现这种情况并不是make命令本身的设计有问题,而是因为我们给了它不好的输入,就是所谓的"垃圾进,垃圾出"(Garbage In, Garbage Out)。

早在1998年的时候,Peter Miller就发现了为工程的每一个模块都编写一个Makefile文件的种种坏处,并且发表有一篇论文Recursive Make Considered Harmful。有兴趣的小伙伴可以读一下,写得非常好。这里我们简单从这篇论文摘录一下为什么Recursive Make会导致"do too little"和"do too much"。

假设我们有一个工程,它的目录结构如下所示:

Project
      ----Makefile
      ----ant
           ----Makefile
           ----main.c
      ----bee
           ----Makefile
           ----parse.c
           ----parse.h

顶层目录的Makefile的内容如下所示:

MODULES = ant bee
    all:
        for dir in $(MODULES); do \
            (cd $$dir; $(MAKE) all); \
        done

ant目录下的Makefile的内容如下所示:

all: main.o
    main.o: main.c ../bee/parse.h
        $(CC) -I../bee -c main.c

通过图1的无回路有向图(DAG)可以清楚看到ant目录的Makefile所描述的文件依赖关系:

图1 ant模块的文件依赖关系图

bee目录下的Makefile的内容如下所示:

OBJ = ../ant/main.o parse.o
    all: prog
    prog: $(OBJ)
        $(CC) -o $@ $(OBJ)
    parse.o: parser.c parse.h
        $(CC) -c parse.c

通过图2的无回路有向图(DAG)可以清楚看到bee目录的Makefile所描述的文件依赖关系:

图2 bee模块的文件依赖关系图

到目前为止,一切都正常,我们在工程根目录下执行make命令,就可以递归对ant和bee模块进行编译,并且最终生成可执行文件prog。

考虑一个情景,bee目录的parse.h和parse.c文件是通过yacc工具自动生成的,也就是我们在bee/Makefile文件增加以下内容来生成parse.h和parse.c文件:

parse.c parse.h: parse.y
        $(YACC) -d parse.y
        mv y.tab.c parse.c
        mv y.tab.h parse.h

这时候bee模块的文件依赖关系图如图3所示:

图3 修改后的bee模块文件依赖关系图

这时候如果我们修改了parse.y文件,那么在工程根目录执行make命令时,先会对ant模块进行编译,然后再对bee模块进行编译。由于对ant模块进行编译时,parse.h文件的内容还没有被修改,因此该文件就认为是最新的,于是就不会重新生成main.o文件。接下来对bee模块进行编译时,就会重新生成parse.h和parse.c文件,并且重新生成parse.o文件。很遗憾,在重新生成prog文件时,使用的是过旧的main.o文件,于是就会得到不正确的prog文件。

产生这个问题的原因就在于ant模块缺乏对parse.h文件的掌控,也就是不知道parse.h文件是如何产生的。如果我们坚持使用这种递归Makefile的方法来编译工程,并且希望解决上述问题,那么有三种方法:

1. 修改工程目录的Makefile,使得它先对bee模块进行编译,再对ant模块进行编译。然而,这就相当于是要求我们需要明确地指定工程的每一个模块的编译顺序。在一个包含有非常多模块的工程里面,要确定每一个模块的编译顺序是相当困难的。而且当我们新增、修改或者删除模块之后,又需要重新确定各个模块的编译顺序。理想的做法是让make自动地帮我们决定各个模块的编译顺序,这也是我们使用make命令来编译工程的初衷。

2. 对工程的各个模块进行重复编译,也就是将工程目录的Makefile修改为以下的内容:

MODULES = ant bee
    all:
        for dir in $(MODULES); do \
            (cd $$dir; $(MAKE) all); \
        done 
        for dir in $(MODULES); do \
            (cd $$dir; $(MAKE) all); \
        done

这里我们只对每一个模块进行了一次重复编译。然而,在一个复杂的工程中,有可能需要多次进行重复编译才能得到正确的编译结果。与前一种方法(do too little)相比,这里明显就是"do too much"。这会使得我们浪费CPU资源,并且拖慢整个工程的编译速度。

3. 修改ant/Makefile文件,增加如下所示的内容:

.PHONY: ../bee/parse.h
    ../bee/parse.h:
        cd ../bee; \
        make clean; \
        make all

这种方法在每次编译ant模块之前,都先对bee模块进行重新编译,无论它是否需要。与第2种方法相比,这种方法不用对工程的每一个模块都进行重新编译,但是它仍然是"do too much",因为不管bee模块是否需要重新编译,它都会被重新编译。

由此可见,上述三种方法,要么是"do too little",要么是"do too much",它们虽然都能解决问题,但都不是理想的解决方案。如果我们进一步分析问题的根源,就会发现工程的文件依赖关系是属于工程级别的,也就是它们属于一个整体,而当我们将它们划分成模块级别的时候,就会造成各个模块只看到一部分的文件依赖关系,因此就不能得到正确的编译结果。

为了保持工程文件依赖关系的整体性,我们就必须使得整个工程只存在一个Makefile。当整个工程只存在一个Makefile时,我们就可以很容易地构造出如图4所示的文件依赖图:

图4 完整的文件依赖关系图

整个工程只有一个Makefile,听起来似乎是一件很疯狂的事情,因为这个Makefile可能会变得无比庞大和复杂。其实不用担心,我们可以按照模块来将这个Makefile划分成一个个Makefile片段(fragement),然后通过Makefile的include指令来将这些Makefile片段组装在一个Makefile中。与递归Makefile相比,每一个模块现在拥有的是一个Makefile片段,而不是一个Makefile文件。这正是Android编译系统的设计思想和原则,也就是说,我们平时所编写的Android.mk编译脚本都只不过是整个Android编译系统的一个Makefile片段。

明白了Android编译系统的设计思想和原则之后,我们就可以通过图5来观察一下Android编译系统的整体架构了:

图5 Android编译系统架构

在使用Android编译系统之前,我们需要打开一个shell进入到Android源码根目录中,并且在该shell中将build/envsetup.sh脚本文件source进来。脚本文件build/envsetup.sh被source到当前shell的过程中,会在vendor和device两个目录将厂商指定的envsetup.sh也source到当前shell当中,这样就可以获得厂商提供的产品配置信息。此外,脚本文件build/envsetup.sh还提供了以下几个重要的命令来帮助我们编译Android源码:

1. lunch

用来初始化编译环境,例如设置环境变量和指定目标产品型号。Lunch命令在执行的时候,主要做两件事情。第一件事情是设置TARGET_PRODUCT、TARGET_BUILD_VARIANT、TARGET_BUILD_TYPE和TARGET_BUILD_APPS等环境变量,用来指定目标产品类型和编译类型。第二件事情是通过make命令执行build/core/config.mk脚本,并且通过加载另外一个脚本build/core/dumpvar.mk打印出当前的编译环境配置信息。注意,build/core/config.mk和build/core/dumpvar.mk均为Makefile脚本,因此它们可以通过make命令来执行。另外,build/core/config.mk脚本还会加载一个名称为BoradConfig.mk的脚本以及build/core/envsetup.mk脚本来配置目标产品型号的相关信息。

2. m

相当于是在执行make命令。对整个Android源码进行编译。

3. mm

如果是在Android源码根目录下执行,那么就相当于是执行make命令对整个源码进行编译。如果是在Android源码根目录下的某一个子目录执行,那么就在会在从该子目录开始,一直往上一个目录直至到根目录,寻找是否存在一个Android.mk文件。如果存在的话,那么就通过make命令对该Android.mk文件描述的模块进行编译。

4. mmm

后面可以跟一个或者若干个目录。如果指定了多个目录,那么目录之间以空格分隔,并且每一个目录下都必须存在一个Android,mk文件。如果没有在目录后面通过冒号指定模块名称,那么在Android.mk文件中描述的所有模块都会被编译,否则只有指定的模块会被编译。如果需要同时指定多个模块,那么这些模块名称必须以逗号分隔。它的语法如下所示:

mmm <dir-1> <dir-2> ... <dir-N>[:module-1,module-2,...,module-M]

该命令会通过make命令来执行Android源码根目录下的Makefile文件,该Makefile文件又会将build/core/main.mk加载进来。文件build/core/main.mk在加载的过程中,还会加载以下几个主要的文件:

(1). build/core/config.mk

该文件根据lunch命令所配置的产品信息在build/target/board、vendor或者device目录中找到对应的BoradConfig.mk文件,以及通过加载build/core/product_config.mk文件在build/target/product、vendor或者device目录中找到对应的AndroidProducts.mk文件,来进一步对编译环境进行配置,以便接下来编译指定模块时可以获得必要的信息。

(2). build/core/definitions.mk

该文件定义了在编译过程需要调用到的各种自定义函数。

(3). 指定的Android.mk

这些指定的Android.mk环境是由mmm命令通过环境变量ONE_SHOT_MAKEFILE传递给build/core/main.mk文件使用的。这些Android.mk文件一般还会通过环境变量BUILD_PACKAGE、BUILD_JAVA_LIBRARY、BUILD_STATIC_JAVA_LIBRARY、BUILD_SHARED_LIBRARY、BUILD_STATIC_LIBRARY、BUILD_EXECUTABLE和BUILD_PREBUILT将build/core/package.mk、build/core/java_library.mk、build/core/static_java_library.mk、build/core/shared_library.mk、build/core/static_library.mk、build/core/executable.mk和build/core/prebuilt.mk等编译片段模板文件加载进来,来表示要编译是APK、Java库、Linux动态库/静态库/可执行文件或者预先编译好的文件等等。

(4). build/core/Makefile

该文件包含了用来制作system.img、ramdisk.img、boot.img和recovery.img等镜像文件的脚本。

在接下来的一系列文章中,我们将按照以下情景来进一步分析Android的编译系统:

1. Android编译系统的环境初始化过程

2. 模块编译命令mmm的执行过程

3. Android镜像文件的制作过程

希望通过这三个情景的分析,使得我们对Android的编译系统有一个深刻的认识,以提高我们的Android系统开发效率,敬请关注!更多信息可以关注老罗的新浪微博:http://weibo.com/shengyangluo

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8