內核棧和用戶棧,哈工大-基于內核棧切換的進程切換

 2023-11-09 阅读 22 评论 0

摘要:1. 課程說明 難度系數:★★★★☆ 本實驗是?操作系統之進程與線程 - 網易云課堂?的配套實驗,推薦大家進行實驗之前先學習相關課程: L10 用戶級線程L11 內核級線程L12 核心級線程實現實例L13 操作系統的那棵樹 Tips:點擊上方文字中的超鏈接或者輸入

1. 課程說明

難度系數:★★★★☆

本實驗是?操作系統之進程與線程 - 網易云課堂?的配套實驗,推薦大家進行實驗之前先學習相關課程:

  • L10 用戶級線程
  • L11 內核級線程
  • L12 核心級線程實現實例
  • L13 操作系統的那棵樹

Tips:點擊上方文字中的超鏈接或者輸入?https://mooc.study.163.com/course/1000002008#/info?進入理論課程的學習。 如果網易云上的課程無法查看,也可以看 Bilibili 上的?操作系統哈爾濱工業大學李治軍老師。

2. 實驗目的

  • 深入理解進程和進程切換的概念;
  • 綜合應用進程、CPU 管理、PCB、LDT、內核棧、內核態等知識解決實際問題;
  • 開始建立系統認識。

3. 實驗內容

現在的 Linux 0.11 采用 TSS(后面會有詳細論述)和一條指令就能完成任務切換,雖然簡單,但這指令的執行時間卻很長,在實現任務切換時大概需要 200 多個時鐘周期。

而通過堆棧實現任務切換可能要更快,而且采用堆棧的切換還可以使用指令流水的并行優化技術,同時又使得 CPU 的設計變得簡單。所以無論是 Linux 還是 Windows,進程/線程的切換都沒有使用 Intel 提供的這種 TSS 切換手段,而都是通過堆棧實現的。

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

本次實驗包括如下內容:

  • 編寫匯編程序?switch_to
  • 完成主體框架;
  • 在主體框架下依次完成 PCB 切換、內核棧切換、LDT 切換等;
  • 修改?fork(),由于是基于內核棧的切換,所以進程需要創建出能完成內核棧切換的樣子。
  • 修改 PCB,即?task_struct?結構,增加相應的內容域,同時處理由于修改了 task_struct 所造成的影響。
  • 用修改后的 Linux 0.11 仍然可以啟動、可以正常使用。
  • (選做)分析實驗 3 的日志體會修改前后系統運行的差別。

4. 實驗報告

回答下面三個題:

問題 1

針對下面的代碼片段:

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

?

回答問題:

  • (1)為什么要加 4096;
  • (2)為什么沒有設置 tss 中的 ss0。

問題 2

針對代碼片段:

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

?

回答問題:

  • (1)子進程第一次執行時,eax=?為什么要等于這個數?哪里的工作讓 eax 等于這樣一個數?
  • (2)這段代碼中的 ebx 和 ecx 來自哪里,是什么含義,為什么要通過這些代碼將其寫到子進程的內核棧中?
  • (3)這段代碼中的 ebp 來自哪里,是什么含義,為什么要做這樣的設置?可以不設置嗎?為什么?

問題 3

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

5. 評分標準

  • switch_to(kernal/system_call.s),40%
  • fork.c,30%
  • sched.h 和 sched.c,10%
  • 實驗報告,20%

6. 實驗提示

本次實驗將 Linux 0.11 中采用的 TSS 切換部分去掉,取而代之的是基于堆棧的切換程序。具體的說,就是將 Linux 0.11 中的?switch_to?(在 kernal/system_call.s 中)實現去掉,寫成一段基于堆棧切換的代碼。

6.1 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 基于 TSS 的進程切換

Intel 架構不僅提供了 TSS 來實現任務切換,而且只要一條指令就能完成這樣的切換,即圖中的 ljmp 指令。

具體的工作過程是:

  • (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 的進程切換聯系在一起了。

圖片描述信息

圖 2 GDT 表中的內容

6.2 本次實驗的內容

雖然用一條指令就能完成任務切換,但這指令的執行時間卻很長,這條 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()

6.3 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);

?

修改為:

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

?

6.4 實現 switch_to

實現?switch_to?是本次實踐項目中最重要的一部分。

由于要對內核棧進行精細的操作,所以需要用匯編代碼來完成函數?switch_to?的編寫。

這個函數依次主要完成如下功能:由于是 C 語言調用匯編,所以需要首先在匯編中處理棧幀,即處理?ebp?寄存器;接下來要取出表示下一個進程 PCB 的參數,并和?current?做一個比較,如果等于 current,則什么也不用做;如果不等于 current,就開始進程切換,依次完成 PCB 的切換、TSS 中的內核棧指針的重寫、內核棧的切換、LDT 的切換以及 PC 指針(即 CS:EIP)的切換。

switch_to:pushl %ebpmovl %esp,%ebppushl %ecxpushl %ebxpushl %eaxmovl 8(%ebp),%ebxcmpl %ebx,currentje 1f
! 切換PCB! ...
! TSS中的內核棧指針的重寫! ...
! 切換內核棧! ...
! 切換LDT! ...movl $0x17,%ecxmov %cx,%fs
! 和后面的 clts 配合來處理協處理器,由于和主題關系不大,此處不做論述cmpl %eax,last_task_used_mathjne 1fclts1:    popl %eaxpopl %ebxpopl %ecxpopl %ebp
ret

?

雖然看起來完成了挺多的切換,但實際上每個部分都只有很簡單的幾條指令。完成 PCB 的切換可以采用下面兩條指令,其中 ebx 是從參數中取出來的下一個進程的 PCB 指針,

movl %ebx,%eax
xchgl %eax,current

?

經過這兩條指令以后,eax 指向現在的當前進程,ebx 指向下一個進程,全局變量 current 也指向下一個進程。

TSS 中的內核棧指針的重寫可以用下面三條指令完成,其中宏?ESP0 = 4struct tss_struct *tss = &(init_task.task.tss);?也是定義了一個全局變量,和 current 類似,用來指向那一段 0 號進程的 TSS 內存。

前面已經詳細論述過,在中斷的時候,要找到內核棧位置,并將用戶態下的?SS:ESPCS:EIP?以及?EFLAGS?這五個寄存器壓到內核棧中,這是溝通用戶棧(用戶態)和內核棧(內核態)的關鍵橋梁,而找到內核棧位置就依靠 TR 指向的當前 TSS。

現在雖然不使用 TSS 進行任務切換了,但是 Intel 的這態中斷處理機制還要保持,所以仍然需要有一個當前 TSS,這個 TSS 就是我們定義的那個全局變量 tss,即 0 號進程的 tss,所有進程都共用這個 tss,任務切換時不再發生變化。

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

?

定義?ESP0 = 4?是因為 TSS 中內核棧指針 esp0 就放在偏移為 4 的地方,看一看 tss 的結構體定義就明白了。

完成內核棧的切換也非常簡單,和我們前面給出的論述完全一致,將寄存器 esp(內核棧使用到當前情況時的棧頂位置)的值保存到當前 PCB 中,再從下一個 PCB 中的對應位置上取出保存的內核棧棧頂放入 esp 寄存器,這樣處理完以后,再使用內核棧時使用的就是下一個進程的內核棧了。

由于現在的 Linux 0.11 的 PCB 定義中沒有保存內核棧指針這個域(kernelstack),所以需要加上,而宏?KERNEL_STACK?就是你加的那個位置,當然將 kernelstack 域加在 task_struct 中的哪個位置都可以,但是在某些匯編文件中(主要是在?kernal/system_call.s?中)有些關于操作這個結構一些匯編硬編碼,所以一旦增加了 kernelstack,這些硬編碼需要跟著修改,由于第一個位置,即 long state 出現的匯編硬編碼很多,所以 kernelstack 千萬不要放置在 task_struct 中的第一個位置,當放在其他位置時,修改?kernal/system_call.s?中的那些硬編碼就可以了。

KERNEL_STACK = 12
movl %esp,KERNEL_STACK(%eax)
! 再取一下 ebx,因為前面修改過 ebx 的值
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp

?

task_struct 的定義:

// 在 include/linux/sched.h 中
struct task_struct {long state;long counter;long priority;long kernelstack;
//......

?

由于這里將 PCB 結構體的定義改變了,所以在產生 0 號進程的 PCB 初始化時也要跟著一起變化,需要將原來的?#define INIT_TASK { 0,15,15, 0,{{},},0,...?修改為?#define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,...,即在 PCB 的第四項中增加關于內核棧棧指針的初始化。

再下一個切換就是 LDT 的切換了,指令?movl 12(%ebp),%ecx?負責取出對應 LDT(next)的那個參數,指令?lldt %cx?負責修改 LDTR 寄存器,一旦完成了修改,下一個進程在執行用戶態程序時使用的映射表就是自己的 LDT 表了,地址空間實現了分離。

最后一個切換是關于 PC 的切換,和前面論述的一致,依靠的就是?switch_to?的最后一句指令 ret,雖然簡單,但背后發生的事卻很多:schedule()?函數的最后調用了這個?switch_to?函數,所以這句指令 ret 就返回到下一個進程(目標進程)的?schedule()?函數的末尾,遇到的是},繼續 ret 回到調用的?schedule()?地方,是在中斷處理中調用的,所以回到了中斷處理中,就到了中斷返回的地址,再調用 iret 就到了目標進程的用戶態程序去執行,和書中論述的內核態線程切換的五段論是完全一致的。

這里還有一個地方需要格外注意,那就是 switch_to 代碼中在切換完 LDT 后的兩句,即:

! 切換 LDT 之后
movl $0x17,%ecx
mov %cx,%fs

?

這兩句代碼的含義是重新取一下段寄存器 fs 的值,這兩句話必須要加、也必須要出現在切換完 LDT 之后,這是因為在實踐項目 2 中曾經看到過 fs 的作用——通過 fs 訪問進程的用戶態內存,LDT 切換完成就意味著切換了分配給進程的用戶態內存地址空間,所以前一個 fs 指向的是上一個進程的用戶態內存,而現在需要執行下一個進程的用戶態內存,所以就需要用這兩條指令來重取 fs。

不過,細心的讀者可能會發現: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,增加了執行指令的效率。現在想必明白了為什么重新設置 fs=0x17 了吧?而且為什么要出現在切換完 LDT 之后?

圖片描述信息

圖 3 段寄存器中的兩個部分

6.5 修改 fork

開始修改 fork() 了,和書中論述的原理一致,就是要把進程的用戶棧、用戶程序和其內核棧通過壓在內核棧中的?SS:ESPCS:IP?關聯在一起。

另外,由于 fork() 這個叉子的含義就是要讓父子進程共用同一個代碼、數據和堆棧,現在雖然是使用內核棧完成任務切換,但 fork() 的基本含義不會發生變化。

將上面兩段描述聯立在一起,修改 fork() 的核心工作就是要形成如下圖所示的子進程內核棧結構。

圖片描述信息

圖 4 fork 進程的父子進程結構

不難想象,對 fork() 的修改就是對子進程的內核棧的初始化,在 fork() 的核心實現?copy_process?中,p = (struct task_struct *) get_free_page();用來完成申請一頁內存作為子進程的 PCB,而 p 指針加上頁面大小就是子進程的內核棧位置,所以語句?krnstack = (long *) (PAGE_SIZE + (long) p);?就可以找到子進程的內核棧位置,接下來就是初始化 krnstack 中的內容了。

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

?

這五條語句就完成了上圖所示的那個重要的關聯,因為其中 ss,esp 等內容都是?copy_proces()?函數的參數,這些參數來自調用?copy_proces()?的進程的內核棧中,就是父進程的內核棧中,所以上面給出的指令不就是將父進程內核棧中的前五個內容拷貝到子進程的內核棧中,圖中所示的關聯不也就是一個拷貝嗎?

接下來的工作就需要和 switch_to 接在一起考慮了,故事從哪里開始呢?回顧一下前面給出來的 switch_to,應該從 “切換內核棧” 完事的那個地方開始,現在到子進程的內核棧開始工作了,接下來做的四次彈棧以及 ret 處理使用的都是子進程內核棧中的東西,

1: popl %eaxpopl %ebxpopl %ecxpopl %ebp
ret

?

為了能夠順利完成這些彈棧工作,子進程的內核棧中應該有這些內容,所以需要對 krnstack 進行初始化:

*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
// 這里的 0 最有意思。
*(--krnstack) = 0;

?

現在到了 ret 指令了,這條指令要從內核棧中彈出一個 32 位數作為 EIP 跳去執行,所以需要弄一個函數地址(仍然是一段匯編程序,所以這個地址是這段匯編程序開始處的標號)并將其初始化到棧中。我們弄的一個名為?first_return_from_kernel?的匯編標號,然后可以用語句?*(--krnstack) = (long) first_return_from_kernel;?將這個地址初始化到子進程的內核棧中,現在執行 ret 以后就會跳轉到?first_return_from_kernel?去執行了。

想一想?first_return_from_kernel?要完成什么工作?PCB 切換完成、內核棧切換完成、LDT 切換完成,接下來應該那個“內核級線程切換五段論”中的最后一段切換了,即完成用戶棧和用戶代碼的切換,依靠的核心指令就是 iret,當然在切換之前應該回復一下執行現場,主要就是?eax,ebx,ecx,edx,esi,edi,gs,fs,es,ds?等寄存器的恢復.

下面給出了?first_return_from_kernel?的核心代碼,當然 edx 等寄存器的值也應該先初始化到子進程內核棧,即 krnstack 中。

popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret

?

最后別忘了將存放在 PCB 中的內核棧指針修改到初始化完成時內核棧的棧頂,即:

p->kernelstack = stack;

相關鏈接:

https://www.lanqiao.cn/courses/115/labs/571/document/

https://blog.csdn.net/qq_41708792/article/details/89637248

版权声明:本站所有资料均为网友推荐收集整理而来,仅供学习和研究交流使用。

原文链接:https://hbdhgg.com/3/169310.html

发表评论:

本站为非赢利网站,部分文章来源或改编自互联网及其他公众平台,主要目的在于分享信息,版权归原作者所有,内容仅供读者参考,如有侵权请联系我们删除!

Copyright © 2022 匯編語言學習筆記 Inc. 保留所有权利。

底部版权信息