本文摘自人民邮电出版社异步社区《深入理解Android内核设计思想(第2版)(上下册)》
购书地址: 试读地址:第2章 Android源码下载及编译
在分析Android源码前,首先要学会如何下载和编译系统。本章将向读者完整地呈现Android源码的下载流程、常见问题以及处理方法,并从开发者的角度来理解如何正确地编译出Android系统(包括原生态系统和定制设备)。
后面,我们将在此基础上深入到编译脚本的分析中,以“庖丁解牛”的方式来还原一个庞大而严谨的Android编译系统。
2.1 Android源码下载指南
2.1.1 基于Repo和Git的版本管理
Git是一种分布式的版本管理系统,最初被设计用于Linux内核的版本控制。本书工具篇中对Git的使用方法、原理框架有比较详细的剖析,建议读者先到相关章节阅读了解。
Git的功能非常强大,速度也很快,是当前很多开源项目的首选工具。不过Git也存在一定的缺点,如相对于图形界面化的工具没那么容易上手、需要对内部原理有一定的了解才能很好地运用、不支持断点续传等。
为此,Google提供了一个专门用于下载Android系统源码的Python脚本,即Repo。
在Repo环境下,版本修改与提交流程是:
用Repo创建新的分支,通常情况下不建议在master分支上操作; 开发者根据需求对项目文件进行修改; 利用git add命令将所做修改进行暂存; 利用git commit命令将修改提交到仓库; 利用repo upload命令将修改提交到代码服务器上。由此可见,Repo与我们在工具篇中讨论的Git流程有些许不同,差异主要体现在与远程服务仓库的交互上;而本地的开发仍然是以原生的Git命令为主。下面我们讲解Repo的一些常用命令,读者也可以拿它和Git进行仔细比较。
1.同步
同步操作可以让本地代码与远程仓库保持一致。它有两种形式。
如果是同步当前所有的项目:
$ repo sync复制代码
或者也可以指定需要同步的某个项目:
$ repo sync [PROJECT1] [PROJECT2]…复制代码
2.分支操作
创建一个分支所需的命令:
$ repo start复制代码
也可以查看当前有多少分支:
$ repo branches复制代码
或者:
$ git branch复制代码
以及切换到指定分支:
$ git checkout复制代码
3.查询操作
查询当前状态:
$ repo status复制代码
查询未提交的修改:
$ repo diff复制代码
4.版本管理操作
暂存文件:
$git add复制代码
提交文件:
$git commit复制代码
如果是提交修改到服务器上,首先需要同步一下:
$repo sync复制代码
然后执行上传指令:
$repo upload复制代码
2.1.2 Android源码下载流程
了解了Repo的一些常规操作后,这一小节接着分析Android源码下载的全过程。这既是剖析Android系统原理的前提,也是让很多新手感到困惑的地方——源码下载可以作为初学者了解Android系统的“Hello World”。
值得一提的是,Android官方建议我们务必确保编译系统环境符合以下几点要求:
Linux或者Mac系统在虚拟机上或是其他不支持的系统(例如Windows)上编译Android系统也是可能的,事实上Google鼓励大家去尝试不同的操作系统平台。不过Google内部针对Android系统的编译和测试工作大多是在Ubuntu LTS(14.04)上进行的。因而建议开发人员也都选择同样的操作系统版本来开展工作,经验告诉我们这样可以少走很多弯路。
如果是在虚拟机上运行的Linux系统,那么理论上至少需要16GB的RAM/Swap才有可能完成整个Android系统的编译。
对于Gingerbread(2.3.X)及以上的版本,64位的开发环境是必需的。其他旧的Android系统版本可以采用32位的开发环境。 需要至少100GB以上的磁盘空间才能完成系统的一系列编译过程——仅源码大小就已经将近10GB了。 Python 2.6-2.7,开发人员可以从Python官网上下载:www.python.org。 GNU Make 3.81-3.82,开发人员可以从Gnu官网上下载:www.gnu.org。 如果是编译最新版本的Android N系统,那么需要Java8(OpenJDK)。后续编译章节我们还会专门介绍。 Git 1.7以上版本,开发人员可以从Git官网上下载:要特别提醒大家的是,以下所有步骤都是在Ubuntu操作系统中完成的(“#”号后面表示注释内容)。
1.下载Repo
$ cd ~ #进入home目录 $ mkdir bin #创建bin目录用于存放Repo脚本 $ PATH=~/bin:$PATH #将bin目录加入系统路径中 $ curl > ~/bin/repo #curl #是一个基于命令行的文件传输工具,它支持非常多的协议。这里我们利用curl来将repo保存到相应目录下 $ chmod a+x ~/bin/repo复制代码
注:网上有很多开发者(中国大陆地区)反映上面的地址经常无法成功访问。如果读者也有类似困扰,可以试试下面这个:
$curl > ~/bin/repo复制代码
另外,国内不少组织(特别是教育机构)也对Android做了镜像,如清华大学提供的开源项目(TUNA)的mirror地址如下:
复制代码
下面是TUNA官方对Android代码库的使用帮助节选:
Android镜像使用帮助 参考Google教程 source.com/全部使用git://aosp.tuna.tsinghua.edu.cn/android/代替即可。 本站资源有限,每个IP限制并发数为4,请勿使用repo sync-j8这样的方式同步。 替换已有的AOSP源代码的remote。 如果你之前已经通过某种途径获得了AOSP的源码(或者你只是init这一步完成后),你希望以后通过TUNA同步AOSP部分的代码,只需要将.repo/manifest.xml把其中的AOSP这个remote的fetch从. googlesource.com改为git://aosp.tuna.tsinghua.edu.cn/android/。
下载repo后,最好进行一下校验,各版本的校验码如下所示:
对于 版本 1.17, SHA-1 checksum是:ddd79b6d5a7807e911b524cb223bc3544b661c28 对于 版本 1.19, SHA-1 checksum是:92cbad8c880f697b58ed83e348d06619f8098e6c 对于 版本 1.20, SHA-1 checksum 是:e197cb48ff4ddda4d11f23940d316e323b29671c 对于 版本 1.21, SHA-1 checksum 是:b8bd1804f432ecf1bab730949c82b93b0fc5fede复制代码
2.Repo配置
在开始下载源码前,需要对Repo进行必要的配置。
如下所示:
$ mkdir source #用于存放整个项目源码 $ cd source $ repo init -u ############以下为注释部分######## init命令用于初始化repo并得到近期的版本更新信息。如果你想获取某个非master分支的代码,需要在命令最后加上-b选项。如: $ repo init -u -b android-4.0.1_r1 完成配置后,repo会有如下提示: repo initialized in /home/android 这时在你的机器home目录下会有一个.repo目录,用于记录manifest等信息########## ######复制代码
3.下载源码
完成初始化动作后,就可以开始下载源码了。根据上一步的配置,下载到的可能是最新版本或者某分支版本的系统源码。
$ repo sync复制代码
由于整个Android源码项目非常大,再加上网络等不确定因素,运气好的话可能1~2个小时就能品尝到“Android盛宴”;运气不好的话,估计一个礼拜也未必能完成这一步——如果下载一直失败的话,读者也可以尝试到网上搜索别人已经下载完成的源码包,因为通常在新版本发布后的第一时间就有热心人把它上传到网上了。
可以看到在Repo的帮助下,整个下载过程还是相当简单直观的。
提示:如果你在下载过程中出现暂时性的问题(如下载意外中断),可以多试几次。如果一直存在问题,则很可能是代理、网关等原因造成的。更多常见问题的描述与解决方法,可以参见下面这个网址。
复制代码
典型的repo下载界面如图2-1所示。
▲图2-1 原生Android工程的典型下载界面
Android系统本身是由非常多的子项目组成的,这也是为什么我们需要repo来统一管理AOSP源码的一个重要原因,如图2-2所示(部分)。
▲图2-2 子项目
另外,不同子项目之间的branches和tags的区别如图2-3所示。
▲图2-3 Android各子项目的分支和标签
(左:frameworks/base,中:frameworks/native,右:/platform/libcore)当我们使用repo init命令初始化AOSP工程时,会在当前目录下生成一个repo文件夹,如图2-4所示。
▲图2-4 repo文件
其中manifests本身也是一个Git项目,它提供的唯一文件名为default.xml,用于管理AOSP中的所有子项目(每个子项目都由一个project标签表示):
另外,default.xml中记录了我们在初始化时通过-b选项指定的分支版本,例如“android-n-preview-2”:
这样当执行repo sync命令时,系统就可以根据我们的要求去获取正确的源码版本了。
友情提示:经常有读者询问阅读Android源码可以使用哪些工具。除了著名的Source Insight外,另外还有一个名为SlickEdit的IDE也是相当不错的(支持Windows、Linux和Mac),建议大家可以对比选择最适合自己的工具。
2.2 原生Android系统编译指南
任何一个项目在编译前,都首先需要搭建一个完整的编译环境。Android系统通常是运行于类似Arm这样的嵌入式平台上,所以很可能涉及交叉编译。
什么是交叉编译呢?
简单来说,如果目标平台没有办法安装编译器,或者由于资源有限等无法完成正常的编译过程,那就需要另一个平台来辅助生成可执行文件。如很多情况下我们是在PC平台上进行Android系统的研发工作,这时就需要通过交叉编译器来生成可运行于Arm平台上的系统包。需要特别提出的是,“平台”这个概念是指硬件平台和操作系统环境的综合。
交叉编译主要包含以下几个对象。
宿主机(Host):指的是我们开发和编译代码所在的平台。目前不少公司的开发平台都是基于X86架构的PC,操作系统环境以Windows和Linux为主。
目标机(Target):相对于宿主机的就是目标机。这是编译生成的系统包的目标平台。
交叉编译器(Cross Compiler):本身运行于宿主机上,用于产生目标机可执行文件的编译器。
针对具体的项目需求,可以自行配置不同的交叉编译器。不过我们建议开发者尽可能直接采用国际权威组织推荐的经典交叉编译器。因为它们在release之前就已经在多个项目上测试过,可以为接下来的产品开发节约宝贵的时间。表2-1所示给出了一些常见的交叉编译器及它们的应用环境。
表2-1 常用交叉编译器及应用环境
交叉编译器 | 宿 主 机 | 目 标 机 |
---|---|---|
armcc | X86PC(windows),ADS开发环境 | Arm |
arm-elf-gcc | X86PC(windows),Cygwin开发环境 | Arm |
arm-linux-gcc | X86PC(Linux) | Arm |
2.2.1 建立编译环境
本书所采用的宿主机是X86PC(Linux),通过表2-1可知在编译过程中需要用到arm-linux-gcc交叉编译器(注:Android系统工程中自带了交叉编译工具,只要在编译时做好相应的配置即可)。
接下来我们分步骤来搭建完整的编译环境,并完成必要的配置。所选取的宿主机操作系统是Ubuntu的14.04版本LTS(这也是Android官方推荐的)。为了不至于在编译过程中出现各种意想不到的问题,建议大家也采用同样的操作系统环境来执行编译过程。
Step1. 通用工具的安装
表2-2给出了所有需要安装的通用工具及它们的下载地址。
表2-2 通用编译工具的安装及下载地址
通 用 工 具 | 安装地址、指南 | |
Python 2.X | ||
GNU Make 3.81 -- 3.82 | ||
JDK | Java 87 针对Kitkat以上版本 | 最新的Android工程已经改用OpenJDK,并要求为Java 87及以上版本。这点大家应该特别注意,否则可能在编译过程中遇到各种问题。具体安装方式见下面的描述 |
JDK 6 针对Gingerbread到Kitkat之间的版本 | ||
JDK 5 针对Cupcake到Froyo之间版本 | ||
Git 1.7以上版本 |
对于开发人员来说,他们习惯于通过以下方法安装JDK(如果处于Ubuntu系统下):
Java 6:
$ sudo add-apt-repository "deb lucid partner" $ sudo apt-get update $ sudo apt-get install sun-java6-jdk复制代码
Java 5:
$ sudo add-apt-repository "deb hardy main multiverse" $sudo add-apt-repository "deb hardy-updates main multiverse" $ sudo apt-get update $ sudo apt-get install sun-java5-jdk复制代码
但是随着Java的版本变迁及Sun(已被Oracle收购)公司态度的转变,目前获取Java的方式也发生了很大变化。基于版权方面的考虑(大家应该已经听说了Oracle和Google之间的官司恩怨),Android系统已经将Java环境切换到了OpenJDK,安装步骤如下所示:
$ sudo apt-get update $ sudo apt-get install openjdk-8-jdk复制代码
首先通过上述命令install OpenJDK 8,成功后再进行如下配置:
$ sudo update-alternatives --config java $ sudo update-alternatives --config javac复制代码
如果出现Java版本错误的问题,make系统会有如下提示:
** You are attempting to build with the incorrect version of java. Your version is: WRONG_VERSION. The correct version is: RIGHT_VERSION. Please follow the machine setup instructions at **复制代码
Step2. Ubuntu下特定工具的安装
注意,这一步中描述的安装过程是针对Ubuntu而言的。如果你是在其他操作系统下执行的编译,请参阅官方文档进行正确配置;如果你是在虚拟机上运行的Ubuntu系统,那么请至少保留16GB的RAM/SWAP和100GB以上的磁盘空间,这是完成编译的基本要求。
Ubuntu 14.04$ sudo apt-get install bison g++-multilib git gperf libxml2-utils make zlib1g-dev:i386 zip复制代码Ubuntu 12.04
所需的命令如下:
$ sudo apt-get install git gnupg flex bison gperf build-essential \ zip curl libc6-dev libncurses5-dev:i386 x11proto-core-dev \ libx11-dev:i386 libreadline6-dev:i386 libgl1-mesa-glx:i386 \ libgl1-mesa-dev g++-multilib mingw32 tofrodos \ python-markdown libxml2-utils xsltproc zlib1g-dev:i386 $ sudo ln -s /usr/lib/i386-linux-gnu/mesa/libGL.so.1 /usr/lib/i386-linux-gnu/libGL.so复制代码Ubuntu 10.04 - 11.10
需要安装的程序比较多,不过我们还是可以通过apt-get来轻松完成。
具体命令如下:
$ sudo apt-get install git-core gnupg flex bison gperf build-essential \ zip curl zlib1g-dev libc6-dev lib32ncurses5-dev ia32-libs \ x11proto-core-dev libx11-dev lib32readline5-dev lib32z-dev \ libgl1-mesa-dev g++-multilib mingw32 tofrodos python-markdown \ libxml2-utils xsltproc复制代码
注意,如果以上命令中存在某些包找不到的情况,可以试试以下命令:
$ sudo apt-get install git-core gnupg flex bison gperf libsdl-dev libesd0-dev libwxgtk2.6-dev build-essential zip curl libncurses5-dev zlib1g-dev openjdk-6-jdk ant gcc-multilib g++-multilib复制代码
如果你的操作系统刚好是Ubuntu 10.10,那么还需要:
$ sudo ln -s /usr/lib32/mesa/libGL.so.1 /usr/lib32/mesa/libGL.so复制代码
如果你的操作系统刚好是Ubuntu 11.10,那么还需要:
$ sudo apt-get install libx11-dev:i386复制代码
Step3. 设立ccache(可选)
如果你经常执行“make clean”,或者需要经常编译不同的产品类别,那么ccache还是有用的。它可以作为编译时的缓冲,从而加快重新编译的速度。
首先,需要在.bashrc中加入如下命令。
export USE_CCACHE=1复制代码
如果你的home目录是非本地的文件系统(如NFS),那么需要特别指定(默认情况下它存放于~/.ccache):
export CCACHE_DIR=复制代码
在源码下载完成后,必须在源码中找到如下路径并执行命令:
prebuilt/linux-x86/ccache/ccache -M 50G #推荐的值为50-100GB,你可以根据实际情况进行设置复制代码
Step4. 配置USB访问权限
USB的访问权限在我们对实际设备进行操作时是必不可少的(如下载系统程序包到设备上)。在Ubuntu系统中,这一权限通常需要特别的配置才能获得。
可以通过修改/etc/udev/rules.d/51-android.rules来达到目的。
例如,在这个文件中加入以下命令内容:
# adb protocol on passion (Nexus One) SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="4e12", MODE="0600", OWNER ="" # fastboot protocol on passion (Nexus One) SUBSYSTEM=="usb", ATTR{idVendor}=="0bb4", ATTR{idProduct}=="0fff", MODE="0600", OWNER =" " # adb protocol on crespo/crespo4g (Nexus S) SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="4e22", MODE="0600", OWNER =" " # fastboot protocol on crespo/crespo4g (Nexus S) SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="4e20", MODE="0600", OWNER =" " # adb protocol on stingray/wingray (Xoom) SUBSYSTEM=="usb", ATTR{idVendor}=="22b8", ATTR{idProduct}=="70a9", MODE="0600", OWNER =" " # fastboot protocol on stingray/wingray (Xoom) SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="708c", MODE="0600", OWNER =" " # adb protocol on maguro/toro (Galaxy Nexus) SUBSYSTEM=="usb", ATTR{idVendor}=="04e8", ATTR{idProduct}=="6860", MODE="0600", OWNER =" " # fastboot protocol on maguro/toro (Galaxy Nexus) SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="4e30", MODE="0600", OWNER =" " # adb protocol on panda (PandaBoard) SUBSYSTEM=="usb", ATTR{idVendor}=="0451", ATTR{idProduct}=="d101", MODE="0600", OWNER =" " # fastboot protocol on panda (PandaBoard) SUBSYSTEM=="usb", ATTR{idVendor}=="0451", ATTR{idProduct}=="d022", MODE="0600", OWNER =" " # usbboot protocol on panda (PandaBoard) SUBSYSTEM=="usb", ATTR{idVendor}=="0451", ATTR{idProduct}=="d00f", MODE="0600", OWNER =" " # usbboot protocol on panda (PandaBoard ES) SUBSYSTEM=="usb", ATTR{idVendor}=="0451", ATTR{idProduct}=="d010", MODE="0600", OWNER =" "复制代码
如果严格按照上述4个步骤来执行,并且没有任何错误——那么恭喜你,一个完整的Android编译环境已经搭建完成了。
2.2.2 编译流程
上一小节我们建立了完整的编译环境,可谓“万事俱备,只欠东风”,现在就可以执行真正的编译操作了。
下面内容仍然采用分步的形式进行讲解。
Step1. 执行envsetup脚本
脚本文件envsetup.sh记录着编译过程中所需的各种函数实现,如lunch、m、mm等。你可以根据需求进行一定的修改,然后执行以下命令:
$ source ./build/envsetup.sh复制代码
也可以用点号代替source:
$ . ./build/envsetup.sh复制代码
Step2. 选择编译目标
编译目标由两部分组成,即BUILD和BUILDTYPE。表2-3和表2-4给出了详细的解释。
表2-3 BUILD参数详解
BUILD | 设 备 | 备 注 |
---|---|---|
Full | 模拟器 | 全编译,即包括所有的语言、应用程序、输入法等 |
full_maguro | maguro | 全编译,并且运行于 Galaxy Nexus GSM/HSPA+ ("maguro") |
full_panda | panda | 全编译,并且运行于 PandaBoard ("panda") |
可见BUILD可用于描述不同的目标设备。
表2-4 BUILDTYPE参数详解
BUILDTYPE | 备 注 |
---|---|
User | 编译出的系统有一定的权限限制,通常用来发布最终的上市版本 |
userdebug | 编译出的系统拥有root权限,通常用于调试目的 |
Eng | 即engineering版本 |
可见BUILDTYPE可用于描述各种不同的编译场景。
选择不同的编译目标,可以使用以下命令:
$ lunch BUILD-BUILDTYPE复制代码
如我们执行命令“lunch full-eng”,就相当于编译生成一个用于工程开发目的,且运行于模拟器的系统。
如果不知道有哪些产品类型可选,也可以只敲入“lunch”命令,这时会有一个列表显示出当前工程中已经配置过的所有产品类型(后续小节会讲解如何添加一款新产品);然后可以根据提示进行选择,如图2-5所示。
▲图2-5 使用“lunch”来显示所有产品
Step3. 执行编译命令
最直接的就是输入如下命令:
$ make复制代码
对于2.3以下的版本,整个编译过程在一台普通计算机上需要3小时以上的时间。而对于JellyBean以上的项目,很可能会花费5小时以上的时间(这取决于你的宿主机配置)。
如果希望充分利用CPU资源,也可以使用make选项“-jN”。N的值取决于开发机器的CPU数、每颗CPU的核心数以及每个核心的线程数。
例如,你可以使用以下命令来加快编译速度:
$ make –j4复制代码
有个小技巧可以为这次编译轻松地打上Build Number标签,而不需要特别更改脚本文件,即在make之前输入如下命令:
$ export BUILD_NUMBER=${USER}-'date +%Y%m%d-%H%M%S'复制代码
在定义BUILD_NUMBER变量值时要特别注意容易引起错误的符号,如“$”“&”“:”“/”“\”“<”“>”等。
这样我们就成功编译出Android原生态系统了——当然,上面的“make”指令只是选择默认的产品进行编译。假如你希望针对某个特定的产品来执行,还需要先通过上一小节中的“lunch”进行相应的选择。
接下来看看如何编译出SDK。这是很多开发者,特别是应用程序研发人员所关心的。因为很多时候通过SDK所带的模拟器来调试APK应用,比在真机上操作要来得高效且便捷;而且模拟器可以配置出各种不同的屏幕参数,用以验证应用程序的“适配”能力。
SDK是运行于Host机之上的,因而编译过程根据宿主操作系统的不同会有所区别。详细步骤如下:
Mac OS和Linux
(1)下载源码,和前面已经讲过的源码下载过程没有任何区别。
(2)执行envsetup.sh。
(3)选择SDK对应的产品。
$ lunch sdk-eng复制代码
提示:如果通过“lunch”没有出现“sdk”这个种类的产品也没有关系,可以直接输入上面的命令。
(4)最后,使用以下命令进行SDK编译:
$ make sdk复制代码
Windows
运行于Windows环境下的SDK编译需要基于上面Linux的编译结果(注意只能是Linux环境下生成的结果,而不支持MacOS)。
(1)执行Linux下SDK编译的所有步骤,生成Linux版的SDK。
(2)安装额外的支持包。
$ sudo apt-get install mingw32 tofrodos复制代码
(3)再次执行编译命令,即:
$ . ./build/envsetup.sh $ lunch sdk-eng $ make win_sdk复制代码
这样我们就完成Windows版本SDK的编译了。
当然上面编译SDK的过程也同样可以利用多核心CPU的优势。例如:
$ make -j4 sdk复制代码
面向Host和Target的编译结果都存放在源码工程out目录下,分为两个子目录。
host:SDK生成的文件存放在这里。例如:
MacOS
out/host/darwin-x86/sdk/android-sdk_eng.<build-id>_mac-x86.zip
Windows
out/host/windows/sdk/android-sdk_eng.${USER}_windows/
target:通过make命令生成的文件存放在这里。
另外,启动一个模拟器可以使用以下命令。
$ emulator [OPTIONS]复制代码
模拟器提供的启动选项非常丰富,读者可以参见本书工具篇中的详细描述。
2.3 定制产品的编译与烧录
上一小节我们学习了原生态Android系统的编译步骤,为大家进一步理解定制设备的编译流程打下了基础。Android系统发展到今天,已经在多个产品领域得到了广泛的应用。相信有一个问题是很多人都想了解的,那就是如何在原生态Android系统中添加自己的定制产品。
2.3.1 定制新产品
仔细观察整个Android源码项目可以发现,它的根目录下有一个device文件夹,其中又包含了诸如samsung、moto、google等厂商名录,如图2-6所示。
▲图2-6 device文件夹下的厂商目录
在Android编译系统中新增一款设备的过程如下。
Step 1. 和图2-6所列的各厂商一样,我们也最好先在device目录下添加一个以公司命名的文件夹。当然,Android系统本身并没有强制这样做(后面会看到vendor目录也是可以的),只不过规范的做法有利于项目的统一管理。
然后在这个公司名目录下为各产品分别建立对应的子文件夹。以samsung为例,其文件夹中包含的产品如图2-7所示。
▲图2-7 一个厂商通常有多种产品
完成产品目录的添加后,和此项目相关的所有特定文件都应该优先放置到这里。一般的组织结构如图2-8所示。
▲图2-8 device目录的组织架构
由图2-8最后一行可以看出,一款新产品的编译需要多个配置文件(sh、mk等)的支持。我们按照这些文件所处的层级进行一个系统的分类,如表2-5所示。
表2-5 定制新设备所需的配置文件分类
层 级 | 作 用 |
---|---|
芯片架构层(Architecture) | 产品所采用的硬件架构,如ARM、X86等 |
核心板层(Board) | 硬件电路的核心板层配置 |
设备层(Device) | 外围设备的配置,如有没有键盘 |
产品层(Product) | 最终生成的系统需要包含的软件模块和配置,如是否有摄像头应用程序、默认的国家或地区语言等 |
也就是说,一款产品由底层往上的构建顺序是:芯片架构→核心板→设备→产品。这样讲可能有点抽象,给大家举个具体的例子。我们知道,当前嵌入式领域市场占有率最高的当属ARM系列芯片。但是首先,ARM公司本身并不生产具体的芯片,而只授权其他合作伙伴来生产和销售半导体芯片。ARM架构就是属于最底层的硬件体系,需要在编译时配置。其次,很多芯片设计商(如三星)在获得授权后,可以在ARM架构的基础上设计出具体的核心板,如S5PV210。接下来,三星会将其产品进一步销售给有需要的下一级厂商,如某手机生产商。此时就要考虑整个设备的硬件配置了,如这款手机是否要带有按键、触摸屏等。最后,在确认了以上3个层次的硬件设计后,我们还可以指定产品的一些具体属性,如默认的国家或地区语言、是否带有某些应用程序等。
后续的步骤中我们将分别讲解与这几个层次相关的一些重要的脚本文件。
Step 2. vendorsetup.sh
虽然我们已经为新产品创建了目录,但Android系统并不知道它的存在——所以需要主动告知Android系统新增了一个“家庭成员”。以三星toro为例,为了让它能被正确添加到编译系统中,首先就要在其目录下新建一个vendorsetup.sh脚本。这个脚本通常只需要一个语句。具体范例如下:
add_lunch_combo full_toro-userdebug复制代码
大家应该还记得前一小节编译原生态系统的第一步是执行envsetup.sh,函数add_lunch_combo就是在这个文件中定义的。此函数的作用是将其参数所描述的产品(如full_toro-userdebug)添加到系统相关变量中——后续lunch提供的选单即基于这些变量产生的。
那么,vendorsetup.sh在什么时候会被调用呢?
答案也是envsetup.sh。这个脚本的大部分内容是对各种函数进行定义与实现,末尾则会通过一个for循环来扫描工程中所有可用的vendorsetup.sh,并执行它们。具体源码如下:
# Execute the contents of any vendorsetup.sh files we can find. for f in 'test -d device && find device -maxdepth 4 -name 'vendorsetup.sh' 2> /dev/null' \ 'test -d vendor && find vendor -maxdepth 4 -name 'vendorsetup.sh' 2> /dev/null' do echo "including $f" . $f Done unset f复制代码
可见,默认情况下编译系统会扫描如下路径来查找vendorsetup.sh:
/vendor/ /device/复制代码
注:vendor这个目录在4.3版本的Android工程中已经不存在了,建议开发者将产品目录统一放在device中。
打一个比方,上述步骤有点类似于超市的工作流程:工作人员(编译系统)首先要扫描仓库(vendor和device目录),统计出有哪些商品(由vendorsetup.sh负责记录),并通过一定的方式(add_lunch_combo@envsetup.sh)将物品上架,然后消费者才能在货架上挑选(lunch)自己想要的商品。
Step 3. 添加AndroidProducts.mk。消费者在货架上选择(lunch)了某样“商品”后,工作人员的后续操作(如结账、售后等)就完全基于这个特定商品来展开。编译系统会先在商品所在目录下寻找AndroidProducts.mk文件,这里记录着针对该款商品的一些具体属性。不过,通常我们只在这个文件中做一个“转向”。如:
/device/samsung/toro/AndroidProducts.mk/ PRODUCT_MAKEFILES := \ $(LOCAL_DIR)/aosp_toro.mk \ $(LOCAL_DIR)/full_toro.mk复制代码
因为AndroidProducts.mk对于每款产品都是通用的,不利于维护管理,所以可另外新增一个或者多个以该产品命名的makefile(如full_toro.mk和aosp_toro.mk),再让前者通过PRODUCT_MAKEFILES“指向”它们。
Step4. 实现上一步所提到的某产品专用的makefile文件(如full_toro.mk和aosp_toro.mk)。可以充分利用编译系统已有的全局变量或者函数来完成任何需要的功能。例如,指定编译结束后需要复制到设备系统中的各种文件、设置系统属性(系统属性最终会写入设备/system目录下的build.prop文件中)等。以full_toro.mk为例:
/device/samsung/toro/full_toro.mk/ #将apns等配置文件复制到设备的指定目录中 PRODUCT_COPY_FILES += \ device/samsung/toro/bcmdhd.cal:system/etc/wifi/bcmdhd.cal \ device/sample/etc/apns-conf_verizon.xml:system/etc/apns-conf.xml \ … # 继承下面两个mk文件 $(call inherit-product, $(SRC_TARGET_DIR)/product/aosp_base_telephony.mk) $(call inherit-product, device/samsung/toro/device_vzw.mk) # 下面重载编译系统中已经定义的变量 PRODUCT_NAME :=full_toro #产品名称 PRODUCT_DEVICE := toro #设备名称 PRODUCTBRAND := Android #品牌名称 …复制代码
这部分的变量基本上以“PRODUCT”开头,我们在表2-6中对其中常用的一些变量做统一讲解。
表2-6 PRODUCT相关变量
变 量 | 描 述 |
---|---|
PRODUCT_NAME | 产品名称,最终会显示在系统设置中的“关于设备”选项卡中 |
PRODUCT_DEVICE | 设备名称 |
PRODUCT_BRAND | 产品所属品牌 |
PRODUCT_MANUFACTURER | 产品生产商 |
PRODUCT_MODEL | 产品型号 |
PRODUCT_PACKAGES | 系统需要预装的一系列程序,如APKs |
PRODUCT_LOCALES | 所支持的国家语言。格式如下: [两字节语言码]-[两字节国家码]如en_GB de_DE各语言间以空格分隔 |
PRODUCT_POLICY | 本产品遵循的“策略”,如: android.policy_phoneandroid.policy_mid |
PRODUCT_TAGS | 一系列以空格分隔的产品标签描述 |
PRODUCT_PROPERTY_OVERRIDES | 用于重载系统属性。 格式:key=value示例:ro.product.firmware=v0.4rc1dalvik.vm.dexopt-data-only=1这些属性最终会被存储在系统设备的/system/build.prop文件中 |
Step 5. 添加BoardConfig.mk文件。这个文件用于填写目标架构、硬件设备属性、编译器的条件标志、分区布局、boot地址、ramdisk大小等一系列参数(参见下一小节对系统映像文件的讲解)。下面是一个范例(因为toro中的BoardConfig主要引用了tuna的BoardConfig实现,所以我们直接讲解后者的实现):
#/device/samsung/tuna/BoardConfig.mk/ TARGET_CPU_ABI := armeabi-v7a ## eabi即Embedded application binary interface TARGET_CPU_ABI2 := armeabi … TARGET_NO_BOOTLOADER := true ##不编译bootloader … BOARD_SYSTEMIMAGE_PARTITION_SIZE := 685768704#system.img分区大小 BOARD_USERDATAIMAGE_PARTITION_SIZE := 14539537408#userdata.img的分区大小 BOARD_FLASH_BLOCK_SIZE := 4096 #flash块大小 … BOARD_WLANDEVICE := bcmdhd #wifi设备复制代码
可以看到,这个makefile文件中涉及的变量大部分以“TARGET”和“BOARD_”开头,且数量众多。相信对于第一次编写BoardConfig.mk的开发者来说,这是一个不小的挑战。那么,有没有一些小技巧来加速学习呢?
答案是肯定的。
各大厂商在自己产品目录下存放的BoardConfig.mk样本就是我们学习的绝佳材料。通过比较可发现,这些文件大部分都是雷同的。所以我们完全可以先从中复制一份(最好选择架构、主芯片与自己项目相当的),然后根据产品的具体需求进行修改。
Step 6. 添加Android.mk。这是Android系统下编译某个模块的标准makefile。有些读者可能分不清楚这个文件与前面几个步骤中的makefile有何区别。我们举例说明,如果Step1-Step5中的文件用于决定一个产品的属性,那么Android.mk就是生产这个“产品”某个“零件”的“生产工序”。——要特别注意,只是某个“零件”而已。整个产品是需要由很多Android.mk生产出的“零件”组合而成的。
Step7. 完成前面6个步骤后,我们就成功地将一款新设备定制到编译系统中了。接下来的编译流程和原生态系统是完全一致的,这里不再赘述。
值得一提的是,/system/build.prop这个文件的生成过程也是由编译系统控制的。具体处理过程在/build/core/Makefile中,它主要由以下几个部分组成:
/build/tools/buildinfo.sh这个脚本用于向build.prop中输出各种<key> <value>组合,实现方式也很简单。下面是其中的两行节选:
echo "ro.build.id=$BUILD_ID"
echo "ro.build.display.id=$BUILD_DISPLAY_ID"
TARGET_DEVICE_DIR目录下的system.prop ADDITIONAL_BUILD_PROPERTIES /build/tools/post_process_props.py清理工作,将黑名单中的项目从最终的build.prop中移除。
开发人员在定制一款新设备时,可以根据实际情况将自己的配置信息添加到上述几个组成部分中,以保证设备的正常运行。
2.3.2 Linux内核编译
不同产品的硬件配置往往是有差异的。比如某款手机配备了蓝牙芯片,而另一款则没有;即便是都内置了蓝牙模块的两款手机,它们的生产商和型号也很可能不一样——这就不可避免地要涉及内核驱动的移植。前面我们分析的编译流程只针对Android系统本身,而Linux内核和Android的编译是独立的。因此对于设备开发商来说,还需要下载、修改和编译内核版本。
接下来以Android官方提供的例子来讲解如何下载合适的内核版本。
这个范例基于Google的Panda设备,具体步骤如下。
Step1. 首先通过以下命令来获取到git log:
$ git clone $ cd panda $ git log --max-count=1 kernel复制代码
这样就得到了panda kernel的提交值,在后续步骤中会用到。
Step2. Google针对Android系统提供了以下可用的内核版本:
$ git clone $ git clone $ git clone $ git clone $ git clone $ git clone $ git clone 复制代码
上述命令的每一行都代表了一个可用的内核版本。
那么,它们之间有何区别呢?
exynos,适用于Samsung Exynos芯片组; goldfish,适用于模拟平台; msm,适用于ADP1,ADP2,Nexus One以及Qualcomm MSM芯片组; omap,适用于PandaBoard和Galaxy Nexus以及TI OMAP芯片组; samsung,适用于Nexus S以及Samsung Hummingbird芯片组; tegra,适用于Xoom以及NVIDIA Tegra芯片组; common,则是通用版本。由此可见,与Panda设备相匹配的是omap.git这个版本的内核。
Step3. 除了Linux内核,我们还需要下载prebuilt。具体命令如下:
$ git clone $ export PATH=$(pwd)/prebuilt/linux-x86/toolchain/arm-eabi-4.4.3/bin:$PATH复制代码
Step4. 完成以上步骤后,就可以进行Panda内核的编译了:
$ export ARCH=arm $ export SUBARCH=arm $ export CROSS_COMPILE=arm-eabi- $ cd omap $ git checkout <第一步获取到的值> $ make panda_defconfig $ make复制代码 第一步获取到的值>
整个内核的编译相对简单,读者可以自行尝试。
2.3.3 烧录/升级系统
将编译生成的可执行文件包通过各种方式写入硬件设备的过程称为烧录(flash)。烧录的方式有很多,各厂商可以根据实际的需求自行选择。常见的有以下几种。
(1)SD卡工厂烧录方式
当前市面上的CPU主芯片通常会提供多种跳线方式,来支持嵌入式设备从不同的存储介质(如Flash、SD Card等)中加载引导程序并启动系统。这样的设计显然会给设备开发商带来更多的便利。研发人员只需要将烧录文件按一定规则先写入SD卡,然后将设备配置为SD卡启动。一旦设备成功启动后,处于烧写模式下的BootLoader就会将各文件按照要求写入产品存储设备(通常是FLASH芯片)的指定地址中。
由此可见Bootloader的主要作用有两个:其一是提供下载模式,将组成系统的各个Image写入到设备的永久存储介质中;其二才是在设备开机过程中完成引导系统正常启动的重任。
一个完整的Android烧录包至少需要由3部分内容(即Boot Loader,Linux Kernel和Android System)组成。我们可以利用某种方式对它们先进行打包处理,然后统一写入设备中。一般情况下,芯片厂商(如Samsung)会针对某款或某系列芯片提供专门的烧录工具给开发人员使用;否则各产品开发商需要根据实际情况自行研发合适的工具。
总的来说,SD卡的烧录手法以其操作简便、不需要PC支持等优点被广泛应用于工厂生产中。
(2)USB方式
这种方式需要在PC的配合下完成。设备首先与PC通过USB进行连接,然后运行于PC上的客户端程序将辅助Android设备来完成文件烧录。
(3)专用的烧写工具
比如使用J-Tag进行系统烧录。
(4)网络连接方式
这种方式比较少见,因为它要求设备本身能接入网络(局域网、互联网),这对于很多嵌入式设备来说过于苛刻。
(5)设备Bootloader+fastboot的模式
这也就是我们俗称的“线刷”。需要特别注意的是,能够使用这种升级模式的一个前提是设备中已经存在可用的Bootloader,因而它不能被运用于工厂烧录中(此时设备中还未有任何有效的系统程序)。
当然,各大厂商通常还会在这种模式上做一些“易用性的封装”(譬如提供带GUI界面的工具),从而在一定程度上降低用户的使用门槛。
迫使Android设备进入Bootloader模式的方法基本上大同小异,下面这两种是最常见的:
通过“fastboot reboot-bootloader”命令来重启设备并进入Bootloader模式;
在关机状态下,同时按住设备的“音量减”和电源键进入Bootloader模式。
(6)Recovery模式
和前一种方式类似,Recovery模式同样不适用于设备首次烧录的场景。“Recovery”的字面意思是“还原”,这也从侧面反映出它的初衷是帮助那些出现异常的系统进行快速修复。由于OTA这种得到大规模应用的升级方式同样需要借助于Recovery模式,使得后者逐步超出了原先的设计范畴,成为普通消费者执行设备升级操作的首选方式。我们将在后续小节中对此做更详细的讲解。
2.4 Android Multilib Build
早期的Android系统只支持32位CPU架构的编译,但随着越来越多的64位硬件平台的出现,这种编译系统的局限性就突显出来了。因而Android系统推出了一种新的编译方式,即Multilib build。可想而知,这种编译系统上的改进需要至少满足两个条件:
支持64-bit和32-bit64位和32位平台在很长一段时间内都需要“和谐共处”,因而编译系统必须保证以下几个场景。
Case1:支持只编译64-bit系统。
Case2:支持只编译32-bit系统。
Case3:支持编译64和32bit系统,64位系统优先。
Case4:支持编译32和64位系统,32位系统优先。
在现有编译系统基础上不需要做太多改动事实上Multilib Build提供了比较简便的方式来满足以上两个条件,我们将在下面内容中学习到它的具体做法。
(1)平台配置
BoardConfig.mk用于指定目标平台相关的很多属性,我们可以在这个脚本中同时指定Primary和Secondary的CPU Arch和ABI:
与Primary Arch相关的变量有TARGET_ARCH、TARGET_ARCH_VARIANT、TARGET_CPU_VARIANT等,具体范例如下:
TARGET_ARCH := arm64 TARGET_ARCH_VARIANT := armv8-a TARGET_CPU_VARIANT := generic TARGET_CPU_ABI := arm64-v8a复制代码
与Secondary Arch相关的变量有TARGET_2ND_ARCH、TARGET_2ND_ARCH_VARIANT、TARGET_2ND_CPU_VARIANT等,具体范例如下:
TARGET_2ND_ARCH := arm TARGET_2ND_ARCH_VARIANT := armv7-a-neon TARGET_2ND_CPU_VARIANT := cortex-a15 TARGET_2ND_CPU_ABI := armeabi-v7a TARGET_2ND_CPU_ABI2 := armeabi复制代码
如果希望默认编译32-bit的可执行程序,可以设置:
TARGET_PREFER_32_BIT := true复制代码
通常lunch列表中会针对不同平台提供相应的选项,如图2-9所示。
▲图2-9 相应的选项
当开发者选择不同平台时,会直接影响到TARGET_2ND_ARCH等变量的赋值,从而有效控制编译流程。比如图2-10中左、右两侧分别对应我们使用lunch 1和lunch 2所产生的结果,大家可以对比下其中的差异。
▲图2-10 控制编译流程
另外,还可以设置TARGET_SUPPORTS_32_BIT_APPS和TARGET_SUPPORTS_64_BIT_APPS来指明需要为应用程序编译什么版本的本地库。此时需要特别注意:
如果这两个变量被同时设置,那么系统会编译64-bit的应用程序——除非你设置了TARGET_PREFER_32_BIT或者在Android.mk中对变量做了重载; 如果只有一个变量被设置了,那么就只编译与之对应的应用程序; 如果两个变量都没有被设置,那么除非你在Android.mk中做了变量重载,否则默认只编译32-bit应用程序。那么在支持不同位数的编译时,所采用的Tool Chain是否有区别?答案是肯定的。
如果你希望使用通用的GCC工具链来同时处理两种Arch架构,那么可以使用TARGET_GCC_VERSION_EXP;反之你可以使用TARGET_TOOLCHAIN_ROOT和2ND_TARGET_TOOLCHAIN_ROOT来为64和32位编译分别指定不同的工具链。
(2)单模块配置
我们当然也可以针对单个模块来配置Multilib。
对于可执行程序,编译系统默认情况下只会编译出64-bit的版本。除非我们指定了TARGET_PREFER_32_BIT或者LOCAL_32_BIT_ONLY。 对于某个模块依赖的库的编译方式,会和该模块有紧密关系。简单来讲32-bit的库或者可执行程序依赖的库,会被以32位来处理;对于64位的情况也同样如此。需要特别注意的是,在make命令中直接指定的目标对象只会产生64位的编译。举一个例子来说,“lunch aosp_arm64-eng”→“make libc”只会编译64-bit的libc。如果你想编译32位的版本,需要执行“make libc_32”。
描述单模块编译的核心脚本是Android.mk,在这个文件里我们可以通过指定LOCAL_MULTILIB来改变默认规则。各种取值和释义如下所示:
“first”只考虑Primary Arch的情况
“both”同时编译32和64位版本
“32”只编译32位版本
“64”只编译64位版本
“”这是默认值。编译系统会根据其他配置来决定需要怎么做,如LOCAL_MODULE_TARGET_ARCH,LOCAL_32_BIT_ONLY等。
如果你需要针对某些特定的架构来做些调整,那么以下几个变量可能会帮到你:
LOCAL_MODULE_TARGET_ARCH可以指定一个Arch列表,例如“arm x86 arm64”等。这个列表用于指定你的模块所支持的arch范围,换句话说,如果当前正在编译的arch不在列表中将导致本模块不被编译:
LOCAL_MODULE_UNSUPPORTED_TARGET_ARCH如其名所示,这个变量起到和上述变量相反的作用。
LOCAL_MODULE_TARGET_ARCH_WARN LOCAL_MODULE_UNSUPPORTED_TARGET_ARCH_WARN这两个变量的末尾多了个“WARN”,意思就是如果当前模块在编译时被忽略,那么会有warning打印出来。
各种编译标志也可以打上与Arch相应的标签,如以下几个例子:
LOCAL_SRC_FILES_arm, LOCAL_SRC_FILES_x86 LOCAL_CFLAGS_arm, LOCAL_CFLAGS_arm64 LOCAL_LDFLAGS_arm, LOCAL_LDFLAGS_arm64我们再来看一下安装路径的设置。对于库文件来说,可以使用LOCAL_MODULE_RELATIVE_PATH来指定一个不同于默认路径的值,这样32位和64位的库都会被放置到这里。对于可执行文件来说,可以分别使用以下两类变量来指定文件名和安装路径:
LOCAL_MODULE_STEM_32, LOCAL_MODULE_STEM_64分别指定32位和64位下的可执行文件名称。
LOCAL_MODULE_PATH_32, LOCAL_MODULE_PATH_64分别指定32位和64位下的可执行文件安装路径。
(3)Zygote
支持Multilib Build还需要考虑一个重要的应用场合,即Zygote。可想而知,Multilib编译会产生两个版本的Zygote来支持不同位数的应用程序,即Zygote64和Zygote32。早期的Android系统中,Zygote的启动脚本被直接书写在init.rc中。但从Lollipop开始,这种情况一去不复返了。我们来看一下其中的变化:
/system/core/rootdir/init.rc/ import /init.${ro.hardware}.rc import /init.${ro.zygote}.rc复制代码
根据系统属性ro.zygote的不同,init进程会调用不同的zygote描述脚本,从而启动不同版本的“孵化器”。以ro.zygote为“zygote64_32”为例,具体脚本如下:
/system/core/rootdir/init.zygote64_32.rc/ service zygote /system/bin/app_process64 -Xzygote /system/bin --zygote --start-system -server --socket-name=zygote class main socket zygote stream 660 root system onrestart write /sys/android_power/request_state wake onrestart write /sys/power/state on onrestart restart media onrestart restart netd service zygote_secondary /system/bin/app_process32 -Xzygote /system/bin --zygote -- socket-name=zygote_secondary class main socket zygote_secondary stream 660 root system onrestart restart zygote复制代码
这个脚本描述的是Primary Arch为64,Secondary Arch为32位时的情况。因为zygote的承载进程是app_process,所以我们可以看到系统同时启动了两个Service,即app_process64和app_process32。关于zygote启动过程中的更多细节,读者可以参考本书的系统启动章节,我们这里先不进行深入分析。
因为系统需要有两个不同版本的zygote同时存在,根据前面内容的学习我们可以断定,zygote的Android.mk中一定做了同时编译32位和64位程序的配置:
/frameworks/base/cmds/app_process/Android.mk/ LOCAL_SHARED_LIBRARIES := \ libcutils \ libutils \ liblog \ libbinder \ libandroid_runtime LOCAL_MODULE:= app_process LOCAL_MULTILIB := both LOCAL_MODULE_STEM_32 := app_process32 LOCAL_MODULE_STEM_64 := app_process64 include $(BUILD_EXECUTABLE)复制代码
上面这个脚本可以作为需要支持Multilib build的模块的一个范例。其中LOCAL_MULTILIB告诉系统,需要为zygote生成两种类型的应用程序;而LOCAL_MODULE_STEM_32和LOCAL_MODULE_STEM_64分别用于指定两种情况下的应用程序名称。
2.5 Android系统映像文件
通过前面几个小节的学习,我们已经按照产品需求编译出自定制的Android版本了。编译成功后,会在out/target/product/[YOUR_PRODUCT_NAME]/目录下生成最终要烧录到设备中的映像文件,包括system.img,userdata.img,recovery.img,ramdisk.img等。初次看到这些文件的读者一定想知道为什么会生成这么多的映像、它们各自都将完成什么功能。
这是本小节所要回答的问题。
Android中常见image文件包的解释如表2-7所示。
表2-7 Android系统常见image释义
Image | Description |
---|---|
boot.img | 包含内核启动参数、内核等多个元素(详见后面小节的描述) |
ramdisk.img | 一个小型的文件系统,是Android系统启动的关键 |
system.img | Android系统的运行程序包(framework就在这里),将被挂载到设备中的/system节点下 |
userdata.img | 各程序的数据存储所在,将被挂载到/data目录下 |
recovery.img | 设备进入“恢复模式”时所需要的映像包 |
misc.img | 即“miscellaneous”,包含各种杂项资源 |
cache.img | 缓冲区,将被挂载到/cache节点中 |
它们的关系可以用图2-11来表示。
接下来对boot、ramdisk、system三个重要的系统image进行深入解析。
▲图2-11 关系图
2.5.1 boot.img
理解boot.img的最好方法就是学习它的制作工具—— mkbootimg,源码路径在system/core/ mkbootimg中。这个工具的语法规则如下:
mkbootimg --kernel--ramdisk [ --second <2ndbootloader-filename>] [ --cmdline ] [ --board ] [ --base ] [ --pagesize ] -o|--output 复制代码
--kernel:指定内核程序包(如zImage)的存放路径;
--ramdisk:指定ramdisk.img(下一小节有详细分析)的存放路径;
--second:可选,指第二阶段文件;
--cmdline:可选,内核启动参数;
--board:可选,板名称;
--base:可选,内核启动基地址;
--pagesize:可选,页大小;
--output:输出名称。
那么,编译系统是在什么地方调用mkbootimg的呢?
其一就是droidcore的依赖中,INSTALLED_BOOTI MAGE_TARGET,如图2-12所示。
▲图2-12 droidcore的依赖
其二就是生成INSTALLED_BOOTIMAGE_TARGET的地方(build/core/Makefile),如图2-13所示。
▲图2-13 生成INSTALLED_BOOTIMAGE_TARGET的地方
可见mkbootimg程序的各参数是由INTERNAL_BOOTIMAGE_ARGS和BOARD_MKBOOTIMG_ARGS来指定的,而这两者又分别取决于其他makefile中的定义。如BoardConfig.mk中定义的BOARD_KERNEL_CMDLINE在默认情况下会作为--cmdline参数传给mkbootimg;BOARD_KERNEL_BASE则作为--base参数传给mkbootimg。
按照Bootimg.h中的描述,boot.img的文件结构如图2-14所示。
▲图2-14 boot.img的文件结构
各组成部分如下:
1.boot header
存储内核启动“头部”—— 内核启动参数等信息,占据一个page空间,即4KB大小。Header中包含的具体内容可以通过分析Mkbootimg.c中的main函数来获知,它实际上对应boot_img_hdr这个结构体:
/system/core/mkbootimg/Bootimg.h/ struct boot_img_hdr { unsigned char magic[BOOT_MAGIC_SIZE]; unsigned kernel_size; / size in bytes / unsigned kernel_addr; / physical load addr / unsigned ramdisk_size; / size in bytes / unsigned ramdisk_addr; / physical load addr / unsigned second_size; / size in bytes / unsigned second_addr; / physical load addr / unsigned tags_addr; / physical addr for kernel tags / unsigned page_size; / flash page size we assume / unsigned unused[2]; / future expansion: should be 0 / unsigned char name[BOOT_NAME_SIZE]; / asciiz product name / unsigned char cmdline[BOOT_ARGS_SIZE]; unsigned id[8]; / timestamp / checksum / sha1 / etc / };复制代码
这样讲有点抽象,下面举个实际的boot.img例子,我们可以用UltraEditor或者WinHex把它打开,如图2-15所示。
可以看到,文件最起始的8个字节是“ANDROID!”,也称为BOOT_MAGIC;后续的内容则包括kernel_size,kernel_addr等,与上述的boot_img_hdr结构体完全吻合。
▲图2-15 boot header实例
2.kernel
内核程序是整个Android系统的基础,也被“装入”boot.img中——我们可以通过--kernel选项来指定内核映射文件的存储路径。其所占据的大小为:
n pages=(kernel_size + page_size - 1) / page_size复制代码
由此可以看出,boot.img中的各元素必须是页对齐的。
3.ramdisk
不仅是kernel,boot.img中也包含了ramdisk.img。其所占据大小为:
m pages=(ramdisk_size + page_size - 1) / page_size复制代码
可见也是页对齐的。
其他关于ramdisk的详细描述请参照下一小节,这里先不做解释。
4.second stage
这一项是可选的。其占据大小为:
o pages= (second_size + page_size - 1) / page_size复制代码
这个元素通常用于扩展功能,默认情况下可以忽略。
2.5.2 ramdisk.img
无论什么类型的文件,从计算机存储的角度来说都只不过是一堆“0”“1”数字的集合—— 它们只有在特定处理规则的解释下才能表现出意义。如txt文本用Ultra Editor打开就可以显示出里面的文字;jpg图像文件在Photoshop工具的辅助下可以让用户看到其所包含的内容。而文本与jpeg图像文件本质上并没有区别,只不过存储与读取这一文件的“规则”发生了变化—— 正是这些“五花八门”的“规则”才创造出成千上万的文件类型。
另外,文件后缀名也并不是必需的,除非操作系统用它来鉴别文件的类型。而更多情况下,后缀名的存在只是为了让用户有个直观的认识。如我们会认为“.txt”是文本文档、“.jpg”是图片等。
Android的系统文件以“.img”为后缀名,这种类型的文件最初用来表示某个disk的完整复制。在从原理的层面讲解这些系统映像之前,可以通过一种方式来让读者对这些文件有个初步的感性认识(下面的操作以ramdisk.img为例,其他映像文件也是类似的)。
首先对ramdisk.img执行file命令,得到如下结果:
$file ramdisk.img ramdisk.img: gzip compressed data, from Unix复制代码
这说明它是一个gZip的压缩文件。我们将其改名为ramdisk.img.gz,再进行解压。具体命令如下:
$gzip –d ramdisk.img.gz复制代码
这时会得到另一个名为ramdisk.img的文件,不过文件类型变了:
$file ramdisk.img ramdisk.img: ASCII cpio archive (SVR4 with no CRC)复制代码
由此可知,这时的ramdisk.img是CPIO文件了。
再来执行以下操作:
$cpio -i -F ramdisk.img 3544 blocks复制代码
这样就解压出了各种文件和文件夹,范例如图2-16所示。
▲图2-16 范例
可以清楚地看到,常用的system目录、data目录以及init程序(系统启动过程中运行的第一个程序)等文件都包含在ramdisk.img中。
这样我们可以得出一个大致的结论,ramdisk.img中存放的是root根目录的镜像(编译后可以在out/target/product/[YOUR_PRODUCT_NAME]/root目录下找到)。它将在Android系统的启动过程中发挥重要作用。
2.5.3 system.img
要将system.img像ramdisk.img一样解压出来会相对麻烦一些。不过方法比较多,除了以下提到的方式,读者还可以尝试使用unyaffs(参考. google.com/p/yaffs2utils/)来实现。
这里我们采取mount的方法,这是目前最省时省力的解决方式。
步骤如下:
simg2img编译成功后,这个工具的可执行文件在out/host/linux-x86/bin中。
源码目录 system/extras/ext4_utils。
将此工具复制到与system.img同一目录下。
执行如下命令可以查询simg2img的用法:
$ ./simg2img --h Usage: simg2img复制代码
对system.img执行:
$ ./simg2img system.img system.img.step1复制代码mount
将上一步得到的文件通过以下操作挂载到system_extracted中:
$ mkdir system_extracted $ sudo mount -o loop system.img.step1 system_extracted复制代码
最终我们得到如图2-17所示的结果。
▲图2-17 结果图
这说明该image文件包含了设备/system节点中的相关内容。
2.5.4 Verified Boot
Android领域的开放性催生了很多第三方ROM的繁荣(例如市面上“五花八门”的Recovery、定制的Boot Image、System Image等),同时也给系统本身的安全性带来了挑战。
从4.4版本开始,Android结合Kernel的dm-verity驱动能力实现了一个名为“Verified Boot”的安全特性,以期更好地保护系统本身免受恶意程序的侵害。我们在本小节将向大家讲解这一特性的基本原理,以便读者们在无法成功利用fastboot写入image时可以清楚地知道隐藏在背后的真正原因。
我们先来熟悉表2-8所示的术语。
当设备开机以后,根据Boot State和Device State的状态值不同,有如图2-18所示几种可能性。
表2-8 Verified Boot相关术语
术 语 | 释 义 |
---|---|
dm-verity | Linux kernel的一个驱动,用于在运行时态验证文件系统分区的完整性(判断依据是Hash Tree和Signed metadata) |
Boot State | 保护等级,分为GREEN、YELLOW、ORANGE和RED四种 |
Device State | 表明设备接受软件刷写的程度,通常有LOCKED和UNLOCKED两种状态 |
Keystore | 公钥合集 |
OEM key | Bootloader用于验证boot image的key |
▲图2-18 Verified Boot总体流程
(引用自Android官方文档)最下方的4个圆圈颜色分别为:GREEN、YELLOW、RED和ORANGE。例如当前设备的Device State是LOCKED,那么就首先需要经历OEM KEY Verification——如果通过的话Boot State是GREEN,表示系统是安全的;否则需要进入下一轮的Signature Verification,其结果决定了Boot State是YELLOW或者是RED(比较危险)。当然,如果当前设备本身就是UNLOCKED的,那就不用经过任何检验——不过它和YELLOW、RED一样的地方是,都会在屏幕上显式地告诫用户潜在的各种风险。部分Android设备还会要求用户主动做出选择后才能正常启动,如图2-19所示典型示例。
如果设备的Device State发生切换的话(fastboot就提供了类似的命令,只不过大部分设备都需要解锁码才能完成),那么系统中的data分区将会被擦除,以保证用户数据的安全。
▲图2-19 典型示例
我们知道,Android系统在启动过程中要经过Bootloader->Kernel->Android三个阶段,因而在Verified Boot的设计中,它对分区的看护也是环环相扣的。具体来说,Bootloader承担boot和recovery分区的完整性校验职责;而Boot Partition则需要保证后续的分区,如system的安全性。另外,Recovery的工作和Boot是基本类似的。
不过,由于分区文件大小有差异,具体的检验手段也是不同的。结合前面小节对boot.img的描述,其在增加了verified boot后的文件结构变化如图2-20所示。
▲图2-20 文件结构变化
除了mkbootimg来生成原始的boot.img外,编译系统还会调用另一个新工具,即boot_signer(对应源码目录system/extras/verity)来在boot.img的尾部附加一个signature段。这个签名是针对boot.img的Hash结果展开的,默认使用的key在/build/target/product/security目录下。
而对于某些大块分区(如System Image),则需要通过dm-verity来验证它们的完整性。关于dm-verity还有非常多的技术细节,限于篇幅我们不做过多讨论,但强烈建议读者自行查阅相关资料做进一步深入学习。
2.6 ODEX流程
ODEX是Android旧系统的一个优化机制。对于很多开发人员来说,ODEX可以说是既熟悉又陌生。熟悉的原因在于目前很多手机系统,或者APK中的文件都从以前的格式变成了如图2-21和图2-22所示的样子。
而陌生的原因在于有关ODEX的资料并不是很多,不少开发人员对于ODEX是什么,能做什么以及它的应用流程并不清楚——这也是我们本小节所要向大家阐述的内容。
▲图2-21 系统目录system/framework下的文件列表
ODEX是Optimized Dalvik Executable的缩写,从字面意思上理解,就是经过优化的Dalvik可执行文件。Dalvik是Android系统(目前已经切换到Art虚拟机)中采用的一种虚拟机,因而经过优化的ODEX文件让我们很自然地想到可以为虚拟机的运行带来好处。
事实上也的确如此——ODEX是Google为了提高Android运行效率做出努力的成果之一。我们知道,Android系统中不少代码是使用Java语言编写的。编译系统首先会将一个Java文件编译成class的形式,进而再通过一个名为dx的工具来转换成dex文件,最后将dex和资源等文件压缩成zip格式的APK文件。换句话说,一个典型的Android APK的组成结构如图2-23所示。
▲图2-22 系统目录/system/app下的文件列表
▲图2-23 APK的组成结构
本书的Android应用程序编译和打包章节将做更为详细介绍。现在大家只要知道APK中有哪些组成元素就可以了。当应用程序启动时,系统需要提取图2-23中的dex(如果之前没有做过ODEX优化的话,或者/data/dalvik-cache中没有对应的ODEX缓存),然后才能执行加载动作。而ODEX则是预先将DEX提取出来,并针对当前具体设备做了优化工作后的产物,这样做除了能提高加载速度外,还有如下几个优势:
加大了破解程序的难度ODEX是在dex基础上针对当前具体设备所做的优化,因而它和生成时所处的具体设备有很大关联。换句话说,除非破解者能提供与ODEX生成时相匹配的环境文件(比如core.jar、ext.jar、framework.jar、services.jar等),否则很难完成破解工作。这就在无形中提高了系统的安全性。
节省了存储空间按照Android系统以前的做法,不仅APK中需要存放一个dex文件,而且/data/dalvik-cache目录下也会有一个dex文件,这样显然会浪费一定的存储空间。相比之下,ODEX只有一份,而且它比dex所占的体积更小,因而自然可以为系统节省更多的存储空间。
2.7 OTA系统升级
前面我们讨论了系统包烧录的几种传统方法,而Android系统其实还提供了另一种全新 的升级方案,即OTA(Over the Air)。OTA非常灵活,它既可以实现完整的版本升级,也可以做到增量升级。另外,用户既可以选择通过SD卡来做本地升级,也可以直接采用网络在线升级。
不论是哪种升级形式,都可以总结为3个阶段:
生成升级包; 获取升级包; 执行升级过程。下面我们来逐一分析这3个阶段。
2.7.1 生成升级包
升级包也是由系统编译生成的,其编译过程本质上和普通Android系统编译并没有太大区别。如果想生成完整的升级包,具体命令如下:
$make otapackage复制代码
注意
生成OTA包的前提是,我们已经成功编译生成了系统映像文件(system.img等)。
最终将生成以下文件:
out/target/product/[YOUR_PRODUCT_NAME]/[YOUR_PRODUCT_NAME]-ota-eng.[UID].zip复制代码
而生成差分包的过程相对麻烦一些,不过方法也很多。以下给出一种常用的方式:
将上一次生成的完整升级包复制并更名到某个目录下,如~/OTA_DIFF/old_target_file.zip;
对源文件进行修改后,用make otapackage编译出一个新的OTA版本;
将本次生成的OTA包更名后复制到和上一个升级包相同的目录下,如~/OTA_DIFF/ new_target_file.zip;
调用ota_from_target_files脚本来生成最终的差分包。
这个脚本位于:
build/tools/releasetools/ota_from_target_files复制代码
值得一提的是,完整升级包的生成过程其实也使用了这一脚本。区分的关键就在于使用时是否提供了-i参数。
其具体语法格式是:
ota_from_target_files [Flags] input_target_files output_ota_package复制代码
所有Flags参数释义如表2-9所示。
表2-9 ota_from_target_files参数
参 数 | 说 明 |
---|---|
-b (--board_config) <file> | 在新版本中已经无效 |
-k (--package_key) <key> | <key>用于包的签名默认使用input_target-files中的META/misc_info.txt文件如果此文件不存在,则使用build/target/product/security/testkey |
-i (--incremental_from) <file> | 该选项用于生成差分包 |
-w (--wipe_user_data) | 由此生成的OTA包在安装时会自动擦除user data 分区 |
-n (--no_prereq) | 忽略时间戳检查 |
-e (--extra_script) <file> | 将<file>内容插入update脚本的尾部 |
-a (--aslr_mode) <on|off> | 是否开启ASLR技术默认为开 |
在这个例子中,我们可以采用以下命令生成一个OTA差分包:
./build/tools/releasetools/ota_from_target_files-i ~/OTA_DIFF/old_target_file.zip~/OTA_DIFF/new_target_file.zip复制代码
这样生成的update.zip就是最终可用的差分升级包。一方面,差分升级包体积较小,传输方便;但另一方面,它对升级的设备有严格要求,即必须是安装了上一升级包版本的那些设备才能正常使用本次的OTA差分包。
2.7.2 获取升级包
如图2-24所示,有两种常见的渠道可以获取到OTA升级包,分别是在线升级和本地升级。
▲图2-24 获取OTA升级包的两种方式
1.在线升级
开发者将编译生成的OTA包上传至网络存储服务器上,然后用户可以直接通过终端访问和下载升级文件。通常我们把下载到的OTA包存储在设备的SD卡中。
在线升级的方式涉及两个核心因素。
服务器端的架构设备厂商需要架构服务器来存放、管理OTA包,并为客户端提供包括查询在内的多项服务。
客户端与服务器的交互方式客户终端如何与服务器进行交互,是否需要认证,OTA包如何传输等都是需要考虑的。
由此可见,在线升级方式要求厂商提供较好的硬件环境来解决用户大规模升级时可能引发的问题,因而成本较高。不过这种方式对消费者来说比较方便,而且可以实时掌握版本的最新动态,所以对凝聚客户有很大帮助。目前很多主流设备生产商(如HTC)和第三方的ROM开发商(如MIUI)都提供了在线升级模式。
服务器和客户端的一种理论交互方案可以参见图2-25所示的图例。
步骤如下:
在手动升级的情况下,由用户发出升级的指令;而在自动升级的情况下,则由程序根据一定的预设条件来启动升级流程。比如设定了开机自动检查是否有可用的更新,那么每次机器启动后都会去服务器取得最新的版本信息。
无论是手动还是自动升级,都必须通过服务器查询信息。与服务器的连接方式是多种多样的,由开发人员自行决定。在必要的情况下,还应该使用加密连接。
如果一切顺利,我们就得到了服务器上最新升级文件的版本号。接下来需要将这个版本号与本地安装的系统版本号进行比较,决定是否进入下一步操作。
如果服务器上的升级文件要比本地系统新(在制定版本号规则时,应尽量考虑如何可以保证新旧版本的快速比较),那么升级继续;否则中止升级流程——且若是手动升级的情况,一定要提示用户中止的原因,避免造成不好的用户体验。
升级文件一般都比较大(Android系统文件可能达到几百MB)。这么大的数据量,如果是通过移动通信网络(GSM\WCDMA\CDMA\TD-SCDMA等)来下载,往往不现实。因此如果没有事先知会用户而自动下载的话,很可能会引起用户的不满。“提示框”的设计也要尽可能便利,如可以让用户快捷地启用Wi-Fi通道进行下载。
下载后的升级文件需要存储在本地设备中才能进入下一步的升级。通常这一文件会直接被放置在SD卡的根目录下,命名为update.zip。
接下来系统将自动重启,并进入RecoveryMode进行升级。
▲图2-25 在线升级图例
2.本地升级
OTA升级包并非一定要通过网络在线的方式才可以下载到——只要条件允许,就可以从其他渠道获取到升级文件update.zip,并复制到SD卡的根目录下,然后手动进入升级模式(见下一小节)。
在线升级和本地升级各有利弊,开发商应根据实际情况来提供最佳的升级方式。
2.7.3 OTA升级——Recovery模式
经过前面小节的讲解,现在我们已经准备好系统升级文件了(不论是在线还是本地升级),接下来就进入OTA升级最关键的阶段——Recovery模式,也就是大家俗称的“卡刷”。
Recovery相关的源码主要在工程项目的如下目录中:
\bootable\recovery
因为涉及的模块比较多,这个文件夹显得有点杂乱。我们只挑选与Recovery刷机有关联的部分来进行重点分析。
▲图2-26 进入RecoveryMode的流程
图2-26所示是Android系统进入RecoveryMode的判断流程,可见在如下两种情况下设备会进入还原模式。
开机过程中检测到RecoveryKey按下很多Android设备的RecoveryKey都是电源和Volume+的组合键,因为这两个按键在大部分设备上都是存在的。
系统指定进入RecoveryMode系统在某些情况下会主动要求进入还原模式,如我们前面讨论的“在线升级”方式——当OTA包下载完成后,系统需要重启然后进入RecoveryMode进行文件的刷写。
当进入RecoveryMode后,设备会运行一个名为“Recovery”的程序。这个程序对应的主要源码文件是/bootable/recovery/ recovery.cpp,并且通过如下几个文件与Android主系统进行沟通。
(1)/cache/recovery/command INPUT
Android系统发送给recovery的命令行文件,具体命令格式见后面的表格。
(2)/cache/recovery/log OUTPUT
recovery程序输出的log文件。
(3)/cache/recovery/intent OUTPUT
recovery传递给Android的intent。
当Android系统希望开机进入还原模式时,它会在/cache/recovery/command中描述需要由Recovery程序完成的“任务”。后续Recovery程序通过解析这个文件就可以知道系统的“意图”,如表2-10所示。
表2-10 CommandLine参数释义
Command Line | Description |
---|---|
--send_intent=anystring | 将text输出到recovery.intent中 |
--update_package=path | 安装OTA包 |
--wipe_data | 擦除user data,然后重启 |
--wipe_cache | 擦除cache(不包括user data),然后重启 |
--set_encrypted_filesystem=on|off | enable/disable加密文件系统 |
--just_exit | 直接退出,然后重启 |
由表格所示的参数可以知道Recovery不但负责OTA的升级,而且也是“恢复出厂设置”的实际执行者,如图2-27所示。
▲图2-27 系统设置中的“恢复出厂设置”
接下来分别讲解这两个功能在Recovery程序中的处理流程。
恢复出厂设置。
(1)用户在系统设置中选择了“恢复出厂设置”。
(2)Android系统在/cache/recovery/command中写入“--wipe_data”。
(3)设备重启后发现了command命令,于是进入recovery。
(4)recovery将在BCB(bootloader control block)中写入“boot-recovery”和“--wipe_data”,具体是在get_args()函数中——这样即便设备此时重启,也会再进入erase流程。
(5)通过erase_volume来重新格式化/data。
(6)通过erase_volume来重新格式化/cache。
(7)finish_recovery将擦除BCB,这样设备重启后就能进入正常的开机流程了。
(8)main函数调用reboot来重启。
上述过程中的BCB是专门用于recovery和bootloader间互相通信的一个flash块,包含了如下信息:
struct bootloader_message { char command[32]; char status[32]; char recovery[1024]; };复制代码
依据前面对Android系统几大分区的讲解,BCB数据应该存放在哪个image中呢?没错,是misc。
OTA升级具体如下。
(1)OTA包的下载过程参见前一小节的介绍。假设包名是update.zip,存储在SDCard中。
(2)系统在/cache/recovery/command中写入"--update_package=[路径名]"。
(3)系统重启后检测到command命令,因而进入recovery。
(4)get_args将在BCB中写入"boot-recovery" 和 "--update_package=..." —— 这样即便此时设备重启,也会尝试重新安装OTA升级包。
(5)install_package开始安装OTA升级包。
(6)finish_recovery擦除BCB,这样设备重启后就可以进入正常的开机流程了。
(7)如果install失败的话:
prompt_and_wait显示错误,并等待用户响应; 用户重启(比如拔掉电池等方式)。(8)main调用maybe_install_firmware_update,OTA包中还可能包含radio/hboot firmware的更新,具体过程略。
(9)main调用reboot重启系统。
总体来说,整个Recovery.cpp源文件的逻辑层次比较清晰,读者可以基于上述流程的描述来对照并阅读代码。
2.8 Android反编译
目前我们已经学习了Android原生态系统及定制产品的编译和烧录过程。和编译相对的,却同样重要的是反编译。比如,一个优秀的“用毒”高手往往也会是卓越的“解毒”大师,反之亦然。大自然的一个奇妙之处即万事万物都是“相生相克”的,只有在竞争中才能不断地进步和发展。
首先要纠正不少读者可能会持有的观点——“反编译”就是去“破解”软件。应该说,破解一款软件的确需要用到很多反编译的知识,不过这并不是它的全部用途。比如笔者就曾经在开发过程中利用反编译辅助解决了一个bug,在这里和读者分享一下。
问题是这样的:开发人员A修改了framework中的某个文件,然后通过正常的编译过程生成了image,再将其烧录到了机器上。但奇怪的是,文件的修改并没有体现出来(连新加的log也没有打印出来)。显然,出现问题的可能是下列步骤中的任何一个,如图2-28所示。
▲图2-28 可能出现问题的几个步骤
可疑点为:
程序没有执行到打印log的地方因为加log的那个函数是系统会频繁调用到的,而且log就放在函数开头没有加任何判断,所以这个可能性被排除。
log被屏蔽打印log所用的方法与此文件中其他地方所用的方法完全一致,而且其他地方的log确实成功输出了,所以也排除这一可能性。
修改的文件没有被编译到虽然Android的编译系统非常强大,但是难免会有bug,因而这个可能性还是存在的。那么如何确定我们修改的文件真的被编译到了呢?此时反编译就有了用武之地了。
文件确实被编译到,但是烧录时出现了问题这并不是空穴来风,确实发生过开发人员因为粗心大意烧错版本的“事故”(对于某些细微修改,编译系统不会主动产生新的版本号)。通过反编译机器上的程序,然后和原始文件进行比较,我们可以清楚地确认机器中运行的程序是不是预期的版本。
由上述分析可知,反编译是确定该问题最直接的方式。
Android反编译过程按照目标的不同分为如下两类(都是基于Java语言的情况)。
APK应用程序包的反编译。 Android系统包(如本例中的framework)的反编译。不论针对哪种目标对象,它们的步骤都可以归纳为如图2-29所示。
APK应用安装包实际上是一个Zip压缩包,使用Zip或WinRAR等软件打开后里面有一个“classes.dex”文件—— 这是Dalvik JVM虚拟机支持的可执行文件(Dalvik Executable)。关于这个文件的生成过程,可以参见本书应用篇中对APK编译过程的介绍。换句话说,classes.dex这个文件包含了所有的可执行代码。
▲图2-29 反编译的一般流程
由前面小节的学习我们知道,odex是classes.dex经过dex优化(optimize)后产生的。一方面,Dalvik虚拟机会根据运行需求对程序进行合理优化,并缓存结果;另一方面,因为可以直接访问到程序的odex,而不是通过解压缩包去获取数据,所以无形中加快了系统开机及程序的运行速度。
针对反编译过程,我们首先是要取得程序的dex或者odex文件。如果是APK应用程序,只需要使用Zip工具解压缩出其中的classes.dex即可(有的APK原始的classes.dex会被删除,只保留对应的odex文件);而如果是包含在系统image中的系统包(如framework就是在system.img中),就需要通过其他方法间接地将其原始文件还原出来。具体步骤可以参见前一小节的介绍。
取得dex/odex文件后,我们将它转化成Jar文件。
odex目前已经有不少研究项目在分析Android的逆向工程,其中最著名的就是smali/baksmali。可以在这里下载到它的最新版本:
复制代码
“smali”和“baksmali”分别对应冰岛语中“assembler”和“disassembler”。为什么要用冰岛语命名呢?答案就是Dalvik这个名字实际上是冰岛的一个小渔村。
如果是odex,需要先用baksmali将其转换成dex。具体语法如下:
$ baksmali -a-x -d 复制代码
-a指定了API Level,-x表示目标odex文件,-d指明了framework路径。因为这个工具需要用到诸如core.jar,ext.jar,framework.jar等一系列framework包,所以建议读者直接在Android源码工程中out目录下的system/framework中进行操作,或者把所需文件统一复制到同一个目录下。
范例如下(1.4.1版本):
$ java -jar baksmali-1.4.1.jar -a 16 -x example.odex复制代码
如果是要反编译系统包中的odex(如services.odex),请参考以下命令:
$java -Xmx512m -jar baksmali-1.4.1.jar -a 16 -c:core.jar:bouncycastle.jar:ext.jar:framework. jar:android.policy.jar:services.jar:core-junit.jar -d framework/ -x services.odex复制代码
更多语法规则可以通过以下命令获取:
$ java -jar baksmali-1.4.1.jar --help复制代码
执行结果会被保存在一个out目录中,里面包含了与odex相应的所有源码,只不过由smali语法描述。读者如果有兴趣的话,可以阅读以下文档来了解smali语法:
复制代码
当然对于大部分开发人员来说,还是希望能反编译出最原始的Java语言文件。此时就要再将smali文件转化成dex文件。具体命令如下:
$ java -jar smali-1.4.1.jar out/ -o services.dex复制代码
于是接下来的流程就是dex→Java,请参考下面的说明。
dex前面我们已经成功将odex“去优化”成dex了,离胜利还有一步之遥——将dex转化成jar文件。目前比较流行的工具是dex2jar,可以在这里下载到它的最新版本:
使用方法也很简单,具体范例如下:
$ ./dex2jar.sh services.dex复制代码
上面的命令将生成services_dex2jar.jar,这个Jar包中包含的就是我们想要的原始Java文件。那么,选择什么工具来阅读Jar中的内容呢?在本例中,我们只是希望确定所加的log是否被正确编译进目标文件中,因而可以使用任何常用的文本编辑器查阅代码。而如果希望能更方便地阅读代码,推荐使用jd-gui,它是一款图形化的反编译代码阅读工具。
这样,整个反编译过程就完成了。
顺便提一下,目前,几乎所有的Android程序在编译时都使用了“代码混淆”技术,反编译后的结果和原始代码还是有一定差距,但不影响我们理解程序的主体架构。“代码混淆”可以有效地保护知识产权,防止某些不法分子恶意剽窃,或者篡改源码(如添加广告代码、植入木马等),建议大家在实际的项目开发中尽量采用。
2.9 NDK Build
我们知道Android系统下的应用程序主要是由Java语言开发的,但这并不代表它不支持其他语言,比如C++和C。事实上,不同类型的应用程序对编程语言的诉求是有区别的——普通Application的UI界面基本上是静态的,所以,利用Java开发更有优势;而游戏程序,以及其他需要基于OpenGL(或基于各种Game Engine)来绘制动态界面的应用程序则更适合采用C或者C++语言。
伴随着Android系统的不断发展,开发者对于C/C++语言的需求越来越多,也使得Google需要不断完善它所提供的NDK工具链。NDK的全称是Native Development Kit,可以有效支撑Android系统中使用C/C++等Native语言进行开发,从而让开发者可以:
提高程序运行效率完成同样的功能,Java虚拟机理论上来说比C/C++要耗费更多的系统资源。因而,如果程序本身对运行性能要求很高的话,建议利用NDK进行开发。
复用已有的C和C++库好处是显而易见,即最大程度地避免重复性开发。
NDK的官方网址是:
复制代码
它的安装很简单,在Windows下只要下载一个几百MB的自解压包然后双击打开它就可以了。NDK文件夹可以被放置到磁盘中的任何位置,不过为了操作方便,建议开发者可以设置系统环境变量来指向其中的关键程序。NDK既支持Java和C/C++混合编程的模式,也允许我们只开发纯Native实现的程序。前者需要用到JNI技术(即Java Native Interface),它的神奇之处在于可以让两种看似没有瓜葛的语言间进行无缝的调用。例如下面是一个JNI的实例:
public class MyActivity extends Activity { /* Native method implemented in C/C++ */ public native void jniMethodExample(); }复制代码
MyActivity是一个Java类,它的内部包含一个声明为Native的成员变量,即jniMethodExample。这个函数的实现是通过C/C++完成的,并被编译成so库来供程序加载使用。更多JNI的细节,我们将在后续章节进行详细介绍。
本小节我们将通过一个具体实例来着重讲解如何利用NDK来为应用程序执行C/C++的编译。
在此之前,请确保你已经下载并解压了NDK包,并为它设置了正确的系统环境变量。这个例子中将包含如下几个文件,我们统一放在一个JNI文件夹中:
Android.mk; Application.mk; testNative.cpp。Android.mk用于描述一个Android的模块,包括应用程序、动态库、静态库等。它和我们本章节讲解的用法基本一致,因而不再赘述。
Application.mk用于描述你的程序中所用到的各个Native模块(可以是静态或者动态库,或者可执行程序)。这个脚本中常用的变量不多,我们从中挑选几个核心的来讲解:
1.APP_PROJECT_PATH
指向程序的根目录。当然,如果你是按照Android系统默认的结构来组织工程文件的话,这个变量是可选的。
2.APP_OPTIM
用于指示当前是release或者debug版本。前者是默认的值,将会生成优化程度较高的二进制文件;调试模式则会生成未优化的版本,以便保留更多的信息来帮助开发者追踪问题。在AndroidManifest.xml的<application>标签中声明android:debuggable会将默认值变更为debug,不过APP_OPTIM的优先级更高,可以重载debuggable的设置。
3.APP_CFLAGS
设置对全体module有效的C/C++编译标志。
4.APP_LDFLAGS
用于描述一系列链接器标志,不过只对动态链接库和可执行程序有效。如果是静态链接库的情况,系统将忽略这个值。
5.APP_ABI
用于指示编译所针对的目标Application Binary Interface,默认值是armeabi。可选值如表2-11所示。
表2-11 可选值
指 令 集 | ABI值 |
---|---|
Hardware FPU instructions on ARMv7 based devices | APP_ABI := armeabi-v7a |
ARMv8 AArch64 | APP_ABI := arm64-v8a |
IA-32 | APP_ABI := x86 |
Intel64 | APP_ABI := x86_64 |
MIPS32 | APP_ABI := mips |
MIPS64 (r6) | APP_ABI := mips64 |
All supported instruction sets | APP_ABI := all |
文件testNative.cpp中的内容就是程序的源码实现,对此NDK官方提供了较为完整的Samples供大家参考,涵盖了OpenGL、Audio、Std等多个方面,有兴趣的读者可以自行下载分析。
那么有了这些文件后,如何利用NDK把它们编译成最终产物呢?
最简单的方式就是采用如下的命令:
cd$ /ndk-build复制代码
其中ndk-build是一个脚本,等价于:
$GNUMAKE -f/build/core/build-local.mk 复制代码
<ndk>指的是NDK的安装路径。
可见使用NDK来编译还是相当简单的。另外,除了常规的编译外,ndk-build还支持多种选项,譬如:
“clean”表示清理掉之前编译所产生的各种中间文件;
“-B”会强制发起一次完整的编译流程;
“NDK_LOG=1”用于打开NDK的内部log消息;
……
2.10 第三方ROM的移植
除了本章所描述的Android原生代码外,开发人员也可以选择一些知名的第三方开源ROM来进行学习,譬如CyanogenMod。
CyanogenMod(简称CM)的官方网址如下:
它目前的最新版本是基于Android 6.0的CM 13,并同时支持Google Nexus、HTC、Huawei、LG等多个品牌的众多设备。CyanogenMod的初衷是将Android系统移植到更多的没有得到Google官方支持的设备中,所以有的时候CM针对某特定设备的版本更新时间可能比设备厂商来得还要早。
那么CyanogenMod是如何做到针对多种设备的移植和适配工作的呢?我们将在接下来的内容中为大家揭开这个问题的答案。图2-30是CM的整体描述图。
▲图2-30 CM的整体描述
下面我们分步骤进行讲解。
Step1. 前期准备
在做Porting之前,有一些准备工作需要我们去完成。
(1)获取设备的Product Name、Code Name、Platform Architecture、Memory Size、Internal Storage Size等信息
这些数据有很多可以从/system/build.prop文件中获得,不过前提条件是手机需要被root。
(2)收集设备对应的内核源码
根据GPL开源协议的规定,Android厂商必须公布受GPL协议保护的内容,包括内核源码。因而实现这一步是可行的,只是可能会费些周折。
(3)获取设备的分区信息
Step2. 建立3个核心文件夹
分别是:
device/[vendor]/[codename]/设备特有的配置和代码将保存在这个路径下。
vendor/[vendor]/[codename]/这个文件夹中的内容是从原始设备中拉取出来的,由此可见主要是那些没有源代码可以生成的部分,例如一些二进制文件。
kernel/[vendor]/[codename]/专门用于保存内核版本源码的地方。
CM提供了一个名为mkvendor.sh的脚本来帮助创建上述文件夹的“雏形”,有兴趣的读者可以参见build/tools/device/mkvendor.sh文件。不过很多情况下还需要开发者手工修改其中的部分文件,例如device目录下的BoardConfig.mk、device_[codename].mk、cm.mk、recovery.fstab等核心文件。
Step3. 编译一个用于测试的recovery image
编译过程和普通CM编译的最大区别在于选择make recoveryimage。如果在recovery模式下发现Android设备的硬件按键无法使用,那么可以尝试修改/device/[vendor]/[codename]/recovery/ recovery_ui.cpp中的GPIO配置。
Step4. 为上述的device目录建立github仓库,以便其他人可以访问到。
Step5. 填充vendor目录
可以参考CM官网上成熟的设备范例提供的extract-files.sh和setup-makefiles.sh,并据此完成适合自己的这两个脚本。
Step6. 通过CM提供的编译命令最终编译出ROM升级包,并利用前面生成的recovery来将其刷入到设备中。这个过程很可能不是“一蹴而就”的,需要不断调试和修改,直至成功。
当然,限于篇幅我们在本小节只是讲解了CM升级包的核心制作过程,读者如果有兴趣的话可以查阅