慕課18原創作品轉載請註明出處 + 《Linux核心分析》MOOC課程http://mooc.study.163.com/course/USTC-1000029000

一、背景知識:

1、程序與程式的關係:

  • 程序是動態的。而程式是靜態的;
  • 從結構上看,每個程序的實體都是由程式碼斷和相應的資料段兩部分組成的,這與程式的含義非常相近;
  • 一個程序能夠涉及多個程式的執行。一個程式也能夠相應多個程序,即一個程式段可在不同資料集合上執行。構成不同的程序;
  • 併發性;
  • 程序具有建立其它程序的功能;
  • 作業系統中的每個程序都是在一個程序現場中執行的。

linux中使用者程序是由fork系統呼叫建立的。計算機的記憶體、CPU 等資源都是由作業系統來分配的,而作業系統在分配資源時,大多數情況下是以程序為個體的。

每個程序僅僅有一個父程序,可是一個父程序卻能夠有多個子程序。當程序建立時,作業系統會給子程序建立新的地址空間,並把父程序的地址空間的對映拷貝到子程序的地址空間去;父程序和子程序共享僅僅讀資料和程式碼段,可是堆疊和堆是分離的。

2、程序的組成:

  • 程序控制塊
  • 程式碼
  • 資料

程序的程式碼和資料由程式提供。而程序控制塊則是由作業系統提供。

3、程序控制塊的組成:

  • 程序識別符號
  • 程序上下文環境
  • 程序排程資訊
  • 程序控制資訊

程序識別符號:

  • 程序ID
  • 程序名
  • 程序家族關係
  • 擁有該程序的使用者標識

程序的上下文環境:(主要指程序執行時CPU的各暫存器的內容)

  • 通用暫存器
  • 程式狀態在暫存器
  • 堆疊指標暫存器
  • 指令指標暫存器
  • 標誌暫存器等

程序排程資訊:

  • 程序的狀態
  • 程序的排程策略
  • 程序的優先順序
  • 程序的執行睡眠時間
  • 程序的堵塞原因
  • 程序的佇列指標等

當程序處於不同的狀態時,會被放到不同的佇列中。

程序控制資訊:

  • 程序的程式碼、資料、堆疊的起始地址
  • 程序的資源控制(程序的記憶體描寫敘述符、檔案描寫敘述符、訊號描寫敘述符、IPC描寫敘述符等)

程序使用的全部資源都會在PCB中描寫敘述。

程序建立時,核心為其分配PCB塊,當程序請求資源時核心會將對應的資源描寫敘述資訊增加到程序的PCB中,程序退出時核心會釋放PCB塊。通常來說程序退出時應該釋放它申請的資源。如檔案描寫敘述符等。為了防止程序遺忘某些資源(或是某些惡意程序)從而導致資源洩漏。核心一般會依據PCB中的資訊回收程序使用過的資源。

4、task_struct 在記憶體中的儲存:

在linux中程序控制塊定義為task_struct, 下圖為task_struct的主要成員:

在2.6曾經的核心中,各個程序的task_struct存放在他們核心棧的尾端。

這樣做是為了讓那些像X86那樣暫存器較少的硬體體系結構僅僅要通過棧指標就能計算出它的位置。而避免使用額外的暫存器來專門記錄。

因為如今使用slab分配器動態生成task_struct,所以僅僅需在棧底或棧頂建立一個新的結果struct thread_info(在檔案 asm/thread_info.h中定義)

struct thread_info{

    struct task_struct    *task;

    struct exec_domain    *exec_domain。

    __u32    flags;

    __u32 status。

    __u32 cpu;

    int  preempt_count;

    mm_segment addr_limit。

    struct restart_block restart_block;

    void    *sysenter_return;

    int  uaccess_err;

};

5、fork()、vfork()的聯絡:

