Caffeinated 6.828:實驗 4:搶佔式多任務處理
簡介
在本實驗中,你將在多個同時活動的用戶模式環境之間實現搶佔式多任務處理。
在 Part A 中,你將在 JOS 中添加對多處理器的支持,以實現循環調度。並且添加基本的環境管理方面的系統調用(創建和銷毀環境的系統調用、以及分配/映射內存)。
在 Part B 中,你將要實現一個類 Unix 的 fork()
,它將允許一個用戶模式中的環境去創建一個它自已的副本。
最後,在 Part C 中,你將在 JOS 中添加對進程間通訊(IPC)的支持,以允許不同用戶模式環境之間進行顯式通訊和同步。你也將要去添加對硬體時鐘中斷和優先權的支持。
預備知識
使用 git 去提交你的實驗 3 的源代碼,並獲取課程倉庫的最新版本,然後創建一個名為 lab4
的本地分支,它跟蹤我們的名為 origin/lab4
的遠程 lab4
分支:
athena% cd ~/6.828/lab
athena% add git
athena% git pull
Already up-to-date.
athena% git checkout -b lab4 origin/lab4
Branch lab4 set up to track remote branch refs/remotes/origin/lab4.
Switched to a new branch "lab4"
athena% git merge lab3
Merge made by recursive.
...
athena%
實驗 4 包含了一些新的源文件,在開始之前你應該去瀏覽一遍:
kern/cpu.h Kernel-private definitions for multiprocessor support
kern/mpconfig.c Code to read the multiprocessor configuration
kern/lapic.c Kernel code driving the local APIC unit in each processor
kern/mpentry.S Assembly-language entry code for non-boot CPUs
kern/spinlock.h Kernel-private definitions for spin locks, including the big kernel lock
kern/spinlock.c Kernel code implementing spin locks
kern/sched.c Code skeleton of the scheduler that you are about to implement
實驗要求
本實驗分為三部分:Part A、Part B 和 Part C。我們計劃為每個部分分配一周的時間。
和以前一樣,你需要完成實驗中出現的、所有常規練習和至少一個挑戰問題。(不是每個部分做一個挑戰問題,是整個實驗做一個挑戰問題即可。)另外,你還要寫出你實現的挑戰問題的詳細描述。如果你實現了多個挑戰問題,你只需寫出其中一個即可,雖然我們的課程歡迎你完成越多的挑戰越好。在動手實驗之前,請將你的挑戰問題的答案寫在一個名為 answers-lab4.txt
的文件中,並把它放在你的 lab
目錄的根下。
Part A:多處理器支持和協調多任務處理
在本實驗的第一部分,將去擴展你的 JOS 內核,以便於它能夠在一個多處理器的系統上運行,並且要在 JOS 內核中實現一些新的系統調用,以便於它允許用戶級環境創建附加的新環境。你也要去實現協調的循環調度,在當前的環境自願放棄 CPU(或退出)時,允許內核將一個環境切換到另一個環境。稍後在 Part C 中,你將要實現搶佔調度,它允許內核在環境佔有 CPU 一段時間後,從這個環境上重新取回對 CPU 的控制,那怕是在那個環境不配合的情況下。
多處理器支持
我們繼續去讓 JOS 支持 「對稱多處理器」(SMP),在一個多處理器的模型中,所有 CPU 們都有平等訪問系統資源(如內存和 I/O 匯流排)的權力。雖然在 SMP 中所有 CPU 們都有相同的功能,但是在引導進程的過程中,它們被分成兩種類型:引導程序處理器(BSP)負責初始化系統和引導操作系統;而在操作系統啟動並正常運行後,應用程序處理器(AP)將被 BSP 激活。哪個處理器做 BSP 是由硬體和 BIOS 來決定的。到目前為止,你所有的已存在的 JOS 代碼都是運行在 BSP 上的。
在一個 SMP 系統上,每個 CPU 都伴有一個本地 APIC(LAPIC)單元。這個 LAPIC 單元負責傳遞系統中的中斷。LAPIC 還為它所連接的 CPU 提供一個唯一的標識符。在本實驗中,我們將使用 LAPIC 單元(它在 kern/lapic.c
中)中的下列基本功能:
- 讀取 LAPIC 標識符(APIC ID),去告訴那個 CPU 現在我們的代碼正在它上面運行(查看
cpunum()
)。 - 從 BSP 到 AP 之間發送處理器間中斷(IPI)
STARTUP
,以啟動其它 CPU(查看lapic_startap()
)。 - 在 Part C 中,我們設置 LAPIC 的內置定時器去觸發時鐘中斷,以便於支持搶佔式多任務處理(查看
apic_init()
)。
一個處理器使用內存映射的 I/O(MMIO)來訪問它的 LAPIC。在 MMIO 中,一部分物理內存是硬編碼到一些 I/O 設備的寄存器中,因此,訪問內存時一般可以使用相同的 load/store
指令去訪問設備的寄存器。正如你所看到的,在物理地址 0xA0000
處就是一個 IO 入口(就是我們寫入 VGA 緩衝區的入口)。LAPIC 就在那裡,它從物理地址 0xFE000000
處(4GB 減去 32MB 處)開始,這個地址對於我們在 KERNBASE 處使用直接映射訪問來說太高了。JOS 虛擬內存映射在 MMIOBASE
處,留下一個 4MB 的空隙,以便於我們有一個地方,能像這樣去映射設備。由於在後面的實驗中,我們將介紹更多的 MMIO 區域,你將要寫一個簡單的函數,從這個區域中去分配空間,並將設備的內存映射到那裡。
練習 1、實現
kern/pmap.c
中的mmio_map_region
。去看一下它是如何使用的,從kern/lapic.c
中的lapic_init
開始看起。在mmio_map_region
的測試運行之前,你還要做下一個練習。
引導應用程序處理器
在引導應用程序處理器之前,引導程序處理器應該會首先去收集關於多處理器系統的信息,比如總的 CPU 數、它們的 APIC ID 以及 LAPIC 單元的 MMIO 地址。在 kern/mpconfig.c
中的 mp_init()
函數,通過讀取內存中位於 BIOS 區域里的 MP 配置表來獲得這些信息。
boot_aps()
函數(在 kern/init.c
中)驅動 AP 的引導過程。AP 們在實模式中開始,與 boot/boot.S
中啟動引導載入程序非常相似。因此,boot_aps()
將 AP 入口代碼(kern/mpentry.S
)複製到實模式中的那個可定址內存地址上。不像使用引導載入程序那樣,我們可以控制 AP 將從哪裡開始運行代碼;我們複製入口代碼到 0x7000
(MPENTRY_PADDR
)處,但是複製到任何低於 640KB 的、未使用的、頁對齊的物理地址上都是可以運行的。
在那之後,通過發送 IPI STARTUP
到相關 AP 的 LAPIC 單元,以及一個初始的 CS:IP
地址(AP 將從那兒開始運行它的入口代碼,在我們的案例中是 MPENTRY_PADDR
),boot_aps()
將一個接一個地激活 AP。在 kern/mpentry.S
中的入口代碼非常類似於 boot/boot.S
。在一些簡短的設置之後,它啟用分頁,使 AP 進入保護模式,然後調用 C 設置程序 mp_main()
(它也在 kern/init.c
中)。在繼續喚醒下一個 AP 之前, boot_aps()
將等待這個 AP 去傳遞一個 CPU_STARTED
標誌到它的 struct CpuInfo
中的 cpu_status
欄位中。
練習 2、閱讀
kern/init.c
中的boot_aps()
和mp_main()
,以及在kern/mpentry.S
中的彙編代碼。確保你理解了在 AP 引導過程中的控制流轉移。然後修改在kern/pmap.c
中的、你自己的page_init()
,實現避免在MPENTRY_PADDR
處添加頁到空閑列表上,以便於我們能夠在物理地址上安全地複製和運行 AP 引導程序代碼。你的代碼應該會通過更新後的check_page_free_list()
的測試(但可能會在更新後的check_kern_pgdir()
上測試失敗,我們在後面會修復它)。
.
問題 1、比較
kern/mpentry.S
和boot/boot.S
。記住,那個kern/mpentry.S
是編譯和鏈接後的,運行在KERNBASE
上面的,就像內核中的其它程序一樣,宏MPBOOTPHYS
的作用是什麼?為什麼它需要在kern/mpentry.S
中,而不是在boot/boot.S
中?換句話說,如果在kern/mpentry.S
中刪掉它,會發生什麼錯誤? 提示:回顧鏈接地址和載入地址的區別,我們在實驗 1 中討論過它們。
每個 CPU 的狀態和初始化
當寫一個多處理器操作系統時,區分每個 CPU 的狀態是非常重要的,而每個 CPU 的狀態對其它處理器是不公開的,而全局狀態是整個系統共享的。kern/cpu.h
定義了大部分每個 CPU 的狀態,包括 struct CpuInfo
,它保存了每個 CPU 的變數。cpunum()
總是返回調用它的那個 CPU 的 ID,它可以被用作是數組的索引,比如 cpus
。或者,宏 thiscpu
是當前 CPU 的 struct CpuInfo
縮略表示。
下面是你應該知道的每個 CPU 的狀態:
- 每個 CPU 的內核棧
因為內核能夠同時捕獲多個 CPU,因此,我們需要為每個 CPU 準備一個單獨的內核棧,以防止它們運行的程序之間產生相互干擾。數組 percpu_kstacks[NCPU][KSTKSIZE]
為 NCPU 的內核棧資產保留了空間。
在實驗 2 中,你映射的 bootstack
所引用的物理內存,就作為 KSTACKTOP
以下的 BSP 的內核棧。同樣,在本實驗中,你將每個 CPU 的內核棧映射到這個區域,而使用保護頁做為它們之間的緩衝區。CPU 0 的棧將從 KSTACKTOP
處向下增長;CPU 1 的棧將從 CPU 0 的棧底部的 KSTKGAP
位元組處開始,依次類推。在 inc/memlayout.h
中展示了這個映射布局。
- 每個 CPU 的 TSS 和 TSS 描述符
為了指定每個 CPU 的內核棧在哪裡,也需要有一個每個 CPU 的任務狀態描述符(TSS)。CPU i 的任務狀態描述符是保存在 cpus[i].cpu_ts
中,而對應的 TSS 描述符是定義在 GDT 條目 gdt[(GD_TSS0 >> 3) + i]
中。在 kern/trap.c
中定義的全局變數 ts
將不再被使用。
- 每個 CPU 當前的環境指針
由於每個 CPU 都能同時運行不同的用戶進程,所以我們重新定義了符號 curenv
,讓它指向到 cpus[cpunum()].cpu_env
(或 thiscpu->cpu_env
),它指向到當前 CPU(代碼正在運行的那個 CPU)上當前正在運行的環境上。
- 每個 CPU 的系統寄存器
所有的寄存器,包括系統寄存器,都是一個 CPU 私有的。所以,初始化這些寄存器的指令,比如 lcr3()
、ltr()
、lgdt()
、lidt()
、等待,必須在每個 CPU 上運行一次。函數 env_init_percpu()
和 trap_init_percpu()
就是為此目的而定義的。
練習 3、修改
mem_init_mp()
(在kern/pmap.c
中)去映射每個 CPU 的棧從KSTACKTOP
處開始,就像在inc/memlayout.h
中展示的那樣。每個棧的大小是KSTKSIZE
位元組加上未映射的保護頁KSTKGAP
的位元組。你的代碼應該會通過在check_kern_pgdir()
中的新的檢查。
.
練習 4、在
trap_init_percpu()
(在kern/trap.c
文件中)的代碼為 BSP 初始化 TSS 和 TSS 描述符。在實驗 3 中它就運行過,但是當它運行在其它的 CPU 上就會出錯。修改這些代碼以便它能在所有 CPU 上都正常運行。(注意:你的新代碼應該還不能使用全局變數ts
)
在你完成上述練習後,在 QEMU 中使用 4 個 CPU(使用 make qemu CPUS=4
或 make qemu-nox CPUS=4
)來運行 JOS,你應該看到類似下面的輸出:
...
Physical memory: 66556K available, base = 640K, extended = 65532K
check_page_alloc() succeeded!
check_page() succeeded!
check_kern_pgdir() succeeded!
check_page_installed_pgdir() succeeded!
SMP: CPU 0 found 4 CPU(s)
enabled interrupts: 1 2
SMP: CPU 1 starting
SMP: CPU 2 starting
SMP: CPU 3 starting
鎖定
在 mp_main()
中初始化 AP 後我們的代碼快速運行起來。在你更進一步增強 AP 之前,我們需要首先去處理多個 CPU 同時運行內核代碼的爭用狀況。達到這一目標的最簡單的方法是使用大內核鎖。大內核鎖是一個單個的全局鎖,當一個環境進入內核模式時,它將被加鎖,而這個環境返回到用戶模式時它將釋放鎖。在這種模型中,在用戶模式中運行的環境可以同時運行在任何可用的 CPU 上,但是只有一個環境能夠運行在內核模式中;而任何嘗試進入內核模式的其它環境都被強制等待。
kern/spinlock.h
中聲明大內核鎖,即 kernel_lock
。它也提供 lock_kernel()
和 unlock_kernel()
,快捷地去獲取/釋放鎖。你應該在以下的四個位置應用大內核鎖:
- 在
i386_init()
時,在 BSP 喚醒其它 CPU 之前獲取鎖。 - 在
mp_main()
時,在初始化 AP 之後獲取鎖,然後調用sched_yield()
在這個 AP 上開始運行環境。 - 在
trap()
時,當從用戶模式中捕獲一個 陷阱 時獲取鎖。在檢查tf_cs
的低位比特,以確定一個陷阱是發生在用戶模式還是內核模式時。 - 在
env_run()
中,在切換到用戶模式之前釋放鎖。不能太早也不能太晚,否則你將可能會產生爭用或死鎖。
練習 5、在上面所描述的情況中,通過在合適的位置調用
lock_kernel()
和unlock_kernel()
應用大內核鎖。如果你的鎖定是正確的,如何去測試它?實際上,到目前為止,還無法測試!但是在下一個練習中,你實現了調度之後,就可以測試了。
.
問題 2、看上去使用一個大內核鎖,可以保證在一個時間中只有一個 CPU 能夠運行內核代碼。為什麼每個 CPU 仍然需要單獨的內核棧?描述一下使用一個共享內核棧出現錯誤的場景,即便是在它使用了大內核鎖保護的情況下。
小挑戰!大內核鎖很簡單,也易於使用。儘管如此,它消除了內核模式的所有並發。大多數現代操作系統使用不同的鎖,一種稱之為細粒度鎖定的方法,去保護它們的共享的棧的不同部分。細粒度鎖能夠大幅提升性能,但是實現起來更困難並且易出錯。如果你有足夠的勇氣,在 JOS 中刪除大內核鎖,去擁抱並發吧!
由你來決定鎖的粒度(一個鎖保護的數據量)。給你一個提示,你可以考慮在 JOS 內核中使用一個自旋鎖去確保你獨佔訪問這些共享的組件:
- 頁分配器
- 控制台驅動
- 調度器
- 你將在 Part C 中實現的進程間通訊(IPC)的狀態
循環調度
本實驗中,你的下一個任務是去修改 JOS 內核,以使它能夠在多個環境之間以「循環」的方式去交替。JOS 中的循環調度工作方式如下:
- 在新的
kern/sched.c
中的sched_yield()
函數負責去選擇一個新環境來運行。它按順序以循環的方式在數組envs[]
中進行搜索,在前一個運行的環境之後開始(或如果之前沒有運行的環境,就從數組起點開始),選擇狀態為ENV_RUNNABLE
的第一個環境(查看inc/env.h
),並調用env_run()
去跳轉到那個環境。 sched_yield()
必須做到,同一個時間在兩個 CPU 上絕對不能運行相同的環境。它可以判斷出一個環境正運行在一些 CPU(可能是當前 CPU)上,因為,那個正在運行的環境的狀態將是ENV_RUNNING
。- 我們已經為你實現了一個新的系統調用
sys_yield()
,用戶環境調用它去調用內核的sched_yield()
函數,並因此將自願把對 CPU 的控制禪讓給另外的一個環境。
練習 6、像上面描述的那樣,在
sched_yield()
中實現循環調度。不要忘了去修改syscall()
以派發sys_yield()
。確保在
mp_main
中調用了sched_yield()
。修改
kern/init.c
去創建三個(或更多個!)運行程序user/yield.c
的環境。運行
make qemu
。在它終止之前,你應該會看到像下面這樣,在環境之間來回切換了五次。也可以使用幾個 CPU 來測試:
make qemu CPUS=2
。... Hello, I am environment 00001000. Hello, I am environment 00001001. Hello, I am environment 00001002. Back in environment 00001000, iteration 0. Back in environment 00001001, iteration 0. Back in environment 00001002, iteration 0. Back in environment 00001000, iteration 1. Back in environment 00001001, iteration 1. Back in environment 00001002, iteration 1. ...
在程序
yield
退出之後,系統中將沒有可運行的環境,調度器應該會調用 JOS 內核監視器。如果它什麼也沒有發生,那麼你應該在繼續之前修復你的代碼。問題 3、在你實現的
env_run()
中,你應該會調用lcr3()
。在調用lcr3()
的之前和之後,你的代碼引用(至少它應該會)變數e
,它是env_run
的參數。在載入%cr3
寄存器時,MMU 使用的地址上下文將馬上被改變。但一個虛擬地址(即e
)相對一個給定的地址上下文是有意義的 —— 地址上下文指定了物理地址到那個虛擬地址的映射。為什麼指針e
在地址切換之前和之後被解除引用?
.
問題 4、無論何時,內核從一個環境切換到另一個環境,它必須要確保舊環境的寄存器內容已經被保存,以便於它們稍後能夠正確地還原。為什麼?這種事件發生在什麼地方?
.
小挑戰!給內核添加一個小小的調度策略,比如一個固定優先順序的調度器,它將會給每個環境分配一個優先順序,並且在執行中,較高優先順序的環境總是比低優先順序的環境優先被選定。如果你想去冒險一下,嘗試實現一個類 Unix 的、優先順序可調整的調度器,或者甚至是一個彩票調度器或跨步調度器。(可以在 Google 中查找「彩票調度」和「跨步調度」的相關資料)
寫一個或兩個測試程序,去測試你的調度演算法是否工作正常(即,正確的演算法能夠按正確的次序運行)。如果你實現了本實驗的 Part B 和 Part C 部分的
fork()
和 IPC,寫這些測試程序可能會更容易。
.
小挑戰!目前的 JOS 內核還不能應用到使用了 x87 協處理器、MMX 指令集、或流式 SIMD 擴展(SSE)的 x86 處理器上。擴展數據結構
Env
去提供一個能夠保存處理器的浮點狀態的地方,並且擴展上下文切換代碼,當從一個環境切換到另一個環境時,能夠保存和還原正確的狀態。FXSAVE
和FXRSTOR
指令或許對你有幫助,但是需要注意的是,這些指令在舊的 x86 用戶手冊上沒有,因為它是在較新的處理器上引入的。寫一個用戶級的測試程序,讓它使用浮點做一些很酷的事情。
創建環境的系統調用
雖然你的內核現在已經有了在多個用戶級環境之間切換的功能,但是由於內核初始化設置的原因,它在運行環境時仍然是受限的。現在,你需要去實現必需的 JOS 系統調用,以允許用戶環境去創建和啟動其它的新用戶環境。
Unix 提供了 fork()
系統調用作為它的進程創建原語。Unix 的 fork()
通過複製調用進程(父進程)的整個地址空間去創建一個新進程(子進程)。從用戶空間中能夠觀察到它們之間的僅有的兩個差別是,它們的進程 ID 和父進程 ID(由 getpid
和 getppid
返回)。在父進程中,fork()
返回子進程 ID,而在子進程中,fork()
返回 0。默認情況下,每個進程得到它自己的私有地址空間,一個進程對內存的修改對另一個進程都是不可見的。
為創建一個用戶模式下的新的環境,你將要提供一個不同的、更原始的 JOS 系統調用集。使用這些系統調用,除了其它類型的環境創建之外,你可以在用戶空間中實現一個完整的類 Unix 的 fork()
。你將要為 JOS 編寫的新的系統調用如下:
sys_exofork
:
這個系統調用創建一個新的空白的環境:在它的地址空間的用戶部分什麼都沒有映射,並且它也不能運行。這個新的環境與 sys_exofork
調用時創建它的父環境的寄存器狀態完全相同。在父進程中,sys_exofork
將返回新創建進程的 envid_t
(如果環境分配失敗的話,返回的是一個負的錯誤代碼)。在子進程中,它將返回 0。(因為子進程從一開始就被標記為不可運行,在子進程中,sys_exofork
將並不真的返回,直到它的父進程使用 …. 顯式地將子進程標記為可運行之前。)
sys_env_set_status
:
設置指定的環境狀態為 ENV_RUNNABLE
或 ENV_NOT_RUNNABLE
。這個系統調用一般是在,一個新環境的地址空間和寄存器狀態已經完全初始化完成之後,用於去標記一個準備去運行的新環境。
sys_page_alloc
:
分配一個物理內存頁,並映射它到一個給定的環境地址空間中、給定的一個虛擬地址上。
sys_page_map
:
從一個環境的地址空間中複製一個頁映射(不是頁內容!)到另一個環境的地址空間中,保持一個內存共享,以便於新的和舊的映射共同指向到同一個物理內存頁。
sys_page_unmap
:
在一個給定的環境中,取消映射一個給定的已映射的虛擬地址。
上面所有的系統調用都接受環境 ID 作為參數,JOS 內核支持一個約定,那就是用值 「0」 來表示「當前環境」。這個約定在 kern/env.c
中的 envid2env()
中實現的。
在我們的 user/dumbfork.c
中的測試程序里,提供了一個類 Unix 的 fork()
的非常原始的實現。這個測試程序使用了上面的系統調用,去創建和運行一個複製了它自己地址空間的子環境。然後,這兩個環境像前面的練習那樣使用 sys_yield
來回切換,父進程在迭代 10 次後退出,而子進程在迭代 20 次後退出。
練習 7、在
kern/syscall.c
中實現上面描述的系統調用,並確保syscall()
能調用它們。你將需要使用kern/pmap.c
和kern/env.c
中的多個函數,尤其是要用到envid2env()
。目前,每當你調用envid2env()
時,在checkperm
中傳遞參數 1。你務必要做檢查任何無效的系統調用參數,在那個案例中,就返回了-E_INVAL
。使用user/dumbfork
測試你的 JOS 內核,並在繼續之前確保它運行正常。
.
小挑戰!添加另外的系統調用,必須能夠讀取已存在的、所有的、環境的重要狀態,以及設置它們。然後實現一個能夠 fork 出子環境的用戶模式程序,運行它一小會(即,迭代幾次
sys_yield()
),然後取得幾張屏幕截圖或子環境的檢查點,然後運行子環境一段時間,然後還原子環境到檢查點時的狀態,然後從這裡繼續開始。這樣,你就可以有效地從一個中間狀態「回放」了子環境的運行。確保子環境與用戶使用sys_cgetc()
或readline()
執行了一些交互,這樣,那個用戶就能夠查看和突變它的內部狀態,並且你可以通過給子環境給定一個選擇性遺忘的狀況,來驗證你的檢查點/重啟動的有效性,使它「遺忘」了在某些點之前發生的事情。
到此為止,已經完成了本實驗的 Part A 部分;在你運行 make grade
之前確保它通過了所有的 Part A 的測試,並且和以往一樣,使用 make handin
去提交它。如果你想嘗試找出為什麼一些特定的測試是失敗的,可以運行 run ./grade-lab4 -v
,它將向你展示內核構建的輸出,和測試失敗時的 QEMU 運行情況。當測試失敗時,這個腳本將停止運行,然後你可以去檢查 jos.out
的內容,去查看內核真實的輸出內容。
Part B:寫時複製 Fork
正如在前面提到過的,Unix 提供 fork()
系統調用作為它主要的進程創建原語。fork()
系統調用通過複製調用進程(父進程)的地址空間來創建一個新進程(子進程)。
xv6 Unix 的 fork()
從父進程的頁上複製所有數據,然後將它分配到子進程的新頁上。從本質上看,它與 dumbfork()
所採取的方法是相同的。複製父進程的地址空間到子進程,是 fork()
操作中代價最高的部分。
但是,一個對 fork()
的調用後,經常是緊接著幾乎立即在子進程中有一個到 exec()
的調用,它使用一個新程序來替換子進程的內存。這是 shell 默認去做的事,在這種情況下,在複製父進程地址空間上花費的時間是非常浪費的,因為在調用 exec()
之前,子進程使用的內存非常少。
基於這個原因,Unix 的最新版本利用了虛擬內存硬體的優勢,允許父進程和子進程去共享映射到它們各自地址空間上的內存,直到其中一個進程真實地修改了它們為止。這個技術就是眾所周知的「寫時複製」。為實現這一點,在 fork()
時,內核將複製從父進程到子進程的地址空間的映射,而不是所映射的頁的內容,並且同時設置正在共享中的頁為只讀。當兩個進程中的其中一個嘗試去寫入到它們共享的頁上時,進程將產生一個頁故障。在這時,Unix 內核才意識到那個頁實際上是「虛擬的」或「寫時複製」的副本,然後它生成一個新的、私有的、那個發生頁故障的進程可寫的、頁的副本。在這種方式中,個人的頁的內容並不進行真實地複製,直到它們真正進行寫入時才進行複製。這種優化使得一個fork()
後在子進程中跟隨一個 exec()
變得代價很低了:子進程在調用 exec()
時或許僅需要複製一個頁(它的棧的當前頁)。
在本實驗的下一段中,你將實現一個帶有「寫時複製」的「真正的」類 Unix 的 fork()
,來作為一個常規的用戶空間庫。在用戶空間中實現 fork()
和寫時複製有一個好處就是,讓內核始終保持簡單,並且因此更不易出錯。它也讓個別的用戶模式程序在 fork()
上定義了它們自己的語義。一個有略微不同實現的程序(例如,代價昂貴的、總是複製的 dumbfork()
版本,或父子進程真實共享內存的後面的那一個),它自己可以很容易提供。
用戶級頁故障處理
一個用戶級寫時複製 fork()
需要知道關於在防寫頁上的頁故障相關的信息,因此,這是你首先需要去實現的東西。對用戶級頁故障處理來說,寫時複製僅是眾多可能的用途之一。
它通常是配置一個地址空間,因此在一些動作需要時,那個頁故障將指示去處。例如,主流的 Unix 內核在一個新進程的棧區域中,初始的映射僅是單個頁,並且在後面「按需」分配和映射額外的棧頁,因此,進程的棧消費是逐漸增加的,並因此導致在尚未映射的棧地址上發生頁故障。在每個進程空間的區域上發生一個頁故障時,一個典型的 Unix 內核必須對它的動作保持跟蹤。例如,在棧區域中的一個頁故障,一般情況下將分配和映射新的物理內存頁。一個在程序的 BSS 區域中的頁故障,一般情況下將分配一個新頁,然後用 0 填充它並映射它。在一個按需分頁的系統上的一個可執行文件中,在文本區域中的頁故障將從磁碟上讀取相應的二進位頁並映射它。
內核跟蹤有大量的信息,與傳統的 Unix 方法不同,你將決定在每個用戶空間中關於每個頁故障應該做的事。用戶空間中的 bug 危害都較小。這種設計帶來了額外的好處,那就是允許程序員在定義它們的內存區域時,會有很好的靈活性;對於映射和訪問基於磁碟文件系統上的文件時,你應該使用後面的用戶級頁故障處理。
設置頁故障服務程序
為了處理它自己的頁故障,一個用戶環境將需要在 JOS 內核上註冊一個頁故障服務程序入口。用戶環境通過新的 sys_env_set_pgfault_upcall
系統調用來註冊它的頁故障入口。我們給結構 Env
增加了一個新的成員 env_pgfault_upcall
,讓它去記錄這個信息。
練習 8、實現
sys_env_set_pgfault_upcall
系統調用。當查找目標環境的環境 ID 時,一定要確認啟用了許可權檢查,因為這是一個「危險的」系統調用。 「`
在用戶環境中的正常和異常棧
在正常運行期間,JOS 中的一個用戶環境運行在正常的用戶棧上:它的 ESP
寄存器開始指向到 USTACKTOP
,而它所推送的棧數據將駐留在 USTACKTOP-PGSIZE
和 USTACKTOP-1
(含)之間的頁上。但是,當在用戶模式中發生頁故障時,內核將在一個不同的棧上重新啟動用戶環境,運行一個用戶級頁故障指定的服務程序,即用戶異常棧。其它,我們將讓 JOS 內核為用戶環境實現自動的「棧切換」,當從用戶模式轉換到內核模式時,x86 處理器就以大致相同的方式為 JOS 實現了棧切換。
JOS 用戶異常棧也是一個頁的大小,並且它的頂部被定義在虛擬地址 UXSTACKTOP
處,因此用戶異常棧的有效位元組數是從 UXSTACKTOP-PGSIZE
到 UXSTACKTOP-1
(含)。儘管運行在異常棧上,用戶頁故障服務程序能夠使用 JOS 的普通系統調用去映射新頁或調整映射,以便於去修復最初導致頁故障發生的各種問題。然後用戶級頁故障服務程序通過彙編語言 stub
返回到原始棧上的故障代碼。
每個想去支持用戶級頁故障處理的用戶環境,都需要為它自己的異常棧使用在 Part A 中介紹的 sys_page_alloc()
系統調用去分配內存。
調用用戶頁故障服務程序
現在,你需要去修改 kern/trap.c
中的頁故障處理代碼,以能夠處理接下來在用戶模式中發生的頁故障。我們將故障發生時用戶環境的狀態稱之為捕獲時狀態。
如果這裡沒有註冊頁故障服務程序,JOS 內核將像前面那樣,使用一個消息來銷毀用戶環境。否則,內核將在異常棧上設置一個陷阱幀,它看起來就像是來自 inc/trap.h
文件中的一個 struct UTrapframe
一樣:
<-- UXSTACKTOP
trap-time esp
trap-time eflags
trap-time eip
trap-time eax start of struct PushRegs
trap-time ecx
trap-time edx
trap-time ebx
trap-time esp
trap-time ebp
trap-time esi
trap-time edi end of struct PushRegs
tf_err (error code)
fault_va <-- %esp when handler is run
然後,內核安排這個用戶環境重新運行,使用這個棧幀在異常棧上運行頁故障服務程序;你必須搞清楚為什麼發生這種情況。fault_va
是引發頁故障的虛擬地址。
如果在一個異常發生時,用戶環境已經在用戶異常棧上運行,那麼頁故障服務程序自身將會失敗。在這種情況下,你應該在當前的 tf->tf_esp
下,而不是在 UXSTACKTOP
下啟動一個新的棧幀。
去測試 tf->tf_esp
是否已經在用戶異常棧上準備好,可以去檢查它是否在 UXSTACKTOP-PGSIZE
和 UXSTACKTOP-1
(含)的範圍內。
練習 9、實現在
kern/trap.c
中的page_fault_handler
的代碼,要求派發頁故障到用戶模式故障服務程序上。在寫入到異常棧時,一定要採取適當的預防措施。(如果用戶環境運行時溢出了異常棧,會發生什麼事情?)
用戶模式頁故障入口點
接下來,你需要去實現彙編程序,它將調用 C 頁故障服務程序,並在原始的故障指令處恢復程序運行。這個彙編程序是一個故障服務程序,它由內核使用 sys_env_set_pgfault_upcall()
來註冊。
練習 10、實現在
lib/pfentry.S
中的_pgfault_upcall
程序。最有趣的部分是返回到用戶代碼中產生頁故障的原始位置。你將要直接返回到那裡,不能通過內核返回。最難的部分是同時切換棧和重新載入 EIP。
最後,你需要去實現用戶級頁故障處理機制的 C 用戶庫。
練習 11、完成
lib/pgfault.c
中的set_pgfault_handler()
。 」`
測試
運行 user/faultread
(make run-faultread)你應該會看到:
...
[00000000] new env 00001000
[00001000] user fault va 00000000 ip 0080003a
TRAP frame ...
[00001000] free env 00001000
運行 user/faultdie
你應該會看到:
...
[00000000] new env 00001000
i faulted at va deadbeef, err 6
[00001000] exiting gracefully
[00001000] free env 00001000
運行 user/faultalloc
你應該會看到:
...
[00000000] new env 00001000
fault deadbeef
this string was faulted in at deadbeef
fault cafebffe
fault cafec000
this string was faulted in at cafebffe
[00001000] exiting gracefully
[00001000] free env 00001000
如果你只看到第一個 「this string」 行,意味著你沒有正確地處理遞歸頁故障。
運行 user/faultallocbad
你應該會看到:
...
[00000000] new env 00001000
[00001000] user_mem_check assertion failure for va deadbeef
[00001000] free env 00001000
確保你理解了為什麼 user/faultalloc
和 user/faultallocbad
的行為是不一樣的。
小挑戰!擴展你的內核,讓它不僅是頁故障,而是在用戶空間中運行的代碼能夠產生的所有類型的處理器異常,都能夠被重定向到一個用戶模式中的異常服務程序上。寫出用戶模式測試程序,去測試各種各樣的用戶模式異常處理,比如除零錯誤、一般保護故障、以及非法操作碼。
實現寫時複製 Fork
現在,你有個內核功能要去實現,那就是在用戶空間中完整地實現寫時複製 fork()
。
我們在 lib/fork.c
中為你的 fork()
提供了一個框架。像 dumbfork()
、fork()
應該會創建一個新環境,然後通過掃描父環境的整個地址空間,並在子環境中設置相關的頁映射。重要的差別在於,dumbfork()
複製了頁,而 fork()
開始只是複製了頁映射。fork()
僅當在其中一個環境嘗試去寫入它時才複製每個頁。
fork()
的基本控制流如下:
- 父環境使用你在上面實現的
set_pgfault_handler()
函數,安裝pgfault()
作為 C 級頁故障服務程序。 - 父環境調用
sys_exofork()
去創建一個子環境。 - 在它的地址空間中,低於 UTOP 位置的、每個可寫入頁、或寫時複製頁上,父環境調用
duppage
後,它應該會映射頁寫時複製到子環境的地址空間中,然後在它自己的地址空間中重新映射頁寫時複製。[ 注意:這裡的順序很重要(即,在父環境中標記之前,先在子環境中標記該頁為 COW)!你能明白是為什麼嗎?嘗試去想一個具體的案例,將順序顛倒一下會發生什麼樣的問題。]duppage
把兩個 PTE 都設置了,致使那個頁不可寫入,並且在 「avail」 欄位中通過包含PTE_COW
來從真正的只讀頁中區分寫時複製頁。
然而異常棧是不能通過這種方式重映射的。對於異常棧,你需要在子環境中分配一個新頁。因為頁故障服務程序不能做真實的複製,並且頁故障服務程序是運行在異常棧上的,異常棧不能進行寫時複製:那麼誰來複制它呢?
fork()
也需要去處理存在的頁,但不能寫入或寫時複製。
- 父環境為子環境設置了用戶頁故障入口點,讓它看起來像它自己的一樣。
- 現在,子環境準備去運行,所以父環境標記它為可運行。
每次其中一個環境寫一個還沒有寫入的寫時複製頁時,它將產生一個頁故障。下面是用戶頁故障服務程序的控制流:
- 內核傳遞頁故障到
_pgfault_upcall
,它調用fork()
的pgfault()
服務程序。 pgfault()
檢測到那個故障是一個寫入(在錯誤代碼中檢查FEC_WR
),然後將那個頁的 PTE 標記為PTE_COW
。如果不是一個寫入,則崩潰。pgfault()
在一個臨時位置分配一個映射的新頁,並將故障頁的內容複製進去。然後,故障服務程序以讀取/寫入許可權映射新頁到合適的地址,替換舊的只讀映射。
對於上面的幾個操作,用戶級 lib/fork.c
代碼必須查詢環境的頁表(即,那個頁的 PTE 是否標記為 PET_COW
)。為此,內核在 UVPT
位置精確地映射環境的頁表。它使用一個 聰明的映射技巧 去標記它,以使用戶代碼查找 PTE 時更容易。lib/entry.S
設置 uvpt
和 uvpd
,以便於你能夠在 lib/fork.c
中輕鬆查找頁表信息。
練習 12、在
lib/fork.c
中實現fork
、duppage
和pgfault
。使用
forktree
程序測試你的代碼。它應該會產生下列的信息,在信息中會有 『new env'、'free env'、和 'exiting gracefully』 這樣的字眼。信息可能不是按如下的順序出現的,並且環境 ID 也可能不一樣。1000: I am '' 1001: I am '0' 2000: I am '00' 2001: I am '000' 1002: I am '1' 3000: I am '11' 3001: I am '10' 4000: I am '100' 1003: I am '01' 5000: I am '010' 4001: I am '011' 2002: I am '110' 1004: I am '001' 1005: I am '111' 1006: I am '101'
.
小挑戰!實現一個名為
sfork()
的共享內存的fork()
。這個版本的sfork()
中,父子環境共享所有的內存頁(因此,一個環境中對內存寫入,就會改變另一個環境數據),除了在棧區域中的頁以外,它應該使用寫時複製來處理這些頁。修改user/forktree.c
去使用sfork()
而是不常見的fork()
。另外,你在 Part C 中實現了 IPC 之後,使用你的sfork()
去運行user/pingpongs
。你將找到提供全局指針thisenv
功能的一個新方式。
.
小挑戰!你實現的
fork
將產生大量的系統調用。在 x86 上,使用中斷切換到內核模式將產生較高的代價。增加系統調用介面,以便於它能夠一次發送批量的系統調用。然後修改fork
去使用這個介面。你的新的
fork
有多快?你可以用一個分析來論證,批量提交對你的
fork
的性能改變,以它來(粗略地)回答這個問題:使用一個int 0x30
指令的代價有多高?在你的fork
中運行了多少次int 0x30
指令?訪問TSS
棧切換的代價高嗎?等待 …或者,你可以在真實的硬體上引導你的內核,並且真實地對你的代碼做基準測試。查看
RDTSC
(讀取時間戳計數器)指令,它的定義在 IA32 手冊中,它計數自上一次處理器重置以來流逝的時鐘周期數。QEMU 並不能真實地模擬這個指令(它能夠計數運行的虛擬指令數量,或使用主機的 TSC,但是這兩種方式都不能反映真實的 CPU 周期數)。
到此為止,Part B 部分結束了。在你運行 make grade
之前,確保你通過了所有的 Part B 部分的測試。和以前一樣,你可以使用 make handin
去提交你的實驗。
Part C:搶佔式多任務處理和進程間通訊(IPC)
在實驗 4 的最後部分,你將修改內核去搶佔不配合的環境,並允許環境之間顯式地傳遞消息。
時鐘中斷和搶佔
運行測試程序 user/spin
。這個測試程序 fork 出一個子環境,它控制了 CPU 之後,就永不停歇地運轉起來。無論是父環境還是內核都不能回收對 CPU 的控制。從用戶模式環境中保護系統免受 bug 或惡意代碼攻擊的角度來看,這顯然不是個理想的狀態,因為任何用戶模式環境都能夠通過簡單的無限循環,並永不歸還 CPU 控制權的方式,讓整個系統處於暫停狀態。為了允許內核去搶佔一個運行中的環境,從其中奪回對 CPU 的控制權,我們必須去擴展 JOS 內核,以支持來自硬體時鐘的外部硬體中斷。
中斷規則
外部中斷(即:設備中斷)被稱為 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,它確實導致了硬體中斷服務程序和處理器異常處理之間的混淆!)
在 JOS 中,相比 xv6 Unix 我們做了一個重要的簡化。當處於內核模式時,外部設備中斷總是被關閉(並且,像 xv6 一樣,當處於用戶空間時,再打開外部設備的中斷)。外部中斷由 %eflags
寄存器的 FL_IF
標誌位來控制(查看 inc/mmu.h
)。當這個標誌位被設置時,外部中斷被打開。雖然這個標誌位可以使用幾種方式來修改,但是為了簡化,我們只通過進程所保存和恢復的 %eflags
寄存器值,作為我們進入和離開用戶模式的方法。
處於用戶環境中時,你將要確保 FL_IF
標誌被設置,以便於出現一個中斷時,它能夠通過處理器來傳遞,讓你的中斷代碼來處理。否則,中斷將被屏蔽或忽略,直到中斷被重新打開後。我們使用引導載入程序的第一個指令去屏蔽中斷,並且到目前為止,還沒有去重新打開它們。
練習 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 Intel 架構軟體開發者手冊 卷 3 的 5.8 節。
在完成這個練習後,如果你在你的內核上使用任意的測試程序去持續運行(即:
spin
),你應該會看到內核輸出中捕獲的硬體中斷的捕獲幀。雖然在處理器上已經打開了中斷,但是 JOS 並不能處理它們,因此,你應該會看到在當前運行的用戶環境中每個中斷的錯誤屬性並被銷毀,最終環境會被銷毀並進入到監視器中。
處理時鐘中斷
在 user/spin
程序中,子環境首先運行之後,它只是進入一個高速循環中,並且內核再無法取得 CPU 控制權。我們需要對硬體編程,定期產生時鐘中斷,它將強制將 CPU 控制權返還給內核,在內核中,我們就能夠將控制權切換到另外的用戶環境中。
我們已經為你寫好了對 lapic_init
和 pic_init
(來自 init.c
中的 i386_init
)的調用,它將設置時鐘和中斷控制器去產生中斷。現在,你需要去寫代碼來處理這些中斷。
練習 14、修改內核的
trap_dispatch()
函數,以便於在時鐘中斷髮生時,它能夠調用sched_yield()
去查找和運行一個另外的環境。現在,你應該能夠用
user/spin
去做測試了:父環境應該會 fork 出子環境,sys_yield()
到它許多次,但每次切換之後,將重新獲得對 CPU 的控制權,最後殺死子環境後優雅地終止。
這是做回歸測試的好機會。確保你沒有弄壞本實驗的前面部分,確保打開中斷能夠正常工作(即: forktree
)。另外,嘗試使用 make CPUS=2 target
在多個 CPU 上運行它。現在,你應該能夠通過 stresssched
測試。可以運行 make grade
去確認。現在,你的得分應該是 65 分了(總分為 80)。
進程間通訊(IPC)
(嚴格來說,在 JOS 中這是「環境間通訊」 或 「IEC」,但所有人都稱它為 IPC,因此我們使用標準的術語。)
我們一直專註於操作系統的隔離部分,這就產生了一種錯覺,好像每個程序都有一個機器完整地為它服務。一個操作系統的另一個重要服務是,當它們需要時,允許程序之間相互通訊。讓程序與其它程序交互可以讓它的功能更加強大。Unix 的管道模型就是一個權威的示例。
進程間通訊有許多模型。關於哪個模型最好的爭論從來沒有停止過。我們不去參與這種爭論。相反,我們將要實現一個簡單的 IPC 機制,然後嘗試使用它。
JOS 中的 IPC
你將要去實現另外幾個 JOS 內核的系統調用,由它們共同來提供一個簡單的進程間通訊機制。你將要實現兩個系統調用,sys_ipc_recv
和 sys_ipc_try_send
。然後你將要實現兩個庫去封裝 ipc_recv
和 ipc_send
。
用戶環境可以使用 JOS 的 IPC 機制相互之間發送 「消息」 到每個其它環境,這些消息有兩部分組成:一個單個的 32 位值,和可選的一個單個頁映射。允許環境在消息中傳遞頁映射,提供了一個高效的方式,傳輸比一個僅適合單個的 32 位整數更多的數據,並且也允許環境去輕鬆地設置安排共享內存。
發送和接收消息
一個環境通過調用 sys_ipc_recv
去接收消息。這個系統調用將取消對當前環境的調度,並且不會再次去運行它,直到消息被接收為止。當一個環境正在等待接收一個消息時,任何其它環境都能夠給它發送一個消息 — 而不僅是一個特定的環境,而且不僅是與接收環境有父子關係的環境。換句話說,你在 Part A 中實現的許可權檢查將不會應用到 IPC 上,因為 IPC 系統調用是經過慎重設計的,因此可以認為它是「安全的」:一個環境並不能通過給它發送消息導致另一個環境發生故障(除非目標環境也存在 Bug)。
嘗試去發送一個值時,一個環境使用接收者的 ID 和要發送的值去調用 sys_ipc_try_send
來發送。如果指定的環境正在接收(它調用了 sys_ipc_recv
,但尚未收到值),那麼這個環境將去發送消息並返回 0。否則將返回 -E_IPC_NOT_RECV
來表示目標環境當前不希望來接收值。
在用戶空間中的一個庫函數 ipc_recv
將去調用 sys_ipc_recv
,然後,在當前環境的 struct Env
中查找關於接收到的值的相關信息。
同樣,一個庫函數 ipc_send
將去不停地調用 sys_ipc_try_send
來發送消息,直到發送成功為止。
轉移頁
當一個環境使用一個有效的 dstva
參數(低於 UTOP
)去調用 sys_ipc_recv
時,環境將聲明願意去接收一個頁映射。如果發送方發送一個頁,那麼那個頁應該會被映射到接收者地址空間的 dstva
處。如果接收者在 dstva
已經有了一個頁映射,那麼已存在的那個頁映射將被取消映射。
當一個環境使用一個有效的 srcva
參數(低於 UTOP
)去調用 sys_ipc_try_send
時,意味著發送方希望使用 perm
許可權去發送當前映射在 srcva
處的頁給接收方。在 IPC 成功之後,發送方在它的地址空間中,保留了它最初映射到 srcva
位置的頁。而接收方也獲得了最初由它指定的、在它的地址空間中的 dstva
處的、映射到相同物理頁的映射。最後的結果是,這個頁成為發送方和接收方共享的頁。
如果發送方和接收方都沒有表示要轉移這個頁,那麼就不會有頁被轉移。在任何 IPC 之後,內核將在接收方的 Env
結構上設置新的 env_ipc_perm
欄位,以允許接收頁,或者將它設置為 0,表示不再接收。
實現 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
非常有趣,你將看到所有的 fork 和 IPC 都是在幕後進行。
.
小挑戰!為什麼
ipc_send
要循環調用?修改系統調用介面,讓它不去循環。確保你能處理多個環境嘗試同時發送消息到一個環境上的情況。
.
小挑戰!質數篩選是在大規模並發程序中傳遞消息的一個很巧妙的用法。閱讀 C. A. R. Hoare 寫的 《Communicating Sequential Processes》,Communications of the ACM_ 21(8) (August 1978), 666-667,並去實現矩陣乘法示例。
.
小挑戰!控制消息傳遞的最令人印象深刻的一個例子是,Doug McIlroy 的冪序列計算器,它在 M. Douglas McIlroy,《Squinting at Power Series》,Software–Practice and Experience, 20(7) (July 1990),661-683 中做了詳細描述。實現了它的冪序列計算器,並且計算了 sin ( x + x 3) 的冪序列。
.
小挑戰!通過應用 Liedtke 的論文(通過內核設計改善 IPC 性能)中的一些技術、或你可以想到的其它技巧,來讓 JOS 的 IPC 機制更高效。為此,你可以隨意修改內核的系統調用 API,只要你的代碼向後兼容我們的評級腳本就行。
Part C 到此結束了。確保你通過了所有的評級測試,並且不要忘了將你的小挑戰的答案寫入到 answers-lab4.txt
中。
在動手實驗之前, 使用 git status
和 git diff
去檢查你的更改,並且不要忘了去使用 git add answers-lab4.txt
添加你的小挑戰的答案。在你全部完成後,使用 git commit -am 'my solutions to lab 4』
提交你的更改,然後 make handin
並關注它的動向。
via: https://pdos.csail.mit.edu/6.828/2018/labs/lab4/
作者:csail.mit 選題:lujun9972 譯者:qhwdw 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive