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


作業要求:結合中斷上下文切換和進程上下文切換分析Linux內核一般執行過程

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

Linux 是一個多任務操作系統,它支持遠大於 CPU 數量的任務同時運行。當然,這些任務實際上並不是真的在同時運行,而是因為系統在很短的時間內,將 CPU 輪流分配給它們,造成多任務同時運行的錯覺。而在每個任務運行前,CPU 都需要知道任務從哪里加載、又從哪里開始運行,也就是說,需要系統事先幫它設置好CPU 寄存器和程序計數器CPU 寄存器和程序計數器就是 CPU 上下文,因為它們都是 CPU 在運行任何任務前,必須的依賴環境。

一、CPU 上下文切換

CPU 上下文切換就是先把前一個任務的 CPU 上下文(也就是 CPU 寄存器和程序計數器)保存起來,然后加載新任務的上下文到這些寄存器和程序計數器,最后再跳轉到程序計數器所指的新位置,運行新任務。

而這些保存下來的上下文,會存儲在系統內核中,並在任務重新調度執行時再次加載進來。這樣就能保證任務原來的狀態不受影響,讓任務看起來還是連續運行。

CPU 上下文切換根據任務的不同,可以分為以下三種類型 : 進程上下文切換 - 線程上下文切換 - 中斷上下文切換

程序在執行過程中通常有用戶態和內核態兩種狀態,CPU對處於內核態根據上下文環境進一步細分,因此有了下面三種狀態:

(1)內核態,運行於進程上下文,內核代表進程運行於內核空間。
(2)內核態,運行於中斷上下文,內核代表硬件運行於內核空間。
(3)用戶態,運行於用戶空間

1.1 進程上下文切換

Linux按照特權等級,把進程的運行空間分為內核空間和用戶空間,分別對應着下圖中,CPU特權等級的Ring 0 和Ring 3

  • 內核空間(Ring 0)具有最高權限,可以直接訪問所有資源
  • 用戶空間(Ring 3) 只能訪問受限資源,不能直接訪問內存等硬件設備,必須通過系統調用陷入到內核中,才能訪問這些特權資源

Linux特權等級

換個角度看,也就是說,進程既可以在用戶空間運行,又可以在內核空間運行(從用戶態到內核態的轉變,需要通過系統調用來完成,系統調用實質上是一種特殊的中斷,由用戶程序觸發,之前提到的中斷是由硬件觸發)。進程在用戶空間運行時,被稱為進程的用戶態,而陷入內核空間的時候,被稱為進程的內核態。

主要注意的是:

  • 進程上下文切換,是指從一個進程切換到另一個進程運行
  • 而系統調用過程中一直是同一個進程在運行

系統調用過程通常稱為特權模式切換,而不是上下文切換。當進程調用系統調用或者發生中斷時,CPU從用戶模式(用戶態)切換成內核模式(內核態),此時,無論是系統調用程序還是中斷服務程序,都處於當前進程的上下文中,並沒有發生進程上下文切換。當系統調用或中斷處理程序返回時,CPU要從內核模式切換回用戶模式,此時會執行操作系統的調用程序。如果發現就需隊列中有比當前進程更高的優先級的進程,則會發生進程切換:當前進程信息被保存,切換到就緒隊列中的那個高優先級進程;否則,直接返回當前進程的用戶模式,不會發生上下文切換。

進程上下文切換

進程的上下文不僅包括了虛擬內存、棧、全局變量等用戶空間的資源,還包括了內核堆棧、寄存器等內核空間的狀態。因此進程的上下文切換就比系統調用時多了一步:在保存當前進程的內核狀態和CPU寄存器之前,需要先把該進程的虛擬內存、棧等保存下來;而加載下一進程的內核態后,還需要刷新進程的虛擬內存和用戶棧。模式切換與進程切換比較起來,容易很多,而且節省時間,因為模式切換最主要的任務只是切換進程寄存器上下文的切換。

1.2 中斷上下文切換

