驗(yàn)2:用戶程序加載與系統(tǒng)調(diào)用實(shí)現(xiàn)全解析)
1. 項(xiàng)目概述從理論到實(shí)踐的Pintos操作系統(tǒng)實(shí)驗(yàn)如果你正在學(xué)習(xí)操作系統(tǒng)課程或者對(duì)操作系統(tǒng)的內(nèi)部運(yùn)行機(jī)制充滿好奇那么“Pintos”這個(gè)名字你一定不陌生。它是一個(gè)由斯坦福大學(xué)開發(fā)專門用于教學(xué)的小型操作系統(tǒng)內(nèi)核。而“實(shí)驗(yàn)2useprog”這個(gè)標(biāo)題乍一看可能有些模糊但它精準(zhǔn)地指向了Pintos實(shí)驗(yàn)系列中一個(gè)承上啟下的關(guān)鍵環(huán)節(jié)——用戶程序User Programs的實(shí)現(xiàn)。簡(jiǎn)單來說這個(gè)實(shí)驗(yàn)的目標(biāo)是讓Pintos內(nèi)核從一個(gè)只能運(yùn)行內(nèi)核線程的“裸奔”狀態(tài)進(jìn)化到能夠加載、執(zhí)行并管理來自磁盤的普通用戶程序這是現(xiàn)代操作系統(tǒng)最基礎(chǔ)、最核心的功能之一。我當(dāng)年做這個(gè)實(shí)驗(yàn)時(shí)感覺就像是在給一個(gè)剛學(xué)會(huì)走路的機(jī)器人安裝大腦和感官讓它能理解并執(zhí)行更復(fù)雜的指令。內(nèi)核之前只能處理自己內(nèi)部的“家務(wù)事”系統(tǒng)調(diào)用、線程調(diào)度等現(xiàn)在則需要建立起一套完整的機(jī)制來安全、高效地運(yùn)行為它設(shè)計(jì)的“外來”程序。這涉及到內(nèi)存管理、系統(tǒng)調(diào)用接口、文件系統(tǒng)交互、進(jìn)程保護(hù)等一系列核心概念。通過親手實(shí)現(xiàn)“useprog”你不僅能深刻理解exec(),wait()這些系統(tǒng)調(diào)用背后發(fā)生了什么更能建立起從高級(jí)語(yǔ)言代碼到CPU指令執(zhí)行的完整認(rèn)知鏈條。無論你是計(jì)算機(jī)專業(yè)的學(xué)生還是希望夯實(shí)底層知識(shí)的開發(fā)者這個(gè)實(shí)驗(yàn)都是一次絕佳的“手術(shù)刀式”的深度學(xué)習(xí)。2. 實(shí)驗(yàn)核心思路與架構(gòu)設(shè)計(jì)拆解在動(dòng)手寫代碼之前我們必須先搞清楚Pintos實(shí)驗(yàn)2要我們具體做什么以及為什么這么設(shè)計(jì)。實(shí)驗(yàn)文檔通常不會(huì)把所有細(xì)節(jié)都喂到你嘴邊它給出的是目標(biāo)和測(cè)試用例而如何搭建橋梁到達(dá)彼岸正是鍛煉你系統(tǒng)設(shè)計(jì)能力的關(guān)鍵。2.1 核心需求解析用戶程序的完整生命周期實(shí)驗(yàn)的核心是讓Pintos支持用戶程序的加載與執(zhí)行。一個(gè)用戶程序在Pintos中的完整生命周期可以分解為以下幾個(gè)關(guān)鍵階段這也是我們實(shí)現(xiàn)時(shí)需要逐個(gè)攻克的堡壘程序加載Loading內(nèi)核需要從磁盤的文件系統(tǒng)中找到名為“程序”的文件例如一個(gè)可執(zhí)行的ELF文件將其代碼和數(shù)據(jù)正確地讀取到內(nèi)存的特定位置。地址空間構(gòu)建Setup Address Space為這個(gè)程序創(chuàng)建一個(gè)獨(dú)立的虛擬地址空間。這包括設(shè)置頁(yè)目錄、頁(yè)表將加載的代碼和數(shù)據(jù)映射到正確的虛擬地址在Pintos中用戶程序的虛擬地址通常從0x08048000開始并分配用戶??臻g。執(zhí)行上下文初始化Initialization準(zhǔn)備好程序執(zhí)行所需的初始狀態(tài)。這包括設(shè)置用戶態(tài)CPU寄存器如%eip指向程序入口點(diǎn)%esp指向用戶棧頂以及處理可能通過命令行傳遞的參數(shù)。系統(tǒng)調(diào)用支持System Call Support程序運(yùn)行起來后不可避免地需要向內(nèi)核請(qǐng)求服務(wù)例如讀寫文件、創(chuàng)建新進(jìn)程、申請(qǐng)更多內(nèi)存等。內(nèi)核必須提供一套安全的機(jī)制來響應(yīng)用戶程序的這些請(qǐng)求這就是系統(tǒng)調(diào)用。我們需要實(shí)現(xiàn)一個(gè)從用戶態(tài)到內(nèi)核態(tài)的“受控入口”。進(jìn)程控制Process Control實(shí)現(xiàn)諸如exec()執(zhí)行新程序、wait()等待子進(jìn)程結(jié)束等進(jìn)程管理相關(guān)的系統(tǒng)調(diào)用從而支持簡(jiǎn)單的進(jìn)程樹和進(jìn)程間同步。內(nèi)存保護(hù)與錯(cuò)誤處理Memory Protection Fault Handling確保用戶程序不能訪問內(nèi)核內(nèi)存或其他程序的內(nèi)存。當(dāng)用戶程序試圖進(jìn)行非法操作如訪問非法地址、執(zhí)行特權(quán)指令時(shí)內(nèi)核需要能夠捕獲這些錯(cuò)誤通過頁(yè)面錯(cuò)誤#PF或通用保護(hù)錯(cuò)誤#GP等異常并得體地終止該程序而不是導(dǎo)致整個(gè)系統(tǒng)崩潰。實(shí)驗(yàn)的測(cè)試用例make check會(huì)系統(tǒng)地驗(yàn)證上述每一個(gè)環(huán)節(jié)。例如rox-simple測(cè)試檢查只讀數(shù)據(jù)段是否真的不可寫exec-multiple測(cè)試連續(xù)執(zhí)行多個(gè)程序的能力wait-simple則測(cè)試父進(jìn)程等待子進(jìn)程的功能。2.2 方案選型與設(shè)計(jì)考量面對(duì)這些需求我們需要在Pintos已有的框架下做出一些關(guān)鍵的設(shè)計(jì)決策。1. 程序加載與ELF解析Pintos的用戶程序是標(biāo)準(zhǔn)的32位ELF格式。我們不需要實(shí)現(xiàn)一個(gè)完整的ELF加載器但必須理解ELF文件頭Elf32_Ehdr和程序頭Elf32_Phdr的結(jié)構(gòu)。核心任務(wù)是遍歷程序頭表找到所有類型為PT_LOAD的段Segment這些段指明了需要被加載到內(nèi)存的代碼和數(shù)據(jù)。我們需要計(jì)算每個(gè)段在虛擬地址空間中的位置p_vaddr然后在當(dāng)前進(jìn)程的頁(yè)表中建立從該虛擬地址到物理內(nèi)存的映射最后將段的內(nèi)容從文件中讀取到對(duì)應(yīng)的物理頁(yè)中。注意這里一個(gè)常見的“坑”是文件偏移p_offset和內(nèi)存虛擬地址p_vaddr的對(duì)齊問題。p_vaddr可能不是頁(yè)面對(duì)齊的但我們?cè)诮?nèi)存映射時(shí)必須以頁(yè)面為單位。這意味著你可能需要先分配一個(gè)完整的物理頁(yè)然后只將段數(shù)據(jù)寫入該頁(yè)中從p_vaddr對(duì)應(yīng)偏移開始的部分。段末尾未使用的部分應(yīng)清零對(duì)應(yīng).bss段。2. 系統(tǒng)調(diào)用實(shí)現(xiàn)機(jī)制如何讓用戶程序安全地調(diào)用內(nèi)核功能x86架構(gòu)提供了int指令軟件中斷作為從用戶態(tài)ring 3陷入內(nèi)核態(tài)ring 0的標(biāo)準(zhǔn)方式。Pintos實(shí)驗(yàn)2約定使用int 0x30作為系統(tǒng)調(diào)用中斷號(hào)。用戶側(cè)我們需要在lib/user/syscall.c中提供一系列封裝函數(shù)如write,exec,wait等。這些函數(shù)的工作是將系統(tǒng)調(diào)用號(hào)例如SYS_WRITE和參數(shù)按照特定的約定比如壓棧準(zhǔn)備好然后執(zhí)行int $0x30指令。內(nèi)核側(cè)我們需要在src/userprog/syscall.c中實(shí)現(xiàn)syscall_handler()函數(shù)它作為0x30號(hào)中斷的處理例程。這個(gè)處理函數(shù)需要 a. 從用戶?;蚣拇嫫骶唧w約定需查看實(shí)驗(yàn)文檔或lib/syscall-nr.h中取出系統(tǒng)調(diào)用號(hào)和參數(shù)。 b. 進(jìn)行參數(shù)驗(yàn)證例如指針參數(shù)指向的用戶內(nèi)存地址是否有效。 c. 根據(jù)調(diào)用號(hào)分派到具體的處理函數(shù)如sys_write,sys_exec等。 d. 將返回值設(shè)置到某個(gè)寄存器如%eax中供用戶程序讀取。3. 進(jìn)程數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)Pintos內(nèi)核的thread結(jié)構(gòu)體最初是為內(nèi)核線程設(shè)計(jì)的。為了支持進(jìn)程我們需要擴(kuò)展它。通常我們會(huì)創(chuàng)建一個(gè)struct process或直接在struct thread中添加以下字段tid_t parent_tid父進(jìn)程的線程ID。struct list children子進(jìn)程列表。int exit_status進(jìn)程的退出狀態(tài)碼。struct semaphore wait_sema一個(gè)信號(hào)量用于實(shí)現(xiàn)wait()系統(tǒng)調(diào)用時(shí)的同步。父進(jìn)程在該信號(hào)量上等待子進(jìn)程退出時(shí)up此信號(hào)量。bool loaded標(biāo)識(shí)程序是否成功加載。這在exec()中至關(guān)重要因?yàn)榧虞d可能失敗文件不存在、非ELF格式等我們需要將失敗信息返回給調(diào)用者。struct file *executable指向進(jìn)程可執(zhí)行文件對(duì)象的指針。保持文件打開直到進(jìn)程結(jié)束以防止文件在運(yùn)行時(shí)被刪除。3. 核心模塊實(shí)現(xiàn)與實(shí)操要點(diǎn)理解了整體設(shè)計(jì)我們就可以深入到各個(gè)核心模塊的代碼實(shí)現(xiàn)中了。這里我會(huì)結(jié)合我當(dāng)年調(diào)試時(shí)遇到的典型問題和技巧逐一拆解。3.1 用戶程序加載器Loader的實(shí)現(xiàn)細(xì)節(jié)加載器的入口函數(shù)通常是process_execute()或load()。它的偽代碼邏輯如下bool load(const char *file_name, void (**eip) (void), void **esp) { // 1. 打開文件 struct file *file filesys_open(file_name); if (file NULL) return false; // 2. 讀取并驗(yàn)證ELF文件頭 Elf32_Ehdr ehdr; file_read(file, ehdr, sizeof(ehdr)); if (memcmp(ehdr.e_ident, ELFMAG, SELFMAG) ! 0) return false; // 魔數(shù)校驗(yàn) // 3. 遍歷程序頭表 for (int i 0; i ehdr.e_phnum; i) { Elf32_Phdr phdr; file_seek(file, ehdr.e_phoff i * ehdr.e_phentsize); file_read(file, phdr, sizeof(phdr)); if (phdr.p_type PT_LOAD) { // 4. 為這個(gè)LOAD段分配頁(yè)面并建立映射 uint32_t read_bytes phdr.p_filesz; // 段在文件中的大小 uint32_t zero_bytes phdr.p_memsz - phdr.p_filesz; // .bss部分需要清零的大小 uint32_t page_offset phdr.p_vaddr (PGSIZE - 1); // 段起始地址在頁(yè)面內(nèi)的偏移 // 計(jì)算需要多少頁(yè) uint32_t start_page pg_round_down(phdr.p_vaddr); uint32_t end_page pg_round_down(phdr.p_vaddr phdr.p_memsz - 1); for (uint32_t page start_page; page end_page; page PGSIZE) { // 分配一個(gè)物理頁(yè)幀并在頁(yè)表中建立 page - frame 的映射權(quán)限根據(jù)phdr.p_flags設(shè)置可讀、可寫、可執(zhí)行 // ... } // 5. 將段數(shù)據(jù)從文件讀入內(nèi)存 file_seek(file, phdr.p_offset); while (read_bytes 0 || zero_bytes 0) { // 計(jì)算當(dāng)前頁(yè)面能寫入多少數(shù)據(jù)... // 調(diào)用file_read讀取文件內(nèi)容到臨時(shí)緩沖區(qū)再?gòu)?fù)制到用戶虛擬地址 // 調(diào)用memset清零.bss部分 } } } // 6. 設(shè)置入口點(diǎn)和初始棧指針 *eip (void (*)(void)) ehdr.e_entry; *esp (void*) PHYS_BASE; // Pintos用戶棧初始位置通常在物理內(nèi)存頂部 // 7. 將文件名參數(shù)壓入用戶棧如果需要 // ... return true; }實(shí)操要點(diǎn)與避坑指南文件操作與保持打開加載器需要打開可執(zhí)行文件并讀取內(nèi)容。一個(gè)關(guān)鍵細(xì)節(jié)是這個(gè)文件描述符struct file*必須在進(jìn)程整個(gè)生命周期內(nèi)保持打開直到進(jìn)程退出。這是因?yàn)檫M(jìn)程的代碼段在內(nèi)存中是以文件的內(nèi)存映射mmap-like方式存在的如果文件被提前關(guān)閉當(dāng)發(fā)生頁(yè)面換出再換入時(shí)就無法從磁盤重新讀取數(shù)據(jù)。通常我們將這個(gè)file指針保存在進(jìn)程控制塊PCB中。棧的初始化Pintos要求將命令行參數(shù)按照C語(yǔ)言main(int argc, char *argv[])的約定壓入用戶棧。這包括argv指針數(shù)組、各個(gè)參數(shù)字符串本身、以及一個(gè)哨兵NULL指針。壓棧順序必須嚴(yán)格遵守System V ABI或?qū)嶒?yàn)具體要求從右向左壓入?yún)?shù)最后壓入argc和argv。棧指針%esp必須指向argc的地址。這一步非常繁瑣且容易出錯(cuò)建議單獨(dú)寫一個(gè)函數(shù)setup_stack()來處理并用GDB仔細(xì)檢查棧內(nèi)存布局。內(nèi)存映射權(quán)限根據(jù)ELF程序頭中的p_flags設(shè)置頁(yè)表項(xiàng)的權(quán)限位。PF_R對(duì)應(yīng)可讀PF_W對(duì)應(yīng)可寫PF_X對(duì)應(yīng)可執(zhí)行。只讀數(shù)據(jù)段如.rodata應(yīng)設(shè)置為只讀任何寫入嘗試都應(yīng)觸發(fā)頁(yè)面錯(cuò)誤這正是rox-*測(cè)試用例要檢驗(yàn)的。3.2 系統(tǒng)調(diào)用分派與參數(shù)驗(yàn)證框架系統(tǒng)調(diào)用處理程序syscall_handler()是內(nèi)核安全的第一道大門。它的首要任務(wù)不是執(zhí)行功能而是驗(yàn)證。static void syscall_handler(struct intr_frame *f) { // 1. 從中斷幀f中獲取系統(tǒng)調(diào)用號(hào)。約定可能保存在%eax寄存器中。 int syscall_no f-eax; // 2. 參數(shù)驗(yàn)證輔助函數(shù)檢查指針ptr指向的用戶內(nèi)存是否有效可讀/可寫 if (!is_user_vaddr(ptr) || ptr NULL || !pagedir_get_page(thread_current()-pagedir, ptr)) { // 無效地址終止進(jìn)程exit(-1) thread_exit_with_status(-1); } // 3. 根據(jù)調(diào)用號(hào)分派 switch (syscall_no) { case SYS_HALT: ... case SYS_EXIT: { // 首先驗(yàn)證狀態(tài)參數(shù)如果是整數(shù)通常無需額外驗(yàn)證 int status f-ecx; // 假設(shè)第一個(gè)參數(shù)在ecx sys_exit(status); break; } case SYS_EXEC: { // 首先驗(yàn)證字符串指針 const char *cmd_line (const char*)f-ecx; validate_user_string(cmd_line); // 需要檢查字符串是否以\0結(jié)尾且在有效內(nèi)存范圍內(nèi) f-eax sys_exec(cmd_line); break; } case SYS_WRITE: { int fd f-ecx; const void *buffer (const void*)f-edx; unsigned size f-ebx; // 參數(shù)順序依約定而定 validate_user_buffer(buffer, size, true); // 檢查buffer開始的size字節(jié)是否可讀 f-eax sys_write(fd, buffer, size); break; } // ... 其他系統(tǒng)調(diào)用 default: // 未知系統(tǒng)調(diào)用終止進(jìn)程 thread_exit_with_status(-1); } }參數(shù)驗(yàn)證的深層邏輯為什么必須驗(yàn)證用戶程序可能是惡意的或有bug的。它可能傳遞一個(gè)指向內(nèi)核地址的指針如果內(nèi)核直接解引用就會(huì)讀取或破壞內(nèi)核數(shù)據(jù)造成安全漏洞或系統(tǒng)崩潰。驗(yàn)證什么地址有效性指針是否在用戶地址空間0x08048000到PHYS_BASE是否為空內(nèi)存存在性該地址是否已經(jīng)映射了物理頁(yè)通過pagedir_get_page()查詢當(dāng)前進(jìn)程的頁(yè)表。訪問權(quán)限對(duì)于寫入SYS_WRITE的buffer內(nèi)存是否可寫對(duì)于讀取SYS_READ的buffer內(nèi)存是否可讀這需要查詢頁(yè)表項(xiàng)的權(quán)限位。字符串完整性對(duì)于字符串參數(shù)需要確保整個(gè)字符串直到遇到\0都在可讀的用戶內(nèi)存內(nèi)。需要寫一個(gè)循環(huán)來逐頁(yè)檢查。驗(yàn)證失敗的處理通常的做法是立即終止exit(-1)發(fā)出非法請(qǐng)求的進(jìn)程。這模擬了現(xiàn)代操作系統(tǒng)對(duì)非法內(nèi)存訪問拋出SIGSEGV信號(hào)的行為。3.3 關(guān)鍵系統(tǒng)調(diào)用exec與wait的實(shí)現(xiàn)exec和wait是進(jìn)程管理的基石它們的實(shí)現(xiàn)需要精心設(shè)計(jì)進(jìn)程間的同步與狀態(tài)傳遞。sys_exec的實(shí)現(xiàn)思路驗(yàn)證命令行字符串。調(diào)用process_execute()。注意這個(gè)函數(shù)會(huì)創(chuàng)建一個(gè)新線程來加載和運(yùn)行目標(biāo)程序。關(guān)鍵難點(diǎn)加載成功與否的同步。process_execute()創(chuàng)建新線程后立即返回但新線程可能加載失敗文件不存在。父進(jìn)程調(diào)用者需要知道這個(gè)結(jié)果。常見的解決方案是在子進(jìn)程的線程結(jié)構(gòu)體中設(shè)置一個(gè)struct semaphore load_sema初始值為0。子線程在加載完成后無論成功失敗將加載結(jié)果成功則記錄進(jìn)程ID失敗則記錄錯(cuò)誤存入線程結(jié)構(gòu)體然后執(zhí)行sema_up(load_sema)。父線程在調(diào)用process_execute()后立即執(zhí)行sem_down(load_sema)進(jìn)行等待。父線程被喚醒后檢查子線程結(jié)構(gòu)體中的加載結(jié)果。若成功返回子進(jìn)程的PID在Pintos中即線程IDtid_t若失敗返回-1或錯(cuò)誤碼。如果加載成功父進(jìn)程需要將子進(jìn)程加入自己的children鏈表以便后續(xù)wait。sys_wait的實(shí)現(xiàn)思路驗(yàn)證提供的PID是否是自己的子進(jìn)程。在子進(jìn)程的線程結(jié)構(gòu)體中找到對(duì)應(yīng)的wait_sema信號(hào)量并執(zhí)行sem_down。父進(jìn)程將在此阻塞。子進(jìn)程在退出時(shí)sys_exit中需要設(shè)置自己的exit_status。從父進(jìn)程的children鏈表中移除自己注意同步。執(zhí)行sem_up(wait_sema)來喚醒可能正在等待的父進(jìn)程。如果父進(jìn)程已經(jīng)終止則需要由祖先進(jìn)程init來回收資源這涉及到更復(fù)雜的孤兒進(jìn)程處理。在基礎(chǔ)實(shí)驗(yàn)中有時(shí)可以簡(jiǎn)化處理。父進(jìn)程被喚醒后獲取子進(jìn)程的exit_status銷毀子進(jìn)程的資源如關(guān)閉打開的文件、釋放頁(yè)表等然后返回該狀態(tài)碼。重要心得exec和wait的實(shí)現(xiàn)強(qiáng)烈依賴于線程/進(jìn)程數(shù)據(jù)結(jié)構(gòu)的設(shè)計(jì)和信號(hào)量的正確使用。務(wù)必在添加任何字段時(shí)想清楚這個(gè)字段由誰(shuí)寫入由誰(shuí)讀取在什么時(shí)機(jī)是否需要鎖或信號(hào)量保護(hù)畫一個(gè)簡(jiǎn)單的狀態(tài)轉(zhuǎn)換圖會(huì)非常有幫助。4. 完整實(shí)現(xiàn)流程與關(guān)鍵代碼剖析讓我們沿著一個(gè)用戶程序從被加載到結(jié)束的完整路徑串聯(lián)起各個(gè)模塊并看看關(guān)鍵代碼如何組織。4.1 從process_execute到用戶main函數(shù)這是用戶程序生命的起點(diǎn)。我們跟蹤一次exec(“myprog arg1 arg2”)的調(diào)用。用戶庫(kù)發(fā)起調(diào)用在用戶程序中exec()是lib/user/syscall.c中的一個(gè)封裝函數(shù)。它把系統(tǒng)調(diào)用號(hào)SYS_EXEC和參數(shù)字符串地址放入寄存器然后執(zhí)行int 0x30。陷入內(nèi)核CPU切換到內(nèi)核態(tài)跳轉(zhuǎn)到syscall_handler。參數(shù)驗(yàn)證與分派syscall_handler驗(yàn)證字符串地址然后調(diào)用sys_exec(“myprog arg1 arg2”)。內(nèi)核創(chuàng)建新進(jìn)程sys_exec調(diào)用process_execute(“myprog arg1 arg2”)。tid_t process_execute(const char *file_name) { char *fn_copy; tid_t tid; struct thread *cur thread_current(); // 復(fù)制文件名因?yàn)樵羔樦赶虻挠脩魞?nèi)存在新線程上下文可能無效 fn_copy palloc_get_page(0); if (fn_copy NULL) return TID_ERROR; strlcpy(fn_copy, file_name, PGSIZE); // 創(chuàng)建新線程入口函數(shù)是start_process tid thread_create(file_name, PRI_DEFAULT, start_process, fn_copy); if (tid TID_ERROR) { palloc_free_page(fn_copy); } // 在這里父進(jìn)程會(huì)通過信號(hào)量等待子進(jìn)程加載完成見上文 sema_down(cur-child_load_sema); // 假設(shè)信號(hào)量在thread結(jié)構(gòu)體中 return cur-load_status; // 返回加載結(jié)果PID或-1 }新線程的初始化新線程開始執(zhí)行start_process(fn_copy)。static void start_process(void *file_name_) { char *file_name file_name_; struct intr_frame if_; bool success; // 初始化中斷幀模擬一個(gè)從用戶態(tài)進(jìn)入的中斷 memset(if_, 0, sizeof if_); if_.gs if_.fs if_.es if_.ds if_.ss SEL_UDSEG; // 用戶數(shù)據(jù)段選擇子 if_.cs SEL_UCSEG; // 用戶代碼段選擇子 if_.eflags FLAG_IF | FLAG_MBS; // 開中斷 // 加載程序這是最核心的一步。 success load(file_name, if_.eip, if_.esp); // 加載完成通知父進(jìn)程 struct thread *cur thread_current(); cur-parent-load_status success ? cur-tid : -1; sema_up(cur-parent-child_load_sema); if (!success) { // 加載失敗線程直接退出 thread_exit(); } // 設(shè)置棧上的參數(shù)argc, argv setup_stack(if_.esp, file_name); // 釋放臨時(shí)復(fù)制的文件名頁(yè)面 palloc_free_page(file_name); // 通過匯編指令iret“返回”到用戶態(tài)此時(shí)CPU會(huì)從中斷幀if_中恢復(fù)所有寄存器。 // eip指向程序入口esp指向設(shè)置好的棧頂程序開始執(zhí)行。 asm volatile (movl %0, %%esp; jmp intr_exit : : g(if_) : memory); }用戶程序開始執(zhí)行iret指令后CPU跳轉(zhuǎn)到用戶程序的入口點(diǎn)通常是_start最終調(diào)用main(argc, argv)。4.2 文件描述符與系統(tǒng)調(diào)用擴(kuò)展為了讓用戶程序能進(jìn)行文件操作open,read,write,close我們需要實(shí)現(xiàn)一個(gè)簡(jiǎn)單的文件描述符fd表。Pintos內(nèi)核本身有struct file抽象我們的任務(wù)是為每個(gè)進(jìn)程維護(hù)一個(gè)從整數(shù)fd到struct file*的映射。設(shè)計(jì)建議在進(jìn)程結(jié)構(gòu)體中添加一個(gè)struct file* fd_table[FD_MAX]數(shù)組。通常FD_MAX定義為128或256。約定fd 0, 1, 2分別為標(biāo)準(zhǔn)輸入、輸出、錯(cuò)誤。在進(jìn)程創(chuàng)建時(shí)可以將它們初始化為對(duì)應(yīng)的文件對(duì)象例如輸出可以關(guān)聯(lián)到控制臺(tái)。sys_open調(diào)用filesys_open()在fd表中找到一個(gè)空閑槽位存儲(chǔ)返回的file*返回fd索引。sys_read/sys_write通過fd索引找到file*調(diào)用file_read/file_write。必須驗(yàn)證buffer和size參數(shù)指向的用戶內(nèi)存有效sys_close調(diào)用file_close()并將fd表中對(duì)應(yīng)項(xiàng)置為NULL。文件共享與復(fù)制當(dāng)實(shí)現(xiàn)sys_exec時(shí)子進(jìn)程默認(rèn)不會(huì)繼承父進(jìn)程的文件描述符。但sys_fork如果實(shí)驗(yàn)要求則需要復(fù)制fd表。更精細(xì)的實(shí)現(xiàn)需要引用計(jì)數(shù)。內(nèi)存映射文件mmap的簡(jiǎn)化實(shí)現(xiàn)一些高級(jí)測(cè)試可能需要mmap。一個(gè)簡(jiǎn)化的實(shí)現(xiàn)思路是在sys_mmap中將文件的全部或一部分直接映射到用戶進(jìn)程的一段空閑虛擬地址區(qū)域。這需要你修改頁(yè)錯(cuò)誤處理程序page_fault()當(dāng)缺頁(yè)發(fā)生在mmap區(qū)域時(shí)不是從交換區(qū)讀而是從對(duì)應(yīng)的文件位置讀取數(shù)據(jù)到新分配的物理頁(yè)。munmap時(shí)則解除映射并釋放資源。5. 調(diào)試技巧、常見問題與測(cè)試通關(guān)實(shí)錄Pintos實(shí)驗(yàn)2的調(diào)試是一場(chǎng)硬仗。以下是我和同學(xué)們當(dāng)年總結(jié)出的“血淚經(jīng)驗(yàn)”。5.1 調(diào)試工具與核心技巧printf大法好但需謹(jǐn)慎在關(guān)鍵路徑如load,syscall_handler入口setup_stack后添加printf打印狀態(tài)信息。注意在內(nèi)核中大量使用printf可能會(huì)改變時(shí)序掩蓋一些并發(fā)bug。最好配合ASSERT使用。GDB是你的最佳伙伴必須學(xué)會(huì)用GDB調(diào)試內(nèi)核。make debug啟動(dòng)Pintos并等待GDB連接。在另一個(gè)終端pintos-gdb或配置好的GDB中使用target remote localhost:1234連接。關(guān)鍵命令b function_name在函數(shù)處設(shè)斷點(diǎn)。b file.c:123在特定行設(shè)斷點(diǎn)。c繼續(xù)執(zhí)行。n單步執(zhí)行不進(jìn)入函數(shù)。s單步執(zhí)行進(jìn)入函數(shù)。p variable打印變量。x/Nx addr以十六進(jìn)制檢查內(nèi)存。info registers查看所有寄存器狀態(tài)在syscall_handler里查看中斷幀f的內(nèi)容至關(guān)重要。thread apply all bt打印所有線程的調(diào)用棧用于診斷死鎖。檢查用戶內(nèi)存在GDB中檢查用戶虛擬地址0x0804xxxx的內(nèi)容需要先切換到目標(biāo)進(jìn)程的頁(yè)表上下文。Pintos的pagedir函數(shù)可以幫助你?;蛘咴趕yscall_handler中驗(yàn)證失敗時(shí)打印出有問題的地址和當(dāng)前進(jìn)程名能快速定位是哪個(gè)測(cè)試用例的哪個(gè)調(diào)用出了問題。理解測(cè)試用例不要盲目跑make check。仔細(xì)閱讀tests/userprog目錄下的測(cè)試源文件。它們清楚地展示了期望的系統(tǒng)調(diào)用序列和返回值。例如exec-once測(cè)試了什么multi-oom測(cè)試如何耗盡內(nèi)存這能幫你理解失敗的原因。5.2 常見問題排查清單問題現(xiàn)象可能原因排查方向make check大量失敗特別是exec-*,wait-*進(jìn)程控制邏輯錯(cuò)誤exec/wait同步問題。1. 檢查exec中父子進(jìn)程的信號(hào)量同步邏輯確保父進(jìn)程能正確獲取子進(jìn)程加載結(jié)果。2. 檢查wait實(shí)現(xiàn)確保父進(jìn)程能在子進(jìn)程的wait_sema上正確阻塞和喚醒。3. 檢查進(jìn)程退出時(shí)exit_status的設(shè)置和資源釋放。rox-*測(cè)試失敗寫入只讀內(nèi)存未觸發(fā)錯(cuò)誤頁(yè)表權(quán)限設(shè)置錯(cuò)誤。1. 在load函數(shù)中確保為PF_W未設(shè)置的段如.rodata建立頁(yè)表映射時(shí)清除可寫位。2. 檢查頁(yè)錯(cuò)誤處理程序page_fault()當(dāng)錯(cuò)誤是由于用戶程序?qū)懭胫蛔x頁(yè)引起時(shí)應(yīng)終止該進(jìn)程而不是內(nèi)核panic。sc-*測(cè)試失敗系統(tǒng)調(diào)用參數(shù)驗(yàn)證系統(tǒng)調(diào)用參數(shù)驗(yàn)證不完整或錯(cuò)誤。1. 確保syscall_handler中對(duì)每一個(gè)指針參數(shù)都進(jìn)行了有效性驗(yàn)證地址范圍、映射存在、訪問權(quán)限。2. 特別檢查字符串參數(shù)驗(yàn)證函數(shù)必須驗(yàn)證到字符串結(jié)束符\0。3. 驗(yàn)證失敗后必須終止進(jìn)程設(shè)置狀態(tài)為-1退出而不是直接返回錯(cuò)誤值。測(cè)試卡住超時(shí)死鎖或無限循環(huán)。1. 使用GDB的thread apply all bt查看所有線程堆??词欠裼芯€程在鎖或信號(hào)量上永久等待。2. 檢查lock_acquire和sema_down周圍是否有遞歸調(diào)用或未釋放鎖的情況。3. 在sys_wait中檢查是否錯(cuò)誤地在自己的wait_sema上等待導(dǎo)致父子互相等待的死鎖。頁(yè)面錯(cuò)誤Page Fault或通用保護(hù)錯(cuò)誤GPF訪問了非法地址或內(nèi)核數(shù)據(jù)結(jié)構(gòu)被破壞。1. 首先看錯(cuò)誤地址。如果是用戶地址0x0804xxxx或0xc0000000以下是用戶程序問題應(yīng)終止該進(jìn)程。2. 如果是內(nèi)核地址很可能是內(nèi)核代碼有bug。檢查a. 系統(tǒng)調(diào)用驗(yàn)證不嚴(yán)用戶指針指向了內(nèi)核數(shù)據(jù)并被解引用。b. 棧溢出破壞了線程結(jié)構(gòu)。c. 并發(fā)訪問共享數(shù)據(jù)未加鎖導(dǎo)致數(shù)據(jù)競(jìng)爭(zhēng)。內(nèi)存耗盡測(cè)試oom-*失敗內(nèi)存分配失敗處理不當(dāng)。1.palloc_get_page()或malloc失敗時(shí)應(yīng)返回NULL或false你的代碼需要處理這種錯(cuò)誤優(yōu)雅地失敗例如exec返回-1而不是崩潰。2. 確保所有分配的資源物理頁(yè)、文件描述符、進(jìn)程槽位在進(jìn)程退出時(shí)都被正確釋放。5.3 進(jìn)階挑戰(zhàn)與優(yōu)化思考當(dāng)基礎(chǔ)功能全部通過測(cè)試后你可以思考一些更深層次的問題這能極大提升你對(duì)操作系統(tǒng)的理解共享內(nèi)存與fork()如果實(shí)驗(yàn)包含fork()你需要實(shí)現(xiàn)寫時(shí)復(fù)制Copy-On-Write, COW。這需要修改頁(yè)錯(cuò)誤處理程序當(dāng)發(fā)現(xiàn)對(duì)只讀的私有頁(yè)面進(jìn)行寫入時(shí)不是終止進(jìn)程而是復(fù)制該物理頁(yè)為新頁(yè)建立可寫映射。執(zhí)行效率當(dāng)前的load是“急切加載”eager loading即一次性將整個(gè)程序讀入內(nèi)存。能否實(shí)現(xiàn)“惰性加載”lazy loading即只建立頁(yè)表映射標(biāo)記頁(yè)面不存在當(dāng)程序首次訪問該頁(yè)時(shí)觸發(fā)缺頁(yè)中斷再將對(duì)應(yīng)的代碼/數(shù)據(jù)從磁盤讀入。這能加快exec的速度。更真實(shí)的文件描述符管理實(shí)現(xiàn)文件描述符的復(fù)制dup/dup2和繼承通過fork/exec時(shí)傳遞CLONE_FILES標(biāo)志。這需要為struct file引入引用計(jì)數(shù)。信號(hào)Signals的簡(jiǎn)化版嘗試實(shí)現(xiàn)一個(gè)簡(jiǎn)單的信號(hào)機(jī)制例如SIGCHLD當(dāng)子進(jìn)程終止時(shí)通知父進(jìn)程這可以替代或增強(qiáng)基于信號(hào)量的wait同步。完成Pintos實(shí)驗(yàn)2你收獲的遠(yuǎn)不止是幾十個(gè)通過的測(cè)試用例。你親手搭建了一個(gè)微型但五臟俱全的進(jìn)程管理框架對(duì)虛擬內(nèi)存、系統(tǒng)調(diào)用、進(jìn)程間同步有了刻骨銘心的理解。這些知識(shí)是理解Linux、Windows等現(xiàn)代操作系統(tǒng)的基石。當(dāng)你再在高級(jí)語(yǔ)言中調(diào)用fork()或CreateProcess時(shí)你腦海中浮現(xiàn)的將是頁(yè)表、中斷描述符和信號(hào)量這種透過抽象看到本質(zhì)的能力正是這個(gè)實(shí)驗(yàn)帶給你的最大財(cái)富。