从 Linux 内核的角度来看 Binder 驱动

关于进程间通信我们是再熟悉不过了,有时面试也经常被问到你了解 IPC 吗?我们一般都会答 AIDL ,Binder 驱动,共享内存?如果要我们再说详细点呢?或者说说共享内存的具体实现?这里推荐一篇罗升阳的博客 《Android进程间通信(IPC)机制Binder简要介绍和学习计划》。本文是基于 linux 进程间通信来写的,我们都知道 Android 是基于 linux 内核,因此了解了 linux 进程间通信也就基本了解了 Android 底层进程间通信。去年初来深圳去腾讯面试,被问到了知道进程间通信吗?我说 binder 驱动,还有吗?我说 Socket ,还有吗?我说其他的就不了解了。最后面试的结果也是不出所料,GG。

首先来了解一下进程间通信的本质是什么。在 Android 开发者需要知道的 Linux 知识 一文中提到,一个完整的进程在 32 位系统上的虚拟内存分布为: 0-3G 是用户空间,3-4G 是内核空间。操作系统在映射开辟物理内存时,每个进程的用户空间会映射到不同区域,每个进程的内核空间会映射到同一区域(可以简单的这么理解)。因此如果两个进程间需要传递数据是不能直接访问的,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程 A 把数据拷贝到内存缓冲区,进程 B 再从内核缓冲区把数据读走,这种机制称为进程间通信(IPC,InterProcess Communication),因此进程间通信得要借助内核空间。

在 linux 中常见的进程间通信方式有:文件,管道,信号,信号量,共享内存,消息队列,套接字,命名管道,随着 linux 的发展到目前最最常见的有:

  • 管道(使用最简单)
  • 信号(开销最小)
  • 共享映射区(无血缘关系)
  • 本地套接字(低速稳定)

对于一个 Android 开发者来说,最最最常见的就只剩共享映射区了,像我们最熟悉的 Binder 驱动,腾讯开源的 MMKV, 自己实现高性能的日志库等等,都是基于共享映射区也就是我们所说的共享内存。因此本文我们着重来分析共享映射区,其他的内容就一笔带过了,如果大家实在感兴趣,可以自行查阅资料。

1. 管道

我们通常所说的管道一般是指无名管道,是 IPC 中最古老的一种形式。1. 数据不能自己写,自己读;2. 管道中数据不可反复读,一旦读走,管道中不再存在;3. 采用半双工通信方式,数据只能单方向上流动;4. 只能在带有血缘关系的进程间通信;5. 管道可以看成是一种特殊的文件,对于它的读写也可以使用普通的 read、write 等函数,但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

#include<stdio.h>
#include<unistd.h>
  
int main()
 {
     int fd[2];  // 两个文件描述符
     pid_t pid;
     char buff[20];
 
     if(pipe(fd) < 0)  // 创建管道
         printf("Create Pipe Error!\n");
 
     if((pid = fork()) < 0)  // 创建子进程
         printf("Fork Error!\n");
     else if(pid > 0)  // 父进程
     {
         close(fd[0]); // 关闭读端
         write(fd[1], "hello pipe\n", 11);
     }
     else
     {
         close(fd[1]); // 关闭写端
         read(fd[0], buff, 20);
         printf("%s", buff);
     }
     return 0;
 }

2. 信号

信号 (signal) 机制是 Linux 系统中最为古老的进程间通信机制,信号不能携带大量的数据信息,一般在满足特定场景时才会触发信号。信号啥时会产生?

  1. 按键产生,ctrl+c,ctrl+z
  2. 系统调用产生,kill,raise,abort
  3. 软件条件产生,alarm
  4. 硬件异常产生,非法访问内存,除0,内存对齐出错
  5. 命令产生,kill

信号出现时怎么处理?

  1. 忽略此信号,但有两种信号决不能被忽略,它们是: SIGKILL\SIGSTOP。 这是因为这两种信号向超级用户提供了一种终止或停止进程的方法。
  2. 执行系统默认动作,对大多数信号的系统默认动作是终止该进程。
  3. 执行用户希望的动作,通知内核在某种信号发生时,调用一个用户函数。在用户函数中,执行用户希望的处理。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>

int main(int argc, char* argv[]){
        pid_t pid = fork();
        if(pid < 0){
                printf("fork error!\n");
        }else if(pid > 0){
                while(1){
                        printf("I am parent!\n");
                        sleep(1);
                }
        }else if(pid == 0){
                sleep(5);
                kill(getppid(), SIGKILL);
        }
        return 0;
}

上面是一个非常简单的小例子,大家不妨看一下 Process.killProcess() 的源码。

3. 共享映射区

有关于共享内存的实现方式,大家可以参考一下这篇文章《JNI 基础 - Android 共享内存的序列化过程》 ,这里我们主要来讲讲 mmap 这个函数作用与实现原理,在 Android 的 binder 驱动中,在腾讯开源的 MMKV库中,在一些高性能的日志库中,凡是关于共享映射区的地方都会有它的存在。先来看下函数的原型:

void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offsize);

参数 start:指向欲映射的内存起始地址,通常设为 NULL,代表让系统自动选定地址,映射成功后返回该地址。

参数 length:代表将文件中多大的部分映射到内存。

参数 prot:映射区域的保护方式。可以为以下几种方式的组合:

  • PROT_EXEC 页内容可以被执行
  • PROT_READ 页内容可以被读取
  • PROT_WRITE 页可以被写入
  • PROT_NONE 页不可访问

参数 flags:指定映射对象的类型,映射选项和映射页是否可以共享。可以为以下几种方式的组合:

  • AP_FIXED 使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
  • MAP_SHARED 与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
  • MAP_PRIVATE 建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
  • MAP_NORESERVE 不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
  • MAP_ANONYMOUS 匿名映射,映射区不与任何文件关联。
  • 等等不常用的

参数 fd:文件句柄 fd。如果 MAP_ANONYMOUS 被设定,为了兼容问题,其值应为-1。

参数 offset:被映射对象内容的偏移位置(起点)。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>

struct person{
        char name[24];
        int age;
};

void sys_err(const char *str){
        perror(str);
        exit(0);
}

int main(int argc, char *argv[]){
        int fd;
        struct person stu = {"Darren", 25};
        struct person *p;
        fd = open("test_map", O_RDWR|O_CREAT|O_TRUNC, 0644);
        if(fd == -1)
                sys_err("open error");

        ftruncate(fd, sizeof(stu));

        p = (person*)mmap(NULL, sizeof(stu), PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
        if(p == MAP_FAILED)
                sys_err("mmap error");
        while(1){
                memcpy(p, &stu, sizeof(stu));
                stu.age++;
                sleep(1);
        }

        munmap(p, sizeof(stu));
        close(fd);
        return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>

struct person{
        char name[24];
        int age;
};

void sys_err(const char *str){
        perror(str);
        exit(0);
}

int main(int argc, char *argv[]){
        int fd;
        struct person *p;
        fd = open("test_map", O_RDONLY);
        if(fd == -1)
                sys_err("open error");

        p = (person*)mmap(NULL, sizeof(person), PROT_READ, MAP_SHARED, fd, 0);
        if(p == MAP_FAILED)
                sys_err("mmap error");
        while(1){
                printf("name = %s, age = %d\n", p->name, p->age);
                sleep(2);
        }

        munmap(p, sizeof(person));
        close(fd);
        return 0;
}

关于其实现的原理,最好的方式自然是看源码,但这里我们主要来聊聊 Android binder 中 mmap 的作用及原理(一次内存拷贝),关于 mmap 的源码大家可以自行阅读(不难的),具体的位置在

android/platform/bionic/libc/bionic/mmap.cpp 

Android 应用在进程启动之初会创建一个单例的 ProcessState 对象,其构造函数执行时会同时完成 binder 的 mmap,为进程分配一块内存,专门用于 Binder 通信,如下。

ProcessState::ProcessState(const char *driver)
    : mDriverName(String8(driver))
    , mDriverFD(open_driver(driver))
    ...
 {
    if (mDriverFD >= 0) {
        // mmap the binder, providing a chunk of virtual address space to receive transactions.
        mVMStart = mmap(0, BINDER_VM_SIZE, PROT_READ, MAP_PRIVATE | MAP_NORESERVE, mDriverFD, 0);
        ...
    }
}

第一个参数是分配地址,为0意味着让系统自动分配,先在用户空间找到一块合适的虚拟内存,之后,在内核空间也找到一块合适的虚拟内存,修改两个控件的页表,使得两者映射到同一块物理内存。

Linux 的内存分用户空间跟内核空间,同时页表也分两类,用户空间页表跟内核空间页表,每个进程有一个用户空间页表,但是系统只有一个内核空间页表。而 Binder mmap 的关键是:也更新用户空间对应的页表的同时也同步映射内核页表,让两个页表都指向同一块地址,这样一来,数据只需要从 A 进程的用户空间,直接拷贝拷贝到 B 所对应的内核空间,而 B 多对应的内核空间在 B 进程的用户空间也有相应的映射,这样就无需从内核拷贝到用户空间了。

static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
{
    int ret;
    ...
    if ((vma->vm_end - vma->vm_start) > SZ_4M)
        vma->vm_end = vma->vm_start + SZ_4M;
    ...
    // 在内核空间找合适的虚拟内存块
    area = get_vm_area(vma->vm_end - vma->vm_start, VM_IOREMAP);
    proc->buffer = area->addr;
    // 记录用户空间虚拟地址跟内核空间虚拟地址的差值 
    proc->user_buffer_offset = vma->vm_start - (uintptr_t)proc->buffer;
    ...
    proc->pages = kzalloc(sizeof(proc->pages[0]) * ((vma->vm_end - vma->vm_start) / PAGE_SIZE), GFP_KERNEL);
    // 分配page,并更新用户空间及内核空间对应的页表 
    ret = binder_update_page_range(proc, 1, proc->buffer, proc->buffer + PAGE_SIZE, vma);
    ...
    return ret;
}

static int binder_update_page_range(struct binder_proc *proc, int allocate,
            void *start, void *end,
            struct vm_area_struct *vma)
{
  ...
  // 一页页分配
  for (page_addr = start; page_addr < end; page_addr += PAGE_SIZE) {
    int ret;
    struct page **page_array_ptr;
    // 分配一页
    page = &proc->pages[(page_addr - proc->buffer) / PAGE_SIZE];
    *page = alloc_page(GFP_KERNEL | __GFP_HIGHMEM | __GFP_ZERO);
    ...
    // 修改页表,让物理空间映射到内核空间 
    ret = map_vm_area(&tmp_area, PAGE_KERNEL, &page_array_ptr);
    ..
    // 根据之前记录过差值,计算用户空间对应的虚拟地址 
    user_page_addr =
        (uintptr_t)page_addr + proc->user_buffer_offset;
    // 修改页表,让物理空间映射到用户空间 
    ret = vm_insert_page(vma, user_page_addr, page[0]);
  }
  ...
  return -ENOMEM;
}

上面的代码可以看到,binder 一次拷贝的关键是,完成内存的时候,同时完成了内核空间跟用户空间的映射,也就是说,同一份物理内存,既可以在用户空间用虚拟地址访问,也可以在内核空间用虚拟地址访问。

视频链接:https://pan.baidu.com/s/17kmg5JUhFrlNIvhKRaNv4Q
视频密码:a4pw

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章