結合中斷上下文切換和程式上下文切換分析Linux核心的一般執行過程

FM收音機發表於2020-06-14

結合中斷上下文切換和程式上下文切換分析Linux核心的一般執行過程

一. 實驗準備

  1. 詳細要求

結合中斷上下文切換和程式上下文切換分析Linux核心一般執行過程

  • 以fork和execve系統呼叫為例分析中斷上下文的切換
  • 分析execve系統呼叫中斷上下文的特殊之處
  • 分析fork子程式啟動執行時程式上下文的特殊之處
  • 以系統呼叫作為特殊的中斷,結合中斷上下文切換和程式上下文切換分析Linux系統的一般執行過程

完成一篇部落格總結分析Linux系統的一般執行過程,以期對Linux系統的整體運作形成一套邏輯自洽的模型,並能將所學的各種OS和Linux核心知識/原理融通進模型中

  1. 實驗環境

發行版本:Ubuntu 18.04.4 LTS

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

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

GNOME:3.28.2

二. 實驗過程

I 分析中斷上下文的切換

中斷髮生以後,CPU跳到核心設定好的中斷處理程式碼中去,由這部分核心程式碼來處理中斷。這個處理過程中的上下文就是中斷上下文

幾乎所有的體系結構,都提供了中斷機制。當硬體裝置想和系統通訊的時候,它首先發出一個非同步的中斷訊號去打斷處理器的執行,繼而打斷核心的執行。中斷通常對應著一箇中斷號,核心通過這個中斷號找到中斷服務程式,呼叫這個程式響應和處理中斷。當你敲擊鍵盤時,鍵盤控制器傳送一箇中斷訊號告知系統,鍵盤緩衝區有資料到來,核心收到這個中斷號,呼叫相應的中斷服務程式,該服務程式處理鍵盤資料然後通知鍵盤控制器可以繼續輸入資料了。為了保證同步,核心可以使用中止---既可以停止所有的中斷也可以有選擇地停止某個中斷號對應的中斷,許多作業系統的中斷服務程式都不在程式上下文中執行,它們在一個與所有程式無關的、專門的中斷上下文中執行。之所以存在這樣一個專門的執行環境,為了保證中斷服務程式能夠在第一時間響應和處理中斷請求,然後快速退出。

對同一個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()系統呼叫的知識
  • 思考程式碼執行的流程與原理

相關文章