Fork()  在2.6版本號的核心中Linux通過clone()系統呼叫實現fork()。這個系統呼叫通過一系列的引數標誌來指明父、子程序須要共享的資源。

Fork()、vfork()和庫函式都依據各自須要的引數標誌去呼叫clone(),然後由clone()去呼叫do_fork().

do_fork()完畢了建立中的大部分工作,它的定義在kernel/fork.c檔案裡。

該函式呼叫copy_process()函式,然後程序開始執行。Copy_process()函式完畢的工作非常有意思:

1)、呼叫dup_task_struct()為新程序建立一個核心堆疊、thread_info結構和task_struct結構。這些值與當前程序的值全然同樣。此時子程序和父程序的描寫敘述符是全然同樣的。

2)、檢查並確保新建立這個子程序後,當前使用者所擁有的程序數目沒有超出給他分配的資源的限制。

3)、子程序著手是自己與父程序差別開來。

程序描寫敘述符內的很多成員變數都要被清零或設為初始值。那些不是繼承而來的程序描寫敘述符成員。主要是統計資訊。Task_struc中的大多資料都依舊未被改動。

4)、子程序的狀態被設定為TASK_UNINTRRUPTIBLE,以保證它不會被投入執行。

5)、copy_process()呼叫copy_flags()以更新task_struct 的flags成員。表明程序是否擁有超級使用者許可權的PF_SUPERPRIV標誌被清0.表明程序還沒有呼叫exec()函式的PF_FORKNOEXEC標誌被設定。

6)、呼叫alloc_pid()為新程序分配一個有效的PID。

7)、依據傳遞給clone() 的引數標誌,copy_process()拷貝或共享開啟的檔案、檔案系統資訊、訊號處理函式、程序地址空間和名稱空間等。

在普通情況下,這些資源會被給定程序的全部執行緒共享;否則,這些資源對每一個程序是不同的因此被複制到這裡。

8)、最後copy_process()做掃尾工作並返回一個指向子程序的指標。

在回到do_fork()函式,假設copy_process()函式成功返回,新建立的子程序被喚醒並讓其投入執行。核心有意選擇子程序首先執行(儘管總是想子程序先執行。可是並不是總能如此)。

由於一般子程序都會立即呼叫exec()函式。這樣能夠避免寫時拷貝(copy-on-write)的額外開銷,假設父程序首先執行的話,有可能會開始向地址空間寫入。

Vfork()  除了不拷貝父程序的頁表項外vfork()和fork()的功能同樣。

子程序作為父程序的一個單獨的執行緒在它的地址空間裡執行,父程序被堵塞。直到子程序退出或執行exec()。子程序不能向地址空間寫入(在沒有實現寫時拷貝的linux版本號中,這一優化是非常實用的)。

do_fork() -->  clone() --> fork() 、vfork() 、__clone() ----->exec()

clone()函式的引數及其意思例如以下:

CLONE_FILES                        父子程序共享開啟的檔案

CLONE_FS                              父子程序共享檔案系統資訊

CLONE_IDLETASK                將PID設定為0(僅僅供idle程序使用)

CLONE_NEWNS                    為子程序建立新的名稱空間

CLONE_PARENT                   指定子程序與父程序擁有同一個父程序

CLONE_PTRACE                   繼續除錯子程序

CLONE_SETTID                     將TID寫回到使用者空間

CLONE_SETTLS                    為子程序建立新的TLS

CLONE_SIGHAND                 父子程序共享訊號處理函式以及被阻斷的訊號

CLONE_SYSVSEM                父子程序共享System V SEM_UNDO語義

CLONE_THREAD                  父子程序放進同樣的程序組

CLONE_VFORK                     呼叫Vfork(),所以父程序準備睡眠等待子程序將其喚醒

CLONE_UNTRACED            防止跟蹤程序在子程序上強制執行CLONE_PTRACE

CLONE_STOP                        以TASK_SROPPED狀態開始執行

