北航操作系統實驗2019:Lab4-1流程梳理
前言
操作系統的實驗課實在令人頭禿。我們需要在兩周時間內學習相關知識、讀懂指導書、讀懂代碼、補全代碼、處理玄學bug和祖傳bug,以及回答令人窒息的思考題。可以說,這門課的要求非常高,就個人感覺,遠比計算機組成實驗課要難受。
一方面,想要達到細致理解操作系統每個實現細節,非常困難,需要大量時間和經歷的投入;但另一方面,如果我們能夠理解了操作系統實現的每個細節,我們的水平也會有大幅度的提升。在這里,我記錄下本次實驗課下我的學習經歷,如果有不對的地方,希望能夠指出,以求共同進步。
一、預備知識
在前面三個Lab的實驗中,我們成功的搭建起了操作系統的內核,建立了內存管理機制和進程調度機制。一般來說,進程是給用戶使用的,而用戶無法直接對系統內核進行存取。另一方面,進程與進程之間的虛擬地址互相獨立,這使得兩個進程之間的互相通信變得困難。但是,用戶會在有些情況下需要使用只有內核才能進行的操作。為了解決這個問題,操作系統設計了系統調用。
指導書上已有的知識,我在此不再贅述。在進行實驗之前,我們需要稍微補習一點知識,主要是關於匯編函數方面的東西。這些知識,指導書或者其他地方都有,只不過比較零碎。我稍微聚集了一下這些知識,如果想要了解的更詳細,可以深入了解。
1. 匯編函數構造宏(include/asm/asm.h)
為了方便的像C語言一樣構造函數,我們的操作系統事先為我們提供了函數的宏,我們可以直接使用。這個宏的代碼並非由本校人員開發,應當是較為通用的定義方式。文件中為我們提供了兩種函數的宏,即葉函數(LEAF)和嵌套函數(NESTED)。
我們把函數體中沒有函數調用語句的函數稱為葉函數,自然如果有函數調用語句的函數稱為非葉函數。在MIPS 的調用規范中,進入函數體時會通過對棧指針做減法的方式為自身的局部變量、返回地址、調用函數的參數分配存儲空間(葉函數沒有后兩者),在函數調用結束之后會對棧指針做加法來釋放這部分空間,我們把這部分空間稱為棧幀(Stack Frame)。
——OS指導書
下面是宏的具體實現定義。可以看到,函數定義無非是聲明一個全局符號,給定一個標簽用於跳轉和返回。
下面是文件中部分代碼的引用。有些代碼后面我沒有寫注釋,是因為我自己也弄不太清楚,不敢亂講,怕引起誤會。如果有同學明白,希望可以給我講講。
#define LEAF(symbol) \
.globl symbol; \聲明"symbol"為全局變量
.align 2; \下一個數據的地址空間按字對齊
.type symbol,@function; \
.ent symbol,0; \告訴匯編器"symbol"函數的起始點,用於調試
symbol: .frame sp,0,ra 提供一個名為"symbol"的標簽,將跳轉到此處
#define NESTED(symbol, framesize, rpc) \
.globl symbol; \
.align 2; \
.type symbol,@function; \
.ent symbol,0; \
symbol: .frame sp, framesize, rpc 確定棧幀大小以及結束時的返回地址
#define END(function) \
.end function; \指出函數結尾,用於調試
.size function,.-function 在符號表中列出函數名和函數指令字節數
2.C函數和匯編函數的參數、返回值傳遞
有時候,我們會不可避免的在C語言中調用匯編函數,也會在匯編語言中調用C函數。根據MIPS軟件標准(ABI)的定義,函數的參數傳遞按照如下原則:
- 如果函數參數個數≤4,則將參數依次存入a0-a3寄存器中,並在棧幀底部保留16字節的空間(即sp的值減去16),但並不一定使用這些空間。
- 如果函數參數個數>4,則前4個參數依次存入a0-a3寄存器中,從第5個參數開始,依次在前4個參數預留空間之外的空間內存儲,即沒有寄存器去保存這些值。
- 舉例,如果一個C函數有6個參數,在匯編語言中需要調用的時候,應當將前4個參數存在a0-a3寄存器中,第5個參數存在16(sp)的位置,第6個參數存在20(sp)的位置。區間0-15的空間保留但不使用。
而關於函數的返回值,MIPS ABI規定,返回值存在$v0寄存器中。某些特殊的情況下也會用到$v1寄存器,但不常見。想了解更多關於返回值的知識,請查閱書籍See MIPS Run Linux。
3.棧幀方法宏(include/stackframe.h)
我們在進行用戶態和內核態之間的切換,或者進程之間的切換時,需要保存現場。所謂現場,就是include/trap.h中所定義的trap結構體,其中包含的信息有:
- 32個寄存器的值
- CP0部分寄存器的值
- HI、LO兩個乘除法寄存器的值
- 程序的指令計數器PC
但是這個文件中只有結構體的定義,沒有將數據存入結構體的操作。將寄存器中的值存入內存,顯然要用匯編語言去完成。stackframe.h中定義了一些匯編函數的宏,方便我們對現場進行存取操作。下面摘錄了其中的宏,並作出相應的解釋。
//TF_SIZE是Trapframe寄存器的字節大小
.macro STI //Set Interrupt,打開全局中斷使能(允許中斷)
.macro CLI //Close Interrupt,關閉全局中斷使能(屏蔽中斷)
.macro SAVE_ALL //保存所有現場,將數據以Trapframe結構體形式存在sp為開頭的空間中
.macro RESTORE_SOME //恢復部分現場,此處的“部分”僅不包括sp的值
.macro RESTORE_ALL //恢復所有現場,包括棧頂的位置
.macro RESTORE_ALL_AND_RET //恢復現場並從內核態中返回
.macro get_sp //獲取棧頂位置,此函數會判斷當前的狀態是異常還是中斷,
//從而決定棧頂是TIMESTACK還是KERNEL_SP。
//系統調用是編號為8的異常,進程切換是時鍾中斷信號。
二、系統調用機制的實現
按照指導書上的思路,我們來梳理一下系統調用的流程:
- 調用一個需要內核配合才能完成的函數,該函數會調用syscall_xxx函數(user/syscall_lib.c)
- syscall_xxx函數會調用我們寫的匯編函數msyscall(user/syscall_wrap.S),該函數使用特權指令syscall
- 此時CPU觸發異常,陷入內核態,異常向量分發器檢測到是系統調用(異常編號為8),進入handle_sys函數(lib/syscall.S),進行處理
- handle_sys函數會進一步讀取系統調用號,進行進一步分發,分發進C函數(lib/syscall_all.c),在C語言中進行處理。
- 在內核態中處理完畢,返回用戶態,並將返回值(位於$v0寄存器)傳遞回去,一層層回到調用處。
需要填寫的文件:
-
user/syscall_wrap.S
只需要念一句咒語:syscall就好。當然,考慮到MIPS的習慣,可以move v0, a0,這樣后面取出系統調用號也可以在v0中取。
-
lib/syscall.S
TODO項有三:
- 取出EPC,計算一個合理的值,再存回去。合理的值是什么呢?如果syscall不在延遲槽里面,合理的值自然只能是順位的下一條指令EPC+4啦。而我們寫的函數里面,顯然沒有把syscall放在延遲槽,所以就是EPC+4。
- 將系統調用號存入寄存器a0。系統調用號是我們函數的第一個參數。根據MIPS ABI,第一個參數放在a0寄存器中。然而,a0寄存器的值從存入到使用沒有發生變化。所以,只要你前面沒有瞎寫,這一步完全可以不用操作。如果你前面寫了move v0, a0,也可以從TF_REG2中讀取,但顯得沒有必要。
- 在當前棧指針分配6個參數的存儲空間,並將6個參數安置到期望的位置。前四個參數存在a0-a3寄存器,后兩個參數(預設代碼已經幫你取出,存在t3、t4寄存器)存在16(sp)和20(sp)的位置就行。
注:第二、三、四個參數的值沒有改變過,因而也不需要修改。系統調用號寄存器a0雖然用於計算相對位置,但是此后的調用函數根本沒有用到,只是起到一個占位的作用(指導書所言),因而也可以不用修改a0的值,將錯就錯,不會影響。
-
lib/syscall_all.c
此處需要實現四個函數,按照文件中的函數順序來介紹。
/* Overview: * 這個函數允許當前進程釋放CPU。 * Post-Condition: * 取消運行當前進程。這個函數永遠也不會返回。(?) */ void sys_yield(void) { // your code here /* 直接使用我們之前寫的sched_yield函數即可。 * 不過,需要在KERNEL_SP和TIMESTACK上做一點准備工作, * 因為當前進程處於內核態,保存的現場在KERNEL_SP - sizeof(struct Trapframe), * 但是env_run中所使用的進程切換機制中, * bcopy從TIMESTACK - sizeof(struct Trapframe)的位置進行復制 * 因而我們要把現場復制到TIMESTACK棧區。 */ }
/* Overview: * 分配一頁內存,並映射到進程envid空間中的虛擬地址va,加上權限perm。 * 可能的副作用是,如果va已經和一個頁面p構建了映射,那么頁面p就會被解除映射。 * Pre-Condition: * perm的PTE_V(有效)位必須為1,而PTE_COW(寫時復制)位必須為0。其他位隨意。 * Post-Condition: * 返回值0是成功映射,返回值小於0即是出錯。 * 注意va必須小於UTOP,以及env可能會調整自己和子進程的地址空間。 */ int sys_mem_alloc(int sysno, u_int envid, u_int va, u_int perm) { // Your code here. struct Env *env; struct Page *ppage; int ret; ret = 0; /* 首先將上方注釋里的所有需要判斷的情況全部判斷完。 * 包括va的范圍,perm的部分位,envid是否合法。 * 進行頁面分配(page_alloc)和頁面插入(page_insert)的時候也會報錯,注意返回值。 * 各種負數返回值的意義在include/mmu.h中,此后不再贅述調用函數的返回值。 * / }
/* Overview: * 將源進程地址空間中的相應內存映射到目標進程的相應地址空間的相應虛擬內存中去, * 並且附加保護位perm。perm的限制和sys_mem_alloc中一樣。 * (也許我們應該加上只讀頁面不可映射為可寫頁面的判斷?) * Post-Condition: * 返回值0代表成功,小於0代表報錯。 * Note: * 不能對UTOP以上的內存進行操作。 */ int sys_mem_map(int sysno, u_int srcid, u_int srcva, u_int dstid, u_int dstva, u_int perm) { int ret; u_int round_srcva, round_dstva; struct Env *srcenv; struct Env *dstenv; struct Page *ppage; Pte *ppte; ppage = NULL; ret = 0; round_srcva = ROUNDDOWN(srcva, BY2PG); round_dstva = ROUNDDOWN(dstva, BY2PG); //此處將兩個虛擬地址按頁進行對齊,映射時應當使用以上兩個地址。 // your code here /* 首先判斷srcva,dstva,perm,srcid,dstid是否合法, * 然后在源進程的地址空間中找到所需的頁面,插入到目標進程的地址空間中。 * 主要是使用page_lookup和page_insert兩個函數不能出錯。 */ return ret; }
/* Overview: * 解除envid進程空間中虛擬地址va所綁定的頁面。 * (如果va本身就沒綁定頁面,函數不作任何操作,算作成功) * Post-Condition: * 返回值0代表成功,小於0代表出錯。 * 不能解除UTOP地址以上空間的映射。 */ int sys_mem_unmap(int sysno, u_int envid, u_int va) { // Your code here. int ret = 0; struct Env *env; /* 首先判斷va,envid是否合法,然后page_remove即可,沒有技術含量。 * 注意page_remove本身具有判斷地址是否綁定的功能,所以無需多此一舉。 */ return ret; }
三、進程間通信機制(IPC)
IPC 是微內核最重要的機制之一,目的是使得兩個進程之間可以通訊,需要通過系統調用來實現。通訊最直觀的一種理解就是交換數據。
兩個進程之間之所以沒法相互交換數據,是因為各個進程的地址空間相互獨立。我們在之前寫的函數,正是為了實現地址空間之間的溝通。而溝通兩個進程,自然需要一個權限凌駕兩個進程之上的存在來進行操作,即內核態。
在Lab3使用的進程控制塊(struct Env)中,有部分值用於本次實驗的進程間通信,代碼如下:
// Lab 4 IPC
u_int env_ipc_value; // 傳遞的數據值
u_int env_ipc_from; // 發送者的進程id
u_int env_ipc_recving; // 進程是否阻塞,從而能夠接收。0為不能接收,1為可以接收。
u_int env_ipc_dstva; // 接收物理頁面的虛擬地址
u_int env_ipc_perm; // 接收頁面的保護位
IPC的操作,本質是在內核態中對這些部分進行賦值。我們需要填的兩個函數位於lib/syscall_all.c中。
/* Overview:
* 這個函數使得調用進程可以接收其他進程發送的信息。更准確地說,
* 這個函數可以標記當前進程,使得其他進程可以向其發送信息。
* Pre-Condition:
* dstva必須合法(NULL也是合法的)。
* Post-Condition:
* 這個系統調用函數會將當前進程狀態置為NOT RUNNABLE,並釋放CPU。
*/
void sys_ipc_recv(int sysno, u_int dstva)
{
/* 首先判斷dstva是否合法。然后,置recving位為1,給dstva賦值,
* 設置進程狀態為阻塞,並且重新調用sys_yield。
* 由於我們的算法采用了兩個鏈表,所以當進程為阻塞時,應當從就緒鏈表中移出。
* 不過如果你采用了這種寫法,就必須得另想辦法終止當前進程。
* 因為哪怕進程不在sched_list里面,只要時間片沒用光,依然可能繼續運行。
* 這樣程序就會出錯。可以選擇不刪除不插入,yield函數遇到NOT RUNNABLE就跳過。
*/
}
/* Overview:
* Try to send 'value' to the target env 'envid'.
* 將value傳給目標進程envid。
* 如果目標進程尚未處於可接收狀態,返回值應當為-E_IPC_NOT_RECV。
* 其他情況下,發送成功后,目標進程的IPC部分數據應當按照如下規則更新:
* env_ipc_recving設置為0,防止多余的接收。
* env_ipc_from設置為發送進程的id。
* env_ipc_value設置為函數參數value。
* 目標進程需要標記為RUNNABLE,以便重新運行。
* Post-Condition:
* 返回值0代表成功,小於0代表出錯。
*
* Hint: 你唯一需要調用的函數只有envid2env()。
*/
int sys_ipc_can_send(int sysno, u_int envid, u_int value, u_int srcva, u_int perm)
{
int r;
struct Env *e;
struct Page *p;
Pte *ppte;
/* 判斷envid是否合法,目標進程是否處於可接收狀態。
* 這個函數貌似是殘缺的,srcva和perm沒有使用,也沒有映射物理頁面。
* 只是單純的傳遞一個值value而已。很迷。
* 同樣需要注意,設置為就緒后是否加入就緒狀態鏈表。取決於個人程序。
*/
return 0;
}
四、思考題分享參考
此處只是分享我的看法,不保證答案的正確性和完備性。
Thinking 4.1 思考並回答下面的問題:
-
內核在保存現場的時候是如何避免破壞通用寄存器的?
內核保存現場的方法,是將所有通用寄存器、CP0寄存器、當前PC值保存到棧里。但是,通用寄存器的值卻非一成不變、完全保存。k0、k1兩個寄存器由中斷/自陷程序保留,這兩個寄存器的值得不到保證。內核使用k0、k1兩個寄存器保存用戶棧、取出內核棧,再進行保存,從而維護了大多數通用寄存器的值。
-
系統陷入內核調用后可以直接從當時的a0-a3參數寄存器中得到用戶調用msyscall留下的信息嗎?
可以。內核保存現場的過程中沒有破壞a0-a3參數寄存器的值,只改變過k0, k1, v0的值。
-
我們是怎么做到讓sys開頭的函數“認為”我們提供了和用戶調用msyscall時同樣的參數的?
參數的傳遞依賴於a0-a3參數寄存器和棧。只要我們保證a0-a3參數寄存器不變,棧能夠以原本的樣子復制到內核棧空間中,就能夠讓sys開頭的函數認為參數相同。
-
內核處理系統調用的過程對Trapframe做了哪些更改?這種修改對應的用戶態的變化是?
處理過程中,內核改變了Trapframe中寄存器v0的值,用於在用戶態中傳遞系統調用函數的返回值。此外,內核改變了EPC的值,使得程序返回用戶態后能夠從正確的位置繼續執行。
系統調用號 對於系統調用syscall_cgetc,它傳入msyscall函數的系統調用號的數字值應該是?
打開文件user/syscall_lib.h,可以看到系統調用號的數值是常量SYS_cgetc。
打開文件include/unistd.h,可以讀到__SYSCALL_BASE = 9527,SYS_cgetc = 9527+14 = 9541。
所以系統調用號的數字值應當是9541。