Open cyrushine opened 1 year ago
https://juejin.cn/post/6844904113046568973
进程A通过 binder_ transaction 往进程B传输数据的过程,其中步骤 4、5、6 和 7 是一页一页地循环执行:
binder_ transaction
准备需要发送的数据, 数据存放在进程A的用户空间中
进程A通过系统调用进入binder驱动的代码逻辑中
在binder驱动代码中, 找到与所发送数据大小匹配的进程B的 binder buffer, 该 buffer 此时只有虚拟地址(虚拟地址指向进程B的用户空间), 尚未映射物理页
binder buffer
为此次数据传输分配物理页
建立 binder buffer 中虚拟页与物理页的映射关系
通过 kmap 为物理页映射一个内核空间的虚拟页
kmap
copy_ from_user 将进程A用户空间的数据写入刚刚映射的内核空间虚拟页中
copy_ from_user
根据映射关系, 刚刚写入的数据现在可以通过进程B binder buffer的地址读出
说起Binder的内存拷贝,相信大多数人都听过“一次拷贝”:相较于传统IPC的两次拷贝,Binder在数据传输时显得效率更高。 其实不少人在面试时都能回答出上面这句话,但若是追问他更多细节,估计又哑口无言了。 其实内存拷贝的概念既简单又复杂。简单是因为它功能单一,而复杂则在于不少人对于虚拟内存,物理内存,用户空间,内核空间的认识并不充分。所谓地基不稳,高楼难立。 本文尝试揭示Binder内存拷贝的本质,另外还会介绍新版本中相应实现的一些改动。
在做任何一件事之前,先明确目的。我相信Binder的开发者在最初设计时也一定仔细考虑过这个问题。根据我的理解,Binder数据传输的目的可以概括成这句话:
一个进程可以通过自己用户空间的虚拟地址访问另一个进程的数据。
要想充分理解这句话,需要在基础知识上达成一些共识。
所有的数据都存储在物理内存中,而进程访问内存只能通过虚拟地址。因此,若是想成功访问必须得有个前提:
虚拟地址和物理内存之间建立映射关系
若是这层映射关系不建立,则访问会出错。信号11(SIGSEGV)的MAPERR就是专门用来描述这种错误的。 虚拟地址和物理地址间建立映射关系通过mmap完成。这里我们不考虑file-back的mapping,只考虑anonymous mapping。当mmap被调用(flag=MAP_ANONYMOUS)时,实际上会做以下两件事:
mmap做完这两件事后,就会返回连续虚拟地址空间的起始地址。在mmap调用结束后,其实并不会立即分配物理页。如果此时不分配物理页,那么就会有如下两个问题:
PTE也即页表的条目,它的内容反映了一个虚拟地址到物理地址之间的映射关系。如果没有新的物理页分配,那这些新的虚拟地址都和哪些物理地址之间建立了映射关系呢?答案是所有的虚拟地址都和同一个zero page(页内容全为0)建立了映射关系。
拿到mmap返回的虚拟地址后,并不会有新的物理页分配。此时若是直接读取虚拟地址中的值,则会通过PTE追踪到刚刚建立映射关系的zero page,因此读取出来的值都是0。 如果此时往虚拟地址中写入数据,将会在page fault handler中触发一个正常的copy-on-write机制。需要写多少页,就会新分配多少物理页。所以我们可以看到,真实的物理页是符合lazy(on-demand) allocation原则的。这一点,极大地保证了物理资源的合理分配和使用。
先说结论,不同进程间的用户空间是完全隔离的,内核空间是共享的。 那么“隔离”和“共享”在这个语境下又是什么意思呢? 从实现角度而言,“隔离”的意思是不同进程的页表不同,“共享”的意思是不同进程的页表相同,仅此而已。我们知道,页表反映的是虚拟地址和物理地址的映射关系。那么一张页表应该管理哪些虚拟地址呢?是整个地址空间的所有虚拟地址么? 当然不是。Linux将虚拟地址空间分为了用户空间和内核空间,因此管理不同空间虚拟地址的页表也不一样。
如上图所示,A进程的用户空间使用页表1,B进程的用户空间使用页表2,而A/B进程的内核空间都使用页表3。A/B中使用相同的用户空间虚拟地址来访问内存,由于页表不同,因此最终映射的物理页也不同,这就是所谓的“进程隔离”。而由于A/B进程的内核空间使用了同一张页表,所以只要他们使用相同的虚拟地址(位于内核空间),那么必然访问到同一个物理页。
虚拟地址只是为了进行内存访问封装的一层接口,而数据总归是存在物理内存上的。因此,若是想让A进程通过(用户空间)虚拟地址访问到B进程中的数据,最高效的方式就是修改A/B进程中某些虚拟地址的PTE,使得这些虚拟地址映射到同一片物理区域。这样就不存在任何拷贝,因此数据在物理空间中也只有一份。
共享内存虽然高效,但由于物理内存只有一份,因此少不了考虑各种同步机制。让不同进程考虑数据的同步问题,这对于Android而言是个挑战。因为作为系统平台,它必然希望降低开发者的开发难度,最好让开发者只用管好自己的事情。因此,让开发者频繁地考虑跨进程数据同步问题不是一个好的选择。
取而代之的是内存拷贝的方法。该方法可以保证不同进程都拥有一块属于自己的数据区域,该区域不用考虑进程间的数据同步问题。 由于不同进程的内核空间是共享的(只有共享才能完成传输,否则只能隔江相望了),因此很自然地考虑到将它作为数据中转站。常规的做法需要两次拷贝,一次是由发送进程的用户空间拷贝到发送进程的内核空间,另一次是由接收进程的内核空间拷贝到接收进程的用户空间。这两次拷贝中间有一个隐含的转换关系,即发送进程的内核空间和接收进程的内核空间是共享的,因此持有相同的虚拟地址就会访问到同一片物理区域。
两次拷贝的方法比较符合直觉,但在效率上还有可优化的空间。 既然两次拷贝都发生在一个进程的用户空间和内核空间之间,那么其实也就隐含了一个前提:
用户空间和内核空间的虚拟地址指向不同的物理页。
正是因为指向不同的物理页,所以才需要拷贝。那有没有可能让二者指向同一个物理页?如果可以,这样不就节省了一次拷贝么? 事实上,Binder正是这样做的。
为了减少一次拷贝,接收数据的进程必须同时满足下面三个条件:
条件1、2在进程调用Binder的mmap函数时已经完成,而条件3则在每次数据通信时进行。 下面假设进程1发送数据,进程2接收数据,我们来分析下内存拷贝到底发生在何时(以下执行均发生在进程1中,只不过此时正在执行驱动代码[陷入内核态])。
整个过程中,只有步骤4发生了一次数据拷贝。
从性能角度而言,早期版本的实现几乎无可挑剔。但是它有一个致命的稳定性缺陷,这是Google工程师们无法忍受的。因此从Android Q开始,Binder内存拷贝的实现有了新的改动。 通过之前的分析可以知道,驱动的mmap函数执行完之后,该进程将会在内核空间分配一块虚拟地址区域B。对Android应用进程而言,B的默认大小为1M-8K。只要这个进程没有退出,这1M-8K的虚拟地址就会一直分配给它。 通常对于虚拟地址长时间的占用并不会产生问题,但不幸的是,Binder的这个占用确实产生了问题。
32位机器的寻址空间为4G,其中高位的1G用作内核地址空间,低位的3G用作用户地址空间,这些都是虚拟地址的概念。 1G的内核地址空间又划分为四块不同的区域:
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区域的做法只能算是缓兵之计,绝非最佳策略。
让我们回到最初的目的,仔细思考内核空间虚拟地址存在的意义。 其实,它只是内核空间中我们为物理页找的访问入口而已,它既没有一直存在的必要,也不会有后续使用的价值。一旦数据传输完毕,这个入口也就失去了意义。 既然如此,我们何不采用一种更加动态的方式,在每次传输之前分配这个入口,传输完成后再释放这个入口? 事实上新版本的Binder就是这么做的。
上图右边的文字展示了一次完整数据传输所经历的过程。早期版本的Binder通过一次copy_from_user将数据整体拷贝完成,新版本的Binder则通过循环调用copy_from_user将数据一页一页的拷贝完成。以下是核心代码差异的展示:
Android version ≤ P:
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:
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将分配的内核空间虚拟地址释放掉。这样就再也不会发生长时间占用内核空间虚拟地址的情况
https://juejin.cn/post/6844904113046568973
进程A通过
binder_ transaction
往进程B传输数据的过程,其中步骤 4、5、6 和 7 是一页一页地循环执行:准备需要发送的数据, 数据存放在进程A的用户空间中
进程A通过系统调用进入binder驱动的代码逻辑中
在binder驱动代码中, 找到与所发送数据大小匹配的进程B的
binder buffer
, 该 buffer 此时只有虚拟地址(虚拟地址指向进程B的用户空间), 尚未映射物理页为此次数据传输分配物理页
建立 binder buffer 中虚拟页与物理页的映射关系
通过
kmap
为物理页映射一个内核空间的虚拟页copy_ from_user
将进程A用户空间的数据写入刚刚映射的内核空间虚拟页中根据映射关系, 刚刚写入的数据现在可以通过进程B binder buffer的地址读出
说起Binder的内存拷贝,相信大多数人都听过“一次拷贝”:相较于传统IPC的两次拷贝,Binder在数据传输时显得效率更高。 其实不少人在面试时都能回答出上面这句话,但若是追问他更多细节,估计又哑口无言了。 其实内存拷贝的概念既简单又复杂。简单是因为它功能单一,而复杂则在于不少人对于虚拟内存,物理内存,用户空间,内核空间的认识并不充分。所谓地基不稳,高楼难立。 本文尝试揭示Binder内存拷贝的本质,另外还会介绍新版本中相应实现的一些改动。
内存拷贝概述
在做任何一件事之前,先明确目的。我相信Binder的开发者在最初设计时也一定仔细考虑过这个问题。根据我的理解,Binder数据传输的目的可以概括成这句话:
要想充分理解这句话,需要在基础知识上达成一些共识。
虚拟地址和数据的关系
所有的数据都存储在物理内存中,而进程访问内存只能通过虚拟地址。因此,若是想成功访问必须得有个前提:
若是这层映射关系不建立,则访问会出错。信号11(SIGSEGV)的MAPERR就是专门用来描述这种错误的。 虚拟地址和物理地址间建立映射关系通过mmap完成。这里我们不考虑file-back的mapping,只考虑anonymous mapping。当mmap被调用(flag=MAP_ANONYMOUS)时,实际上会做以下两件事:
mmap做完这两件事后,就会返回连续虚拟地址空间的起始地址。在mmap调用结束后,其实并不会立即分配物理页。如果此时不分配物理页,那么就会有如下两个问题:
没有新的物理页分配,那么PTE都更新了些什么内容呢?
PTE也即页表的条目,它的内容反映了一个虚拟地址到物理地址之间的映射关系。如果没有新的物理页分配,那这些新的虚拟地址都和哪些物理地址之间建立了映射关系呢?答案是所有的虚拟地址都和同一个zero page(页内容全为0)建立了映射关系。
如果后续使用mmap返回的虚拟地址访问内存,会有什么情况产生呢?
拿到mmap返回的虚拟地址后,并不会有新的物理页分配。此时若是直接读取虚拟地址中的值,则会通过PTE追踪到刚刚建立映射关系的zero page,因此读取出来的值都是0。 如果此时往虚拟地址中写入数据,将会在page fault handler中触发一个正常的copy-on-write机制。需要写多少页,就会新分配多少物理页。所以我们可以看到,真实的物理页是符合lazy(on-demand) allocation原则的。这一点,极大地保证了物理资源的合理分配和使用。
进程间用户空间/内核空间是否隔离?
先说结论,不同进程间的用户空间是完全隔离的,内核空间是共享的。 那么“隔离”和“共享”在这个语境下又是什么意思呢? 从实现角度而言,“隔离”的意思是不同进程的页表不同,“共享”的意思是不同进程的页表相同,仅此而已。我们知道,页表反映的是虚拟地址和物理地址的映射关系。那么一张页表应该管理哪些虚拟地址呢?是整个地址空间的所有虚拟地址么? 当然不是。Linux将虚拟地址空间分为了用户空间和内核空间,因此管理不同空间虚拟地址的页表也不一样。
如上图所示,A进程的用户空间使用页表1,B进程的用户空间使用页表2,而A/B进程的内核空间都使用页表3。A/B中使用相同的用户空间虚拟地址来访问内存,由于页表不同,因此最终映射的物理页也不同,这就是所谓的“进程隔离”。而由于A/B进程的内核空间使用了同一张页表,所以只要他们使用相同的虚拟地址(位于内核空间),那么必然访问到同一个物理页。
数据传输的两种方式
共享内存
虚拟地址只是为了进行内存访问封装的一层接口,而数据总归是存在物理内存上的。因此,若是想让A进程通过(用户空间)虚拟地址访问到B进程中的数据,最高效的方式就是修改A/B进程中某些虚拟地址的PTE,使得这些虚拟地址映射到同一片物理区域。这样就不存在任何拷贝,因此数据在物理空间中也只有一份。
内存拷贝
共享内存虽然高效,但由于物理内存只有一份,因此少不了考虑各种同步机制。让不同进程考虑数据的同步问题,这对于Android而言是个挑战。因为作为系统平台,它必然希望降低开发者的开发难度,最好让开发者只用管好自己的事情。因此,让开发者频繁地考虑跨进程数据同步问题不是一个好的选择。
取而代之的是内存拷贝的方法。该方法可以保证不同进程都拥有一块属于自己的数据区域,该区域不用考虑进程间的数据同步问题。 由于不同进程的内核空间是共享的(只有共享才能完成传输,否则只能隔江相望了),因此很自然地考虑到将它作为数据中转站。常规的做法需要两次拷贝,一次是由发送进程的用户空间拷贝到发送进程的内核空间,另一次是由接收进程的内核空间拷贝到接收进程的用户空间。这两次拷贝中间有一个隐含的转换关系,即发送进程的内核空间和接收进程的内核空间是共享的,因此持有相同的虚拟地址就会访问到同一片物理区域。
两次拷贝的方法比较符合直觉,但在效率上还有可优化的空间。 既然两次拷贝都发生在一个进程的用户空间和内核空间之间,那么其实也就隐含了一个前提:
正是因为指向不同的物理页,所以才需要拷贝。那有没有可能让二者指向同一个物理页?如果可以,这样不就节省了一次拷贝么? 事实上,Binder正是这样做的。
Binder内存拷贝的实现
早期版本(≤Android P)
为了减少一次拷贝,接收数据的进程必须同时满足下面三个条件:
条件1、2在进程调用Binder的mmap函数时已经完成,而条件3则在每次数据通信时进行。 下面假设进程1发送数据,进程2接收数据,我们来分析下内存拷贝到底发生在何时(以下执行均发生在进程1中,只不过此时正在执行驱动代码[陷入内核态])。
整个过程中,只有步骤4发生了一次数据拷贝。
当前版本(Android Q,R)
从性能角度而言,早期版本的实现几乎无可挑剔。但是它有一个致命的稳定性缺陷,这是Google工程师们无法忍受的。因此从Android Q开始,Binder内存拷贝的实现有了新的改动。 通过之前的分析可以知道,驱动的mmap函数执行完之后,该进程将会在内核空间分配一块虚拟地址区域B。对Android应用进程而言,B的默认大小为1M-8K。只要这个进程没有退出,这1M-8K的虚拟地址就会一直分配给它。 通常对于虚拟地址长时间的占用并不会产生问题,但不幸的是,Binder的这个占用确实产生了问题。
32位机器上Binder内存拷贝的缺陷
32位机器的寻址空间为4G,其中高位的1G用作内核地址空间,低位的3G用作用户地址空间,这些都是虚拟地址的概念。 1G的内核地址空间又划分为四块不同的区域:
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区域的做法只能算是缓兵之计,绝非最佳策略。
新的实现
让我们回到最初的目的,仔细思考内核空间虚拟地址存在的意义。 其实,它只是内核空间中我们为物理页找的访问入口而已,它既没有一直存在的必要,也不会有后续使用的价值。一旦数据传输完毕,这个入口也就失去了意义。 既然如此,我们何不采用一种更加动态的方式,在每次传输之前分配这个入口,传输完成后再释放这个入口? 事实上新版本的Binder就是这么做的。
上图右边的文字展示了一次完整数据传输所经历的过程。早期版本的Binder通过一次copy_from_user将数据整体拷贝完成,新版本的Binder则通过循环调用copy_from_user将数据一页一页的拷贝完成。以下是核心代码差异的展示:
Android version ≤ P:
Android version ≥ Q:
可以看到在新版本的实现中,每拷贝一页的内容就调用一次kunmap将分配的内核空间虚拟地址释放掉。这样就再也不会发生长时间占用内核空间虚拟地址的情况