0%

第6章:进程间通信——Binder

我们知道,同一个程序中两个函数之间能够直接调用的根本原因是他们处于相同的内存区域中,因为在同一个内存区域中,虚拟地址的映射规则完全一致,所以A函数和B函数的调用关系很简单,但两个不同进程,他们是没有办法直接通过内存地址来访问到对方内部的函数或者变量的。

既然无法直接访问,那间接的方法就是Binder。如果通观Binder的各个元素,就会惊奇地发现它和 TCP/IP 网络有很多相似之处:

  • Binder 驱动 -> 路由器
  • Service Manager(本质也是个服务器) -> DNS(本质也是个服务器)
  • Binder Client -> 客户端
  • Binder Server -> 服务器

TCP/IP中一个典型的服务连接过程如下图所示:

TCP/IP连接

这个简化的流程图有以下几个步骤:

  • Client 向dns 查询 google.com 的ip地址。
    显然,Client 一定得先知道DNS 的 IP地址,才能向它发起查询,DNS 服务器的ip设置是在接入网络前就已经设置完成的。当然,client向DNS 查询 ip地址不一定需要,因为如果已经知晓server的ip,就无需这一步,从而加快访问速度;

  • DNS 将查询结果返回 Client。
    Client的Ip地址对于 DNS 是必须的,不过这些信息会封装在 TCP/IP 包中。

  • Client 发起连接。这里我们没有提及Router的作用,因为它所负担的责任是将数据包投递到用户设定的目标IP中,它是整个通信结构中的基础。

而对于Binder来说,Binder的 DNS (Service Manager)也不是必须的——前提是客户端能记住它要访问的进程的 Binder 标志(IP地址),尤其要注意的是,这个标志是个“动态IP”,意味着即使客户端记住了本次通信过程中目标进程的唯一标志,下一次访问仍然需要重新获取。因此,Service Manager 这个 DNS 的还是挺有必要的。

既然Service Manager是 DNS 服务器,那么它的 IP 地址是多少呢?Binder 机制对此作了特别规定,Service Manager在Binder通信中唯一标志用于都是0。

智能指针

进程间的数据传递载体——Parcel

关于进程间如何传递,我们可以使用2个生活例子来类比:

  • 用快递寄衣服:虽然快递种类比较多,但是无论用哪个快递,用哪种运输方式,“衣服”本身始终是没有变过的,接收人拿到的还是原来那件衣服。
  • 通过电子邮件发送图片:在接收人看到邮件中的图片时,我们无法确认这张图片在传输过程中被复制了多少次,但是可以肯定的是,对方看到的图片和原始图片是一样的。

进程间通信的数据传递类似于第2种情况,如果只是一个int型数值,不断复制直到目标进程即可;如果是一个对象呢?我们直到,同一个进程中对象的传递实质上是传递了一个内存地址,但是在跨进程的情况下就无能为力了,因为采用了虚拟内存机制,两个进程都有自己独立的内存地址空间,所以跨进程传递的地址空间是无效的。

进程间的数据传递是Binder 机制中的重要环节,而担负这一重任的就是Parcel。 Parcel 的直译是 “打包” ,上面提到,进程间数据传递直接传送对象的地址是行不通的,那把对象在进程 A 中占据的内存相关数据打包起来,然后寄送到内存 B 中,由 B 在自己的进程空间中“复现”这个对象,是否可行? Parcel 就具有这种打包能力。

Parcel 提供了很多接口方便程序使用,他可以存储多种类型的数据:

  • 原始数据类型 以及 原始数据类型数组
  • Parcelable
  • Bundle
  • Active Objects

通常我们存入Parcel 的是对象的内容,而 Active Objects 写入的则是他们的特殊标志引用。所以从 Parcel 中读取这些对象时,大家看到的并不是重新创建的对象实例,而是原来那个被写入的实例,能够以这种方式传递的对象目前主要有两类: Binder 以及 FileDescriptor (Linux 中的文件描述符)。

  • Untyped Containers

它是用于读写标准的任意类型的 Java 容器,包括 : writeArray(Object[])/readArray(ClassLoader)、writeList(list)/readList(list)

Parcel 可以用集装箱来类比,理由如下:

  • 货物无关性: 不排斥运输的货物种类,电子产品、汽车等都可以
  • 不同的货物需要不同的打包和卸货方案: 比如运载易碎物品和坚硬物品的装箱和卸货方式就有很大的不同。

值得注意的是,Parcel 存/取 数据的方式都是一一对应的,如 writeByte(byte)/readByte()

  • 远程运输和组装: 集装箱的货物一般需要跨洋,这类似于 Parcel 的跨进程。不过集装箱运输公司本身并不负责所运输货物的组装,而 Parcel 会依据协议为接收方提供还原冤死数据对象的业务。

Parcel 的工作方式(书上没有,自己添加)

Parcel 的 Parcel.obtain() 方法可以获取一个Parcel 对象,系统预先产生了一个大小为6的 Parcel 池 sOwnedPool,在obtain 操作时,如果 sOwnedPool 中还有现成的 Parcel 对象,则直接利用,否则通过 new Parcel(0) 创建 Parcel 对象。

Parcel.java 实际上只是一个简单的中介,它的主要内容都是 JNI 层的 Parcel 实现的。Parcel 对象的初始化过程只是简单地给各个变量赋初始值,并没有设想中的内存分配动作,因为 Parcel 遵循的是“动态扩展”的内存申请原则,只有在需要时才申请内存,避免资源浪费。

Parcel 提供数据当前位置的值 dataPosition,类似于游标。每当存储新数据时,都是从 dataPosition 位置接着往后存储,存储新数据时,会判断当前空间是否足够,如果不足,则申请新的空间(个人根据文中内容理解的,不太确定是否正确)。

Binder驱动与协议

Android 是 linux 内核的,因而 Binder 驱动也是标准的 linux 驱动,具体而言,Binder驱动会把自己注册成一个 misc device,并向上层提供 /dev/binder节点——但是它并不对应真实的硬件设备。Binder 驱动运行于内核态,可提供 open()、ioctl()、mmap() 等常用文件操作。

Android 系统为什么把 Binder 注册成 misc device 类型的驱动呢?因为 linux 字符设备通常要通过 alloc_chrdev_region()、cdev_init() 等操作才能在内核中注册自己;而 misc 类型驱动相对简单,只需要 misc_register() 就可轻松解决。

Binder 驱动为上层提供了6个接口,但一般文件操作用到的 read() 和 write() 则没有出现,这是因为它们的功能完全可以用 ioctl() 和 mmap() 来代替,并且会更灵活。这6个接口中使用得最多的是 binder_ioctl,binder_mmap 和 binder_open,,以下分别介绍这三种接口。

打开Binder驱动——binder_open

上层进程在访问 Binder 驱动时,首先需要打开 /dev/binder 节点,这个操作最终的实现是在 binder_open() 中,在这个方法中,会创建一个 binder_proc 实体,这个实体用于记录各种管理数据(Binder 驱动会在 /proc 系统目录下生成各种管理信息),并且,每个进程都有独立的记录。

在完成proc 的初始化之后,就会把这个 proc 加入到 Binder 的全局管理中,这个过程涉及资源互斥,因而需要使用保护机制。到目前为止,Binder 驱动已经为用户创建了一个它自己的 binder_proc 实体,之后用户对Binder 设备的操作都以这个对象为基础。

binder_mmap

对于 Binder 驱动来说,上层用户调用的 mmap() 最终对应了 binder_mmap()操作(应用程序最多只能申请 4M 的空间,如果超出这个大小,不会退出或者报异常,而只会满足用户 4M 的请求),那么Binder 采用 mmap 的目的是什么呢?我们知道,mmap() 可以把设备指定的内存块直接映射到应用程序的内存空间中,但Binder 本身并不是硬件设备,而是基于内存的“伪硬件”,那么它映射了什么内存块到应用程序中呢?

假设有连个进程A和B,其中进程B通过 open() 和 mmap() 与Binder驱动建立了联系,如下图:

进程B连接Binder

可以看到 :

  1. 对于进程B而言,通过mmap()返回值得到一个内存地址(当然是虚拟地址),这个地址最终会指向物理内存的某个位置(通过虚拟内存转换)。
  1. 对于Binder驱动而言,它也有个指针(binder_proc->buffer)指向某个虚拟内存地址,这个地址转换后,与进程B指向的物理内存地址位于同一个位置。

个人理解:进程B执行 mmap() ,最终是通过 Binder 的 binder_mmap() 来实现,在 B 拿到这块内存后(当然是经过虚拟内存转换后的虚拟内存地址),Binder 驱动同时将这块内存赋值给了 binder_proc->buffer。这样,Binder和应用程序就拥有了若干公用的物理内存块,它们对着各自内存地址的操作,实际是在同一块内存中执行,这时候我们再把A进程加入进来,如下图:

进程A复制数据

这时候,左半部分没有变化,右半部分Binder驱动通过copy_from_user(),把A进程中某段数据复制到其binder_proc->buffer所指向的内存空间,这时候我们惊喜发现,binder_proc->buffer在物理内存中的位置和进程B是共享的,进而,B进程可以直接访问到这段数据,也就是说,Binder驱动只用了一次复制,就实现了进程A和B之间的数据共享。

以上通过 mmap 映射的映射区是 只读的。

binder_ioctl

