Binder | 内存拷贝的本质和变迁

10,362 阅读13分钟

说起Binder的内存拷贝,相信大多数人都听过“一次拷贝”:相较于传统IPC的两次拷贝,Binder在数据传输时显得效率更高。

其实不少人在面试时都能回答出上面这句话,但若是追问他更多细节,估计又哑口无言了。

其实内存拷贝的概念既简单又复杂。简单是因为它功能单一,而复杂则在于不少人对于虚拟内存,物理内存,用户空间,内核空间的认识并不充分。所谓地基不稳,高楼难立。

本文尝试揭示Binder内存拷贝的本质,另外还会介绍新版本中相应实现的一些改动。

1. 内存拷贝概述

在做任何一件事之前,先明确目的。我相信Binder的开发者在最初设计时也一定仔细考虑过这个问题。根据我的理解,Binder数据传输的目的可以概括成这句话:

一个进程可以通过自己用户空间的虚拟地址访问另一个进程的数据。

要想充分理解这句话,需要在基础知识上达成一些共识。

1.1 虚拟地址和数据的关系

所有的数据都存储在物理内存中,而进程访问内存只能通过虚拟地址。因此,若是想成功访问必须得有个前提:

虚拟地址和物理内存之间建立映射关系

若是这层映射关系不建立,则访问会出错。信号11(SIGSEGV)的MAPERR就是专门用来描述这种错误的。

虚拟地址和物理地址间建立映射关系通过mmap完成。这里我们不考虑file-back的mapping,只考虑anonymous mapping。当mmap被调用(flag=MAP_ANONYMOUS)时,实际上会做以下两件事:

  1. 分配一块连续的虚拟地址空间。
  2. 更新这些虚拟地址对应的PTE(Page Table Entry)。

mmap做完这两件事后,就会返回连续虚拟地址空间的起始地址。在mmap调用结束后,其实并不会立即分配物理页。如果此时不分配物理页,那么就会有如下两个问题:

  1. 没有新的物理页分配,那么PTE都更新了哪些内容?
  2. 如果后续使用mmap返回的虚拟地址访问内存,会有什么情况产生呢?

1.1.1 没有新的物理页分配,那么PTE都更新了些什么内容呢?

PTE也即页表的条目,它的内容反映了一个虚拟地址到物理地址之间的映射关系。如果没有新的物理页分配,那这些新的虚拟地址都和哪些物理地址之间建立了映射关系呢?答案是所有的虚拟地址都和同一个zero page(页内容全为0)建立了映射关系。

1.1.2 如果后续使用mmap返回的虚拟地址访问内存,会有什么情况产生呢?

拿到mmap返回的虚拟地址后,并不会有新的物理页分配。此时若是直接读取虚拟地址中的值,则会通过PTE追踪到刚刚建立映射关系的zero page,因此读取出来的值都是0。

如果此时往虚拟地址中写入数据,将会在page fault handler中触发一个正常的copy-on-write机制。需要写多少页,就会新分配多少物理页。所以我们可以看到,真实的物理页是符合lazy(on-demand) allocation原则的。这一点,极大地保证了物理资源的合理分配和使用。

1.2 进程间用户空间/内核空间是否隔离?

先说结论,不同进程间的用户空间是完全隔离的,内核空间是共享的。

那么“隔离”和“共享”在这个语境下又是什么意思呢?

从实现角度而言,“隔离”的意思是不同进程的页表不同,“共享”的意思是不同进程的页表相同,仅此而已。我们知道,页表反映的是虚拟地址和物理地址的映射关系。那么一张页表应该管理哪些虚拟地址呢?是整个地址空间的所有虚拟地址么?

当然不是。Linux将虚拟地址空间分为了用户空间和内核空间,因此管理不同空间虚拟地址的页表也不一样。

如上图所示,A进程的用户空间使用页表1,B进程的用户空间使用页表2,而A/B进程的内核空间都使用页表3。A/B中使用相同的用户空间虚拟地址来访问内存,由于页表不同,因此最终映射的物理页也不同,这就是所谓的“进程隔离”。而由于A/B进程的内核空间使用了同一张页表,所以只要他们使用相同的虚拟地址(位于内核空间),那么必然访问到同一个物理页。

1.3 数据传输的两种方式

1.3.1 共享内存

