實驗文檔-lab3
一、思考題匯總
思考1:
為什么我們在構造空閑進程鏈表時必須使用特定的插入的順序?(順序或者逆序)
答:插入空閑進程鏈表時采用的是逆序插入。
由於我們的操作系統在插入空閑進程鏈表時采用的方式為LIST_INSERT_HEAD
,所以在插入時只有通過逆序插入,才能使我們在從env_free_list
中取出進程塊時能夠從前往后地進行取出。
思考2:
思考env.c/mkenvid
函數和envid2env
函數:
-
請你談談對
mkenvid
函數中生成id
的運算的理解,為什么這么做? -
為什么
envid2env
中需要判斷e->env_id != envid
的情況?如果沒有這步判斷會發生什么情況?
答:(1)生成id時,先用一個靜態變量表示第幾次調用這個函數,再將其左移11位,再加上這個env塊在env數組中的偏移,得到這個進程塊所對應的env_id
。
(2)在該判斷語句的之前有一句e = &envs[ENVX(envid)];
來通過envid來求出env。可以發現,在這個語句中判斷對應關系只用了低位位數,也就是進程塊的偏移。但實際上,envid還有高位部分,高位不同代表這個進程塊被調用過不止一次,仍然不是一個進程。但由於一個進程塊同時只能對標一個正在執行的進程,所以若高位不同,代表所查詢的envid所對應的進程一定不存在,因此返回-E_BAD_ENV。若沒有這步判斷,則會在查詢一個不存在的進程id時卻能夠得到對應的進程,導致程序錯誤。
思考3:
結合include/mmu.h 中的地址空間布局,思考env_setup_vm 函數:
-
我們在初始化新進程的地址空間時為什么不把整個地址空間的pgdir 都清零,而是復制內核的boot_pgdir作為一部分模板?(提示:mips 虛擬空間布局)
-
UTOP 和ULIM 的含義分別是什么,在UTOP 到ULIM 的區域與其他用戶區相比有什么最大的區別?
-
在env_setup_vm 函數的最后,我們為什么要讓pgdir[PDX(UVPT)]=env_cr3?(提示: 結合系統自映射機制)
-
談談自己對進程中物理地址和虛擬地址的理解
答:(1)MIPS操作系統的虛擬地址采用的是2G/2G的結構,其中高2G是內核區,低2G是用戶區。在內核區中。每一個進程的這一段區域虛擬地址到物理地址的映射都是完全一樣的,這就意味着所以的進程都可以直接通過在boot_pgdir
中復制來得到這一部分的內容。
(2)UTOP = 0x7f400000
,其含義為用戶所能操縱的地址空間的最大值;ULIM = 0x80000000
,其含義為操作系統分配給用戶地址空間的最大值。這一段空間被定義為一個只讀片段,屬於“內核態”,主要功能在於讓用戶進程去查看其他進程的信息,用戶在此處進行讀取不會陷入異常。
(3)UVPT
的含義為User Virtual Page Table,因此這一段需要映射到他的進程在pgdir
中的頁目錄地址。所以我們在將這一段空間的虛擬地址轉化為物理地址時可以很快找到對應的頁目錄。
(4)進程只能操作虛擬地址,而實現虛擬地址和物理地址之間的映射由操作系統完成。
思考4:
思考user_data 這個參數的作用。沒有這個參數可不可以?為什么?(如果你能說明哪些應用場景中可能會應用這種設計就更好了。可以舉一個實際的庫中的例子)
答:在函數load_icode_mapper
中,被傳入的user_data
被用於這樣一個語句中:
struct Env *env = (struct Env *)user_data;
因此這個所謂的user_data
實際上在函數中的真正含義就是這個被操作的進程指針。那么我們來一步步追溯這個變量最開始是以什么形式被傳入的。
回到load_elf
函數中,我們可以看到user_data
從函數本身被傳入到調用load_icode_mapper
中沒有改變,那再回到調用load_elf
的load_icode
中,我們發現在調用load_elf
時的語句為:
r = load_elf(binary, size, &entry_point, e, load_icode_mapper);
而其中的e則為傳入load_icode
中的struct Env *e
,因此我們的推測得到證實。
沒有進程指針,我們的加載鏡像的步驟顯然不能正常完成。
思考5:
結合load_icode_mapper 的參數以及二進制鏡像的大小,考慮該函數可能會面臨哪幾種復制的情況?你是否都考慮到了? (提示:1、頁面大小是多少;2、回顧lab1中的ELF文件解析,什么時候需要自動填充.bss段)
答:在load_icode_mapper
中,需要復制的頁面特征可以由以下的一張圖表示
因此我們可以分析出復制的情況有這么幾種:
.text & .data
:
- 第一段,需要切除前半部分的
offset
的一段。 - 中間的普通段。
- 最后一段,即前半部分屬於
.test & .data
,后半部分屬於.bss
。 - 需要考慮的特殊情況有:
- 第一段的前半段已經裝載過內容,因此不能在這一段進行
alloc
與insert
操作,從而保留前半段內容。 offset
= 0,此時從最開始的所有端可以當做正常頁處理。.test & .data
與.bss
被某一個頁分割恰好切開,不存在共同占用一個page
的情況。.test & .data
這一段的長度極小,即第一個page
就為最后一個page
,因此需要同時對兩側的頁面分割進行判定與相應操作。
- 第一段的前半段已經裝載過內容,因此不能在這一段進行
.bss
:
- 第一段,需要同前半段的
.test & .data
段協同考慮。 - 中間的普通段。
- 最后一段,即前半部分屬於
.bss
,后半段在需要復制的內容之外。 - 需要考慮的特殊情況有:
- 第一段的前半段已經在
.text & .data
段被裝載過相關內容,為保證那一段內容不被破壞,在處理.bss
的這一段時,不能使用alloc
以及insert
來進行新的頁面插入。注:在操作正確的情況下,只要兩段不是恰好的頁面分割,那么一定會出現這種情況!! .test & .data
與.bss
被某一個頁分割恰好切開,不存在共同占用一個page
的情況。.bss
這一段的長度極小,即第一個page
就為最后一個page
。- 最后一頁被恰好在
page
的交界分開。
- 第一段的前半段已經在
最終代碼如下:
/*** exercise 3.6 ***/
static int load_icode_mapper(u_long va, u_int32_t sgsize,
u_char *bin, u_int32_t bin_size, void *user_data)
{
struct Env *env = (struct Env *)user_data;
struct Page *p = NULL;
u_long i;
int r;
u_long offset = va - ROUNDDOWN(va, BY2PG);
Pte *temp;
/*Step 1: load all content of bin into memory. */
i = 0;
if (offset != 0) {
p = page_lookup(env -> env_pgdir, va, &temp);
if (p == 0) {
xxxxx;
}
if (BY2PG - offset <= bin_size) {
xxxxx;
} else {
xxxxx;
}
}
for (; i < bin_size; i += BY2PG) {
/* Hint: You should alloc a new page. */
xxxxx;
if (BY2PG + i - offset <= bin_size) {
xxxxx;
} else {
xxxxx;
}
}
/*Step 2: alloc pages to reach `sgsize` when `bin_size` < `sgsize`.
* hint: variable `i` has the value of `bin_size` now! */
offset = i - ROUNDDOWN(i, BY2PG);
if (offset != 0) {
p = page_lookup(env -> env_pgdir, va + i, &temp);
if (offset + sgsize - i < BY2PG) {
xxxxx;
} else {
xxxxx;
}
}
while (i < sgsize) {
xxxxx;
if (sgsize - i < BY2PG) {
xxxxx;
} else {
xxxxx;
}
i += BY2PG;
}
return 0;
}
思考6:
思考上面這一段話,並根據自己在lab2 中的理解,回答:
-
我們這里出現的” 指令位置” 的概念,你認為該概念是針對虛擬空間,還是物理內存所定義的呢?
-
你覺得
entry_point
其值對於每個進程是否一樣?該如何理解這種統一或不同
答:(1)此概念針對的是虛擬地址空間
(2)該值是從load_elf
中的*entry_point = ehdr->e_entry;
語句中進行賦值,所以該值對於每個進程是一樣的。這種值來源於他們都是從ELF文件中的同一個部分進行取值的,elf文件結構的統一決定了該值的統一。
思考7:
思考一下,要保存的進程上下文中的env_tf.pc
的值應該設置為多少?為什么要這樣設置?
答:應當設置為env_tf.cp0_epc
。因為在《計算機組成》中介紹過EPC寄存器就是用來存放異常中斷發生時進程正在執行的指令的地址的。因此在進入異常處理時,進程上下文中的env_tf.pc
應該設置為epc
。
思考8:
思考TIMESTACK 的含義,並找出相關語句與證明來回答以下關於TIMESTACK 的問題:
-
請給出一個你認為合適的TIMESTACK 的定義
-
請為你的定義在實驗中找出合適的代碼段作為證據(請對代碼段進行分析)
-
思考TIMESTACK 和第18 行的KERNEL_SP 的含義有何不同
答:(1)TIMESTACK
是內存中的一塊棧空間,用於存儲進程的狀態。
(2)TIMESTACK
在env.c中再兩處地方被調用:
env_destroy
:
bcopy((void *)KERNEL_SP - sizeof(struct Trapframe),
(void *)TIMESTACK - sizeof(struct Trapframe),
sizeof(struct Trapframe));
env_run
:
old = (struct Trapframe *)(TIMESTACK - sizeof(struct Trapframe));
bcopy((void *)old, (void *)&(curenv->env_tf), sizeof(struct Trapframe));
我們不難發現,在我們的操作系統代碼中對這個變量的調用均在bcopy
中進行,並且利用的都是在TIMESTACK
以下的一段長度為struct Trapframe
的空間。在env_destroy
中,將存於KERNEL_SP
的進程狀態復制到TIMESTACK
處,而在env_run
中,則是從這一段空間中取出進程狀態並轉移給當前進程。
在mmu.h中,我們得到TIMESTACK
的具體值為0x82000000
,對這段地址的調用進行查找,我們在匯編代碼stackframe.S中又找到了一處調用該段地址的地方,即:
.macro get_sp
mfc0 k1, CP0_CAUSE
andi k1, 0x107C
xori k1, 0x1000
bnez k1, 1f
nop
li sp, 0x82000000
j 2f
nop
1:
bltz sp, 2f
nop
lw sp, KERNEL_SP
nop
2: nop
.endm
這段代碼的功能為獲取棧指針的值,若檢測到是中斷異常則將棧指針置於TIMESTACK
處,這樣在發生中斷時我們就能將當前進程的狀態存入TIMESTACK
處,從而進行保存。
(3)從上面那段匯編代碼中我們可以看出,將棧指針設在TIMESTACK
還是KERNEL_SP
與CP0_CAUSE
有關,經查閱,在發生中斷時將進程的狀態保存到TIMESTACK
中,在發生系統調用時,將進程的狀態保存到KERNEL_SP
中。
思考9:
閱讀 kclock_asm.S 文件並說出每行匯編代碼的作用
答:kclock_asm.S內容如下:
.macro setup_c0_status set clr
.set push
mfc0 t0, CP0_STATUS
or t0, \set|\clr
xor t0, \clr
mtc0 t0, CP0_STATUS
.set pop
.endm
.text
LEAF(set_timer)
li t0, 0x01
sb t0, 0xb5000100
sw sp, KERNEL_SP
setup_c0_status STATUS_CU0|0x1001 0
jr ra
nop
END(set_timer)
從.text
段開始:
首先先將0x01
寫入地址0xb5000100
中,其中基地址0xb5000000
為gxemul
用於映射實時鍾的地址,偏移量0x100
代表時鍾的頻率。將棧指針設置為KERNEL_SP
中能夠正確產生時鍾中斷,二、再調用宏函數setup_c0_status
來設置CP0_STATUS
的值,最后通過jr ra
來返回。
思考10:
閱讀相關代碼,思考操作系統是怎么根據時鍾周期切換進程的。
答:在我們的操作系統中,設置了一個進程就緒隊列,並且給每一個進程添加了一個時間片,這個時間片起到計時的作用,一旦時間片的時間走完,則代表該進程需要執行時鍾中斷操作,則再將這個進程移動到就緒隊列的尾端,並復原其時間片,再讓就緒隊列最首端的進程執行相應的時間片段,按照這種規律實現循環往復,從而做到根據時鍾周期切換進程。
二、實驗難點圖示
難點1:初始化新進程地址空間,即env_setup_vm
函數的填寫
在這個函數中,內存空間被分成了如下的兩個部分,即UTOP
以上和UTOP
以下,在UTOP
以下的部分,我們需要將頁目錄的這一塊區域清零,而在UTOP
以上的部分,用戶不能操作,屬於內核態,因此我們可以將boot_pgdir
的內容直接復制到進程的頁目錄中。
在UTOP
之上有一塊被稱為UVPT
的地址,這一塊區域作為用戶進程頁目錄,需要用自映射機制進行單獨處理。
地址空間的結構圖如下:
難點2:加載二進制鏡像
這一部分的內容較多且難度較大,由三個函數共同完成,即:
- env.c中的
load_icode
- kernal_elfloader.c中的
load_elf
- env.c中的
load_icode_mapper
其中load_icode
為實現這個功能的代碼,它的功能在於:
- 分配內存
- 將二進制代碼裝入分配好的內存中
其中,第二步,即裝入內存的操作交給了函數load_elf
來完成,而load_elf
的工作又被分為:
- 解析
ELF
結構 - 將
ELF
的內容復制到內存中
其中,第二步,即將內容復制到內存中的操作又交給了load_icode_mapper
函數去進行,所以三段代碼的協作方式如下圖:
其中,函數load_icode_mapper
函數的難點及圖示已在思考5中給出
在函數load_elf
中,我們不難發現,我們在load_icode_mapper
中用到的許多參量在這里都有了很明確的實例對應,具體映射如下:
因此我們只要將給定的ELF文件進行正確解析,就能利用load_icode_mapper
對其進行內容復制
函數load_icode
重點在於設置PC
值,即從load_elf
中返回的entry_point
難點3:中斷與進程調度
中斷機制的設置主要包括三步,都較為容易:
- 在
start.S
中補充異常分法代碼 - 修改鏈接腳本,使得異常中斷發生時能夠正確的跳轉到異常處理代碼段
- 調用
set_timer()
開啟時鍾中斷
進程的調度主要在sched_yield
中進行。
在我們的env
結構體中,有這么一個變量:
u_int env_pri;
這個變量的字面含義為進程的“優先級”,但實際上在完成實驗的過程中,我們可以發現這個所謂“優先級”與進程的優先執行並沒有關系,它的真正含義是一個進程所能連續運行時間片的大小,即產生多少次時鍾中斷之后必須要切換到下一個進程。在創建一個進程時,這個量的賦值在函數env_create_priority
中進行:
void
env_create_priority(u_char *binary, int size, int priority)
{
struct Env *e;
/*Step 1: Use env_alloc to alloc a new env. */
if (env_alloc(&e, 0) != 0) {
return;
}
/*Step 2: assign priority to the new env. */
e->env_pri = priority;
/*Step 3: Use load_icode() to load the named elf binary,
and insert it into env_sched_list using LIST_INSERT_HEAD. */
load_icode(e, binary, size);
LIST_INSERT_HEAD(&env_sched_list[0], e, env_sched_link);
}
我們在init.c
中創建進程時,所用到的代碼為:
ENV_CREATE_PRIORITY(user_A, 2);
ENV_CREATE_PRIORITY(user_B, 1);
我們可以看到這兩個進程的“優先級”分別被設置為了2和1,這也就解釋了為什么最后的輸出結果中兩個量的輸出比例為2:1
經過查找,發現代碼中的user_A
與user_B
在code_A.c
與code_B.c
中被定義,且兩個文件中都只有一個unsigned char
型數組,兩個數組中,只有第六行的第三個值不同,user_A
為0x1
,user_B
為0x2
,於是猜測這個值決定了這兩個進程的輸出,經過修改這個值並重新運行,此猜測得到驗證,與最后輸出中呈現的1為2的兩倍恰好對應。
進程的調度也是基於這個時間片來進行,主要的步驟為如下幾步:
- 設置兩個隊列,其中一個為目前的進程調度隊列
q0
,另一個為一個空隊列q1
。 - 首先判斷當前隊列指針指向的隊首進程的
env_status
- 如果為
ENV_FREE
,則要將該進程從隊列中移除 - 如果為
ENV_NOT_RUNNABLE
,則直接將其插入另一個隊列的尾部 - 如果為
ENV_RUNNABLE
,則判斷這個進程的時間片是否用完,若用完則復原其時間片並將其插入到另一個隊列尾部 - 當一個隊列為空時,將指針轉移到另一個隊列隊首
- 如果為
三、體會與感想
本次實驗中首次讓我們脫離“程序”的概念,而是用“進程”來思考問題。
就代碼填寫難度來說,個人認為這一lab的難度是要低於lab2的,大多數的部分只需要調用相關函數即可完成,但本次實驗仍然會在許多的地方運用到lab2的內容,因此可以看出關於內存管理的內容貫穿着整個操作系統課程,一定要掌握充分。
本單元仍然需要多閱讀代碼,且此次需要閱讀的代碼量更大,分布更廣,我認為對代碼的理解應該為:
- 需要填寫的代碼:一定要理解每一行的意思,清楚這一個函數的功能以及其中每一個語句的含義
- 在填寫中反復調用的函數和宏函數:同樣需要理解代碼的含義與功能,以便在實驗中能夠靈活運用
- 功能極其特化,只在一個或有限幾個地方會用到,且十分難以理解的C代碼或匯編代碼:最起碼要理解這個函數的功能是什么,在精力允許情況下可以嘗試去掌握其中的細節
還有一個十分重要的內容便是程序Bug
的修復,操作系統的Bug
有時候會十分隱蔽,可能在很多請況下都發現不了,並且能通過課程組提供的自測和公測點,但是一旦在后期突然出現就會十分致命,並且很難找到(比如我在lab2時曾因為某個尋址語句沒有進行強制類型轉化導致lab2-2的課上測試一直0分且完全沒有意識到是課下的內容出了問題)。同樣的,在這次實驗中許多身邊的同學都在最后一個進程調度中無法得到正常的結果,最終發現是lab2-1的宏函數填寫出了問題。因此我們在之后填寫代碼中一定要十分細心,多去關注可能出bug的語句,必要時與同學和老師,助教交流。
在Debug過程中,由於在虛擬式上沒有ide的調試工具,可以采用在可疑點的上下文增加一些有標記性意義的輸出:
- 判斷是否在這一步被卡住:輸出
chk1
,chk2
等具有索引的尋蹤字段 - 判斷這一步是否發生內容錯誤:輸出
env_id
等與程序執行中的重要變量有關的信息(在lab2-1-exam的part2給了我們一種全方位的判斷信息是否正確的思路,可以在之后的實驗中采用)
同時,在填寫一些復雜度很高的函數,如load_icode_mapper
時,不能拿到代碼,看到hint就往上一股腦的填,而應該去嘗試復盤整個過程,並考慮到各種情況,無重復、無遺漏地對每個情況進行處理
四、指導書反饋
個人認為在load_icode_mapper
中的函數內容可以略加改善
在其中,部分已提供的代碼是一個全局循環,其中i
以BY2PG
為一個單位遞增
/*Step 1: load all content of bin into memory. */
for (i = 0; i < bin_size; i += BY2PG) {
/* Hint: You should alloc a new page. */
}
但實際上,經過分析我們得出i
要想變化到正確位置,每次遞增的量並不一定是BY2PG
(詳見思考5),且並不一定每一個段都需要alloc
操作,因此我認為這段代碼具有一定的誤導性,且如果要完全保留的話可能會在后面寫出一些意義不明的代碼(如強制調整i
的位置),可在之后的課程中改善。
五、殘留難點
本次實驗的主要殘留難點在於進程的行為,即這個進程在執行時在做什么。
個人目前的猜測是進程的行為和之前發現的輸出數據一樣,存儲在code_a.c
的大數組中,用機器碼表示,但目前仍然沒有具體找出能夠代表行為的代碼,可能在之后的lab中能夠解析進程的行為。