1. 程式人生 > >Linux 系統呼叫 —— fork 核心原始碼剖析

Linux 系統呼叫 —— fork 核心原始碼剖析

系統呼叫流程簡述

  • fork() 函式是系統呼叫對應的 API,這個系統呼叫會觸發一個int 0x80 的中斷;
    當用戶態程序呼叫 fork() 時,先將 eax(暫存器) 的值置為 2(即 __NR_fork 系統呼叫號);
     
  • 執行 int $0x80,cpu 進入核心態;
     
  • 執行 SAVE_ALL,儲存所有暫存器到當前程序核心棧中;
     

  • 進入 sys_call,將 eax 的值壓棧,根據系統呼叫號查詢 system_call_table ,呼叫對應的函式;
     
  • 函式返回,執行 RESTORE_ALL,恢復儲存的暫存器;執行 iret,cpu 切換至使用者態;
     

  • 從 eax 中取出返回值,fork() 返回;

詳見:系統呼叫的工作機制

 
 
 

fork 在核心中做了什麼

當我們呼叫 fork()、clone()、vfork() 時,實際上在核心中呼叫的都是同一個函式 —— do_fork()

這裡的三個系統呼叫的區別就在於呼叫 do_fork() 時傳入的引數不同

do_fork() 中第一個引數 clone_flags 是一個 32bit 的標誌,其中不同的 bit 置 1 代表不同的選項,表示新的子程序與父程序之間共享哪些資源

其中 sys_fork() 呼叫 do_fork() 只設置了 SIGCHLD 選項,sys_vfork() 設定了 CLONE_VM | CLONE_VFORK | SIGCHLD 選項,而 sys_clone() 的引數來自上層,通過 ebx 傳入;

下面簡述下 do_fork() 的執行過程

do_fork()

  • 查詢 pidmap_array 點陣圖,為子程序分配新的 pid;
  • 呼叫 copy_process() ,將新的 pid 傳入引數,這個函式是建立程序的關鍵步驟,該函式返回新的 task_struct 地址;

copy_process()

  • 建立 task_struct 結構體指標;
  • 檢查引數;
  • 呼叫 dup_task_struct() ,將父程序 task_struct 傳入引數,為子程序獲取程序描述符;

dup_task_struct()

  • 建立 task_struct 、thread_info 結構體指標;
struct task_struct *tsk;
struct thread_info *ti;
  • 呼叫 alloc_task_struct() 巨集為新程序獲取程序描述符,並儲存至 tsk 中;
tsk = alloc_task_struct();
if (!tsk)
    return NULL;
  • 呼叫 alloc_thread_info() 巨集獲取一塊空閒記憶體區,儲存在 ti 中(這塊記憶體的大小為 8K/4k,用來存放新程序的 thread_info 結構體和核心棧)

struct task_struct
{
    struct thread_info * thread_info; // 指向 thread_info 的指標
    struct mm_struct * mm; // 程序地址空間
    pid_t pid;
    struct list_head children; // 子程序連結串列
    ...
}

struct thread_info
{
    struct task_struct task; // 指向 task_struct 的指標
    _u32 cpu; // 當前所在的cpu
    mm_segment_t addr_limit; // 執行緒地址空間
    // user_thread   0-0xBFFFFFFF
    // kernel_thread 0-0xFFFFFFFF
    ...
}

// thread_info 和 stack 共享一塊記憶體
union thread_union
{
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
};
  • 複製程序描述符和 thread_info,並將兩者中的互指指標初始化;
*ti = *current->thread_info;
ti->task = tsk;
  • 將新程序描述符的使用計數器 usage 置為2,表示描述符正在被使用而其對應的程序處於活動狀態;

新程序的程序描述符建立完成,返回至 copy_process()

  • 檢查當前使用者所擁有的程序數是否超過了限制的值(1024),有root許可權除外;若超過了限制則返回錯誤碼,否則增加使用者擁有的程序計數;
atomic_inc(p->user->process);
  • 檢查系統中的程序數量是否超過了 max_threads;
    max_threads的數量由系統記憶體容量決定,所有的thread_info描述符和核心棧所佔用空間不能超過系統記憶體的1/8;

  • 拷貝所有的程序資訊:

  • 其中最重要的是 copy_mm() ,該函式通過建立新程序所有頁表和記憶體描述符來建立程序地址空間;
struct mm_struct
{
    struct vm_area_struct * mmap; // 指向線性區物件的連結串列頭
    struct rb_root mm_rb; // 指向線性區物件的紅黑樹的根
    pgd_t * pgd; // 指向頁全域性目錄
    atomic_t mm_users; // 次使用計數器,存放共享 mm_struct 資料結構輕量級程序的個數
    atomic_t mm_count; // 主使用計數器,每當 mm_count 遞減,核心就要檢查它是否為0,如果是就要解除這個記憶體描述符
}

copy_mm()

  • 建立 mm_struct * mm, oldmm 結構體指標(記憶體描述符);
    oldmm = current->mm; //oldmm 初始化為父程序的 mm_struct
  • 檢查 clone_flags 是否設定了 CLONE_VM 位;
    若設定了 CLONE_VM 位,則表示建立執行緒,與父程序共享地址空間
atomic_inc(&oldmm->mm_users);   // 父程序的地址空間引用計數加一
mm = oldmm;         // 將父程序地址空間賦給子程序
  • 否則,就要建立新的地址空間,並從當前程序複製 mm 的內容
mm = allocate_mm();
memcpy(mm, oldmm, sizeof(*mm));
  • 呼叫 dup_mmap() 複製父程序的線性區和頁表

dup_mmap()

  • 複製父程序每個 vm_area_struct 線性區描述符,插入到子程序的線性區連結串列和紅黑樹中;
struct vm_area_struct
{
    struct mm_struct * vm_mm; // 指向線性區所在的記憶體描述符
    unsigned long vm_start; // 當前線性區起始地址
    unsigned long vm_end; // 線性區尾地址
    struct vm_area_struct * vm_next; // 下一個線性區
    pgprot_t vm_page_prot; // 線性區訪問許可權
    struct rb_node vm_rb; // 用於紅黑樹搜尋的節點
}
  • 用 copy_page_range() 建立新的頁表,在新的 vm_area_struct 中連結並複製父程序的頁表條目;
copy_page_range()
  • 建立新的頁表;
  • 複製父程序的頁表來初始化子程序的新頁表;
    私有/可寫的頁( VM_SHARED 標誌關閉/ VM_MAYWRITE 標誌開啟)所對應的許可權父子程序都設為只讀,以便於 Copy-on-write 機制處理。

新程序的線性區和頁表複製完成,返回至copy_process()

  • 呼叫 copy_thread() 用父程序的核心棧初始化子程序的核心棧

    copy_thread()

  • 將eax的值強制設為0(fork / clone 系統呼叫的返回值)

childregs->eax = 0;

sched_fork()

  • 呼叫 sched_fork() 完成對新程序排程程式資料結構的初始化,將新程序狀態設為 TASK_RUNNING
  • 為了公平起見,父子程序共享父程序的時間片

程序建立完成,返回至 do_fork()

  • 如果設定 CLONE_STOPPED,就將子程序設定 TASK_STOPPED 狀態並掛起;
    否則呼叫 wake_up_new_task() 調整父子程序的排程引數;

wake_up_new_task()

  • 如果父子程序執行在同一個 cpu 上,並且不能共享同一組頁表 (CLONE_VM 位為 0),就把子程序插入執行佇列中的父程序之前;
    如果子程序建立之後呼叫 exec 執行新程式,就可以避免寫時拷貝機制執行不必要的頁面複製;
    否則,如果執行在不同的cpu上,或父子共享同一組頁表,就將子程序插入執行佇列的隊尾。

返回至 do_fork()

  • 返回子程序的 pid

2017/8/3 補充

  • fork() 和 vfork() 引數是寫死的,而 clone() 是可選的,它可以選擇當前建立的程序哪些部分是共享的,哪些部分是獨立的;

  • vfork() 是歷史的產物,當呼叫 fork() 的時候,需要將父程序的線性區和頁表都拷貝一份,而呼叫 exec() 執行新程式後,又要把所有頁表刪除重置新的頁表,建立對映關係,效率很低;

  • 所以要有 vfork(),vfork() 的 clone_flags 位置了 CLONE_VM ,表示共享父程序的地址空間,vfork() 中建立的程序沒有分配自己的地址空間,而是通過一個 mm_struct 指標指向父程序的地址空間,這個程序是為了在之後呼叫 exec() 執行新的程式;

  • 而在有了 Copy-on-write 技術後,fork() 出的子程序只建立了自己的地址空間,然後用父程序的地址空間初始化,每個頁表的項置為父程序的頁表項,共享父程序的物理頁面,並將所有 私有/可寫 頁面改為只讀;

  • 當我們改變父子程序的資料後,cpu 在執行過程中會發生一個缺頁錯誤,cpu 轉交控制權給作業系統,作業系統查詢 VMA 發現該頁許可權為只讀,但所在段又是可寫的,產生一個矛盾,這就是識別 Copy-on-write 的方法,接著 OS 給子程序分配一個新的物理頁,並將頁表該頁的地址修改成新的物理頁地址;

  • 這樣 fork() 後再呼叫 exec() 就不用那麼麻煩了,可以直接將新的物理頁與子程序的虛擬空間建立對映
     

小結

綜上,fork 在建立子程序時,主要做了這些工作

  1. 為子程序分配新的 pid,並通過父程序 PCB(task_struct)建立新的子程序 PCB
  2. 檢查程序數是否達到上限(分別檢查使用者限制和系統限制)
  3. 拷貝所有的程序資訊(開啟的檔案 / 訊號處理 / 程序地址空間等),這裡需要拷貝的選項由呼叫 do_fork() 時傳入的引數 clone_flags 決定
  4. 用父程序的核心棧初始化子程序的核心棧,設定子程序的返回值為 0(eax = 0)
  5. 設定新程序的狀態(TASK_RUNNING / TASK_STOPPED),調整父子程序排程
  6. 父程序 fork 返回子程序的 pid