彻底搞懂Binder,看这一篇就够了
前言
作为安卓进程间通信最主要的方式,Binder机制可以说是每个安卓开发者必须要掌握的知识,不过由于它涉及到linux以及c/c++,所以理解起来有点难。但本文会带领大家从0到1一步一步去深挖Binder机制,务求让大家对Binder不再疑惑。
上篇 Binder机制总结(上篇)–java层与框架层分析 我们已经从Linux的进程间通信以及java层与框架层去分析Binder机制。接下来就要继续深入Binder的核心–驱动层去分析它。
提示:以下是本篇文章正文内容
一、ServiceManager与SystemServer的关系
当安卓手机开机后,init进程就会去启动Zygote进程,然后Zygote进程就会去启动SystemService进程,而SystemService进程里就会启动它里面的核心服务。
关于这点不清楚的可以看回 手把手带你搞懂AMS启动原理 。而在 Binder机制总结(上篇)–java层与框架层分析 上篇已经分析过init.rc脚本中会去启动一个ServiceManager进程,而这个进程会去开启binder通信,因此我们能在service_manager.c文件里的main函数看到它调用了binder_open函数:
int main(int argc, char** argv)
{
struct binder_state *bs;
union selinux_callback cb;
char *driver;
if (argc > 1) {
driver = argv[1];
} else {
driver = "/dev/binder";
}
bs = binder_open(driver, 128*1024);
...
SystemServer进程则是由Zygote进程启动的,因此ServiceManager与SystemServer的关系如下:
ServiceManager它是用c写的,可以看看service_manager.c所在目录:
里面都是关于Binder的c文件,由此也可推测出ServiceManager进程主要运行Binder。而SystemServer则用java写,它持有AMS、PMS等这些核心服务类,将它们统一放在SystemServer里,但是不对外暴露(为了安全性),所以ServiceManager管理这些服务类类时实则是通过Binder跨进程通信去管理的,这也是为什么要搞多一个ServiceManager类去管理这些服务类的原因。
我们来看看PMS的main()方法:
public static PackageManagerService main(Context context, Installer installer,
boolean factoryTest, boolean onlyCore) {
// Self-check for initial settings.
PackageManagerServiceCompilerMapping.checkProperties();
PackageManagerService m = new PackageManagerService(context, installer,
factoryTest, onlyCore);
m.enableSystemUserPackages();
ServiceManager.addService("package", m);
final PackageManagerNative pmn = m.new PackageManagerNative();
ServiceManager.addService("package_native", pmn);
return m;
}
可以看到,PMS调用了ServiceManager的addService(),而addService()方法的具体实现就是在ServiceManagerNative里:
public void addService(String name, IBinder service, boolean allowIsolated, int dumpPriority)
throws RemoteException {
Parcel data = Parcel.obtain();
Parcel reply = Parcel.obtain();
data.writeInterfaceToken(IServiceManager.descriptor);
data.writeString(name);
data.writeStrongBinder(service);
data.writeInt(allowIsolated ? 1 : 0);
data.writeInt(dumpPriority);
mRemote.transact(ADD_SERVICE_TRANSACTION, data, reply, 0);
reply.recycle();
data.recycle();
}
可以看到调用了 mRemote的transact()方法进行Binder通信。到这里为止,应该明白了ServiceManager为什么也称为服务管理类了吧,它通过Binder去跟SystemServer进行通信,进而能拥有核心服务类的引用,从而能管理它们。
对于SystemServer、PMS和AMS如果有什么疑问可以看回:
应用的开端–PackageManagerService(PMS)
二、内核空间与用户空间
上篇已经详细讲解mmap()函数,它能把磁盘上文件映射到物理内存上,相当于进程在这块物理内存上进行读写数据时,数据都会被持久化到磁盘上:
mmap函数返回的是一个指针,它就是指向这块物理内存的地址:
#include <jni.h>
#include <string>
#import <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
extern "C"
JNIEXPORT void JNICALL
Java_com_pingred_mytest_Binder_binder_open(JNIEnv *env, jobject thiz) {
//之前源码这里open的是/dev/binder
std::string file = "/storage/emulated/0/test.txt";
//返回文件的引用
int file_fd=open(file.c_str(), O_RDWR | O_CREAT, S_IRWXU);
//一页为4k
int32_t file_size = getpagesize();
//文件设置为4K
ftruncate(file_fd, file_size);
int8_t* file_ptr = static_cast<int8_t *>(mmap(0, m_size, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd,0));
}
这段代码是上篇中使用mmap()函数去映射test.txt文件,然后得到它的内存地址赋值给file_ptr指针,有了它就可以对test.txt进行读写操作,而不用调用文件流OutPutStream去操作test.txt。
平时我们在应用层调用OutPutStream去写文件的时候,它都是要经过一个由用户空间切换到内核空间的过程,来看看OutPutStream的write方法:
public void write(byte b[], int off, int len) throws IOException {
// Android-added: close() check before I/O.
if (closed && len > 0) {
throw new IOException("Stream Closed");
}
// Android-added: Tracking of unbuffered I/O.
tracker.trackIo(len);
// Android-changed: Use IoBridge instead of calling native method.
IoBridge.write(fd, b, off, len);
}
里面是调用IoBridge(它是Linux内核里的)的write方法,实质上就是要切换到内核空间去对磁盘文件进行操作的:
当用户空间要把数据传到内核空间里,那就要进行内存拷贝,也就是把数据拷贝到内核空间,这其实是一个消耗性能的操作,而mmap函数就免去了这个内存拷贝的过程,直接就把磁盘映射到物理内存上,让用户进程直接在这块内存里操作,写入数据,然后数据就可以直接持久化到磁盘中去,不用拷贝。
此时如果再来一个进程B,想要跟进程A进行通信,那就对进程B使用mmap函数,也对磁盘中这个文件进行映射到同一个物理内存中,这样两个进程在这块共享区域内进行读写操作,双方都能感知到,也就是完成了进程间通信:
但这样直接互相mmap对于安卓来说不安全的,进程间能随意互相访问,如果该进程是内核空间的话,那普通的应用进程也能随意访问内核,这会引发很多问题,而且一旦进程奔溃,进而影响到内核奔溃,导致整个系统都会奔溃。还有共享内存会引发并发问题,这些安全问题都是要避免的。
因此,安卓就使用ServiceManager来开启binder,也就是定义了一个binder驱动文件(Linux中一切皆文件,不过驱动文件与一般文件有区别),binder驱动文件同样作为文件在磁盘中,然后ServiceManager进程就会进行mmap操作,为磁盘上的binder驱动文件映射出了一块物理内存块,大小为128k,假如现在AMS想要跟ServiceManager进程通信,其实就是属于SystemServer进程与ServiceManager进程的通信:
public void addService(String name, IBinder service, boolean allowIsolated, int dumpPriority)
throws RemoteException {
Parcel data = Parcel.obtain();
Parcel reply = Parcel.obtain();
data.writeInterfaceToken(IServiceManager.descriptor);
data.writeString(name);
data.writeStrongBinder(service);
data.writeInt(allowIsolated ? 1 : 0);
data.writeInt(dumpPriority);
mRemote.transact(ADD_SERVICE_TRANSACTION, data, reply, 0);
reply.recycle();
data.recycle();
}
调用了mRemote的transact方法,开启binder通信:
注册的时候,在映射的128k物理内存里划分一个4k的区域(也叫页)给PMS,当PMS要跟其他进程通信的话,就再扩容。
当我们从桌面打开app的时候,就是PMS、AMS与用户进程通信的过程,由于PMS它们这些核心服务在开机的时候已经启动,完成了binder的注册,也就是把自己注册在ServiceManager里,映射了一个4k大小的物理内存空间,因此现在用户进程作为客户端,它所在的AcitivityThread进程通过binder得到PMS和AMS这些引用,app与PMS、AMS所在进程SystemServer可以进行通信,app进程进而通过PMS和AMS完成入口类Activity的启动了。而当通信过程中4k空间如果不够用,则会再扩容4k的倍数。
现在PMS作为服务端跟binder驱动都已经实现了mmap映射,那么按照正常思路,app进程也可以使用mmap映射同一块区域,但是前面我们已经说过为了保证安全性,app进程就不要实现mmap了,仍然要把数据拷贝到binder内核里,然后数据在binder里的话,由于PMS已经在binder内核映射出区域,直接读取这块内存,不用拷贝数据,并且也注册了自己的引用在里面,所以app也就能通过binder与PMS等这些核心服务进行通信了。
这里也顺便补充一下传统的进程间通信是怎样的:
进程A要从它自己的用户空间里把数据拷贝到内核空间里,系统调用的copy_from_user()方法就是实现这一操作,然后系统再调用copy_to_user()方法把内核空间里的数据拷贝到进程B的用户空间里,这样就实现了两个进程间的通信。
那为什么会有用户空间与内核空间呢?在Linux中,内核空间运行的是硬件相关的内核代码,用户空间则运行是软件进程的代码。如果一个进程里用户代码与内核代码都运行在一起的话,当用户代码出现问题时,连带内核代码也会出问题,进而导致系统奔溃:
所以用户代码肯定要与内核代码分开运行,那么就要在进程里分为用户空间与内核空间,但这样的话,每个应用进程都要这样分两个区,会太浪费内存空间的:
因此就把普通进程里所有空间都定为用户空间,而在内存里划分一个区域叫内核空间,这个内核进程专门运行驱动这些系统级别的代码,这样就算用户进程奔溃,也不会影响到内核空间,也就不会影响到系统,而且又节省了空间,让每个用户进程里的用户空间都能访问内核空间。
我们平时见到的蓝屏现象其实就是因为内核空间里的驱动程序(驱动程序运行在内核空间里)跟系统不兼容导致保错,而导致内核奔溃。而Binder是驱动文件,所以是运行在内核空间里,所以如果binder奔溃了,那安卓系统也会跟着奔溃。
最后还有一点就是用户空间与内核空间通信仍然是个消耗性能的过程,因为从用户空间切换到内核空间,是需要系统调度的,因此一旦涉及到内核空间的通信就会消耗性能。
三、虚拟内存与物理内存
为什么会有虚拟内存,以前最早的时候,每个应用程序的内存还很小,而物理内存(内存条)够大,所以这时候全部应用都可以运行在物理内存上,而这时CPU也是直接读取物理内存,但随着数据越来越大(图片和视频的发展等),应用大小也变得越来越大,因此物理内存根本不可能跑得动所有应用程序的,几十个应用加起来内存是非常大的,而常规的物理内存大小顶多就16g,根本不够,那这时就诞生了虚拟内存以及程序的局部性原则。
在没有出现虚拟内存的时候,计算机通过CPU直接向内存存取数据,这样效率很快,但也意味着运行的程序不能超过物理内存的大小,这就限制了运行的程序数量了。因此虚拟内存就出现了,使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。而虚拟内存空间是在硬盘上的一块连续的字节大小的单元组成的数组,每个字节都有一个唯一的虚拟地址,作为到数组的索引。
可以看到CPU中含有一个内存管理单元(Memory Management Unit, MMU)的硬件,它的功能就是将虚拟地址转换为物理地址。MMU需要借助存放在内存中的页表来动态翻译虚拟地址,该页表由操作系统管理。
页表又是什么呢?操作系统通过将虚拟内存分割为大小固定的块来作为硬盘和内存之间的传输单位,这个块被称为虚拟页,而物理内存也会按照这种方法分割为物理页。
CPU在获得虚拟地址之后,就通过MMU将虚拟地址翻译为物理地址。在这个翻译过程中还需要借助页表,页表就是一个存放在物理内存中的数据结构,它记录了虚拟页与物理页的映射关系:
这里的PTE是页表结构的一个有效位,该有效位代表这个虚拟页是否被缓存在物理内存中,物理页号或硬盘地址如果为空值,则代表这个PTE还没有被分配。现在虚拟页VP0、VP4、VP6和VP7被缓存在物理内存中,有效位为1。虚拟页VP2和VP5被分配在页表中,但并没有缓存在物理内存,有效位为1为0。虚拟页VP1和VP3还没有被分配,有效位为0,地址也为空值。
MMU根据虚拟地址在页表中寻址到了PTE4,该PTE的有效位为1,代表该虚拟页已经被缓存在物理内存中了,最终MMU得到了PTE中的物理内存地址,即指向PP 1。
而在这个表映射过程中,还有个机制,它就是能让几十个应用能同时运行在物理内存中的关键,一个应用它的代码都存在磁盘里,当它运行的时候,只有一部分代码在物理内存中运行,其他部分仍在在磁盘里缓存在交换空间swap里(可以理解为是磁盘里的一个共享区域,当物理内存不够空间了,就会让用户进程代码运行在交换空间里,等到物理内存有空间了,就再从swap里把代码放进物理内存里运行)。
现在硬盘里的apk要运行,由于程序的局部性原则,首先只运行int a = 1这行代码到物理内存里。当CPU要去访问这个a的值时,肯定要先知道它的内存地址是多少才能访问,所以现在页表第一项有效位还是0,地址为NULL。
然后MMU把磁盘的地址也就是虚拟内存地址记录在页表里,有效位还是0,表示虚拟内存被缓存在页表里:
这时候MMU翻译CPU要操作的内存地址,将物理内存跟表里的虚拟地址映射,将有效位为1,当下一次CPU再访问这个内存里的数据,就直接通过MMU直接定位到物理内存里的内存地址上了:
当该页记录的每一项都满了,就会回收旧的内存地址,把那一项记录进行清除,记录新的数据的内存地址和有效位,如此反复,这就是一个应用程序运行时的过程,当有多个应用程序要运行的时候,也是这样通过虚拟内存机制以及程序的局部性原则,加上CPU的切片时间够快,不停切换来操作每个应用的数据,所以使得整个过程,仿佛就是同一时间内多个应用在一起同时运行着。
这里还要记住,每次存取是按一页的倍数来操作,而一页有4k大小,一页里的每行就是记录一个数据的内存地址。然后页表就按照这一张张页来记录地址和有效位。
三、Binder驱动层
现在我们已经知道init.rc脚本中会去启动一个ServiceManager进程,而这个进程会去开启binder通信,因此我们能在service_manager.c文件里的main函数看到它调用了binder_open函数:
struct binder_state *binder_open(const char* driver, size_t mapsize)
{
struct binder_state *bs;
struct binder_version vers;
bs = malloc(sizeof(*bs));
if (!bs) {
errno = ENOMEM;
return NULL;
}
bs->fd = open(driver, O_RDWR | O_CLOEXEC);
if (bs->fd < 0) {
fprintf(stderr,"binder: cannot open %s (%s)\n",
driver, strerror(errno));
goto fail_open;
}
...
通过binder_open函数去打开Binder驱动文件,但这里与打开普通文件不一样,因为驱动文件与普通文件的区别在于,驱动文件相当于是一套代码在运行的,这套代码运行在内核空间里,由linux系统去执行驱动binder,也就是说我们对驱动文件的操作会反映到它的驱动代码里。下面我们来分析一下它的驱动代码,binder.c文件:
...
device_initcall(binder_init);
#define CREATE_TRACE_POINTS
#include "binder_trace.h"
MODULE_LICENSE("GPL v2");
拉到最后,可以看到这个device_initcall(binder_init),启动驱动函数,当驱动启动,就调用binder_init()函数:
static int __init binder_init(void)
{
int ret;
binder_deferred_workqueue = create_singlethread_workqueue("binder");
if (!binder_deferred_workqueue)
return -ENOMEM;
binder_debugfs_dir_entry_root = debugfs_create_dir("binder", NULL);
if (binder_debugfs_dir_entry_root)
binder_debugfs_dir_entry_proc = debugfs_create_dir("proc",
binder_debugfs_dir_entry_root);
ret = misc_register(&binder_miscdev);
...
该函数就是binder驱动的逻辑,可以看到这句misc_register(&binder_miscdev)这句代码,把binder_miscdev的地址传到misc_register函数里,misc_register函数是个注册方法,它注册我们对驱动文件进行什么操作时,会触发驱动层去调用对应的哪个方法。binder_miscdev是个结构体:
static struct miscdevice binder_miscdev = {
.minor = MISC_DYNAMIC_MINOR,
.name = "binder",
.fops = &binder_fops
};
它这里有个binder_fops结构体,进去看看:
static const struct file_operations binder_fops = {
.owner = THIS_MODULE,
.poll = binder_poll,
.unlocked_ioctl = binder_ioctl,
.compat_ioctl = binder_ioctl,
.mmap = binder_mmap,
.open = binder_open,
.flush = binder_flush,
.release = binder_release,
};
左边一列就是表示对驱动文件进行相关操作时,会去调用对应的函数,看到这行.open = binder_open代码,意思其实是当对驱动文件Binder进行open函数时,对应的驱动就会执行它的binder_open函数,这也是驱动文件独有的特殊性了,现在对它跟普通文件的区别是不是有了更深的看法了。接下来看这个binder_open函数:
static int binder_open(struct inode *nodp, struct file *filp)
{
struct binder_proc *proc;
binder_debug(BINDER_DEBUG_OPEN_CLOSE, "binder_open: %d:%d\n",
current->group_leader->pid, current->pid);
proc = kzalloc(sizeof(*proc), GFP_KERNEL);
if (proc == NULL)
return -ENOMEM;
get_task_struct(current);
proc->tsk = current;
INIT_LIST_HEAD(&proc->todo);
init_waitqueue_head(&proc->wait);
proc->default_priority = task_nice(current);
binder_lock(__func__);
binder_stats_created(BINDER_STAT_PROC);
hlist_add_head(&proc->proc_node, &binder_procs);
proc->pid = current->group_leader->pid;
INIT_LIST_HEAD(&proc->delivered_death);
filp->private_data = proc;
binder_unlock(__func__);
if (binder_debugfs_dir_entry_proc) {
char strbuf[11];
snprintf(strbuf, sizeof(strbuf), "%u", proc->pid);
proc->debugfs_entry = debugfs_create_file(strbuf, S_IRUGO,
binder_debugfs_dir_entry_proc, proc, &binder_proc_fops);
}
return 0;
}
代码很长,先来说说刚刚我们分析了当对binder驱动文件进行open的时候,其实就是相当于触发了binder_open函数,那么也就是说ServiceManager也会调用这个binder_open函数,既然如此,当其他用户进程(服务端)想要成为服务端时,它去实现binder,自然也要open,进而也会触发到这个驱动方法binder_open函数:
现在再来看这张图,Binder驱动的核心是维护一个binder_proc类型的链表,该链表里面记录了包括ServiceManager在内的所有Client信息,当Client去请求得到某个Service时,Binder驱动就去binder _proc中查找相应的Service返回给Client,同时增加当前Service的引用个数,你能看到binder_open()函数里这行代码:
struct binder_proc *proc;
每次当有服务端app应用申请打开binder,就会生成一个节点添加到链表里,用来描述当前这个app进程信息的,然后接着binder_open()函数里的代码:
proc = kzalloc(sizeof(*proc), GFP_KERNEL);
执行了kzalloc函数,我们都知道malloc函数是声明了一块内存空间,它是相对于用户空间来说的,而kzalloc函数则是内核驱动层里开辟内存区域,也就是说此时这个当前app进程(服务端)在驱动层里开辟了一个内存区域。然后接下去执行get_task_struct(current),得到当前进程,设置给binder驱动里对应的proc,这样binder驱动也就相当于拿到服务端进程的引用了。
get_task_struct(current);
接着往下看:
...
binder_lock(__func__);
binder_stats_created(BINDER_STAT_PROC);
hlist_add_head(&proc->proc_node, &binder_procs);
proc->pid = current->group_leader->pid;
INIT_LIST_HEAD(&proc->delivered_death);
filp->private_data = proc;
binder_unlock(__func__);
...
设置锁,防止并发问题,在这段锁住的过程中,执行hlist_add_head函数,该函数就是添加节点binder_procs,把当前这个服务端进程的binder_proc添加到链表proc里,下个服务端如果也要开启binder,则再创建个节点,添加到链表里:
现在再看回native层的binder_open方法:
struct binder_state *binder_open(const char* driver, size_t mapsize)
{
...
bs->fd = open(driver, O_RDWR | O_CLOEXEC);
if (bs->fd < 0) {
fprintf(stderr,"binder: cannot open %s (%s)\n",
driver, strerror(errno));
goto fail_open;
}
...
bs->mapsize = mapsize;
bs->mapped = mmap(NULL, mapsize, PROT_READ, MAP_PRIVATE, bs->fd, 0);
...
当驱动里完成了对服务进程的引用的获取和添加到链表中后,接下来native层就继续执行mmap函数,根据之前的映射关系:
static const struct file_operations binder_fops = {
.owner = THIS_MODULE,
.poll = binder_poll,
.unlocked_ioctl = binder_ioctl,
.compat_ioctl = binder_ioctl,
.mmap = binder_mmap,
.open = binder_open,
.flush = binder_flush,
.release = binder_release,
};
这时驱动层也就触发了binder_mmap函数:
static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
{
//内核虚拟空间
struct vm_struct *area;
struct binder_proc *proc = filp->private_data;
const char *failure_string;
struct binder_buffer *buffer;
//保证用户空间(服务端)内存大小不超过4M
if ((vma->vm_end - vma->vm_start) > SZ_4M)
vma->vm_end = vma->vm_start + SZ_4M;
...
传进来的参数filp是binder指针,vma是当前的服务端进程的用户空间,接下来又会去定义一个内核空间area,然后之前保存在flip的当前进程信息赋值给proc。接下来就是映射用户空间(服务端)的内存大小,当大小大于4M,最后也只能是只有4M大小。
接下来继续为刚刚声明的内核空间area划分一块内存空间:
//为内核空间分配一个连续的内核虚拟空间,与进程虚拟空间一样大小
area = get_vm_area(vma->vm_end - vma->vm_start, VM_IOREMAP);
大小跟用户空间(服务端进程)一样,此时这个内核空间是虚拟空间,而上面的服务端应用的用户空间也是虚拟空间,它们互相通信的话,肯定需要拷贝的,那这样不就跟以前传统的进程间通信一样了吗,因此接下来就是分别对它们两个空间进行mmap映射,为它们两映射同一块物理内存空间,这样它们两个通信,就直接在这块共享区域操作就可以免去拷贝了,直接映射到binder驱动里。这也就是为什么整个binder机制下进行通信下,内存拷贝只发生一次,除了客户端要把数据拷贝到内核时,而内核不用拷贝数据到服务端里。
所以可以看到接下来这句代码:
proc->pages = kzalloc(sizeof(proc->pages[0]) * ((vma->vm_end - vma->vm_start) / PAGE_SIZE), GFP_KERNEL);
调用了kzalloc函数,分配要申请的物理内存大小,大小用页数表示,然后继续看代码:
if (binder_update_page_range(proc, 1, proc->buffer, proc->buffer + PAGE_SIZE, vma)) {
ret = -ENOMEM;
failure_string = "alloc small buf";
goto err_alloc_small_buf_failed;
}
binder_update_page_range()函数就是让内核空间与进程空间同时映射到物理内存里,来看看它的详情:
static int binder_update_page_range(struct binder_proc *proc, int allocate,
void *start, void *end,
struct vm_area_struct *vma)
{
...
//分配一个page的物理内存
*page = alloc_page(GFP_KERNEL | __GFP_HIGHMEM | __GFP_ZERO);
...
//物理空间映射到虚拟内核空间
ret = map_vm_area(&tmp_area, PAGE_KERNEL, page);
...
//物理空间映射到虚拟进程空间 修改页表,让物理空间映射到用户空间
ret = vm_insert_page(vma, user_page_addr, page[0]);
if (ret) {
pr_err("%d: binder_alloc_buf failed to map page at %lx in userspace\n",
proc->pid, user_page_addr);
goto err_vm_insert_page_failed;
}
/* vm_insert_page does not seem to increment the refcount */
}
}
可以看到,这里调用了alloc_page函数,这个函数作用就是划分一页page大小的物理内存区域,得到这个物理内存之后,就调用map_vm_area函数对内核空间进行映射到物理内存上,其实原理就是修改页表,让物理空间映射到用户空间(参考上面讲解的页表机制),即此时Binder内核空间已经映射到这页物理内存。
接着就是调用vm_insert_page函数,原理还是是修改页表,让物理空间映射到用户空间。即此时服务端应用空间已经映射到这页物理内存。如果想获取以上源码代码可以关我公号Pingred
五、总结
其实所谓的mmap映射,其实是通过修改页表上的内存地址映射数据,来实现各个进程空间互相访问,因为只要能对页表中的映射数据进行操作,就等于直接可以操作物理内存了。