從 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

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