Lab5
實驗的目的在於:
- 了解文件系統的基本概念和作用
- 了解普通磁盤的基本結構和讀寫方式
- 了解實現設備驅動的方法
- 掌握並實現文件系統服務的基本操作
- 了解微內核的基本設計思想和結構
為了避免同志們坐享其成,所有代碼均取自[login學長的開源代碼](login256/BUAA-OS-2019: 北航OS課課設代碼 (github.com)),為方便理解,做少量注釋,可以理解該篇為Lab 5任務導讀(x
什么是文件系統?
文件系統的出現是為了更好地管理在不易失存儲介質上存放的數據,而通常來說這種外部不易失的存儲設備就是磁盤。文件系統將磁盤上的數據抽象化,使得用戶能夠很方便地訪問數據而無需關心具體和磁盤之間的交互。
注意,文件系統是高度抽象性的,它是管理數據的抽象的界面,而背后實際的數據存儲形式是對用戶來說不可見的。因此,文件系統一方面需要面向復雜多樣的數據存儲媒介,一方面需要面向用戶提供簡潔統一的接口。
同時,文件系統也可以不僅是文件系統,諸如proc這樣的虛擬文件系統還可以實現Windows中任務管理器的功能,這取決於你如何定義一個文件是什么。
有關本次實驗的具體問題
本次實驗中提到的文件系統既是指磁盤文件系統,又是指操作系統上的文件系統。注意,磁盤文件系統是在磁盤驅動器上而言的,而操作系統的文件系統是針對操作系統而言的,兩者的結構即使一致,其在磁盤和內存上的表示也會有一定差異,需要注意區分。
本次我們需要分三步實現文件系統:
- 實現磁盤的用戶態驅動程序
- 實現磁盤上和操作系統上的文件系統結構,並在調用驅動程序的基礎上實現文件系統操作相關函數
- 提供接口和機制使得用戶態下也能夠使用文件系統
一些基本概念的補充
為什么說操作系統和磁盤上文件系統不同也能進行正常使用:舉例,Linux使用的是VFS文件系統,但是可以與Ext4等多種文件系統的磁盤驅動器正常通訊。理論上,磁盤文件系統不同的磁盤上,數據的組織方式不同,按統一的方式去訪問肯定不行。但是Linux提出的VFS(Virtual Filesystem Switch)是一個虛擬的文件系統,其將其他不同文件系統分別進行解釋,然后以統一的方式進行管理,由此實現了對於用戶來說完全一致的效果。這恰恰反映了文件系統的抽象性。
磁盤驅動程序:位於操作系統中的一段代碼,與操作系統高度相關,描述了對應磁盤驅動器的信息和提供操作接口。操作系統需要通過驅動程序才能與磁盤驅動器通信。
磁盤與磁盤驅動器:磁盤是一個物理結構,用於存儲信息,而磁盤驅動器是用於控制磁盤旋轉、讀取的機構。操作系統需要通過驅動程序才能和磁盤驅動器交流,而磁盤驅動器再去磁盤上尋找對應信息。
三點幾嘞,寫個磁盤驅動先啦
本次實驗中我們使用內存映射I/O技術(MMIO)來實現一個IDE磁盤的驅動。IDE具體的意思是Integrated Driver Electronics,字面意思指這種磁盤的控制器和盤體集合在一起,但是SATA磁盤也是這樣的結構,二者主要區別在於接口串行和並行。不過這和我們的實驗沒有什么關系。
另外需要說明的一點是,本次的驅動程序完全運行在用戶態下,這是需要兩個新的系統調用sys_write_dev
和sys_read_dev
支持的,它們實現了用戶態下對設備的讀寫。
sys_write_dev
// lib/syscall_all.c
int sys_write_dev(int sysno, u_int va, u_int dev, u_int len)
{
int cnt_dev = 3; // 支持三個設備
u_int dev_start_addr[] = {0x10000000, 0x13000000, 0x15000000}; // 每個設備對應的物理地址
u_int dev_length[] = {0x20, 0x4200, 0x200}; // 每個設備對應的空間長度
u_int target_addr = dev + 0xa0000000; // 計算出對應的內核虛擬地址
int i;
int checked = 0;
//do check:
if (va >= ULIM)
{
return -E_INVAL;
}
for (i = 0; i < cnt_dev; i++)
{
if (dev_start_addr[i] <= dev && dev + len - 1 < dev_start_addr[i] + dev_length[i])
{
checked = 1; // 表示確認了往這個地址寫是在允許范圍內的
break;
}
}
if (checked == 0)
{
return -E_INVAL;
}
//do copy
bcopy((void *)va, (void *)target_addr, len); // 從va處向目標處復制,完成寫入
return 0;
}
sys_read_dev
// lib/syscall_lib.c
int sys_read_dev(int sysno, u_int va, u_int dev, u_int len)
{
int cnt_dev = 3;
u_int dev_start_addr[] = {0x10000000, 0x13000000, 0x15000000};
u_int dev_length[] = {0x20, 0x4200, 0x200};
u_int target_addr = dev + 0xa0000000;
int i;
int checked = 0;
//do check:
if (va >= ULIM)
{
return -E_INVAL;
}
for (i = 0; i < cnt_dev; i++)
{
if (dev_start_addr[i] <= dev && dev + len - 1 < dev_start_addr[i] + dev_length[i])
{
checked = 1;
break;
}
}
if (checked == 0)
{
return -E_INVAL;
}
//do copy
bcopy((void *)target_addr, (void *)va, len); // 和sys_write_dev 唯一的不同
return 0;
}
內存映射I/O MMIO
硬件設備上具有一些寄存器,CPU通過讀寫這些寄存器來和硬件設備進行通信,因此這些寄存器被稱為I/O端口。而這些寄存器並不是直接以寄存器的方式展現給CPU的,而是映射到內存的某個位置。當CPU讀寫這塊內存的時候,實際上讀寫了相應的I/O端口。而操作系統怎么知道不同的外設映射到具體哪個位置呢?實際上這需要在系統啟動后,由BIOS告知。
在MIPS結構中,這種機制更為簡單。其在kseg0和kseg1段里從硬件的層次可預知地實現了物理地址到內核虛擬地址的轉換,這使得所有I/O設備都可以存放在這段空間里,並通過確定的映射關系計算出對應的物理地址。而我們用kseg1來進行轉換,而不用kseg0,因為kseg0需要經過cache緩存,導致不可預知的問題。
進一步,在我們的實驗中,模擬器中I/O設備的物理地址是完全固定的,我們的驅動程序就只需要對特定內核虛擬地址進行讀寫即可。
驅動程序編寫
由於驅動程序的編寫實際上就是對特定地址進行讀寫,我們需要清楚兩個主要問題:
- 往哪里寫?從哪里讀?
- 讀/寫對應的數據的意義是什么?
這兩個是和硬件有關的,所幸的是指導書中已經給了出來,Gxemul中IDE磁盤的基址是0x13000000(注意這是物理地址)。
偏移量 | 說明 |
---|---|
0x0000 | 向這個地址寫入的32位u_int,將指定下一次讀/寫操作相對於磁盤起始地址的偏移量的低32位(以字節計) |
0x0008 | 向這個地址寫入的32位u_int,將指定下一次讀/寫操作相對於磁盤起始地址的偏移量的高32位(以字節計) |
0x0010 | 向這個地址寫入的32位u_int,將指定具體的磁盤ID(本實驗中這個值始終為0) |
0x0020 | 向這個地址寫入的8位u_char,將指定需要進行的操作類型,0為讀,1為寫 |
0x0030 | 從這個位置讀取的8位u_char,將反映上一次操作的執行狀態,0表示失敗,否則為成功 |
0x4000 - 0x41ff | 進行讀操作時,當讀取成功后,從這個區間讀出的512個字節,將反映從指定位置讀出的數據;進行寫操作時,當寫入成功,這512個字節將被寫入指定位置 |
當對其進行操作時,正如我們上文提到的,需要通過kseg1區進行地址轉換,由此我們需要訪問0xB3000000,從而利用kseg1區的地址轉換機構成功訪問0x13000000。由此我們成功解釋了如何通過MIPS指令就能操作磁盤,接下來我們需要以此編寫具體的操作函數。
ide_write
void
ide_write(u_int diskno, u_int secno, void *src, u_int nsecs)
{
u_int offset_begin = secno * 0x200; // 根據起始扇區號計算出起始偏移量,一個扇區512個字節
u_int offset_end = offset_begin + nsecs * 0x200; // 根據讀取扇區數量計算出終止偏移量
u_int offset = 0; // 初始化循環遞增量
u_int dev_addr = 0x13000000;
u_char status = 0;
u_char write_value = 1;
writef("diskno: %d\n", diskno);
while (offset_begin + offset < offset_end) {
// 每個循環操作512個字節,即一個扇區
u_int now_offset = offset_begin + offset;
if (syscall_write_dev((u_int)&diskno, dev_addr + 0x10, 4) < 0)
{
user_panic("ide_write error!");
}
if (syscall_write_dev((u_int)&now_offset, dev_addr + 0x0, 4) < 0)
{
user_panic("ide_write error!");
}
if (syscall_write_dev((u_int)(src + offset), dev_addr + 0x4000, 0x200) < 0)
{
user_panic("ide_write error!");
}
if (syscall_write_dev((u_int)&write_value, dev_addr + 0x20, 1) < 0)
{
user_panic("ide_write error!");
}
status = 0;
if (syscall_read_dev((u_int)&status, dev_addr + 0x30, 1) < 0)
{
user_panic("ide_write error!");
}
if (status == 0)
{
user_panic("ide write faild!");
}
offset += 0x200;
}
//writef("ide_write %x %s\n", offset_begin, src);
}
ide_read
void
ide_read(u_int diskno, u_int secno, void *dst, u_int nsecs)
{
// 0x200: the size of a sector: 512 bytes.
// 除了讀寫行為不同,其他邏輯和ide_write一致
u_int offset_begin = secno * 0x200;
u_int offset_end = offset_begin + nsecs * 0x200;
u_int offset = 0;
u_int dev_addr = 0x13000000;
u_char status = 0;
u_char read_value = 0;
while (offset_begin + offset < offset_end) {
u_int now_offset = offset_begin + offset;
if (syscall_write_dev((u_int)&diskno, dev_addr + 0x10, 4) < 0)
{
user_panic("ide_read error!");
}
if (syscall_write_dev((u_int)&now_offset, dev_addr + 0x0, 4) < 0)
{
user_panic("ide_read error!");
}
if (syscall_write_dev((u_int)&read_value, dev_addr + 0x20, 1) < 0)
{
user_panic("ide_read error!");
}
status = 0;
if (syscall_read_dev((u_int)&status, dev_addr + 0x30, 1) < 0)
{
user_panic("ide_read error!");
}
if (status == 0)
{
user_panic("ide read faild!");
}
if (syscall_read_dev((u_int)(dst + offset), dev_addr + 0x4000, 0x200) < 0)
{
user_panic("ide_read error!");
}
offset += 0x200;
}
//writef("ide_read %x %s\n", offset_begin, dst);
}
吶,文件系統始まる!
文件系統,從根本上來說是一種規范。我們實現的磁盤驅動只是往磁盤讀寫特定的二進制數據,而不管這些數據是如何組織的,也不管這些數據的意義是什么。而文件系統就是一套說明這些數據組織的邏輯,更實際一點,就是如何划分和解釋磁盤的空間,這也是文件系統結構的根本意義。
我們的MOS的文件系統將磁盤按下面這種方式進行划分:
我們將磁盤描述為若干個磁盤塊(Block),每個Block大小為4KB。
對於N個磁盤塊的磁盤,其第一個磁盤塊用於啟動和存放分區表,第二個磁盤塊整個都被分給了超級塊(super block)。它超級在哪里呢?其負責存放整個文件系統的基本信息:磁盤大小、根目錄位置等。
相關機制和數據結構設計
這里課程組有一個鍋,其指導書混淆了磁盤塊(Block,4KB)、扇區(512B)和文件控制塊(sizeof(struct File) = 256B)。如果你是學弟學妹的話,可以留意一下指導書有沒有改。我已經向助教提出了這個問題,學弟學妹們可以注意一下。
超級塊 Super
我們使用一個數據結構來描述超級塊:
struct Super {
u_int s_magic; // Magic number: FS_MAGIC,用於識別文件系統類型
u_int s_nblocks; // 總磁盤塊數量,MOS中為1024
struct File s_root; // 一個文件控制塊,表示根目錄位置
};
注意到雖然超級塊的數據結構並不大,但是磁盤中整整分配給它了一整個磁盤塊(4KB大小)。
文件控制塊 File
File就是我們定義的文件控制塊:
struct File {
u_char f_name[MAXNAMELEN]; // filename
u_int f_size; // file size in bytes
u_int f_type; // file type,分為FILE_REG(普通文件)和FILE_DIR(目錄文件)
u_int f_direct[NDIRECT]; // 文件的直接指針,其數值表示磁盤中特定磁盤塊號,NDIRECT = 10,即可代表至多10個磁盤塊,共40KB的文件大小
u_int f_indirect; // 表示一個間接磁盤塊的塊號,其中存儲了指向文件內容的磁盤塊的直接指針(此處我們規定不使用間接磁盤塊中的前10個直接指針)
struct File *f_dir; // 指向文件所屬的目錄文件
u_char f_pad[BY2FILE - MAXNAMELEN - 4 - 4 - NDIRECT * 4 - 4 - 4]; // 占位,為了使得一個struct File恰好占據256字節(BY2FILE = 256)
};
兩個File結構體恰好占一個扇區。
磁盤塊管理位圖 nbitblock
我們在Lab 5中使用位圖法來管理磁盤塊,這有別於使用鏈表法進行管理。在本實驗中,相應位為1表示空閑,反之為占用。注意,我們在這里使用位圖法,也就是每一位都是對應一個磁盤塊的。而實際上nbitblock
是一個字節數組,其中每個元素都是8位,稱之為一個位圖塊。因此在初始化時,注意有的位圖塊(8位)的后面幾個位不一定有實際的磁盤塊與之對應,所以不能將其初始化為空閑。
文件系統進程空間 與 塊緩存機制
我們的文件系統是在用戶空間內的一個進程,其擁有4GB的進程空間,而這個空間是我們實現和磁盤數據交流的一個重要中介。我們將所有磁盤塊都按一定規則映射到這各進程空間內,而當我們需要往磁盤寫數據時,就從這個進程空間取數據,而讀數據時就往這個進程空間存放數據。
注意這個文件系統進程空間和傳統的進程空間不同,其將DISKMAP~DISKMAP+DISKMAX(0x10000000~0x4fffffff)這一大段空間作為緩沖區,當對硬盤上特定磁盤塊Block[id]進行讀寫時,其唯一對應於這塊緩沖區中一塊512字節的空間,需要寫入的數據會存放在這塊空間里等候寫入,讀出來的信息也會放在這塊空間里等候發送給用戶進程。這就是塊緩存。
而這個進程空間本身又是由虛擬內存管理來實現的,每個block在沒有被觸發時只是在理論上占有一個虛擬空間,而當使用時會動態分配一個物理頁給它(我們會發現一個磁盤塊大小恰為一個物理頁)。
由此我們的文件系統實際上與三個數據結構有較大聯系:
- 磁盤塊管理位圖 nbitblock
- 磁盤塊中的文件控制塊 File
- 文件系統進程空間映射關系(實際上是管理其的頁表結構)
訪問文件的中經歷了什么?
訪問一個文件,首先要找到其對應的文件控制塊結構。該過程首先需要經過從根目錄文件的逐級查找。而找到對應文件控制塊后,則可以通過其中的指針找到對應的磁盤塊,從而利用驅動程序訪問到指定數據。
而文件系統在我們的操作系統中作為獨立的進程存在,其通過進程間IPC的方式來服務於用戶進程,其為服務所開放的函數中存儲在fs/serv.c中。用戶進程需要調用user/file.c中的函數實現文件系統操作,而其底層調用user/fsipc.c中的函數來實現和文件系統進程的IPC。
代碼的分布和調用邏輯
以打開一個文件並獲取其數據為例,用戶進程需要調用file.c中的open
函數,其中調用了fsipc.c中的fsipc_open
和fsipc_map
。
用戶進程調用fsipc_open
來將文件路徑path和打開方式mode打包進一個特殊的數據結構中,然后通過IPC發送給文件系統,文件系統返回一個用於描述該文件的文件描述塊(struct Fd,其中包含對應的文件控制塊id、文件大小等)。
用戶進程調用fsipc_map
來通過指定文件控制塊fileid和偏移量(以字節計),來獲取指定位置的磁盤塊中的數據。其打包發送給文件系統進程后,文件系統通過fileid找到對應文件,再通過offset找到對應磁盤塊。磁盤塊數據恰好1頁大小,正好能夠通過我們的IPC機制通訊傳送回到用戶進程。有趣的是這里面磁盤塊數據涉及多次映射,一次是從磁盤中映射到文件系統進程的指定位置,一次是在IPC過程中從文件系統映射回用戶進程。
文件系統進程中的函數
fs.c
fs.c文件定義了有關磁盤塊和文件的一系列操作,主要包含兩個大方面:
磁盤塊管理:
上圖中表明了磁盤塊管理相關函數的調用關系:
- 綠色框內為基礎檢查/映射函數,不參與實際管理,僅被其他函數調用,故忽略調用關系
- 紅色為系統調用
- 亮紫色為驅動程序
-
磁盤塊位圖的管理:
alloc_block_sum
:遍歷bitmap,找到的第一個空閑的磁盤塊,然后將其寫入磁盤(DEBUG:沒看懂login為什么這么寫,我得寫個函數調用圖),然后返回磁盤塊號alloc_block
:調用alloc_block_sum
並為獲得的新的磁盤塊分配一個物理頁面free_block
:在位圖中標記一個磁盤塊為空block_is_free
:檢查位圖來判斷是否是空閑的
-
磁盤塊在文件系統進程空間的映射管理
-
diskaddr
:實現從磁盤塊號到對應虛擬地址的映射 -
map_block
:調用syscall_mem_alloc
為該磁盤塊分配對應的物理頁面並添加進入頁表 -
unmap_block
:檢查是否需要將該磁盤塊內容寫入磁盤(取決於是否dirty),再調用syscall_mem_unmap
釋放物理頁面 -
va/block_is_mapped/dirty
:通過查詢頁表來檢查其是否已經被分配了物理頁面/因更改而變dirty,通過檢查權限位實現
-
-
磁盤塊對應虛擬地址空間到磁盤的通過驅動程序支持的讀寫管理:
read_block
:從磁盤中讀出指定磁盤塊對應的數據並存放在對應的虛擬空間里write_block
:從虛擬空間中讀出需要寫入磁盤的數據並寫入
文件控制塊管理:
文件控制塊管理囊括大量的文件操作函數,在這里不一一細講。大部分函數都由課程組實現了,同學們一定要自己看一下。
-
file_block_walk
:通過一個文件控制塊指針和一個整數filebno,去找到該文件中第filebno個4KB起始位置對應的磁盤塊號。這個函數涉及直接指針和間接指針,注意查找邏輯。 -
file_map_block
:將file_block_walk
進行包裝,當alloc==1時,如果沒找到對應的磁盤塊號,則調用alloc_block
新建一個。 -
file_clear_block
:將某個磁盤塊從文件中移除 -
file_get_block
:讀取文件特定磁盤塊的信息 -
file_dirty
:將該文件特定磁盤塊設置為dirty -
dir_lookup
:根據一個指向目錄文件的文件控制塊指針dir,找到特定名字的文件對應的文件控制塊。……
其中dir_lookup
函數需要我們自己實現:
int
dir_lookup(struct File *dir, char *name, struct File **file)
{
int r;
u_int i, j, nblock;
void *blk;
struct File *f;
nblock = ROUND(dir->f_size, BY2BLK) / BY2BLK; // 根據目錄大小計算出內部磁盤塊的數量
for (i = 0; i < nblock; i++) {
r = file_get_block(dir, i, &blk); // 讀出該目錄第i個磁盤塊的信息並保存在blk中
if (r < 0)
{
return r;
}
for (j = 0; j < FILE2BLK; j++) {
// 遍歷該磁盤塊中所有的文件控制塊
f = ((struct File *)blk) + j;
if (strcmp(f->f_name, name) == 0)
{
//如果找到目標文件,就返回
f->f_dir = dir;
*file = f;
return 0;
}
}
}
return -E_NOT_FOUND; // 否則報異常
}
fsformat.c
該文件中存放了文件系統格式化相關的函數,包括磁盤的初始化、文件的創建、寫入,主要以定義在普通文件和文件目錄上的操作為主,可以看做是較為高級的文件操作集合。同樣地,源碼大部分已經被課程組實現了,就不再細說。
以下講一下create_file
函數:
這個函數從一個目錄文件出發,目的是尋找第一個能夠放下新的文件控制塊的位置。當它找到一個指向已經被刪除了的文件的文件控制塊指針時,它直接返回,以求后續操作將這個空間覆蓋掉。而當沒有找到時,其直接進行拓展一個Block,並返回這個新的空白空間的起始地址。這里我們需要注意到,一個未被占用的空間被解釋為文件控制塊指針時,其行為和一個指向已經被刪除了的文件的文件控制塊指針一致,因此能夠被統一處理。
struct File *create_file(struct File *dirf) {
struct File *dirblk;
int i, bno, j;
int nblk = dirf->f_size / BY2BLK; // 計算出該目錄文件下有多少磁盤塊
for (i = 0; i < nblk; i++)
{
// 遍歷所有磁盤塊
if (i < NDIRECT)
{
bno = dirf->f_direct[i];
}
else
{
bno = ((int *)(disk[dirf->f_indirect].data))[i];
}
// 根據直接指針或間接指針獲得第i個磁盤塊的塊號
dirblk = (struct File *)(disk[bno].data);
// 得到第i個磁盤塊起始位置起算的文件控制塊數組的基址
for (j = 0; j < FILE2BLK; j++)
{
// 遍歷該磁盤塊中所有文件控制塊
if (dirblk[j].f_name[0] == '\0')
{
// 如果發現有的文件控制塊名稱為終止符,說明這個文件已經被刪除了/這個文件控制塊的位置還沒被占用,將該文件控制塊起始地址返回
return &dirblk[j];
}
}
}
// 遍歷了所有的Block后都沒找到一個空的能放文件控制塊的地方
bno = make_link_block(dirf, nblk);
//直接拓展dirf的大小,並使第nblk個磁盤塊鏈接到一個新的磁盤塊(塊號bno)
return (struct File *)disk[bno].data;
// 返回這個新的磁盤塊內的起始地址
}
用戶接口
用戶進程雖然通過IPC機制與文件系統通信,但是其在基本的通信邏輯上又封裝了一些更為簡潔的接口,存放在user/file.c中。這一部分代碼基本就是對文件系統服務的封裝,在這里就不一一細說。
同時用戶接口中封裝了一套專門用於描述文件的數據結構和邏輯,稱為文件描述符,對應數據結構為struct Fd和struct Filefd。
文件描述符
struct Fd {
u_int fd_dev_id; // 指示了該文件所處的設備
u_int fd_offset; // 指示了當前用戶進程對該文件進行操作的指針偏移位置(從文件開頭起)
u_int fd_omode; // 指示了文件的訪問權限
};
struct Filefd {
struct Fd f_fd;
u_int f_fileid;
struct File f_file;
};
注意struct Fd.fd_offset,其描述了用戶進程當前在文件操作中的指針位置,該值會在read
、write
和seek
時被修改(定義在user/fd.c中)。
可以看出,Filefd實際上是Fd和File的組合,並包含了其文件控制塊id。你甚至可以直接讓Fd*指向Filefd類型,因為Filefd類型的內存上的前一部分存放的正是一個Fd類型的數據。
fd.c
描述了write\read\close等用戶使用的接口,其基於文件描述符去尋找所需的進行的操作。
file.c
定義了一系列基於文件控制塊的函數,通過對文件控制塊的解析和調用IPC來實現功能。
fsipc.c
定義了一系列IPC相關的函數,主要功能在於打包所要傳遞的參數和發送\接收。