簡單回顧 在開始 lab3 的學習之前,我們先簡單回顧下 到目前為止,我們的內核能做了什麼: lab1中,我們學習了 PC啟動的過程,看到BIOS將我們編寫的boot loader 載入記憶體,然後通過bootloader 將內核載入記憶體。同時,使用了一個寫死的臨時頁表(entry_pgdir)完成了 ...
簡單回顧
在開始 lab3 的學習之前,我們先簡單回顧下 到目前為止,我們的內核能做了什麼:
lab1中,我們學習了 PC啟動的過程,看到BIOS將我們編寫的boot loader 載入記憶體,然後通過bootloader 將內核載入記憶體。同時,使用了一個寫死的臨時頁表(entry_pgdir)完成了簡單的地址映射;我們的內核最後執行monitor函數(一個簡單的shell),這是個看起來像是xxx管理系統的C語言課程設計程式,他接收命令行輸入,將輸入解析成命令,並逐個調用相關函數。
但是,問題在於,這樣簡單的頁表,只能映射4MB大小的物理記憶體,如果我們的內核代碼增加了(更不用說載入用戶進程了),4MB不夠用了,就直接G了,因此發展內核的當務之急就是解決記憶體的生存空間危機。因此lab2中,我們學習瞭如何通過 pageinfo 構成的數組pages 和 鏈表 page_free_list 來管理物理記憶體;然後學習了頁表的映射原理,並編寫代碼實現了增刪查改頁表,達到pageinfo和pte之間的映射和取消映射。擁有了這樣的基礎設施,我們可以將所有物理記憶體全部利用起來。
但是到現在為止,我們的JOS的功能還是只有一個簡單的monitor,無法載入用戶進程(或者說,載入運行其他的可執行文件)。為了能夠實現載入用戶進程,在lab3中,我們要實現進程載入、調度的基礎設施。
lab3主要內容是
- 完成進程管理的初始化
- 完成中斷管理的初始化
- 完成系統調用的中斷處理
- 完成記憶體保護
lab3 新增的代碼源文件如下,沒必要一開始就全看,跟著手冊遇到什麼看什麼,最後自然就看完了。
目錄 | 文件 | 備註 |
---|---|---|
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 |
Part A 用戶進程和異常處理
就像使用 pages 數組管理物理記憶體一樣,JOS 使用 envs 數組管理所有的進程。在 lab3 中,我們的目標是載入、運行一個用戶環境,但是一個操作系統當然要處理多個進程了,不過這是 lab4要做的事情了,現在我們要做的是熟悉JOS 維護進程的數據結構和相應的函數。
在 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
的常量。)分配完畢後,envs
數組將包含一個 Env
數據結構實例,用於表示每個 NENV
可能的環境。
JOS 內核會將所有不活動的 Env
結構保存在 env_free_list
中。這種設計可以方便地分配和取消分配環境,因為只需將它們添加到空閑列表或從空閑列表中移除即可。這和 page_free_list
異曲同工。
核使用 curenv
符號隨時跟蹤當前正在執行的環境。在啟動過程中,在第一個環境開始運行之前,curenv
初始化為 NULL
。
現在我們要先熟悉 env 結構體,其位於 inc/env.h
。
struct Env {
struct Trapframe env_tf; // 保存的寄存器
struct Env *env_link; // 下一個空閑的進程
envid_t env_id; // 進程的唯一標識符
envid_t env_parent_id; // 該進程的父進程的 env_id
enum EnvType env_type; // 用於標識是否是特殊的系統進程
unsigned env_status; // 進程狀態
uint32_t env_runs; // 進程運行次數
// Address space
pde_t *env_pgdir; // Kernel virtual address of page dir
};
可以看到,比較關鍵的有進程id、進程狀態、特殊進程標識,這些在 inc/env.h
中都有定義。比較令人迷惑的是這個運行次數,暫時不知道是什麼含義。先來看看進程ID的定義:
typedef int32_t envid_t;
// An environment ID 'envid_t' has three parts:
//
// +1+---------------21-----------------+--------10--------+
// |0| Uniqueifier | Environment |
// | | | Index |
// +------------------------------------+------------------+
// \--- ENVX(eid) --/
//
// The environment index ENVX(eid) equals the environment's index in the
// 'envs[]' array. The uniqueifier distinguishes environments that were
// created at different times, but share the same environment index.
//
// All real environments are greater than 0 (so the sign bit is zero).
// envid_ts less than 0 signify errors. The envid_t == 0 is special, and
// stands for the current environment.
#define LOG2NENV 10
#define NENV (1 << LOG2NENV)
#define ENVX(envid) ((envid) & (NENV - 1))
可以看到 env_id 本質上就是 32位整數,然後低10位代表其在envs中的下標,之後21位用來區分不同時間被創建的進程。這裡還是有點迷惑,一個進程被多次載入後,為啥要共用相同的 environment index
。
進程 status
這裡直接看手冊就行了,手冊帶著介紹了下 struct env以及相關知識。
分配進程數組
Exercise 1
練習 1. 修改 `kern/pmap.c` 中的 `mem_init()` ,分配並映射 `envs` 數組。該數組由 `Env` 結構的 `NENV` 實例組成,分配方式與分配頁面數組類似。與頁面數組一樣,支持 `envs` 的記憶體也應在 `UENVS`(定義於 `inc/mlayout.h` )處映射為用戶只讀,這樣用戶進程才能讀取該數組。
你應該運行代碼並確保 `check_kern_pgdir()` 成功。
[[lab3 - 翻譯#^988084]]
這裡和 lab2 的對pages
的處理是一樣的:
envs = (struct Env *)boot_alloc(sizeof(struct Env) * NENV);
memset(envs, 0, sizeof(struct Env) * NENV);
//...
boot_map_region(kern_pgdir, UENVS, PTSIZE, PADDR(envs), PTE_U | PTE_P);
經過 mem_init
之後記憶體映射情況如下:
創建並運行進程
經過 練習1,我們有了進程管理的基本數據結構,但是沒有初始化和維護這些數據結構的代碼,接下來就是實現這一切的重頭戲,在 kern/env.c
中編寫運行用戶環境所需的代碼 。
因為我們還沒有文件系統,所以我們 將設置內核來載入嵌入在內核本身中的靜態二進位映像 。JOS將該二進位文件作為ELF可執行映像嵌入內核。但是,如何將可執行文件嵌入到內核里呢?這種活當然是鏈接器來幹了,看看kern/Makefrag
,鏈接器命令行上的 -b
二進位選項會將這些文件作為 "原始 "未解釋的二進位文件鏈接進去,而不是編譯器生成的普通 .o
文件。
Paart A 的目標就是將用戶的進程運行起來,之前,我們的內核在最終會迴圈調用monitor。現在發生了一些變化:
void
i386_init(void)
{
extern char edata[], end[];
// Before doing anything else, complete the ELF loading process.
// Clear the uninitialized global data (BSS) section of our program.
// This ensures that all static/global variables start out zero.
memset(edata, 0, end - edata);
// Initialize the console.
// Can't call cprintf until after we do this!
cons_init();
cprintf("6828 decimal is %o octal!\n", 6828);
// Lab 2 memory management initialization functions
mem_init();
// Lab 3 user environment initialization functions
env_init();
trap_init();
#if defined(TEST)
// Don't touch -- used by grading script!
ENV_CREATE(TEST, ENV_TYPE_USER);
#else
// Touch all you want.
ENV_CREATE(user_hello, ENV_TYPE_USER);
#endif // TEST*
// We only have one user environment for now, so just run it.
env_run(&envs[0]);
}
在mem_init後又出現了 env_init、trap_init,然後就是用於創建進程的 ENV_CREATE,最後由 env_run將用戶進程執行起來,monitor被去掉了。
ENV_CREATE是個巨集,在這裡他的含義對 user_hello 這個用戶進程調用 env_create。
env_create(_binary_obj_user_hello_start, ENV_TYPE_USER)
該巨集定義於 kern/env.h :
#define ENV_PASTE3(x, y, z) x ## y ## z
#define ENV_CREATE(x, type) \
do { \
extern uint8_t ENV_PASTE3(_binary_obj_, x, _start)[]; \
env_create(ENV_PASTE3(_binary_obj_, x, _start), \
type); \
} while (0)
#endif // !JOS_KERN_ENV_H
Excercise 2
練習 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()
啟動以用戶模式運行的給定環境。
在編寫這些函數時,您可能會發現新的 cprintf verb %e 非常有用--它會列印出與錯誤代碼相對應的描述。例如r = -E_NO_MEM;
panic("env_alloc: %e", r);
就會出現 "env_alloc: 記憶體不足 "的提示。
env_init
就像 page_init 一樣將 envs 數組初始化
// 將 “envs ”中的所有環境標記為空閑環境,
// 將它們的 env_ids 設置為 0,並將它們插入 env_free_list 中。
// 確保環境在空閑列表中的順序與它們在 envs 數組中的順序一致(
// 也就是說,這樣第一次調用 env_alloc()時就會返回 envs[0])。
//
void
env_init(void)
{
// Set up envs array
// LAB 3: Your code here.
env_free_list = NULL;
for(int i = NENV-1;i>=0;--i){
envs[i].env_status = ENV_FREE;
envs[i].env_id = 0;
envs[i].env_link = env_free_list;
env_free_list = &envs[i];
}
// Per-CPU part of the initialization
env_init_percpu(); //載入 GDT 和段描述符。
}
註意 for
迴圈必須是從 NENV
至 0 進行遍歷。這樣才能保證 env_free_list 的循序和 envs 數組中的順序一致。
這裡看一眼 env_init_percpu()是乾什麼的
// 載入 GDT 和段描述符。
void
env_init_percpu(void)
{
lgdt(&gdt_pd);
// 內核從不使用 GS 或 FS,因此我們將其設置為用戶數據段。
asm volatile("movw %%ax,%%gs" : : "a" (GD_UD|3));
asm volatile("movw %%ax,%%fs" : : "a" (GD_UD|3));
// 內核會使用 ES、DS 和 SS。 我們將根據需要在內核和用戶數據段之間進行切換。
asm volatile("movw %%ax,%%es" : : "a" (GD_KD));
asm volatile("movw %%ax,%%ds" : : "a" (GD_KD));
asm volatile("movw %%ax,%%ss" : : "a" (GD_KD));
// 將內核文本段載入 CS。
asm volatile("ljmp %0,$1f\n 1:\n" : : "i" (GD_KT));
// 為了穩妥起見,清除本地描述符表(LDT),因為我們不使用它。
lldt(0);
}
env_setup_vm
// 為環境 e 初始化內核虛擬記憶體佈局。
// 分配一個頁面目錄,相應設置 e->env_pgdir,並初始化新進程地址空間的內核部分。
// 暫時不要將任何內容映射到環境虛擬地址空間的用戶部分。
//
// 成功時返回 0,錯誤時返回 <0。 錯誤包括
// -E_NO_MEM 如果頁面目錄或表無法分配。
//
static int
env_setup_vm(struct Env *e)
{
int i;
struct PageInfo *p = NULL;
// 為頁面目錄分配頁面
// 由於我們在構造一個新的pgdir,而不是向已經存在的kern_pgdir中插入 pde,或插入增加映射
// 我們不能使用 page_insert、pgdir_walk等用於頁表管理的方法,
// 只能通過 物理記憶體管理的方法,申請物理頁,並手動調整七計數
if (!(p = page_alloc(ALLOC_ZERO)))
return -E_NO_MEM;
// 現在,設置 e->env_pgdir 並初始化頁面目錄。
//
// 提示:
// 所有環境的 VA 空間在 UTOP 以上是相同的(UVPT 除外,我們在下麵設置)。
// 有關許可權和佈局,請參見 inc/memlayout.h。
// 能否將 kern_pgdir 用作模板? 提示:可以。
// (確保您在lab 2 中正確設置了許可權)。
// - UTOP 下麵的初始 VA 是空的。
// - 你不需要再調用 page_alloc。
// - 註意:一般情況下,pp_ref 不會被維護。
// 但 env_pgdir 是個例外 -- 你需要增加 env_pgdir 的 pp_ref 才能使 env_free 正常工作。
// - kern/pmap.h 中的函數非常方便。
// LAB 3: Your code here.
e->env_pgdir = page2kva(p);
memcpy(e->env_pgdir, kern_pgdir, PGSIZE);
// UVPT 將環境自身的頁表映射為只讀。
p->pp_ref ++;
// Permissions: kernel R, user R
e->env_pgdir[PDX(UVPT)] = PADDR(e->env_pgdir) | PTE_P | PTE_U;// p在此時被映射到UVPT
p->pp_ref ++;//由於直接調用了page_alloc,需要手動計數
return 0;
}
env_setup_vm 會在初始化一個 env 的時候發揮作用,這裡看看已經寫好的 env_alloc
env_alloc
// 分配並初始化一個新環境。
// 成功後,新環境將存儲在 *newenv_store 中。
//
// 成功時返回 0,失敗時返回 <0。 錯誤包括
// -E_NO_FREE_ENV 如果所有 NENV 環境都已分配完畢
// 記憶體耗盡時返回 E_NO_MEM
//
int
env_alloc(struct Env **newenv_store, envid_t parent_id)
{
int32_t generation;
int r;
struct Env *e;
if (!(e = env_free_list))//從空閑列表中取下一個 env
return -E_NO_FREE_ENV;
// 為該環境分配和設置頁面目錄。
if ((r = env_setup_vm(e)) < 0)
return r;
// 為該環境生成 env_id。
generation = (e->env_id + (1 << ENVGENSHIFT)) & ~(NENV - 1);
if (generation <= 0) // Don't create a negative env_id.
generation = 1 << ENVGENSHIFT;
e->env_id = generation | (e - envs);
// 設置基本狀態變數。
e->env_parent_id = parent_id;
e->env_type = ENV_TYPE_USER;
e->env_status = ENV_RUNNABLE;
e->env_runs = 0;
// 清除所有已保存的寄存器狀態,
// 以防止該 Env 結構中先前環境的寄存器值 “泄漏 ”到我們的新環境中。
memset(&e->env_tf, 0, sizeof(e->env_tf));
// 為段寄存器設置適當的初始值。
// GD_UD 是 GDT 中的用戶數據段選擇器,
// GD_UT 是用戶文本段選擇器(參見 inc/memlayout.h)。
// 每個段寄存器的低 2 位包含請求者許可權級別(RPL);3 表示用戶模式。
// 當我們切換許可權級別時,硬體會對 RPL 和存儲在描述符中的
// 描述符許可權級別(DPL)進行各種檢查。
e->env_tf.tf_ds = GD_UD | 3;
e->env_tf.tf_es = GD_UD | 3;
e->env_tf.tf_ss = GD_UD | 3;
e->env_tf.tf_esp = USTACKTOP;
e->env_tf.tf_cs = GD_UT | 3;
// You will set e->env_tf.tf_eip later.
// commit the allocation
env_free_list = e->env_link;
*newenv_store = e;
cprintf("[%08x] new env %08x\n", curenv ? curenv->env_id : 0, e->env_id);
return 0;
}
region_alloc()
//
// 為環境 env 分配 len 位元組的物理記憶體,並將其映射到環境地址空間中的虛擬地址 va。
// 不會以任何方式將映射頁清零或初始化。
// 頁面應可被用戶和內核寫入。
// 如果任何分配嘗試失敗,就會panic。
//
static void
region_alloc(struct Env *e, void *va, size_t len)
{
// 實驗 3:此處為您的代碼。
// (但僅限於 load_icode 需要時)。
// 提示:如果調用者可以傳遞非頁面對齊的'va'和'len'值,則使用 region_alloc 會更容易。
// 應該將 va 向下舍入,將 (va + len) 向上舍入。
// (註意拐角情況!)。
void *begin = ROUNDDOWN(va, PGSIZE), *end = ROUNDUP(va+len, PGSIZE);
while(begin < end){
struct PageInfo *pp = page_alloc(ALLOC_ZERO);
if(!pp){
panic("region_alloc failed\n");
}
page_insert(e->env_pgdir, pp, begin, PTE_P | PTE_W | PTE_U);
begin += PGSIZE;
}
}
load_icode
細節看註釋,需要強調的一點是:
在載入各個段時需要切換到這個進程的頁表目前進程的頁表內容和 kern_pgdir 是一樣的(UVPT除外)
因此,使用進程的頁表一樣能訪問到鏈接的二進位文件(這裡的 elfhdr)
為什麼要切換呢?每個進程都有自己的頁表,將物理記憶體中的代碼數據映射到自己獨立的地址空間
//
// 為用戶進程設置初始程式二進位文件、堆棧和處理器標誌。
// 該函數只在內核初始化期間,即運行第一個用戶模式環境之前調用。
//
// 該函數將 ELF 二進位映像中的所有可載入段載入到環境的用戶記憶體中,從 ELF 程式頭中指示的相應虛擬地址開始。
// 同時,它將程式頭中標記為映射但實際上不存在於 ELF 文件中的任何部分(即程式的 bss 部分)清零。
// 除了 Boot Loader 還需要從磁碟讀取代碼外,所有這些都與 Boot Loader 的工作非常相似。 看看 boot/main.c 就會明白。
//
// 最後,這個函數為程式的初始堆棧映射了一個頁面。
//
// load_icode 在遇到問題時會panic
// - load_icode 怎麼會失敗? 給定的輸入可能有什麼問題?
static void
load_icode(struct Env *e, uint8_t *binary)
{
// 提示:
// 按照 ELF 程式段頭指定的地址將每個程式段載入到虛擬記憶體中。
// 只應載入 ph->p_type == ELF_PROG_LOAD 的程式段。
// 每個程式段的虛擬地址可以在 ph->p_va 中找到,其在記憶體中的大小可以在 ph->p_memsz 中找到。
// ELF 二進位文件中從 “binary + ph->p_offset ”開始的 ph->p_filesz 位元組應複製到虛擬地址 ph->p_va。 剩餘的記憶體位元組應清零。
// (ELF 頭應該是 ph->p_filesz <= ph->p_memsz。)
// 使用前一個lab中的函數分配和映射頁面。
//
// 目前所有頁面保護位都應為用戶讀/寫。
// ELF 程式段不一定是頁面對齊的,但在本函數中可以假設沒有兩個程式段會接觸同一個虛擬頁面。
//
// 你可能會發現 region_alloc 這樣的函數很有用。
//
// 如果能直接將數據移動到存儲在 ELF 二進位文件中的虛擬地址,載入段就會簡單得多。
// 那麼在執行此函數時,哪個頁面目錄應該有效呢?
//
// 你還必須對程式的入口點做一些處理,以確保環境在那裡開始執行。
// 參見下麵的 env_run() 和 env_pop_tf())。
// 實驗 3:你的代碼在這裡。
struct Elf * elfhdr = (struct Elf *) binary;
struct Proghdr *ph = (struct Proghdr *) ((uint8_t *) elfhdr + elfhdr->e_phoff);
if (elfhdr->e_magic != ELF_MAGIC) {
panic("binary is not ELF format\n");
}
ph = (struct Proghdr *) ((uint8_t *) elfhdr + elfhdr->e_phoff);
int ph_num = elfhdr->e_phnum;
// 接下來需要切換到這個 進程的頁表,目前進程的頁表內容和 kern_pgdir 是一樣的(UVPT除外)
// 因此,使用進程的頁表一樣能訪問到鏈接的二進位文件(這裡的 elfhdr)
// 為什麼要切換呢?每個進程都有自己的頁表,將物理記憶體中的代碼數據映射到自己獨立的地址空間
lcr3(PADDR(e->env_pgdir));
for(int i = 0; i < ph_num; i++){
if(ph[i].p_type == ELF_PROG_LOAD){
region_alloc(e, (void *)ph[i].p_va, ph[i].p_memsz);
memset((void*) ph[i].p_va, 0, ph[i].p_memsz);
memcpy((void*) ph[i].p_va, binary + ph[i].p_offset, ph[i].p_filesz);
}
}
lcr3(PADDR(kern_pgdir));
e->env_tf.tf_eip = elfhdr->e_entry;//在 env_alloc 中,唯獨 eip 沒有初始化
// 現在為程式的初始堆棧映射一個頁面
// 虛擬地址 USTACKTOP - PGSIZE.
region_alloc(e, (void *)(USTACKTOP - PGSIZE), PGSIZE);
}
關於 Elf 結構體的 p_memsz 和 p_filesz:
如果一個段是可裝載段PT_LOAD(Program Header Type-Loadable):表明本程式頭指向一個可裝載的段。段的內容會被從文件中拷貝到記憶體中。段在文件中的大小是 p_filesz,在記憶體中的大小是 p_memsz。如果 p_memsz 大於 p_filesz,在記憶體中多出的存儲空間應填 0 補充,也就是說,段在記憶體中可以比在文件中占用空間更大;而相反,p_filesz 永遠不應該比 p_memsz 大,因為這樣的話,記憶體中就將無法完整地映射段的內容。
env_create()
// 使用 env_alloc 分配一個新環境,
// 使用 load_icode 將命名的精靈二進位文件載入其中,並設置其 env_type。
// 只有在運行第一個用戶模式環境之前,內核初始化過程中才會調用該函數。
// 新環境的父 ID 被設置為 0。
void env_create(uint8_t *binary, enum EnvType type)
{
// LAB 3: Your code here.
struct Env * new_env;
envid_t parent_id = 0;
int r = env_alloc(&new_env, parent_id);
if(r< 0)
panic("env_create error: %e", r);
load_icode(new_env, binary);
new_env->env_type = type;//順帶一體,env_alloc 預設已將type設為 ENV_TYPE_USER
}
然後看一看已經寫好的 env_free()
env_free
釋放 env e 及其使用的所有記憶體。
在看代碼之前,我們想一想,一個已經 env_create 的 env 占用了哪些物理記憶體?
- 首先是e env 的 env_pgdir 自身占用的頁面
- 然後是代碼、數據、棧占用的頁面
為了釋放掉所有這些記憶體,首先應該處理後者(因為這個過程依賴前者)
void
env_free(struct Env *e)
{
pte_t *pt;
uint32_t pdeno, pteno;
physaddr_t pa;
// 如果釋放當前環境,則在釋放頁面目錄之前切換到 kern_pgdir,以防頁面被重覆使用。
if (e == curenv)
lcr3(PADDR(kern_pgdir));
// Note the environment's demise.
cprintf("[%08x] free env %08x\n", curenv ? curenv->env_id : 0, e->env_id);
// 清空地址空間用戶部分的所有映射頁
static_assert(UTOP % PTSIZE == 0);
for (pdeno = 0; pdeno < PDX(UTOP); pdeno++) {
// only look at mapped page tables
if (!(e->env_pgdir[pdeno] & PTE_P))
continue;
// 查找頁表的 pa 和 va
pa = PTE_ADDR(e->env_pgdir[pdeno]);
pt = (pte_t*) KADDR(pa);
// unmap all PTEs in this page table
for (pteno = 0; pteno <= PTX(~0); pteno++) {
if (pt[pteno] & PTE_P)
page_remove(e->env_pgdir, PGADDR(pdeno, pteno, 0));
}
// free the page table itself
// 一般來說,我們使用 page_remove,但是這裡是對頁表占用的記憶體頁進行釋放
// 因此不能使用第一參數需要 pgdir 的 page_remove,需要手動處理。
// 這裡是 env_setup_vm 中的逆過程,env_setup_vm 由於需要構建頁表,
// 也需要手動調用 page_alloc,並處理計數
e->env_pgdir[pdeno] = 0;
page_decref(pa2page(pa));
}
// 釋放頁目錄
pa = PADDR(e->env_pgdir);
e->env_pgdir = 0;
page_decref(pa2page(pa));
// return the environment to the free list
e->env_status = ENV_FREE;
e->env_link = env_free_list;
env_free_list = e;
}
需要註意一點,在執行 env_free 代碼時,應該使用kern_pgdir,
但是 kern_pgdir 中沒有 env e 的映射,所以需要先用 env e 的 env_pgdir 來獲取物理地址,
然後利用 KADDR 巨集從地址空間頂部的 物理記憶體映射區訪問。
回憶一下lab2中,我們在mem_init里寫過:
//////////////////////////////////////////////////////////////////////
// 將所有物理記憶體映射到 KERNBASE。
// 也就是說,VA 範圍 [KERNBASE, 2^32) 應該映射到 PA 範圍 [0, 2^32 - KERNBASE)
// 我們可能沒有 2^32 - KERNBASE 位元組的物理記憶體,但我們還是設置了映射。
// 許可權:內核 RW,用戶 NONE
// Your code goes here:
boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W);
註意,雖然每個進程都有頂部的 物理記憶體映射區 的頁表,但是他們沒有許可權讀寫。(這麼方便的功能當然只該有內核有許可權,這也是執行env_free時,使用kern_pgdir的原因之一)
除此之外,我們需要註意 env_free
中需要手動調用 page_decref
。lab2 和 lab3 代碼中更多的是調用封裝好的 page_insert
(不用處理 計數遞增)和 page_remove
(不用處理 計數遞減)。但是一旦涉及到對頁表本身占用的物理頁做增刪處理時,就需要手動調用 page_alloc
或 page_decref
處理計數。
(比如 pgdir_walk
(頁表二級遍歷時,可能需要訪問尚未分配的 pte_table
)、env_create
(用戶進程創建頁表)和這裡的 env_free
)
env_run
在看代碼之前,我們先想一想,讓一個進程 Env e 運行起來,大概需要做那些事呢?
首先,想到的就是恢復這個進程的寄存器,讓EIP指向繼續執行的代碼,
在那之前還要恢復cr3寄存器,載入 Env e 的頁表
除此之外,我們還要考慮,env_run 是什麼時候調用的,也許此時有一個進程正在運行,curenv指向其他進程。所以要考慮修改 curenv 、以及 Env e 的 status、runs等變數。
// 從 curenv 到 env e 的上下文切換。
// 註意:如果這是第一次調用 env_run,則 curenv 為空。
// 此函數不返回
//
void
env_run(struct Env *e)
{
// 第 1 步: 如果這是一個上下文切換(一個新環境正在運行):
// 1.如果當前環境是 ENV_RUNNING,則將當前環境(如果有的話)設置回 ENV_RUNNABLE(想想它還能處於什麼狀態)、
// 2. 將 “curenv ”設置為新環境、
// 3. 將其狀態設置為 ENV_RUNNING; // 4、
// 4. 更新 “env_runs ”計數器、
// 5.使用 lcr3() 切換到它的地址空間。
// 第 2 步:使用 env_pop_tf()恢復環境的 // 寄存器,並切換到用戶模式。
// 寄存器,併進入環境中的用戶模式。
// 提示:該函數從 e->env_tf 載入新環境的狀態。 回過頭來看看你上面寫的代碼,確保已經將 e->env_tf 的相關部分設置為合理的值。
// 實驗 3:此處為您的代碼。
if(curenv != NULL){ //如果這不是第一次調用 env_run ,即,這是一個上下文切換
if(curenv->env_status == ENV_RUNNING){
curenv->env_status = ENV_RUNNABLE;
}
}
curenv = e;
e->env_status = ENV_RUNNING;
lcr3(e->env_pgdir);
// 恢復寄存器,這裡直接將覆蓋了當前寄存器狀態
// 可以想到,當原進程的寄存器狀態,應該已經妥當保存好了
// 並且在進程切換時負責進行保存,畢竟這裡沒有處理。
env_pop_tf(&e->env_tf);
}
正如註釋中說的,env_run 如果不是首次調用,說明這是一次進程切換。那麼什麼時候需要切換進程呢?
大概能想到,發生異常、進程調度等,如果是進程調度這種比較平和的方式,那麼curenv 肯定是 ENV_RUNNING了,其他情況暫時等 lab4 學完才能瞭解到。
註意最後一句 env_pop_tf(&e->env_tf);
寄存器狀態恢復,就意味著進程正式運行了,因為eip被改變了。
總結:env.c中的函數關係
此時, make qemu ,內核就會將 user/hello.c 編譯出來的可執行文件,通過 env_create 創建出來,並通過 env_run 運行起來。在這之前,我們來看一眼這個程式:
// hello, world
#include <inc/lib.h>
void
umain(int argc, char **argv)
{
cprintf("hello, world\n");
cprintf("i am environment %08x\n", thisenv->env_id);
}
註意,用戶程式的cprintf的聲明雖然和我們剛剛編程時用的cprintf相同,都來自 inc/stdio.h,但是他們的實現不同:
用vscode 搜索可以發現,有兩個定義
內核使用的是 kern/printf.c 而 user/hello.c 使用的卻是 lib/printf.c,用戶的 cprintf 會調用到 lib/syscall.c 中的 sys_cputs
syscall 這個函數的定義如下:
實際上就是使用了 int 0x30 系統調用,這是一個中斷。這個中斷只能在內核態調用,但是XXX所以 make qemu 會導致三重故障,即:
如果一切順利,系統將進入用戶空間並執行 hello 二進位文件,直到使用 int 指令進行系統調用。這時就會出現問題,因為 JOS 沒有設置允許從用戶空間過渡到內核的硬體。當中央處理器發現自己的設置不允許處理這個系統調用中斷時,它就會產生一個一般保護異常,發現自己無法處理這個異常後,又會產生一個雙重故障異常,發現自己也無法處理這個異常後,最後就會放棄,這就是所謂的 "三重故障" 。通常情況下,CPU 會重置,系統會重啟。雖然這對傳統應用程式很重要(請參閱本博文中的原因解釋)
就像這個樣子:
我們用 GDB 在 env_pop_tf() 函數設置斷點,然後通過指令 si,單步調試,觀察 iret 指令前後寄存器的變化。
為了能讓用戶進程有能力處理異常,學習如何處理中斷和異常
處理中斷和異常
在這之前,我們需要徹底摸頭 x86中斷和異常機制。練習3的任務就是學習80386的手冊。
Exercise 3
練習 3. 如果還沒有,請閱讀《80386 程式員手冊》第 9 章 "異常和中斷"[Chapter 9, Exceptions and Interrupts](https://pdos.csail.mit.edu/6.828/2018/readings/i386/c09.htm)(或《IA-32 開發人員手冊》第 5 章 [IA-32 Developer's Manual](https://pdos.csail.mit.edu/6.828/2018/readings/ia32/IA32-3A.pdf))。
觀察下TSS
建立中斷描述符表
JOS 在 trapentry.S
中,為每個異常或中斷設置處理程式,
在 trap_init()
中用這些處理程式的地址建立 IDT
。
那這些處理程式具體要做什麼呢?手冊提示我們:
- 每個處理程式都應在堆棧上建立一個
struct Trapframe
(參見inc/trap.h
)
2.將Trapframe
的地址作為參數調用trap()
(在trap.c
中)。
先來看看 trapentry.S
trapentry.S
/* TRAPHANDLER 定義了一個全局可見的陷阱處理函數。
* 它將一個陷阱編號推入堆棧,然後跳轉到 _alltraps。
* 使用 TRAPHANDLER 來處理 CPU 自動推送錯誤代碼的陷阱。
*
* 你不應該在 C 語言中調用 TRAPHANDLER 函數,但你可能需要在 C 語言中聲明一個函數(例如,在 IDT 設置過程中獲取函數指針)。 您可以用以下方式聲明函數
* void NAME();
* 其中 NAME 是傳遞給 TRAPHANDLER 的參數。
*/
#define TRAPHANDLER(name, num) \
.globl name; /* define global symbol for 'name' */ \
.type name, @function; /* symbol type is function */ \
.align 2; /* align function definition */ \
name: /* function starts here */ \
pushl $(num); \
jmp _alltraps
/* 對於 CPU 不推送錯誤代碼的陷阱,使用 TRAPHANDLER_NOEC。
* 它會推送一個 0 來代替錯誤代碼,因此陷阱幀在這兩種情況下的格式都是一樣的。
* 格式。
*/
#define TRAPHANDLER_NOEC(name, num) \
.globl name; \
.type name, @function; \
.align 2; \
name: \
pushl $0; \
pushl $(num); \
jmp _alltraps
.text
/*
* Lab 3: Your code here for generating entry points for the different traps.
*/
// 按照手冊提示,為 /inc/trap.h 中的每一個(lab3是0~31)異常創建處理程式
// 註意,有的異常會在堆棧上推入ErrorCode,有的則沒有,需要參照 x86手冊
TRAPHANDLER_NOEC(handler_0, T_DIVIDE)
TRAPHANDLER_NOEC(handler_1, T_DEBUG)
TRAPHANDLER_NOEC(handler_2, T_NMI)
TRAPHANDLER_NOEC(handler_3, T_BRKPT)
TRAPHANDLER_NOEC(handler_4, T_OFLOW)
TRAPHANDLER_NOEC(handler_5, T_BOUND)
TRAPHANDLER_NOEC(handler_6, T_ILLOP)
TRAPHANDLER_NOEC(handler_7, T_DEVICE)
TRAPHANDLER(handler_8, T_DBLFLT)
TRAPHANDLER(handler_10, T_TSS)
TRAPHANDLER(handler_11, T_SEGNP)
TRAPHANDLER(handler_12, T_STACK)
TRAPHANDLER(handler_13, T_GPFLT)
TRAPHANDLER(handler_14, T_PGFLT)
TRAPHANDLER_NOEC(handler_16, T_FPERR)
TRAPHANDLER(handler_17, T_ALIGN)
TRAPHANDLER_NOEC(handler_18, T_MCHK)
TRAPHANDLER_NOEC(handler_19, T_SIMDERR)
/*
* Lab 3: Your code here for _alltraps
*/
/*
按照手冊的提示:
你的 `_alltraps` 應該
1. 推值,使堆棧看起來像結構 Trapframe
2. 將 `GD_KD` 載入到 `%ds` 和 `%es` 中
3. 推送 `%esp` 以傳遞一個指向 `Trapframe` 的指針作為 `trap()` 的參數
4. `call trap` ( `trap` 會返回嗎?)
*/
_alltraps:
pushl %ds
pushl %es
pushal
pushl $GD_KD
popl %ds
pushl $GD_KD
popl %es
pushl %esp
call trap
trap_init()
void
trap_init(void)
{
extern struct Segdesc gdt[];
// LAB 3: Your code here.
// 聲明 異常處理函數
void handler_0();
void handler_1();
void handler_2();
void handler_3();
void handler_4();
void handler_5();
void handler_6();
void handler_7();
void handler_8();
void handler_10();
void handler_11();
void handler_12();
void handler_13();
void handler_14();
void handler_16();
void handler_17();
void handler_18();
void handler_19();
// 通過異常處理函數建立IDT
SETGATE(idt[0], 0, GD_KT, handler_0, 0);
SETGATE(idt[1], 0, GD_KT, handler_1, 0);
SETGATE(idt[2], 0, GD_KT, handler_2, 0);
SETGATE(idt[3], 0, GD_KT, handler_3, 3);
SETGATE(idt[4], 0, GD_KT, handler_4, 0);
SETGATE(idt[5], 0, GD_KT, handler_5, 0);
SETGATE(idt[6], 0, GD_KT, handler_6, 0);
SETGATE(idt[7], 0, GD_KT, handler_7, 0);
SETGATE(idt[8], 0, GD_KT, handler_8, 0);
SETGATE(idt[10], 0, GD_KT, handler_10, 0);
SETGATE(idt[11], 0, GD_KT, handler_11, 0);
SETGATE(idt[12], 0, GD_KT, handler_12, 0);
SETGATE(idt[13], 0, GD_KT, handler_13, 0);
SETGATE(idt[14], 0, GD_KT, handler_14, 0);
SETGATE(idt[16], 0, GD_KT, handler_16, 0);
SETGATE(idt[17], 0, GD_KT, handler_17, 0);
SETGATE(idt[18], 0, GD_KT, handler_18, 0);
SETGATE(idt[19], 0, GD_KT, handler_19, 0);
// Per-CPU setup
trap_init_percpu();
}
來看看這個最後調用的 trap_init_percpu
// 初始化並載入每個 CPU 的 TSS 和 IDT
void
trap_init_percpu(void)
{
// 設置 TSS,以便在向內核發送陷阱時獲得正確的堆棧。
// 當我們向內核發送陷阱時。
ts.ts_esp0 = KSTACKTOP;
ts.ts_ss0 = GD_KD;
ts.ts_iomb = sizeof(struct Taskstate);
// 初始化 gdt 的 TSS 插槽。
gdt[GD_TSS0 >> 3] = SEG16(STS_T32A, (uint32_t) (&ts),
sizeof(struct Taskstate) - 1, 0);
gdt[GD_TSS0 >> 3].sd_s = 0;
// 載入 TSS 選擇器(與其他分段選擇器一樣,最下麵的三個比特是特殊的,我們將其置 0)
ltr(GD_TSS0);
// Load the IDT
lidt(&idt_pd);
}
這裡的 ts 是用來存儲 TSS 數據的結構體,位於 inc/mmu.h。
實際上,到了這裡,Part A的練習已經完成,簡單小結一下,異常發生後的處理過程
所以說,trap,帶著 Trapframe 究竟做了什麼呢,在進入 Part B 之前必要將這一切弄明白
JOS 的中斷處理過程
先來看看 trap()做了什麼
trap
void
trap(struct Trapframe *tf)
{
// 環境可能已經設置了 DF,
// 某些版本的 GCC 依賴於 DF 的明確性
asm volatile("cld" ::: "cc");
// 檢查中斷是否被禁用。
// 如果斷言失敗,切勿試圖通過在中斷路徑中插入一個 “cli ”來修複。
assert(!(read_eflags() & FL_IF));
cprintf("Incoming TRAP frame at %p\n", tf);
if ((tf->tf_cs & 3) == 3) {
// 從用戶模式捕獲。
assert(curenv);
// 將陷阱幀(當前在堆棧上)複製到 “curenv->env_tf ”中,以便在陷阱點重新開始運行環境。
curenv->env_tf = *tf;
// 從現在起,堆棧上的陷阱框架應被忽略。
tf = &curenv->env_tf;
}
// 記錄 tf 是最後一個真正的陷阱幀,以便 print_trapframe 可以列印一些附加信息。
last_tf = tf;
// 根據陷阱類型進行調度
trap_dispatch(tf);
// 返回當前運行環境。
assert(curenv && curenv->env_status == ENV_RUNNING);
env_run(curenv);
}
可以看到,trap 的主要工作就是調用 trap_dispatch 處理tf,然後調用 env_run 將控制交換給用戶進程。trapdispatch是個需要我們後期補全的函數。