编译,就是将高级语言转换成机器语言。譬如,通过gcc将C语言编译成可以运行的二进制;通过javac将Java语言编译成可以在Java虚拟机上可以运行的字节码。
对于简单的项目,源文件数量较少,通常只需要几条命令,组织一下源文件,调用一下编译器,生成一个可以运行的文件,就算是一个“编译系统”; 但对于大型的项目,文件数量很多,通常会被组织成众多的模块,模块之间构成依赖关系,这就不是简单几条命令就能够成为“编译系统”了。一个大型项目的编译系统,需要管理好各个模块的依赖关系,组织好大量的编译中间产物,具备随时应对模块变更的扩展性,同时,也能够高效的完成编译任务。
在Linux上,一些传统大型项目的编译系统都是基于make这个工具,make并不是编译器,仍然需要调用gcc或javac等编译命令来对源码进行编译,make的输入是一个Makefile文本文件,make只是按照Makefile文件中定义的规则来完成工作。所以,这些编译系统中最核心的就是Makefile定义的编译规则和编译顺序,包括:哪些源文件需要编译、如何编译、哪些文件存在对其他文件的依赖,优先编译哪些文件。同其他编程语言一样,Makefile也有自己的语法,当正确书写完Makefile文件后,通过一个make命令,就能自动完成大型项目的编译。
Android编译系统也是基于make的,要编译出整个Android系统的镜像文件并打包成刷机包(OTA Package),编译出SDK和文档(JavaDoc),同时,Android引入了很多第三方开源项目,需要兼顾不同模块的编译,仅仅是这些就已经够让Android编译系统受苦了,还别提支持不同设备厂商的硬件类型,方便设备厂商进行定制这些兼容性、扩展性、编译效率的问题。Android编译系统的复杂度可见一斑,纵观整个编译,除了大量的编译规则文件(Makefile文件片段),还有很多Shell和Python脚本组织在一起,基于make,但又不同于已有传统项目的编译系统,Android有自己的一套编译机制。
本文会分析以下问题:
Android编译系统的设计背景是什么? 这涉及到Android编译系统设计的意图。
Android编译系统的设计原理是什么? 这涉及到Android编译系统运转的内在机制。
Makefile文件的书写规则,不是本文要讨论的内容,但是Makefile规则的基本形式还是有必要在这里点出来,作为后续分析的理论基础。
# 规则语法 # 示例
TARGET... : PREREQUISITES... main.o: main.c main.h
COMMAND 1 gcc -c main.c
... ...
COMMAND N echo "Compile finished"
复杂的Makefile规则,都是由若干基本的规则组合而成。最终,Makefile会被解析成一个有向无环图(Directed Acyclic Graph, DAG), 每一个目标(Target)构成DAG的节点,每一个依赖关系(Dependency)构成DAG的边。
同所有基于make的编译系统一样,Android也需要Makefile文件来定义编译规则和编译顺序。但与大多数编译系统不同的是,Android并不是Recursive Make的,那么什么是Resursive Make呢?
当在Makefile文件中,使用make命令来编译另一个模块时,就构成了Resurvise Make。
对于一个大型项目而言,可以为每个子系统构建一个Makefile文件,然后在最顶级的Makefile文件中,调用make命令来编译各个子系统:
subsystem:
cd subdir && make
Resurvise Make的设计能够降低编译的复杂度,更容易理解和维护,很多Linux上的大型项目,包括Linux内核的编译系统,都是采用的Resurvise Make设计,但Android并没有采用,因为这种设计存在很多缺陷。早在1997年,Peter Miller就在Recursive Make Considered Harmful这篇论文中,指出了Resurvise Make会导致编译系统“做得太少(do too little)”或“做得太多(do too much)”, “do too little”会导致最终编译产物可能是错误的结果,而“do too much”会导致编译效率下降。
TODO:补充Resurvise Make缺陷的示例
由于Resurvise Make的缺陷,Android采用了Non-Resursive Make的方式,将所有的编译规则集中于一个Makefile中,可以想象最终成型的这个Makefile是极其庞大的。 为了提升编译效率和灵活性,需要对模块的编译进行控制:单个模块可以单独进行编译,不需要的模块不会被重新编译,以便节省编译时间。
在上述意图的驱使下,Android编译系统有以下主要的要求 (更详细的要求,请查阅build/core/build-system.html ):
编译出多种目标: 除了最终Android系统的产物(譬如:system.img, boot.img),编译系统还要能够编译出很多实用的工具(譬如:aapt, adb),这些工具不仅是编译环境需要的,也是开发者需要的。
支持多平台: Android需要在Linux和Mac上进行编译,编译产出也需要支持Linux和Mac。编译系统对Windows的支持并不好,但面向开发者的SDK是支持Windows的。
Makefile片段化:最终的规则集中于一个Makefile,并不意味着编译系统会维护这么大一个Makefile。为了提高代码的重复利用率,编译系统包含很多Makefile片段,最终通过include将片段包含进来。 每一个待编译的模块都会有一个Android.mk,其内容也是Makefile片段。
自动构建依赖: 模块之间依赖关系是自动构建的,这意味着,我们只需要使用编译系统提供的接口,定义一个模块的编译规则,不需要关心编译系统如何管理众多模块之间的依赖关系。
至此,我们分析了Android编译系统重新设计的背景,主要是为了避免Resursive Make存在的缺陷,同时应对多模块多平台的高效率编译需求。
在Android源码的根目录有一个Makefile文件,有效内容只有一行:
include build/core/main.mk
所有的编译规则都定义在build/core/main.mk文件中,最终所有的Makefile片段都将汇聚在这一个文件中。
Android编译系统如此强大,要变成Makefile的最终形态,当然是要经过很长一段路的,下图是整个编译系统的框架:
我们会基于这个图来分析整个Android编译系统的设计原理:
从Android源码来看,编译系统的核心功能位于build/core/目录, 在device和vendor目录下,存放了与具体机型相关的配置,这些配置信息都是.mk文件的形式存放的(譬如BoardConfig.mk和AndroidProducts.mk), 另外,每一个模块的编译配置信息都是以独立的Android.mk文件的形式分散在各个模块的子目录中。
编译系统需要经过初始化(setup)来完成必要的参数赋值。初始化的操作命令很简单,但实际要配置的参数是非常多的,Android支持不同平台上不同产品,甚至是不同模块的编译, 也支持编译SDK以及PC上一些常用的工具,编译系统通过配置信息的指导,来完成具体的编译任务。
每一次的编译任务,就是给make一个Makefile文件,所以,每次编译任务,编译系统就会经过汇集众多零散Makefile片段的过程。编译整个工程和编译一个模块,最终 汇集成的Makefile是不一样的,这样就能做到灵活对各个模块进行编译。
Android将编译产物都放到了out/目录下,out/目录下又有host/和target/两个子目录,分别表示PC上和手机上的编译产物。
接下来,我们就来进一步分析编译系统的设计细节。
要使Android编译系统运转起来,首先需要经过初始化,其实就是完成所有参数的配置。Android编译系统的配置,可以分为四个层级,从下到上依次是:结构级(Architecture),芯片级(Board),产品级(Product),模块级(Module)
芯片级(Architecture):涵盖CPU体系结构和指令集的配置,譬如arm, x86, mips
平台级(Board):这个层级的配置通常定义在BoardConfig.mk,包括内核、驱动、CPU等与硬件紧密相关的
产品级(Product): 这个层级的配置通常定义在AndroidProducts.mk,包括产品名称、需要包含哪些模块和文件等
模块级(Module): 这个层级的配置都是由Android.mk定义的,模块具体的一些配置,包括模块名称、模块类型、对其他模块的依赖等。 要知道Android一共有多少个模块,可以在AOSP的源码根目录下执行以下命令:
$ find . -name Android.mk | wc -l # 笔者在Android 5.0.1下执行这个命令,得到的结果是3868,汗!
这几个层级的配置并非独立的,而是在不同参数配置下相互影响的。Android提供两种方式来进行参数配置:运行envsetup.sh或配置buildspec.mk
运行envsetup.sh
Android提供了一个环境初始化的脚本build/envsetup.sh, 通过source命令,便可以将该脚本添加到shell环境中。接着,便发现多了一个lunch命令,我们就是通过这个命令来配置Android初始化的参数。 除了lunch,还会有很多其他命令,譬如: m, mm, mmm,我们会在编译系统(2)-初始化过程这篇文章中来详细介绍envsetup.sh的工作过程。
$ source build/envsetup.sh # 将envsetup.sh添加到shell执行环境中
$ lunch # 通过lunch来交互式的完成参数配置
配置buildspec.mk
该文件需要置于Android源码的根目录,Android提供一个配置模板build/buildspec.mk.default, 只需要将拷贝到根目录,重命名后,根据需要修改文件内容便可完成参数的配置。
注:支持这种文件配置的方式来完成初始化,是考虑到有些固定的编译场景,不需要每次都重复运行envsetup.sh脚本来配置相同的参数。
无论是采用哪种方式,都会涉及到以下几个重要的参数:
TARGET_PRODUCT:目标产品。这个参数的取值来自于一个具体产品的定义,通常位于device/[manufacture]/[product]下的AndroidProducts.mk文件中, 通过PRODUCT_MAKEFILES这个属性来汇集所有产品级别的配置,包括产品名称PRODUCT_NAME,产品品牌PRODUCT_BRAND等。产品名称PRODUCT_NAME实际上就对应到了TARGET_PRODUCT。
Android 5.0.1提供了一些默认的目标产品:aosp_arm、aosp_arm64、aosp_mips、aosp_mips64、aosp_x86、aosp_x86_64,分别表示arm, mips, x86上32位和64位的产品类型。 到Android实际支持的机型,就有aosp_hammerhead, aosp_manta,分别表示LGE Nexus 5和SAMSUNG 4S。
TARGET_BUILD_VARIANT:目标产品的版本。每一个模块都可以通过LOCAL_MODULE_TAGS这个参数来标记自己,可选的标记值有user, debug, eng, tests, optional, 或samples。 设定目标产品的类型,就能筛选出指定标记的模块,只将符合要求的模块编译打包到最终的产品中去。
TARGET_BUILD_VARIANT有以下取值,除了筛选模块,还有一些调试级别的差异:
TARGET_BUILD_TYPE: 目标产品的类型。只有release和debug两种取值,默认的取值为release。Android源码中包含一些调试专用的代码,当取值为debug时,这些调试代码会编译到最终的产品中去。
TARGET_TOOLS_PREFIX: 编译工具链的路径前缀。默认情况下,Android使用prebuilts目录下的工具,但通过这个值也可自行定制为其他目录。
当TARGET_PRODUCT设定后,编译系统就可以基于它获取其他参数了。这个产品是哪个体系结构,哪个芯片,内核的命令参数是什么? 这些硬件相关的参数都是通过BoardConfig.mk来配置的,编译系统会搜寻device和vendor目录下的$(TARGET_DEVICE)/BoardConfig.mk,这样就能找到对应产品的的芯片级(Board)配置了。
BoardConfig.mk文件中参数主要有以下几个类别:
CPU体系结构: TARGET_CPU_ABI, TARGET_CPU_VARIANT, TARGET_ARCH, TARGET_ARCH_VARIANT等。其中一些参数的取值不同,会连带引发其他参数的不同。
内核参数: BOARD_KERNEL_BASE, BOARD_KERNEL_PAGESIZE, BOARD_KERNEL_CMDLINE这些参数最终会被打包到boot分区的镜像文件中(boot.img),作为内核的启动参数。
分区镜像: TARGET_USERIMAGES_USE_EXT4, BOARD_BOOTIMAGE_PARTITION_SIZE, BOARD_SYSTEMIMAGE_PARTITION_SIZE等与分区格式、分区大小相关的参数。
驱动: BOARD_HAVE_BLUETOOTH, BOARD_WLAN_DEVICE等与蓝牙、wifi硬件配置相关的参数
不同的产品(Product)配置会对应到不同的平台(Board)配置,而平台(Board)的配置也会影响到芯片(Architecture)的配置。BoardConfig.mk中定义的TARGET_ARCH和TARGET_ARCH_VARIANT两个参数决定了TARGET_ARCH_SPECIFIC_MAKEFILE这个芯片级(Architecture)的配置文件,它的值等于build/core/combo/arch/ (TARGETARCH)/ (TARGET_ARCH_VARIANT).mk。
Android默认定义了arm, arm64, mips, mips64, x86, x86_64这几组与CPU芯片相关的编译参数。
Android编译系统的设计理念是将模块级别的配置独立出来,每个模块的Android.mk都是独立的。在编译单个模块时,会将模块的Android.mk添加到main.mk中,形成一个Makefile。 当然,模块的Android.mk必须遵循编译系统定义的规则,具体的配置细节可以参见编译系统(4)-定制。这里只说明编译系统提供给模块编译的接口:
接口变量 | 接口定义 | 作用 |
BUILD_EXECUTABLE | executable.mk | 编译二进制可执行文件,如adbd |
BUILD_HOST_EXECUTABLE | host_executable.mk | 编译PC上的二进制可执行文件,如aapt |
BUILD_JAVA_LIBRARY | java_library.mk | 编译动态Java库文件,如framework.jar |
BUILD_HOST_JAVA_LIBRARY | host_java_library.mk | 编译PC上的Java库文件,如signapk.jar |
BUILD_SHARED_LIBRARY | shared_library.mk | 编译动态的库文件,如libfilterfw.so |
BUILD_STATIC_LIBRARY | static_library.mk | 编译静态的库文件,如libip6tc.so |
BUILD_PACKAGE | package.mk | 编译APK,如SystemUI.apk |
所有接口定义的源文件,都在build/core目录下,在Android.mk中,只需要引用这些变量,就能触发一个模块的编译,不同的模块使用不用的编译方式。 在将一个Android.mk文件include到main.mk中的时候,也会依次将上述变量定义的.mk文件include进来,从而生成最终的Makefile配置。
编译系统的运行过程可以分为两部分:
合成最终的Makefile:零散的Makefile片段,会按照引用关系汇集到main.mk中,作为最终编译的Makefile
根据依赖关系逐步构建出最终产物:Makefile的编译规则最终成型为一个DAG,make会按照“后根顺序(post-order)”来遍历DAG,被依赖的目标总是先被执行
通过Makefile的include语法,就能将其他Makefile片段包含到当前Makefile文件中来,当我们在Android目录下执行make命令时,实际上输入文件是根目录下的Makefile,所有的片段最终都汇集到该文件中。
大体的汇集过程如下图所示:
最终Makefile文件中仅仅引入了main.mk, main.mk位于build/core目录,望文生意,这就表示已经进入Android编译系统最为核心的部分了,main.mk会做编译环境检查,定义最重要的编译目标(droid),依次引入其他功能片段:
help.mk,最优先引入的片段,文件内容很简单,就是定义了一个名为help的目标,通过输入make help
命令,就可以看到该目标的输出结果是一些最主要的make目标的帮助信息。
config.mk,文件内容很庞大,目的就是为了配置编译环境。该文件定义了用于其他模块编译的常量(BUILD_JAVA_LIBRARY, CLEAR_VARS等),也定义了编译时所依赖工具的本地路径(aapt, minigzip等),同时也会引入与基于机型配置相关的其他片段(BoardConfig.mk, AndroidProducts.mk等)。
cleanbuild.mk,定义了installclean这个编译目标,不同于clean,执行make installclean
的时候,并不会完整的删除out/目录,而是仅仅删除与当前TARGET_PRODUCT, TARGET_BUILD_VARIANT, PRODUCT_LOCALES这属性关联到的编译产出。通俗一点来说,就是删除out/target/product/目录下本次机型编译的产物,out/host目录下的文件是保留下来的。
definitions.mk,定义了大量的函数,这些函数都是编译系统的其他文件将用到的。譬如:my-dir, all-subdir-makefiles, find-subdir-files等,通过Makefile的$(call func_name)就能调用这些函数。
dex_preopt.mk,为了提升代码的运行效率,Android会对可执行文件做优化,即将dex格式的文件转换为odex格式的文件。在ART虚拟机下,仍然采用dex(Dalvik Executable)和odex(Optimized Dalvik Executable)这个文件的命名方式,但实际上ART与Dalvik的文件格式是不同的。
Android.mk,Android有全编译和模块编译之分:
Android.mk的编写模板基本都是一致的,它会引入很多编译系统已经初始化好的变量,譬如CLEAR_VARS, BUILD_JAVA_LIBRARY, 其实就是引入变量所对应的.mk文件,所以Android.mk的生成过程,也是一个Makefile片段的汇集过程。
post_clean.mk,在引入待编译模块后,就可以定义模块的清除规则了。该片段定义了基于上一次编译产出的清除规则,譬如,某个模块的AIDL文件或者资源发生了变化,那再次编译这个模块时,就需要清除上一次的编译产物。
legacy_prebuilts.mk,定义了GRANDFATHERED_ALL_PREBUILT变量,表示不需要经过编译的预装文件,譬如gps.conf(GPS配置文件), radio.img(射频分区镜像文件),这些文件都是预编译好的,只需要拷贝到编译产出即可。Android定义了一个默认的PREBUILT列表,而且不希望第三方改动这个列表。当第三方有预编译文件,但又不在PREBUILT列表中时,就需要通过PRODUCT_COPY_FILES这个变量来指定了。
Makefile,不同于AOSP根目录下的Makefile,这个Makefile位于build/core目录下,Android官方对这个文件的解释是”定义一些杂乱的编译规则(miscellaneous rules)”,实际上,这个文件相当重要,诸如system.img, recovery.img, userdata.img, cache.img的目标定义都在这个文件中,更笼统点说,out/target/product/PRODUCT_NAME/目录下大部分的编译产出都是由该文件定义的。
最终Makefile所定义的依赖关系可以用一个有向无环图(Directed Acyclic Graph, DAG)来表示,如果解析Makefile规则,发现存在一个依赖环,那就不是合法的Makefile规则。
可以把编译系统构建的依赖关系分成系统级和模块级别两个层面:
系统级的依赖关系,是指最终编译出Android系统所涉及到的目标之间的依赖。譬如编译出system.img有哪些依赖目标需要先编译完成。这一层的依赖是由编译系统定义的。
模块级的依赖关系,是指各个模块之间的依赖。譬如模块A依赖模块B,那么就需要将这个依赖关系告诉编译系统,只有当模块B编译完成了,才能开始编译模块A。这一层的依赖是由各个模块单独定义的。
最终生成的DAG是极其庞大的,为了说明问题,我们仅列出了一些关键的编译目标之间的依赖关系图:
droid,是最顶层的编译目标,执行make命令时,就会默认编译这个目标,但这个目标并没有什么实质内容,仅仅作为所依赖目标的组合。其中dist_files是编译产物emma_meta.zip,主要用作测试覆盖率; apps_only这个编译目标是由TARGET_BUILD_APPS的值决定的,当只编译指定的app时,就会编译这个目标,否则就编译droidcore这个目标。
droidcore,Android全编译的顶层目标,也仅仅作为所依赖目标的组合。boot.img, recovery.img, userdata.img, cache.img, system.img这些最终刷入设备的镜像都是被依赖的目标。
files,该目标囊括所有预编译的模块prebuilt,需要编译安装的模块modules_to_install,这些模块的编译产物输出到最终需要打包的分区镜像文件中。
system.img,在Makefile中,真正的编译目标是systemiamge,它依赖于INSTALLED_SYSTEMIMAGE这个变量,其实就是编译完成后out/目录下的system.img, 要编译system.img,当然依赖于很多模块的编译产出(由ALL_DEFAULT_INSTALLED_MODULES变量描述的),这里就构成了两个目标依赖于同一个目标的依赖关系,但仍然没有构成环。
userdata.img, cache.img, recovery.img,这些都是分区镜像文件,最终刷入设备上对应的分区,它们都是被droidcore依赖的目标,当然,这些目标的生成也依赖于很多其他的目标。
installed-files.txt, 一个文本文件,最终生成在out/目录下,文件内容列出了所有安装的模块。
这里并没有把更多的依赖细节体现出来,只是展开了几个很顶层的目标,实际上,各个目标之间的依赖关系是错综复杂的。
每一个模块的Android.mk文件中,都可以通过LOCAL_REQUIRED_MODULES的值,来设置所依赖的其他模块,但这只是一个依赖关系的定义,对LOCAL_REQUIRED_MODULES的处理还是编译系统完成的。
在Android.mk中,都会调用编译系统提供的模板,来编译一个特定的模块,这是通过引用Makefile变量来实现的,譬如: (BUILDHOSTEXECUTABLE)</strong>表示编译PC机上的可执行程序,<strong> (BUILD_PACKAGE)表示编译一个APK, $(BUILD_JAVA_LIBRARY)表示编译一个Java库文件。
每一个$(BUILD_XX)的变量,都会对应到一个.mk文件,而这些.mk文件也会引用其他.mk文件,最终都会落到引用base_rules.mk中:
在base_rules.mk中,待编译的模块会把自己添加到ALL_MODULES这个变量中,如此一来,当所有的Android.mk引入完成后,ALL_MODULES就记录了所有待编译的模块。 所有被依赖的模块会保存在ALL_MODULES. (m).REQUIRED</strong>这个变量中,<strong> (m)就是当前被引入的模块,在编译 (m)</strong>这个模块的时候,就会顺着<strong>ALLMODULES. (m).REQUIRED变量找到被依赖的模块,从而构建出依赖关系图。
我们最关心的当然是一次编译能够有什么产出,所有的编译产物都在out/目录下,有两个子目录:host/表示针对PC上的产物,target/表示针对移动设备上的产物。所有编译产物的生成过程可以大致分为三步:
各个模块编译出中间产物。这些中间产物都位于host/和target/下面的obj/子目录中,对于C代码而言,中间产物通常就是.o文件;对于Java代码而言,中间产物通常就是.jar文件。
以target/下面的obj/为例,有以下类别的中间产物:
对于host下面的obj/,也有类似的目录结构。
链接各个中间产物,生成可执行的文件。对于EXECUTABLES,会在模块的临时目录生成一个二进制程序;对于SHARED_LIBRARIES,会在模块的临时目录生成一个LINKED的子目录,存放这最终编译完成的so库文件。
打包二进制文件,生成镜像文件。譬如,将各种apk、jar和so文件拷贝到system/目录,打包生成system.img镜像。
TODO
版权声明:本文为博主转载文章,转载请注明出处点这