Unix文件系統
當今的Unix文件系統(Unix File System, UFS)起源於Berkeley Fast File System。和所有的文件系統一樣,Unix文件系統是以塊(Block)為單位對磁盤進行讀寫的。一般而言,一個塊的大小為512Byte或者4KB。文件系統的所有數據結構都以塊為單位存儲在硬盤上,一些典型的數據塊包括:superblock, inode, data block, directory block and indirection block。
Superblock包含了關於整個文件系統的元信息(metadata),比如文件系統的類型、大小、狀態和關於其他文件系統數據結構的信息。Superblock對文件系統是非常重要的,因此Unix文件系統的實現會保存多個Superblock的副本。
inode是Unix文件系統中用於表示文件的抽象數據結構。inode不僅是指抽象了一組硬盤上的數據的"文件”,目錄和外部IO設備等也會用inode數據結構來表示。inode包含了一個文件的元信息,比如擁有者、訪問權限、文件類型等等。對於一個文件系統里的所有文件,文件系統會維護一個inode列表,這個列表可能會占據一個或者多個磁盤塊。
Data block用於存儲實際的文件數據。一些文件系統中可能會存在用於存放目錄的Directory Block和Indirection Block,但是在Unix文件系統中這些文件塊都被視為數據,上層文件系統通過inode對其加以操作,他們唯一的區別是inode里記錄的屬性有所不同。
Xv6中的文件系統設計思想與Unix大抵相同,但是實現細節多有簡化。在底層實現上,Xv6采用與Linux類似的分層實現思路,層層向上逐級封裝,以便能支持多種多樣的設備和IO方式。Xv6的文件系統包含了磁盤IO層、Log層、Inode層、File層和系統調用層,下面會依次介紹其實現,
Xv6中的磁盤IO
Xv6中的磁盤IO在ide.c
中,這是一個基於Programmed IO的面向IDE磁盤的簡單實現。一個Xv6中的磁盤讀寫請求用如下的數據結構表示
struct buf {
int flags;
uint dev;
uint blockno;
struct sleeplock lock;
uint refcnt;
struct buf *prev; // LRU cache list
struct buf *next;
struct buf *qnext; // disk queue
uchar data[BSIZE];
};
其中,對IDE磁盤而言,需要關心的域是flags
(DIRTY, VALID),dev
(設備),blockno
(磁盤塊編號)和next
(指向隊列的下一個成員的指針).
磁盤讀寫實現的思路是這樣的:Xv6會維護一個進程請求磁盤操作的隊列(idequeue
)。當進程請求磁盤讀寫時,請求會被加入隊列,進程會進入睡眠狀態(iderw()
)。任何時候,隊列的開頭表示當前正在進行的磁盤讀寫請求。當一個磁盤讀寫操作完成時,會觸發一個中斷,中斷處理程序(ideintr()
)會移除隊列開頭的請求,喚醒隊列開頭請求所對應的進程。如果還有后續的請求,就會將其移到隊列開頭,開始處理下一個磁盤請求。
磁盤請求隊列的聲明如下,當然對其訪問是必須加鎖的。
static struct spinlock idelock;
static struct buf *idequeue;
ide.c
中函數及其對應功能如下
函數名 | 功能 |
---|---|
idewait() |
等待磁盤進入空閑狀態 |
ideinit() |
初始化IDE磁盤IO |
idestart() |
開始一個磁盤讀寫請求 |
iderw() |
上層文件系統調用的磁盤IO接口 |
ideintr() |
當磁盤請求完成后中斷處理程序會調用的函數 |
操作系統啟動時,main()
函數會調用ideinit()
對ide
磁盤進行初始化,初始化函數中會初始化ide鎖,設定磁盤中斷控制,並檢查是否存在第二個磁盤。
iderw()
函數提供了面向頂層文件系統模塊的接口。iderw()
既可用於讀,也可用於寫,只需通過判斷buf->flag
里的DIRTY位和VALID位就能判斷出請求是讀還是寫。如果請求隊列為空,證明當前磁盤不是工作狀態,那么就需要調用idestart()
函數初始化磁盤請求隊列,並設置中斷。如果請求是個寫請求,那么idestart()
中會向磁盤發出寫出數據的指令。之后,iderw()
會將調用者陷入睡眠狀態。
當磁盤讀取或者寫操作完畢時,會觸發中斷進入trap.c
中的trap()
函數,trap()
函數會調用ideintr()
函數處理磁盤相關的中斷。在ideintr()
函數中,如果當前請求是讀請求,就讀取目前已經在磁盤緩沖區中准備好的數據。最后,ideintr()
會喚醒正在睡眠等待當前請求的進程,如果隊列里還有請求,就調用idestart()
來處理新的請求。
Buffer Cache的功能與實現
在文件系統中,Buffer Cache擔任了一個磁盤與內存文件系統交互的中間層。由於對磁盤的讀取是非常緩慢的,因此將最近經常訪問的磁盤塊緩存在內存里是很有益處的。
Xv6中Buffer Cache的實現在bio.c中,Buffer Cache的數據結構如下(rev11版本)
struct {
struct spinlock lock;
struct buf buf[NBUF];
// Linked list of all buffers, through prev/next.
// head.next is most recently used.
struct buf head;
} bcache;
此數據結構在固定長度的數組上維護了一個由struct buf
組成的雙向鏈表,並且用一個鎖來保護對Buffer Cache鏈表結構的訪問。值得注意的是,對鏈表結構的訪問和對一個struct buf
結構的訪問需要的是不同的鎖。
在緩存初始化時,系統調用binit()
對緩存進行初始化。binit()
函數對緩存內每個元素初始化了睡眠鎖,並從后往前連接成一個雙向鏈表。一開始的時候,緩存內所有的塊都是空的。
上層文件系統使用bread()
和bwrite()
對緩存中的磁盤塊進行讀寫。關於緩存的全部操作是在bread()
與bwrite()
中自動完成的,不需要上層文件系統的參與。
bread()
會首先調用bget()
函數,bget()
函數會檢查請求的磁盤塊是否在緩存中。如果在緩存中,那么直接返回緩存中對應的磁盤塊即可。如果不在緩存中,那么需要先使用最底層的iderw()
函數先將此磁盤塊從磁盤加載進緩存中,再返回此磁盤塊。
bget()
函數的實現有一些Tricky。搜索緩存塊的代碼非常直接,但是在其中必須仔細考慮多進程同時訪問磁盤塊時的同步機制。在Xv6 rev7版本中由於沒有實現睡眠鎖,為了避免等待的緩沖區在等待的過程中改變了內容,必須在從鎖中醒來時重新掃描磁盤緩沖區尋找合適的磁盤塊,但是在rev11版本中由於實現了睡眠鎖,在找到對應的緩存塊時,只需釋放對Buffer Cache的鎖並拿到與當前緩存塊有關的睡眠鎖即可。
bwrite()
函數直接將緩存中的數據寫入磁盤。Buffer Cache層不會嘗試執行任何延遲寫入的操作,何時調用bwrite()
寫入磁盤是由上層的文件系統控制的。
上層文件系統調用brelse()
函數來釋放一塊不再使用的沖區。brelse()
函數中主要涉及的是對雙向鏈表的操作,在此不再贅述。
Log層的功能與實現
在文件系統中添加Log層是為了能夠使得文件系統能夠處理諸如系統斷電之類的異常情況,避免磁盤上的文件系統出現Inconsistency。Log層的實現思路是這樣的,對於上層文件系統的全部磁盤操作,將其分割為一個個transaction,每個transaction都會首先將數據和其對應磁盤號寫入磁盤上的Log區域,並且只有在Log區域寫入全部完成后,再將Log區域的數據寫入真正存儲的數據區域。通過這種設計,如果在寫入Log的時候斷電,那么文件系統會當做這些寫入不存在,如果在寫入真正區域的時候斷電,那么Log區域的數據可以用於恢復文件系統。如此,就可以避免文件系統中文件的損壞。
在Xv6 rev7的文件系統實現中,不允許多個進程並發地向Log層執行transaction,然而rev11的實現有所不同,允許多個進程並發地向Log層執行transaction。以下對實現細節的討論基於rev11版本。
上層文件系統在使用log層時,必須首先調用begin_op()
函數。begin_op()
函數會記錄一個新的transaction信息。在使用完log層后,上層系統必須調用end_op()
函數。只有當沒有transaction在執行時,log才會執行真正的磁盤寫入。真正的磁盤寫入操作在commit()
函數中,可以看到commit()
函數只有在end_op()
結束,log.outstanding==0
時才會被調用(以及開機的時刻)。commit()
函數會先調用write_log()
函數將緩存里的磁盤塊寫到磁盤上的Log區域里,並將Log Header寫入到磁盤區域。只有當磁盤里存在Log Header的區域數據更新了,這一次Log更新才算完成。在Log區域更新后,commit()
函數調用install_trans()
完成真正的磁盤寫入步驟,在這之后調用write_head()
函數清空當前的Log數據。
XV6 文件系統的硬盤布局
在Xv6操作系統的硬盤中,依次存放了如下幾個硬盤塊。對這些硬盤塊的索引是直接使用一個整數來進行的,
[boot block | super block | log | inode blocks | free bit map | data blocks]
第一個硬盤塊boot block會在開機的時候被加載進內存,磁盤塊編號是0。第二個superblock占據了一個硬盤塊,編號是1,在Xv6中的聲明如下
struct superblock {
uint size; // Size of file system image (blocks)
uint nblocks; // Number of data blocks
uint ninodes; // Number of inodes.
uint nlog; // Number of log blocks
uint logstart; // Block number of first log block
uint inodestart; // Block number of first inode block
uint bmapstart; // Block number of first free map block
};
Superblock中存儲了文件系統有關的元信息。操作系統必須先讀入Super Block才知道剩下的log塊,inode塊,bitmap塊和datablock塊的大小和位置。在Superblock之后順序存儲了多個log塊、多個inode塊、多個bitmap塊。磁盤剩余的部分存儲了data block塊。
XV6中的文件
Xv6中的文件(包括目錄)全部用inode數據結構加以表示,所有文件的inode都會被存儲在磁盤上。系統和進程需要使用某個inode時,這個inode會被加載到inode緩存里。存儲在內存里的inode會比存儲在磁盤上的inode多一些運行時信息。內存里的inode數據結構聲明如下。
// in-memory copy of an inode
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
struct sleeplock lock; // protects everything below here
int valid; // inode has been read from disk?
short type; // copy of disk inode
short major;
short minor;
short nlink;
uint size;
uint addrs[NDIRECT+1];
};
其中,inode.type
指明了這個文件的類型。Xv6中,這個類型可以是普通文件,目錄,或者是特殊文件。
內核會在內存中維護一個inode緩存,緩存的數據結構聲明如下
struct {
struct spinlock lock;
struct inode inode[NINODE];
} icache;
對於Inode節點的基本操作如下
函數名 | 功能 |
---|---|
iinit() |
讀取Superblock,初始化inode相關的鎖 |
ialloc() |
在磁盤上分配一個inode |
iupdate() |
將內存里的一個inode寫入磁盤 |
iget() |
獲取指定inode,更新緩存 |
iput() |
對內存內一個Inode引用減1,引用為0則釋放inode |
ilock() |
獲取指定inode的鎖 |
iunlock() |
釋放指定inode的鎖 |
readi() |
往inode讀數據 |
writei() |
往inode寫數據 |
bmap() |
返回inode的第n個數據塊的磁盤地址 |
一個Inode有12(NDIRECT
)個直接映射的磁盤塊,有128個間接映射的磁盤塊,這些合計起來,Xv6系統支持的最大文件大小為140*512B=70KB。
Xv6系統中的文件描述符
Unix系統一個著名的設計哲學就是"Everything is a file",這句話更准確地說是"Everything is a file descriptor"。上文所提的inode數據結構用於抽象文件系統中的文件和目錄,而文件描述符除了抽象文件之外,還能抽象包含Pipe、Socket之類的其他IO,成為了一種通用的I/O接口。
Xv6中,一個文件的數據結構表示如下
struct file {
enum { FD_NONE, FD_PIPE, FD_INODE } type;
int ref; // reference count
char readable;
char writable;
struct pipe *pipe;
struct inode *ip;
uint off;
};
從中可見,一個file數據結構既可以表示一個inode,也可以表示一個pipe。多個file數據結構可以抽象同一個inode,但是Offset可以不同。
系統所有的打開文件都在全局文件描述符表ftable
中,ftable
數據結構的聲明如下
struct {
struct spinlock lock;
struct file file[NFILE];
} ftable;
從中可以看出Xv6最多支持同時打開100(NFILE
)個文件,從struct proc
中可以看出Xv6中每個進程最多同時可以打開16(NOFILE
)個文件。
對File數據結構的基本操作包括filealloc()
, filedup()
, fileclose()
, fileread()
, filewrite()
和filestat()
。命名風格與Unix提供的接口一致,因此從名字很容易就能看出其基本功能。
對於Inode類型的file而言,上述操作的實現依賴於inode的諸如iread()
,iwrite()
等基本操作。
Xv6中文件相關的系統調用
利用上一層的實現,大多數系統調用的實現都是比較直接的。Xv6中支持的文件相關系統調用列表如下
名稱 | 功能 |
---|---|
sys_link() |
為已有的inode創建一個新的名字 |
sys_unlink() |
為已有的inode移除一個名字,可能會移除這個inode |
sys_open() |
打開一個指定的文件描述符 |
sys_mkdir() |
創建一個新目錄 |
sys_mknod() |
創建一個新文件 |
sys_chdir() |
改變進程當前目錄 |
sys_fstat() |
改變文件統計信息 |
sys_read() |
讀文件描述符 |
sys_write() |
寫文件描述符 |
sys_dup() |
增加文件描述符的引用 |
絕大多數系統調用的語義都與Unix標准相同。