【干货篇】全网最通透的Binder内存拷贝的本质和变迁
纸上得来终觉浅,绝知此事要躬行。
作者:芦航
说起Binder的内存拷贝,相信大多数人都听过“一次拷贝”:相较于传统IPC的两次拷贝,Binder在数据传输时显得效率更高。
其实不少人在面试时都能回答出上面这句话,但若是追问他更多细节,估计又哑口无言了。
其实内存拷贝的概念既简单又复杂。简单是因为它功能单一,而复杂则在于不少人对于虚拟内存,物理内存,用户空间,内核空间的认识并不充分。所谓地基不稳,高楼难立。
本文尝试揭示Binder内存拷贝的本质,另外还会介绍新版本中相应实现的一些改动。
1. 内存拷贝概述
在做任何一件事之前,先明确目的。我相信Binder的开发者在最初设计时也一定仔细考虑过这个问题。根据我的理解,Binder数据传输的目的可以概括成这句话:
一个进程可以通过自己用户空间的虚拟地址访问另一个进程的数据。
要想充分理解这句话,需要在基础知识上达成一些共识。
1.1 虚拟地址和数据的关系
所有的数据都存储在物理内存中,而进程访问内存只能通过虚拟地址。因此,若是想成功访问必须得有个前提:
虚拟地址和物理内存之间建立映射关系
若是这层映射关系不建立,则访问会出错。信号11(SIGSEGV)的MAPERR就是专门用来描述这种错误的。
虚拟地址和物理地址间建立映射关系通过mmap完成。这里我们不考虑file-back的mapping,只考虑anonymous mapping。当mmap被调用(flag=MAP_ANONYMOUS)时,实际上会做以下两件事:
- 分配一块连续的虚拟地址空间。
- 更新这些虚拟地址对应的PTE(Page Table Entry)。
mmap做完这两件事后,就会返回连续虚拟地址空间的起始地址。在mmap调用结束后,其实并不会立即分配物理页。如果此时不分配物理页,那么就会有如下两个问题:
- 没有新的物理页分配,那么PTE都更新了哪些内容?
- 如果后续使用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)
为了减少一次拷贝,接收数据的进程必须同时满足下面三个条件:
- 在用户空间分配一块连续区域A(仅仅是虚拟地址的分配)。
- 在内核空间分配一块同样大小的连续区域B(同样仅仅是虚拟地址的分配)。
- 在每次数据通信的时候,根据实际需求分配物理页,并将该物理页同时映射到A/B中偏移相同的位置。
条件1、2在进程调用Binder的mmap函数时已经完成,而条件3则在每次数据通信时进行。
下面假设进程1发送数据,进程2接收数据,我们来分析下内存拷贝到底发生在何时(以下执行均发生在进程1中,只不过此时正在执行驱动代码[陷入内核态])。
- 由于进程2之前调用过mmap函数(只会调用一次),因此它拥有用户空间的区域A和内核空间的区域B(只分配了虚拟地址,并未映射物理页)。
- 得知即将发送的数据大小,并根据该大小分配实际的物理页。
- 将刚刚分配出来的物理页映射到进程2的A/B区域中(由于进程1处于内核态,因此可以操作进程2的PTE)。
- 将用户空间的发送数据通过copy_from_user拷贝到内核区域B中。
- 由于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将分配的内核空间虚拟地址释放掉。这样就再也不会发生长时间占用内核空间虚拟地址的情况。
文末
感谢大家关注我,分享Android干货,交流Android技术。
对文章有何见解,或者有何技术问题,都可以在评论区一起留言讨论,我会虔诚为你解答。
Android架构师系统进阶学习路线、58万字学习笔记、教学视频免费分享地址:我的GitHub
也欢迎大家来我的B站找我玩,有各类Android架构师进阶技术难点的视频讲解,助你早日升职加薪。
B站直通车:https://space.bilibili.com/544650554