1. ucore lab5介紹
ucore在lab4中實現了程式/執行緒機制,能夠建立並進行核心執行緒的排程。通過上下文的切換令執行緒分時的獲得CPU,使得不同執行緒能夠併發的執行。
在lab5中需要更進一步,實現我們平常開發接觸到的、執行在使用者態的程式/執行緒機制。使用者執行緒通常用於承載和執行應用程式,為了保護作業系統核心,避免其被不夠魯棒的應用程式破壞。應用程式都執行在低特權級中,無法直接訪問高特權級的核心資料結構,也無法通過程式指令直接的訪問各種外設。
但應用程式訪問高特權級資料、外設的需求是不可避免的(即使簡單的列印資料到控制檯中也是在對顯示卡這一外設進行控制),因此ucore在lab5中也實現了系統呼叫機制。應用程式平常執行在使用者態,在有需要時可以通過系統呼叫的方式間接的訪問外設等受到保護的資源。
系統呼叫介紹
系統呼叫是作業系統提供的一種特殊api介面,底層是通過中斷實現的。應用程式呼叫系統中斷時,其CPL特權級會被暫時的提升到ring0,因此便獲得了訪問外設、核心資料的能力。這一提升CPL特權級從外層使用者態到裡層核心態的過程,也被稱為陷入核心(系統呼叫會陷入核心,但是陷入核心的方式除了系統呼叫外,還包括觸發保護異常等)。
由於系統呼叫是作業系統的開發人員精心設計的,且對傳入的引數等等有著很嚴格的控制,確保了系統呼叫不會對核心造成破壞。同時,在系統呼叫中斷返回時,也會將其CPL特權級對應用程式透明的還原到使用者態。在ucore的lab5中,提供了一些使用者態的demo應用程式,並在核心實現了諸如fork、exit、kill、yield、wait等等系統呼叫功能以及C實現的應用程式系統呼叫庫。
通過lab5的學習,可以更深入的瞭解作業系統中使用者態應用程式的載入、執行和退出機制,以及系統呼叫的工作原理。
lab5是建立在之前實驗的基礎之上的,需要先理解之前的實驗內容才能順利理解lab5的內容。
可以參考一下我關於前面實驗的部落格:
1. ucore作業系統學習(一) ucore lab1系統啟動流程分析
2. ucore作業系統學習(二) ucore lab2實體記憶體管理分析
3. ucore作業系統學習(三) ucore lab3虛擬記憶體管理分析
4. ucore作業系統學習(四) ucore lab4核心執行緒管理
2. ucore lab5實驗細節分析
下面通過解析lab5實驗的原始碼,進一步分析在ucore中載入、並執行一個使用者態應用程式的機制,以及系統呼叫是如何實現的。
2.1 lab5中執行緒控制塊的變化
lab5中引入了使用者程式/執行緒機制,使用者程式/執行緒會隨著程式的執行不斷的被建立、退出並銷燬。同時也引入了父子程式的概念,在子程式退出時,由於其核心棧和子程式自己的程式控制塊無法進行自我回收,因此需要通知其父程式進行最後的回收工作。
為此,ucore在lab5中在程式控制塊proc_sturct中加入了exit_code、wait_state以及標識執行緒之間父子關係的連結串列節點*cptr, *yptr, *optr。
lab5中proc_struct:
/** * 程式控制塊結構(ucore程式和執行緒都使用proc_struct進行管理) * */ struct proc_struct { 。。。 只列出了lab5中新增加的屬性項
// 當前執行緒退出時的原因(在回收子執行緒時會傳送給父執行緒) int exit_code; // exit code (be sent to parent proc) // 當前執行緒進入wait阻塞態的原因 uint32_t wait_state; // waiting state /** * cptr即child ptr,當前執行緒子執行緒(連結串列結構) * yptr即younger sibling ptr; * optr即older sibling ptr; * cptr為當前執行緒的子執行緒雙向連結串列頭結點,通過yptr和optr可以找到關聯的所有子執行緒 * */ struct proc_struct *cptr, *yptr, *optr; // relations between processes };
2.2 系統呼叫的實現原理
系統呼叫貫穿著整個lab5實驗的內容,只有理解了系統呼叫的實現原理後才能更進一步的理解lab5給出的demo程式中應用程式載入、執行及退出的機制。
系統呼叫是提供給執行在使用者態的應用程式使用的,且由於需要進行CPL特權級的提升,因此是通過硬體中斷來實現的。
ucore在初始化中斷描述符表的/trap/trap.c的idt_init函式中,在前面實驗的基礎上額外設定了一個用於系統呼叫的中斷描述符。
idt_init實現:
#define T_SYSCALL 0x80 /* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */ void idt_init(void) { extern uintptr_t __vectors[]; int i; // 首先通過tools/vector.c通過程式生成/kern/trap/verctor.S,並在載入核心時對之前已經宣告的全域性變數__vectors進行整體的賦值 // __vectors陣列中的每一項對應於中斷描述符的中斷服務例程的入口地址,在SETGATE巨集的使用中可以體現出來 // 將__vectors陣列中每一項關於中斷描述符的描述設定到下標相同的idt中,通過巨集SETGATE構造出最終的中斷描述符結構 for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) { // 遍歷idt陣列,將其中的內容(中斷描述符)設定進IDT中斷描述符表中(預設的DPL特權級都是核心態DPL_KERNEL-0) SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL); } // 設定系統呼叫的中斷描述符(T_SYSCALL 0x80),因為是提供給應用程式使用的,因此DPL特權級設定為使用者態(DPL_USER) SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER); // load the IDT 令IDTR中斷描述符表暫存器指向idt_pd,載入IDT // idt_pd結構體中的前16位為描述符表的界限,pd_base指向之前完成了賦值操作的idt陣列的起始位置 lidt(&idt_pd); }
可以看到在idt_init中,額外的設定了一箇中斷號為T_SYSCALL(0x80)的中斷描述符用於處理系統呼叫,且其DPL特權級為使用者態,使得使用者程式可以在使用者態主動的發起該中斷,獲得作業系統核心提供的服務。
對應的,在lab5的/trap/trap.c的中斷處理分發邏輯trap_dispatch函式中,也實現了對應的中斷服務例程用於處理T_SYSCALL系統呼叫中斷。
trap_dispatch函式:
static void trap_dispatch(struct trapframe *tf) { char c; int ret=0; switch (tf->tf_trapno) { 。。。 省略其它中斷號的處理邏輯 case T_SYSCALL: syscall(); break; 。。。 省略其它中斷號的處理邏輯 }
syscall函式(核心中系統呼叫處理邏輯):
static int (*syscalls[])(uint32_t arg[]) = { [SYS_exit] sys_exit, [SYS_fork] sys_fork, [SYS_wait] sys_wait, [SYS_exec] sys_exec, [SYS_yield] sys_yield, [SYS_kill] sys_kill, [SYS_getpid] sys_getpid, [SYS_putc] sys_putc, [SYS_pgdir] sys_pgdir, }; #define NUM_SYSCALLS ((sizeof(syscalls)) / (sizeof(syscalls[0]))) void syscall(void) { struct trapframe *tf = current->tf; uint32_t arg[5]; int num = tf->tf_regs.reg_eax; if (num >= 0 && num < NUM_SYSCALLS) { if (syscalls[num] != NULL) { arg[0] = tf->tf_regs.reg_edx; arg[1] = tf->tf_regs.reg_ecx; arg[2] = tf->tf_regs.reg_ebx; arg[3] = tf->tf_regs.reg_edi; arg[4] = tf->tf_regs.reg_esi; tf->tf_regs.reg_eax = syscalls[num](arg); return ; } } print_trapframe(tf); panic("undefined syscall %d, pid = %d, name = %s.\n", num, current->pid, current->name); }
ucore構造了一個當前系統所支援的系統呼叫處理表(syscalls陣列),根據系統呼叫中斷時傳入的系統呼叫號(儲存在eax中),跳轉執行對應的系統呼叫邏輯。
ucore的系統呼叫允許通過edx、ecx、ebx、edi和esi這5個暫存器傳遞最多5個引數,並且在對應的系統呼叫邏輯完成後通過eax暫存器存放系統呼叫的返回值。另一方面,可以從ucore提供的在使用者態執行的系統呼叫庫函式實現中看到應用程式是如何發起系統呼叫的,主要邏輯位於/user/lib/syscall.c中。
庫函式syscall.c中不同的系統呼叫最終都通過syscall函式進行統一處理,syscall通過內聯彙編的形式,執行了int i 0x80(T_SYSCALL)以發起系統呼叫中斷。系統呼叫號num賦值給了eax(a),且將需要傳遞的引數賦值(a[i])分別給了edx(d)、ecx(c)、ebx(b)、edi(D)和esi(S),且令返回值ret等於中斷返回後的eax(=a ret)。
可以通過互相比對呼叫方使用者態syscall.c庫函式以及核心中的syscall實現,加深對系統呼叫工作機制的理解。
syscall.c(使用者態的系統呼叫函式庫):
#include <defs.h> #include <unistd.h> #include <stdarg.h> #include <syscall.h> #define MAX_ARGS 5 static inline int syscall(int num, ...) { va_list ap; va_start(ap, num); uint32_t a[MAX_ARGS]; int i, ret; for (i = 0; i < MAX_ARGS; i ++) { a[i] = va_arg(ap, uint32_t); } va_end(ap); asm volatile ( "int %1;" : "=a" (ret) : "i" (T_SYSCALL), "a" (num), "d" (a[0]), "c" (a[1]), "b" (a[2]), "D" (a[3]), "S" (a[4]) : "cc", "memory"); return ret; } int sys_exit(int error_code) { return syscall(SYS_exit, error_code); } int sys_fork(void) { return syscall(SYS_fork); } int sys_wait(int pid, int *store) { return syscall(SYS_wait, pid, store); } int sys_yield(void) { return syscall(SYS_yield); } int sys_kill(int pid) { return syscall(SYS_kill, pid); } int sys_getpid(void) { return syscall(SYS_getpid); } int sys_putc(int c) { return syscall(SYS_putc, c); } int sys_pgdir(void) { return syscall(SYS_pgdir); }
在前面的實驗中提到過,如果包括系統呼叫在內的中斷髮生時,80386CPU將會在中斷棧幀中壓入中斷髮生前一刻的CS的值。如果是位於ring3使用者態的應用程式發起的系統呼叫中斷,那麼核心在接受中斷棧幀時其CS的CPL將會是ring3,並在執行中斷服務例程時被臨時的設定CS的CPL特權級為ring0以提升特權級,獲得訪問核心資料、外設的許可權。
在系統呼叫處理完畢返回後,iret指令會將之前CPU硬體自動壓入的cs(ring3的CPL)彈出。系統呼叫處理完畢中斷返回時,應用程式便自動無感知的回到了ring3這一低特權級中。
2.3 應用程式的載入
lab4在總控函式kern_init中通過proc_init建立了兩個核心執行緒idle_proc和init_proc,其中init_proc只是單單在控制檯中列印了hello world。
但lab5中在init_proc中fork了一個核心執行緒執行user_main,在user_main中執行了sys_execve系統呼叫,用於執行BIOS引導時與ucore核心一起被載入到記憶體中的使用者程式/user/exit.c,讓exit.c在使用者態中執行。(之所以要以這種方式載入exit.c是因為ucore目前還沒有實現檔案系統,無法以檔案的形式去載入二進位制的應用程式)
init_main實現(init_proc執行邏輯):
// init_main - the second kernel thread used to create user_main kernel threads static int init_main(void *arg) { size_t nr_free_pages_store = nr_free_pages(); size_t kernel_allocated_store = kallocated(); // fork建立一個執行緒執行user_main int pid = kernel_thread(user_main, NULL, 0); if (pid <= 0) { panic("create user_main failed.\n"); } // do_wait等待回收殭屍態子執行緒(第一個引數pid為0代表回收任意殭屍子執行緒) while (do_wait(0, NULL) == 0) { // 回收一個殭屍子執行緒後,進行排程 schedule(); } // 跳出了上述迴圈代表init_main的所有子執行緒都退出並回收完了 cprintf("all user-mode processes have quit.\n"); assert(initproc->cptr == NULL && initproc->yptr == NULL && initproc->optr == NULL); assert(nr_process == 2); assert(list_next(&proc_list) == &(initproc->list_link)); assert(list_prev(&proc_list) == &(initproc->list_link)); assert(nr_free_pages_store == nr_free_pages()); assert(kernel_allocated_store == kallocated()); cprintf("init check memory pass.\n"); return 0; }
user_main實現:
// user_main - kernel thread used to exec a user program static int user_main(void *arg) { #ifdef TEST KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE); #else KERNEL_EXECVE(exit); #endif panic("user_main execve failed.\n"); } #define __KERNEL_EXECVE(name, binary, size) ({ \ cprintf("kernel_execve: pid = %d, name = \"%s\".\n", \ current->pid, name); \ kernel_execve(name, binary, (size_t)(size)); \ }) #define KERNEL_EXECVE(x) ({ \ extern unsigned char _binary_obj___user_##x##_out_start[], \ _binary_obj___user_##x##_out_size[]; \ __KERNEL_EXECVE(#x, _binary_obj___user_##x##_out_start, \ _binary_obj___user_##x##_out_size); \ }) #define __KERNEL_EXECVE2(x, xstart, xsize) ({ \ extern unsigned char xstart[], xsize[]; \ __KERNEL_EXECVE(#x, xstart, (size_t)xsize); \ }) #define KERNEL_EXECVE2(x, xstart, xsize) __KERNEL_EXECVE2(x, xstart, xsize) // kernel_execve - do SYS_exec syscall to exec a user program called by user_main kernel_thread static int kernel_execve(const char *name, unsigned char *binary, size_t size) { int ret, len = strlen(name); // 核心中執行系統呼叫SYS_exec asm volatile ( "int %1;" : "=a" (ret) : "i" (T_SYSCALL), "0" (SYS_exec), "d" (name), "c" (len), "b" (binary), "D" (size) : "memory"); return ret; }
exit.c使用者程式的載入
在usermain中,通過kernel_execve函式,在核心態發起了系統呼叫號為SYS_exec的系統呼叫。通過前面對系統呼叫機制的介紹,kernel_execve函式發起系統呼叫號為SYS_exec編號的系統呼叫後,最終邏輯會執行到系統呼叫處理表syscalls中的對應的sys_exec函式中。
sys_exec接受四個引數,arg[0]和arg[1]用於指定所要建立、執行的執行緒名以及其字串長度;arg[2]和arg[3]用於指定對應elf格式的二進位制程式在記憶體中的地址以及程式的大小。在sys_exec的核心邏輯位於其呼叫的do_execve中。
sys_exec函式:
static int sys_exec(uint32_t arg[]) { const char *name = (const char *)arg[0]; size_t len = (size_t)arg[1]; unsigned char *binary = (unsigned char *)arg[2]; size_t size = (size_t)arg[3]; return do_execve(name, len, binary, size); }
do_execve函式解析:
在視訊的實驗公開課中提到,do_execve實現的大致原理就是令被載入的binary二進位制程式藉著當前執行緒的殼,用被載入的二進位制程式的記憶體空間替換掉之前執行緒的記憶體空間,達到騰籠換鳥的目的。
具體到程式碼中來,可以看到在進行了基礎的校驗之後,do_execve首先一步就是將當前執行緒current對應的mm_struct替換掉,且如果發現被替換掉的mm_struct沒有再被其它執行緒共享,則將其徹底銷燬釋放。之後通過一個較為複雜的load_icode函式,解析對應的binary二進位制程式,生成一個完全不同的,屬於被載入程式的mm_struct。
對當前執行緒current對應的記憶體總管理器mm_struct的一刪一增,導致current當前執行緒所執行的程式碼段、所屬的資料段和之前完全不同,巧妙的實現了藉著之前執行緒的殼,重新執行一個截然不同的程式的目標。從這裡也能更深入的體會到靜態的程式與動態的程式/執行緒的關係。
do_execve實現:
// do_execve - call exit_mmap(mm)&put_pgdir(mm) to reclaim memory space of current process // - call load_icode to setup new memory space accroding binary prog. // 執行binary對應的應用程式 int do_execve(const char *name, size_t len, unsigned char *binary, size_t size) { struct mm_struct *mm = current->mm; if (!user_mem_check(mm, (uintptr_t)name, len, 0)) { return -E_INVAL; } if (len > PROC_NAME_LEN) { len = PROC_NAME_LEN; } char local_name[PROC_NAME_LEN + 1]; memset(local_name, 0, sizeof(local_name)); memcpy(local_name, name, len); if (mm != NULL) { lcr3(boot_cr3); // 由於一般是通過fork一個新執行緒來執行do_execve,然後通過load_icode進行騰籠換鳥 // 令所載入的新程式佔據這個被fork出來的臨時執行緒的殼,所以需要先令當前執行緒的mm被引用次數-1(後續會建立新的mm給當前執行緒) if (mm_count_dec(mm) == 0) { // 如果當前執行緒的mm被引用次數為0,回收整個mm exit_mmap(mm); put_pgdir(mm); mm_destroy(mm); } current->mm = NULL; } int ret; // 開始騰籠換鳥,將二進位制格式的elf檔案載入到記憶體中,令current執行緒執行被載入完畢後的新程式 if ((ret = load_icode(binary, size)) != 0) { goto execve_exit; } // 設定程式名 set_proc_name(current, local_name); return 0; execve_exit: do_exit(ret); panic("already exit: %e.\n", ret); }
load_icode函式解析:
load_icode函式是lab5中十分核心、重要的一個部分。在load_icode中,按順序執行以下幾個步驟,為需要載入執行的新程式構造了完整的執行環境。
1. 為當前程式建立一個新的mm結構。
2. 為新的mm分配並設定一個新的頁目錄表。
3. 在二進位制資料空間中分配記憶體(虛擬、實體記憶體空間),從elf格式的二進位制程式中複製出對應的程式碼/資料段,並初始化BSS段(需要對ELF格式的檔案有一定的瞭解)。
4. 為當前程式建立、分配對應的使用者棧。
5. 將前面構造好的新的mm_struct結構與當前執行緒關聯起來。
6. 為了令當前應用程式能夠在載入後順利的回到使用者態執行,需要構造對應的中斷棧幀(設定中斷返回時的應用程式入口以及使用者態許可權的程式碼段暫存器、資料段、棧段暫存器等等)。在中斷返回後欺騙CPU,令CPU以為當前執行緒在之前發生了一次特權級切換的中斷(其實已經是一個和之前執行緒完全不同的新程式了)。
/* load_icode - load the content of binary program(ELF format) as the new content of current process * @binary: the memory addr of the content of binary program * @size: the size of the content of binary program */ static int load_icode(unsigned char *binary, size_t size) { if (current->mm != NULL) { panic("load_icode: current->mm must be empty.\n"); } int ret = -E_NO_MEM; struct mm_struct *mm; //(1) create a new mm for current process // 為當前程式建立一個新的mm結構 if ((mm = mm_create()) == NULL) { goto bad_mm; } //(2) create a new PDT, and mm->pgdir= kernel virtual addr of PDT // 為mm分配並設定一個新的頁目錄表 if (setup_pgdir(mm) != 0) { goto bad_pgdir_cleanup_mm; } //(3) copy TEXT/DATA section, build BSS parts in binary to memory space of process // 從程式的二進位制資料空間中分配記憶體,複製出對應的程式碼/資料段,建立BSS部分 struct Page *page; //(3.1) get the file header of the binary program (ELF format) // 從二進位制程式中得到ELF格式的檔案頭(二進位制程式資料的最開頭的一部分是elf檔案頭,以elfhdr指標的形式將其對映、提取出來) struct elfhdr *elf = (struct elfhdr *)binary; //(3.2) get the entry of the program section headers of the bianry program (ELF format) // 找到並對映出binary中程式段頭的入口起始位置 struct proghdr *ph = (struct proghdr *)(binary + elf->e_phoff); //(3.3) This program is valid? // 根據elf的魔數,判斷其是否是一個合法的ELF檔案 if (elf->e_magic != ELF_MAGIC) { ret = -E_INVAL_ELF; goto bad_elf_cleanup_pgdir; } uint32_t vm_flags, perm; // 找到並對映出binary中程式段頭的入口截止位置(根據elf->e_phnum進行對應的指標偏移) struct proghdr *ph_end = ph + elf->e_phnum; // 遍歷每一個程式段頭 for (; ph < ph_end; ph ++) { //(3.4) find every program section headers if (ph->p_type != ELF_PT_LOAD) { // 如果不是需要載入的段,直接跳過 continue ; } if (ph->p_filesz > ph->p_memsz) { // 如果檔案頭標明的檔案段大小大於所佔用的記憶體大小(memsz可能包括了BSS,所以這是錯誤的程式段頭) ret = -E_INVAL_ELF; goto bad_cleanup_mmap; } if (ph->p_filesz == 0) { // 檔案段大小為0,直接跳過 continue ; } //(3.5) call mm_map fun to setup the new vma ( ph->p_va, ph->p_memsz) // vm_flags => VMA段的許可權 // perm => 對應物理頁的許可權(因為是使用者程式,所以設定為PTE_U使用者態) vm_flags = 0, perm = PTE_U; // 根據檔案頭中的配置,設定VMA段的許可權 if (ph->p_flags & ELF_PF_X) vm_flags |= VM_EXEC; if (ph->p_flags & ELF_PF_W) vm_flags |= VM_WRITE; if (ph->p_flags & ELF_PF_R) vm_flags |= VM_READ; // 設定程式段所包含的物理頁的許可權 if (vm_flags & VM_WRITE) perm |= PTE_W; // 在mm中建立ph->p_va到ph->va+ph->p_memsz的合法虛擬地址空間段 if ((ret = mm_map(mm, ph->p_va, ph->p_memsz, vm_flags, NULL)) != 0) { goto bad_cleanup_mmap; } unsigned char *from = binary + ph->p_offset; size_t off, size; uintptr_t start = ph->p_va, end, la = ROUNDDOWN(start, PGSIZE); ret = -E_NO_MEM; //(3.6) alloc memory, and copy the contents of every program section (from, from+end) to process's memory (la, la+end) end = ph->p_va + ph->p_filesz; //(3.6.1) copy TEXT/DATA section of bianry program // 上面建立了合法的虛擬地址段,現在為這個虛擬地址段分配實際的實體記憶體頁 while (start < end) { // 分配一個記憶體頁,建立la對應頁的虛實對映關係 if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) { goto bad_cleanup_mmap; } off = start - la, size = PGSIZE - off, la += PGSIZE; if (end < la) { size -= la - end; } // 根據elf中程式頭的設定,將binary中的對應資料複製到新分配的物理頁中 memcpy(page2kva(page) + off, from, size); start += size, from += size; } //(3.6.2) build BSS section of binary program // 設定當前程式段的BSS段 end = ph->p_va + ph->p_memsz; // start < la代表BSS段存在,且最後一個物理頁沒有被填滿。剩下空間作為BSS段 if (start < la) { /* ph->p_memsz == ph->p_filesz */ if (start == end) { continue ; } off = start + PGSIZE - la, size = PGSIZE - off; if (end < la) { size -= la - end; } // 將BSS段所屬的部分格式化清零 memset(page2kva(page) + off, 0, size); start += size; assert((end < la && start == end) || (end >= la && start == la)); } // start < end代表還需要為BSS段分配更多的物理空間 while (start < end) { // 為BSS段分配更多的物理頁 if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) { goto bad_cleanup_mmap; } off = start - la, size = PGSIZE - off, la += PGSIZE; if (end < la) { size -= la - end; } // 將BSS段所屬的部分格式化清零 memset(page2kva(page) + off, 0, size); start += size; } } //(4) build user stack memory // 建立使用者棧空間 vm_flags = VM_READ | VM_WRITE | VM_STACK; // 為使用者棧設定對應的合法虛擬記憶體空間 if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0) { goto bad_cleanup_mmap; } assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-PGSIZE , PTE_USER) != NULL); assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-2*PGSIZE , PTE_USER) != NULL); assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-3*PGSIZE , PTE_USER) != NULL); assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-4*PGSIZE , PTE_USER) != NULL); //(5) set current process's mm, sr3, and set CR3 reg = physical addr of Page Directory // 當前mm被執行緒引用次數+1 mm_count_inc(mm); // 設定當前執行緒的mm current->mm = mm; // 設定當前執行緒的cr3 current->cr3 = PADDR(mm->pgdir); // 將指定的頁表地址mm->pgdir,載入進cr3暫存器 lcr3(PADDR(mm->pgdir)); //(6) setup trapframe for user environment // 設定使用者環境下的中斷棧幀 struct trapframe *tf = current->tf; memset(tf, 0, sizeof(struct trapframe)); /* LAB5:EXERCISE1 YOUR CODE * should set tf_cs,tf_ds,tf_es,tf_ss,tf_esp,tf_eip,tf_eflags * NOTICE: If we set trapframe correctly, then the user level process can return to USER MODE from kernel. So * tf_cs should be USER_CS segment (see memlayout.h) * tf_ds=tf_es=tf_ss should be USER_DS segment * tf_esp should be the top addr of user stack (USTACKTOP) * tf_eip should be the entry point of this binary program (elf->e_entry) * tf_eflags should be set to enable computer to produce Interrupt */ // 為了令核心態完成載入的應用程式能夠在載入流程完畢後順利的回到使用者態執行,需要對當前的中斷棧幀進行對應的設定 // CS段設定為使用者態段(平坦模型下只有一個唯一的使用者態程式碼段USER_CS) tf->tf_cs = USER_CS; // DS、ES、SS段設定為使用者態的段(平坦模型下只有一個唯一的使用者態資料段USER_DS) tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS; // DS // 設定使用者態的棧頂指標 tf->tf_esp = USTACKTOP; // 設定系統呼叫中斷返回後執行的程式入口,令其為elf頭中設定的e_entry(中斷返回後會復原中斷棧幀中的eip) tf->tf_eip = elf->e_entry; // 預設中斷返回後,使用者態執行時是開中斷的 tf->tf_eflags = FL_IF; ret = 0; out: return ret; bad_cleanup_mmap: exit_mmap(mm); bad_elf_cleanup_pgdir: put_pgdir(mm); bad_pgdir_cleanup_mm: mm_destroy(mm); bad_mm: goto out; }
2.4 exit.c應用程式執行流程分析
在load_icode、sys_exec函式返回,整個系統中斷的服務例程完成之後。根據構造好的trapframe中斷棧幀,執行iret指令中斷返回後,CPU的指令指標暫存器eip將指向elf->entry,即使用者程式的執行入口(exit.c的main函式入口)。且其CS程式碼段暫存器、DS/ES/SS等資料段暫存器的特權級均處於ring3使用者態。
在exit.c中,main函式中通過ulib.h中提供的使用者庫,執行了fork系統呼叫。fork完成後將會出現父子兩個執行緒,其中子執行緒在進行了多次yield後呼叫了exit庫函式進行了執行緒退出操作。而父執行緒則在fork完畢後通過呼叫waitpid的庫函式等待對應的子執行緒退出,將其最終回收。而作為應用程式的父執行緒在另一方面也是核心執行緒initproc建立的子執行緒(init_main函式)。當父執行緒的main函式執行完畢後,initmain函式中initproc也呼叫了do_wait函式在等待使用者態的user_main執行緒最終退出。
exit.c應用程式:
#include <stdio.h> #include <ulib.h> int magic = -0x10384; /** * 使用者態的應用程式 * */ int main(void) { int pid, code; cprintf("I am the parent. Forking the child...\n"); // 通過fork庫函式執行do_fork系統呼叫複製一個子執行緒執行 if ((pid = fork()) == 0) { // 子執行緒fork返回後的pid為0 cprintf("I am the child.\n"); yield(); yield(); yield(); yield(); yield(); yield(); yield(); // 通過exit庫函式執行do_exit系統呼叫,退出子執行緒 exit(magic); } else { // 父執行緒會執行到這裡,fork返回的pid為子執行緒的pid,不等於0 cprintf("I am parent, fork a child pid %d\n",pid); } assert(pid > 0); cprintf("I am the parent, waiting now..\n"); // 父執行緒通過wait庫函式執行do_wait系統呼叫,等待子執行緒退出並回收(waitpid返回值=0代表回收成功) assert(waitpid(pid, &code) == 0 && code == magic); // 再次回收會失敗waitpid返回值不等於0 assert(waitpid(pid, &code) != 0 && wait() != 0); cprintf("waitpid %d ok.\n", pid); cprintf("exit pass.\n"); return 0; }
init_main函式:
// init_main - the second kernel thread used to create user_main kernel threads static int init_main(void *arg) { size_t nr_free_pages_store = nr_free_pages(); size_t kernel_allocated_store = kallocated(); // fork建立一個執行緒執行user_main int pid = kernel_thread(user_main, NULL, 0); if (pid <= 0) { panic("create user_main failed.\n"); } // do_wait等待回收殭屍態子執行緒(第一個引數pid為0代表回收任意殭屍子執行緒) while (do_wait(0, NULL) == 0) { // 回收一個殭屍子執行緒後,進行排程 schedule(); } // 跳出了上述迴圈代表init_main的所有子執行緒都退出並回收完了 cprintf("all user-mode processes have quit.\n"); assert(initproc->cptr == NULL && initproc->yptr == NULL && initproc->optr == NULL); assert(nr_process == 2); assert(list_next(&proc_list) == &(initproc->list_link)); assert(list_prev(&proc_list) == &(initproc->list_link)); assert(nr_free_pages_store == nr_free_pages()); assert(kernel_allocated_store == kallocated()); cprintf("init check memory pass.\n"); return 0; }
lab5_answer執行的結果截圖:
可以看到,exit.c中的列印語句和init_main中的列印語句按照各自父子執行緒的退出順序,依次的被執行了。
下面我們分析exit.c應用程式執行的流程,分析為什麼會以這樣的順序執行列印語句?
首先,前面分析了核心執行緒在user_main裡在核心狀態下發起了sys_execve系統呼叫,解析並載入了應用程式exit.c。在系統呼叫中斷返回後,exit.c回到了使用者態並跳轉到main函式入口處開始執行。
在exit.c的main函式中,首先執行了語句"cprintf("I am the parent. Forking the child...\n");",然後便通過使用者庫執行了fork系統呼叫,fork系統呼叫最終的處理邏輯在核心的syscall.c中定義的sys_fork處。sys_fork中呼叫do_fork函式用於fork複製一個子執行緒。
sys_fork函式:
static int sys_fork(uint32_t arg[]) { struct trapframe *tf = current->tf; uintptr_t stack = tf->tf_esp; return do_fork(0, stack, tf); }
do_fork函式:
/* do_fork - parent process for a new child process * @clone_flags: used to guide how to clone the child process * @stack: the parent's user stack pointer. if stack==0, It means to fork a kernel thread. * @tf: the trapframe info, which will be copied to child process's proc->tf */ int do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) { int ret = -E_NO_FREE_PROC; struct proc_struct *proc; if (nr_process >= MAX_PROCESS) { goto fork_out; } ret = -E_NO_MEM; // 1. call alloc_proc to allocate a proc_struct // 2. call setup_kstack to allocate a kernel stack for child process // 3. call copy_mm to dup OR share mm according clone_flag // 4. call copy_thread to setup tf & context in proc_struct // 5. insert proc_struct into hash_list && proc_list // 6. call wakeup_proc to make the new child process RUNNABLE // 7. set ret vaule using child proc's pid // 分配一個未初始化的執行緒控制塊 if ((proc = alloc_proc()) == NULL) { goto fork_out; } // 其父程式屬於current當前程式 proc->parent = current; assert(current->wait_state == 0); // 設定,分配新執行緒的核心棧 if (setup_kstack(proc) != 0) { // 分配失敗,回滾釋放之前所分配的記憶體 goto bad_fork_cleanup_proc; } // 由於是fork,因此fork的一瞬間父子執行緒的記憶體空間是一致的(clone_flags決定是否採用寫時複製) if (copy_mm(clone_flags, proc) != 0) { // 分配失敗,回滾釋放之前所分配的記憶體 goto bad_fork_cleanup_kstack; } // 複製proc執行緒時,設定proc的上下文資訊 copy_thread(proc, stack, tf); bool intr_flag; local_intr_save(intr_flag); { // 生成並設定新的pid proc->pid = get_pid(); // 加入全域性執行緒控制塊雜湊表 hash_proc(proc); set_links(proc); } local_intr_restore(intr_flag); // 喚醒proc,令其處於就緒態PROC_RUNNABLE wakeup_proc(proc); ret = proc->pid; fork_out: return ret; bad_fork_cleanup_kstack: put_kstack(proc); bad_fork_cleanup_proc: kfree(proc); goto fork_out; }
fork返回時父子執行緒的返回值為什麼不同?
do_fork函式在lab4中有著比較詳細的介紹,而在lab5中需要注意的一點是:由於fork函式會在複製建立新的子執行緒的那一瞬間將父執行緒對應的mm_struct進行復制,因此其指令指標在fork的一瞬間是一致的。對於父執行緒而言,執行執行完copy_thread函式之後,會繼續執行do_fork函式的後續邏輯。
如果父執行緒的fork流程執行過程中沒有出錯最終會執行到ret = proc->pid,do_fork系統呼叫再到終端使用者庫的fork函式便會返回子執行緒的pid,一個大於0的值。
子執行緒則由於在do_fork的copy_thread邏輯中,通過"proc->tf->tf_regs.reg_eax = 0;"設定了中斷棧幀的eax的值為0,且由於其陷入核心時的中斷棧幀中指令指標和父執行緒一樣(sys_fork函式中傳入的中斷棧幀),在iret返回到使用者態時,也會進行fork函式的呼叫返回。但返回時使用者態中斷棧幀復原時eax的值為0,導致exit.c中子執行緒fork函式的返回值為0。
父執行緒fork時返回的是子執行緒的pid,而子執行緒fork的返回值為0。可以通過判斷fork函式的返回值是否為0,編寫對應父子執行緒的不同處理邏輯。
copy_thread函式:
// copy_thread - setup the trapframe on the process's kernel stack top and // - setup the kernel entry point and stack of process static void copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) { // 令proc-tf 指向proc核心棧頂向下偏移一個struct trapframe大小的位置 proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1; // 將引數tf中的結構體資料複製填入上述proc->tf指向的位置(正好是上面struct trapframe指標-1騰出來的那部分空間) *(proc->tf) = *tf; proc->tf->tf_regs.reg_eax = 0; proc->tf->tf_esp = esp; proc->tf->tf_eflags |= FL_IF; // 令proc上下文中的eip指向forkret,切換恢復上下文後,新執行緒proc便會跳轉至forkret proc->context.eip = (uintptr_t)forkret; // 令proc上下文中的esp指向proc->tf,指向中斷返回時的中斷棧幀 proc->context.esp = (uintptr_t)(proc->tf); }
在父執行緒fork完成之後,fork的返回值不為0,因此會執行else塊的邏輯列印(I am parent, fork a child pid %d),以及列印(I am the parent, waiting now..)。子執行緒則會列印(I am the child)。
在exit子執行緒的邏輯中,最終會通過exit,執行sys_exit系統呼叫,用於退出當前執行緒。
在ucore lab5的參考示例程式碼中,idleproc執行緒的pid為0、initproc執行緒的pid為1;而執行user_main的父執行緒pid為2,其fork出的子執行緒pid為3。
sys_exit實現:
在sys_exit的實現中,由於執行緒需要退出,當前需要退出的執行緒會盡可能的將自己持有的包括佔用的記憶體空間的等各種資源釋放。但是由於要執行指令,需要退出的執行緒的核心棧是無法自己回收的;當前退出執行緒的執行緒控制塊由於排程的需要,也是無法自己回收掉的。執行緒退出最終的回收工作需要其父執行緒來完成。
因此,在do_exit函式的最後,當前執行緒會將自己的執行緒狀態設定為殭屍態,並且嘗試著喚醒可能在等待子執行緒退出而被阻塞的父執行緒。
如果當前退出的子執行緒還擁有著自己的子執行緒,那麼還需要將其託管給核心的第一個執行緒initproc,令initproc代替被退出執行緒,成為這些子執行緒的父執行緒,以完成後續的子執行緒回收工作(initproc作為後續建立的其它執行緒的祖先,是用來兜底的)。
static int sys_exit(uint32_t arg[]) { int error_code = (int)arg[0]; return do_exit(error_code); } // do_exit - called by sys_exit // 1. call exit_mmap & put_pgdir & mm_destroy to free the almost all memory space of process // 呼叫exit_mmap & put_pgdir & mm_destroy去釋放退出執行緒佔用的幾乎全部的記憶體空間(執行緒棧等需要父程式來回收) // 2. set process' state as PROC_ZOMBIE, then call wakeup_proc(parent) to ask parent reclaim itself. // 設定執行緒的狀態為殭屍態PROC_ZOMBIE,然後喚醒父程式去回收退出的程式 // 3. call scheduler to switch to other process // 呼叫排程器切換為其它執行緒 int do_exit(int error_code) { if (current == idleproc) { panic("idleproc exit.\n"); } if (current == initproc) { panic("initproc exit.\n"); } struct mm_struct *mm = current->mm; if (mm != NULL) { // 核心執行緒的mm是null,mm != null說明是使用者執行緒退出了 lcr3(boot_cr3); // 由於mm是當前程式內所有執行緒共享的,當最後一個執行緒退出時(mm->mm_count == 0),需要徹底釋放整個mm管理的記憶體空間 if (mm_count_dec(mm) == 0) { // 解除mm對應一級頁表、二級頁表的所有虛實對映關係 exit_mmap(mm); // 釋放一級頁表(頁目錄表)所佔用的記憶體空間 put_pgdir(mm); // 將mm中的vma連結串列清空(釋放所佔用記憶體),並回收mm所佔實體記憶體 mm_destroy(mm); } current->mm = NULL; } // 設定當前執行緒狀態為殭屍態,等待父執行緒回收 current->state = PROC_ZOMBIE; // 設定退出執行緒的原因(exit_code) current->exit_code = error_code; bool intr_flag; struct proc_struct *proc; local_intr_save(intr_flag); { proc = current->parent; // 如果父執行緒等待狀態為WT_CHILD if (proc->wait_state == WT_CHILD) { // 喚醒父執行緒,令其進入就緒態,準備回收該執行緒(呼叫了do_wait等待) wakeup_proc(proc); } // 遍歷當前退出執行緒的子執行緒(通過子執行緒連結串列) while (current->cptr != NULL) { // proc為子執行緒連結串列頭 proc = current->cptr; // 遍歷子執行緒連結串列的每一個子執行緒 current->cptr = proc->optr; proc->yptr = NULL; if ((proc->optr = initproc->cptr) != NULL) { initproc->cptr->yptr = proc; } // 將退出執行緒的子執行緒託管給initproc,令其父執行緒為initproc proc->parent = initproc; initproc->cptr = proc; if (proc->state == PROC_ZOMBIE) { // 如果當前遍歷的執行緒proc為殭屍態 if (initproc->wait_state == WT_CHILD) { // initproc等待狀態為WT_CHILD(呼叫了do_wait等待) // 喚醒initproc,令其準備回收殭屍態的子執行緒 wakeup_proc(initproc); } } } } local_intr_restore(intr_flag); // 當前執行緒退出後,進行其它就緒態執行緒的排程 schedule(); panic("do_exit will not return!! %d.\n", current->pid); }
在exit父執行緒的邏輯中,會呼叫waitpid同步阻塞等待對應pid的子執行緒退出,完成最終的回收工作。wait_pid最終會執行sys_wait系統呼叫,主要邏輯位於核心的sys_wait函式中。
sys_wait實現:
sys_wait的核心邏輯在do_wait函式中,根據傳入的引數pid決定是回收指定pid的子執行緒還是任意一個子執行緒。
1. 如果當前執行緒不存在任何可以回收的子執行緒,sys_wait會直接返回一個錯誤碼。
2. 如果呼叫sys_wait時當前執行緒能夠找到一個符合要求的,且可以立即回收的殭屍態子執行緒,那麼就會將其回收(一次sys_wait只會回收一個子執行緒)。
3. 如果呼叫sys_wait時當前執行緒存在可以回收的子執行緒,但是卻不是殭屍態。那麼當前執行緒將進入阻塞態,等待可以回收的子執行緒退出(前面介紹的sys_exit中就有子執行緒退出時對應的父執行緒喚醒機制)。
static int sys_wait(uint32_t arg[]) { int pid = (int)arg[0]; int *store = (int *)arg[1]; return do_wait(pid, store); } // do_wait - wait one OR any children with PROC_ZOMBIE state, and free memory space of kernel stack // - proc struct of this child. // 令當前執行緒等待一個或多個子執行緒進入殭屍態,並且回收其核心棧和執行緒控制塊 // NOTE: only after do_wait function, all resources of the child proces are free. // 注意:只有在do_wait函式執行完成之後,子執行緒的所有資源才被完全釋放 int do_wait(int pid, int *code_store) { struct mm_struct *mm = current->mm; if (code_store != NULL) { if (!user_mem_check(mm, (uintptr_t)code_store, sizeof(int), 1)) { return -E_INVAL; } } struct proc_struct *proc; bool intr_flag, haskid; repeat: haskid = 0; if (pid != 0) { // 引數指定了pid(pid不為0),代表回收pid對應的殭屍態執行緒 proc = find_proc(pid); // 對應的執行緒必須是當前執行緒的子執行緒 if (proc != NULL && proc->parent == current) { haskid = 1; if (proc->state == PROC_ZOMBIE) { // pid對應的執行緒確實是殭屍態,跳轉found進行回收 goto found; } } } else { // 引數未指定pid(pid為0),代表回收當前執行緒的任意一個殭屍態子執行緒 proc = current->cptr; for (; proc != NULL; proc = proc->optr) { // 遍歷當前執行緒的所有子執行緒進行查詢 haskid = 1; if (proc->state == PROC_ZOMBIE) { // 找到了一個殭屍態子執行緒,跳轉found進行回收 goto found; } } } if (haskid) { // 當前執行緒需要回收殭屍態子執行緒,但是沒有可以回收的殭屍態子執行緒(如果找到去執行found段會直接返回,不會執行到這裡) // 令當前執行緒進入休眠態,讓出CPU current->state = PROC_SLEEPING; // 令其等待狀態置為等待子程式退出 current->wait_state = WT_CHILD; // 進行一次執行緒排程(當有子執行緒退出進入殭屍態時,父執行緒會被喚醒) schedule(); if (current->flags & PF_EXITING) { // 如果當前執行緒被殺了(do_kill),將自己退出(被喚醒之後發現自己已經被判了死刑,自我了斷) do_exit(-E_KILLED); } // schedule排程完畢後當前執行緒被再次喚醒,跳轉到repeat迴圈起始位置,繼續嘗試回收一個殭屍態子執行緒 goto repeat; } return -E_BAD_PROC; found: if (proc == idleproc || proc == initproc) { // idleproc和initproc是不應該被回收的 panic("wait idleproc or initproc.\n"); } if (code_store != NULL) { // 將子執行緒退出的原因儲存在*code_store中返回 *code_store = proc->exit_code; } local_intr_save(intr_flag); { // 暫時關中斷,避免中斷導致併發問題 // 從執行緒控制塊hash表中移除被回收的子執行緒 unhash_proc(proc); // 從執行緒控制塊連結串列中移除被回收的子執行緒 remove_links(proc); } local_intr_restore(intr_flag); // 釋放被回收的子執行緒的核心棧 put_kstack(proc); // 釋放被回收的子執行緒的執行緒控制塊結構 kfree(proc); return 0; }
在exit.c中fork完畢的子執行緒通過sys_exit成功退出後,waitpid的父執行緒也隨之被喚醒進行對應子執行緒的回收工作。依照順序執行列印語句"waitpid %d ok."和"exit pass.\n"(由於子執行緒在exit之後就不會得到CPU了,因此不會列印這些)。
之後執行exit.c的父執行緒main函式也執行完畢並返回,執行流就到了lab4中提到的/kern/process/entry.S中。在entry.S中,最終也是呼叫了核心的do_exit函式令當前執行緒退出。
entry.S:
.text .globl kernel_thread_entry kernel_thread_entry: # void kernel_thread(void) pushl %edx # push arg call *%ebx # call fn pushl %eax # save the return value of fn(arg) call do_exit # call do_exit to terminate current thread
在執行exit.c的父執行緒也退出後,最初載入exit.c應用程式的initproc作為它的父執行緒被喚醒並通過do_wait進行了最終的回收工作(init_main函式)。最終列印出了"all user-mode processes have quit."。
至此,lab5中整個實驗細節的分析就結束了。
3. 總結
ucore在lab5中實現了執行在使用者態的程式/執行緒機制。使用者態的程式通常用於執行許可權受到限制的應用程式,避免應用程式的編寫者有意或無意的破壞作業系統核心。同時也實現了系統呼叫機制,為應用程式提供安全可靠的服務的同時也避免受到核心保護的各種資源被破壞。在實現了較為完善的程式/執行緒機制之後,也為後續的lab6、lab7中的執行緒CPU排程以及執行緒併發控制機制打下了基礎。
通過lab5的學習,使我學到了很多:
1. 瞭解了系統呼叫的實現原理。意識到通過中斷實現的系統呼叫效率是很低的,這也是為什麼在應用程式的開發中要儘量避免使用系統呼叫而陷入核心的主要原因(比如陷入核心阻塞執行緒的作業系統級的悲觀鎖以及使用者態輪詢重試的樂觀鎖)。
2. 瞭解了執行緒是如何去載入並執行一個應用程式的。對動態的程式/執行緒與靜態的程式之間的關係有了更深的理解。
3. 瞭解了諸如fork、exit、wait等系統呼叫的大致工作原理。
這篇部落格的完整程式碼註釋在我的github上:https://github.com/1399852153/ucore_os_lab (fork自官方倉庫)中的lab5_answer。
希望我的部落格能幫助到對作業系統、ucore os感興趣的人。存在許多不足之處,還請多多指教。