【xv6 RISC-V】xv6操作系统原理解析与源代码阅读报告
目錄
- 導言
- 1.系統調用(syscall)
- (1)基本原理
- (2)源代碼分析
- i)用戶代碼
- ii)內核代碼
- 2.陷阱(trap)
- (1)基本原理
- (2)源代碼分析
- 3.內存管理(memory management)
- (1)基本原理
- i)頁表與地址轉換
- ii)地址空間
- (2)源代碼分析
- 4.多線程(multithreading)
- (1)基本原理
- (2)源代碼分析
- 5.鎖(lock)
- (1)基本原理
- (2)源代碼分析
- 6.文件系統(file system)
- (1)基本原理
- (2)源代碼分析
- 結語
- 參考資料
導言
本文具體分析了基于RISC-V多核處理器的xv6操作系統的基本理論與具體實現,通過閱讀對應源代碼,研究了包括系統調用、陷阱、內存管理、多線程、鎖與文件系統等操作系統的重要組成部分,結合mit的lab對其中某些具體實現進行了部分改進。下面將會結合重點核心代碼對各部分進行具體闡述。
1.系統調用(syscall)
(1)基本原理
為了實現不同的系統功能,xv6系統定義了一系列系統調用號與對應的內核處理程序。當應用程序需要使用某一項功能時,可以首先將系統調用號送入a7寄存器,然后執行ecall指令,該指令導致系統陷入內核并執行相應的系統調用處理程序,處理完成后將控制權還給用戶程序。
(2)源代碼分析
i)用戶代碼
- user/user.h:該頭文件為用戶空間的代碼提供了系統調用API的顯式聲明,這樣不論是用戶程序還是庫代碼都可以直接調用這些API:
- user/usys.pl:該文件用于生成匯編代碼“usys.S”,即系統調用的用戶匯編入口,其代碼清晰地展現了用戶程序將系統調用號放入a7寄存器并執行ecall指令的過程:
ii)內核代碼
- kernel/syscall.h:該文件為每一個系統調用分配了系統調用號,原始共23個。
- kernel/syscall.c:該文件定義了重要的數組“syscalls”,該數組以函數地址為內容,將系統調用號映射到對應的系統調用入口。此外,syscall()函數根據用戶進程a7寄存器傳入的系統調用號,通過查數組進行對應的系統調用,并將函數返回值存入用戶進程的a0寄存器。
- kernel/sysproc.c:該文件包含進程相關系統調用的具體定義。
- kernel/proc.h:該文件定義了進程相關的重要數據結構,包括PCB結構proc,上下文結構context,邏輯處理器結構cpu,以及每個進程獨有的trapframe結構。其中比較重要的proc結構包含了進程的pid,狀態,上下文,頁表,打開文件表等信息,是操作系統控制與管理進程的核心:
- kernel/proc.c:該c文件包含了操作系統進程管理的相關具體實現,包括fork(),exec(),sleep(),wait()等重要函數。
2.陷阱(trap)
(1)基本原理
引發操作系統trap的通常有以下幾種情況:一種是系統調用,當用戶程序執行ecall指令要求內核為其做某事時;另一種情況是異常:一條指令(用戶或內核)做了一些非法的事情,如除以零或使用無效的虛擬地址;第三種情況是設備中斷,當一個設備發出需要注意的信號時,例如當磁盤硬件完成一個讀寫請求時。Xv6 trap 處理分為四個階段:trap迫使控制權轉移到內核;內核保存寄存器和其他狀態,以便恢復執行;內核執行適當的處理程序代碼(例如,系統調用實現或設備驅動程序);內核恢復保存的狀態,并從trap中返回,代碼從原來的地方恢復執行。
每個RISC-V CPU都有一組控制寄存器,內核寫入這些寄存器來告訴CPU如何處理trap,內核可以通過讀取這些寄存器來發現已經發生的trap,以下是一些重要的寄存器:
-
stvec:內核在這里寫下trap處理程序的地址;RISC-V跳轉到這里來處理trap。
-
sepc:當trap發生時,RISC-V會將程序計數器保存在這里(因為PC會被stvec覆蓋)。sret(從trap中返回)指令將sepc復制到pc中。內核可以寫sepc來控制sret的返回到哪里。
-
scause:RISC -V在這里放了一個數字,描述了trap的原因。
-
sscratch:內核在這里放置了一個值,在trap處理程序開始時可以方便地使用。
-
sstatus:sstatus中的SIE位控制設備中斷是否被啟用,如果內核清除SIE,RISC-V將推遲設備中斷,直到內核設置SIE。SPP位表示trap是來自用戶模式還是supervisor模式,并控制sret返回到什么模式。
當需要執行trap時,RISC-V硬件對所有的trap類型(除定時器中斷外)進行以下操作:
(2)源代碼分析
- kernel/trampoline.S:該匯編代碼定義了用戶空間陷入內核與離開內核的匯編接口,包含兩個重要的匯編過程:uservec與userret。其中uservec首先通過sscratch獲取用戶空間trapframe的地址,將寄存器值存儲在用戶進程的trapframe中,并從該結構中恢復內核棧與頁表,然后跳轉到usertrap()執行相應的處理程序。而userret則相應地恢復了之前存儲的寄存器值并執行sret指令返回到用戶空間。
- kernel/trap.c:該文件分別定義了從用戶態陷入與從內核態陷入的中斷處理程序,其中usertrap()函數根據scause寄存器的值判斷當前系統中斷的原因并進行相應的處理。特別地,當該中斷為時鐘中斷時,系統會強制該進程放棄cpu。最后,usertrap()調用usertrapret(),該函數將內核棧等信息保存在進程trapframe中,將PSW設置為用戶態并調用userret返回。
3.內存管理(memory management)
(1)基本原理
i)頁表與地址轉換
xv6操作系統使用基于頁表的虛擬內存管理方式,一個RISC-V頁表在邏輯上是一個由2272^{27}227(134,217,728)個頁表項(Page Table Entry, PTE)組成的數組。每個PTE包含一個44位的物理頁號(Physical Page Number, PPN)和一些標志位。分頁硬件通過利用39位中的高27位索引到頁表中找到一個PTE來轉換一個虛擬地址,并計算出一個56位的物理地址,它的前44位來自于PTE中的PPN,而它的后12位則是從原來的虛擬地址復制過來的。如圖所示,在邏輯上可以把頁表看成是一個簡單的PTE數組,操作系統通過頁表來控制虛擬地址到物理地址的轉換,其粒度為4096(2122^{12}212)字節的對齊塊,即內存頁。
一個頁表以三層樹的形式存儲在物理內存中。樹的根部是一個 4096 字節的頁表頁,它包含 512 個 PTE,這些 PTE 包含樹的下一級頁表頁的物理地址。每一頁都包含 512 個 PTE,用于指向下一個頁表或物理地址。分頁硬件用 27 位中的高 9 位選擇根頁表頁中的 PTE,用中間 9 位選擇樹中下一級頁表頁中的 PTE,用低 9 位選擇最后的 PTE。
每個 PTE 都包含標志位,用于告訴分頁硬件相關的虛擬地址被允許怎樣使用。PTE_V 表示 PTE 是否存在:如果沒有設置,對該頁的引用會引起異常(即不允許)。PTE_R 控制是否允許指令讀取該頁。PTE_W 控制是否允許指令向該頁寫入。PTE_X 控制 CPU 是否可以將頁面的內容解釋為指令并執行。PTE_U 控制是否允許用戶態下的指令訪問頁面;如果不設置 PTE_U, 對應 PTE 只能在內核態下使用。
要告訴硬件使用一個頁表,內核必須將對應根頁表頁的物理地址寫入 satp 寄存器中。每個 CPU 都有自己的 satp 寄存器。一個 CPU 將使用自己的 satp 所指向的頁表來翻譯后續指令產生的所有地址。每個 CPU 都有自己的 satp,這樣不同的 CPU 可以運行不同的進程,每個進程都有自己的頁表所描述的私有地址空間。
ii)地址空間
- 內核地址空間:如圖所示,QEMU 模擬的計算機包含 RAM( 物理內存),從物理地址 0x80000000 開始, 至少到 0x86400000,xv6 稱之為 PHYSTOP。QEMU 模擬還包括 I/O 設備,如磁盤接口。QEMU 將設備接口作為內存映射(memory-mapped)的控制寄存器暴露給軟件,這些寄存器位于物理地址空間的 0x80000000 以下。內核可以通過讀取/寫入這些特殊的物理地址與設備進行交互;這種讀取和寫入與設備硬件而不是與 RAM 進行通信。內核對RAM和內存映射的設備寄存器使用“直接映射”,也就是將這些資源映射到和它們物理地址相同的虛擬地址上,然而trampoline 頁與內核棧頁則使用了間接映射。
- 用戶地址空間:如圖所示,一個進程的用戶內存從虛擬地址 0 開始,可以增長到 MAXVA(kernel/riscv.h:348),原則上允許一個進程尋址 256GB 的內存。當一個進程要求 xv6 提供更多的用戶內存時,xv6 首先使用 kalloc 來分配物理頁,然后將指向新物理頁的 PTE 添加到進程的頁表中。Xv6 設置這些 PTE 的 PTE_W、PTE_X、PTE_R、PTE_U 和 PTE_V 標志。大多數進程不使用整個用戶地址空間;xv6 將不使用的 PTE 的 PTE_V 位保持為清除狀態。首先,不同的進程頁表將用戶地址轉化為物理內存的不同頁,這樣每個進程都有私有的用戶內存。第二,每個進程都認為自己的內存具有從零開始的連續的虛擬地址,而進程的物理內存可以是不連續的。第三,內核會映射帶有 trampoline 代碼的頁到用戶地址空間頂端,因此,有一物理內存頁在所有地址空間中都會出現。
(2)源代碼分析
- kernel/memlayout.h:該頭文件具體定義了riscv物理內存的布局,包括內核基址,物理內存上界trampoline頁地址等:
- kernel/kalloc.c:該文件用于進行物理內存分配,其核心結構是線程安全的空閑內存空間列表kmem:
與此相關的是兩個核心函數:kalloc()與kfree(),其中kalloc()函數從空閑鏈表頭去除一個空閑頁,清理后返回其指針,而kfree()函數則將參數指針指向的內存頁清理后重新插入空閑鏈表,等待下一次分配。
- kernel/vm.c:該文件是xv6進行虛擬內存管理的核心代碼,其維護了內核頁表kernel_pagetable,下面對一些重要的函數進行分析:
kvminit()函數按照memlayout.h規定的內存布局為內核初始化頁表,通過這種方式初始化內核地址空間。與此對應,uvmcreate()與uvminit()用于初始化用戶空間頁表。
mappages()函數則是用來進行內存映射的主要功能函數,給定頁表,虛擬地址va與物理地址pa,該函數在頁表中建立對應虛擬地址的PTE表項,將PTE_V位置為1。
walk()函數根據上文提到的頁表三層樹結構進行逐層遍歷,最終返回給定虛擬地址對應的PTE表項,該函數可以根據給定的alloc參數,在頁表項不存在時進行分配。
uvmalloc()用于為用戶進程頁表分配新的空間,uvmdealloc()則用來釋放多余的用戶空間。uvmcopy()函數用來完成父進程到子進程頁表的復制,在原始的實現中,該函數在完成頁表項復制的同時也會進行物理內存的完全復制,這種方式顯然帶來了過多的拷貝開銷。在改進的寫時復制(COW)實現方式中,我們不再進行實際物理內存的復制,取而代之的是將父子進程對應的頁表項標記為只讀的寫時復制頁,這樣當且僅當進程企圖進行寫操作時,這些頁才真正完成復制,成為進程的私有頁,大大減少了內存拷貝開銷,改進了fork()的效率。
除此之外,copyout()與copyin()用于在內核與用戶空間之間完成內存拷貝。
4.多線程(multithreading)
(1)基本原理
多線程并發一直是多處理器操作系統關心的問題,特別地,xv6為我們用戶級多線程的解決方案(在這里我們討論的是mit提供的lab:multithreading)。在該實現中,每個用戶級線程都擁有自己的上下文,線程棧與線程狀態。管理程序通過維護全局的線程控制數組與運行線程指針來完成用戶級的線程調度。
(2)源代碼分析
- user/uthread_switch.S:該文件定義了線程切換的匯編過程thread_switch,其中包括保存舊線程的callee_saved寄存器,棧指針寄存器sp,返回地址寄存器ra,以及恢復新線程的對應寄存器值。
- user/uthread.c:該文件定義了用戶線程的抽象數據結構,包括線程上下文tcontext,線程控制塊thread,控制塊數組all_thread,以及當前運行線程的控制塊指針current_thread:
比較重要的線程管理函數有線程創建函數thread_create(),線程調度函數thread_schedule(),其中thread_create()遍歷線程控制塊數組,找到當前空閑的控制塊,將其返回地址寄存器ra設置為給定的函數地址并將狀態設置為RUNNABLE以完成一個新線程的插入。thread_schedule()函數從當前運行線程的控制塊開始循環遍歷線程控制塊數組,找到下一個可運行的線程,一旦找到就進行線程切換,開始運行新線程。
5.鎖(lock)
(1)基本原理
線程安全問題是基于并發的多處理器操作系統面臨的核心挑戰。為了解決這類問題,鎖的創建與使用顯得至關重要。鎖提供了互斥的功能,確保一次只有一個CPU可以持有一個特定的鎖。如果程序員為每個共享數據項關聯一個鎖,并且代碼在使用某項時總是持有關聯的鎖,那么該項每次只能由一個CPU使用。在這種情況下,我們說鎖保護了數據項。雖然鎖是一種簡單易懂的并發控制機制,但其也帶來了性能降低的缺點,因為鎖將并發操作串行化了。
xv6提供了兩種類型的鎖:自旋鎖(spinlock)和睡眠鎖(sleeplock)。自旋鎖會讓CPU在鎖上自旋等待,可能會浪費大量CPU時間,而睡眠鎖則會在鎖被占用時主動讓出CPU,允許其他線程運行并進入阻塞態等待喚醒。因此自旋鎖最適合于短的關鍵部分,而睡眠鎖對長的操作很有效。
xv6對鎖的使用有粗粒度與細粒度兩種方式:作為粗粒度鎖的一個例子,xv6的kalloc.c分配器有一個單一的空閑列表,由一個單一的鎖構成。如果不同CPU上的多個進程試圖同時分配頁面,那么每個進程都必須通過在acquire中旋轉來等待輪到自己。旋轉會降低性能,因為這不是有用的工作。如果爭奪鎖浪費了相當一部分CPU時間,也許可以通過改變分配器的設計來提高性能,使其擁有多個空閑列表,每個列表都有自己的鎖,從而實現真正的并行分配。
作為細粒度鎖的一個例子,xv6為每個文件都有一個單獨的鎖,這樣操作不同文件的進程往往可以不用等待對方的鎖就可以進行。如果想讓進程模擬寫入同一文件的不同區域,文件鎖方案可以做得更細。最終,鎖的粒度決定需要由性能測量以及復雜性考慮來驅動。
(2)源代碼分析
- kernel/spinlock.h:定義了自旋鎖數據結構,其中locked字段用來區分鎖是否被占用:
- kernel/sleeplock.h:定義了睡眠鎖數據結構,其中含有一個spinlock用于保護對臨界變量的訪問:
- kernel/spinlock.c:該文件定義了自旋鎖相關操作,其中acquire()使用了可移植的C庫調用__sync_lock_test_and_set,它本質上為amoswap指令;返回值是lk->locked的舊(被交換出來的)內容。acquire函數循環交換,重試(旋轉)直到獲取了鎖。每一次迭代都會將1交換到lk->locked中,并檢查之前的值;如果之前的值為0,那么我們已經獲得了鎖,并且交換將lk->locked設置為1。如果之前的值是1,那么其他CPU持有該鎖,而我們原子地將1換成lk->locked并沒有改變它的值。
- kernel/sleeplock.c:該文件定義了睡眠鎖的相關操作,其中acquiresleep()在發現當前鎖被占用時調用sleep()主動放棄CPU,為其他線程提供運行機會,在整個過程中我們使用自旋鎖lk保護對臨界字段locked的訪問:
- kernel/kalloc.c:由于全局的空閑鏈表上只有一把大鎖,因此如果不同CPU上的多個進程試圖同時分配頁面,那么每個進程都必須通過在acquire中旋轉來等待輪到自己。為此,我們可以改進該結構,為每個CPU單獨分配一個空閑鏈表與對應的自旋鎖,這樣不同CPU的內存分配就不會再互相干擾:
- kernel/bio.c:該文件是磁盤塊緩存的管理單元,其中核心數據結構bcache使用一個大自旋鎖保護:
為了提高并行效率,我們可以改進該結構,使用基于哈希表的細粒度多自旋鎖:
struct {struct spinlock lock[NBUCKET];struct buf buf[NBUF];struct buf head[NBUCKET]; // hash buckets of linked list } bcache;這樣在bget()中為特定標號的磁盤塊尋找緩存塊時,首先將其哈希到對應的桶中,在對應的桶中完成查找空閑的buffer,如果沒找到則從其他桶中“竊取”,這種方式大大提高了磁盤塊緩存的并行性能。
6.文件系統(file system)
(1)基本原理
如圖所示,xv6文件系統的實現分為七層。disk層在virtio磁盤上讀寫塊。Buffer cache緩存磁盤塊,并同步訪問它們,確保一個塊只能同時被內核中的一個進程訪問。日志層允許上層通過事務更新多個磁盤塊,并確保在崩潰時,磁盤塊是原子更新的(即全部更新或不更新)。inode層將一個文件都表示為一個inode,每個文件包含一個唯一的i-number和一些存放文件數據的塊。目錄層將實現了一種特殊的inode,被稱為目錄,其包含一個目錄項序列,每個目錄項由文件名稱和i-number組成。路徑名層提供了層次化的路徑名,如/usr/rtm/xv6/fs.c,可以用遞歸查找解析他們。文件描述符層用文件系統接口抽象了許多Unix資源(如管道、設備、文件等),使程序員的生產力得到大大的提高。
文件系統必須安排好磁盤存儲inode和內容塊的位置。為此,xv6將磁盤分為幾個部分,如圖所示。文件系統不使用塊0(它存放boot sector)。第1塊稱為superblock,它包含了文件系統的元數據(以塊為單位的文件系統大小、數據塊的數量、inode的數量和日志中的塊數)。從塊2開始存放著日志。日志之后是inodes,每個塊會包含多個inode。在這些塊之后是位圖塊(bitmap),記錄哪些數據塊在使用。其余的塊是數據塊,每個數據塊要么在bitmap塊中標記為空閑,要么持有文件或目錄的內容。超級塊由一個單獨的程序mkfs寫入,它建立了一個初始文件系統。
(2)源代碼分析
- kernel/fs.h:該文件定義了磁盤上的文件系統格式,其中包括超級磁盤塊superblock,磁盤inode結構dinode,目錄項結構dirent:
在原始的實現中,dinode使用了直接映射與一級間接映射結合的方式,addrs數組前11項直接指向映射的磁盤塊地址,而最后一項指向一級索引表的磁盤地址,這種方式最多支持12+BSIZE/sizeof(uint)12 + BSIZE / sizeof(uint)12+BSIZE/sizeof(uint)個磁盤塊大小的文件。
- kernel/fs.c:該文件是xv6文件系統的具體實現,包含許多低層次的文件操作接口。其中定義了用于緩存inode結構的cache:
ialloc()函數在磁盤上分配給定類型的dinode塊并返回可用的inode結構,iget()函數用于獲取給定序號的inode緩存,ilock()與iunlock()用于管理inode上的鎖結構。bmap()函數基于上面提到的直接與一級索引結構,查找inode中給定塊號的磁盤塊地址,而readi()與writei()函數基于bmp()完成給定inode塊對應文件的讀寫操作。
- kernel/file.h:該文件內存中的inode結構,其內容基本是磁盤上dinode塊的拷貝,此外基于此定義了虛擬file結構:
- kernel/file.c:該文件定義了全局文件表ftable與操作系統管理文件結構的相關功能函數,包括讀寫文件的fileread()與filewrite(),獲取文件元數據的filestat()等:
- kernel/sysfile.c:該文件定義了文件系統相關的系統調用,包括打開關閉調用sys_open()與sys_close(),讀寫調用sys_read()與sys_write(),重定向調用sys_dup(),文件鏈接調用sys_link(),以及建立管道調用sys_pipe()與程序加載調用sys_exec()等。
結語
作為簡化版Unix操作系統,xv6中包含了操作系統各項核心功能(進程調度,內存管理,文件系統等)簡單而有效的實現。閱讀源代碼,結合相關資料進行對比分析,可以幫助我們更加深入而全面地了解操作系統的設計原則與核心精神。
參考資料
[1]xv6-riscv (https://github.com/mit-pdos/xv6-riscv)
[2]xv6-riscv-book (https://pdos.csail.mit.edu/6.S081/2020/xv6/book-riscv-rev1.pdf)
[2]xv6-riscv-labs (https://pdos.csail.mit.edu/6.828/2020/xv6.html)
總結
以上是生活随笔為你收集整理的【xv6 RISC-V】xv6操作系统原理解析与源代码阅读报告的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 安装inde.html使用babel,r
- 下一篇: 为啥JAVA虚拟机不开发系统_理解Jav