Spawning Process 有了文件系統了,我們終於可以方便地讀取磁碟中的文件了。到目前為止,我們創建進程的方法一直都是在編譯內核的時候將程式鏈接到數據段,在 i386_init 通過 ENV_CREATE 巨集創建。 現在我們應該考慮通過文件系統直接將用戶程式從硬碟中讀取出來,spawn 就是 ...
Spawning Process
有了文件系統了,我們終於可以方便地讀取磁碟中的文件了。到目前為止,我們創建進程的方法一直都是在編譯內核的時候將程式鏈接到數據段,在 i386_init 通過 ENV_CREATE 巨集創建。
現在我們應該考慮通過文件系統直接將用戶程式從硬碟中讀取出來,spawn 就是這樣的東西。
spawn和unix中的exec不同,spawn 在用戶空間實現,不需要內核的特殊幫助,讀取文件、創建進程完全通過 syscall。
spawn 已經實現好了,位於 lib/spawn.c 中。很有必要學習一下其中的代碼。
spawn.c
spawn 很像 icode_load,但是他需要通過文件的方式讀取數據。
而且棧的創建、子進程狀態的設置,記憶體映射都需要以syscall的方式實現。
// 從文件系統載入的程式映像中生成一個子進程。
// prog:要運行的程式的路徑名。
// argv: 字元串指針數組的空端指針,這些字元串將作為命令行參數傳遞給子進程。
// 成功時返回子程式 envid,失敗時返回 <0。
int
spawn(const char *prog, const char **argv)
{
unsigned char elf_buf[512];
struct Trapframe child_tf;
envid_t child;
int fd, i, r;
struct Elf *elf;
struct Proghdr *ph;
int perm;
// 打開 elf 文件
if ((r = open(prog, O_RDONLY)) < 0)
return r;
fd = r;
// 讀取 elf文件頭
elf = (struct Elf*) elf_buf;
if (readn(fd, elf_buf, sizeof(elf_buf)) != sizeof(elf_buf)
|| elf->e_magic != ELF_MAGIC) {
close(fd);
cprintf("elf magic %08x want %08x\n", elf->e_magic, ELF_MAGIC);
return -E_NOT_EXEC;
}
// 創建子進程
if ((r = sys_exofork()) < 0)
return r;
child = r;
// Set up trap frame, including initial stack.
// 將子進程的 eip 設置為 elf 的入口點
child_tf = envs[ENVX(child)].env_tf;
child_tf.tf_eip = elf->e_entry;
// 為子進程設置棧
if ((r = init_stack(child, argv, &child_tf.tf_esp)) < 0)
return r;
// Set up program segments as defined in ELF header.
// 將 elf 的程式段載入記憶體
ph = (struct Proghdr*) (elf_buf + elf->e_phoff);
for (i = 0; i < elf->e_phnum; i++, ph++) {
// 使用 Proghdr 中每個程式段的 p_flags 欄位來確定如何映射程式段:
if (ph->p_type != ELF_PROG_LOAD)
continue;
perm = PTE_P | PTE_U;
// 如果 ELF 標誌不包括 ELF_PROG_FLAG_WRITE,則段包含文本和只讀數據。
// 如果 ELF 段標誌包含 ELF_PROG_FLAG_WRITE,則該段包含讀/寫數據和 bss。
if (ph->p_flags & ELF_PROG_FLAG_WRITE)
perm |= PTE_W;
if ((r = map_segment(child, ph->p_va, ph->p_memsz,
fd, ph->p_filesz, ph->p_offset, perm)) < 0)
goto error;
}
close(fd);
fd = -1;
// Copy shared library state.
if ((r = copy_shared_pages(child)) < 0)
panic("copy_shared_pages: %e", r);
child_tf.tf_eflags |= FL_IOPL_3; // devious: see user/faultio.c
if ((r = sys_env_set_trapframe(child, &child_tf)) < 0)
panic("sys_env_set_trapframe: %e", r);
if ((r = sys_env_set_status(child, ENV_RUNNABLE)) < 0)
panic("sys_env_set_status: %e", r);
return child;
error:
sys_env_destroy(child);
close(fd);
return r;
}
spawn的步驟:
- 打開程式文件。
- 像以前一樣讀取 ELF 頭文件,並檢查其神奇數字是否正確。 (檢查你的 load_icode!)。
- 使用 sys_exofork() 創建一個新環境。
- 將 child_tf 設置為子程式的初始 struct Trapframe。
- 調用上面的 init_stack() 函數,為子環境設置初始堆棧頁面。
- 將所有 p_type ELF_PROG_LOAD 類型的程式段映射到新環境的地址空間。
init_stack 則是先在父進程的 UTMP 上將子進程的用戶棧佈局好,然後通過 sys_page_map 將物理頁映射到子進程中。佈局情況如下:
//下麵的argv[n]指的是字元串首地址,也是這個棧中對應條目的虛擬地址
argv_2 --> | "initarg2" | <-- USTACKTOP
argv_1 --> | "initarg1" |
argv_0 --> | "init" |
| 0(NULL) |
| &argv_2 |
| &argv_1 |
&argv --> | &argv_0 |
| &argv |
child->esp(往上是出棧) --> | 3 |
————————————————
練習7
練習 7. `spawn` 依靠新的系統調用 `sys_env_set_trapframe` 來初始化新創建環境的狀態。在 `kern/syscall.c` 中實現 `sys_env_set_trapframe`(別忘了在 `syscall()` 中調度新的系統調用)。
運行 `kern/init.c` 中的 `user/spawnhello` 程式來測試代碼,該程式將嘗試從文件系統中生成 `/hello`。
使用 `make grade` 測試代碼。
// 將 envid 的陷阱框架設置為 “tf”。
// 修改 tf 是為了確保用戶環境始終運行在代碼
// 保護級別 3(CPL 3),啟用中斷,IOPL 為 0。
//
// 成功時返回 0,錯誤時返回 <0。 錯誤是
// -E_BAD_ENV 如果環境 envid 當前不存在、
// 或調用者沒有許可權更改 envid。
static int
sys_env_set_trapframe(envid_t envid, struct Trapframe *tf)
{
// LAB 5: Your code here.
// Remember to check whether the user has supplied us with a good
// address!
// panic("sys_env_set_trapframe not implemented");
struct Env * e;
if(envid2env(envid, &e, true) < 0)
return -E_BAD_ENV;
tf->tf_eflags = FL_IF; //允許中斷
tf->tf_eflags &= ~FL_IOPL_MASK; //IOPL為0
tf->tf_cs = GD_UT | 3; //保護級別 3
e->env_tf = *tf;
return 0;
}
然後在 kern/syscall.c : syscall
中補充:
case SYS_env_set_trapframe:
ret = sys_env_set_trapframe((envid_t) a1, (struct Trapframe *)a2);
return ret;
為了測試效果,在 kern/init.c : i386_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_icode, ENV_TYPE_USER);
ENV_CREATE(user_spawnhello, ENV_TYPE_USER);
#endif // TEST*
測試效果 make qemu
:
跨 fork 和 spawn 共用庫狀態
我們希望在 fork
和 spawn
之間共用文件描述符狀態,但文件描述符狀態保存在用戶空間記憶體中。
現在,在 fork
時,記憶體將被標記為寫入時複製,因此狀態將被覆制而非共用。(這意味著進程無法在自己未打開的文件中定址,管道也無法在 fork
時工作)。
在spawn
時,記憶體將被留下,根本不會被覆制。(實際上,生成(spawned)的進程一開始並沒有打開文件描述符)。
我們將修改 fork
,使其知道某些記憶體區域被 "庫操作系統 "使用,並應始終共用。
我們將在頁表項中設置一個未使用的位,而不是在某個地方硬編碼一個區域列表(就像我們在 fork
中設置 PTE_COW
位一樣)。
我們在 inc/lib.h
中定義了一個新的 PTE_SHARE
位。
該位是 Intel 和 AMD 手冊中標明 "可用於軟體 "的三個 PTE 位之一。
我們將建立一個慣例,即如果頁表項設置了該位,則 PTE 應在 fork
和 spawn
中直接從父節點複製到子節點。
請註意,這與 "寫入時複製 "不同:如第一段所述,我們要確保共用頁面更新。
練習 8. 修改 `lib/fork.c` 中的 `duppage`,以遵循新的約定。如果頁表項設置了 `PTE_SHARE` 位,則直接複製映射即可。(您應該使用 `PTE_SYSCALL`,而不是 0xfff 來屏蔽掉頁表項中的相關位。0xfff 還會拾取訪問位和臟位)。
同樣,在 `lib/spawn.c` 中實現 `copy_shared_pages`。它應該迴圈查看當前進程中的所有頁表項(就像 fork 所做的),將任何設置了 `PTE_SHARE` 位的頁面映射複製到子進程中。
lib/fork.c : duppage
// 將當前進程(父進程)的記憶體映射(頁表)複製給子進程,同時標記COW
static int
duppage(envid_t envid, unsigned pn)
{
int r;
// LAB 4: Your code here.
// panic("duppage not implemented");
void *addr = (void *)(pn * PGSIZE);
//如果頁表項設置了 `PTE_SHARE` 位,則直接複製映射即可。
if(uvpt[pn] & PTE_SHARE){
sys_page_map(0, addr, envid, addr, PTE_SYSCALL);
}
//對父進程所有可寫頁或COW頁,標記COW
else if ((uvpt[pn]&PTE_W)|| (uvpt[pn] & PTE_COW)){
if ((r = sys_page_map(0, addr, envid, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("duppage:sys_page_map:%e", r);
if ((r = sys_page_map(0, addr, 0, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("duppage:sys_page_map:%e", r);
}
//對於父進程的只讀頁不標記COW
else{
sys_page_map(0, addr, envid, addr, PTE_U|PTE_P);
}
return 0;
}
lib/spawn.c : copy_shared_pages
// 將共用頁面的映射複製到子地址空間。
static int
copy_shared_pages(envid_t child)
{
// LAB 5: Your code here.
uintptr_t addr;
for (addr = 0; addr < UTOP; addr += PGSIZE) {
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P) &&
(uvpt[PGNUM(addr)] & PTE_U) && (uvpt[PGNUM(addr)] & PTE_SHARE)) {
sys_page_map(0, (void*)addr, child, (void*)addr, (uvpt[PGNUM(addr)] & PTE_SYSCALL));
}
}
return 0;
}
話說,我們是在什麼時候將文件描述符標記為 PTE_SHARE 的呢?vscode搜索一下:
答案是在 serve_open 的末尾,文件的主迴圈在處理open請求時,調用serve_open,然後serve_open 申請一個新的openfile,代表打開的文件。然後將該openfile關聯的 FD 所在物理頁,以及該物理頁許可權返回給serve,如下圖
緊接著 serve 調用 ipc_send 將 FD 物理頁發送給客戶端,並以帶有 PTE_SHARE 的許可權,將FD安裝在客戶端調用 ipc_recv 時指定的地址。
因此,所有通過open打開的文件描述符,都是 PTE_SHARE 的。經過 fork 或 spawn 後,父子進程共用。
鍵盤介面
為了讓 shell 正常工作,我們需要一種輸入方式。QEMU 一直在顯示我們寫入 CGA 顯示屏和串列埠的輸出,但到目前為止,我們只在內核監視器中輸入了內容。在 QEMU 中,在圖形視窗中輸入的內容會以鍵盤輸入的形式顯示在 JOS 上,而輸入到控制台的內容則會以串列埠上的字元形式顯示。kern/console.c
已經包含了內核監視器從實驗一開始就使用的鍵盤和串列驅動程式,但現在你需要將它們連接到系統的其他部分。
練習 9. 在 kern/trap.c 中,調用 kbd_intr 處理陷阱 IRQ_OFFSET+IRQ_KBD,調用 serial_intr 處理陷阱 IRQ_OFFSET+IRQ_SERIAL。
我們在 lib/console.c 中為您實現了控制台輸入/輸出文件類型。kbd_intr 和 serial_intr 會將最近讀取的輸入內容填入緩衝區,而控制台文件類型則會耗盡緩衝區(除非用戶重定向,否則預設情況下控制台文件類型用於 stdin/stdout)。
運行 make run-testkbd 並鍵入幾行代碼,測試你的代碼。當你輸入完畢時,系統會回聲提示。如果控制台和圖形視窗都可用,請嘗試同時在控制台和圖形視窗中鍵入。
在 kern/trap.c : trap_dispatch
中添加:
// Handle keyboard and serial interrupts.
// LAB 5: Your code here.
if (tf->tf_trapno == IRQ_OFFSET + IRQ_KBD){
kbd_intr();
return;
}
if (tf->tf_trapno == IRQ_OFFSET + IRQ_SERIAL){
serial_intr();
return;
}
然後 make run-testkbd
看起來只是簡單的回顯了輸入,來看看代碼
user/testkbd
#include <inc/lib.h>
void
umain(int argc, char **argv)
{
int i, r;
// Spin for a bit to let the console quiet
for (i = 0; i < 10; ++i)
sys_yield();
close(0);
// 打開一個文件,這個文件的設備類型是終端
if ((r = opencons()) < 0)
panic("opencons: %e", r);
// 由於是第一個打開的,fd應該是0
if (r != 0)
panic("first opencons used fd %d", r);
// 複製一個文件描述符
if ((r = dup(0, 1)) < 0)
panic("dup: %e", r);
for(;;){
char *buf;
buf = readline("Type a line: ");
if (buf != NULL)
// fprintf 最終會調用write向fd1寫入數據
// 此時會將內容顯示在終端上
fprintf(1, "%s\n", buf);
else
fprintf(1, "(end of file received)\n");
}
}
首先打開了文件描述符0,其設備類型為終端devcons(可以通過 opencons 看到)。
然後複製了文件描述符0得到文件描述符1,並向文件描述符1寫入我們輸入的字元串。
從而使得終端顯示了我們的輸入。
The Shell
運行 make run-icode 或 make run-icode-nox。這將運行內核並啟動用戶/icode。icode 會執行 init,將控制台設置為文件描述符 0 和 1(標準輸入和標準輸出)。然後會生成 shell sh。你應該可以運行以下命令:
echo hello world | cat
cat lorem |cat
cat lorem |num
cat lorem |num |num |num |num |num
lsfd
請註意,用戶庫常式 cprintf 直接列印到控制台,而不使用文件描述符代碼。這非常適合調試,但不適合在其他程式中使用。printf("...", ...) 是列印到 FD 1 的快捷方式。有關示例,請參見 user/lsfd.c。
練習 10.
shell 不支持 I/O 重定向。如果能運行 sh <script 就好了,而不必像上面那樣手寫輸入腳本中的所有命令。將 < 的 I/O 重定向添加到 user/sh.c。
在 shell 中鍵入 sh <script 測試你的實現
運行 make run-testshell 測試你的 shell。testshell 只需將上述命令(也可在 fs/testshell.sh 中找到)輸入 shell,然後檢查輸出是否與 fs/testshell.key 一致。
熟悉 linux 的 bash 的話,應該知道IO重定向的概念。 <
用於重定向標準輸入。比如說 [命令a] < [文件b]
的含義就是,將命令a的標準輸入改為文件b。
標準輸入的文件描述符編號是0,所以我們要做的就是將,打開 文件b,然後將 文件描述符0 改為文件b:
// LAB 5: Your code here.
// panic("< redirection not implemented");
// t是gettoken得到的當前短語,即文件b的文件名,打開文件b
if ((fd = open(t, O_RDONLY)) < 0) {
cprintf("open %s for read: %e", t, fd);
exit();
}
if (fd != 0) {
// 將文件描述符0 變為文件b的副本
dup(fd, 0);
// 關閉文件b
close(fd);
}
break;
關於 /lib/sh.c
sh.c 實現了一個 shell,其核心函數式 runcmd。邏輯其實也很簡單,通過迴圈調用gettoken 解析命令。然後根據重定向的需求修改輸入輸出,最後通過 spawn 運行相關程式。
管道部分比較有意思:
管道左側直接跳轉到 runit 運行命令,右側則重新解析命令。
管道右側需要等待管道左側運行完畢後,再運行: