在本實驗中,你將要實現一個基本的核心功能,要求它能夠保護執行的使用者模式環境(即:進程)。你將去增強這個 JOS 核心,去設定資料結構以便於保持對使用者環境的跟蹤、建立一個單一使用者環境、將程式映象載入到使用者環境中、並將它啟動執行。你也要寫出一些 JOS 核心的函數,用來處理任何使用者環境生成的系統呼叫,以及處理由使用者環境引進的各種異常。
注意: 在本實驗中,術語“環境” 和“進程” 是可互換的 —— 它們都表示同一個抽象概念,那就是允許你去執行的程式。我在介紹中使用術語“環境”而不是使用傳統術語“進程”的目的是為了強調一點,那就是 JOS 的環境和 UNIX 的進程提供了不同的介面,並且它們的語意也不相同。
使用 Git 去提交你自實驗 2 以後的更改(如果有的話),獲取課程倉庫的最新版本,以及建立一個命名為 lab3
的本地分支,指向到我們的 lab3 分支上 origin/lab3
:
athena% cd ~/6.828/labathena% add gitathena% git commit -am 'changes to lab2 after handin'Created commit 734fab7: changes to lab2 after handin 4 files changed, 42 insertions(+), 9 deletions(-)athena% git pullAlready up-to-date.athena% git checkout -b lab3 origin/lab3Branch lab3 set up to track remote branch refs/remotes/origin/lab3.Switched to a new branch "lab3"athena% git merge lab2Merge made by recursive. kern/pmap.c | 42 +++++++++++++++++++ 1 files changed, 42 insertions(+), 0 deletions(-)athena%
實驗 3 包含一些你將探索的新原始檔:
inc/ env.h Public definitions for user-mode environments trap.h Public definitions for trap handling syscall.h Public definitions for system calls from user environments to the kernel lib.h Public definitions for the user-mode support librarykern/ env.h Kernel-private definitions for user-mode environments env.c Kernel code implementing user-mode environments trap.h Kernel-private trap handling definitions trap.c Trap handling code trapentry.S Assembly-language trap handler entry-points syscall.h Kernel-private definitions for system call handling syscall.c System call implementation codelib/ Makefrag Makefile fragment to build user-mode library, obj/lib/libjos.a entry.S Assembly-language entry-point for user environments libmain.c User-mode library setup code called from entry.S syscall.c User-mode system call stub functions console.c User-mode implementations of putchar and getchar, providing console I/O exit.c User-mode implementation of exit panic.c User-mode implementation of panicuser/ * Various test programs to check kernel lab 3 code
另外,一些在實驗 2 中的原始檔在實驗 3 中將被修改。如果想去檢視有什麼更改,可以執行:
$ git diff lab2
你也可以另外去看一下 ,它包含了與本實驗有關的偵錯使用者程式碼方面的資訊。
本實驗分為兩部分:Part A 和 Part B。Part A 在本實驗完成後一週內提交;你將要提交你的更改和完成的動手實驗,在提交之前要確保你的程式碼通過了 Part A 的所有檢查(如果你的程式碼未通過 Part B 的檢查也可以提交)。只需要在第二週提交 Part B 的期限之前程式碼檢查通過即可。
由於在實驗 2 中,你需要做實驗中描述的所有正規表示式練習,並且至少通過一個挑戰(是指整個實驗,不是每個部分)。寫出詳細的問題答案並張貼在實驗中,以及一到兩個段落的關於你如何解決你選擇的挑戰問題的詳細描述,並將它放在一個名為 answers-lab3.txt
的檔案中,並將這個檔案放在你的 lab
目標的根目錄下。(如果你做了多個問題挑戰,你僅需要提交其中一個即可)不要忘記使用 git add answers-lab3.txt
提交這個檔案。
在本實驗中你可能發現使用了 GCC 的行內組合語言特性,雖然不使用它也可以完成實驗。但至少你需要去理解這些行內組合語言片段,這些組合語言(asm
語句)片段已經存在於提供給你的原始碼中。你可以在課程 參考資料 的頁面上找到 GCC 行內組合語言有關的資訊。
新檔案 inc/env.h
中包含了在 JOS 中關於使用者環境的基本定義。現在就去閱讀它。核心使用資料結構 Env
去保持對每個使用者環境的跟蹤。在本實驗的開始,你將只建立一個環境,但你需要去設計 JOS 核心支援多環境;實驗 4 將帶來這個高階特性,允許使用者環境去 fork
其它環境。
正如你在 kern/env.c
中所看到的,核心維護了與環境相關的三個全域性變數:
struct Env *envs = NULL; // All environmentsstruct Env *curenv = NULL; // The current envstatic struct Env *env_free_list; // Free environment list
一旦 JOS 啟動並執行,envs
指標指向到一個陣列,即資料結構 Env
,它儲存了系統中全部的環境。在我們的設計中,JOS 核心將同時支援最大值為 NENV
個的活動的環境,雖然在一般情況下,任何給定時刻執行的環境很少。(NENV
是在 inc/env.h
中用 #define
定義的一個常數)一旦它被分配,對於每個 NENV
可能的環境,envs
陣列將包含一個資料結構 Env
的單個範例。
JOS 核心在 env_free_list
上用資料結構 Env
儲存了所有不活動的環境。這樣的設計使得環境的分配和回收很容易,因為這只不過是新增或刪除空閒列表的問題而已。
核心使用符號 curenv
來保持對任意給定時刻的 當前正在執行的環境 進行跟蹤。在系統引導期間,在第一個環境執行之前,curenv
被初始化為 NULL
。
資料結構 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; // env_id of this env's parent enum EnvType env_type; // Indicates special system environments unsigned env_status; // Status of the environment uint32_t env_runs; // Number of times environment has run // Address space pde_t *env_pgdir; // Kernel virtual address of page dir};
以下是資料結構 Env
中的欄位簡介:
env_tf
: 這個結構定義在 inc/trap.h
中,它用於在那個環境不執行時保持它儲存在暫存器中的值,即:當核心或一個不同的環境在執行時。當從使用者模式切換到核心模式時,核心將儲存這些東西,以便於那個環境能夠在稍後重新執行時回到中斷執行的地方。env_link
: 這是一個連結,它連結到在 env_free_list
上的下一個 Env
上。env_free_list
指向到列表上第一個空閒的環境。env_id
: 核心在資料結構 Env
中儲存了一個唯一標識當前環境的值(即:使用陣列 envs
中的特定槽位)。在一個使用者環境終止之後,核心可能給另外的環境重新分配相同的資料結構 Env
—— 但是新的環境將有一個與已終止的舊的環境不同的 env_id
,即便是新的環境在陣列 envs
中複用了同一個槽位。env_parent_id
: 核心使用它來儲存建立這個環境的父級環境的 env_id
。通過這種方式,環境就可以形成一個“家族樹”,這對於做出“哪個環境可以對誰做什麼”這樣的安全決策非常有用。env_type
: 它用於去區分特定的環境。對於大多數環境,它將是 ENV_TYPE_USER
的。在稍後的實驗中,針對特定的系統服務環境,我們將引入更多的幾種型別。env_status
: 這個變數持有以下幾個值之一:ENV_FREE
: 表示那個 Env
結構是非活動的,並且因此它還在 env_free_list
上。ENV_RUNNABLE
: 表示那個 Env
結構所代表的環境正等待被排程到處理器上去執行。ENV_RUNNING
: 表示那個 Env
結構所代表的環境當前正在執行中。ENV_NOT_RUNNABLE
: 表示那個 Env
結構所代表的是一個當前活動的環境,但不是當前準備去執行的:例如,因為它正在因為一個來自其它環境的進程間通訊(IPC)而處於等待狀態。ENV_DYING
: 表示那個 Env
結構所表示的是一個殭屍環境。一個殭屍環境將在下一次被核心捕獲後被釋放。我們在實驗 4 之前不會去使用這個標誌。env_pgdir
: 這個變數持有這個環境的核心虛擬地址的頁目錄。就像一個 Unix 進程一樣,一個 JOS 環境耦合了“執行緒”和“地址空間”的概念。執行緒主要由儲存的暫存器來定義(env_tf
欄位),而地址空間由頁目錄和 env_pgdir
所指向的頁表所定義。為執行一個環境,核心必須使用儲存的暫存器值和相關的地址空間去設定 CPU。
我們的 struct Env
與 xv6 中的 struct proc
類似。它們都在一個 Trapframe
結構中持有環境(即進程)的使用者模式暫存器狀態。在 JOS 中,單個的環境並不能像 xv6 中的進程那樣擁有它們自己的核心棧。在這裡,核心中任意時間只能有一個 JOS 環境處於活動中,因此,JOS 僅需要一個單個的核心棧。
在實驗 2 的 mem_init()
中,你為陣列 pages[]
分配了記憶體,它是核心用於對頁面分配與否的狀態進行跟蹤的一個表。你現在將需要去修改 mem_init()
,以便於後面使用它分配一個與結構 Env
類似的陣列,這個陣列被稱為 envs
。
練習 1、修改在
kern/pmap.c
中的mem_init()
,以用於去分配和對映envs
陣列。這個陣列完全由Env
結構分配的範例NENV
組成,就像你分配的pages
陣列一樣。與pages
陣列一樣,由記憶體支援的陣列envs
也將在UENVS
(它的定義在inc/memlayout.h
檔案中)中對映使用者唯讀的記憶體,以便於使用者進程能夠從這個陣列中讀取。
你應該去執行你的程式碼,並確保 check_kern_pgdir()
是沒有問題的。
現在,你將在 kern/env.c
中寫一些必需的程式碼去執行一個使用者環境。因為我們並沒有做一個檔案系統,因此,我們將設定核心去載入一個嵌入到核心中的靜態的二進位制映象。JOS 核心以一個 ELF 可執行映象的方式將這個二進位制映象嵌入到核心中。
在實驗 3 中,GNUmakefile
將在 obj/user/
目錄中生成一些二進位制映象。如果你看到 kern/Makefrag
,你將注意到一些奇怪的的東西,它們“連結”這些二進位制直接進入到核心中執行,就像 .o
檔案一樣。在連結器命令列上的 -b binary
選項,將因此把它們連結為“原生的”不解析的二進位制檔案,而不是由編譯器產生的普通的 .o
檔案。(就連結器而言,這些檔案壓根就不是 ELF 映象檔案 —— 它們可以是任何東西,比如,一個文字檔案或圖片!)如果你在核心構建之後檢視 obj/kern/kernel.sym
,你將會注意到連結器很奇怪的生成了一些有趣的、命名很費解的符號,比如像 _binary_obj_user_hello_start
、_binary_obj_user_hello_end
、以及 _binary_obj_user_hello_size
。連結器通過改編二進位制檔案的命令來生成這些符號;這種符號為普通核心程式碼使用一種引入嵌入式二進位制檔案的方法。
在 kern/init.c
的 i386_init()
中,你將寫一些程式碼在環境中執行這些二進位制映象中的一種。但是,設定使用者環境的關鍵函數還沒有實現;將需要你去完成它們。
練習 2、在檔案
env.c
中,寫完以下函數的程式碼:
env_init()
初始化
envs
陣列中所有的Env
結構,然後把它們新增到env_free_list
中。也稱為env_init_percpu
,它通過設定硬體,在硬體上為 level 0(核心)許可權和 level 3(使用者)許可權使用單獨的段。
env_setup_vm()
為一個新環境分配一個頁目錄,並初始化新環境的地址空間的核心部分。
region_alloc()
為一個新環境分配和對映實體記憶體
load_icode()
你將需要去解析一個 ELF 二進位制映象,就像引導載入器那樣,然後載入它的內容到一個新環境的使用者地址空間中。
env_create()
使用
env_alloc
去分配一個環境,並呼叫load_icode
去載入一個 ELF 二進位制
env_run()
在使用者模式中開始執行一個給定的環境
在你寫這些函數時,你可能會發現新的 cprintf 動詞
%e
非常有用 – 它可以輸出一個錯誤程式碼的相關描述。比如:r = -E_NO_MEM; panic("env_alloc: %e", r);
中 panic 將輸出訊息
env_alloc: out of memory。
下面是使用者程式碼相關的呼叫圖。確保你理解了每一步的用途。
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 發現沒有系統呼叫中斷的服務程式時,它將生成一個一般保護異常,找到那個異常並去處理它,還將生成一個雙重故障異常,同樣也找到它並處理它,並且最後會出現所謂的“三重故障異常”。通常情況下,你將隨後看到 CPU 復位以及系統重引導。雖然對於傳統的應用程式(在 這篇部落格文章 中解釋了原因)這是重大的問題,但是對於核心開發來說,這是一個痛苦的過程,因此,在打了 6.828 修補程式的 QEMU 上,你將可以看到轉儲的暫存器內容和一個“三重故障”的資訊。
我們馬上就會去處理這些問題,但是現在,我們可以使用偵錯程式去檢查我們是否進入了使用者模式。使用 make qemu-gdb
並在 env_pop_tf
處設定一個 GDB 斷點,它是你進入使用者模式之前到達的最後一個函數。使用 si
單步進入這個函數;處理器將在 iret
指令之後進入使用者模式。然後你將會看到在使用者環境執行的第一個指令,它將是在 lib/entry.S
中的標籤 start
的第一個指令 cmpl
。現在,在 hello
中的 sys_cputs()
的 int $0x30
處使用 b *0x...
(關於使用者空間的地址,請檢視 obj/user/hello.asm
)設定斷點。這個指令 int
是系統呼叫去顯示一個字元到控制台。如果到 int
還沒有執行,那麼可能在你的地址空間設定或程式載入程式碼時發生了錯誤;返回去找到問題並解決後重新執行。
到目前為止,在使用者空間中的第一個系統呼叫指令 int $0x30
已正式壽終正寢了:一旦處理器進入使用者模式,將無法返回。因此,現在,你需要去實現基本的異常和系統呼叫服務程式,因為那樣才有可能讓核心從使用者模式程式碼中恢復對處理器的控制。你所做的第一件事情就是徹底地掌握 x86 的中斷和異常機制的使用。
練習 3、如果你對中斷和異常機制不熟悉的話,閱讀 80386 程式設計師手冊的第 9 章(或 IA-32 開發者手冊的第 5 章)。
在這個實驗中,對於中斷、異常、以其它類似的東西,我們將遵循 Intel 的術語習慣。由於如異常、陷阱、中斷、故障和中止這些術語在不同的架構和作業系統上並沒有一個統一的標準,我們經常在特定的架構下(如 x86)並不去考慮它們之間的細微差別。當你在本實驗以外的地方看到這些術語時,它們的含義可能有細微的差別。
異常和中斷都是“受保護的控制轉移”,它將導致處理器從使用者模式切換到核心模式(CPL=0
)而不會讓使用者模式的程式碼干擾到核心的其它函數或其它的環境。在 Intel 的術語中,一個中斷就是一個“受保護的控制轉移”,它是由於處理器以外的外部非同步事件所引發的,比如外部裝置 I/O 活動通知。而異常正好與之相反,它是由當前正在執行的程式碼所引發的同步的、受保護的控制轉移,比如由於發生了一個除零錯誤或對無效記憶體的存取。
為了確保這些受保護的控制轉移是真正地受到保護,處理器的中斷/異常機制設計是:當中斷/異常發生時,當前執行的程式碼不能隨意選擇進入核心的位置和方式。而是,處理器在確保核心能夠嚴格控制的條件下才能進入核心。在 x86 上,有兩種機制協同來提供這種保護:
中斷描述符表 處理器確保中斷和異常僅能夠導致核心進入幾個特定的、由核心本身定義好的、明確的入口點,而不是去執行中斷或異常發生時的程式碼。
x86 允許最多有 256 個不同的中斷或異常入口點去進入核心,每個入口點都使用一個不同的中斷向量。一個向量是一個介於 0 和 255 之間的數位。一個中斷向量是由中斷源確定的:不同的裝置、錯誤條件、以及應用程式去請求核心使用不同的向量生成中斷。CPU 使用向量作為進入處理器的中斷描述符表(IDT)的索引,它是核心設定的核心私有記憶體,GDT 也是。從這個表中的適當的條目中,處理器將載入:
任務狀態描述符表 處理器在中斷或異常發生時,需要一個地方去儲存舊的處理器狀態,比如,處理器在呼叫異常服務程式之前的 EIP
和 CS
的原始值,這樣那個異常服務程式就能夠稍後通過還原舊的狀態來回到中斷發生時的程式碼位置。但是對於已儲存的處理器的舊狀態必須被保護起來,不能被無許可權的使用者模式程式碼存取;否則程式碼中的 bug 或惡意使用者程式碼將危及核心。
基於這個原因,當一個 x86 處理器產生一個中斷或陷阱時,將導致許可權級別的變更,從使用者模式轉換到核心模式,它也將導致在核心的記憶體中發生棧切換。有一個被稱為 TSS 的任務狀態描述符表規定段描述符和這個棧所處的地址。處理器在這個新棧上推播 SS
、ESP
、EFLAGS
、CS
、EIP
、以及一個可選的錯誤程式碼。然後它從中斷描述符上載入 CS
和 EIP
的值,然後設定 ESP
和 SS
去指向新的棧。
雖然 TSS 很大並且默默地為各種用途服務,但是 JOS 僅用它去定義當從使用者模式到核心模式的轉移發生時,處理器即將切換過去的核心棧。因為在 JOS 中的“核心模式”僅執行在 x86 的執行級別 0 許可權上,當進入核心模式時,處理器使用 TSS 上的 ESP0
和 SS0
欄位去定義核心棧。JOS 並不去使用 TSS 的任何其它欄位。
所有的 x86 處理器上的同步異常都能夠產生一個內部使用的、介於 0 到 31 之間的中斷向量,因此它對映到 IDT 就是條目 0-31。例如,一個頁故障總是通過向量 14 引發一個異常。大於 31 的中斷向量僅用於軟體中斷,它由 int
指令生成,或非同步硬體中斷,當需要時,它們由外部裝置產生。
在這一節中,我們將擴充套件 JOS 去處理向量為 0-31 之間的、內部產生的 x86 異常。在下一節中,我們將完成 JOS 的 48(0x30)號軟體中斷向量,JOS 將(隨意選擇的)使用它作為系統呼叫中斷向量。在實驗 4 中,我們將擴充套件 JOS 去處理外部生成的硬體中斷,比如時鐘中斷。
我們把這些片斷綜合到一起,通過一個範例來鞏固一下。我們假設處理器在使用者環境下執行程式碼,遇到一個除零問題。
SS0
和 ESP0
定義的棧,在 JOS 中,它們各自儲存著值 GD_KD
和 KSTACKTOP
。處理器在核心棧上推入異常引數,起始地址為 KSTACKTOP
:
+--------------------+ KSTACKTOP | 0x00000 | old SS | " - 4| old ESP | " - 8| old EFLAGS | " - 12| 0x00000 | old CS | " - 16| old EIP | " - 20 <---- ESP +--------------------+
由於我們要處理一個除零錯誤,它將在 x86 上產生一個中斷向量 0,處理器讀取 IDT 的條目 0,然後設定 CS:EIP
去指向由條目描述的處理常式。
處理服務程式函數將接管控制權並處理異常,例如中止使用者環境。
對於某些型別的 x86 異常,除了以上的五個“標準的”暫存器外,處理器還推入另一個包含錯誤程式碼的暫存器值到棧中。頁故障異常,向量號為 14,就是一個重要的範例。檢視 80386 手冊去確定哪些異常推入一個錯誤程式碼,以及錯誤程式碼在那個案例中的意義。當處理器推入一個錯誤程式碼後,當從使用者模式中進入核心模式,例外處理服務程式開始時的棧看起來應該如下所示:
+--------------------+ KSTACKTOP | 0x00000 | old SS | " - 4 | old ESP | " - 8 | old EFLAGS | " - 12 | 0x00000 | old CS | " - 16 | old EIP | " - 20 | error code | " - 24 <---- ESP +--------------------+
處理器能夠處理來自使用者和核心模式中的異常和中斷。當收到來自使用者模式的異常和中斷時才會進入核心模式中,而且,在推播它的舊暫存器狀態到棧中和通過 IDT 呼叫相關的異常服務程式之前,x86 處理器會自動切換棧。如果當異常或中斷發生時,處理器已經處於核心模式中(CS
暫存器低位兩個位元為 0),那麼 CPU 只是推入一些值到相同的核心棧中。在這種方式中,核心可以優雅地處理巢狀的異常,巢狀的異常一般由核心本身的程式碼所引發。在實現保護時,這種功能是非常重要的工具,我們將在稍後的系統呼叫中看到它。
如果處理器已經處於核心模式中,並且發生了一個巢狀的異常,由於它並不需要切換棧,它也就不需要去儲存舊的 SS
或 ESP
暫存器。對於不推入錯誤程式碼的異常型別,在進入到異常服務程式時,它的核心棧看起來應該如下圖:
+--------------------+ <---- old ESP | old EFLAGS | " - 4 | 0x00000 | old CS | " - 8 | old EIP | " - 12 +--------------------+
對於需要推入一個錯誤程式碼的異常型別,處理器將在舊的 EIP
之後,立即推入一個錯誤程式碼,就和前面一樣。
關於處理器的異常巢狀的功能,這裡有一個重要的警告。如果處理器正處於核心模式時發生了一個異常,並且不論是什麼原因,比如棧空間洩漏,都不會去推播它的舊的狀態,那麼這時處理器將不能做任何的恢復,它只是簡單地重置。毫無疑問,核心應該被設計為禁止發生這種情況。
到目前為止,你應該有了在 JOS 中為了設定 IDT 和處理異常所需的基本資訊。現在,我們去設定 IDT 以處理中斷向量 0-31(處理器異常)。我們將在本實驗的稍後部分處理系統呼叫,然後在後面的實驗中增加中斷 32-47(裝置 IRQ)。
在標頭檔案 inc/trap.h
和 kern/trap.h
中包含了中斷和異常相關的重要定義,你需要去熟悉使用它們。在檔案kern/trap.h
中包含了到核心的、嚴格的、秘密的定義,可是在 inc/trap.h
中包含的定義也可以被用到使用者級程式和庫上。
注意:在範圍 0-31 中的一些異常是被 Intel 定義為保留。因為在它們的處理器上從未產生過,你如何處理它們都不會有大問題。你想如何做它都是可以的。
你將要實現的完整的控制流如下圖所描述:
IDT trapentry.S trap.c +----------------+ | &handler1 |----> handler1: trap (struct Trapframe *tf)| | // do stuff {| | call trap // handle the exception/interrupt| | // ... }+----------------+| &handler2 |----> handler2:| | // do stuff| | call trap| | // ...+----------------+ . . .+----------------+| &handlerX |----> handlerX:| | // do stuff| | call trap| | // ...+----------------+
每個異常或中斷都應該在 trapentry.S
中有它自己的處理程式,並且 trap_init()
應該使用這些處理程式的地址去初始化 IDT。每個處理程式都應該在棧上構建一個 struct Trapframe
(檢視 inc/trap.h
),然後使用一個指標呼叫 trap()
(在 trap.c
中)到 Trapframe
。trap()
接著處理異常/中斷或派發給一個特定的處理常式。
練習 4、編輯
trapentry.S
和trap.c
,然後實現上面所描述的功能。在trapentry.S
中的宏TRAPHANDLER
和TRAPHANDLER_NOEC
將會幫你,還有在inc/trap.h
中的 T_* defines。你需要在trapentry.S
中為每個定義在inc/trap.h
中的陷阱新增一個入口點(使用這些宏),並且你將有 t、o 提供的_alltraps
,這是由宏TRAPHANDLER
指向到它。你也需要去修改trap_init()
來初始化idt
,以使它指向到每個在trapentry.S
中定義的入口點;宏SETGATE
將有助你實現它。你的
_alltraps
應該:
- 推播值以使棧看上去像一個結構 Trapframe
- 載入
GD_KD
到%ds
和%es
pushl %esp
去傳遞一個指標到 Trapframe 以作為一個 trap() 的引數call trap
(trap
能夠返回嗎?)考慮使用
pushal
指令;它非常適合struct Trapframe
的布局。使用一些在
user
目錄中的測試程式來測試你的陷阱處理程式碼,這些測試程式在生成任何系統呼叫之前能引發異常,比如user/divzero
。在這時,你應該能夠成功完成divzero
、softint
、以有badsegment
測試。
.
小挑戰!目前,在
trapentry.S
中列出的TRAPHANDLER
和他們安裝在trap.c
中可能有許多程式碼非常相似。清除它們。修改trapentry.S
中的宏去自動為trap.c
生成一個表。注意,你可以直接使用.text
和.data
在組合器中切換放置其中的程式碼和資料。
.
問題
在你的
answers-lab3.txt
中回答下列問題:
- 為每個異常/中斷設定一個獨立的服務程式函數的目的是什麼?(即:如果所有的異常/中斷都傳遞給同一個服務程式,在我們的當前實現中能否提供這樣的特性?)
- 你需要做什麼事情才能讓
user/softint
程式正常執行?評級指令碼預計將會產生一個一般保護故障(trap 13),但是softint
的程式碼顯示為int $14
。為什麼它產生的中斷向量是 13?如果核心允許softint
的int $14
指令去呼叫核心頁故障的服務程式(它的中斷向量是 14)會發生什麼事情? “`
本實驗的 Part A 部分結束了。不要忘了去新增 answers-lab3.txt
檔案,提交你的變更,然後在 Part A 作業的提交截止日期之前執行 make handin
。
現在,你的核心已經有了最基本的例外處理能力,你將要去繼續改進它,來提供依賴異常服務程式的作業系統原語。
頁故障異常,中斷向量為 14(T_PGFLT
),它是一個非常重要的東西,我們將通過本實驗和接下來的實驗來大量練習它。當處理器產生一個頁故障時,處理器將在它的一個特定的控制暫存器(CR2
)中儲存導致這個故障的線性地址(即:虛擬地址)。在 trap.c
中我們提供了一個專門處理它的函數的一個雛形,它就是 page_fault_handler()
,我們將用它來處理頁故障異常。
練習 5、修改
trap_dispatch()
將頁故障異常派發到page_fault_handler()
上。你現在應該能夠成功測試faultread
、faultreadkernel
、faultwrite
和faultwritekernel
了。如果它們中的任何一個不能正常工作,找出問題並修復它。記住,你可以使用make run-x
或make run-x-nox
去重引導 JOS 進入到一個特定的使用者程式。比如,你可以執行make run-hello-nox
去執行hello
使用者程式。
下面,你將進一步細化核心的頁故障服務程式,因為你要實現系統呼叫了。
斷點異常,中斷向量為 3(T_BRKPT
),它一般用在偵錯上,它在一個程式程式碼中插入斷點,從而使用特定的 1 位元組的 int3
軟體中斷指令來臨時替換相應的程式指令。在 JOS 中,我們將稍微“濫用”一下這個異常,通過將它打造成一個偽系統呼叫原語,使得任何使用者環境都可以用它來呼叫 JOS 核心監視器。如果我們將 JOS 核心監視認為是原始偵錯程式,那麼這種用法是合適的。例如,在 lib/panic.c
中實現的使用者模式下的 panic()
,它在顯示它的 panic
訊息後執行一個 int3
中斷。
練習 6、修改
trap_dispatch()
,讓它在呼叫核心監視器時產生一個斷點異常。你現在應該可以在breakpoint
上成功完成測試。
.
小挑戰!修改 JOS 核心監視器,以便於你能夠從當前位置(即:在
int3
之後,斷點異常呼叫了核心監視器) ‘繼續’ 異常,並且因此你就可以一次執行一個單步指令。為了實現單步執行,你需要去理解EFLAGS
暫存器中的某些位元的意義。可選:如果你富有冒險精神,找一些 x86 反組合的程式碼 —— 即通過從 QEMU 中、或從 GNU 二進位制工具中分離、或你自己編寫 —— 然後擴充套件 JOS 核心監視器,以使它能夠反組合,顯示你的每步的指令。結合實驗 1 中的符號表,這將是你寫的一個真正的核心偵錯程式。
.
問題
在斷點測試案例中,根據你在 IDT 中如何初始化斷點條目的不同情況(即:你的從
trap_init
到SETGATE
的呼叫),既有可能產生一個斷點異常,也有可能產生一個一般保護故障。為什麼?為了能夠像上面的案例那樣工作,你需要如何去設定它,什麼樣的不正確設定才會觸發一個一般保護故障?你認為這些機制的意義是什麼?尤其是要考慮
user/softint
測試程式的工作原理。
使用者進程請求核心為它做事情就是通過系統呼叫來實現的。當使用者進程請求一個系統呼叫時,處理器首先進入核心模式,處理器和核心配合去儲存使用者進程的狀態,核心為了完成系統呼叫會執行有關的程式碼,然後重新回到使用者進程。使用者進程如何獲得核心的關注以及它如何指定它需要的系統呼叫的具體細節,這在不同的系統上是不同的。
在 JOS 核心中,我們使用 int
指令,它將導致產生一個處理器中斷。尤其是,我們使用 int $0x30
作為系統呼叫中斷。我們定義常數 T_SYSCALL
為 48(0x30)。你將需要去設定中斷描述符表,以允許使用者進程去觸發那個中斷。注意,那個中斷 0x30 並不是由硬體生成的,因此允許使用者程式碼去產生它並不會引起歧義。
應用程式將在暫存器中傳遞系統呼叫號和系統呼叫引數。通過這種方式,核心就不需要去遍歷使用者環境的棧或指令流。系統呼叫號將放在 %eax
中,而引數(最多五個)將分別放在 %edx
、%ecx
、%ebx
、%edi
、和 %esi
中。核心將在 %eax
中傳遞返回值。在 lib/syscall.c
中的 syscall()
中已為你編寫了使用一個系統呼叫的組合程式碼。你可以通過閱讀它來確保你已經理解了它們都做了什麼。
練習 7、在核心中為中斷向量
T_SYSCALL
新增一個服務程式。你將需要去編輯kern/trapentry.S
和kern/trap.c
的trap_init()
。還需要去修改trap_dispatch()
,以便於通過使用適當的引數來呼叫syscall()
(定義在kern/syscall.c
)以處理系統呼叫中斷,然後將系統呼叫的返回值安排在%eax
中傳遞給使用者進程。最後,你需要去實現kern/syscall.c
中的syscall()
。如果系統呼叫號是無效值,確保syscall()
返回值一定是-E_INVAL
。為確保你理解了系統呼叫的介面,你應該去閱讀和掌握lib/syscall.c
檔案(尤其是行內組合的動作),對於在inc/syscall.h
中列出的每個系統呼叫都需要通過呼叫相關的核心函數來處理A。在你的核心中執行
user/hello
程式(make run-hello)。它應該在控制台上輸出hello, world
,然後在使用者模式中產生一個頁故障。如果沒有產生頁故障,可能意味著你的系統呼叫服務程式不太正確。現在,你應該有能力成功通過testbss
測試。
.
小挑戰!使用
sysenter
和sysexit
指令而不是使用int 0x30
和iret
來實現系統呼叫。
sysenter/sysexit
指令是由 Intel 設計的,它的執行速度要比int/iret
指令快。它使用暫存器而不是棧來做到這一點,並且通過假定了分段暫存器是如何使用的。關於這些指令的詳細內容可以在 Intel 參考手冊 2B 卷中找到。在 JOS 中新增對這些指令支援的最容易的方法是,在
kern/trapentry.S
中新增一個sysenter_handler
,在它裡面儲存足夠多的關於使用者環境返回、設定核心環境、推播引數到syscall()
、以及直接呼叫syscall()
的資訊。一旦syscall()
返回,它將設定好執行sysexit
指令所需的一切東西。你也將需要在kern/init.c
中新增一些程式碼,以設定特殊模組暫存器(MSRs)。在 AMD 架構程式設計師手冊第 2 捲的 6.1.2 節中和 Intel 參考手冊的 2B 捲的 SYSENTER 上都有關於 MSRs 的很詳細的描述。對於如何去寫 MSRs,在這裡你可以找到一個新增到inc/x86.h
中的wrmsr
的實現。最後,
lib/syscall.c
必須要修改,以便於支援用sysenter
來生成一個系統呼叫。下面是sysenter
指令的一種可能的暫存器布局:eax - syscall numberedx, ecx, ebx, edi - arg1, arg2, arg3, arg4esi - return pcebp - return espesp - trashed by sysenter
GCC 的內聯組合器將自動儲存你告訴它的直接載入進暫存器的值。不要忘了同時去儲存(
push
)和恢復(pop
)你使用的其它暫存器,或告訴內聯組合器你正在使用它們。內聯組合器不支援儲存%ebp
,因此你需要自己去增加一些程式碼來儲存和恢復它們,返回地址可以使用一個像leal after_sysenter_label, %%esi
的指令置入到%esi
中。注意,它僅支援 4 個引數,因此你需要保留支援 5 個引數的系統呼叫的舊方法。而且,因為這個快速路徑並不更新當前環境的 trap 幀,因此,在我們新增到後續實驗中的一些系統呼叫上,它並不適合。
在接下來的實驗中我們啟用了非同步中斷,你需要再次去評估一下你的程式碼。尤其是,當返回到使用者進程時,你需要去啟用中斷,而
sysexit
指令並不會為你去做這一動作。
一個使用者程式是從 lib/entry.S
的頂部開始執行的。在一些設定之後,程式碼呼叫 lib/libmain.c
中的 libmain()
。你應該去修改 libmain()
以初始化全域性指標 thisenv
,使它指向到這個環境在陣列 envs[]
中的 struct Env
。(注意那個 lib/entry.S
中已經定義 envs
去指向到在 Part A 中對映的你的設定。)提示:檢視 inc/env.h
和使用 sys_getenvid
。
libmain()
接下來呼叫 umain
,在 hello 程式的案例中,umain
是在 user/hello.c
中。注意,它在輸出 ”hello, world
” 之後,它嘗試去存取 thisenv->env_id
。這就是為什麼前面會發生故障的原因了。現在,你已經正確地初始化了 thisenv
,它應該不會再發生故障了。如果仍然會發生故障,或許是因為你沒有對映 UENVS
區域為使用者可讀取(回到前面 Part A 中 檢視 pmap.c
);這是我們第一次真實地使用 UENVS
區域)。
練習 8、新增要求的程式碼到使用者庫,然後引導你的核心。你應該能夠看到
user/hello
程式會輸出hello, world
然後輸出i am environment 00001000
。user/hello
接下來會通過呼叫sys_env_destroy()
(檢視lib/libmain.c
和lib/exit.c
)嘗試去“退出”。由於核心目前僅支援一個使用者環境,它應該會報告它毀壞了唯一的環境,然後進入到核心監視器中。現在你應該能夠成功通過hello
的測試。
記憶體保護是一個作業系統中最重要的特性,通過它來保證一個程式中的 bug 不會破壞其它程式或作業系統本身。
作業系統一般是依靠硬體的支援來實現記憶體保護。作業系統會告訴硬體哪些虛擬地址是有效的,而哪些是無效的。當一個程式嘗試去存取一個無效地址或它沒有存取許可權的地址時,處理器會在導致故障發生的位置停止程式執行,然後捕獲核心中關於嘗試操作的相關資訊。如果故障是可修復的,核心可能修復它並讓程式繼續執行。如果故障不可修復,那麼程式就不能繼續,因為它絕對不會跳過那個導致故障的指令。
作為一個可修復故障的範例,假設一個自動擴充套件的棧。在許多系統上,核心初始化分配一個單棧頁,然後如果程式發生的故障是去存取這個棧頁下面的頁,那麼核心會自動分配這些頁,並讓程式繼續執行。通過這種方式,核心只分配程式所需要的記憶體棧,但是程式可以執行在一個任意大小的棧的假像中。
對於記憶體保護,系統呼叫中有一個非常有趣的問題。許多系統呼叫介面讓使用者程式傳遞指標到核心中。這些指標指向使用者要讀取或寫入的緩衝區。然後核心在執行系統呼叫時廢棄這些指標。這樣就有兩個問題:
由於以上的原因,核心在處理由使用者程式提供的指標時必須格外小心。
現在,你可以通過使用一個簡單的機制來仔細檢查所有從使用者空間傳遞給核心的指標來解決這個問題。當一個程式給核心傳遞指標時,核心將檢查它的地址是否在地址空間的使用者部分,然後頁表才允許對記憶體的操作。
這樣,核心在廢棄一個使用者提供的指標時就絕不會發生頁故障。如果核心出現這種頁故障,它應該崩潰並終止。
練習 9、如果在核心模式中發生一個頁故障,修改
kern/trap.c
去崩潰。提示:判斷一個頁故障是發生在使用者模式還是核心模式,去檢查
tf_cs
的低位位元即可。閱讀
kern/pmap.c
中的user_mem_assert
並在那個檔案中實現user_mem_check
。修改
kern/syscall.c
去常態化檢查傳遞給系統呼叫的引數。引導你的核心,執行
user/buggyhello
。環境將被毀壞,而核心將不會崩潰。你將會看到:[00001000] user_mem_check assertion failure for va 00000001[00001000] free env 00001000Destroyed the only environment - nothing more to do!
最後,修改在
kern/kdebug.c
中的debuginfo_eip
,在usd
、stabs
、和stabstr
上呼叫user_mem_check
。如果你現在執行user/breakpoint
,你應該能夠從核心監視器中執行回溯,然後在核心因頁故障崩潰前看到回溯進入到lib/libmain.c
。是什麼導致了這個頁故障?你不需要去修復它,但是你應該明白它是如何發生的。
注意,剛才實現的這些機制也同樣適用於惡意使用者程式(比如 user/evilhello
)。
練習 10、引導你的核心,執行
user/evilhello
。環境應該被毀壞,並且核心不會崩潰。你應該能看到:[00000000] new env 00001000...[00001000] user_mem_check assertion failure for va f010000c[00001000] free env 00001000
本實驗到此結束。確保你通過了所有的等級測試,並且不要忘記去寫下問題的答案,在 answers-lab3.txt
中詳細描述你的挑戰練習的解決方案。提交你的變更並在 lab
目錄下輸入 make handin
去提交你的工作。
在動手實驗之前,使用 git status
和 git diff
去檢查你的變更,並不要忘記去 git add answers-lab3.txt
。當你完成後,使用 git commit -am 'my solutions to lab 3’
去提交你的變更,然後 make handin
並關注這個指南。