OS實驗五 基於內核棧切換的進程切換

背景

一個任務既可以是一個進程,也可以是一個線程。簡而言之,它指的是一系列共同達到某一目的的操作。
操作系統任務切換過程有兩種,一種是基於TSS(任務狀態段)的切換,這種切換需要使用一個長跳轉指令,需要很多的mov,而且不能進行指令流水(分解成微指令),造成執行起來很慢。執行過程如下:使用TR(描述符表寄存器)找到GDT(全局描述符表)中的新tss描述符,之後使用描述符切換到新的tss。
另外一種是基於krlstack的切換。
通過堆棧實現任務切換可能要更快,而且採用堆棧的切換還可以使用指令流水的並行優化技術,同時又使得 CPU 的設計變得簡單。所以無論是 Linux 還是 Windows,進程/線程的切換都沒有使用 Intel 提供的這種 TSS 切換手段,而都是通過堆棧實現的。
本次實踐項目就是將 Linux 0.11 中採用的 TSS 切換部分去掉,取而代之的是基於堆棧的切換程序。具體的說,就是將 Linux 0.11 中的 switch_to 實現去掉,寫成一段基於堆棧切換的代碼。

實驗報告

問題1

movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)

(1)爲什麼要加 4096?

因爲頁表結構是這樣的:不要誤會了,頁表大小4KB
在這裏插入圖片描述
(2)爲什麼沒有設置ss0?

SS0、SS1和SS2分別是0、1和2特權級的棧段選擇子。
這裏用不着特權級爲0的內核段。
這個可以在《註釋》或者《x86彙編語言 從實模式到保護模式》裏面找到,課裏面好像沒講啊。

問題2

*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;

(1)子進程第一次執行時,eax=?爲什麼要等於這個數?哪裏的工作讓 eax 等於這樣一個數?

這個eax,根據課程裏面講的內容是p_id,所以子進程eax=0;當使用copy_process()創建子進程的時候賦值的。

(2)這段代碼中的 ebx 和 ecx 來自哪裏,是什麼含義,爲什麼要通過這些代碼將其寫到子進程的內核棧中?

父子的內核棧在初始化的時候完全一致,用戶棧指向一個地方。通過copy_process()拷貝了參數。

(3)這段代碼中的 ebp 來自哪裏,是什麼含義,爲什麼要做這樣的設置?可以不設置嗎?爲什麼?

ebp是用戶棧地址,不設置就不能運行了。

問題3

爲什麼要在切換完 LDT 之後要重新設置 fs=0x17?而且爲什麼重設操作要出現在切換完 LDT 之後,出現在 LDT 之前又會怎麼樣?

cpu的段寄存器都存在兩類值,一類是顯式設置段描述符,另一類是隱式設置的段屬性及段限長等值,這些值必須經由movl、lldt、lgdt等操作進行設置,而在設置了ldt後,要將fs顯示設置一次才能保證段屬性等值正確。

TSS 切換

在現在的 Linux 0.11 中,真正完成進程切換是依靠任務狀態段(Task State Segment,簡稱 TSS)的切換來完成的。

具體的說,在設計“Intel 架構”(即 x86 系統結構)時,每個任務(進程或線程)都對應一個獨立的 TSS,TSS 就是內存中的一個結構體,裏面包含了幾乎所有的 CPU 寄存器的映像。有一個任務寄存器(Task Register,簡稱 TR)指向當前進程對應的 TSS 結構體,所謂的 TSS 切換就將 CPU 中幾乎所有的寄存器都複製到 TR 指向的那個 TSS 結構體中保存起來,同時找到一個目標 TSS,即要切換到的下一個進程對應的 TSS,將其中存放的寄存器映像“扣在” CPU 上,就完成了執行現場的切換,如下圖所示。在這裏插入圖片描述
(1)首先用 TR 中存取的段選擇符在 GDT 表中找到當前 TSS 的內存位置,由於 TSS 是一個段,所以需要用段表中的一個描述符來表示這個段,和在系統啓動時論述的內核代碼段是一樣的,那個段用 GDT 中的某個表項來描述,還記得是哪項嗎?是 8 對應的第 1 項。此處的 TSS 也是用 GDT 中的某個表項描述,而 TR 寄存器是用來表示這個段用 GDT 表中的哪一項來描述,所以 TR 和 CS、DS 等寄存器的功能是完全類似的。

(2)找到了當前的 TSS 段(就是一段內存區域)以後,將 CPU 中的寄存器映像存放到這段內存區域中,即拍了一個快照。

(3)存放了當前進程的執行現場以後,接下來要找到目標進程的現場,並將其扣在 CPU 上,找目標 TSS 段的方法也是一樣的,因爲找段都要從一個描述符表中找,描述 TSS 的描述符放在 GDT 表中,所以找目標 TSS 段也要靠 GDT 表,當然只要給出目標 TSS 段對應的描述符在 GDT 表中存放的位置——段選擇子就可以了,仔細想想系統啓動時那條著名的 jmpi 0, 8 指令,這個段選擇子就放在 ljmp 的參數中,實際上就 jmpi 0, 8 中的 8。

(4)一旦將目標 TSS 中的全部寄存器映像扣在 CPU 上,就相當於切換到了目標進程的執行現場了,因爲那裏有目標進程停下時的 CS:EIP,所以此時就開始從目標進程停下時的那個 CS:EIP 處開始執行,現在目標進程就變成了當前進程,所以 TR 需要修改爲目標 TSS 段在 GDT 表中的段描述符所在的位置,因爲 TR 總是指向當前 TSS 段的段描述符所在的位置。

上面給出的這些工作都是一句長跳轉指令 ljmp 段選擇子:段內偏移,在段選擇子指向的段描述符是 TSS 段時 CPU 解釋執行的結果,所以基於 TSS 進行進程/線程切換的 switch_to 實際上就是一句 ljmp 指令:

#define switch_to(n) {
    struct{long a,b;} tmp;
    __asm__(
        "movw %%dx,%1"
        "ljmp %0" ::"m"(*&tmp.a), "m"(*&tmp.b), "d"(TSS(n)
    )
 }

#define FIRST_TSS_ENTRY 4

#define TSS(n) (((unsigned long) n) << 4) + (FIRST_TSS_ENTRY << 3))

GDT 表的結構如下圖所示,所以第一個 TSS 表項,即 0 號進程的 TSS 表項在第 4 個位置上,4<<3,即4 * 8,相當於 TSS 在 GDT 表中開始的位置,TSS(n)找到的是進程 n 的 TSS 位置,所以還要再加上 n<<4,即 n * 16,因爲每個進程對應有 1 個 TSS 和 1 個 LDT,每個描述符的長度都是 8 個字節,所以是乘以 16,其中 LDT 的作用就是上面論述的那個映射表,關於這個表的詳細論述要等到內存管理一章。TSS(n) = n * 16 + 4 * 8,得到就是進程 n(切換到的目標進程)的 TSS 選擇子,將這個值放到 dx 寄存器中,並且又放置到結構體 tmp 中 32 位長整數 b 的前 16 位,現在 64 位 tmp 中的內容是前 32 位爲空,這個 32 位數字是段內偏移,就是 jmpi 0, 8 中的 0;接下來的 16 位是 n * 16 + 4 * 8,這個數字是段選擇子,就是 jmpi 0, 8 中的 8,再接下來的 16 位也爲空。所以 swith_to 的核心實際上就是 ljmp 空, n*16+4*8,現在和前面給出的基於 TSS 的進程切換聯繫在一起了。
在這裏插入圖片描述

本次實驗的內容

雖然用一條指令就能完成任務切換,但這指令的執行時間卻很長,這條 ljmp 指令在實現任務切換時大概需要 200 多個時鐘週期。而通過堆棧實現任務切換可能要更快,而且採用堆棧的切換還可以使用指令流水的並行優化技術,同時又使得 CPU 的設計變得簡單。所以無論是 Linux 還是 Windows,進程/線程的切換都沒有使用 Intel 提供的這種 TSS 切換手段,而都是通過堆棧實現的。

本次實踐項目就是將 Linux 0.11 中採用的 TSS 切換部分去掉,取而代之的是基於堆棧的切換程序。具體的說,就是將 Linux 0.11 中的 switch_to 實現去掉,寫成一段基於堆棧切換的代碼。

在現在的 Linux 0.11 中,真正完成進程切換是依靠任務狀態段(Task State Segment,簡稱 TSS)的切換來完成的。具體的說,在設計“Intel 架構”(即 x86 系統結構)時,每個任務(進程或線程)都對應一個獨立的 TSS,TSS 就是內存中的一個結構體,裏面包含了幾乎所有的 CPU 寄存器的映像。有一個任務寄存器(Task Register,簡稱 TR)指向當前進程對應的 TSS 結構體,所謂的 TSS 切換就將 CPU 中幾乎所有的寄存器都複製到 TR 指向的那個 TSS 結構體中保存起來,同時找到一個目標 TSS,即要切換到的下一個進程對應的 TSS,將其中存放的寄存器映像“扣在”CPU 上,就完成了執行現場的切換。

要實現基於內核棧的任務切換,主要完成如下三件工作:

(1)重寫 switch_to;
(2)將重寫的 switch_to 和 schedule() 函數接在一起;
(3)修改現在的 fork()。

schedule 與 switch_to

目前 Linux 0.11 中工作的 schedule() 函數是首先找到下一個進程的數組位置 next,而這個 next 就是 GDT 中的 n,所以這個 next 是用來找到切換後目標 TSS 段的段描述符的,一旦獲得了這個 next 值,直接調用上面剖析的那個宏展開 switch_to(next);就能完成如圖 TSS 切換所示的切換了。

現在,我們不用 TSS 進行切換,而是採用切換內核棧的方式來完成進程切換,所以在新的 switch_to 中將用到當前進程的 PCB、目標進程的 PCB、當前進程的內核棧、目標進程的內核棧等信息。由於 Linux 0.11 進程的內核棧和該進程的 PCB 在同一頁內存上(一塊 4KB 大小的內存),其中 PCB 位於這頁內存的低地址,棧位於這頁內存的高地址;另外,由於當前進程的 PCB 是用一個全局變量 current 指向的,所以只要告訴新 switch_to()函數一個指向目標進程 PCB 的指針就可以了。同時還要將 next 也傳遞進去,雖然 TSS(next)不再需要了,但是 LDT(next)仍然是需要的,也就是說,現在每個進程不用有自己的 TSS 了,因爲已經不採用 TSS 進程切換了,但是每個進程需要有自己的 LDT,地址分離地址還是必須要有的,而進程切換必然要涉及到 LDT 的切換。

綜上所述,需要將目前的 schedule() 函數(在 kernal/sched.c 中)做稍許修改,即將下面的代碼:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    c = (*p)->counter, next = i;

//......

switch_to(next);
copy

修改爲:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    c = (*p)->counter, next = i, pnext = *p;

//.......

switch_to(pnext, LDT(next));

實現 switch_to

刪除頭文件sched.h中的長跳轉指令:"ljmp *%0\n\t" \

system_call.s中添加系統調用函數switch_to()

.align 2
switch_to:
	pushl %ebp
	movl %esp,%ebp
	pushl %ecx
	pushl %ebc
	pushl %eax

	movl 8(%ebp),%ebx
	cmpl %ebx,current
	je 1f

 // PCB的切換
	movl %ebx,%eax
	xchgl %eax,current
	
	// TSS中內核棧指針的重寫
	movl tss,%ecx
	addl $4096,%ebx
	movl %ebx,ESP0(%ecx)

	//切換內核棧
	movl %esp,KERNEL_STACK(%eax)
	movl 8(%ebp),%ebx
	movl KERNEL_STACK(%ebx),%esp

	//LDT的切換
	movl 12(%ebp),%ecx
	lldt %cx
	movl $0x17,%ecx
	mov %cx,%fs
	
	movl $0x17,%ecx
	mov %cx,%fs
	cmpl %eax,last_task_used_math
	jne 1f
	clts

1:	popl %eax
	popl %ebx
	popl %ecx
	popl %ebp
	ret

fs 是一個選擇子,即 fs 是一個指向描述符表項的指針,這個描述符纔是指向實際的用戶態內存的指針,所以上一個進程和下一個進程的 fs 實際上都是 0x17,真正找到不同的用戶態內存是因爲兩個進程查的 LDT 表不一樣,所以這樣重置一下 fs=0x17 有用嗎,有什麼用?要回答這個問題就需要對段寄存器有更深刻的認識,實際上段寄存器包含兩個部分:顯式部分和隱式部分,如下圖給出實例所示,就是那個著名的 jmpi 0, 8,雖然我們的指令是讓 cs=8,但在執行這條指令時,會在段表(GDT)中找到 8 對應的那個描述符表項,取出基地址和段限長,除了完成和 eip 的累加算出 PC 以外,還會將取出的基地址和段限長放在 cs 的隱藏部分,即圖中的基地址 0 和段限長 7FF。爲什麼要這樣做?下次執行 jmp 100 時,由於 cs 沒有改過,仍然是 8,所以可以不再去查 GDT 表,而是直接用其隱藏部分中的基地址 0 和 100 累加直接得到 PC,增加了執行指令的效率。

更改fork.c

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
		long ebx,long ecx,long edx,
		long fs,long es,long ds,
		long eip,long cs,long eflags,long esp,long ss)
{
	struct task_struct *p;
	int i;
	struct file *f;


	p->tss.back_link = 0;
	p->tss.esp0 = PAGE_SIZE + (long) p;
	p->tss.ss0 = 0x10;


	*(--krnstack) = ss & 0xffff;
	*(--krnstack) = esp;
	*(--krnstack) = eflags;
	*(--krnstack) = cs & 0xffff;
	*(--krnstack) = eip;

	*(--krnstack) = (long) first_return_kernel;//處理switch_to返回的位置

	*(--krnstack) = ebp;
	*(--krnstack) = ecx;
	*(--krnstack) = ebx;
	*(--krnstack) = 0;

	//把switch_to中要的東西存進去
	p->kernelstack = krnstack;
	...


first_return_kernelsystem_call.s中:

首先需要將first_return_kernel設置在全局可見:
.globl switch_to,first_return_kernel

然後需要在fork.c中添加該函數的聲明:
extern void first_return_from_kernel(void);

最後就是將具體的函數實現放在system_call.s頭文件裏面:
first_return_kernel:
 popl %edx
 popl %edi
 popl %esi
 pop %gs
 pop %fs
 pop %es
 pop %ds
 iret
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章