虚拟地址只是为了进行内存访问封装的一层接口,而数据总归是存在物理内存上的。因此,若是想让A进程通过(用户空间)虚拟地址访问到B进程中的数据,最高效的方式就是修改A/B进程中某些虚拟地址的PTE,使得这些虚拟地址映射到同一片物理区域。这样就不存在任何拷贝,因此数据在物理空间中也只有一份。

1.3.2 内存拷贝

共享内存虽然高效,但由于物理内存只有一份,因此少不了考虑各种同步机制。让不同进程考虑数据的同步问题,这对于Android而言是个挑战。因为作为系统平台,它必然希望降低开发者的开发难度,最好让开发者只用管好自己的事情。因此,让开发者频繁地考虑跨进程数据同步问题不是一个好的选择。

取而代之的是内存拷贝的方法。该方法可以保证不同进程都拥有一块属于自己的数据区域,该区域不用考虑进程间的数据同步问题。

由于不同进程的内核空间是共享的(只有共享才能完成传输,否则只能隔江相望了),因此很自然地考虑到将它作为数据中转站。常规的做法需要两次拷贝,一次是由发送进程的用户空间拷贝到发送进程的内核空间,另一次是由接收进程的内核空间拷贝到接收进程的用户空间。这两次拷贝中间有一个隐含的转换关系,即发送进程的内核空间和接收进程的内核空间是共享的,因此持有相同的虚拟地址就会访问到同一片物理区域。

两次拷贝的方法比较符合直觉,但在效率上还有可优化的空间。

既然两次拷贝都发生在一个进程的用户空间和内核空间之间,那么其实也就隐含了一个前提:

用户空间和内核空间的虚拟地址指向不同的物理页。

正是因为指向不同的物理页,所以才需要拷贝。那有没有可能让二者指向同一个物理页?如果可以,这样不就节省了一次拷贝么?

事实上,Binder正是这样做的。

2. Binder内存拷贝的实现

2.1 早期版本(≤Android P)

为了减少一次拷贝,接收数据的进程必须同时满足下面三个条件:

  1. 在用户空间分配一块连续区域A(仅仅是虚拟地址的分配)。
  2. 在内核空间分配一块同样大小的连续区域B(同样仅仅是虚拟地址的分配)。
  3. 在每次数据通信的时候,根据实际需求分配物理页,并将该物理页同时映射到A/B中偏移相同的位置。

条件1、2在进程调用Binder的mmap函数时已经完成,而条件3则在每次数据通信时进行。

下面假设进程1发送数据,进程2接收数据,我们来分析下内存拷贝到底发生在何时(以下执行均发生在进程1中,只不过此时正在执行驱动代码[陷入内核态])。

  1. 由于进程2之前调用过mmap函数(只会调用一次),因此它拥有用户空间的区域A和内核空间的区域B(只分配了虚拟地址,并未映射物理页)。
  2. 得知即将发送的数据大小,并根据该大小分配实际的物理页。
  3. 将刚刚分配出来的物理页映射到进程2的A/B区域中(由于进程1处于内核态,因此可以操作进程2的PTE)。
  4. 将用户空间的发送数据通过copy_from_user拷贝到内核区域B中。
  5. 由于A/B映射到同样的物理页,因此B中的数据也可以通过A的地址读取出来。

整个过程中,只有步骤4发生了一次数据拷贝。

2.2 当前版本(Android Q,R)

从性能角度而言,早期版本的实现几乎无可挑剔。但是它有一个致命的稳定性缺陷,这是Google工程师们无法忍受的。因此从Android Q开始,Binder内存拷贝的实现有了新的改动。

通过之前的分析可以知道,驱动的mmap函数执行完之后,该进程将会在内核空间分配一块虚拟地址区域B。对Android应用进程而言,B的默认大小为1M-8K。只要这个进程没有退出,这1M-8K的虚拟地址就会一直分配给它。

通常对于虚拟地址长时间的占用并不会产生问题,但不幸的是,Binder的这个占用确实产生了问题。

2.2.1 32位机器上Binder内存拷贝的缺陷

32位机器的寻址空间为4G,其中高位的1G用作内核地址空间,低位的3G用作用户地址空间,这些都是虚拟地址的概念。

1G的内核地址空间又划分为四块不同的区域:

  • 直接映射区(Direct memory region),该区域的虚拟地址和物理地址上的低端内存直接映射,因此虚拟地址和物理地址之间永远差一个固定的偏移。kmalloc分配的地址就位于此块区域。
  • vmalloc区,该区域的虚拟地址可以映射到物理地址上的高端内存。由于高端内存的地址范围远大于vmalloc区域的地址范围,因此二者之间的映射不能采用线性映射,只能是动态映射。vmalloc分配的地址就位于此块区域。
  • 临时映射区(Kmap region),4M的固定大小,主要用于先有物理页而后需要为其分配内核空间地址的情况。调用一次kmap只能映射一页,常用于短时间映射的场景。
  • 固定映射区(Fixed mapping region)

vmalloc区的大小随着Kernel版本的不同也发生过变化。从Kernel 3.13开始,vmalloc区域由128M增加到240M。240M看似是个不小的数字,但在应用启动过多的手机上将会出问题。此话怎讲?

随着Android Treble项目(Android O引入)的启动,hardware binder正式进入大众视野。一方面越来越多的HAL service使用hwbinder进行跨进程通信,另一方面原先只需分配1M-8K的应用进程现在需要多分配一块区域用于hwbinder通信。因此,binder驱动对于内核空间vmalloc区域的占用成倍地上升。当应用启动过多时,vmalloc区域的虚拟地址将有可能被耗尽。注意,这里指的是虚拟地址被耗尽,而不是物理地址被耗尽。

当vmalloc区域的虚拟地址被耗尽时,内核中某些使用vmalloc和vmap的代码将会报错,因为他们此时分配不出新的虚拟地址。

为了缓解这个问题,一个简单的想法自然就是增大vmalloc区域。但是1G的内核空间是固定的,厚此必定薄彼。vmalloc区域增大,意味着直接映射区减少。而直接映射区一个最大的好处就是高效(因为采用了线性映射),所以不能被无限制缩小。因此增大vmalloc区域的做法只能算是缓兵之计,绝非最佳策略。

2.2.2 新的实现

让我们回到最初的目的,仔细思考内核空间虚拟地址存在的意义。

其实,它只是内核空间中我们为物理页找的访问入口而已,它既没有一直存在的必要,也不会有后续使用的价值。一旦数据传输完毕,这个入口也就失去了意义。

既然如此,我们何不采用一种更加动态的方式,在每次传输之前分配这个入口,传输完成后再释放这个入口?

事实上新版本的Binder就是这么做的。

上图右边的文字展示了一次完整数据传输所经历的过程。早期版本的Binder通过一次copy_from_user将数据整体拷贝完成,新版本的Binder则通过循环调用copy_from_user将数据一页一页的拷贝完成。以下是核心代码差异的展示:

Android version ≤ P:

/drivers/staging/android/binder.c

1501	if (copy_from_user(t->buffer->data, (const void __user *)(uintptr_t)
1502			   tr->data.ptr.buffer, tr->data_size)) {
1503		binder_user_error("%d:%d got transaction with invalid data ptr\n",
1504				proc->pid, thread->pid);
1505		return_error = BR_FAILED_REPLY;
1506		goto err_copy_data_failed;
1507	}
1508	if (copy_from_user(offp, (const void __user *)(uintptr_t)
1509			   tr->data.ptr.offsets, tr->offsets_size)) {
1510		binder_user_error("%d:%d got transaction with invalid offsets ptr\n",
1511				proc->pid, thread->pid);
1512		return_error = BR_FAILED_REPLY;
1513		goto err_copy_data_failed;
1514	}

Android version ≥ Q:

/drivers/android/binder_alloc.c

1108 	while (bytes) {
1109 		unsigned long size;
1110 		unsigned long ret;
1111 		struct page *page;
1112 		pgoff_t pgoff;
1113 		void *kptr;
1114 
1115 		page = binder_alloc_get_page(alloc, buffer,
1116 					     buffer_offset, &pgoff);
1117 		size = min_t(size_t, bytes, PAGE_SIZE - pgoff);
1118 		kptr = kmap(page) + pgoff;
1119 		ret = copy_from_user(kptr, from, size);
1120 		kunmap(page);
1121 		if (ret)
1122 			return bytes - size + ret;
1123 		bytes -= size;
1124 		from += size;
1125 		buffer_offset += size;
1126 	}

可以看到在新版本的实现中,每拷贝一页的内容就调用一次kunmap将分配的内核空间虚拟地址释放掉。这样就再也不会发生长时间占用内核空间虚拟地址的情况。