现在的位置: 首页 > 自动控制 > 工业·编程 > 正文

Android应用程序开发以及背后的设计思想深度剖析(5)

2015-08-09 05:11 工业·编程 ⁄ 共 18819字 ⁄ 字号 暂无评论

1.5 功耗控制

在嵌入式领域,功耗与运算量几乎成正比。操作系统里所需要的功能越来越复杂、安全性需求越来越高,则会需要更强大的处理能力支持。像在老式的实时操作系统里,没有进程概念,不需要虚拟内存支持,这时即便是写一些简单应用,所需要的运算量、内存都非常小,而一旦换用支持虚拟内存的系统,则所需要的硬件处理能力、电量都会成倍上涨,像一些功能性手机平台,可以成为一台不错的手机,但运行起一个Linux操作系统都很困难。而随着操作系统能力增强,则所能支持的硬件又得以提升,可以使用更大的屏幕、使用更大量内存、支持更多的无线芯片,这些功能增强的同时,也进一步加剧了电量的消耗。虽然现在芯片技术不断提高生产工艺降低制程(就是芯片内部烧写逻辑时的门电路尺寸),几乎都已经接近了物理上的极限(40纳米、28纳米、22纳米),但是出于设计更复杂芯片为目的的,随着双核、四核、以及越来越高的工作频率,事实上,功耗问题不但没有降低,反而进一步被加剧了。

面对这样越来越大的功耗上的挑战,Android在设计上,必须在考虑其他设计因素之前,更关注功耗控制问题。Android在设计上的一些特点,使系统所需要的功耗要高于传统设计:Android是使用Java语言执行环境的,所有在虚拟机之上运行的代码都需要更大的运算量,使用机器代码中需要一条指令的地方,在虚拟机环境下执行则可能需要十几条指令;与其他伪多任务不同,Android是真实多任务的,多任务则意味着在同一时刻会有更多任务在运行;Android是构建上Linux内核之上的系统,Linux内核在性能上表现奇佳,在功耗处理上则是短板,就拿PC环境来说,Linux的桌面环境在功耗控制上从来不如其他操作系统,MacOS或是Windows。

当然,有时没有历史包袱,也未必就是坏事,比如Linux内核在功耗管理上做得还不够好,于是就不会在Linux内核环境里死磕,Android可以通过新的设计来进行功耗控制上的提升。出于跟前面我们所说过的可减小对Linux内核依赖性、加强系统可移植性的设计需求,于是不可避免的,功耗控制将会尽可能多地被推到系统的上层。在我们前面对于安全性的分层中可以看到,Android相当于把整个操作系统都在用户态重新设计了一次,SystemServer这个系统级进程相当于用户态的一个Linux内核,于是将功耗控制更多地抽到用户态来执行,也没有什么不合理的。

在Android的整体系统设计里,功耗控制会先从应用程序着手,通过多任务并行时减小不必要的开销开始;在整个系统构架里,唯一知道当前系统对功耗需求的是SystemServer,于是可以通过相应的安全接口,将功耗的控制提取出来,可由SystemServer来进行后续的处理。Android系统所面临的运行环境需求里,电源是极度有限的资源,于是功耗控制应该是暴力型的,尽可能有能力关闭不需要使用的电源输出。当然暴力关电,则可能引起某些外设芯片不正常工作,于是在芯片驱动里需要做小范围修改。与其他功能部分的设计不同,既然我们功耗控制是通过与驱动打交道来实现,可能无法避免地需要驱动,但要让修改尽可能小,以提供可移植性。

在这种修改方案里,最需要解决的当然首先是多任务处理。我们可以得到的就是我们的生命周期。所谓的生命周期,是不是仅仅只是提供更多一些编程上的回调接口而已呢?不仅如此,我们的所谓生命周期是一种休眠状态点,更多地起到休眠操作时我们有机会插入代码的作用。如果仅是提供编程功能,我们可以参考JAVA ME里对于应用程序实现:

JAVA ME框架里对待应用程序只有三个状态点,运行、暂停、关闭,对应提供三种回调接口就可以驱动起这种编程模型。但我们的Android不是这样处理的,Android在编程模型上,把带显示与不带显示的代码逻辑分别抽象成Activity与Service,每种不同逻辑实现都有其独特的生命周期,以更好地融入到系统的电源管理框架里。

像我们的与显示相关的处理,Activity,它拥有6种不同状态:

它的不同生命周期阶段,取决于这一Activity是否处于交互状态,是否处理可见状态。如果加入这两个限制条件,于是Activity的生命周期则是为这两种状态而设计的。onResume()与onResume()分别是进入交互与退出交互时的状态点,在onResume()执行完之后,这时系统进入了交互状态,也就是Activity的Running状态,而此时如果由于Activity发生调用或是另一个Activity主动执行,弹出一个小对话框,使原来处于Running状态的Activity被挡住,这时Activity就被视为不需要交互了,这时Activity进入不可见互状态,触发onPause()回调。onStart()与onStop()则是对应于是否可见,在onStart()回调之后,应用程序这里就可以被显示出来,但不会真正进入交互期,当Activity变得完全不可见之后,则会触发onStop()。而Android的多任务实现,还会造成进程会被杀死掉,于是也提供两个onCreate()与onDestroy()两种回调方法来提供进程被创建之后与进程被杀死之前的两种不同操作。

