結合中斷上下文切換和進程上下文切換分析Linux內核的通常執行過程

結合中斷上下文切換和進程上下文切換分析Linux內核的通常執行過程

一. 實驗準備

  1. 詳細要求

結合中斷上下文切換和進程上下文切換分析Linux內核通常執行過程node

  • 以fork和execve系統調用爲例分析中斷上下文的切換
  • 分析execve系統調用中斷上下文的特殊之處
  • 分析fork子進程啓動執行時進程上下文的特殊之處
  • 以系統調用做爲特殊的中斷,結合中斷上下文切換和進程上下文切換分析Linux系統的通常執行過程

完成一篇博客總結分析Linux系統的通常執行過程,以期對Linux系統的總體運做造成一套邏輯自洽的模型,並能將所學的各類OS和Linux內核知識/原理融通進模型中linux

  1. 實驗環境

發行版本:Ubuntu 18.04.4 LTS算法

處理器:Intel® Core™ i7-8850H CPU @ 2.60GHz × 3shell

圖形卡:Parallels using AMD® Radeon pro 560x opengl engine網絡

GNOME:3.28.2數據結構

二. 實驗過程

I 分析中斷上下文的切換

中斷髮生之後,CPU跳到內核設置好的中斷處理代碼中去,由這部份內核代碼來處理中斷。這個處理過程當中的上下文就是中斷上下文異步

幾乎全部的體系結構,都提供了中斷機制。當硬件設備想和系統通訊的時候,它首先發出一個異步的中斷信號去打斷處理器的執行,繼而打斷內核的執行。中斷一般對應着一箇中斷號,內核經過這個中斷號找到中斷服務程序,調用這個程序響應和處理中斷。當你敲擊鍵盤時,鍵盤控制器發送一箇中斷信號告知系統,鍵盤緩衝區有數據到來,內核收到這個中斷號,調用相應的中斷服務程序,該服務程序處理鍵盤數據而後通知鍵盤控制器能夠繼續輸入數據了。爲了保證同步,內核可使用停止---既能夠中止全部的中斷也能夠有選擇地中止某個中斷號對應的中斷,許多操做系統的中斷服務程序都不在進程上下文中執行,它們在一個與全部進程無關的、專門的中斷上下文中執行。之因此存在這樣一個專門的執行環境,爲了保證中斷服務程序可以在第一時間響應和處理中斷請求,而後快速退出。xss

對同一個CPU來講,中斷處理比進程擁有更高的優先級,因此中斷上下文切換並不會與進程上下文切換同時發生。因爲中斷程序會打斷正常進程的調度和運行,大部分中斷處理程序都短小精悍,以便儘量快的執行結束。函數

一個進程的上下文能夠分爲三個部分:用戶級上下文、寄存器上下文以及系統級上下文。學習

用戶級上下文: 正文、數據、用戶堆棧以及共享存儲區;
寄存器上下文: 通用寄存器、程序寄存器(IP)、處理器狀態寄存器(EFLAGS)、棧指針(ESP);
系統級上下文: 進程控制塊task_struct、內存管理信息(mm_struct、vm_area_struct、pgd、pte)、內核棧。

當發生進程調度時,進行進程切換就是上下文切換(context switch)。操做系統必須對上面提到的所有信息進行切換,新調度的進程才能運行。而系統調用進行的是模式切換(mode switch)。模式切換與進程切換比較起來,容易不少,並且節省時間,由於模式切換最主要的任務只是切換進程寄存器上下文的切換。


II 分析fork子進程啓動執行時進程上下文及其特殊之處

fork()系統調用會經過複製一個現有進程來建立一個全新的進程. 進程被存放在一個叫作任務隊列的雙向循環鏈表當中。鏈表當中的每一項都是類型爲task_struct成爲進程描述符的結構。

首先咱們來看一段代碼

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(){
  pid_t pid;
  char *message;
  int n;
  pid = fork();
  if(pid<0){
    perror("fork failed");
    exit(1);
  }
  if (pid == 0){
    message = "this is the child \n";
    n=6;
  }else {
    message = "this is the parent \n";
    n=3;
  }
  for(;n>0;n--){
    printf("%s",message);
    sleep(1);
  }
  return 0;
}

在Linux環境中編寫和執行

# 建立一個C文件,名爲t.c,將上面的代碼拷貝進去
touch t.c
# 進行編譯
gcc t.c
# 執行
./a.out

之因此輸出是這樣的結果,是由於程序的執行流程以下圖所示:

以上的fork()例子的執行流程大體以下:

  1. 父進程初始化。
  2. 父進程調用fork,這是一個系統調用,所以進入內核。
  3. 內核根據父進程複製出一個子進程,父進程和子進程的PCB信息相同,用戶態代碼和數據也相同。所以,子進程如今的狀態看起來和父進程同樣,作完了初始化,剛調用了fork進入內核,尚未從內核返回。
  4. 如今有兩個如出一轍的進程看起來都調用了fork進入內核等待從內核返回(實際上fork只調用了一次),此外系統中還有不少別的進程也等待從內核返回。是父進程先返回仍是子進程先返回,仍是這兩個進程都等待,先去調度執行別的進程,這都不必定,取決於內核的調度算法。
  5. 若是某個時刻父進程被調度執行了,從內核返回後就從fork函數返回,保存在變量pid中的返回值是子進程的id,是一個大於0的整數,所以執下面的else分支,而後執行for循環,打印"This is the parent\n"三次以後終止。
  6. 若是某個時刻子進程被調度執行了,從內核返回後就從fork函數返回,保存在變量pid中的返回值是0,所以執行下面的if (pid == 0)分支,而後執行for循環,打印"This is the child\n"六次以後終止。fork調用把父進程的數據複製一份給子進程,但此後兩者互不影響,在這個例子中,fork調用以後父進程和子進程的變量messagen被賦予不一樣的值,互不影響。
  7. 父進程每打印一條消息就睡眠1秒,這時內核調度別的進程執行,在1秒這麼長的間隙裏(對於計算機來講1秒很長了)子進程頗有可能被調度到。一樣地,子進程每打印一條消息就睡眠1秒,在這1秒期間父進程也頗有可能被調度到。因此程序運行的結果基本上是父子進程交替打印,但這也不是必定的,取決於系統中其它進程的運行狀況和內核的調度算法,若是系統中其它進程很是繁忙則有可能觀察到不一樣的結果。另外,讀者也能夠把sleep(1);去掉看程序的運行結果如何。
  8. 這個程序是在Shell下運行的,所以Shell進程是父進程的父進程。父進程運行時Shell進程處於等待狀態,當父進程終止時Shell進程認爲命令執行結束了,因而打印Shell提示符,而事實上子進程這時還沒結束,因此子進程的消息打印到了Shell提示符後面。最後光標停在This is the child的下一行,這時用戶仍然能夠敲命令,即便命令不是緊跟在提示符後面,Shell也能正確讀取。

fork()最特殊之處在於:成功調用後返回兩個值,是因爲在複製時複製了父進程的堆棧段,因此兩個進程都停留在fork函數中,等待返回。因此fork函數會返回兩次,一次是在父進程中返回,另外一次是在子進程中返回,這兩次的返回值不一樣

其中父進程返回子進程pid,這是因爲一個進程能夠有多個子進程,可是卻沒有一個函數可讓一個進程來得到這些子進程id,那談何給別人你建立出來的進程。而子進程返回0,這是因爲子進程能夠調用getppid得到其父進程進程ID,但這個父進程ID卻不可能爲0,由於進程ID0老是有內核交換進程所用,故返回0就可表明正常返回了。

