1 進程切換之前的工作
由於每個進程共用CPU寄存器(咱們就當是單CPU吧),因此在恢複一個進程之前,核心必須確保每個寄存器裝入掛起進程時的值,他們就叫“硬體上下文”。在Linux中,進程硬體上下文得一部分放在TSS段,主要用於對核心態堆棧進行定址,而剩餘部分放在核心態堆棧中。
我們再回憶一下硬體中TSS段的內容:
struct tss_struct {
unsigned short back_link,__blh;
unsigned long esp0; /*當前進程的核心棧棧頂地址位移 */
unsigned short ss0,__ss0h; /* 核心棧段描述符值,系統初始化後整個運行期間不被改變 */
unsigned long esp1;
unsigned short ss1,__ss1h; /* ss1 is used to cache MSR_IA32_SYSENTER_CS */
unsigned long esp2;
unsigned short ss2,__ss2h;
unsigned long __cr3;
unsigned long eip;
unsigned long eflags;
unsigned long eax,ecx,edx,ebx;
unsigned long esp; /*當前進程使用者態堆棧棧頂地址位移 */
unsigned long ebp;
unsigned long esi;
unsigned long edi;
unsigned short es, __esh;
unsigned short cs, __csh;
unsigned short ss, __ssh;
unsigned short ds, __dsh;
unsigned short fs, __fsh;
unsigned short gs, __gsh;
unsigned short ldt, __ldth;
unsigned short trace, io_bitmap_base;
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
unsigned long io_bitmap_max;
struct thread_struct *io_bitmap_owner;
unsigned long __cacheline_filler[35];
unsigned long stack[64];
} __attribute__((packed));
為了方便起見,我們設prev為切換出的進程描述符,next為切換進得進程描述符,那麼進程切換則定義為:儲存prev硬體上下文,用next硬體上下文代替(我們在後面的博文會看到prev和next是調度函數schedule()的局部變數)。核心2.6以前是直接利用80x86的far jmp指令跳到next進程的TSS描述符的選擇符來執行進程切換,而2.6則使用軟體執行進程切換。
進程切換隻發生在核心態。在執行切換之前,即通過中斷的方式執行系統調用由使用者態進入核心態時,linux從使用者態轉移到核心態有三個途徑,系統調用,中斷,異常,對應的代碼在/arch/i386/kernel/entry.S中。使用者進程使用的所有寄存器內容都已被SAVE_ALL彙編指令壓入核心態堆棧:
#define SAVE_ALL /
cld; /
pushl %es; /
pushl %ds; /
pushl %eax; /
pushl %ebp; /
pushl %edi; /
pushl %esi; /
pushl %edx; /
pushl %ecx; /
pushl %ebx; /
movl $(__USER_DS), %edx; /
movl %edx, %ds; /
movl %edx, %es;
注意倒數三行這裡一個問題,為啥進入核心態後,卻要把使用者態的資料區段選擇符__USER_DS裝載到ds和es寄存器中呢?這豈不是胡鬧嗎?回憶一下,linux之所以設定什麼ds,cs段選擇符也是例行公事,完全沒有必要,要明白為什麼這裡將ds設定成__USER_DS,還要先回憶ds是幹什麼用的,ds是資料區段寄存器,cs是程式碼片段寄存器,ds的保護作用是:如果你訪問的段,要求的存取層級高於當前進程的程式碼片段層級的話,訪問會導致GP異常。現在進入了核心,當前程式碼片段的存取層級為0,處於最進階別,它訪問任何段都不會出錯,再者,linux為了減少複雜性,只用了2個層級,那麼就沒有任何問題了,如果要像intel規定的那樣將ds寄存器設定成 __KERNEL_DS,那麼看看從核心返回的時候:恢複CS,EIP的值,此時CS的CPL是3。如果DS、ES被設為了__KERNEL_DS,其DPL是0,則要將DS,ES中的值清除。這樣就要多一個清除動作,損失了效率,因此就先將ds段寄存器設定成__USER_DS,這麼做的一切都是intel的什麼狗屁分段機製造成的,汗!
另外,SAVE_ALL中沒有對eflags、cs、eip、ss和esp這些使用者態指令、堆棧指標地址的寄存器內容儲存到棧中,這又是為什麼呢?我們再回憶一下Linux核心入門(五)——必要的硬體知識博文中,當執行了一條指令後,CS和eip這對寄存器包含下一條將要執行的指令的邏輯地址。在處理那條指令之前,控制單元會檢查在運行前一條指令時是否已經發生了一個中斷或異常。如果發生了一個中斷或異常(這裡是系統調用,相當於中斷),那麼控制單元將檢查是否發生了特權級的變化。如果是(這裡肯定是,因為是由使用者程式碼片段向核心程式碼片段定址),控制單元必須開始使用與新的特權級相關的指令段和棧。這個過程包括從TSS段中裝載ss0和esp0寄存器,在新的核心態堆棧中儲存使用者態的ss和esp的值,用引起異常的指令地址裝載CS和eip寄存器,在棧中儲存eflags、CS及eip的內容。
這裡再次強調一下,每個進程都有一個thread_struct結構的欄位thread,用於保留進程的一部分硬體上下文:
struct thread_struct {
struct desc_struct tls_array[GDT_ENTRY_TLS_ENTRIES];
unsigned long esp0;
unsigned long sysenter_cs;
unsigned long eip;
unsigned long esp;
unsigned long fs;
unsigned long gs;
unsigned long debugreg[8];
unsigned long cr2, trap_no, error_code;
union i387_union i387;
struct vm86_struct __user * vm86_info;
unsigned long screen_bitmap;
unsigned long v86flags, v86mask, saved_esp0;
unsigned int saved_fs, saved_gs;
unsigned long *io_bitmap_ptr;
unsigned long io_bitmap_max;
};
待會我們就將看到,其實裡邊最有用的就是eip和esp兩個欄位,分別表示儲存的指令和堆棧棧頂的位移地址。但這裡不包括ss寄存器的值,因為所有的切換都在核心態,那麼只用為核心態的堆棧基址ss0儲存一次就行了,你們這些進程切來切去,我都不便應萬變,把它儲存在每個CPU所對應的那個TSS段中。這裡也得出了一個很重要的結論,為了提高效率,所有核心態的進程堆棧段基地址都是相同的。而堆棧,由於其具有後進先出的特點,其作用也主要是用於儲存寄存器地址以及代碼的臨時內部變數。所以,我們就為每個進程儲存esp0和esp就行了(esp0表示0級、核心級堆棧棧頂;esp表示使用者級堆棧棧頂)。這就是為什麼Linux核心的SS0始終為__KERNEL_DS保持不變,無需每次切換都將其儲存到init_tss.ss0中的原因了,只需要在Linux核心初始化時將init_tss.ss0設定為__KERNEL_DS就可以了。如果要回到使用者態,再從堆棧中得到ss和esp寄存器的值,就OK了。
#define INIT_TSS { /
.esp0 = sizeof(init_stack) + (long)&init_stack, /
.ss0 = __KERNEL_DS, /
.ss1 = __KERNEL_CS, /
.io_bitmap_base = INVALID_IO_BITMAP_OFFSET, /
.io_bitmap = { [ 0 ... IO_BITMAP_LONGS] = ~0 }, /
}
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS * 8)
#define GDT_ENTRY_KERNEL_DS (GDT_ENTRY_KERNEL_BASE + 1)
#define GDT_ENTRY_KERNEL_BASE 12
所以,預備知識中的必要的硬體知識很重要,希望大家在全面的分析核心之前,一定要把那裡面的知識搞透!
進程切換一般發生在發送器schedule()函數執行之後,這個函數很重要,也很複雜,所以在這裡我們只討論核心如何執行一個進程切換。至於發送器的其他細節,後面的博文我們會詳細討論。
簡單地說,每個進程切換由兩步組成:
1. 切換頁全域目錄以安裝一個新的地址空間;
2. 切換核心態堆棧和硬體上下文,因為硬體上下文提供了核心執行新進程所需要得所以資訊,包含CPU寄存器。
2 真正的進程切換實務 —— switch_to宏
進程切換的實務工作由switch_to宏執行。它是核心中與硬體關係最密切的常式之一,要理解它到底做來些什麼我們必須下些功夫:
#define switch_to(prev,next,last) do { /
unsigned long esi,edi; /
asm volatile("pushfl/n/t" /* Save flags */ /
"pushl %%ebp/n/t" /
"movl %%esp,%0/n/t" /* save ESP */ /
"movl %5,%%esp/n/t" /* restore ESP */ /
"movl $1f,%1/n/t" /* save EIP */ /
"pushl %6/n/t" /* restore EIP */ /
"jmp __switch_to/n" /
"1:/t" /
"popl %%ebp/n/t" /
"popfl" /
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), /
"=a" (last),"=S" (esi),"=D" (edi) /
:"m" (next->thread.esp),"m" (next->thread.eip), /
"2" (prev), "d" (next)); /
} while (0)
首先switch_to宏有三個參數:prev、next和last。前兩個參數大家肯定都猜到了,不錯,是替換進程和新進程的task_struct。那麼第三個參數呢?其實在任何進程切換中,涉及到的是三個進程而不是兩個。假設核心決定暫停A和啟用B進程,在調度中,prev和next分別指向A和B的進程描述符。switch_to宏一旦使A暫停,A的執行就凍結了。隨後,當核心想再次啟用A,就必須暫停另一個進程C。於是就要用prev指向C而next指向A來指向另一個switch_to宏。當A恢複它的執行時,就會找它原來的核心棧,其局部變數prev又指向A而next指向B,C就斷了。switch_to宏的最後一個參數是輸出參數,它表示C的描述符地址在A恢複執行後的記憶體位置。在進程切換之前,宏把第一個輸入參數prev(即在A的核心棧中分配的prev局部變數)表示的變數的內容存入eax寄存器。在完成進程切換後,A已恢複執行時,宏把CPU的eax寄存器的內容寫入第三個參數last所指示的A在記憶體中的位置。因為CPU寄存器不會在切換點發生變化,所以C的描述符地址也存在記憶體的找個位置。在schedule()執行過程中,參數last指向A的局部變數prev,所以prev被C的地址所覆蓋。
實在看暈了就仔細琢磨琢磨下面的圖例:
如果還是看不懂,就可以別去理它了,因為最新的核心中last參數已經不起作用了,留著只是與以前相容。
下面,我們就來詳細分析switch_to宏的具體步驟:
1. 把eflags和ebp寄存器內容壓入prev所對應的核心棧棧頂:
pushfl
pushl %%ebp
2. 把esp的內容儲存到prev->thread.esp中以使該欄位指向prev核心棧的棧頂:
movl %5,%%esp
484(%eax)運算元表示記憶體單元的地址為eax內容加上484。
3. 把next->thread.esp裝入esp。此時,核心開始在next的核心棧上操作,因此這條指令實際上完成了從prev向next的切換:
movl next->thread.esp, %%esp
4. 向prev->thread.eip存入標記為1的地址。當被替換的進程重新恢複執行時,進程執行我們下面標記為1的那條指令:
movl $1f, prev->thread.eip
5. 宏把next->thread.eip的值(絕大多數情況下是上面所述標記為1的地址)壓入next的核心棧:
pushl next->thread.eip
注意體會,當next執行完了以後的函數後,會回到這個棧的位置,執行eip對應的那條指令。
6. 跳到__switch_to()函數:
jmp __switch_to
7. 如幹程式執行後,當A將再次獲得CPU時,它執行一些儲存eflags和ebp寄存器內容內容的指令,這兩條指令的第一條指令被標記為1:
1:
popl %ebp
popfl
主意,這些pop指令是怎樣引用prev進程的核心棧的。當進程發送器再次選擇prev作為新進程在CPU上運行時,將執行這些指令。於是,以prev作為第二個參數調用switch_to宏。因此,esp寄存器指向prev的核心棧。
3 __switch_to函數
__switch_to()函數執行大多數switch_to()宏的進程切換。這個函數作用於prev_p和next_p參數,這兩個參數表示前一個進程和新進程。這個函數的調用不同於一般函數,因為__switch_to()從eax和edx取參數prev_p和next_p,而不像大多數函數那樣從棧中取參數。為了強迫函數從寄存器取出它的參數,核心利用__attribute__和regparm關鍵字,這兩個關鍵字是C語言非標準的副檔名,由gcc編譯器實現。__switch_to()函數的內容如下:
struct task_struct fastcall * __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread,
*next = &next_p->thread;
int cpu = smp_processor_id();
struct tss_struct *tss = &per_cpu(init_tss, cpu);
__unlazy_fpu(prev_p);
if (next_p->mm)
load_user_cs_desc(cpu, next_p->mm);
load_esp0(tss, next);
savesegment(fs, prev->fs);
savesegment(gs, prev->gs);
load_TLS(next, cpu);
if (unlikely(prev->fs | next->fs))
loadsegment(fs, next->fs);
if (prev->gs | next->gs)
loadsegment(gs, next->gs);
if (unlikely(prev->iopl != next->iopl))
set_iopl_mask(next->iopl);
if (unlikely((task_thread_info(next_p)->flags & _TIF_WORK_CTXSW)
|| test_tsk_thread_flag(prev_p, TIF_IO_BITMAP)))
__switch_to_xtra(next_p, tss);
disable_tsc(prev_p, next_p);
return prev_p;
}
函數執行以下步驟:
1. 執行由__unlazy_fpu()宏產生的代碼,以有選擇地儲存prev_p進程的FPU、MMX及XMM寄存器內容:__unlazy_fpu(prev_p)
2. 執行smp_processor_id()宏獲得本地(local)CPU的下標,即執行代碼的CPU。該宏從當前進程的thread_info結構中的cpu欄位獲得下標並將它儲存到cpu局部變數。
3. 把next_p->thread.esp0裝入對應於本地CPU的TSS的esp0欄位(load_esp0(tss, next));我們將在“通過sysenter指令發生系統調用”博文看到,以後任何由sysenter彙編指令產生從使用者態到核心態的特權級轉換將把這個地址拷貝到TSS段中esp寄存器對應的那個欄位:
init_tss[cpu].esp0 = next_p->thread.esp0;
4. 把next_p進程使用的線程儲存TLS段裝入本地CPU的通用描述元表;三個段選擇符儲存在進程描述符的tls_array數組中(load_TLS(next, cpu)):
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];
5. 把fs或gs段寄存器的內容分別存放在prev_p->thread.fs和prev_p->thread.gs中,對應的組合語言指令是:
movl %fs, 40(%esi)
movl %gs, 44(%esi)
esi寄存器指向prey_p->thread結構。
6. 如果fs或gs段寄存器已經被prev_p或next_p進程中的任意一個使用(也就是說如果它們有一個非0值),則將next_p進程的thread_struct描述符中儲存的值裝入這些寄存器中。這一步在邏輯上補充了前一步中執行的操作。主要的組合語言指令如下:
movl 40(%ebx), %fs
movl 44(%ebx), %fs
ebx寄存器指向next_p->thread結構。代碼實際上更複雜,因為當它檢測到一個無效的段寄存器值時,CPU可能產生一個異常。代碼採用一種“修正(fix-up)”途徑來考慮這種可能性。
7. 用next_p->thread.debugreg數組的動態內容裝載dr0,…,dr7中的6個調試寄存器。只有在next_p被掛起時正在使用調試寄存器(也就是說,next_p->thread.debugreg[7]欄位不為0),這種操作才能進行。這些寄存器不需要被儲存,因為只有當一個調試器想要監控prev時prey_p->thread.debugreg才會被修改:
if (unlikely(next->debugreg[7])) {
loaddebug(next, 0);
loaddebug(next, 1);
loaddebug(next, 2);
loaddebug(next, 3);
/* no 4 and 5 */
loaddebug(next, 6);
loaddebug(next, 7);
}
8. 如果有必要,更新TSS中的I/O位元影像。當prev_p或next_p有其自己的定製I/O許可權位元影像時必須這麼做:
if (unlikely(prev->io_bitmap_ptr || next->io_bitmap_ptr))
handle_io_bitmap(next, tss);
因為進程很少修改I/O許可權位元影像,所以該位元影像在“懶”模式中被處理:若且唯若一個進程在目前時間片內實際訪問I/O連接埠時,真實位元影像才被拷貝到本地CPU的TSS中。進程的定製I/O許可權位元影像被儲存在thread_info結構的io_bitmap_ptr欄位指向的緩衝區中。handle_io_bitmap()函數為next_p進程設定本地CPU使用的TSS的io_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界限之外並將因此引起“General protection”異常(參見第四章的“異常”一節)。do_general_protection()例外處理常式將檢查儲存在io_bitmap欄位的值:如果是0x8000,函數發送一個SIGSEGV訊號給使用者態進程;如果是0x9000,函數把進程位元影像(由thread_info結構中的io_bitmap_ptr欄位指示)拷貝到本地CPU的TSS中,把io_bitmap欄位設為實際位元影像的位移(104),並強制再一次執行有缺陷的組合語言指令。
9. 終止。__switch_to() C函數通過使用下列聲明結束:
return prev_p;
由編譯器產生的相應組合語言指令是:
movl %edi,%eax
ret
prev_p參數(在edi中)被拷貝到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()函數的起始地址(參見後面“fork()和vfork()系統區別”博文)。