Caffeinated 6.828:實驗 3:用戶環境
簡介
在本實驗中,你將要實現一個基本的內核功能,要求它能夠保護運行的用戶模式環境(即:進程)。你將去增強這個 JOS 內核,去配置數據結構以便於保持對用戶環境的跟蹤、創建一個單一用戶環境、將程序鏡像載入到用戶環境中、並將它啟動運行。你也要寫出一些 JOS 內核的函數,用來處理任何用戶環境生成的系統調用,以及處理由用戶環境引進的各種異常。
注意: 在本實驗中,術語「環境」 和「進程」 是可互換的 —— 它們都表示同一個抽象概念,那就是允許你去運行的程序。我在介紹中使用術語「環境」而不是使用傳統術語「進程」的目的是為了強調一點,那就是 JOS 的環境和 UNIX 的進程提供了不同的介面,並且它們的語義也不相同。
預備知識
使用 Git 去提交你自實驗 2 以後的更改(如果有的話),獲取課程倉庫的最新版本,以及創建一個命名為 lab3
的本地分支,指向到我們的 lab3 分支上 origin/lab3
:
athena% cd ~/6.828/lab
athena% add git
athena% 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 pull
Already up-to-date.
athena% git checkout -b lab3 origin/lab3
Branch lab3 set up to track remote branch refs/remotes/origin/lab3.
Switched to a new branch "lab3"
athena% git merge lab2
Merge 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 library
kern/ 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 code
lib/ 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 panic
user/ * 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 行內彙編語言有關的信息。
Part A:用戶環境和異常處理
新文件 inc/env.h
中包含了在 JOS 中關於用戶環境的基本定義。現在就去閱讀它。內核使用數據結構 Env
去保持對每個用戶環境的跟蹤。在本實驗的開始,你將只創建一個環境,但你需要去設計 JOS 內核支持多環境;實驗 4 將帶來這個高級特性,允許用戶環境去 fork
其它環境。
正如你在 kern/env.c
中所看到的,內核維護了與環境相關的三個全局變數:
struct Env *envs = NULL; // All environments
struct Env *curenv = NULL; // The current env
static 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),它包含運行許可權為 0—1 級別的、要運行的異常服務程序。(在 JOS 中,所有的異常處理程序都運行在內核模式中,運行級別為 0。)
- 任務狀態描述符表 處理器在中斷或異常發生時,需要一個地方去保存舊的處理器狀態,比如,處理器在調用異常服務程序之前的
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 去處理外部生成的硬體中斷,比如時鐘中斷。
一個示例
我們把這些片斷綜合到一起,通過一個示例來鞏固一下。我們假設處理器在用戶環境下運行代碼,遇到一個除零問題。
- 處理器去切換到由 TSS 中的
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
之後,立即推入一個錯誤代碼,就和前面一樣。
關於處理器的異常嵌套的功能,這裡有一個重要的警告。如果處理器正處於內核模式時發生了一個異常,並且不論是什麼原因,比如棧空間泄漏,都不會去推送它的舊的狀態,那麼這時處理器將不能做任何的恢復,它只是簡單地重置。毫無疑問,內核應該被設計為禁止發生這種情況。
設置 IDT
到目前為止,你應該有了在 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
將會幫你,還有在MARKDOWN_HASHe330dae3dbffb0504ac0b528a3487c98MARKDOWNHASH
中的 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
。
Part B:頁故障、斷點異常、和系統調用
現在,你的內核已經有了最基本的異常處理能力,你將要去繼續改進它,來提供依賴異常服務程序的操作系統原語。
處理頁故障
頁故障異常,中斷向量為 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 number edx, ecx, ebx, edi - arg1, arg2, arg3, arg4 esi - return pc ebp - return esp esp - 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 不會破壞其它程序或操作系統本身。
操作系統一般是依靠硬體的支持來實現內存保護。操作系統會告訴硬體哪些虛擬地址是有效的,而哪些是無效的。當一個程序嘗試去訪問一個無效地址或它沒有訪問許可權的地址時,處理器會在導致故障發生的位置停止程序運行,然後捕獲內核中關於嘗試操作的相關信息。如果故障是可修復的,內核可能修復它並讓程序繼續運行。如果故障不可修復,那麼程序就不能繼續,因為它絕對不會跳過那個導致故障的指令。
作為一個可修復故障的示例,假設一個自動擴展的棧。在許多系統上,內核初始化分配一個單棧頁,然後如果程序發生的故障是去訪問這個棧頁下面的頁,那麼內核會自動分配這些頁,並讓程序繼續運行。通過這種方式,內核只分配程序所需要的內存棧,但是程序可以運行在一個任意大小的棧的假像中。
對於內存保護,系統調用中有一個非常有趣的問題。許多系統調用介面讓用戶程序傳遞指針到內核中。這些指針指向用戶要讀取或寫入的緩衝區。然後內核在執行系統調用時廢棄這些指針。這樣就有兩個問題:
- 內核中的頁故障可能比用戶程序中的頁故障多的多。如果內核在維護它自己的數據結構時發生頁故障,那就是一個內核 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 00001000 Destroyed 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
並關注這個指南。
via: https://pdos.csail.mit.edu/6.828/2018/labs/lab3/
作者:csail.mit 選題:lujun9972 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive