Part C:搶佔式多工和程序間通訊(IPC
lab4到目前為止,我們能夠啟動多個CPU,讓多個CPU同時處理多個程序。實現了中斷處理,並且實現了使用者級頁面故障機制以及寫時複製fork。
但是,我們的程序排程不是搶佔式的,現在每個程序只有在發生中斷的時候,才會被排程(呼叫shed_yeild),這樣就有可能會有程序一直佔用CPU不放。我們希望能夠讓各個程序平分CPU,在各個時間片上處理自己的任務。
於是實驗室 4 的最後一部分,我們的任務就是修改核心,實現搶佔式多程序排程,並實現程序間通訊機制(IPC)。
1. 時鐘中斷和搶佔
我們為什麼需要搶佔式的程序排程?如果有程序一直佔用CPU會是什麼情況,user/spin.c就是個例子。看看 user/spin.c
嘗試在命令列跑 make run-spin 會發現,父程序fork之後再也無法執行了。這是因為我們的核心目前還沒有從未完成的程序中搶回控制的能力。
那時鐘中斷去哪了呢?
手冊:
與 xv6 Unix 相比,我們在 JOS 中做了一個關鍵的簡化。在核心中,外部裝置中斷始終處於禁用狀態(與 xv6 一樣,在使用者空間中處於啟用狀態)。外部中斷由 %eflags 暫存器(參見 inc/mmu.h)的 FL_IF 標誌位控制。該位被設定時,外部中斷被啟用。 雖然可以透過多種方式修改該位,但為了簡化操作,我們將僅透過在進入和離開使用者模式時儲存和恢復 %eflags 暫存器的過程來處理它。您必須確保在使用者環境中執行時設定 FL_IF 標誌,以便在中斷髮生時將其傳遞給處理器,並由您的中斷程式碼進行處理。 否則,中斷將被遮蔽或忽略,直到中斷被重新啟用。我們在啟動載入程式的第一條指令中就遮蔽了中斷,到目前為止,我們還從未重新啟用過中斷。
我們在啟動載入程式的第一條指令中就遮蔽了中斷,到目前為止,我們還從未重新啟用過中斷。
接下來的任務,我們要完善外部中斷的管理,
1.1 中斷管理
外部中斷(即裝置中斷)稱為 IRQ。有 16 個可能的 IRQ,編號從 0 到 15。從 IRQ 編號到 IDT 條目之間的對映關係並不固定。picirq.c 中的 pic_init 將 IRQ 0-15 對映到 IDT 條目 IRQ_OFFSET 至 IRQ_OFFSET+15。
在 inc/trap.h 中,IRQ_OFFSET 被定義為十進位制 32。因此,IDT 項 32-47 對應 IRQ 0-15。例如,時鐘中斷是 IRQ 0,因此 IDT[IRQ_OFFSET+0](即 IDT[32])包含核心中時鐘中斷處理程式例程的地址。選擇這個 IRQ_OFFSET,是為了避免裝置中斷與處理器異常重疊,以免造成混淆。(事實上,在早期執行 MS-DOS 的 PC 中,IRQ_OFFSET 實際上為 0,這確實造成了處理硬體中斷和處理處理器異常之間的大量混淆!)。
與 xv6 Unix 相比,我們在 JOS 中做了一個關鍵的簡化。在核心中,外部裝置中斷始終處於禁用狀態(與 xv6 一樣,在使用者空間中處於啟用狀態)。外部中斷由 %eflags 暫存器(參見 inc/mmu.h)的 FL_IF 標誌位控制。該位被設定時,外部中斷被啟用。 雖然可以透過多種方式修改該位,但為了簡化操作,我們將僅透過在進入和離開使用者模式時儲存和恢復 %eflags 暫存器的過程來處理它。
您必須確保在使用者環境中執行時設定 FL_IF 標誌,以便在中斷髮生時將其傳遞給處理器,並由您的中斷程式碼進行處理。 否則,中斷將被遮蔽或忽略,直到中斷被重新啟用。我們在啟動載入程式的第一條指令中就遮蔽了中斷,到目前為止,我們還從未重新啟用過中斷。
Exercise 13
練習 13. 修改 kern/trapentry.S 和 kern/trap.c,初始化 IDT 中的相應條目,併為 IRQ 0 至 15 提供處理程式。然後修改 kern/env.c 中 env_alloc() 的程式碼,以確保使用者環境始終在啟用中斷的情況下執行。
同時取消對 sched_halt() 中 sti 指令的註釋,以便空閒的 CPU 能解除中斷遮蔽。
在呼叫硬體中斷處理程式時,處理器絕不會推送錯誤程式碼。此時,您可能需要重新閱讀《80386 參考手冊》第 9.2 節或《IA-32 英特爾體系結構軟體開發人員手冊》第 3 卷第 5.8 節。
完成此練習後,如果使用任何執行時間較長(如自旋)的測試程式執行核心,就會看到核心列印硬體中斷的陷阱幀。雖然中斷已在處理器中啟用,但 JOS 還沒有處理它們,所以你會看到它將每個中斷錯誤地歸屬於當前執行的使用者環境,並將其銷燬。最終,它應該會用完要銷燬的環境,並將其放入監視器中。
在 trapentry.S
設定外部中斷處理函式的入口點:
# 外部中斷的入口點
TRAPHANDLER_NOEC(irq_error_handler, IRQ_OFFSET+IRQ_ERROR)
TRAPHANDLER_NOEC(irq_ide_handler, IRQ_OFFSET+IRQ_IDE)
TRAPHANDLER_NOEC(irq_kbd_handler, IRQ_OFFSET+IRQ_KBD)
TRAPHANDLER_NOEC(irq_serial_handler, IRQ_OFFSET+IRQ_SERIAL)
TRAPHANDLER_NOEC(irq_spurious_handler, IRQ_OFFSET+IRQ_SPURIOUS)
TRAPHANDLER_NOEC(irq_timer_handler, IRQ_OFFSET+IRQ_TIMER)
在 trap.c:trap_init()
中定義外部裝置中斷的handler
//初始化外部中斷的中斷向量
void irq_error_handler();
void irq_kbd_handler();
void irq_ide_handler();
void irq_timer_handler();
void irq_spurious_handler();
void irq_serial_handler();
SETGATE(idt[IRQ_OFFSET + IRQ_ERROR], 0, GD_KT, irq_error_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_IDE], 0, GD_KT, irq_ide_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_KBD], 0, GD_KT, irq_kbd_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_SERIAL], 0, GD_KT, irq_serial_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_SPURIOUS], 0, GD_KT, irq_spurious_handler, 3);
SETGATE(idt[IRQ_OFFSET + IRQ_TIMER], 0, GD_KT, irq_timer_handler, 3);
修改 env.c:env_alloc
,在使用者環境執行前開啟外部裝置中斷,在註釋提示處新增語句:
// Enable interrupts while in user mode.
// LAB 4: Your code here.
// 開啟使用者環境的外部裝置中斷
e->env_tf.tf_eflags |= FL_IF;
修改 kern/sched.c:sched_halt,將提示處的sti語句註釋取消掉,sti 指令是開中斷,如手冊中所述,我們在 bootloader 中第一條指令 cli 就遮蔽了外部中斷,到目前為止還沒有重新開啟外部中斷。
sched_halt 這個讓CPU陷入自旋,等待被timer打斷。不開外部中斷是不可能做到被搶斷的。
完成了這些我們再次嘗試 make run-spin
1.2 處理時鐘中斷
在 user/spin
程式中,子環境首次執行後,只是在迴圈中 spin,核心再也無法控制。
我們需要對硬體進行程式設計,使其週期性地產生時鐘中斷,從而迫使控制權回到核心,在核心中我們可以將控制權切換到不同的使用者環境。
lapic_init
和 pic_init
中設定了時鐘和中斷控制器以產生中斷。現在我們需要編寫程式碼來處理這些中斷。
Exercise 14
練習 14. 修改核心的 `trap_dispatch()` 函式,使其在發生時鐘中斷時呼叫 `sched_yield()`,查詢並執行不同的環境。
現在您應該可以讓使用者/自旋測試正常工作了:父環境應該分叉子環境,向其執行幾次 `sys_yield()`,但每次都會在一個時間片後重新獲得 CPU 的控制權,最後殺死子環境並優雅地終止。
目前我們已經在中斷向量表中新增了接受timer訊號的中斷描述符,timer中斷髮生後,控制流會來到trap,然後發往 trap_dispatch,但是 trap_dispatch 中還沒有對應的hander接應,所以現在要在 trap_dispatch 中處理timer的中斷訊號。
// Handle clock interrupts. Don't forget to acknowledge the
// interrupt using lapic_eoi() before calling the scheduler!
// LAB 4: Your code here.
if(tf->tf_trapno == IRQ_OFFSET + IRQ_TIMER)
{
cprintf("Timer interrupt on irq 0\n");
lapic_eoi();
sched_yield();
}
lapic_eoi()
函式的作用是開啟IF標誌位,接收外部中斷,具體原理:
在接收到中斷請求並處理完成後,向本地高階可程式設計中斷控制器(Local Advanced Programmable Interrupt Controller, LAPIC)傳送一個 EOI 命令,通知 LAPIC 中斷處理已完成。這是為了釋放中斷控制器的資源,以便處理下一個中斷。
但是好奇怪,進入trapentry.S 時候,從來沒見過我們主動清零IF啊,為什麼CPU自動關閉接收外部中斷了呢?
翻了一下386手冊,其中提到
中斷門和陷阱門的區別在於對 IF(中斷啟用標誌)的影響。向量透過中斷門的中斷會重置 IF,從而防止其他中斷干擾當前中斷處理程式。隨後的 IRET 指令將 IF 恢復為堆疊上 EFLAGS 映像中的值。透過陷阱門的中斷不會改變 IF。
功能上的區別是這樣,那格式上呢?
我們在trap_init 設定的全是中斷門
這個時候我們再次嘗試 make run-spin ,會發現程式可以正常執行了:
2. 程序間通訊(IPC)
我們一直在關注作業系統的隔離功能,即它能讓人產生一種錯覺,以為每個程式都擁有一臺獨享的機器。作業系統的另一項重要功能是允許程式在需要時相互通訊。讓程式與其他程式進行互動是一項非常強大的功能。Unix 管道模型就是一個典型的例子。
程序間通訊有許多模型。時至今日,人們仍在爭論哪種模式最好。我們不討論這個問題。相反,我們將實現一個簡單的 IPC 機制,然後進行嘗試。
2.1 JOS 的程序間通訊
JOS已經實現了幾個額外的JOS核心系統呼叫,它們共同提供了一個簡單的程序間通訊機制。
使用者需要實現兩個系統呼叫, sys_ipc_recv
和 sys_ipc_try_send
。
然後我們將實現兩個庫包裝器 ipc_recv
和 ipc_send
。(話說,我們已經見識過了這種包裝器,比如 set_pgfault_handler
是 sys_env_set_pgfault_upcall
的包裝器,在其包裝下,為我們簡化了使用者異常棧的清理和 trap-time 狀態的恢復工作)
使用者環境可以使用JOS的IPC機制相互傳送的“訊息”由兩個部分組成:單個32位值
和可選的單個頁對映
。允許程序以訊息的形式傳遞頁對映,這提供了一種高效的方式來傳輸比單個32位整數所能容納的更多的資料,還允許程序輕鬆地建立共享記憶體。
2.2 傳送和接收訊息
為接收訊息,程序呼叫 sys_ipc_recv
。該系統呼叫會掛起當前程序,直到收到訊息後才再次執行。
當一個程序等待接收訊息時,任何其他程序都可以向它傳送訊息——不僅僅是特定的程序,也不僅僅是與接收程序有父/子關係的程序。
換句話說,我們在 Part A 實現的許可權檢查不適用於IPC,因為IPC系統呼叫經過了精心設計,是“安全的”:一個程序不會僅僅透過向它傳送訊息就導致另一個程序故障(除非目標程序也有bug)。
要嘗試傳送一個值,程序會呼叫 sys_ipc_try_send
,指定接受者的程序ID和要傳送的值。
如果目標程序上正在接收(它呼叫了 sys_ipc_recv
,但還沒有得到值),那麼呼叫者這邊的 send 就會傳送資訊,並返回 0。否則,send 返回 -E_IPC_NOT_RECV
表示目標程序當前不希望收到值。
使用者空間中的庫函式 ipc_recv
負責呼叫 sys_ipc_recv
,然後在當前環境的 struct Env
中查詢接收到的值的資訊。
類似地,庫函式 ipc_send
將負責重複呼叫 sys_ipc_try_send
,直到傳送成功。
2.3 傳送記憶體頁
當程序使用有效的 dstva
引數(低於 UTOP)呼叫 sys_ipc_recv
,即表明程序願意接收頁面對映。
如果傳送方傳送了一個頁面,那麼該頁面應對映到接收方地址空間中的 dstva
處。
如果接收方已經在 dstva 處對映了一個頁面,那麼之前的頁面將被取消對映。
當環境以有效的 srcva
(低於 UTOP)呼叫 sys_ipc_try_send
,這意味著傳送方希望將當前對映在 srcva
上的頁面傳送給接收方,並且許可權為 perm
。IPC 成功後,傳送方在其地址空間中保留了位於 srcva 的頁面的原始對映,但接收方也在其地址空間中獲得了位於接收方最初指定的 dstva 的同一物理頁面的對映。因此,該頁面成為傳送方和接收方共享的頁面。
如果傳送方或接收方都沒有表示應該傳輸頁面,那麼就不會傳輸頁面。在任何 IPC 之後,核心都會將接收方 Env 結構中的新欄位 env_ipc_perm
設定為所接收頁面的許可權,如果沒有接收頁面,則設定為 0。
Exercise 15 實現IPC
練習 15. 執行 `kern/syscall.c` 中的 `sys_ipc_recv` 和 `sys_ipc_try_send`。
在執行之前,請閱讀有關這兩個例程的註釋,因為它們必須協同工作。
在這些例程中呼叫 `envid2env` 時,應將 `checkperm` 標誌設定為 0,這意味著任何環境都可以向任何其他環境傳送 IPC 訊息,核心除了驗證目標 `envid` 是否有效外,不會進行任何特殊的許可權檢查。
然後在 `lib/ipc.c` 中實現 `ipc_recv` 和 `ipc_send` 函式。
使用 `user/pingpong` 和 `user/primes` 函式測試你的 IPC 機制。`user/primes` 會為每個質數生成一個新環境,直到 JOS 用完環境為止。閱讀 user/primes.c,瞭解所有分叉和 IPC 的幕後工作,你可能會覺得很有趣。
在 kern/syscall.c 中實現 sys_ipc_try_send
。
按照註釋進行一系列檢查後將 srcva 所在的 pg ,對映到 dstva 所在的地址。
// 嘗試將 “value ”傳送到目標環境 “envid”。
// 如果 srcva < UTOP,則同時傳送當前對映到 “srcva ”的頁面,以便接收者獲得同一頁面的重複對映。
// 如果目標沒有被阻塞,正在等待 IPC,則傳送失敗,返回值為 -E_IPC_NOT_RECV。
// 傳送失敗的原因還包括下面列出的其他原因。
// 否則,傳送成功,目標的 ipc 欄位更新如下:
// env_ipc_recving 設定為 0 以阻止今後的傳送;
// env_ipc_from 設定為傳送的 envid;
// env_ipc_value 設定為引數 “value”;
// 如果傳輸了頁面,env_ipc_perm 設定為 “perm”,否則為 0。
// 目標環境再次被標記為可執行,返回 0。
// 從暫停的 sys_ipc_recv 系統呼叫中返回 0。 (提示:如果
// sys_ipc_recv 函式真的會返回嗎?)
//
// 如果傳送方想傳送頁面,但接收方沒有要求傳送,則不會傳輸頁面對映,但也不會發生錯誤。
// 只有在沒有錯誤發生時,ipc 才會發生。
//
// 成功時返回 0,錯誤時返回 <0。
// 錯誤是
// -E_BAD_ENV 如果環境 envid 當前不存在。
// (無需檢查許可權。)
// -E_IPC_NOT_RECV 如果 envid 當前未在 sys_IPC_recv 中阻塞、
// 或其他環境先傳送。
// -E_INVAL 如果 srcva < UTOP 但 srcva 不是頁面對齊的。
// -E_INVAL 如果 srcva < UTOP 並且 perm 不合適
// (參見 sys_page_alloc)。
// -E_INVAL 如果 srcva < UTOP 但 srcva 沒有對映到呼叫者的 // 地址空間。
// 地址空間。
// -E_INVAL 如果(perm & PTE_W),但 srcva 在 // 當前環境的地址空間中是隻讀的。
// 當前環境的地址空間中是隻讀的。
// -E_NO_MEM 如果沒有足夠的記憶體將 srcva 對映到 envid 的 // 地址空間。
// 地址空間。
static int
sys_ipc_try_send(envid_t envid, uint32_t value, void *srcva, unsigned perm)
{
// LAB 4: Your code here.
// panic("sys_ipc_try_send not implemented");
int r;
struct Env * env;
if((r = envid2env(envid, &env, 0))< 0){
return -E_BAD_ENV;
}
if(env->env_ipc_recving == 0){
return -E_IPC_NOT_RECV;
}
if (srcva < (void*)UTOP) {
// 獲取物理頁
pte_t *pte;
struct PageInfo *pg = page_lookup(curenv->env_pgdir, srcva, &pte);
// 檢查 srcva 是否 page-aligned.
if(srcva != ROUNDDOWN(srcva, PGSIZE)){
return -E_INVAL;
}
// 檢查 perm 是否合規
if((*pte & perm & PTE_SYSCALL)!= (perm & PTE_SYSCALL)){
return -E_INVAL;
}
// 如果來源環境沒有對映pg頁
if(!pg){
return -E_INVAL;
}
// 如果perm要求寫許可權,但是srcva沒有寫許可權
if ((perm & PTE_W) && !(*pte & PTE_W)){
return -E_INVAL;
}
// 如果目標環境以有效dstva引數呼叫 sys_ipc_recv,說明目標環境願意接受頁面對映
if (env->env_ipc_dstva < (void*)UTOP) {
// 將當前環境的 pg 頁 對映到目標環境的dstva上
r = page_insert(env->env_pgdir, pg, env->env_ipc_dstva, perm);
if(r<0){
return -E_NO_MEM;
}
env->env_ipc_perm = perm;
}
}
// 標記目標環境為 未準備接收
env->env_ipc_recving = 0;
// 將目標環境的 IPC傳送方 設定為當前環境
env->env_ipc_from = curenv->env_id;
// 傳送 message 的 value
env->env_ipc_value = value;
// 設定目標環境為可執行
env->env_status = ENV_RUNNABLE;
// 設定目標環境的eax
env->env_tf.tf_regs.reg_eax = 0;
return 0;
}
sys_ipc_recv 則是設定env的與IPC相關的成員,關鍵是env_ipc_recving=1,標記為準備接受資料。
然後呼叫 sched_yield 交出cpu,等待sender傳送資料
// 阻塞,直到值準備就緒。
// 使用 struct Env 的 env_ipc_recving 和 env_ipc_dstva 欄位記錄要接收的資訊,
// 標記自己不可執行,然後放棄 CPU。
//
// 如果'dstva'<UTOP,則表示願意接收一頁資料。
// 'dstva'是虛擬地址,傳送的頁面應對映到該地址。
//
// 該函式僅在出錯時返回,但系統呼叫最終會在成功時返回 0。
// 出錯時返回 <0。 錯誤包括
// -E_INVAL 如果 dstva < UTOP 但 dstva 不是頁面對齊的。
static int
sys_ipc_recv(void *dstva)
{
// LAB 4: Your code here.
// panic("sys_ipc_recv not implemented");
if ((uintptr_t)dstva < UTOP && PGOFF(dstva) != 0){
return -E_INVAL;
}
// 標識正在等待接收訊息
curenv->env_ipc_recving = 1;
// 記錄想要對映頁的虛擬地址
curenv->env_ipc_dstva = dstva;
// 清空記錄的傳送者資訊
curenv->env_ipc_value = 0;
curenv->env_ipc_from = 0;
curenv->env_ipc_perm = 0;
// 設定 Env 狀態,在env_ipc_recving被改變之前,不再被喚醒
curenv->env_status = ENV_NOT_RUNNABLE;
// 交出控制權,等待資料輸入
sched_yield();
return 0;
}
然後不要忘了在 syscall 的 switch 中加上相關呼叫的分支:
case SYS_ipc_try_send:
ret = sys_ipc_try_send((envid_t) a1, (uint32_t) a2, (void *) a3, (unsigned int) a4);
return ret;
case SYS_ipc_recv:
ret = sys_ipc_recv((void*)(a1));
return ret;
接著去使用者的lib/ipc.c 中實現相應庫函式。
// 透過 IPC 接收並返回值。
// 如果 “pg ”為非空,則傳送方傳送的任何頁面都將對映到該地址。
// 如果 “from_env_store ”為非空,則將 IPC 傳送方的 envid 儲存在 *from_env_store 中。
// 如果 “perm_store ”為非空,則在 *perm_store 中儲存 IPC 傳送方的頁面許可權(如果頁面已成功傳輸到 “pg”,則該值為非零)。
// 如果系統呼叫失敗,則在 *fromenv 和 *perm(如果它們非空)中儲存 0,並返回錯誤資訊。
// 否則,返回傳送者傳送的值
//
// 提示
// 使用 “thisenv ”發現值和傳送者。
// 如果'pg'為空,則向 sys_ipc_recv 傳遞一個它可以理解為 “無頁面 ”的值。
// 表示 “無頁面”。 (零不是正確的值,因為這是
// 一個完全有效的頁面對映位置)。
int32_t
ipc_recv(envid_t *from_env_store, void *pg, int *perm_store)
{
// LAB 4: Your code here.
// panic("ipc_recv not implemented");
// 檢查pg是否為空
if(pg == NULL)
{
pg=(void *) -1;
}
//接收 message
int r = sys_ipc_recv(pg);
if(r<0)
{
if(from_env_store) *from_env_store = 0;
if(perm_store) *perm_store = 0;
return r;
}
// 儲存傳送者的envid
if(from_env_store) *from_env_store = thisenv->env_ipc_from;
// 儲存傳送來的頁面的許可權
if(perm_store) *perm_store = thisenv->env_ipc_perm;
// 返回message的value
return thisenv->env_ipc_value;
}
// 將'val'(如果'pg'非空,則將'pg'與'perm'一起)傳送到'toenv'。
// 該函式會不斷嘗試,直到成功為止。
// 如果出現除 -E_IPC_NOT_RECV 以外的任何錯誤,它都會 panic()。
//
// 提示
// 使用 sys_yield()對 CPU 更友好。
// 如果 “pg ”為空,則向 sys_ipc_try_send 傳遞一個它能理解為 “無頁面 ”的值。
// 表示 “無頁面”。 (零值並不合適)。
void
ipc_send(envid_t to_env, uint32_t val, void *pg, int perm)
{
// LAB 4: Your code here.
// panic("ipc_send not implemented");
// 如果pg為NULL, 要提供給sys_ipc_try_send一個能表達“no page”的值,0是有效的地址
if(pg==NULL)
{
pg = (void *)-1;
}
int r;
//不停嘗試傳送訊息直到成功
while(1)
{
r = sys_ipc_try_send(to_env, val, pg, perm);
if (r == 0) { //傳送成功
return;
} else if (r == -E_IPC_NOT_RECV) { //接收環境未準備接收
sys_yield();
}else{
panic("ipc_send() fault:%e\n", r);
}
}
}
lab4 完成