為了快速響應硬件的事件,中斷處理會打斷進程的正常調度和執行,轉而調用中斷處理程序,響應設備事件。而在打斷其他進程時,就需要將進程當前的狀態保存下來,這樣在中斷結束后,進程仍然可以從原來的狀態恢復運行

跟進程上下文不同,中斷上下文切換並不涉及到進程的用戶態。所以,即便中斷過程打斷了一個正處於用戶態的進程,也不需要保存和恢復這個進程的虛擬內存、全局變量等用戶態資源。中斷上下文,其實只包括內核態中斷服務程序執行所必需的狀態,包括CPU寄存器、內核堆棧、硬件中斷參數等。

對同一個CPU來說,中斷處理比進程擁有更高的優先級,所以中斷上下文切換並不會與進程上下文切換同時發生。同樣道理,由於中斷會打斷正常進程的調度和執行,所以大部分中斷處理程序都短小精悍,以便盡可能快的執行結束。

1.3 線程上下文切換

線程與進程最大的區別在於,線程是調度的基本單位,而進程則是資源擁有的基本單位。說白了,所謂內核中的任務調度,實際上的調度對象是線程;而進程只是給線程提供了虛擬內存、全局變量等資源。

線程的上下文切換其實就可以分為兩種情況:

  • 第一種:前后兩個線程屬於不同進程。此時,因為資源不共享,所以切換過程就跟進程上下文切換是一樣
  • 第二種:前后兩個線程屬於同一個進程。此時,因為虛擬內存是共享的,所以在切換時,虛擬內存這些資源就保持不動,只需要切換線程的私有數據、寄存器等不共享的數據。

到這里你應該也發現了,雖然同為上下文切換,但同進程內的線程切換,要比多進程間的切換消耗更少的資源,而這,也正是多線程代替多進程的一個優勢。

二、中斷上下文的切換的一般流程(以系統調用為例)

中斷分外部中斷(硬件中斷)和內部中斷(軟件中斷),內部中斷又稱為異常(Exception),異常又分為故障(fault)和陷阱(trap)。 系統調用就是利用陷阱(trap)這種軟件中斷方式主動從用戶態進入內核態的。

中斷的具體分類

系統調用層次:用戶程序------>C庫(即API):INT 0x80 ----->system_call------->系統調用服務例程-------->內核程序
系統編程接口API和系統調用的關系

  1. 應用程序代碼調用 xyz(),該函數是一個包裝系統調用的庫函數;
  2. 庫函數 xyz() 負責准備向內核傳遞的參數,並觸發軟中斷以切換到內核;
  3. CPU 被軟中斷打斷后,執行中斷處理函數,即系統調用處理函數(system_call);
  4. 系統調用處理函數調用系統調用服務例程(sys_xyz ),真正開始處理該系統調用。

在Linux中通過執行int $0x80或syscall指令來觸發系統調用的執行,其中這條int $0x80匯編指令是產生中斷向量為128的編程異常(trap)。

當用戶態進程調用一個系統調用時,CPU切換到內核態並開始執行 system_call(entry_INT80_32或entry_SYSCALL_64)匯編代碼,其 中根據系統調用號調用對應的內核處理函數。

內核實現了很多不同的系統調用,用戶態進程 必須指明需要執行哪個系統調用,這需要使用EAX寄存器傳遞一個名 為系統調用號的參數。除了系統調用號外,系統調用也可能需要傳遞 參數。在32位x86體系結構下普通的函數調用是通過將參數壓棧的方式傳遞的。

系統調用從用戶 態切換到內核態,在用戶態和內核態這兩種執行模式下使用的是不同的堆棧,即進程的用戶態堆棧和進程的內核態堆棧,傳遞參數方法無法通過參數壓棧的方式,而是通過寄存器 傳遞參數的方式。寄存器傳遞參數的個數是有限制的,而且每個參數的長度不能超過寄存 器的長度,32位x86體系結構下寄存器的長度最大32位。除了EAX用於傳遞系統調用號 外,參數按順序賦值給EBX、ECX、EDX、ESI、EDI、EBP,參數的個數不能超過6個, 即上述6個寄存器。如果超過6個就把某一個寄存器作為指針,指向內存,就可以通過內 存來傳遞更多的參數。

