Introduction
在這個實驗中,我們將實現操作系統的一些基本功能,來實現用戶環境下的進程的正常運行。你將會加強JOS內核的功能,為它增添一些重要的數據結構,用來記錄用戶進程環境的一些信息;創建一個單一的用戶環境,並且加載一個程序運行它。你也可以讓JOS內核能夠完成用戶環境所作出的任何系統調用,以及處理用戶環境產生的各種異常。
Part A: User Environments and Exception Handling
新包含的文件inc/env.h里面包含了JOS內核的有關用戶環境(User Environment)的一些基本定義。用戶環境指的就是一個應用程序運行在系統中所需要的一個上下文環境,操作系統內核使用數據結構 Env 來記錄每一個用戶環境的信息。在這個實驗中,我們只會創建一個用戶環境,但是之后我們會把它設計成能夠支持多用戶環境,即多個用戶程序並發執行。
在 kern/env.c 文件中我們看到,操作系統一共維護了三個重要的和用戶環境相關的全局變量:
struct Env *envs = NULL; //所有的 Env 結構體
struct Env *curenv = NULL; //目前正在運行的用戶環境
static struct Env *env_free_list; //還沒有被使用的 Env 結構體鏈表
一旦JOS啟動,envs指針便指向了一個 Env 結構體鏈表,表示系統中所有的用戶環境的env。在我們的設計中,JOS內核將支持同一時刻最多 NENV 個活躍的用戶環境,盡管這個數字要比真實情況下任意給定時刻的活躍用戶環境數要多很多。系統會為每一個活躍的用戶環境在envs鏈表中維護一個 Env 結構體。
JOS內核也把所有不活躍的Env結構體,用env_free_list鏈接起來。這種設計方式非常方便進行用戶環境env的分配和回收。
內核也會把 curenv 指針指向在任意時刻正在執行的用戶環境的 Env 結構體。在內核啟動時,並且還沒有任何用戶環境運行時,curenv的值為NULL。
Environment Status
我們要看一下,Env結構體每一個字段的具體含義是什么,Env結構體定義在 inc/env.h 文件中
struct Env {
struct Trapframe env_tf; //saved registers
struct Env * env_link; //next free Env
envid_t env_id; //Unique environment identifier
envid_t env_parent_id; //envid of this env's parent
enum EnvType env_type; //Indicates special system environment
unsigned env_status; //Status of the environment
uint32_t env_runs; //Number of the times environment has run
pde_t *env_pgdir; //Kernel virtual address of page dir.
};
env_tf:
這個類型的結構體在inc/trap.h文件中被定義,里面存放着當用戶環境暫停運行時,所有重要寄存器的值。內核也會在系統從用戶態切換到內核態時保存這些值,這樣的話用戶環境可以在之后被恢復,繼續執行。
env_link:
這個指針指向在env_free_list中,該結構體的后一個free的Env結構體。當然前提是這個結構體還沒有被分配給任意一個用戶環境時,該域才有用。
env_id:
這個值可以唯一的確定使用這個結構體的用戶環境是什么。當這個用戶環境終止,內核會把這個結構體分配給另外一個不同的環境,這個新的環境會有不同的env_id值。
env_parent_id:
創建這個用戶環境的父用戶環境的env_id
env_type:
用於區別出來某個特定的用戶環境。對於大多數環境來說,它的值都是 ENV_TYPE_USER.
env_status:
這個變量存放以下可能的值
ENV_FREE: 代表這個結構體是不活躍的,應該在鏈表env_free_list中。
ENV_RUNNABLE: 代表這個結構體對應的用戶環境已經就緒,等待被分配處理機。
ENV_RUNNING: 代表這個結構體對應的用戶環境正在運行。
ENV_NOT_RUNNABLE: 代表這個結構體所代表的是一個活躍的用戶環境,但是它不能被調度運行,因為它在等待其他環境傳遞給它的消息。
ENV_DYING: 代表這個結構體對應的是一個僵屍環境。一個僵屍環境在下一次陷入內核時會被釋放回收。
env_pgdir:
這個變量存放着這個環境的頁目錄的虛擬地址
就像Unix中的進程一樣,一個JOS環境中結合了“線程”和“地址空間”的概念。線程通常是由被保存的寄存器的值來定義的,而地址空間則是由env_pgdir所指向的頁目錄表還有頁表來定義的。為了運行一個用戶環境,內核必須設置合適的寄存器的值以及合適的地址空間。
Allocating the Environments Array
在lab 2,你在mem_init() 函數中分配了pages數組的地址空間,用於記錄內核中所有的頁的信息。現在你需要進一步去修改mem_init()函數,來分配一個Env結構體數組,叫做envs。
Exercise 1. 修改一下mem_init()的代碼,讓它能夠分配envs數組。這個數組是由NENV個Env結構體組成的。envs數組所在的這部分內存空間也應該是用戶模式只讀的。被映射到虛擬地址UENVS處。
答:
就像題目中說的那樣,我們只需要像在Lab2里面分配pages數組那樣,分配一個Env數組給指針envs就可以了。
主要要在兩個地方要添加代碼,首先要在page_init()之前為envs分配內存空間。
envs = (struct Env*)boot_alloc(NENV*sizeof(struct Env)); memset(envs, 0, NENV * sizeof(struct Env));
然后要在頁表中設置它的映射關系,位於check_page()函數之后
boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U);
Creating and Running Environments
現在你需要去編寫 kern/env.c 文件來運行一個用戶環境了。由於你現在沒有文件系統,所以必須把內核設置成能夠加載內核中的靜態二進制程序映像文件。
Lab3 里面的 GNUmakefile 文件在obj/user/目錄下面生成了一系列的二進制映像文件。如果你看一下 kern/Makefrag 文件,你會發現一些奇妙的地方,這些地方把二進制文件直接鏈接到內核可執行文件中,只要這些文件是.o文件。其中在鏈接器命令行中的-b binary 選項會使這些文件被當做二進制執行文件鏈接到內核之后。
在 i386_init() 函數中,你會看到運行上述二進制文件的代碼,但是我們需要完成能夠設置這些代碼的運行用戶環境的功能。
Exercise 2. 在文件 env.c中,完成下列函數:
env_init(): 初始化所有的在envs數組中的 Env結構體,並把它們加入到 env_free_list中。 還要調用 env_init_percpu,這個函數要配置段式內存管理系統,讓它所管理的段,可能具有兩種訪問優先級其中的一種,一個是內核運行時的0優先級,以及用戶運行時的3優先級。
env_setup_vm(): 為一個新的用戶環境分配一個頁目錄表,並且初始化這個用戶環境的地址空間中的和內核相關的部分。
region_alloc(): 為用戶環境分配物理地址空間
load_icode(): 分析一個ELF文件,類似於boot loader做的那樣,我們可以把它的內容加載到用戶環境下。
env_create(): 利用env_alloc函數和load_icode函數,加載一個ELF文件到用戶環境中
env_run(): 在用戶模式下,開始運行一個用戶環境。
答:
env_init函數很簡單,就是遍歷 envs 數組中的所有 Env 結構體,把每一個結構體的 env_id 字段置0,因為要求所有的 Env 在 env_free_list 中的順序,要和它在 envs 中的順序一致,所以需要采用頭插法。
代碼:
1 void 2 env_init(void) 3 { 4 // Set up envs array 5 // LAB 3: Your code here. 6 int i; 7 env_free_list = NULL; 8 for(i=NENV-1; i>=0; i--){ 9 envs[i].env_id = 0; 10 envs[i].env_status = ENV_FREE; 11 envs[i].env_link = env_free_list; 12 env_free_list = &envs[i]; 13 } 14 // Per-CPU part of the initialization 15 env_init_percpu(); 16 }
env_setup_vm 函數主要是初始化新的用戶環境的頁目錄表,不過只設置頁目錄表中和操作系統內核跟內核相關的頁目錄項,用戶環境的頁目錄項不要設置,因為所有用戶環境的頁目錄表中和操作系統相關的頁目錄項都是一樣的(除了虛擬地址UVPT,這個也會單獨進行設置),所以我們可以參照 kern_pgdir 中的內容來設置 env_pgdir 中的內容。
代碼:
1 static int 2 env_setup_vm(struct Env *e) 3 { 4 int i; 5 struct PageInfo *p = NULL; 6 7 // Allocate a page for the page directory 8 if (!(p = page_alloc(ALLOC_ZERO))) 9 return -E_NO_MEM; 10 11 // LAB 3: Your code here. 12 e->env_pgdir = (pde_t *)page2kva(p); 13 p->pp_ref++; 14 15 //Map the directory below UTOP. 16 for(i = 0; i < PDX(UTOP); i++) { 17 e->env_pgdir[i] = 0; 18 } 19 20 //Map the directory above UTOP 21 for(i = PDX(UTOP); i < NPDENTRIES; i++) { 22 e->env_pgdir[i] = kern_pgdir[i]; 23 } 24 25 // UVPT maps the env's own page table read-only. 26 // Permissions: kernel R, user R 27 e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U; 28 29 return 0; 30 }
region_alloc 為用戶環境分配物理空間,這里注意我們要先把起始地址和終止地址進行頁對齊,對其之后我們就可以以頁為單位,為其一個頁一個頁的分配內存,並且修改頁目錄表和頁表。
代碼:
1 static void 2 region_alloc(struct Env *e, void *va, size_t len) 3 { 4 // LAB 3: Your code here. 5 void* start = (void *)ROUNDDOWN((uint32_t)va, PGSIZE); 6 void* end = (void *)ROUNDUP((uint32_t)va+len, PGSIZE); 7 struct PageInfo *p = NULL; 8 void* i; 9 int r; 10 for(i=start; i<end; i+=PGSIZE){ 11 p = page_alloc(0); 12 if(p == NULL) 13 panic(" region alloc, allocation failed."); 14 15 r = page_insert(e->env_pgdir, p, i, PTE_W | PTE_U); 16 if(r != 0) { 17 panic("region alloc error"); 18 } 19 } 20 }
load_icode 功能是為每一個用戶進程設置它的初始代碼區,堆棧以及處理器標識位。每個用戶程序都是ELF文件,所以我們要解析該ELF文件。
代碼:
1 static void 2 load_icode(struct Env *e, uint8_t *binary) 3 { 4 5 // LAB 3: Your code here. 6 struct Elf* header = (struct Elf*)binary; 7 8 if(header->e_magic != ELF_MAGIC) { 9 panic("load_icode failed: The binary we load is not elf.\n"); 10 } 11 12 if(header->e_entry == 0){ 13 panic("load_icode failed: The elf file can't be excuterd.\n"); 14 } 15 16 e->env_tf.tf_eip = header->e_entry; 17 18 lcr3(PADDR(e->env_pgdir)); //????? 19 20 struct Proghdr *ph, *eph; 21 ph = (struct Proghdr* )((uint8_t *)header + header->e_phoff); 22 eph = ph + header->e_phnum; 23 for(; ph < eph; ph++) { 24 if(ph->p_type == ELF_PROG_LOAD) { 25 if(ph->p_memsz - ph->p_filesz < 0) { 26 panic("load icode failed : p_memsz < p_filesz.\n"); 27 } 28 29 region_alloc(e, (void *)ph->p_va, ph->p_memsz); 30 memmove((void *)ph->p_va, binary + ph->p_offset, ph->p_filesz); 31 memset((void *)(ph->p_va + ph->p_filesz), 0, ph->p_memsz - ph->p_filesz); 32 } 33 } 34 35 // Now map one page for the program's initial stack 36 // at virtual address USTACKTOP - PGSIZE. 37 region_alloc(e,(void *)(USTACKTOP-PGSIZE), PGSIZE); 38 // LAB 3: Your code here. 39 }
env_create 是利用env_alloc函數和load_icode函數,加載一個ELF文件到用戶環境中
代碼:
1 void 2 env_create(uint8_t *binary, enum EnvType type) 3 { 4 // LAB 3: Your code here. 5 struct Env *e; 6 int rc; 7 if((rc = env_alloc(&e, 0)) != 0) { 8 panic("env_create failed: env_alloc failed.\n"); 9 } 10 11 load_icode(e, binary); 12 e->env_type = type; 13 }
env_run 是真正開始運行一個用戶環境
代碼:
1 void 2 env_run(struct Env *e) 3 { 4 5 if(curenv != NULL && curenv->env_status == ENV_RUNNING) { 6 curenv->env_status = ENV_RUNNABLE; 7 } 8 9 curenv = e; 10 curenv->env_status = ENV_RUNNING; 11 curenv->env_runs++; 12 lcr3(PADDR(curenv->env_pgdir)); 13 14 env_pop_tf(&curenv->env_tf); 15 // LAB 3: Your code here. 16 17 panic("env_run not yet implemented"); 18 }
用戶環境的代碼被調用前,操作系統一共按順序執行了以下幾個函數:
* start (kern/entry.S)
* i386_init (kern/init.c)
cons_init
mem_init
env_init
trap_init (目前還未實現)
env_create
env_run
env_pop_tf
一旦你完成上述子函數的代碼,並且在QEMU下編譯運行,系統會進入用戶空間,並且開始執行hello程序,直到它做出一個系統調用指令int。但是這個系統調用指令不能成功運行,因為到目前為止,JOS還沒有設置相關硬件來實現從用戶態向內核態的轉換功能。當CPU發現,它沒有被設置成能夠處理這種系統調用中斷時,它會觸發一個保護異常,然后發現這個保護異常也無法處理,從而又產生一個錯誤異常,然后又發現仍舊無法解決問題,所以最后放棄,我們把這個叫做"triple fault"。通常來說,接下來CPU會復位,系統會重啟。
所以我們馬上要來解決這個問題,不過解決之前我們可以使用調試器來檢查一下程序要進入用戶模式時做了什么。使用make qemu-gdb 並且在 env_pop_tf 處設置斷點,這條指令應該是即將進入用戶模式之前的最后一條指令。然后進行單步調試,處理會在執行完 iret 指令后進入用戶模式。然后依舊可以看到進入用戶態后執行的第一條指令了,該指令是一個cmp指令,開始於文件 lib/entry.S 中。 現在使用 b *0x... 設置一個斷點在hello文件(obj/user/hello.asm)中的sys_cputs函數中的 int $0x30 指令處。這個int指令是一個系統調用,用來展示一個字符到控制台。如果你的程序運行不到這個int指令,說明有錯誤。
Handling Interrupts and Exceptions
到目前為止,當程序運行到第一個系統調用 int $0x30 時,就會進入錯誤的狀態,因為現在系統無法從用戶態切換到內核態。所以你需要實現一個基本的異常/系統調用處理機制,使得內核可以從用戶態轉換為內核態。你應該先熟悉一下X86的異常中斷機制。
Basics of Protected Control Transfer
異常(Exception)和中斷(Interrupts)都是“受到保護的控制轉移方法”,都會使處理器從用戶態轉移為內核態。在Intel的術語中,一個中斷指的是由外部異步事件引起的處理器控制權轉移,比如外部IO設備發送來的中斷信號。一個異常則是由於當前正在運行的指令所帶來的同步的處理器控制權的轉移,比如除零溢出異常。
為了能夠確保這些控制的轉移能夠真正被保護起來,處理器的中斷/異常機制通常被設計為:用戶態的代碼無權選擇內核中的代碼從哪里開始執行。處理器可以確保只有在某些條件下,才能進入內核態。在X86上,有兩種機制配合工作來提供這種保護:
1. 中斷向量表:
處理器保證中斷和異常只能夠引起內核進入到一些特定的,被事先定義好的程序入口點,而不是由觸發中斷的程序來決定中斷程序入口點。
X86允許多達256個不同的中斷和異常,每一個都配備一個獨一無二的中斷向量。一個向量指的就是0到255中的一個數。一個中斷向量的值是根據中斷源來決定的:不同設備,錯誤條件,以及對內核的請求都會產生出不同的中斷和中斷向量的組合。CPU將使用這個向量作為這個中斷在中斷向量表中的索引,這個表是由內核設置的,放在內核空間中,和GDT很像。通過這個表中的任意一個表項,處理器可以知道:
*需要加載到EIP寄存器中的值,這個值指向了處理這個中斷的中斷處理程序的位置。
*需要加載到CS寄存器中的值,里面還包含了這個中斷處理程序的運行特權級。(即這個程序是在用戶態還是內核態下運行。)
2. 任務狀態段
處理器還需要一個地方來存放,當異常/中斷發生時,處理器的狀態,比如EIP和CS寄存器的值。這樣的話,中斷處理程序一會可以重新返回到原來的程序中。這段內存自然也要保護起來,不能被用戶態的程序所篡改。
正因為如此,當一個x86處理器要處理一個中斷,異常並且使運行特權級從用戶態轉為內核態時,它也會把它的堆棧切換到內核空間中。一個叫做 “任務狀態段(TSS)”的數據結構將會詳細記錄這個堆棧所在的段的段描述符和地址。處理器會把SS,ESP,EFLAGS,CS,EIP以及一個可選錯誤碼等等這些值壓入到這個堆棧上。然后加載中斷處理程序的CS,EIP值,並且設置ESP,SS寄存器指向新的堆棧。
盡管TSS非常大,並且還有很多其他的功能,但是JOS僅僅使用它來定義處理器從用戶態轉向內核態所采用的內核堆棧,由於JOS中的內核態指的就是特權級0,所以處理器用TSS中的ESP0,SS0字段來指明這個內核堆棧的位置,大小。
Types of Exceptions and Interrupts
所有的由X86處理器內部產生的異常的向量值是0到31之間的整數。比如,頁表錯所對應的向量值是14.而大於31號的中斷向量對應的是軟件中斷,由int指令生成;或者是外部中斷,由外部設備生成。
在這一章,我們將擴展JOS的功能,使它能夠處理0~31號內部異常。在下一章會讓JOS能夠處理48號軟件中斷,主要被用來做系統調用。在Lab4中會繼續擴展JOS使它能夠處理外部硬件中斷,比如時鍾中斷。
An Example
讓我們看一個實例,假設處理器正在用戶狀態下運行代碼,但是遇到了一個除法指令,並且除數為0.
1. 處理器會首先切換自己的堆棧,切換到由TSS的SS0,ESP0字段所指定的內核堆棧區,這兩個字段分別存放着GD_KD和KSTACKTOP的值。
2. 處理器把異常參數壓入到內核堆棧中,起始於地址KSTACKTOP:
3. 因為我們要處理的是除零異常,它的中斷向量是0,處理器會讀取IDT表中的0號表項,並且把CS:EIP的值設置為0號中斷處理函數的地址值。
4. 中斷處理函數開始執行,並且處理中斷。
對於某些特定的異常,除了上面圖中要保存的五個值之外,還要再壓入一個字,叫做錯誤碼。比如頁表錯,就是其中一個實例。當壓入錯誤碼之后,內核堆棧的狀態如下:
以上幾步都是由硬件自動完成的。
Nested Exceptions and Interrupts
處理器在用戶態下和內核態下都可以處理異常或中斷。只有當處理器從用戶態切換到內核態時,才會自動地切換堆棧,並且把一些寄存器中的原來的值壓入到堆棧上,並且觸發相應的中斷處理函數。但如果處理器已經由於正在處理中斷而處在內核態下時,此時CPU只會向內核堆棧壓入更多的值。通過這種方式,內核就可處理嵌套中斷。
如果處理器已經在內核態下並且遇到嵌套中斷,因為它不需要切換堆棧,所以它不需要存儲SS,ESP寄存器的值。此時內核堆棧的就像下面這個樣子:
這里有一個重要的警告。如果處理器在內核態下接受一個異常,而且由於一些原因,比如堆棧空間不足,不能把當前的狀態信息(寄存器的值)壓入到內核堆棧中時,那么處理器是無法恢復到原來的狀態了,它會自動重啟。
Setting Up the IDT
你現在應該有了所有的基本信息去設置IDT表,並且在JOS處理異常。現在你只需要處理內部異常(中斷向量號0~31)。
在頭文件 inc/trap.h和kern/trap.h 中包含了和中斷異常相關的非常重要的定義,你應該好好熟悉一下。kern/trap.h 文件中包含了僅內核可見的一些定義, inc/trap.h 中包含了用戶態也可見的一些定義。
最后你要實現的代碼的效果如下:
每一個中斷或異常都有它自己的中斷處理函數,分別定義在 trapentry.S中,trap_init()將初始化IDT表。每一個處理函數都應該構建一個結構體 Trapframe 在堆棧上,並且調用trap()函數指向這個結構體,trap()然后處理異常/中斷,給他分配一個中斷處理函數。
所以整個操作系統的中斷控制流程為:
1. trap_init() 先將所有中斷處理函數的起始地址放到中斷向量表IDT中。
2. 當中斷發生時,不管是外部中斷還是內部中斷,處理器捕捉到該中斷,進入核心態,根據中斷向量去查詢中斷向量表,找到對應的表項
3. 保存被中斷的程序的上下文到內核堆棧中,調用這個表項中指明的中斷處理函數。
4. 執行中斷處理函數。
5. 執行完成后,恢復被中斷的進程的上下文,返回用戶態,繼續運行這個進程。
Exercise 4.
編輯一下trapentry.S 和 trap.c 文件,並且實現上面所說的功能。宏定義 TRAPHANDLER 和 TRAPHANDLER_NOEC 會對你有幫助。你將會在 trapentry.S文件中為在inc/trap.h文件中的每一個trap加入一個入口指, 你也將會提供_alttraps的值。
你需要修改trap_init()函數來初始化idt表,使表中每一項指向定義在trapentry.S中的入口指針,SETGATE宏定義在這里用得上。
你所實現的 _alltraps 應該:
1. 把值壓入堆棧使堆棧看起來像一個結構體 Trapframe
2. 加載 GD_KD 的值到 %ds, %es寄存器中
3. 把%esp的值壓入,並且傳遞一個指向Trapframe的指針到trap()函數中。
4. 調用trap
考慮使用pushal指令,他會很好的和結構體 Trapframe 的布局配合好。
答:
首先看一下 trapentry.S 文件,里面定義了兩個宏定義,TRAPHANDLER,TRAPHANDLER_NOEC。他們的功能從匯編代碼中可以看出:聲明了一個全局符號name,並且這個符號是函數類型的,代表它是一個中斷處理函數名。其實這里就是兩個宏定義的函數。這兩個函數就是當系統檢測到一個中斷/異常時,需要首先完成的一部分操作,包括:中斷異常碼,中斷錯誤碼(error code)。正是因為有些中斷有中斷錯誤碼,有些沒有,所以我們采用利用兩個宏定義函數。
然后就會調用 _alltraps,_alltraps函數其實就是為了能夠讓程序在之后調用trap.c中的trap函數時,能夠正確的訪問到輸入的參數,即Trapframe指針類型的輸入參數tf。
所以在trapentry.S中,我們要根據這個中斷是否有中斷錯誤碼,來選擇調用TRAPHANDLER,還是TRAPHANDLER_NOEC,然后再統一調用_alltraps,其實目的就是為了能夠讓系統在正式運行中斷處理程序之前完成必要的准備工作,比如保存現場等等。
具體的代碼可以去看一下我的github https://github.com/fatsheepzzq/6.828mit/
而在trap.c文件中,我們應該繼續完善trap_init函數,這個函數中將會對系統的IDT表進行初始化設置。
同理,由於篇幅有限,可以去我的github上看一下相關的代碼。
Question:
1. What is the purpose of having an individual handler function for each exception/interrupt? (i.e., if all exceptions/interrupts were delivered to the same handler, what feature that exists in the current implementation could not be provided?)
答:
不同的中斷或者異常當然需要不同的中斷處理函數,因為不同的異常/中斷可能需要不同的處理方式,比如有些異常是代表指令有錯誤,則不會返回被中斷的命令。而有些中斷可能只是為了處理外部IO事件,此時執行完中斷函數還要返回到被中斷的程序中繼續運行。
2. Did you have to do anything to make the user/softint program behave correctly? The grade script expects it to produce a general protection fault (trap 13), but softint's code says int $14. Why should this produce interrupt vector 13? What happens if the kernel actually allows softint's int $14 instruction to invoke the kernel's page fault handler (which is interrupt vector 14)?
答:
因為當前的系統正在運行在用戶態下,特權級為3,而INT指令為系統指令,特權級為0。特權級為3的程序不能直接調用特權級為0的程序,會引發一個General Protection Exception,即trap 13。
以上就是Lab3 Part A~
歡迎大家的意見與問題
zzqwf12345@163.com