这种设计的技巧在于,当Activity处于可交互状况时,这是系统里的全马力执行的周期。而再向外走一个状态期,只是处于可见但不可交互状态时,我们就可以开始通过技巧降功耗了,比如此时界面不再刷新、可以关闭一些所有与用户交互相关的硬件。当Activity再进一步退出可见状态时,可以进一步退出所有硬件设备的使用,这时就可以全关电了。编写应用程序时,当我们希望它有不一样的表现时,我们可以去通过IoC去灵活地覆盖并改进这些回调接口,而假如这种标准的模型满足我们的需求,我们就什么都不需要用,自动地被这种框架所管理起来。

当然,这种模型也不符合所有的需求,比如对于很多应用程序来说,在后台不可见状态下,仍然需要做一些特定的操作。于是Android的应用程序模型里,又增加了一个Service。对于一些暴力派的开发者,比较喜欢使用后台线程来实现这种需求,但这种实现在Android并不科学,因为只通过Activity承载的后台线程,有可能会被杀死掉,在有状态更新需求时,后台线程需要通过Activity重绘界面,实际上这样也会破坏Android在功耗控制上的这种合理性设计。比较合适的做法,所有不带界面、需要在后台持续进行某些操作的实现,都需要使用Service来实现,而状态显示的改变应该是在onStart()里完成的,状态上的交互则需要放到onResume()方法里,这样的实现可以有效绕开进程被杀死的问题。并且在我们后面介绍AIDL的部分,还可以看到,这样实现还可以加强后台任务的可交互性,当我们进一步将Service通过AIDL转换成Remote Service之后,则我们的实现会具备强大的可复用性,多个进程都可以访问到。

Service也会有其生存周期,但Service的生存周期相对而言要简单得多,因为它的生存周期只存在“是否正在被使用”的区别。当然,同样出于Android的多任务设计,“使用中”这个状态之外,也会有进程是否存在的状态。

于是,我们的Service也可被纳入到这种代码活跃状态的受控环境,当是不需要与后台的Service发生交互,这时,我们可能只是通过一个startService()发出Intent,这时Service在执行完相应的处理请求则直接退出。而如果是一个AIDL方式抛出的Remote Service,或是自己进程范围内的Service,但使用bindService()进行了交互,这时,Service的运行状态,只处于onBind()与OnUnbind()回调方法之间。

当我们的应用程序的各种不同执行逻辑,都是处于一个可控状态下时,这时,我们的功耗控制就可以被集中到一个系统进程的SystemServer来完成。这时,我们面临一种设计上的选择,是默认提供一种松散的电源控制,让应用程序尽可能多自由地控制电源使用,还是提供一种严格逻辑,默认情况下实施严格的电源输出管理,只允许应用程序出于特殊的需求来调高它的需求?当然,前一种方式灵活,但出于电源的有限性,这时Android系统里使用了第二次逻辑,尽可能多地严格电源输出控制。

在默认情况下,Android会尝试让系统尽可能多地进入到休眠状态之中。在从用户开始进行了最后一次交互之后,系统则会触发一个计时器,计时器会在一定的时间间隔后超时,但每次用户的交互操作都会重置这一计时器。如果用户一直没有进行第二次交互,计时器超时则触发一些功耗控制的操作。比如第一步,会先变暗直至关闭系统的屏幕,如果在后续的一定时间内用户继续没有任何操作,这时系统则会进一步尝试将整个系统变成休眠状态。

休眠部分的操作,基本上是Linux内核的功耗控制逻辑了。休眠操作的最后,会将内存控制器设成自刷新模式,关掉CPU。到这种低功耗运行模式之下,这时系统功耗会降到最低,如果是不带3G模组的芯片,待机电流应该处于1mA以下。但我们的系统是手机,一般2G、3G、或是4G是必须存在的,而且待机状态时关掉这种不同网络制式下的Modem,也失去了手机存在的意义,于是,一般功耗上会加上一个移动Modem,专业术语是基带(Baseband)的功耗,这时一般要控制在10 – 30mA的待机电流,100mW左右的待机功耗。如果这时,用户按些某些用于唤醒的按键、或是基带芯片上过来了一些短信或是电话之类的信息,则系统会通过唤醒操作,回到休眠之前的状态。

但是Linux内核的Suspend与Resume方案,是针对ACPI里通用计算环境(我们的PC、笔记本、服务器)的功耗控制方案,并不完全与手机的使用需求相符合。而Linux内核所缺失的,主要是UI控制上功耗管理,手机平台上耗电最大的元器件,是屏幕与背光,是无法通过Linux内核的suspend/resume两级模型来实现高效的电源管理。于是,Android系统,在原始的suspend与resume接口之外,再增加了两级early_suspend与late_resume,用于UI交互时的提前休眠。

我们的Android系统,在出现用户操作超时的情况下,会先进入early_suspend状态点,关闭一些UI交互相关的硬件设备,比如屏幕、背光、触摸屏、Sensor、摄像头等。然后,在进一步没有相应唤醒操作时,会进入suspend关闭系统里的其他类的硬件。最后系统进入到内存自刷新、CPU关电的状态。如果在系统完全休眠的情况下,发生了某种唤醒事件,比如电话打进来、短信、或是用户按了电源键,这时就会先进resume,将与UI交互不相关的硬件唤醒,再进入late_resume唤醒与UI交互相关的硬件。但如果设备在进入early_suspend状态但还没有开始suspend操作之前发生了唤醒事件,这时就直接会走到late_resume,唤醒UI交互的硬件驱动,从而用户又可以看到屏幕上的显示,并且可以进行交互操作。

经过了这样的修改,在没有用户操作的情况下,系统会不断进入休眠模式省电,而用户并不会感受到这种变化,在频繁操作时,实际上休眠与唤醒只是快进快出的UI相关硬件的休眠与唤醒。但完全暴力型的休眠也会存在问题,比如我们有些应用程序,QQ需要保持登录,下载需要一直在后台下载,这些都不符合Android的需求的,于是,我们还需要一种机制,让某些特殊的应用程序,在万不得已的情况下,我们还是可以得这些应用程序足够的供电运行得下去。

于是Android在设计上,又提出了一套创新框架,wake_lock,在多加了early_suspend与late_resume之外,再加上可以提供功耗上的特殊控制。Wake_lock这套机制,跟我们C++里使用的智能指针(Smart pointer),借用智能指针的思想来设计电源的使用和分配。我们也知道Smart Pointer都是引用,则它的引用计数会自动加1,取消引用则引用计数减1,使用了智能指针的对象,当它的引用计数为0时,则该对象会被回收掉。同样,我们的wake_lock也保持使用计数,只不过这种“智能指针”的所使用的资源不再是内存,而是电量。应用程序会通过特定的WakeLock去访问硬件,然后硬件会根据引用计数是否为0来决定是不是需要关闭这一硬件的供电。

Suspend与wake_lock这两种新加入的机制,最后也是需要加放SystemServer这个进程里,因为这是属于系统级的服务,需要特权才能保证“沙盒”机制。于是,我们得到了Android里的电源管理框架:

当然,这里唯一不太好的地方,就是Android系统设计必须对Linux内核原有的电源管理机制进行改动,需要加入wake_lock机制的处理,也需要在原始的内核驱动之上加入新的early_suspend与late_resume两个新的电源管理级别与wake_lock相配套。这部分的代码,则会造成Android系统所需要的驱动,与标准Linux内核的驱动并不完全匹配,同时这种简单粗暴的方式,也会破坏掉内核原有的清晰简要的风格。这方面也造成了Linux社区与Android社区之间曾一度吵得很凶,Linux内核拒绝Android提交的修改,而Android源代码则不再使用标准的Linux内核源代码,使用自己特殊的分支进行开发。

我们再来看Android系统对于功能接口的设计。

1.6 功能接口设计

我们实现一个系统,必须尽可能多地提供给应用程序尽可能多的开发接口,作为一个开源系统更应该如此。虽然我们前面提到了,我们需要有权限控制机制来限制应用程序可访问系统功能与硬件功能,但是这是权限控制的角度,如果应用程序得到了授权,应该有理由来使用这一功能,一个能够获得所有权限的应用程序,则理所当然应该享受系统里所提供的一切功能。

对于一个标准的Java系统,无论是桌面环境里使用的Java SE还是嵌入式环境里使用的Java ME,都不存在任何问题,因为这时Java本就只是系统的一层“皮”,每个Java写成的应用程序,只是一层底层系统上的二次封装,实际上都是借用底层操作系统来完成访问请求的。对于传统的应用程序,一个main()进入死循环处理UI,也不存在这个问题,通过链接到系统里的动态链接库或是直接访问设备文件,也可以实现。但这样的方式,到了Android系统里,就会面临一个功能接口的插分问题。因为我们的Android,不再是一层操作系统之上的Java虚拟机封装,而是抽象出来的在用户态运转的操作系统,同时还会有“沙盒”模式,应用程序并不见得拥有所有权限来访问系统资源,则又不能影响它的正常运行。

于是,对于Android在功能接口设计上,会被划分成两个层次的,一种是以“受托管”环境下通过一个系统进程SystemServer来执行,另一种是被映射到应用程序的进程空间内来完成。而我们前面分析的使用Java编程语言,而Framework层功能只以API方式向上提供访问接口,就变得非常有远见。使用了Java语言,则我们更容易实现代码结构上的重构,如果我们的功耗接口有变动,则可以通过访问接口的重构来隐藏掉这样的差异性;只以Framework的API版本为标准来支持应用程序,则进一步提供封装,在绝大部分情况下,虽然我们底层结构已经发生了巨大变动,应用程序却完全不受影响,也不会知道有这样的变化。

从这种设计思路,我们再去看Android的进程模型,我们就可以看到,通常意义上的Framework,实际上被拆分成两部分:一部分被应用程序用Java实现的classes.dex所引用,这部分用来提供应用程序运行所必须的功能;另一部分,则是由我们的SystemServer进程来提供。

在应用程序只需要完成基本的功能,比如只是使用Activity来处理图形交互时,通过Activity来构建方便用户使用的一些功能时,这时会通过自己进程空间内映射的功能来完成。而如果要使用一些特殊功能,像打电话、发短信,则需要通过一种跨进程通讯,将请求提交到SystemServer来完成。

这种由于特殊设计而得到的运行模型很重要,也是Android系统有别于其他系统很重要的一个区别。这样的框架设计,使Android与传统Linux上所面临的易用性问题在设计角度就更容易解决。

比如显示处理。我们传统的嵌入式环境里,要不就是简单的Framebuffer直接运行,要么会针对通用性使用一个DirectFB的显示处理方案,但这种方案通用性很低,安全性极差。为了达到安全性,同时又能尽可能兼容传统桌面环境下的应用程序,大都会传承桌面环境里的一个Xorg的显示系统,比如Meego,以及Meego的前身Maemo,都是使用了Xorg用来处理图形。但Xorg有个很严重的性能问题:

使用Xorg处理显示的,所有的应用程序实际上只是一个客户端,通过Unix Socket,使用一种与传统兼容的X11的网络协议。用户交互,应用程序会在自己的交互循环里,通过X11发起创建窗口的请求,之后的交互,则会通过输入设备读取输入事件,再通过Xorg服务器,转回客户端,而应用程序界面上的重绘操作,则还是会通过X11协议,走回到Xorg Server之后,再进行最后的绘制与输出。虽然现在我们使用的经过模块化重新设计的XorgR7.7,已经尽可能通过硬件加速来完成这种操作,Xorg服务器还是有可能会成为整个图形交互的瓶颈,更重要的是复杂度太高,在这种构架里修改一个bug都有点困难,更不要说改进。在嵌入式平台上更是如此,性能本就不够的系统环境,Xorg的缺陷暴露无移,比如使用Xorg的Meego更新过程远比Android要困难,用户交互体验也比较差。

在Android里,处理模型则跟传统的Xorg构架很不一样。从设计角度来讲,绘制图形界面与获取输入设备过来的输入事件,本来不需要像Xorg那样的中控服务器,尤其像Android运行环境这样,并不存在多窗口问题(多窗口的系统需要有个服务器决定哪个窗口处于前台,哪个窗口处于交互状态中)。而从实现的角度,如果能够提供一种设计,将图形处理与最终输出分开,则更容易实现优化处理。基于图形界面的交互,实际上将由三个不同的功能实体来完成:应用程序、负责将图层进行叠加渲染的SurfaceFlinger、以及负责输入事件管理和选择合适的地址进行发送的SystemServer。当然,我们的上层的应用程序不会看到内部的复杂逻辑,它只知道通过android.view这个包来访问所有的图形交互功能。

于是得到Android系统的图形处理框架:

我们的SurfaceFlinger,是Android里的一种Native Service的实现,所以有原理上来说,只要有一个承载它的执行体(进程、线程皆可),就可以在系统里执行。在实现过程里,SurfaceFlinger作为一个线程在SystemServer这个进程空间里完成也是可以的,只是出于稳定性的考虑,一般将它独立成一个单独的SurfaceFlinger的独立进程。

这种设计,可以达到一个低耦合设计的优势,这套图形处理框架将变得更简单,同时也不会将Xorg那样需要大量的特殊内核接口与其适配,如果在别的操作系统内核之上进行移植,也不会有太大的依赖性。但这时会带来严重的性能问题,因为图层的处理和输出是需要大量内存的(如果是24位真彩色输出,即使是800x480的分辩率,每秒60桢的输出频率,也需要3*800*480*60 = 69120000,69M Byte/s),这种开销对于嵌入式方案而言,是难以承受的。在进程间传递数据时,会先需要在一个进程执行上下文环境里通过copy_from_user()把数据从用户态拷贝到内核态,然后在另一个进程执行的上下文环境里通过copy_to_user()把数据拷贝从内核态拷贝到另一个用户态环境,这样才能保证互不干扰。

而回过头来看Linux内核,搞过Linux内核态开发的都知道,在Linux系统的进程之间减小内存拷贝的开销,最直接的手段就是通过mmap()来完成内存映射,让保存数据的内存页只会在内核态里循环,这时就没有内存拷拷贝的开销了。使用了mmap()之后,内存页是直接在内核态分配的内存,两个进程都通过mmap()把这段区域映射到自己的用户空间,然后可以一个进程直接操作内存,另一个进程就可以直接访问到。在图层处理上,最好这些在内核态申请的内存是连续内存,这时就可以直接通过LCD控制器的DMA直接输出,Android于是提供了一种新的特殊驱动pmem,用来处理连续物理内存的分配与管理。同时,这种方式很裸,最好还在上层提供一次抽象,编程时则灵活度会更高,针对这种需求,就有了我们的Gralloc的HAL接口。加入了这两种接口之后,Android在图像处理上便自成体系,不再受限于传统实现了。

我们的图层,是由应用程序在创建是通过Gralloc来申请图层存储空间,然后被包装成上层的Surface类,在Activity实现里Surface则是按需要进行重绘(调用view的draw()方法),并在绘制完成后通过post()将绘制完成的消息发送给SurfaceComposer远程对象。而在SurfaceFlinger这段,则是将已经绘制完成的Surface通过其对应的模式,进行图层的合成并输出到屏幕。对于上层实现,貌似是一种很松散的交互,而对于底层实现,实际则是一种很高效的流水线操作。

这里,值得一提的是Surface本身也包含了图层处理加速的另一种技巧,就是double buffer技术。一个Surface会有两个图层buffer,一桢在后台被绘制,另一桢在前台进行输出。当后台绘制完成后,会通过一次Page Flipping操作,原来的后台桢被换到前台进行输出,而绘制操作则继续在后台完成。这样用户总会看到绘制完整的图像,因为图层总是绘制完成后才能输出。而有了double buffer,使我们图形输出的性能也得到提升,我们输出绘制与输出使用独立的循环,通过流水线加快了图层处理,尤其在Android里,可能有多个绘制的逻辑部分,性能得以进一步加速。在Android 4.1里面,这种图形处理得以进一步优化,使用了triple buffer(三重缓冲),加深了图层处理的流水线操作能力。

这种显示处理上的灵活性,在Android系统里也具备非常重要的意义,可以让整个系统在功能设计上可以变得更加灵活。我们提供了一种“零拷贝”图层处理技术之后,最终上层都可以通过一个特殊的可以跨进程的Surface对象来进行异步的绘制处理(如果我们不是直接操作控件,而是通过“打洞”方式来操作图形界面上的某个区域,则属于SurfaceView提供的,当然,这时也只是操作Surface的某一部分)。我们的Surface的绘制与post()异步进行的,于是多个执行体可以并行处理图层,而用户只会看到通过post()发送的图层绘制完成的同步事件之后的完整图层,图层质量与流畅性反而可以更佳。比如,我们的VOIP应用程序,可以会涉及多个功能实体的交互,Camera、多媒体编解码、应用程序、SurfaceFlinger。

应用程序、多媒体编解码与Camera都只会通过一个Surface对象来在后台桢上进行交互界面的绘制,像前摄像头出来的回显,从网络解码出来的远端的视频,然后应用程序的操作控件,都将重绘后台图层。而如果这一应用程序处于Activity的可交互状态(见前面的生命周期的部分),就会通过找到同一Surface对象,将这一Surface对象的前台桢(也就是绘制完成但还没有输出的图层)输出。输出完则对这一Surface对象的前后两桢图层进行对调,于是这样的流水线则可以很完美的运行下去。

Android并非是最高效的方案,而只是一种通过面向对象方式完全重新设计的嵌入式解决方案,高效是其设计的一部分要素。如果单从效率角度出发,无进程概念的实时操作系统最高效,调度开销也小,没有虚址切换时的开销。作为Android系统,通过目前我们看到的功能性接口的设计,至少得到了以良好的构架为基础同时又兼顾性能的一种设计。

当然,我们前面所总结的,对于Android系统的种种特性,最终得到的一种印象是每种设计都是万能胶,同一种设计收获了多种的好处。那是不是这种方式最好,大家都应该遵循这种设计上的思路与技巧?That depends,要看情况。像Android这样要完整地实现一整套这种在嵌入式环境里运行的,面向对象式的,而且是基于沙盒模式的系统,要么会得到效率不高的解决方案,要么会兼顾性能而得到大量黑客式的接口。Android最终也就是这么一套黑客式的系统,这个系统一环套一环,作为系统核心部分的设计,都彼此过分依赖,拆都拆不开,对它进行拆分、精减或是定制,其实都很困难。但Android,其系统的核心就是Framework,而所谓的Framework,从软件工程学意义上来说,这样的构架却是可以接受的。所谓的Framework,对上提供统一接口,保持系统演进时的灵活性;对下则提供抽象,封装掉底层实现的细节。Android的整个系统层构架,则很好的完成了这样的抽象,出于这样的角度,我们来看看Android的可移植性设计。

1.7 可移植性

单纯从可移植性角度来说,Linux内核是目前世界上可移植性最强的操作系统内核,没有之一。目前,只要处理器芯片能够提供基本的运算能力(可以支撑多进程在调度上的开销),只要能够提供C语言的编译器(准确地说是Gnu C编译工具链),就可以运行Linux内核。Linux内核在设计上保持了传统Unix的特点,大部分使用了C语言开发,极少部分机器相关的代码使用汇编,这种结构使其可移植性很强。在Linux内核发展到2.6版本之后,这种强大的可移植性得到进一步提升,通过驱动模型与驱动框架的引入和不断加强,使Linux内核里绝大部分源代码几乎都没有硬件平台上的依赖性。于是,Linux内核几乎能够运行在所有的硬件平台之上,常见有的X86、ARM,不那么常见但可能也会在不知道不觉地使用到的有MIPS、PowerPC、Alpha,另外还有一些我们听都没有听过的,像Blackfin,Cris、SuperH、Xtensa,Linux内核都支持,平台支持可参考linux内核源代码的arch目录。甚至,出于Linux内核的可移植性,Linux一般也被作为芯片验证的工具,芯片从FPGA设计到最终出厂前,都会通过Linux内核来检测这一芯片是否可以运行,是否存在芯片设计上的错误。

得益于Linux内核,构建于其上的操作系统,多多少少可继承这样的可移植性。但Android又完成应用程序运行环境的二次抽象,在用户态几乎又构造出一层新的操作系统,于是它的可移植性多多少少会受此影响,而且,像我们前面所分析出来的,Android的核心层构建本身,也因为性能上的考虑,耦合性也有点强,于是在可移植性也会面临挑战。“穷山恶水出刁民”,正因为挑战大,于是Android反倒通过各种技巧来加强系统本身的可移植性,反而做得远比其他系统要好得多。Android在可移植性上的特点有:

l 按需要定制可移植性。与传统嵌入式Linux操作系统不同,Android在设计上有明确的设计思想与目标,不会为了使用更多开源软件而提供更高兼容性的编译环境,而是列出功能需求,按功能需求来定制所需要的开源软件。有些开源软件能够提供更复杂的功能,但在Android环境里,只会选择其验证过的必需功能,像蓝牙,BlueZ本身可以提供更复杂的蓝牙控制,但Android只选择了BlueZ的基本功能,更多功能是由Android自己来实现,于是减小了依赖性,也降低了移植时的风险性。

l 尽可能跨平台。与以前的系统相比,Android在跨平台上得益于Java语言的使用,使其跨平台能力更强,在开发上几乎可以使用任何Java环境可以运行的操作系统里。而在源代码级别,它也能够在MacOSX与Linux环境里进行编译,这也是一个大的突破。

l 硬件抽象层。Android在系统设计的最初,便规划了硬件抽象层,通过对硬件访问接口的抽象,使硬件的访问接口相对稳定,而具体的实现则可在底层换用不同硬件访问接口时灵活地加以实现,不要说应用程序,就是Framework都不会意识到这种变动。这是以前的嵌入式Linux操作系统所没有的一种优点。硬件抽象层的使用,使Android并不一定需要局限于Linux内核之上,如果将底层偷换成别的接口,也不会有太大的工作量。

l 实现接口统一的规范化。Android在构架上,都是奉行一种统一化的思路,先定义好API,然后会有Framework层的实现,然后再到硬件抽象层上的变动。API可在同一版本上拓展,Framework也在逐步加强,而硬件抽象层本身可提供的能力也越来越强,但这一切都在有组织有纪律的环境下进行,变动在任何一次版本更新上来看,都是增量的小范围变动,而不会像普通的Linux环境那样时刻都在变,时刻都有不兼容的风险。从可移植性角度来说,这种规范化提供的好处,便是大幅降低了移植时的工作量。

l 尽可能简单。简单明了是Android系统构成上的一大特色,这种特色在可移植性上也是如此。像编译环境,Android在交叉编译环境上,是通过固化编译选项来达到简编译过程的上的,最终,Android源代码的编译工程,会是一个个由Android.mk来构造的可编译环境,这当然会降低灵活性,但直接导致了这套框架在跨平台上表现非常出色。再比如硬件抽象层,同样的抽象在现代嵌入式操作系统上可能都有,但是大都会远比Android的HAL层要复杂,简单于是容易理解和开发,在跨平台性方面也会表现更好。

我们传统的嵌入式Linux环境,几乎都会遵从一种约定俗成的传统,就是专注于如何将开源软件精减,然后尽可能将PC上的运行环境照搬到嵌入式。在这种思路引导下开发出来的系统,可移植性本身是没什么问题的,只是不是跟X86绑定的源代码,铁定是可以移植。但是,这样构建出来的系统,一般都在结构上过于复杂,会有过多的依赖性,应用程序接口并不统一,升级也困难。所有这样的系统,最后反倒是影响到了系统的可移植性。比如开源嵌入式Linux解决方案,maemo,就是一个很好的例子:

对于Maemo的整体框架而言,我们似乎也可以看到类似于Android的层次化结构,但注意看这种系统组成时,我们就不难发现,这样的层次化结构是假的,在Maemo环境里,实际上就是一个小型化的Linux桌面环境,有Xorg,有gtk,有一大堆的依赖库,编程环境几乎与传统Linux没任何区别。这种所谓的软件上的构架,到了Maemo的后继者Meego,也是如此,只不过把gtk的图形界面换成了Qt的,然后再在Qt库环境里包装出所谓的UX,换汤不换药,这时,Meego还是拥有一颗PC的心。

一般这种系统的交叉编译环境,还必须构建在一套比较复杂的编译环境之上,通过在编译环境里模拟出一个Linux运行环境,然后才能编译尽可能多的源代码。这样的交叉编译环境有Open Embedded,ScratchBox等。虽然有不同的交叉编译实现上的思路,但并没有解决可移植性问题,它们必须在Linux操作系统里运行,而且使用上的复杂程度,不是经验丰富的Linux工作者还没办法灵活使用。即便是比较易用的ScratchBox,也会有如下令人眼花缭乱的结构。

针对这样的现状,Android在解决可移植性问题时的思路就要简单得多。既然原来的尝试不成功,PC被精减到嵌入式环境里效果并不好,这时就可以换一种思路,一种“返璞归真”的思路,直接从最底层来简化设计,简化交叉编译。这样做法的一个最重要前提条件,就是Android本身是完整重新设计与实现的,是一个自包含的系统,所有的编译环境,都可以从源代码里把系统编译出来。

在系统结构上,Android在设计上便抛弃了传统的大肆搜刮开源代码的做法,由自己的设计来定位需要使用的开源代码,如果没有合适的开源代码,则会提供一个简单实现来实现这一部分的功能。于是,得到我们经常见到的Android的四层结构:

从这样简化过的四层结构里,最底层的Linux内核层,这些都与其他嵌入式Linux解决方案是共通的特性,都是一样的。其他三层就与其他操作系统大相径庭了:应用程序层是一种基于“沙盒”模式的,以功能共享为最终目的的统一开发层,并非只是用于开发,同时还会通过API来规范这些应用程序的行为;Framework层,这是Android真正的核心层,而从编程环境上来说,这一层算是Java层,任何底层功能或硬件接口的访问,都会通过JNI访问到更低层次来实现;给Framework提供支撑的就是Library层,也就是使用的一个自己实现的,或是第三方的库环境,这一层以C/C++编写的可以直接在机器上执行的ELF文件为主。

有了这种更简化的层次关系,使Android最后得到的源代码相对来说更加固定,应用程序这层我们只会编译Java,Framework层的编译要么是Java要么是JNI,而Library层则会是C/C++的编译。在比较固定的编译目标基础上,编译环境所需要解决的问题则会比较少,于是更容易通过一些简化过的编译环境来实现。Android使用了最基本的编译环境GnuMake,然后再在其上使用最基本的Gnu工具链(不带library与动态链接支持)来编译源代码,最后再通过简化过的链接器来完成动态链接。得到的结果是几乎不需要编译主机上的环境支持,从而可以在多种操作系统环境里运行,Android的编译工程文件还比较简单,更容易编写,Android.mk这种Android里的编译工程文件,远比天书般的autoconf工具要简单,比传统的Makefile也更容易理解。

Android的编译系统,由build目录里一系列.mk编译脚本和其他脚本组成,以build/main.mk作为主入口。main.mk文件会改到配置项,然后再通过配置项循环地去编译出所需要的LOCAL_MODULE。而这些LOCAL_MODULE的编译目标,是由Android.mk来定义的,而到底如何编译目标对象,则是由简单的include $(BUILD_*)这样的编译宏选项来提供,比如include $(BUILD_SHARED_LIBRARY)则会编译生成动态链接库.so文件。这样的编译系统抛弃autoconf的灵活性,换回了跨平台、编写简单。

当然,这样的编译工具,也不是没有代价的,使用简易化的Android编译环境则意味着Android放弃了一些现有绝大部分代码的可移植性,Linux环境里的一些常用库移植到Android环境则需要大量移植工作,像万能播放器核心ffmpeg,目前几乎只有商用应用程序才肯花精力将它移植到Android系统里。

当然,光有自己代码结构,光有更简易的编译环境,并不解决所有问题,我们的Android系统,最后还必须通过访问系统调用、读写驱动的设备文件来完成底层的操作。不然我们的Android系统就只会是一个光杆司令,什么都不是,没有任何功能。既然我们拥有了相关简化的系统结构,我们在内部自己按自己的想法去访问硬件也是可以,只是这样在升级与维护起来的代码会变得很大,比如我们通过”/dev/input/event0”来读触摸屏,”/dev/input/event1”来读重力感应器,如果换个平台,这些设备名字变了怎么办?或者有些私有化平台,都完全不使用这样的标准化设备文件命名时怎么办?难道针对每个平台准备一份源代码来做这些修改?于是就有了内部访问接口统一化的问题,Android在对于底层的设备文件的访问上,又完成了一层抽象,也就是我们的硬件抽象层。

硬件抽象层,准确地说,是介于Framework层与Linux内核层之间的一个层次,Framework通过硬件抽象层的统一接口来向下访问,在Linux内核上硬件访问接口上的差异性,则通过硬件抽象层来进行屏蔽。硬件抽象层,英文是Hardware Abstraction Layer,简称HAL,于是也就是我们常见到的AndroidHAL层。

提供硬件抽象层之后,这时Framework层与底层Linux内核之间的耦合性,就完全消除掉了。如果要将Android部署到其他操作系统、或是操作系统内核之上,只需要将HAL层的相应接口实现换成其他平台上的访问机制即可,而Framework只会使用HAL层的统一接口向下访问,完全不知道底层变动信息。

在Android的世界里,硬件抽象其实是包含多种含义的,可能会有多种的硬件访问实现,比如RIL、BlueZ这样的尽可能兼容已有解决方案的广义上的HAL,Framework会通过Socket来访问传统方式实现的daemon,也有Gralloc、Camera这样先定义Framework向下访问的接口,然后由硬件向上提供接口实现的狭义上的HAL。我们的Android上的HAL实现,更多地专指狭义上的HAL,由源代码里的hardware目录提供。

狭义上的HAL,在实现上也会分成两种:一种基于函数直接访问的方式来实现,这种方式比较简单粗暴,所以在命名上,被称为libhardware_legacy实现,由目录hardware/libhardware_legacy里实现的一个个动态链接库来实现;另一种则使用了面向对象的技巧,上层通过一个hardware_module_t来访问,而具体的某一类HAL的实现,则会将这一hardware_module_t按需求进行拓展,这种方式被称为libhardware实现,由hardware/libhardware提供hardware_modult_t的访问接口,硬件访问的实现部分,也是动态链接库文件,但会在运行时根据板卡的具体配置,进行动态加载。从长远看,libhardware_legacy结构的HAL实现是一种中间方案,在Android发展过程里有可能会被libhardware方式的实现所取代,比如Android 4.0里,Audio、Camera完成了libhardware_legacy到libhardware的转变。

从设计的角度来看,libhardware_legacy虽然解决了与Linux内核的耦合问题,但是直接函数接口的访问,终究还是灵活性不够。以libhardware_legacy实现HAL的动态链接库,必须被直接链接到Framework的实现里,通过JNI进行直接访问,不具备动态性,不能支持同一份Android可执行环境支持不同硬件平台。从设计角度来说,任何抽象都是由另一次间接调用来实现,于是,我们在硬件访问接口里再加入一层抽象,这就是libhardware.so。Framework并不直接访问具体的动态链接库实现,而是通过libhardware.so里实现的通用接口来向下访问,并且也只会调用到预定义好的一些访问接口,而HAL实现的,是以Stub方式来提供到系统里一些功能访问的具体实现。如果不好理解的,可以认为是系统提供头文件,也就是我们右图中的<<Hardware Access API>>,这些只是接口类定义。而实现上,并不是直接通过头文件来实现,而是通过实现一个具有一定特性的hardware_module_t的数据结构,来向上提供具体的函数调用接口,与头文件里所需要的接口相对应。为什么叫它Stub呢?是因为在libhardware这种模式里,把接口定义与具体的实现抽离开来,虽然不一定会使用面向对象语言来实现(一般是通过C来实现的),但提供了Interface+Stub这样面向对象式的实现,所以libhardware有时也被称为stub模式实现的HAL。