觸發系統調用后的大致流程:

  1. 正在運行的用戶態進程X

  2. 發生中斷(包括異常、系統調用等),CPU完成以下動作

    • save cs:eip/ss:esp/eflags:當前CPU上下文壓入進程X的內核堆棧
    • load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack):加載當前進程內核堆棧相關信息,跳轉到中斷處理程序,即中斷執行路徑的起點
  3. SAVE_ALL,保存現場,此時完成了中斷上下文切換,即從進程X的用戶態到進程X的內核態

  4. 中斷處理過程中或中斷返回前調用了schedule函數,其中的switch_to做了關鍵的進程上下文切換。將當前進程X的內核堆棧切換到進程調度算法選出來的next進程 (本例假定為進程Y)的內核堆棧,並完成了進程上下文所需的EIP等寄存器狀態切換。詳細過程見前述內容

  5. 標號1,即前述3.18.6內核的swtich_to代碼第50行“”1:\t“ ”(地址為switch_to中的“$1f”),之后開始運行進程Y(這里進程Y曾經通過以上步驟被切換出去,因此可以 從標號1繼續執行)

  6. restore_all,恢復現場,與(3)中保存現場相對應。注意這⾥是進程Y的中斷處理過程中,而(3)中保存現場是在進程X的中斷處理過程中,因為內核堆棧從進程X 切換到進程Y了

  7. iret - pop cs:eip/ss:esp/eflags,從Y進程的內核堆棧中彈出(2)中硬件完成的壓棧內容。此時完成了中斷上下文的切換,即從進程Y的內核態返回到進程Y的用戶態

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

中斷進入詳細過程:

  1. 確定與中斷或者異常關聯的向量i(0~255)

  2. 讀idtr寄存器指向的IDT表中的第i項 3,從gdtr寄存器獲得GDT的基地址,並在GDT中查找, 以讀取IDT表項中的段選擇符所標識的段描述符

  3. 從gdtr寄存器獲得GDT的基地址,並在GDT中查找, 以讀取IDT表項中的段選擇符所標識的段描述符

  4. 確定中斷是由授權的發生源發出的

  5. 檢查是否發生了特權級的變化,一般指是否由 用戶態陷入了內核態。

    如果是由用戶態陷入了內核態,控制單元必須開 始使用與新的特權級相關的堆棧

    • 讀tr寄存器,訪問運行進程的tss段
    • 用與新特權級相關的棧段和棧指針裝載ss和esp寄存器。這些值可以在進程的tss段中找到
    • 在新的棧中保存ss和esp以前的值,這些值指明了與 舊特權級相關的棧的邏輯地址
  6. 若發生的是故障,用引起異常的指令地址修改cs 和eip寄存器的值,以使得這條指令在異常處理結 束后能被再次執行

  7. 在棧中保存eflags、cs和eip的內容

  8. 如果異常產生一個硬件出錯碼,則將它保存在棧 中

  9. 裝載cs和eip寄存器,其值分別是IDT表中第i項門 描述符的段選擇符和偏移量字段。這對寄存器值 給出中斷或者異常處理程序的第一條指定的邏輯 地址

此時的進程內核態堆棧
中斷返回詳細過程:

中斷/異常處理完后,相應的處理程序會 執行一條iret匯編指令,這條匯編指令讓 CPU控制單元做如下事情:

  1. 用保存在棧中的值裝載cs、eip和eflags寄存器。如果一個硬件出錯碼曾被壓入棧中, 那么彈出這個硬件出錯碼

  2. 檢查處理程序的特權級是否等於cs中最低 兩位的值(這意味着進程在被中斷的時候是 運行在內核態還是用戶態)。若是,iret終止 執行;否則,轉入3

  3. 從棧中裝載ss和esp寄存器。這步意味着返 回到與舊特權級相關的棧

  4. 檢查ds、es、fs和gs段寄存器的內容,如 果其中一個寄存器包含的選擇符是一個段描 述符,並且特權級比當前特權級高,則清除 相應的寄存器。這么做是防止懷有惡意的用 戶程序利用這些寄存器訪問內核空間

三、fork子進程啟動執行時進程上下文的特殊之處

首先看一段程序及其執行的結果來了解fork函數的作用:

#include <unistd.h>
#include <stdio.h>

int main()
{
    int pid = fork();

    if (pid == -1)
        return -1;

    if (pid)
    {
        printf("I am father, my pid is %d\n", getpid());
        return 0;
    }
    else
    {
        printf("I am child, my pid is %d\n", getpid());
        return 0;
    }
}

執行結果

看到這個結果是不是很奇怪,為什么if的分支執行到了,else的分支也執行到了。這明顯不符合程序執行最基本的原理。這個放到后面再來解釋,先來了解一下fork這個函數:

pid_t fork();

上面是fork函數的原型,它有三個返回值
- 該進程為父進程時,返回子進程的pid
- 該進程為子進程時,返回0
- fork執行失敗,返回-1

fork的作用是克隆進程,也就是將原先的一個進程再克隆出一個來,克隆出的這個進程就是原進程的子進程,這個子進程和其他的進程沒有什么區別,同樣擁有自己的獨立的地址空間。不同的是子進程是在fork返回之后才開始執行的,就像一把叉子一樣,執行fork之后,父子進程就分道揚鑣了,所以fork這個名字就很形象,叉子的意思。fork給父進程返回子進程pid,給其拷貝出來的子進程返回0,這也是他的特點之一,一次調用,兩次返回,所以與一般的系統調用處理流程也必定不同。

Linux下用於創建進程的API有三個fork,vfork和clone,這三個函數分別是通過系統調用sys_fork,sys_vfork以及sys_clone實現的(目前討論的都是基於x86架構的)。而且這三個系統調用,都是通過do_fork來實現的,只是傳入了不同的參數。所以我們可以得出結論:所有的子進程是在do_fork實現創建和調用的。下面我們就來整理一下整個進程的在用戶態到內核態的過程是怎么樣的。fork系統調用如下:

fork系統調用過程

do_fork的代碼如下(linux3.18.6):

long do_fork(unsigned long clone_flags,
    unsigned long stack_start,
    unsigned long stack_size,
    int __user *parent_tidptr,
    int __user *child_tidptr)
{
    struct task_struct *p;
    int trace = 0;
    long nr;

    /*
    * Determine whether and which event to report to ptracer.  When
    * called from kernel_thread or CLONE_UNTRACED is explicitly
    * requested, no event is reported; otherwise, report if the event
    * for the type of forking is enabled.
    */
    if (!(clone_flags & CLONE_UNTRACED)) {
        if (clone_flags & CLONE_VFORK)
            trace = PTRACE_EVENT_VFORK;
        else if ((clone_flags & CSIGNAL) != SIGCHLD)
            trace = PTRACE_EVENT_CLONE;
        else
            trace = PTRACE_EVENT_FORK;
    
        if (likely(!ptrace_event_enabled(current, trace)))
            trace = 0;
    }
    
    p = copy_process(clone_flags, stack_start, stack_size,
        child_tidptr, NULL, trace);
    /*
    * Do this prior waking up the new thread - the thread pointer
    * might get invalid after that point, if the thread exits quickly.
    */
    if (!IS_ERR(p)) {
        struct completion vfork;
        struct pid *pid;
    
        trace_sched_process_fork(current, p);
    
        pid = get_task_pid(p, PIDTYPE_PID);
        nr = pid_vnr(pid);
    
        if (clone_flags & CLONE_PARENT_SETTID)
            put_user(nr, parent_tidptr);
    
        if (clone_flags & CLONE_VFORK) {
            p->vfork_done = &vfork;
            init_completion(&vfork);
            get_task_struct(p);
        }
    
        wake_up_new_task(p);
    
        /* forking complete and child started to run, tell ptracer */
        if (unlikely(trace))
            ptrace_event_pid(trace, pid);
    
        if (clone_flags & CLONE_VFORK) {
            if (!wait_for_vfork_done(p, &vfork))
                ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
        }
    
        put_pid(pid);
    } else {
        nr = PTR_ERR(p);
    }
    return nr;

}