CLONE_SETTLS                   為子程序建立新的TLS(thread-local storage)

CLONE_CHILD_CLEARTID      清除子程序的TID

CLONE_CHILD_SETTID            設定子程序的TID

CLONE_PARENT_SETTID        設定父程序的TID

CLONE_VM                                  父子程序共享地址空間

二、GDB追蹤fork()系統呼叫。

GDB 除錯的相關內容能夠參考:GDB追蹤核心啟動 篇 這裡不再佔用過多篇幅贅述。以下先直接上圖。在具體分析程式碼的執行過程。

啟動GDB後分別在sys_clone、do_fork、copy_process、copy_thread、ret_from_fork、syscall_exit等位置設定好斷點,見證fork()函式的執行過程(執行環境與GDB追蹤核心啟動 篇全然一致)

能夠看到,當我們在menuos中執行fork 命令的時候。核心會先呼叫clone。在sys_clone 斷點處停下來了。

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

在呼叫sys_clone() 後,核心依據不同的引數去呼叫do_fork()系統呼叫。進入do_fork()後就去又執行了copy_process().

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

在copy_process() 中又執行了copy_thread(),然後跳轉到了ret_from_fork 處執行一段彙編程式碼。再然後就跳到了syscall_exit(這是在arch/x86/kernel/entry_32.S中的一個標號。是執行系統呼叫後用於退出核心空間的彙編程式。

),

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

能夠看到。GDB追蹤到syscall_exit 後就無法繼續追蹤了.................

三、程式碼分析(3.18.6版本號的核心)

在3.18.6版本號的核心  kernel/fork.c檔案裡:

#ifdef __ARCH_WANT_SYS_FORK

SYSCALL_DEFINE0(fork)

{

#ifdef CONFIG_MMU

    return do_fork(SIGCHLD, 0, 0, NULL, NULL);

#else

    /* can not support in nommu mode */

    return -EINVAL;

#endif

}

#endif

#ifdef __ARCH_WANT_SYS_VFORK

SYSCALL_DEFINE0(vfork)

{

    return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL);

}

#endif

#ifdef __ARCH_WANT_SYS_CLONE

#ifdef CONFIG_CLONE_BACKWARDS

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,   int __user *, parent_tidptr,  int, tls_val,int __user *, child_tidptr)

#elif defined(CONFIG_CLONE_BACKWARDS2)

SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,  int __user *, parent_tidptr,  int __user *, child_tidptr,  int, tls_val)

#elif defined(CONFIG_CLONE_BACKWARDS3)

SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,  int, stack_size,  int __user *, parent_tidptr,  int __user *, child_tidptr,  int, tls_val)

#else

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,  int __user *, parent_tidptr, int __user *, child_tidptr,   int, tls_val)

#endif

{

    return do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);

}

#endif

從以上fork()、vfork()、clone() 的定義能夠看出,三者都是依據不同的情況傳遞不同的引數直接呼叫了do_fork()函式,去掉了中間環節clone()。

進入do_fork 後:

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

在do_fork中首先是對引數做了大量的引數檢查。然後就執行就執行 copy_process將父程序的PCB複製一份到子程序。作為子程序的PCB,再然後依據copy_process的返回值P推斷程序PCB複製是否成功。假設成功就先喚醒子程序,讓子程序就緒準備執行。

所以在do_fork中最重要的也就是copy_process()了,它完畢了子程序PCB的複製與初始化操作。以下就進入copy_process中看看核心是怎樣實現的:

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

先從總體上看一下。發現,copy_process中開頭一部分的程式碼相同是引數的檢查和依據不同的引數執行一些相關的操作。然後建立了一個任務。接著dup_task_struct(current)將當前程序的task_struct 複製了一份,並將新的task_struct地址作為指標返回!

在dup_task_struct中為子程序建立了一個task_struct結構體、一個thread_info 結構體,並進行了簡單的初始化,可是這是子程序的task_struct還是空的所以接下來的中間一部顯然是要將父子程序task_struct中同樣的部分從父程序複製到子程序,然後不同的部分再在子程序中進行初始化。

最後面的一部分則是。出現各種錯誤後的退出口。

以下來看一下中間那部分:怎樣將父子程序同樣的、不同的部分差別開來。

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

能夠看到,核心先是將父程序的stask_struct中的內容無論三七二十一全都複製到子程序的stask_struct中了(這裡面大部分的內容都是和父程序一樣。僅僅有少部分依據引數的不同稍作改動),每個模組拷貝結束後都進行了對應的檢查,看是否拷貝成功,假設失敗就跳到對應的出口處執行恢復操作。

最後又運行了一個copy_thread(),

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

在copy_thread這個函式中做了兩件很重要的事情:1、就是把子程序的 eax 賦值為 0。childregs->ax = 0,使得 fork 在子程序中返回 0;2、將子程序喚醒後執行的第一條指令定向到 ret_from_fork。所以這裡能夠看到子程序的執行從ret_from_fork開始。

借來繼續看copy_process中的程式碼。拷貝完父程序中的內容後,就要對子程序進行“個性化”,

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQv/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" alt="">

從程式碼也能夠看出,這裡是對子程序中的其它成員程序初始化操作。然後就退出了copy_process,回到了do_fork()中。

再接著看一下do_fork()中“掃尾“工作是怎麼做的:

前面植依據引數做一些變數的改動,後面兩個操作比較重要。假設是通過fork() 建立子程序,那麼最後就直接將子程序喚醒,可是假設是通過vfork()來建立子程序,那麼就要通知父程序必須等子程序執行結束才幹開始執行。

總結:

綜上所述:核心在建立一個新程序的時候,主要運行了一下任務:

1、父程序執行一個系統呼叫fork()或vfork();但最後都是通過呼叫do_fork()函式來操作,僅僅只是fork(),vfork()傳遞給do_fork()的引數不同。

2、在do_fork()函式中,前面做引數檢查。後面負責喚醒子程序(假設是vfork則讓父程序等待),中間部分負責建立子程序和子程序的PCB的初始化,這些工作都在copy_process()中完畢。

3、在copy_process()中先是例行的引數檢查和依據引數進行配置;然後是呼叫大量的copy_*****  函式將父程序task_struct中的內容複製到子程序的task_struct中,然後對於子程序與父程序之間不同的地方,在子程序中初始化或是清零。

4、完畢子程序的建立和初始化後,將子程序喚醒,優先讓子程序先執行,由於假設讓父程序先執行的話。由於linux的寫時拷貝機制,父程序非常可能會對資料進行寫操作,這時就須要拷貝資料段和程式碼斷的內容了。但假設先執行子程序的話,子程序通常都會通過exec()轉去執行其它的任務,直接將新任務的資料和程式碼拷過來即可了,而不須要像前面那樣先把父程序的資料程式碼拷過來,然後拷新任務的程式碼的時候又將其覆蓋掉。

5、執行完copy_process()後就回到了do_fork()中,接著父程序回到system_call中執行syscall_exit: 後面的程式碼。而子程序則先從ret_from_fork: 處開始執行,然後在回到system_call 中去執行syscall_exit:.

ENTRY(ret_from_fork)

   CFI_STARTPROC

   pushl_cfi %eax

   call schedule_tail

   GET_THREAD_INFO(%ebp)

   popl_cfi %eax

    pushl_cfi $0x0202        # Reset kernel eflags

    popfl_cfi

    jmp syscall_exit

    CFI_ENDPROC

END(ret_from_fork)

6、父程序和子程序最後都是通過system_call 的出口從核心空間回到使用者空間,回到使用者空間後。因為fork()函式對父子程序的返回值不同,所以依據返回值推斷出回來的是父程序還是子程序,然後分別執行不同的操作。