實驗3正篇——用戶進程

        進入實驗3的實驗過程了,在實驗2中我們實現了操作系統基本模塊的內存管理部分,然後需要實現的部分就是進程管理的部分了。而對於進程管理實驗其實分爲兩個實驗——實驗3與實驗4,實現集中在創建進程,而實驗4則集中於調度進程。從硬件接口的觀點來看,本實驗是對處理器中斷機制進行封裝,然後以此爲基礎,實現用戶進程的創建與操作系統的交互。

    本實驗的主要任務是實現一個保護模式下的用戶模式環境(進程)首先,完成數據結構維護進程,創建一個單用戶環境,加載一個程序,然後運行之最後需要實現系統調用,同時也需要處理它產生的各種常見異常。這裏會涉及到很多操作系統的基本概念的實現,所以需要我們去認真理解,另外因爲其他操作系統也實現了類似的機制,但是本實驗的實現只使用了最基本而重要的部分,保證能夠創建進程並且運行,對我們起到了“骨架”的作用,爲我們理解其他系統的實現或者豐富目前系統有很好的啓示。

   一)實驗準備

    1.源碼:

    更新最新軟件:git pull

    獲取軟件:git checkout -b lab3 origin/lab3

    2.更新的文件介紹:

    本實驗的主要內容是圍繞三個部分展開的第一個就是進程的定義與操作;第二個就是中斷處理——系統調用,各種異常;第三個爲用戶進程的處理流程。對於前者我們將以面向對象的方式來描述之,對於第二個我們將結合實驗3前篇的介紹來具體解剖一下實現細節。

   二)用戶進程

    操作系統爲了能夠管理與控制用戶進程,所以需要對用戶進程進行抽象,我們將以用戶進程的屬性最小集(包含進程的最基本的屬性,能夠完全實現用戶進程創建與管理的操作)來描述之。

    屬性(inc/env.h)

<strong>struct Env {
	struct Trapframe env_tf;	// Saved registers——保存進程的現場(當前運行的寄存器值)
	struct Env *env_link;		// Next free Env——指向未使用的進程,管理方式和內存管理方式一致
	envid_t env_id;			// Unique environment identifier——進程id,唯一識別進程的編號
	envid_t env_parent_id;		// env_id of this env's parent——父進程id,用於表示進程間的關係,方便對進程進行調試與信息傳遞
	enum EnvType env_type;		// Indicates special system environments——進程的特定類型
	unsigned env_status;		// Status of the environment——進程的運行狀態
	uint32_t env_runs;		// Number of times environment has run——進程運行的次數

	// Address space
	pde_t *env_pgdir;		// Kernel virtual address of page dir——進程的頁目錄地址,用於指定進程的內存分配
};</strong>

   進程的現場:用於保存進程當前運行的寄存器值,當進程進入內核運行之後保存寄存器狀態,用於之後恢復進程使用。詳細定義如下:

struct PushRegs {//主要保存所有寄存器值
	/* registers as pushed by pusha 如下的寄存器的順序與實現是pusha的順序*/
	uint32_t reg_edi;
	uint32_t reg_esi;
	uint32_t reg_ebp;
	uint32_t reg_oesp;		/* Useless */
	uint32_t reg_ebx;
	uint32_t reg_edx;
	uint32_t reg_ecx;
	uint32_t reg_eax;
} __attribute__((packed));

struct Trapframe {
	struct PushRegs tf_regs;
	uint16_t tf_es;//軟件保存es與ds
	uint16_t tf_padding1;
	uint16_t tf_ds;
	uint16_t tf_padding2;
	uint32_t tf_trapno;//軟件保存中斷號用於軟件根據它來處理分別處理
	/* below here defined by x86 hardware ,當發生中斷時,x86的硬件自動將如下的寄存器保存到堆棧中*/
	uint32_t tf_err;
	uintptr_t tf_eip;
	uint16_t tf_cs;
	uint16_t tf_padding3;
	uint32_t tf_eflags;
	/* below here only when crossing rings, such as from user to kernel */
	uintptr_t tf_esp;
	uint16_t tf_ss;
	uint16_t tf_padding4;
} __attribute__((packed));

    根據如上的結構體定義,我們可以發現寄存器的順序是固定的,必需按照硬件與處理器的要求來確定——主要是中斷執行流程與指令保存所有寄存器的指令(PUSHA).

    進程的特定類型:指明進程的一種屬性用於賦予進程特定的訪問權限——在試驗5中會用到,默認爲ENV_TYPE_USER

    進程的狀態:進程的切換其實是一個有限狀態機模型,而目前我們的使用的是經典5種狀態切換模型:

    其他屬性都是簡單易懂的,見註釋即可,不需要另外介紹。

    方法(kern/env.h聲明,kern/env.c實現):

    voidenv_init(void);//初始化用戶進程的基本數據結構。初始化所有Env結構,然後將它們放到env_free_list; 然後調用 env_init_percpu,配置段地址,區分內核態與用戶態。

 

    輸入:e爲分配的進程地址

parent_id爲輸入的父進程id

    輸出:分配結果,成功或者失敗

    intenv_alloc(struct Env **e, envid_t parent_id);//根據父進程id給用戶進程分配內存,進程處於ENV_FREE狀態。

 

    輸入:e被釋放的進程地址

    voidenv_free(struct Env *e);//釋放給的用戶進程的內存

 

    輸入:binary傳入的用戶進程執行程序地址

  size爲傳入的進程大小

  type爲進程的特定類型

    voidenv_create(uint8_t *binary, size_t size, enum EnvType type);//根據給定的用戶執行程序地址創建用戶進程。

 

    輸入:e被銷燬的用戶進程地址

    voidenv_destroy(struct Env *e);//銷燬給定的用戶進程

 

    輸入:envid爲給定的用戶進程id

  env_store得到的用戶進程

  checkperm是否檢查權限

    輸出:是否獲得成功

    intenvid2env(envid_t envid, struct Env **env_store, bool checkperm);//通過給定的進程id,得到對應的用戶進程數據結構

    輸入:e爲運行的進程地址,之後進程處於ENV_RUNNING狀態

    voidenv_run(struct Env *e) __attribute__((noreturn));//運行進程。

 

    如上爲public的接口,如下爲private的接口:

    env_init_percpu();//執行每個處理器的初始化。

    輸入:e爲分配虛擬內存空間的進程

    輸出:是否分配成功

    int env_setup_vm(struct Env *e);//分配1個頁目錄給新進程,創建進程的內核地址空間。

 

    輸入:e爲用戶進程

      va爲分配空間給定的開始虛擬地址

len爲分配空間的長度

    void region_alloc(struct Env *e, void *va, size_t len);//爲進程e給定的地址區間[va,va+len)分配物理地址。

 

    輸入:e爲用戶進程

binary爲給定的用戶進程程序地址

size爲給定的用戶進程代碼長度

     void load_icode(struct Env *e, uint8_t *binary, size_t size);//根據給定的用戶進程程序地址,通過解析elf bin文件(像開機loader一樣),然後加載它到用戶空間。

 

     輸入:tf爲當前要進行用戶進程的運行狀態。

     void env_pop_tf(struct Trapframe *tf);//根據給定的用戶進程的運行狀態,跳入該進程運行之。

    初始化:

    struct Env *envs = NULL;// All environments——指向所有進程數組的地址

    當系統初始化時,會爲進程管理分配一段空間(NENV個進程)作爲系統支持的所有進程數組,由envs指向。在mem_init()中實現內存分配,並將其映射到虛擬地址UENVS

    struct Env *curenv = NULL;// The current env——指向當前運行的進程

    static struct Env *env_free_list;// Free environment list——指向沒有分配的進程。

    實現了類似內存管理的方式,分配一個數組保存所有的進程信息,然後用進程數據結構中的空間指針鏈接到空間鏈表中,用於記錄進程空間的使用情況。它的實現在env_init()中實現。

   三)中斷與異常處理

    中斷與異常處理是與處理器構架息息相關的,我們在上一篇博客已經詳細介紹了。本節就是對於它的實現,首先先看一下我們實現的邏輯流程圖:

    其中包含了兩個流程——初始化x86中斷的流程與中斷處理流程。如上圖所示,中斷初始化流程,在trap_init()中設置中斷向量表(IDT),將每個中斷指向內核執行的代碼——如圖所示,用宏(SETGATE)設置了中斷向量0(除0中斷),系統調用(T_SYSCALL--48)的中斷;在設置中斷向量的同時,需要初始化一個任務管理段(TSS)用於發生堆棧切換時,找到保存的堆棧,目前設置爲內核堆棧,因爲堆棧切換隻會發生在從用戶進程切換到內核執行。對於中斷向量設定的執行函數的實現,也是用宏的方式(在trapentry.S中)來實現TRAPHANDLER/TRAPHANDLER_NOEC(沒有ErrorCode),主要執行功能爲將中斷號壓入堆棧(如果沒有ErrorCode,需要壓入0來填充),然後跳轉到_alltraps執行。當中斷髮生時,硬件會做處理(可以參考如上博客,將堆棧與處理器狀態壓入堆棧),然後調用我們設定的中斷處理函數(TXX),從而執行_alltraps_alltraps主要爲調用trap函數執行創造條件(struct Trapframe的結構體壓入堆棧),然後調用trap。最後trap再調用中斷分配函數trap_dispatch,實現根據不同的中斷號,去掉用對應的中斷處理。

    如上所述,硬件會根據不同的中斷號去調用不同的中斷處理,但是爲什麼我們實現的內核又先將所有的中斷處理匯聚到trap函數之後,又重新通過trap_dispatch去分配呢?我個人認爲原因有兩個:其一爲爲中斷提供統一的調用接口,其二爲中斷只能調用沒有參數的函數,這對於C語言實現的函數來說,獲取中斷時的狀態很是複雜,這樣處理可以簡化中斷處理;但是這樣明顯損失了程序效率。

    對於中斷或者異常發生在用戶進程,會發生堆棧切換(使用內核的堆棧),對於trap函數的參數就是全的struct Trapframe結構,而當發生在內核狀態,不會發生堆棧切換,所以對於trap函數來說,只有struct Trapframe結構的部分(沒有最低的SS/ESP),但是對於我們C語言訪問struct Trapframe結構的數據來說,也是正確的。

    如上圖所示,我們主要實現了頁錯誤,斷點異常,系統調用的3箇中斷處理。

    對於頁錯誤異常(T_PGFLT-14)——我們考慮的是頁缺失(訪問了沒有定義的內存地址),我們的處理是爲之分配物理空間,然後跳轉到中斷之前的地方繼續執行。根據x86開發手冊,訪問異常的地址在寄存器CR2中保存着。

    斷點異常(T_BRKPT--3)的處理是用來允許調試器插入斷點到程序代碼中,進行程序調試,我們只是簡單的返回到程序中斷處執行。

    系統中斷(T_SYSCALL--48)實現的功能爲用戶進程提供操作系統的服務接口,我們使用硬件中斷不能使用的48號中斷,將傳遞給操作系統的參數保存到寄存器中,具體對應關係(詳細的見lib/syscall.c)如下,當執行完內核服務時,我們會返回到用戶進程繼續執行。

    參數               寄存器

    Num(系統調用號)      EAX

    A1(參數1)            EDX

    A2                    ECX

    A3                    EBP

    A4                    EDI

    A5                    ESI

   返回值以EAX返回。

   詳細代碼如下:

static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4
#ifndef FAST_SYSCALL
, uint32_t a5
#endif
)
{
	int32_t ret;

	// Generic system call: pass system call number in AX,
	// up to five parameters in DX, CX, BX, DI, SI.
	// Interrupt kernel with T_SYSCALL.
	//
	// The "volatile" tells the assembler not to optimize
	// this instruction away just because we don't use the
	// return value.
	//
	// The last clause tells the assembler that this can
	// potentially change the condition codes and arbitrary
	// memory locations.

#ifndef FAST_SYSCALL
	asm volatile("int %1\n"
		: "=a" (ret)
		: "i" (T_SYSCALL),
#else
//	set_msr4fast_syscall(IA32_SYSENTER_CS,0x0,GD_KD);
	asm volatile(
			"pushl %%ebp\n"
			"pushl %%esi\n"
			"movl %%esp,%%ebp\n"
			"leal 1f,%%esi\n"
			"sysenter\n"
			"1:popl %%esi\n"
		     	"  popl %%ebp\n"
		:"=a"(ret):
#endif
		  "a" (num),
		  "d" (a1),
		  "c" (a2),
		  "b" (a3),
		  "D" (a4)
#ifndef FAST_SYSCALL
		  ,"S" (a5)
#endif
		: "cc", "memory");

	
	if(check && ret > 0)
		panic("syscall %d returned %d (> 0)", num, ret);

	return ret;
}

     系統調用流程如下:

    如上圖所示,我們以向終端輸出字符串爲例——sys_cputs.從左到右爲用戶進程到內核的交互流程。從服務使用層來看,用戶進程使用系統調用的直接結果爲使用內核的服務;從接口對等層來看,用戶進程與內核的接口都是一一對應的,即系統調用的接口實現在內核中有且僅有唯一一個與之對應;從接口傳遞層來看,也有一一對應的關係,而且是唯一的調用syscall,但是對於用戶進程來說是產生中斷(INT 0x30),而對於內核來說則是調用接口對等層,其中聯繫二者的紐帶爲系統的中斷機制。從上到下來看,對於用戶進程來說系統調用就是一系列的接口調用,最終通過中斷機制間接調用了內核的服務函數。

    對於如上流程,我們需要回想BIOS爲軟件提供接口的方式也是中斷,但是它的功能是不同的中斷號提供,而我們的系統調用卻是使用相同的中斷號。然而,這並不妨礙我們對其的統一理解。

    對於如上流程,我們需要理解,用戶進程與內核進程的功能分工而協作關係,內核控制了系統的所有資源(比如,終端,磁盤,內存等),對於用戶進程來說,需要使用資源就需要對內核提出功能請求,而請求的方式就是系統調用。從用戶進程的角度來說,它通過系統調用使用系統資源,就像是運行在操作系統爲之創建的虛擬機上。對於內核來說,它只是一套準備好的接口,時刻等待着用戶進程的請求。

    對於如上流程,我們還需要反思,純粹而簡單的一次調用,卻輾轉經歷瞭如此多的步驟,可以想象一次系統調用的代價是很大的,對於我們應用軟件的編寫與操作系統的實現來說都有指導意義,比如:編寫應用軟件,需要儘可能少的調用系統調用;操作系統的優化來說,儘可能的使系統調用的路徑縮短,一種解決方案是使用快速調用指令(sysenter/sysexit)

   四)用戶進程啓動代碼

    當有了如上知識儲備,就應該創建與運行我們用戶進程了。對於目前的程序狀態,我們沒有支持對文件系統的支持,所以我們只能將我們的用戶進程的代碼放入到內核中,然後加載運行之。我們以運行hello爲例,代碼實現爲user/hello.c。對於如上的鏡像設置我們需要細緻的分析一下user/hello.c的編譯流程:

    如上圖所示,我們需要編譯user/hello.c時,需要依賴於libjos.aentry.o,然後以entry.o,hello.o,libjos.auser.ld的腳本上鍊接成hello,最後在被鏈接器將編譯出來的內核kernel,用 -b binary的參數鏈接成最終的kernel文件。如上過程需要注意兩個方面,第一是entry.o必需在第一個被鏈接,第二個鏈接生成的kernel如何訪問hello的程序——這個通過readelf -s kernel發現,在kernel中的地址區間[_binary_hello_start,_binary_hello_end]即爲hello的運行程序。本來還應該分析hello鏡像到內存映像的圖,但是在之前的博客已經分析過了,所以這裏只是給出指引。

    然後我們需要了解加載hello的流程:

    如上所示,爲了創建用戶進程,我們首先需要爲所有進程分配內存空間(爲所有的進程分配一個數組空間),然後使用用戶進程的接口來初始化進程空間的管理,最後根據在內核保存的user_hello進程的elf數據創建用於進程,接着調用env_run來運行之。在調用env_create進程時,我們首先需要在進程的內存空間分配一個進程給當前進程(env_alloc),當分配之後再調用load_icode去解析helloelf數據映射到用戶進程的地址空間中。當創建好了用戶進程好了之後,就需要運行之——通過修改它的運行狀態,然後切換到用戶進程的地址空間(加載頁目錄到CR3),最後再調用env_pop_tf通過中斷返回指令iret跳轉到用戶進程執行之。

    當從內核切換到用戶進程之後,沒有直接運行我們定義的umain函數(類似c語言的程序進入點),這是爲什麼呢?爲了解決這個謎團,我們還是先看看用戶進程運行的流程吧,當然也是本節的主題——用戶進程啓動代碼:

    如上圖所示,在運行用戶編輯的C代碼之前的程序即爲用戶進程啓動代碼,根據user.ld的配置與執行流程,發現用戶進程代碼主要實現的功能爲定義一些用戶進程的配置信息(比如:指向當前進程的指針),同時也創建了類似命令行參數的基本結構傳入給用戶的main函數兩個參數:命令行參數個數與命令行參數列表。這樣做的好處,是爲用戶進程創建必要的運行環境,保證程序運行得更加方便。當然也是可以直接運行c代碼的,但是有了一段彙編的橋接代碼似乎成了操作系統的調用c函數進入點的習慣,而有了這樣的過度,讓c語言程序運行得更加有序。

    一葉說:麻雀雖小五臟俱全,我們實現的這種精簡結構的進程管理與運行簡單的用戶進程,已經將進程創建,運行,基本管理的結構演示的一覽無餘,而我們現代的操作系統大都有類似的結構,當我們理解了它的實現之後,以此作爲“脊骨”去分析與理解現代操作系統,將會起到事半功倍的效果。當然,也可以以此爲基礎來進行擴展,實現更加豐富的進程管理功能。這些基本結構的演示也讓我們知道了用戶進程與操作系統交互的方法,爲我們實現用戶進程的代碼也提供了指導意義,保證我們的程序能夠更加有效而且準確,同時也爲我們封裝基本的函數庫——比如:C語言基本庫,系統調用接口等提供了基礎,也加深了對用戶進程的加載與運行的理解,在編譯c語言程序與使用編譯器時也提供了很好的參考。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章