这是 Binder 接口函数中工作量最大的一个。前面提到过,Binder 并不提供 read() 和 write() 等常规文件操作,因为 ioctl 完全可以替代它们。它主要提供了以下命令:

  • BINDER_WRITER_READ: 读写操作,可以用此命令向 Binder 读取或写入数据
  • BINDER_SET_MAX_THREAD: 设置支持的最大线程数,因为客户端可以并发向服务器端发送请求,如果Binder 驱动发现当前的线程数量已经超过设定值,就会告知 Binder server 停止启动新的线程。
  • BINDER_SET_CONTEXT_MGR: Service Manager 专用,让它把自己设置为“Binder”大管家。系统中只有一个 Service Manager。

Service Manager

Service Manager(后面简称 SM) 也就是Binder 中的 “DNS服务器”,既然是DNS,那么在用户可以浏览网页之前就必须就位,因此SM在有人使用Binder之前就处于正常工作状态。SM 的主要工作:

  • 从Binder驱动读取消息
  • 调用binder_parse 处理解析消息
  • 不断循环,而且永远不会主动退出,除非出现致命错误

它提供的服务应该至少包括以下几种:

  • 注册——当一个Binder Server 创建后,它们要将自己的相关信息告知 SM 备案
  • 查询——应用程序可以向 SM 发起查询请求,已获知某个 Binder Server 对应的句柄。

SM 的查询过程很简单,主要是调用 do_find_server 遍历内部列表,并返回目标 Server;注册 Binder server 也很简单,首先在 SM 维护的数据列表中查找是否已经有对应的节点存在,,如果没有,就创建一个新的节点记录这个 Server。

实际上,我们获取 SM 也很简单,只需要以下几步:

  1. 打开Binder设备
  2. 执行mmap
  3. 通过Binder驱动向 SM 发送请求(SM 的 handle是 0)
  4. 获得结果

Binder 客户端

Binder 的最大消费者是Java层的应用程序,但是在各种上层的应用场景中切换“过于丝滑”,因此我们很少能感觉到Binder的存在,但是我们能够通过Android的四大组件的行为看出蛛丝马迹:

  • Activity:通过 startActivity 可以启动目标进程,不论它是不是属于这个应用。
  • Service:任何应用程序都可以通过startService 或者 bindService 来启动特定的服务,而不论后者是不是跨进程的。
  • BroadCast:任何应用都可以通过 sendBroadcast 来发送一个广播,且无论广播接收者是不是在同一个进程中。

组件的上述操作中,多数并不会特别指明要由哪个目标应用程序来响应请求,它们只需要通过Intent表达意愿,然后由系统找出最匹配的应用进程完成工作。为了更明确地说明个中的进程间通信,这里以binderService举例说明:

  1. 首先Application1 填写Intent,调用 bindService 发出请求
  2. 在Application1的运行空间中收到 bindService 请求,这时候会与 ActivityManagerService(AMS),这就需要得到AMS的Binder句柄,就涉及到进程间通信了(需要ServiceManage.getService),拿到句柄后,程序才能真正向它发起请求。
  3. AMS基于“最优匹配策略”,从其存储的所有服务组件中找到最符合Intent的一个,然后向它发送Service绑定请求(这也是进程间通信),如果目标进程还不存在的话,AMS还要负责将其启动
  4. “被绑定”的服务进程需要响应绑定,并在完成任务后通知AMS,然后由后者回调发起请求的Application1(回调接口是ServiceConnection)。

Server 服务端

在建立服务之后,可以有两种方式向外面提供服务:

  • Server在ServiceManager中注册,这样,调用者只需要通过 ServiceManager.getService(NAME)就可以获得句柄,随后与之通信。
  • 所谓的“匿名Server”,并不需要在ServiceManager中注册,那么Client是如何访问的呢?其实它通过其他Server作为中介,没错,就是通过一个“第三方”实名的Server,调用者首先通过ServiceManager获取这个实名的server,在由它提供匿名者的 Binder 句柄。

Binder 优点

基于自己的理解而言,Binder具有以下优点:

  • 性能较好

Binder只需要拷贝一次数据,仅次于共享内存(一次都不要),消息队列和管道采用存储-转发方式,即数据先从发送方缓存区拷贝到内核开辟的缓存区中,然后再从内核缓存区拷贝到接收方缓存区,至少有两次拷贝过程。

  • 稳定性好

基于C/S架构在逻辑上更清晰,client有需求,Server完成。共享内存实现起来复杂,各方没有客户端与服务端之别,要考虑并发问题以及可能出现的死锁。

  • 安全性好

传统 Linux IPC 的接收方要么无法获得对方进程可靠的 UID/PID ,从而无法鉴别对方身份;要么只能由用户在数据包里填入UID/PID。Android 为每个应用分配了自己的UID,前面提到 C/S 架构,Server 会根据权限控制策略判断 Client 的请求是否满足权限。并且Binder机制还有匿名 Binder ,压根无法直接获取句柄,安全性更好。

  • 使用简单。

获得句柄之后,就像调用本地方法一样方便。并且Linux的IPC方式使用C语言,而Android应用层主要使用Java,这可能也是个因素。

谢谢你的鼓励