參考《深入理解Linux內核(第三版)》
進程切換
爲了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復以前掛起的某個進程的執行。這種行爲被稱爲進程切換,任務切換或上下文切換。下面幾節描述在Linux中進行進程切換的主要內容。
硬件上下文
儘管每個進程可以擁有屬於自己的地址空間,但所有進程必須共享CPU寄存器。因此要恢復一個進程的執行之前,內核必須確保每個寄存器裝入了掛起進程時的值。
進程恢復執行前必須裝入寄存器的一組數據稱爲硬件上下文。硬件上下文是進程可執行上下文的一個子集,因爲可執行上下文包含進程執行時需要的所有信息。在Linux中,進程硬件上下文的一部分存在TSS段,而剩餘部分存放在內核態的堆棧中。
在下面的描述中,我們假定用prev局部變量表示切換出的進程的描述符,next表示切換進的進程的描述符。因此,我們把進程切換定義爲這樣的行爲:保存prev硬件上下文,用next硬件上下文代替prev。因爲進程切換經常發生,因此減少和裝入硬件上下文所花費的時間是非常重要的。
早期的Linux版本利用80x86體系結構所提供的硬件支持,並通過far jmp指令跳到進程TSS描述符的選擇符來執行進程切換。當執行這條指令時,CPU通過自動保存原來的硬件上下文,裝入新的硬件上下文來執行硬件上下文切換。但是基於以下原因,Linux2.6使用軟件執行進程切換:
-
通過一組mov指令逐步執行切換,這樣能較好地控制所裝入數據的合法性,尤其是,這使檢查ds和es段寄存器的值成爲可能,這些值有可能被惡意用戶僞造。當用單獨的farjmp指令時,不可能進行這類檢查。
-
舊方法和新方法所需時間大致相同。然而,儘管當前的切換代碼還有改進的餘地,卻不能對硬件上下文切換進行優化。
進程切換隻發生在內核態。在執行進程切換之前,用戶態進程所使用的所有寄存器內容已保存在內核態堆棧上,這也包括ss和esp這對寄存器的內容。
任務狀態段
80x86體系結構包括一個特殊的段類型,叫任務狀態段(Task State Segment, TSS)來存放硬件上下文。儘管Linux並不使用硬件上下文切換,但是強制它爲系統中每個不同的CPU創建一個TSS。這樣做的兩個主要理由爲:
-
當80x86的一個CPU從用戶態切換到內核態時,它就從TSS中獲取內核態堆棧的地址。
-
當用戶態進程試圖通過in或out指令訪問一個I/O端口時,CPU需要訪問存放在TSS中的I/O許可圖以檢查該進程是否有訪問端口的權力。
更確切地說,當進程在用戶態下執行in或out指令時,控制單元執行下列操作:
-
它檢查eflags寄存器中的2位IOPL字段。如果該字段值爲3,控制單元就執行I/O指令。否則,執行下一個檢查。
-
訪問tr寄存器以確定當前的TSS和相應的I/O許可權位圖。
-
檢查I/O指令中指定的I/O端口在I/O許可權位圖中對應的位。如果該位清0,這條I/O指令就執行,否則控制單元產生一個”Generalprotetion”異常。
tss_struct結構描述TSS的格式。正如第二章(《深入理解Linux內核(第三版)》)所提到的,init_tss數組爲系統上每個不同的CPU存放一個TSS。在每次進程切換時,內核都更新TSS的某些字段以便相應的CPU控制單元可以安全地檢索到它需要的信息。因此,TSS反映了CPU上的當前進程的特權級,但不必爲沒有在運行的進程保留TSS。
每個TSS有它自己8字節的任務狀態段描述符。這個描述符包括指向TSS起始地址的32位Base字段,20位Limit字段。TSSD的S標誌被清0,以表示相應的TSS是系統段的事實。
Type字段置爲11或9以表示這個段實際上是TSS。在Intel的原始設計中,系統中的每個進程都應當指向自己的TSS;Type字段的第二個有效位叫做Busy位;如果進程正由CPU執行,則該位置爲1,否則置爲0。在Linux的設計中,每個CPU只有一個TSS,因此,Busy位總置爲1。
由linux創建的TSSD存放在全局描述符表中。GDT的基地址存放在每個CPU的gdtr寄存器中。每個CPU的tr寄存器包含相應TSS的TSSD選擇符,也包括了兩個隱藏了非編程字段;TSSD的Base字段和Limit字段。這樣,處理器就能直接對TSS尋址而不用從GDT中檢索TSS的地址。
Thread字段
在每次進程切換時,被替換進程的硬件上下文必須保存在別處。不能像Intel原始設計那樣把它保存在TSS中,因爲Linux爲每個處理器而不是爲每個進程使用TSS。
因此,每個進程描述符包含一個類型爲thread_struct的thread字段,只要進程被切換出去,內核就把其硬件上下文保存在這個結構中。隨後我們會看到,這個數據結構包含的字段涉及大部分CPU寄存器,但不包括諸如exa、ebx等等這些通用寄存器,它們的值保留在內核堆棧中。
執行進程切換
進程切換可能只發生在精心定義的點:schedule()函數(《深入理解Linux內核(第三版)》第七章有詳細討論)。這裏,我們僅關注內核如何執行一個進程切換。
從本質上說,每個進程切換由兩步組成:
-
切換頁全局目錄以安裝一個新的地址空間;將在第九章(《深入理解Linux內核(第三版)》)描述這一步。
-
切換內核態堆棧和硬件上下文,因爲硬件上下文提供了內核執行新進程所需要的所有信息,包含CPU寄存器。
我們又一次假定prev指向被替換進程的描述符,而next指向被激活進程的描述符。prev和next是schedule()函數的局部變量。
switch_to宏
進程切換的第二步由switch_to宏執行。它是內核中與硬件關係最密切的例程之一,要理解它到低做了些什麼我們必須下些功夫。
首先,該宏有三個參數,它們是prev,next和last。你可能很容易猜到prev和next的作用:它們僅是局部變量prev和next的佔位符,即它們是輸入參數,分別表示被替換進程和新進程描述符的地址在內存中的位置。
那第三個參數last呢?在任何進程切換中,涉及到三個進程而不是兩個。假設內核決定暫停進程A而激活里程B。在schedule()函數中,prev指向A的描述符而next指向B的描述符。switch_to宏一但使A暫停,A的執行流就凍結。
隨後,當內核想再次此激活A,就必須暫停另一個進程C,於是就要用prev指向C而next指向A來執行另一個swithch_to宏。當A恢復它的執行流時,就會找到它原來的內核棧,於是prev局部變量還是指向A的描述符而next指向B的描述符。此時,代表進程A執行的內核就失去了對C的任何引用。但是,事實表明這個引用對於完成進程切換是很有用的。
switch_to宏的最後一個參數是輸出參數,它表示宏把進程C的描述符地址寫在內存的什麼位置了。在進程切換之前,宏把第一個輸入參數prev表示的變量的內容存入CPU的eax寄存器。在完成進程切換,A已經恢復執行時,宏把CPU的eax寄存器的內容寫入由第三個輸出參數-------last所指示的A在內存中的位置。因爲CPU寄存器不會在切換點發生變化,所以C的描述符地址也存在內存的這個位置。在schedule()執行過程中,參數last指向A的局部變量prev,所以prev被C的地址覆蓋。
圖3-7顯示了進程A,B,C內核堆棧的內容以及eax寄存器的內容。必須注意的是:圖中顯示的是在被eax寄存器的內容覆蓋以前的prev局部變量的值。
由於switch_to宏採用擴展的內聯彙編語言編碼,所以可讀性比較差:實際上這段代碼通過特殊位置記數法使用寄存器,而實際使用的通用寄存器由編譯器自由選擇。我們將採用標準彙編語言而不是麻煩的內聯彙編語言來描述switch_to宏在80x86微處理器上所完成的典型工作。
-
在eax和edx寄存器中分別保存prev和next的值。
movl prev ,%eax
movl next ,%edx
-
把eflags和ebp寄存器的內容保存在prev內核棧中。必須保存它們的原因是編譯器認爲在switch_to結束之前它們的值應當保持不變。
Pushf1
push %ebp
-
把esp的內容保存到prev->thread.esp中以使該字段指向prev內核棧的棧頂:
movl %esp, 484(%eax)
-
把next->thread.esp裝入esp.此時,內核開始在next的內核棧上操作,因此這條指令實際上完成了從prev到next的切換。由於進程描述符的地址和內核棧的地址緊挨着,所以改變內核棧意味着改變進程。
movl 484(%edx),%esp
-
把標記爲1的地址存入prev->thread.eip。當被替換的進程重新恢復執行時,進程執行被標記爲1的那條指令:
movl $lf, 480(%eax)
-
宏把next->thread.eip的值壓入next的內核棧。
Push1 480(%edx)
-
跳到__switch_to() C函數
jmp__switch_to
-
這裏被進程B替換的進程A再次獲得CPU;它執行一些保存eflags和ebp寄存器內容的指令,這兩條指令的第一條指令被標記爲1。
-
拷貝eax寄存器的內容到switch_to宏的第三個參數lash標識的內存區域中:
movl %eax, last
正如以前討論的,eax寄存器指向剛被替換的進程描述符。
__switch_to()函數
__switch_to()函數執行大多數開始於switch_to()宏的進程切換。這個函數作用於prev_p和next_p參數,這兩個參數表示前一個進程和新進程。這個函數的調用不同於一般函數的調用,因爲__switch_to()從eax和edx取參數prev_p和next_p,而不像大多數函數一樣從棧中取參數。爲了強迫函數從寄存器取它的參數,內核利用__attribute__和regparm關鍵字,這兩個關鍵字是C語言非標準的擴展名,由gcc編譯程序實現。在include/asm-i386/system.h頭文件中,__switch_to()函數的聲明如下:
__switch_to(structtask_struct *prev_p,struct tast_struct *next_p)__attribute_(regparm(2));
函數執行的步驟如下:
-
執行由__unlazy_fpu()宏產生的代碼,以有選擇地保存prev_p進程的FPU、MMX及XMM寄存器的內容。
__unlazy_fpu(prev_p);
-
執行smp_processor_id()宏獲得本地(local)CPU的下標,即執行代碼的CPU。該宏從當前進程的thread_info結構的cpu字段獲得下標將它保存到cpu局部變量。
-
把next_p->thread.esp0裝入對應於本地CPU的TSS的esp0字段;將在通過sysenter指令發生系統調用一節看到,以後任何由sysenter彙編指令產生的從用戶態到內核態的特權級轉換將把這個地址拷貝到esp寄存器中:
init_tss[cpu].esp0= next_p->thread.esp0;
-
把next_p進程使用的線程局部存儲段裝入本地CPU的全局描述符表;三個段選擇符保存在進程描述符內的tls_array數組中
cpu_gdt_table[cpu][6]= next_p->thread.tls_array[0];
cpu_gdt_table[cpu][7]= next_p->thread.tls_array[1];
cpu_gdt_table[cpu][8]= next_p->thread.tls_array[2];
-
把fs和gs段寄存器的內容分別存放在prev_p->thread.fs和prev_p->thread.gs中,對應的彙編語言指令是:
movl%fs,40(%esi)
movl%gs,44(%esi)
-
如果fs或gs段寄存器已經被prev_p或next_p進程中的任意一個使用,則將next_p進程的thread_struct描述符中保存的值裝入這些寄存器中。這一步在邏輯上補充了前一步中執行的操作。主要的彙編語言指令如下:
movl40(%ebx),%fs
movl44(%edb),%gs
ebx寄存器指向next_p->thread結構。代碼實際上更復雜,因爲當它檢測到一個無效的段寄存器值時,CPU可能產生一個異常。
-
用next_p->thread.debugreg數組的內容裝載dr0,...,dr7中的6個調試寄存器。只有在next_p被掛起時正在使用調試寄存器,這種操作才能進行。這些寄存器不需要被保存,因爲只有當一個調試器想要監控prev時prev_p->thread.debugreg纔會修改。
if(next_p->thread.debugreg[7]){
loaddebug(&next_p->thread,0);
loaddebug(&next_p->thread,1);
loaddebug(&next_p->thread,2);
loaddebug(&next_p->thread,3);
loaddebug(&next_p->thread,6);
loaddebug(&next_p->thread,7);
-
如果必要,更新TSS中的I/O位圖。當next_p或prev_p有其自己的定製I/O權限位圖時必須這麼做:
if(prev_p->thread.io_bitmap_ptr|| next_p->thread.io_bitmap_ptr )
handle_io_bitmap(&next_p->thread,&init_tss[cpu]);
因爲進程很修改I/O權限位圖,所以該位圖在“懶”模式中被處理;當且僅當一個進程在當前時間片內實際訪問I/O端口時,真實位圖才被拷貝到本地CPU的TSS中。進程的定製I/O權限位圖被保存在thread_info結構的io_bitmap_ptr字段指向的緩衝區中。handle_io_bitmap()函數爲next_p進程設置本地CPU使用的TSS的in_bitmap字段如下:
(a)如果next_p進程不擁有自己的I/O權限位圖,則TSS的io_bitmap字段被設爲0x8000.
(b) 如果next_p進程擁有自己的I/O權限位圖,則TSS的io_bitmap字段被設爲0x9000。
TSS的io_bitmap字段應當包含一個在TSS中的偏移量,其中存放實際位圖。無論何時用戶態進程試圖訪問一個I/O端口,0x8000和0x9000指向TSS界限之外並將因此引起”Generalprotection”異常。do_general_protection()異常處理程序將檢查保存在io_bitmap字段的值:如果是0x8000,函數發送一個SIGSEGV信號給用戶態進程;如果是0x9000,函數把進程位圖拷貝拷貝到本地CPU的TSS中,把io_bitmap字段爲實際位圖的偏移(104),並強制再一次執行有缺陷的彙編指令。
-
終止。__switch_to()C函數通過使用下列聲明結束:
returnprev_p;
由編譯器產生的相應彙編語言指令是:
movl%edl,%eax
ret
prev_p參數被拷貝到eax,因爲缺省情況下任何C函數的返回值被傳遞給eax寄存器。注意eax的值因此在調用__switch_to()的過程中被保護起來;這非常重要,因爲調用switch_to宏時會假定eax總是用來存放被替換的進程描述符的地址。
彙編語言指令ret把棧頂保存的返回地址裝入eip程序計數器。不過,通過簡單地跳轉到__switch_to()函數來調用該函數。因此,ret彙編指令在棧中找到標號爲1的指令的地址,其中標號爲1的地址是由switch_to()宏推入棧中的。如果因爲next_p第一次執行而以前從未被掛起,__switch_to()就找到ret_from_fork()函數的起始地址。