從fork函數開始之後的代碼父子共享,既父進程要執行這段代碼,子進程也要執行這段代碼.(子進程得到父進程數據空間,堆和棧的副本. 可是父子進程並不共享這些存儲空間部分. (即父,子進程共享代碼段.)。如今不少實現並不執行一個父進程數據段,堆和棧的徹底複製. 而是採用寫時拷貝技術。這些區域有父子進程共享,並且內核地他們的訪問權限改成只讀的.若是父子進程中任一個試圖修改這些區域,則內核值爲修改區域的那塊內存製做一個副本, 也就是若是你不修改咱們一塊兒用,你修改了以後對於修改的那部份內容咱們分開各用個的。

再一個就是,在重定向父進程的標準輸出時,子進程標準輸出也被重定向。這就源於父子進程會共享全部的打開文件。 由於fork的特性就是將父進程全部打開文件描述符複製到子進程中。當父進程的標準輸出被重定向,子進程本是寫到標準輸出的時候,此時天然也改寫到那個對應的地方;與此同時,在父進程等待子進程執行時,子進程被改寫到文件show.out中,而後又更新了與父進程共享的該文件的偏移量;那麼在子進程終止後,父進程也寫到show.out中,同時其輸出還會追加在子進程所寫數據以後。

在fork以後處理文件描述符通常有如下兩種狀況:

  • 父進程等待子進程完成。此種狀況,父進程無需對其描述符做任何處理。當子進程終止後,它曾進行過讀,寫操做的任一共享描述符的文件偏移已發生改變。
  • 父子進程各自執行不一樣的程序段。這樣fork以後,父進程和子進程各自關閉它們再也不使用的文件描述符,這樣就避免干擾對方使用的文件描述符了。這相似於網絡服務進程。

同時父子進程也是有區別的:它們不只僅是兩個返回值不一樣;它們各自的父進程也不一樣,父進程的父進程是ID不變的;還有子進程不繼承父進程設置的文件鎖,子進程未處理的信號集會設置爲空集等不一樣

事實上linux平臺經過clone()系統調用實現fork()。fork(),vfork()和clone()庫函數都根據各自須要的參數標誌去調用clone(),而後由clone()去調用do_fork(). 再而後do_fork()完成了建立中的大部分工做,他定義在kernel/fork.c當中.該函數調用copy_process()。

具體的流程能夠參考下圖:


III 分析execve系統調用中斷上下文及其特殊之處

execve() 系統調用的做用是運行另一個指定的程序。它會把新程序加載到當前進程的內存空間內,當前的進程會被丟棄,它的堆、棧和全部的段數據都會被新進程相應的部分代替,而後會重新程序的初始化代碼和 main 函數開始運行。同時,進程的 ID 將保持不變。

execve() 系統調用一般與 fork() 系統調用配合使用。從一個進程中啓動另外一個程序時,一般是先 fork() 一個子進程,而後在子進程中使用 execve() 變身爲運行指定程序的進程。 例如,當用戶在 Shell 下輸入一條命令啓動指定程序時,Shell 就是先 fork() 了自身進程,而後在子進程中使用 execve() 來運行指定的程序。

Linux提供了execl、execlp、execle、execv、execvp和execve等六個用以執行一個可執行文件的函數(統稱爲exec函數,其間的差別在於對命令行參數和環境變量參數的傳遞方式不一樣)。這些函數的第一個參數都是要被執行的程序的路徑,第二個參數則向程序傳遞了命令行參數,第三個參數則向程序傳遞環境變量。以上函數的本質都是調用在arch/i386/kernel/process.c文件中實現的系統調用sys_execve來執行一個可執行文件。

asmlinkage int sys_execve(struct pt_regs regs)
{
    int  error;
    char * filename;
    //將可執行文件的名稱裝入到一個新分配的頁面中
    filename = getname((char __user *) regs.ebx);
    error = PTR_ERR(filename);
    if (IS_ERR(filename))
       goto out;
    //執行可執行文件
    error = do_execve(filename,
          (char __user * __user *) regs.ecx,
          (char __user * __user *) regs.edx,
         &regs);
    if (error == 0) {
       task_lock(current);
       current->ptrace &= ~PT_DTRACE;
       task_unlock(current);
       
       set_thread_flag(TIF_IRET);
    }
    putname(filename);
out:
    return error;
}

該系統調用所須要的參數pt_regs在include/asm-i386/ptrace.h文件中定義。該參數描述了在執行該系統調用時,用戶態下的CPU寄存器在覈心態的棧中的保存狀況。經過這個參數,sys_execve能夠得到保存在用戶空間的如下信息:可執行文件路徑的指針(regs.ebx中)、命令行參數的指針(regs.ecx中)和環境變量的指針(regs.edx中)。

struct pt_regs {
    long ebx;
    long ecx;
    long edx;
    long esi;
    long edi;
    long ebp;
    long eax;
    int xds;
    int xes;
    long orig_eax;
    long eip;
    int xcs;
    long eflags;
    long esp;
    int xss;
};

regs.ebx保存着系統調用execve的第一個參數,便可執行文件的路徑名。由於路徑名存儲在用戶空間中,這裏要經過getname拷貝到內核空間中。getname在拷貝文件名時,先申請了一個page做爲緩衝,而後再從用戶空間拷貝字符串。爲何要申請一個頁面而不使用進程的系統空間堆棧?首先這是一個絕對路徑名,可能比較長,其次進程的系統空間堆棧大約爲7K,比較緊缺,不宜濫用。用完文件名後,在函數的末尾調用putname釋放掉申請的那個頁面。

sys_execve的核心是調用do_execve函數,傳給do_execve的第一個參數是已經拷貝到內核空間的路徑名filename,第二個和第三個參數仍然是系統調用execve的第二個參數argv和第三個參數envp,它們表明的傳給可執行文件的參數和環境變量仍然保留在用戶空間中。簡單分析一下這個函數的思路:先經過open_err()函數找到並打開可執行文件,而後要從打開的文件中將可執行文件的信息裝入一個數據結構linux_binprm,do_execve先對參數和環境變量的技術,並經過prepare_binprm讀入開頭的128個字節到linux_binprm結構的bprm緩衝區,最後將執行的參數從用戶空間拷貝到數據結構bprm中。內核中有一個formats隊列,該隊列的每一個成員認識並只處理一種格式的可執行文件,bprm緩衝區中的128個字節中有格式信息,便要經過這個隊列去辨認。do_execve()中的關鍵是最後執行一個search_binary_handler()函數,找到對應的執行文件格式,並返回一個值,這樣程序就能夠執行了。

do_execve 定義在 <fs/exec.c> 中,關鍵代碼解析以下。

int do_execve(char * filename, char __user *__user *argv,
       char __user *__user *envp,    struct pt_regs * regs)
{
    struct linux_binprm *bprm; //保存要執行的文件相關的數據
    struct file *file;
    int retval;
    int i;
    retval = -ENOMEM;
    bprm = kzalloc(sizeof(*bprm), GFP_KERNEL);
    if (!bprm)
       goto out_ret;
    //打開要執行的文件,並檢查其有效性(這裏的檢查並不完備)
    file = open_exec(filename);
    retval = PTR_ERR(file);
    if (IS_ERR(file))
       goto out_kfree;
    //在多處理器系統中才執行,用以分配負載最低的CPU來執行新程序
    //該函數在include/linux/sched.h文件中被定義以下:
    // #ifdef CONFIG_SMP
    // extern void sched_exec(void);
    // #else
    // #define sched_exec() {}
    // #endif
    sched_exec();
    //填充linux_binprm結構
    bprm->p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *);
    bprm->file = file;
    bprm->filename = filename;
    bprm->interp = filename;
    bprm->mm = mm_alloc();
    retval = -ENOMEM;
    if (!bprm->mm)
       goto out_file;
    //檢查當前進程是否在使用LDT,若是是則給新進程分配一個LDT
    retval = init_new_context(current, bprm->mm);
    if (retval  0)
       goto out_mm;
    //繼續填充linux_binprm結構
    bprm->argc = count(argv, bprm->p / sizeof(void *));
    if ((retval = bprm->argc)  0)
       goto out_mm;
    bprm->envc = count(envp, bprm->p / sizeof(void *));
    if ((retval = bprm->envc)  0)
       goto out_mm;
    retval = security_bprm_alloc(bprm);
    if (retval)
       goto out;
    //檢查文件是否能夠被執行,填充linux_binprm結構中的e_uid和e_gid項
    //使用可執行文件的前128個字節來填充linux_binprm結構中的buf項
    retval = prepare_binprm(bprm);
    if (retval  0)
       goto out;
    //將文件名、環境變量和命令行參數拷貝到新分配的頁面中
    retval = copy_strings_kernel(1, &bprm->filename, bprm);
    if (retval  0)
       goto out;
    bprm->exec = bprm->p;
    retval = copy_strings(bprm->envc, envp, bprm);
    if (retval  0)
       goto out;
    retval = copy_strings(bprm->argc, argv, bprm);
    if (retval  0)
       goto out;
    //查詢可以處理該可執行文件格式的處理函數,並調用相應的load_library方法進行處理
    retval = search_binary_handler(bprm,regs);
    if (retval >= 0) {
       free_arg_pages(bprm);
       //執行成功
       security_bprm_free(bprm);
       acct_update_integrals(current);
       kfree(bprm);
       return retval;
    }
out:
    //發生錯誤,返回inode,並釋放資源
    for (i = 0 ; i  MAX_ARG_PAGES ; i++) {
       struct page * page = bprm->page;
       if (page)
         __free_page(page);
    }
    if (bprm->security)
       security_bprm_free(bprm);
out_mm:
    if (bprm->mm)
       mmdrop(bprm->mm);
out_file:
    if (bprm->file) {
       allow_write_access(bprm->file);
       fput(bprm->file);
    }
out_kfree:
    kfree(bprm);
out_ret:
    return retval;
}

該函數用到了一個類型爲linux_binprm的結構體來保存要執行的文件相關的信息,該結構體在include/linux/binfmts.h文件中定義:

struct linux_binprm{
    char buf[BINPRM_BUF_SIZE]; //保存可執行文件的頭128字節
    struct page *page[MAX_ARG_PAGES];
    struct mm_struct *mm;
    unsigned long p;    //當前內存頁最高地址
    int sh_bang;
    struct file * file;     //要執行的文件
    int e_uid, e_gid;    //要執行的進程的有效用戶ID和有效組ID
    kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
    void *security;
    int argc, envc;     //命令行參數和環境變量數目
    char * filename;   //要執行的文件的名稱
    char * interp;       //要執行的文件的真實名稱,一般和filename相同
   unsigned interp_flags;
    unsigned interp_data;
    unsigned long loader, exec;
};

在該函數的最後,又調用了fs/exec.c文件中定義的search_binary_handler函數來查詢可以處理相應可執行文件格式的處理器,並調用相應的load_library方法以啓動進程。這裏,用到了一個在include/linux/binfmts.h文件中定義的linux_binfmt結構體來保存處理相應格式的可執行文件的函數指針以下:

struct linux_binfmt {
    struct linux_binfmt * next;
    struct module *module;
    // 加載一個新的進程
    int (*load_binary)(struct linux_binprm *, struct pt_regs * regs);
    // 動態加載共享庫
    int (*load_shlib)(struct file *);
    // 將當前進程的上下文保存在一個名爲core的文件中
   int (*core_dump)(long signr, struct pt_regs * regs, struct file * file);
    unsigned long min_coredump;
};

Linux內核容許用戶經過調用在include/linux/binfmt.h文件中定義的register_binfmt和unregister_binfmt函數來添加和刪除linux_binfmt結構體鏈表中的元素,以支持用戶特定的可執行文件類型。
在調用特定的load_binary函數加載必定格式的可執行文件後,程序將返回到sys_execve函數中繼續執行。該函數在完成最後幾步的清理工做後,將會結束處理並返回到用戶態中,最後,系統將會將CPU分配給新加載的程序。

execve系統調用的過程總結以下:

  • execve系統調用陷入內核,並傳入命令行參數和shell上下文環境
  • execve陷入內核的第一個函數:do_execve,該函數封裝命令行參數和shell上下文
  • do_execve調用do_execveat_common,後者進一步調用__do_execve_file,打開ELF文件並把全部的信息一股腦的裝入linux_binprm結構體
  • do_execve_file中調用search_binary_handler,尋找解析ELF文件的函數
  • search_binary_handler找到ELF文件解析函數load_elf_binary
  • load_elf_binary解析ELF文件,把ELF文件裝入內存,修改進程的用戶態堆棧(主要是把命令行參數和shell上下文加入到用戶態堆棧),修改進程的數據段代碼段
  • load_elf_binary調用start_thread修改進程內核堆棧(特別是內核堆棧的ip指針)
  • 進程從execve返回到用戶態後ip指向ELF文件的main函數地址,用戶態堆棧中包含了命令行參數和shell上下文環境

IV 以系統調用做爲特殊的中斷,結合中斷上下文切換和進程上下文切換分析Linux系統的通常執行過程

Linux系統的通常執行過程

正在運行的用戶態進程X切換到運行用戶態進程Y的過程

  1. 發生中斷 ,完成如下步驟:

    save cs:eip/esp/eflags(current) to kernel stack
    load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack)

  2. SAVE_ALL //保存現場,這裏是已經進入內核中斷處裏過程

  3. 中斷處理過程當中或中斷返回前調用了schedule(),其中的switch_to作了關鍵的進程上下文切換

  4. 標號1以後開始運行用戶態進程Y(這裏Y曾經經過以上步驟被切換出去過所以能夠從標號1繼續執行)

  5. restore_all //恢復現場

  6. 繼續運行用戶態進程Y

進程間的特殊狀況

  • 經過中斷處理過程當中的調度時機,用戶態進程與內核線程之間互相切換和內核線程之間互相切換
  • 與最通常的狀況很是相似,只是內核線程運行過程當中發生中斷沒有進程用戶態和內核態的轉換;
  • 內核線程主動調用schedule(),只有進程上下文的切換,沒有發生中斷上下文的切換,與最通常``的狀況略簡略;
  • 建立子進程的系統調用在子進程中的執行起點及返回用戶態,如fork;
  • 加載一個新的可執行程序後返回到用戶態的狀況,如execve;0-3G內核態和用戶態均可以訪問,3G以上只能內核態訪問。內核是全部進程共享的。內核是各類中斷處理過程和內核線程的集合。

三. 總結

此次實驗主要作了以下的事情:

  • 學習並完成實驗環境的配置的搭建
  • 學習並瞭解Linux內核中系統調用相關知識
  • 學習了中斷相關的知識
  • 學習並實踐了fork()與execve()系統調用的知識
  • 思考代碼執行的流程與原理
相關文章
相關標籤/搜索