mit6.828筆記 - lab5(下)- Spawn and Shell

toso發表於2024-05-27

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:

image.png

跨 fork 和 spawn 共享庫狀態

我們希望在 forkspawn 之間共享檔案描述符狀態,但檔案描述符狀態儲存在使用者空間記憶體中。
現在,fork 時,記憶體將被標記為寫入時複製,因此狀態將被複制而非共享(這意味著程序無法在自己未開啟的檔案中定址,管道也無法在 fork 時工作)。
spawn時,記憶體將被留下,根本不會被複制。(實際上,生成(spawned)的程序一開始並沒有開啟檔案描述符)。

我們將修改 fork,使其知道某些記憶體區域被 "庫作業系統 "使用,並應始終共享。
我們將在頁表項中設定一個未使用的位,而不是在某個地方硬編碼一個區域列表(就像我們在 fork 中設定 PTE_COW 位一樣)。

我們在 inc/lib.h 中定義了一個新的 PTE_SHARE 位。
該位是 Intel 和 AMD 手冊中標明 "可用於軟體 "的三個 PTE 位之一。
我們將建立一個慣例,即如果頁表項設定了該位,則 PTE 應在 forkspawn 中直接從父節點複製到子節點。
請注意,這與 "寫入時複製 "不同:如第一段所述,我們要確保共享頁面更新。

練習 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,如下圖

image.png

緊接著 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

image.png

看起來只是簡單的回顯了輸入,來看看程式碼
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寫入我們輸入的字串。
從而使得終端顯示了我們的輸入。
image.png

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;

image.png

關於 /lib/sh.c
sh.c 實現了一個 shell,其核心函式式 runcmd。邏輯其實也很簡單,透過迴圈呼叫gettoken 解析命令。然後根據重定向的需求修改輸入輸出,最後透過 spawn 執行相關程式。

image.png

管道部分比較有意思:
image.png

管道左側直接跳轉到 runit 執行命令,右側則重新解析命令。

管道右側需要等待管道左側執行完畢後,再執行:

image.png

相關文章