整個創建新進程是由copy_process()這個函數實現的,copy_process()做的主要工作如下:

1. 復制一個PCB——task_struct

p = dup_task_struct(current);

復制當前進程的PCB描述符task_struct。我們在進入到該函數dup_task_struct體內就可以看到這個pcb是如何復制的。主要的賦值函數是

err = arch_dup_task_struct(tsk, orig);	//賦值操作
...
tsk = alloc_task_struct_node(node);
ti = alloc_thread_info_node(tsk, node);
tsk->stack = ti;
setup_thread_stack(tsk, orig);			//這里只是復制thread_info,而非復制內核堆棧

然而我們再 往dup_task_struct(current)函數往下看,后面是大量的修改進程的內容,也就是對復制過來的東西修改為子進程所擁有的數據。也就是初始化一個子進程,在copy_process()函數有一個非常重要的函數copy_thread,部分代碼如下:

struct pt_regs *childregs = task_pt_regs(p);
struct task_struct *tsk;
int err;

p->thread.sp = (unsigned long) childregs;
p->thread.sp0 = (unsigned long) (childregs+1);
memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));

if (unlikely(p->flags & PF_KTHREAD)) {
    /* kernel thread */
    memset(childregs, 0, sizeof(struct pt_regs));
    p->thread.ip = (unsigned long) ret_from_kernel_thread;
    task_user_gs(p) = __KERNEL_STACK_CANARY;
    childregs->ds = __USER_DS;
    childregs->es = __USER_DS;
    childregs->fs = __KERNEL_PERCPU;
    childregs->bx = sp; /* function */
    childregs->bp = arg;
    childregs->orig_ax = -1;
    childregs->cs = __KERNEL_CS | get_kernel_rpl();
    childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;
    p->thread.io_bitmap_ptr = NULL;
    return 0;
}
*childregs = *current_pt_regs();	//拷貝父進程的內核堆棧棧底,也就是已有的內核堆棧數據的拷貝
childregs->ax = 0;					//給eax賦值為0,因為子進程返回的是0,系統調用是通過eax返回的,
if (sp)
    childregs->sp = sp;				//修改棧頂

p->thread.ip = (unsigned long) ret_from_fork;			//給ip賦值,這就是子進程執行的起點

從用戶態的代碼看fork();函數返回了兩次,即在父子進程中各返回一次,父進程從系統調用中返回比較容易理解,子進程從系統調用中返回,那它在系統調用處理過程中的哪里開始執行的呢?這就涉及子進程的內核堆棧數據狀態和task_struct中thread記錄的sp和ip的一致性問題,這是在哪里設定的?copy_thread in copy_process

struct pt_regs *childregs = task_pt_regs(p);
*childregs = *current_pt_regs(); 				//復制內核堆棧棧底
childregs->ax = 0; 								//為什么子進程的fork返回0,這里就是原因!

p->thread.sp = (unsigned long) childregs; 		//調度到子進程時的內核棧頂
p->thread.ip = (unsigned long) ret_from_fork; 	//調度到子進程時的第一條指令地址

上面賦值復制的內核堆棧並不是父進程的所有內核堆棧的內容,那復制的是哪些部分呢?我們可以看上面代碼的第一句,其中復制的內容就是pt_regs里面的內容。里面的代碼如下:

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

父進程堆棧復制給子進程的就是上面那些參數。從copy_thread中我們就已經得出堆棧復制和子進程開始執行的起始地方。總結一下:linux創建一個新的進程是從復制開始的,在系統內核里首先是將父進程的進程控制塊PCB進行拷貝,然后再根據自己的情況修改相應的參數,獲取自己的進程號,再開始執行。內核中主要通過do_fork()實現,復制進程主要是靠copy_process()完成的,整個過程實現如下:

  1. p = dup_task_struct(current); 為新進程創建一個內核棧、thread_iofo和task_struct,這里完全copy父進程的內容,所以到目前為止,父進程和子進程是沒有任何區別的
  2. 為新進程在其內存上建立內核堆棧
  3. 對子進程task_struct任務結構體中部分變量進行初始化設置,檢查所有的進程數目是否已經超出了系統規定的最大進程數,如果沒有的話,那么就開始設置進程描訴符中的初始值,從這開始,父進程和子進程就開始區別開了。
  4. 父進程的有關信息復制給子進程,建立共享關系
  5. 設置子進程的狀態為不可被TASK_UNINTERRUPTIBLE,從而保證這個進程現在不能被投入運行,因為還有很多的標志位、數據等沒有被設置
  6. 復制標志位(falgs成員)以及權限位(PE_SUPERPRIV)和其他的一些標志
  7. 調用get_pid()給子進程獲取一個有效的並且是唯一的進程標識符PID
  8. return ret_from_fork;返回一個指向子進程的指針,開始執行

總結來說,進程的創建過程大致是父進程通過fork系統調用進入內核_ do_fork函數,如下圖所示復制進程描述符及相關進程資源(采用寫時復制技術)、分配子進程的內核堆棧並對內核堆棧和 thread等進程關鍵上下文進行初始化,最后將子進程放入就緒隊列,fork系統調用返回;而子進程則在被調度執行時根據設置的內核堆棧和thread等進程關鍵上下文開始執行。

四、execve系統調用中斷上下文的特殊之處

首先看兩段程序及其執行的結果來了解execve函數的作用:

/*	execve.c	*/
#include <unistd.h>
#include <stdlib.h>

char *buf [] = {"/bin/sh", NULL};

void main(){
	execve("/bin/sh", buf, 0);
	exit(0);
}

execve.c執行結果

/*	execve2.c	*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char * argv[])
{
     int pid;
     /* fork another process */
     pid = fork();
     if (pid < 0)
     {
         /* error occurred */
         fprintf(stderr, "Fork Failed!");
         exit(-1);
     }
     else if (pid == 0)
     {
         /* child process */
         execlp("/bin/ls", "ls", NULL);
     }
     else
     {
         /* parent process */
         /* parent will wait for the child to complete*/
         wait(NULL);
         printf("Child Complete!\n");
         exit(0);
     }
}

execve2.c執行結果

execve2.c簡化了的Shell程 序執行ls命令的過程。首先fork一個子進程,pid為0的分支是將來的子進程要執行的,在子進程里調用 execlp來加載可執行程序ls。子進程通過execlp加載可執行程序時按如圖所示的結構重新布局用戶態堆棧,可以看到用戶態堆棧的棧頂就是main函數調用堆棧框架,這就是程序的main函數起點的執行環境。

在布局一個新的用戶態堆棧時,實際上是把命令行參數內容和環境變量的內容通過指針的方式傳到系統調用內核處理函數,再創建一個新的用戶態堆棧時會把這些char *argcv[]和char *envp[]等復制到用戶態堆棧中,來初始化這個新的可執行程序的執行上下文環境。所以新 的程序可以從main函數開始把對應的參數接收過來,然后執行

在調用execve系統調用時,當前的執行環境是從父進程復制過來的, execve系統調用加載完新的可執行程序之后已經覆蓋了原來父進程的上下文環境。execve 系統調用在內核中幫我們重新布局了新的用戶態執行環境。

正常的一個系統調用都是陷入內核態,再返回到用戶態,然后繼續執行系統調用后的下一條指令。上文講到,fork和其他系統調用不同之處是它在陷入內核態之后有兩次返回,第一次返回到原來的父進程的位 置繼續向下執行,這和其他的系統調用是一樣的。在子進程中fork也返回了一次,會返回到一個特定的點——ret_from_fork,通過內核構造的堆棧環境,它可以正常系統調用返回到用戶態,所以它 稍微特殊一點。同樣,execve也比較特殊。當前的可執行程序在執行,執行到execve系統調用時陷入內核態,在內核里面用do_execve加載可執行文件,把當前進程的可執行程序給覆蓋掉。當execve系統調用返回時,返回的已經不是原來的那個可執行程序了,而是新的可執行程序。execve返回的是新的可執行程序執行的起點,靜態鏈接的可執行文件也就是main函數的大致位置,動態鏈接的可執行文件還需 要ld鏈接好動態鏈接庫再從main函數開始執行。

Linux系統一般會提供了execl、execlp、execle、execv、execvp和execve等6個用以加載執行 一個可執行文件的庫函數,這些庫函數統稱為exec函數,差異在於對命令行參數和環境變量參數 的傳遞方式不同。exec函數都是通過execve系統調用進入內核,對應的系統調用內核處理函數為 sys_execve或__x64_sys_execve,它們都是通過調用do_execve來具體執行加載可執行文件的 ⼯作。

整體的調用關系為sys_execve() / x64_sys_execve ==> do_execve() ==> do_execveat_common() ==> do_execve_file ==> exec_binprm() ==> search_binary_handler() ==> load_elf_binary() ==> start_thread()。

do_execve流程

execve執行后具體處理過程:

  1. 檢查看可執行文件的類型:當進入 execve() 系統調用之后,Linux 內核就開始進行真正的裝載工作。在內核中,execve() 系統調用相應的入口是 sys_execve()sys_execve() 進行一些參數的檢查復制之后,調用 do_execve()do_execve() 會首先查找被執行的文件,如果找到文件,則讀取文件的前 128 個字節。
    為什么要先讀取文件的前 128 個字節?這是因為Linux支持的可執行文件不止 ELF 一種,還包括 a.outJava 程序#! 開頭的腳本程序do_execve()通過讀取前 128 個字節來判斷文件的格式。每種可執行文件格式的開頭幾個字節都是很特殊的,尤其是前4個字節,被稱為 魔數(Magic Number)

  2. 搜索匹配裝載處理過程:當 do_execve() 讀取了128個字節的文件頭部之后,調用 search_binary_handle() 去搜索和匹配合適的可執行文件裝載處理過程。Linux 中所有被支持的可執行文件格式都有相應的裝載處理過程search_binary_handler() 會通過判斷頭部的魔術確定文件的格式,並且調用相應的裝載處理過程。常見的可執行程序及其裝載處理過程的對應關系如下所示.

    • ELF 可執行文件:load_elf_binary()
    • a.out 可執行文件:load_aout_binary()
    • 可執行腳本程序:load_script()
  3. 裝載執行可執行文件
    以 ELF 的裝載處理過程 load_elf_binary() 為例,其所包含的步驟如下圖所示:

    load_elf_binary裝載過程

    1. 操作系統讀取可執行文件 ELF 的 Header,檢查文件的有效性。
    2. 操作系統讀取可執行文件 ELF的 Program Header Table 中讀取每個 Segment 的虛擬地址、文件地址、屬性等。
    3. 操作系統根據 Program Header Table 將可執行文件 ELF 映射至內存。
    4. 如果是靜態鏈接的情況,則直接跳轉至第 7 步;如果是動態鏈接的情況,操作系統將查找 .interp 節,找到 動態鏈接器(Dynamic Linker) 的位置,並啟動動態鏈接器。在 Linux 下,動態鏈接器 ld.so 是一個共享對象,操作系統同樣通過映射的方式將它加載到進程的地址空間。操作系統在加載完后,將控制權交給動態鏈接器的入口。
    5. 動態鏈接器獲得控制權后,開始執行一系列初始化操作。
    6. 動態鏈接器根據當前的環境參數,對可執行文件進行動態鏈接工作。
    7. 控制權被轉交到可執行文件的入口地址,程序開始正式執行。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM