BUAA OS Lab5 文件系統


Lab5

實驗的目的在於:

  1. 了解文件系統的基本概念和作用
  2. 了解普通磁盤的基本結構和讀寫方式
  3. 了解實現設備驅動的方法
  4. 掌握並實現文件系統服務的基本操作
  5. 了解微內核的基本設計思想和結構

為了避免同志們坐享其成,所有代碼均取自[login學長的開源代碼](login256/BUAA-OS-2019: 北航OS課課設代碼 (github.com)),為方便理解,做少量注釋,可以理解該篇為Lab 5任務導讀(x

什么是文件系統?

文件系統的出現是為了更好地管理在不易失存儲介質上存放的數據,而通常來說這種外部不易失的存儲設備就是磁盤。文件系統將磁盤上的數據抽象化,使得用戶能夠很方便地訪問數據而無需關心具體和磁盤之間的交互。

注意,文件系統是高度抽象性的,它是管理數據的抽象的界面,而背后實際的數據存儲形式是對用戶來說不可見的。因此,文件系統一方面需要面向復雜多樣的數據存儲媒介,一方面需要面向用戶提供簡潔統一的接口。

同時,文件系統也可以不僅是文件系統,諸如proc這樣的虛擬文件系統還可以實現Windows中任務管理器的功能,這取決於你如何定義一個文件是什么。

有關本次實驗的具體問題

本次實驗中提到的文件系統既是指磁盤文件系統,又是指操作系統上的文件系統。注意,磁盤文件系統是在磁盤驅動器上而言的,而操作系統的文件系統是針對操作系統而言的,兩者的結構即使一致,其在磁盤和內存上的表示也會有一定差異,需要注意區分。

本次我們需要分三步實現文件系統:

  1. 實現磁盤的用戶態驅動程序
  2. 實現磁盤上和操作系統上的文件系統結構,並在調用驅動程序的基礎上實現文件系統操作相關函數
  3. 提供接口和機制使得用戶態下也能夠使用文件系統

一些基本概念的補充

為什么說操作系統和磁盤上文件系統不同也能進行正常使用:舉例,Linux使用的是VFS文件系統,但是可以與Ext4等多種文件系統的磁盤驅動器正常通訊。理論上,磁盤文件系統不同的磁盤上,數據的組織方式不同,按統一的方式去訪問肯定不行。但是Linux提出的VFS(Virtual Filesystem Switch)是一個虛擬的文件系統,其將其他不同文件系統分別進行解釋,然后以統一的方式進行管理,由此實現了對於用戶來說完全一致的效果。這恰恰反映了文件系統的抽象性

磁盤驅動程序:位於操作系統中的一段代碼,與操作系統高度相關,描述了對應磁盤驅動器的信息和提供操作接口。操作系統需要通過驅動程序才能與磁盤驅動器通信。

磁盤與磁盤驅動器:磁盤是一個物理結構,用於存儲信息,而磁盤驅動器是用於控制磁盤旋轉、讀取的機構。操作系統需要通過驅動程序才能和磁盤驅動器交流,而磁盤驅動器再去磁盤上尋找對應信息。

三點幾嘞,寫個磁盤驅動先啦

本次實驗中我們使用內存映射I/O技術(MMIO)來實現一個IDE磁盤的驅動。IDE具體的意思是Integrated Driver Electronics,字面意思指這種磁盤的控制器和盤體集合在一起,但是SATA磁盤也是這樣的結構,二者主要區別在於接口串行和並行。不過這和我們的實驗沒有什么關系。

另外需要說明的一點是,本次的驅動程序完全運行在用戶態下,這是需要兩個新的系統調用sys_write_devsys_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設備的物理地址是完全固定的,我們的驅動程序就只需要對特定內核虛擬地址進行讀寫即可。

驅動程序編寫

由於驅動程序的編寫實際上就是對特定地址進行讀寫,我們需要清楚兩個主要問題:

  1. 往哪里寫?從哪里讀?
  2. 讀/寫對應的數據的意義是什么?

這兩個是和硬件有關的,所幸的是指導書中已經給了出來,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在沒有被觸發時只是在理論上占有一個虛擬空間,而當使用時會動態分配一個物理頁給它(我們會發現一個磁盤塊大小恰為一個物理頁)。

由此我們的文件系統實際上與三個數據結構有較大聯系:

  1. 磁盤塊管理位圖 nbitblock
  2. 磁盤塊中的文件控制塊 File
  3. 文件系統進程空間映射關系(實際上是管理其的頁表結構)

訪問文件的中經歷了什么?

訪問一個文件,首先要找到其對應的文件控制塊結構。該過程首先需要經過從根目錄文件的逐級查找。而找到對應文件控制塊后,則可以通過其中的指針找到對應的磁盤塊,從而利用驅動程序訪問到指定數據。

文件系統在我們的操作系統中作為獨立的進程存在,其通過進程間IPC的方式來服務於用戶進程,其為服務所開放的函數中存儲在fs/serv.c中。用戶進程需要調用user/file.c中的函數實現文件系統操作,而其底層調用user/fsipc.c中的函數來實現和文件系統進程的IPC。

代碼的分布和調用邏輯

文件系統代碼調用邏輯

以打開一個文件並獲取其數據為例,用戶進程需要調用file.c中的open函數,其中調用了fsipc.c中的fsipc_openfsipc_map

用戶進程調用fsipc_open來將文件路徑path和打開方式mode打包進一個特殊的數據結構中,然后通過IPC發送給文件系統,文件系統返回一個用於描述該文件的文件描述塊(struct Fd,其中包含對應的文件控制塊id、文件大小等)。

用戶進程調用fsipc_map來通過指定文件控制塊fileid和偏移量(以字節計),來獲取指定位置的磁盤塊中的數據。其打包發送給文件系統進程后,文件系統通過fileid找到對應文件,再通過offset找到對應磁盤塊。磁盤塊數據恰好1頁大小,正好能夠通過我們的IPC機制通訊傳送回到用戶進程。有趣的是這里面磁盤塊數據涉及多次映射,一次是從磁盤中映射到文件系統進程的指定位置,一次是在IPC過程中從文件系統映射回用戶進程。

文件系統進程中的函數

fs.c

fs.c文件定義了有關磁盤塊和文件的一系列操作,主要包含兩個大方面:

磁盤塊管理:

磁盤塊管理相關函數關系(部分調用省略)

上圖中表明了磁盤塊管理相關函數的調用關系:

  • 綠色框內為基礎檢查/映射函數,不參與實際管理,僅被其他函數調用,故忽略調用關系
  • 紅色為系統調用
  • 亮紫色為驅動程序
  1. 磁盤塊位圖的管理:

    • alloc_block_sum:遍歷bitmap,找到的第一個空閑的磁盤塊,然后將其寫入磁盤(DEBUG:沒看懂login為什么這么寫,我得寫個函數調用圖),然后返回磁盤塊號
    • alloc_block:調用alloc_block_sum並為獲得的新的磁盤塊分配一個物理頁面
    • free_block:在位圖中標記一個磁盤塊為空
    • block_is_free:檢查位圖來判斷是否是空閑的
  2. 磁盤塊在文件系統進程空間的映射管理

    • diskaddr:實現從磁盤塊號到對應虛擬地址的映射

    • map_block:調用syscall_mem_alloc為該磁盤塊分配對應的物理頁面並添加進入頁表

    • unmap_block:檢查是否需要將該磁盤塊內容寫入磁盤(取決於是否dirty),再調用syscall_mem_unmap釋放物理頁面

    • va/block_is_mapped/dirty:通過查詢頁表來檢查其是否已經被分配了物理頁面/因更改而變dirty,通過檢查權限位實現

  3. 磁盤塊對應虛擬地址空間到磁盤的通過驅動程序支持的讀寫管理:

    • 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,其描述了用戶進程當前在文件操作中的指針位置,該值會在readwriteseek時被修改(定義在user/fd.c中)。

可以看出,Filefd實際上是Fd和File的組合,並包含了其文件控制塊id。你甚至可以直接讓Fd*指向Filefd類型,因為Filefd類型的內存上的前一部分存放的正是一個Fd類型的數據。

fd.c

描述了write\read\close等用戶使用的接口,其基於文件描述符去尋找所需的進行的操作。

file.c

定義了一系列基於文件控制塊的函數,通過對文件控制塊的解析和調用IPC來實現功能。

fsipc.c

定義了一系列IPC相關的函數,主要功能在於打包所要傳遞的參數和發送\接收。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM