本文是 Namespace based Dynamic Linking - Isolating Native Library of Application and System in Android 的 Google 翻译版本,经过一点修改以保证阅读基本通畅。

注意,本文直接给出了一些 AOSP (Android Open Source Project)的代码的链接,而非将这些代码片段嵌入到文章中。由于这些代码是由 Google 托管的,如果你无法访问原始链接,可以通过几个镜像站点来查询相关的代码:GitHub中科大 AOSP 镜像

摘要

Android 提供针对 Java 的 SDK 和针对本地应用的 NDK 作为 API 。对于私有接口,Java 库通过 Java ClassLoader 对应用程序隐藏,而本地共享库库可以很容易地被访问。另一方面,Oreo 的 Project Treble 旨在通过将 Android 实现分为框架(Framework) 和供应商(Vendor)两部分来应对碎片化的生态系统。这种代码划分需要在一个进程中分别管理两个本机库的集合。面对这些挑战,Android 动态链接器(/system/bin/linker)引入了 命名空间namespace,参考 123)来隔离动态链接空间。 Android 系统部署 命名空间 以防止应用程序与私有本地库动态链接,并在不同的沙箱中管理 Framework 和 Vendor 库。本文分析了Android Oreo 的 命名空间 ,包括动态链接器的机制以及它与高层策略的相互配合,并讨论了其影响和收益。

引论

私有本地库的问题

Android 最主要的是移动设备操作系统,各种各样的应用程序都基于它发布 SDKNDK。一些应用程序(参见行为不当的应用程序一节)对公共 API 不满意,于是它们使用 SDK 和 NDK 以外的系统私有库。

对于Java,类加载器 保证应用程序的类型安全执行环境。类加载器 的层次结构隐藏来自应用程序访问的非SDK资源。然而,对本地库而已,Android 缺乏自制的机制来限制私有库的使用——因为 Android 是基于 Linux 的,而 Linux 专注于用户/内核数据保护。另外,由于 Android 是开放源代码,开发人员很容易使用“强大”的私有库。

然而,私有库的界面是不稳定的,因为它们可能在Android版本中发生变化而没有任何公开通知,那么依赖于旧版私有接口的应用程序可能会在新发布的 Android 上无法运行。因此,禁止Android应用程序访问私有本机库对生态系统至关重要。

Project Treble 的问题

另一方面,Android 平台高度碎片化。设备制造商不愿意将旧设备升级到新的Android平台,因为它通常需要大量工作;同时,开发人员被迫在大量设备上测试他们的应用程序,因为其中有许多传统平台。

在 Android Oreo 中,Google 宣布 Project Treble 来解决片段问题。Treble 将 Android 平台分为框架(Framework)和供应商(Vendor)部分,分别承载应用程序和管理设备特定功能。当处理设备特定功能时,框架通过供应商界面(Vendor Interface)要求供应商提供服务。该接口计划在多个 Android 版本中保持稳定。因此,通过 Treble,Android 框架可以在供应商实现保持不变的情况下升级,如图1所示。通过这种方式,供应商可以轻松升级旧设备和应用程序,从而可以从最新的框架功能中受益。

Project Treble:更新框架而不改变供应商实现

从本地库视角来看,Treble 引入了两套本地库——框架和供应商的。在某些情况下,这两个库中的库可能具有相同的名称,但是不同的实现。由于库的符号暴露给一个进程的所有代码,因此这两个集合需要被单独区分和链接,否则可能会出现混乱的调用。

命名空间

为了解决这些问题,Android 动态链接器引入了基于命名空间的动态链接(namespace based dynamic linking),它和 Java 类加载器 隔离资源的方法类似。通过这种设计,每个库都加载到一个特定的 命名空间 中,除非它们通过 命名空间链接namespace link)共享,否则不能访问其他 命名空间 中的库。

本文首先介绍了基于 命名空间 的动态链接的机制。之后,说明了向 Android 应用程序隐藏私有库并管理框架和供应商自己的库的相关策略。我们还讨论影响和效益。

基于命名空间的动态链接机制

命名空间 是 Android Nougat 和 Oreo 中动态链接器最重要的变化。本节总结了基于 命名空间 的动态链接,它将本机库加载到沙盒中,并通过 命名空间链接 共享这些库。

动态链接的基本概念

动态链接器在运行时从存储器中找到共享库,将它们加载到内存中并链接它们的符号。

每当接收到库加载请求时,动态链接器遍历库搜索路径(Library Search Path, LSPath)来搜索该库。 Solaris 和 Linux 上的 LSPath 由变量 LD_LIBRARY_PATH 配置,它包含了存储共享库的目录。动态链接器在进程开始时解析 LD_LIBRARY_PATH 作为 LSPath,并且在进程的生命周期内 LSPath 不太可能发生更改。

动态链接器用 已加载库列表(Alread Loaded Library list, ALList)跟踪加载的库,以便进一步使用(如符号查找)。如果要求加载的库已经在 ALList 中,现有的数据将被直接使用——从而加速动态链接。当进程正在运行时,ALList 代表本地代码资源的状态。

在一个过程中,所有本地代码在 LSPath 下具有对共享库的相同访问权限;同时,所有加载的库都注册在同一个 ALList 中,如图2 的左侧部分。(相关详细解释请参阅 Marshmallow 中的 Android 动态链接器 )。

互相隔离的命名空间

从 Nougat 开始,Android 动态链接器将一个进程的空间加载到多个 命名空间 中。每个 命名空间 都有自己的 LSPath 和 ALList 。

在运行时,动态链接器会根据请求创建 命名空间 ,并分配可在 命名空间 中变化的 LSPath 。在一个 命名空间 中加载库将仅搜索该 命名空间 的 LSPath 。以图 2 为例,考虑两个 命名空间 namespace 1namespace 2 ,其中 LSPath 分别是 path1path2 。由于动态链接器在特定 命名空间 的 LSPath 中搜索库, namespace 1 只能在 path1 下加载库,而 namespace 2 只能在 path2 下加载库。

动态链接场景:没有和使用 namespace

Android 动态链接器提供 命名空间 API,用户(这里的用户指 Android 系统。不推荐应用开发者使用 命名空间 机制)可以通过该 API 定义自己的 命名空间 策略来隔离在不同目录中的本地库。在这些 API 中,android_create_ namespace () 创建具有特定 LSPath 的 命名空间 ,而android_dlopen_ext()在特定 命名空间 中加载库。另外,在 android_create_ namespace () 中,用户可以声明 命名空间 是否“严格隔离” (标志为 ANDROID_NAMESPACE_TYPE_ISOLATED)和 “特许路径” (permitted path)。非严格隔离的 命名空间 接受任何绝对路径,而严格隔离的路径只接受在 LSPath 或允许路径下的库的绝对路径。在以下讨论中我们将专注于 LSPath 。

对于使用常规动态链接 API 的库,例如dlopen(),动态链接器在调用者的 命名空间 中加载新的库。动态链接器总是管理一个 (default) 命名空间 ,没有使用 命名空间 的进程完全处于该 命名空间 中。在这种情况下的动态链接与传统方法相同。因此, 命名空间 向后兼容并且对这类共享库是透明的。

通过这种设计,库被加载在互相隔离的 命名空间 中,其他 命名空间 中的库对它们是不可见的。然而,操作系统中显然有一些库需要对一个进程的所有代码可见,例如 NDK 。Android Oreo 引入了 命名空间链接 从而在 命名空间 之间共享库。

命名空间链接

命名空间链接 是在两个 命名空间 之间创建的单向链接,用于将库从一个 命名空间 共享给另一个。在图 3 中,黄色库节点被共享,而灰色节点不被共享。这些库通过 命名空间 中的文件名共享(预计与 SONAME 相同)。 命名空间 可以链接到多个 命名空间 链接,如图 3 中最左侧的节点;并且可以被多个 命名空间链接 ,如中心节点。命名空间 可以通过链接到它的 命名空间 链接来共享不同的库集合。链接 命名空间 还可以链接到其他 命名空间 ,图 3 中的节点 1 和中心节点都是这样的示例。

*命名空间* 链接:从一个 *命名空间* 共享库到另一个 *命名空间*

加载库时,首先搜索正在使用的 命名空间 ;如果失败,动态链接器会遍历该 命名空间 的所有 链接 ,并尝试在链接到的 命名空间 中加载库。加载到链接到的 命名空间 中的库及其依赖库将不会添加到其共享的 命名空间 的 ALList 中。依赖库对于其他 命名空间 是不可见的,除非某些 命名空间 通过其他 命名空间链接 共享,如图 3 所示。

通过部署 命名空间链接,库在隔离的 命名空间 中被优雅地共享。除通用设计之外,动态链接器还创建了两个有特殊的 命名空间

特殊的命名空间

当 Android 动态链接器初始化时,它通过创建第一个 命名空间 - (default) 命名空间 来引导 命名空间(default) 命名空间 不是“严格隔离”的,并且它的 LSPath 是从 LD_LIBRARY_PATH 解析的。如 “互相隔离的命名空间” 一节所述,如果没有库在一个进程中使用 命名空间 API,(default) 命名空间 将是唯一的 命名空间,同时也是加载所有库的 命名空间命名空间 机制在这种情况下退化为传统的动态链接。

Android 动态链接器在 API android_init_anonymous_namespace() 中创建 (default) 命名空间 。虽然每个库都属于某个 命名空间 ,但 JIT(Just In Time) 代码, 如 Mono ,不属于任何库或 命名空间 。对于类似于 JIT 的代码中的 dlopen(),新库将被加载到 (anonymous) 命名空间 中。所以(anonymous) 命名空间 被设计为保存类似 JIT 的代码所加载的库。 命名空间 用户应该主动调用android_init_anonymous_namespace() 来初始化 (anonymous) 命名空间; 否则,(default) 命名空间 将被当做是 (anonymous) 命名空间

Android 系统的命名空间策略

为了实现禁止应用程序访问私有库并分别管理 Framework 和 Vendor 库的目标,Android 系统以不同的方式部署 命名空间 机制。

NativeLoader:将类加载器映射到命名空间

NativeLoader 是 Android 系统的组件,它通过使用 命名空间 机制禁止库接口的访问,并通过 命名空间 共享库。

Android 应用程序以两种方式加载本机库:Java 中的 System.loadLibrary() 和本地中的 dlopen()android_dlopen_ext()。对于 Java 而言,System.loadLibrary() 最终调用到 NativeLoader,而 NativeLoader 把库加载到由 System.loadLibrary() 的上下文的 类加载器 实例映射的 命名空间 中。另一方面,库本身调用 Android 动态链接器(NDK 中的 libdl.so 提供了虚拟 API )来加载库。

NativeLoader 维护从 Java 类加载器 到本地 命名空间 的映射。图 4 是简化的 NativeLoader 管理的 Java 类加载器 (左半部分)和 命名空间 (右半部分)的层次结构。由同一个类加载器 加载的所有 Java 类内的第一个 System.loadLibrary() 将创建一个 命名空间 。一个例外是 ApplicationLoader 主动创建的第一个 命名空间 (当应用程序启动时,CL NS 0 由图 4 中的 Path CL 映射)。 NativeLoader 将所有这些 命名空间 命名为 classloader- namespace,并将它们链接到 (default) 命名空间 ,那么应用程序可以访问(default) 命名空间 中的同一组系统库—。通过这些 命名空间链接 共享的库将从/etc/public.libraries.txt/vendor/etc/public.libraries.txt (SoC 或设备供应商可能会扩展)解析得到。由于本地 命名空间 是由 类加载器 映射的,因此它们构成相同的层次结构。在图 4 中,App CL 1 映射到 CL NS 1App CL 2 映射到 CL NS 2 。然而,一些 类加载器 没有对等的 命名空间 ,因为它不使用本地库,例如节点 App CL 3

由 NativeLoader 管理的 *命名空间* 层次结构

这样一来,应用程序将无法访问除了 /etc/public.libraries.txt 之外的库。

Treble:管理框架和供应商库

Treble 将 Android 分为框架部分和供应商部分,由框架供应商(如 Google 和小米)和SoC或设备供应商(如高通和三星)分开维护。图 1 中的供应商接口包括 Hal Interface Definition Language(HIDL,有时被解释为 Hardware Interface Definition Language),Vendor NDK(VNDK)等。 HIDL 是一个定义供应商如何服务框架的方案。

VNDK 是供应商在实现其功能时可以依赖的一组属于框架部分的本地库。在 Treble 中,如果供应商没有框架更新,Android 系统会将遗留的 VNDK 库保留在 /system/lib/nvdk-sp 中,以便供应商部分代码仍然可以使用它们。如果框架和供应商是相同的版本,他们使用的 VNDK 库集合也是一样的;否则,他们使用不同的 VNDK 。

Android 系统通过创建 sphalvndk 命名空间 来管理这些场景,其中所有的供应商库都被加载(通过libvndksupport.so类似的逻辑)和旧的 VNDK 库被加载。sphal 命名空间 链接到vndk 命名空间 ,以防需要传统的 VNDK 实现。

生态系统的影响与解决方案

命名空间 影响了许多依赖于非 NDK 库的应用程序。我们调查了这些应用程序,并介绍了相关的临时额向后兼容性解决方案。

一些“行为不端”的应用程序

Instagramcom.instagram.android,版本 10.3.2libigbitmap_runtime_for_v21.solibigbitmap_runtime_for_v23.so 依赖于libandroid_runtime.so)是一个依赖的私有库libandroid_runtime.so的例子。根据 Android 的 命名空间 机制和策略,加载该库失败。当库加载失败时,如果不忽略UnsatisfiedLinkError——这是大多数情况——应用会直接崩溃。然而,有太多的应用程序与私有库链接,因此直接禁止他们都太激进了。Greylist(将在 “Greylist” 一节中详解)容忍一些传统的行为不端的应用程序。

Messenger(com.facebook.orca,版本 95.0.0.20.70libcpp_helper 中尝试 dlopen(libart.so))是一个加载私有库libart.so的例子。由于libart.so不是 greylist 的一部分,这意味着 Messenger 将会发生错误。而这一行为在 Facebook 的应用程序中非常普遍,同时 Facebook 又是一个极为强势的应用程序供应商,似乎谷歌未能在 libart.so 问题上和 Facebook 达成一致。最后的解决办法是,当创建 classloader-namespace 命名空间 时,Android 添加/system/fake-libs 作为 LSPath 的一部;;并创建一个假的 libart.so ,这个 libart.so 只打印一些日志来“敷衍” Facebook 应用程序。

Greylist

为尽量减少 命名空间 对生态系统的影响,Google 调查了Play商店中“行为不端”的应用,最终设计了 greylist 来兼容它们。Greylist 是一个库的集合,当在一个 命名空间 中加载库并无法在当前 命名空间 和链接的 命名空间 中加载某个库是,如果该库是一个 greylist 库,则 Android 动态链接器将尝试使用 (default) 命名空间 的 LSPath 。

Greylist 仅适用于针对早期 Android 系统的应用程序(Nougat之前)。如果应用程序加载或依赖于这些库,且没有自己打包,系统将会将会加载 greylist 库。 Greylist 的依赖库也都会“成为” greylisted (NDK 库除外),否则某些依赖就无法加载。 Greylist 库直接加载到当前的 命名空间 中——在内存中的位置与 (default) 命名空间 中的同名库不同。

在某些情况下,访问非 greylist 私人库也可能会成功。例如,dlopen(libandroid_runtime.so) 之后的 dlopen(libandroid_runtime.so) 将通过 libandroidfw.so (这是一个非 greylist 库,它依赖于 greylistlibandroid_runtime.so )成功加载,因为此时 libandroid_runtime.so 已经在这个 命名空间 中了。这个现象应该超出了 greylist 的设计意图。无论如何,greylist 是一种临时行的向后兼容性解决方案,据称将在未来的 Android 版本中被删除。

内存消耗

最后,我们介绍 命名空间 引入的额外的内存消耗。

我们已经说明过,经典动态链接在进程的范围内进行库加载。那么对于存储系统中的任何库,每个进程的内存中只有一个副本。然而,基于 命名空间 的动态链接可能会根据应用程序行为将不同的库副本加载到内存中。

额外的内存消耗是因为大多数库不在 命名空间 间共享。典型的例子是 greylist 库。考虑一个面向 Android Marshmallow 的应用程序,它有三个classloader- namespace 命名空间 ,如ns1ns2ns3,其中分别加载了 lib1.solib2.solib3.so 。这三个库都依赖于libandroid_runtime.so(一个 greylist 库)。因此,除了(default) 命名空间 之外,在这三个 命名空间 中各自存在这 libandroid_runtime.so 及其依赖库的副本。

拥有多个 命名空间 并加载 greylist 库的应用程序将会消耗大量额外的内存。

总结

为强制 Android 应用程序仅使用公共系统库和在 Project Treble 中同时管理框架和供应商库,Android 引入了基于 命名空间 的动态链接和精确的使用策略。

Android 动态链接器将一个进程的动态链接范围分隔成 命名空间 。在每个 命名空间 中加载的库与在一个进程中的常规动态链接相同。因此,一个 命名空间 中的库将对其他 命名空间 隐藏。同时,命名空间链接 将一个 命名空间 链接到另一个 命名空间 ,在 命名空间 之间共享特定的库。

通过依据 Java 类加载器 维护 命名空间 ,并在这些 命名空间 共享公共库,Android 系统隔离了系统和应用程序的本地库。此外,还引入了 greylist (其授予应用程序访问某些特定的私有库)来暂时解决行为不端的应用程序。Greylist 的副作用是增加了本地库的内存消耗。

通过在加载供应商库和 VNDK 库中创建 sphal 命名空间vndk 命名空间 ,Android 系统在一个进程中分别管理框架库和供应商库。本地库的分层和框架库与供应商库之间的交互是复杂的。

总而言之, 命名空间 是一种可以有效和优雅地隔离并共享本地库资源的方案。它可以部署到其他现代操作系统中,以阻止应用程序滥用系统功能,从而改善整个生态系统。

参考资料

呃,实际上所有的参考资料都已经通过链接展示了,那么似乎不需要在列一个表在这里……