使用libhardware之后,HAL层实现上的可复用性与运行时的灵活性则被大大增强了。在libhardware框架下,Framework都不再直接调用HAL层,而是通过hw_get_module()方法来在/system/lib/hw和/system/vendor/lib/hw这两个目录里循环寻找合适的.so实现,比如针对sensor,会有sensor.default.so,sensor.goldfish.so,sensor.xxx.so,会有不同种实现,以用于加载合适的实现。虽然这些只会在开机时执行一次,但通过简单方式至少也实现了用同一份二进制代码提供多种硬件平台的支持。使用libhardware实现的HAL,当我们的实现的HAL有问题时,我们可以删掉有问题的HAL,此时启动时会使用一个xxx.default.so的纯软件实现的不进行任何硬件访问的.so文件,让我们还是可以绕开启动时的HAL加载错误。

Android这种强化可移植性的设计,最终使Android的移植移植过程变得相对比较简单。如果是做Android的手机或是平板的开发,也许我们需要做的只是提供板块相关的配置文件,从而可以改变一些编译时的配置参数。如果我们的硬件平台跟Android源代码时使用的标准平台(比如Google的“亲儿子”手机Nexus系列的产品,或是pandaboard这样作为参考设计的产品),对于移植过程而言,我们可能什么都不需要做,直接可以编译运行,然后再做产品化的微调;如果我们使用的硬件平台跟某些产商提供的开源项目的硬件结构一样,比如Qualcomm提供的codeaurora.org,TI的Omapedia,还有各大厂商都涌跃参与的linaro.org项目等等,这时需要完成的移植工作也会类似的很小;如果我们的提供的硬件平台跟Android这些已有的开源资源很不一样,这时,我们需要完成的移植工作也不会很大,只需要根据特定的硬件平台实现HAL,这一过程所需要的工作量远小于其他平台的移植过程。

Android的移植过程,基本上分为:

l Bootloader与Linux内核的移植

l Repo环境(Android源代码基于repo管理,最后使用repo)

l 交叉编译器、Bionic C库与Dalvik虚拟机的移植(如果不是ARM、X86和MIPS这三种基本构架)

l 提供板卡支持所需要的配置

l 实现所需要使用的HAL

l Android产品化,完成界面或是功能上的定制

这些移植过程的步骤如下图所示:

对于我们做Android移植与系统级开发而言,可能我们所需要花的代码并不是那么大。像Bootloader与Linux内核的移植,这一般都在进行Android系统移植时早就会就绪的,比如我们去选购某个产商的主芯片时(Application Processor,术语为AP),这些Android之前的支持大都已经就绪。而作为产业的霸主,我们除非极其特殊的情况,我们也不需要接触交叉编译器和Dalvik虚拟机的移植。所以一般情况下,我们的Android移植是从建立repo源代码管理环境开始,然后再进行板卡相关的配置,然后实现HAL。而Android的产品化这个步骤,Framework的细微调整与编写自己平台上特殊的应用程序,严格意义上来说,Framework也不属于Android移植工作范围内的,我们一般把它定位于Android产品化或是Android定制化这个步骤里。Android移植相对来说非常简单,而真正完成Android产品化则会是一个比较耗时耗人力的过程。

所谓的板卡的配置文件,一般是放在一个专门的目录里,在2.3以前,是发在vendor目录下,从2.3开始,vendor目录只存放二进制代码,配置文件移到了device目录。在这一目录里,会以“产商名/设备名”的命名方式来规范配置的目录结构。比如是产商名是ti,设备名是panda,则会以“device/ti/panda”来存放这些配置文件,再在这个目录里放置平台相关的配置项。配置文件,则是会几个关键文件构成:

} vendorsetup.sh,使用add_lunch_combo将配置项导入编译环境

} AndroidProducts.mk,这是会被编译系统扫描的文件,通过在这一文件里再导入具体的编译配置文件,比如ti_panda.mk

} ti_panda.mk,在这一文件里定义具体的产品名,设备名这些关键变量,这些变量是在Android编译过程里起关键配置作用的变量。一般说来,这个文件不会很复杂,主要依赖导入一些其他的配置文件来完成所有的配置,比如语言配置等。而设备特殊的设置,则一般是使用同目录下的device.mk文件来进行定制化的设置。

} device.mk,在这一文件会使用一些更加复杂一些配置,包含一些需要编译的子工程,设置某些特殊的编译参数,以及进行系统某些特性的定制化,比如需要自定义怎样的显示效果、配置文件等

} BoardConfig.mk,在这一文件则是板子相关的一些定制项,以宏的方式传入到编译过程里,比如BOARD_SYSTEMIMAGE_PARTITION_SIZE来控制system分区的大小, TARGET_CPU_SMP来控制是否需要使用SMP(对称多处理器)支持等。一般,对于同一个板卡环境,这些参数可以照抄,勿须修改。

所有的这些配置文件,并不是必须的,只不过是建议性的,在这一点上也常会透露出产商在开源文化的素质。毕竟是开源的方案,如果都使用约定俗成的解决方案,则大家都会不用看也知道怎么改。但一些在开源做得不好的厂商,对这些的配置环境都喜欢自己搞一套东西出来,要显得自己与众不同,所以对于配置文件的写法与移植过程,也需要具体情况具体对待。

当我们完成了这些配置上的工作后,可以先将这些配置上传到repo的服务器管理起来,剩下的移植工作就是实现所需要的HAL了。在Android移植过程里,很多HAL的实现,是可以大量复用的,可以找一个类似的配置复制过来,然后再进行细微调整,比如使用ALSA ASoC框架的音频支持,基本功能都是通用的,只需要在Audio Path和HiJack功能上进行微调即可。

作者:宋宝华

给我留言

留言无头像?