文章目錄
- 背景
- Linux vfs框架介紹
- 數據結構
- 系統調用
- open
- write
- read
- 總體框架
- Linux 磁盤高速緩存機制
- 標準文件訪問
- 同步文件訪問
- 異步文件訪問
- buffer_head
- 如何實現一個簡單的文件系統
- blkdevfs
- 注冊文件系統
- 產生一個文件
- 讓文件變得可讀可寫
背景
在新的分區升級啟動方案中需要分別實現兩個簡單的文件系統,其中一個文件系統作用是可以將存放digicap的塊設備變成可以掛載的設備,掛載后可以直接訪問digicap打包的所有文件,命名為digicapfs。另外一個文件系統的作用是可以將任意塊設備掛載為一個只有單個文件的文件系統,可以將寫入塊設備的讀寫操作轉化為對文件的讀寫。
文件系統是操作系統向用戶提供一套存取數據的抽象數據結構,方便用戶管理一組數據。文件系統在Linux操作系統)中的位置在下圖紅框中標出,如Ext2、Ext4等。而在windows中現在常用的文件系統為NTFS、exFAT等,想必大家在格式化U盤、硬盤的時候就經常見到了。
為什么要用文件系統來存取數據呢?是為了圖個方便。試想如果沒有文件系統,放置在存儲介質(硬盤)中的數據將是一個龐大的數據主體,無法分辨一個數據從哪里停止,下一個數據又從哪里開始。通過將數據分為一塊一塊的,并為每一塊都賦予一個名字,數據將會很容易隔離和確定。當然這都是在邏輯上去劃分。既然是在邏輯上劃分,那總得有個依據,將劃分的結果落實下來。這時候我們就需要創建一系列的數據結構(包含數據和對此數據的一系列操作),來表示我們劃分的邏輯,這就是文件系統。
Linux vfs框架介紹
數據結構
首先,我們通過進程task_struct結構體中fs成員表示了進程可見根文件系統的根節點及當前工作目錄:
task_struct{...struct fs_struct *fs; /進程目錄信息/struct files_struct *files; /進程打開文件信息/...
}
fs_struct結構體定義在/include/linux/fs_struct.h頭文件:
struct fs_struct {int users; /結構體實例用戶數量/spinlock_t lock;seqcount_t seq;int umask;int in_exec;struct path root, pwd; /進程根目錄和當前工作目錄/
};
path結構體實例,結構體定義如下:
struct path {struct vfsmount *mnt; /目錄項所在文件系統掛載信息,vfsmount.mnt/struct dentry *dentry; /目錄項指針/
};
root成員表示進程訪問內核根文件系統,通常為根文件系統的根節點,但也可以通過chroot()系統調用修改進程根目錄。進程以絕對路徑搜索文件時,從進程根目錄開始。pwd成員表示進程當前工作目錄。進程以相對路徑訪問文件時,將會從當前工作目錄開始查找。chdir()系統調用用于改變進程當前工作目錄。在前面介紹的VFS初始化中,將創建內核根文件系統,并設置內核線程的根目錄、當前工作目錄為根文件系統根目錄
files成員指向files_struct結構體實例,結構體定義在include/linux/fdtable.h頭文件:
struct files_struct {/** read mostly part*/atomic_t count; /*實例引用計數*/bool resize_in_progress;wait_queue_head_t resize_wait; /*進程等待隊列*/struct fdtable __rcu *fdt; /*fdtable結構體指針,初始值指向fdtab成員*/struct fdtable fdtab; /*fdtable結構體成員*//** written part on a separate cache line in SMP*/spinlock_t file_lock ____cacheline_aligned_in_smp;/*下一個打開文件的文件描述符,初始值為0,每次分配描述符后設置*/unsigned int next_fd;/*執行execve()系統調用時關閉文件的位圖*/unsigned long close_on_exec_init[1];/*打開文件位圖*/unsigned long open_fds_init[1];unsigned long full_fds_bits_init[1];/*打開文件file指針數組*/struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};
files_struct結構體主要成員簡介如下:
open_fds_init[1]:進程打開文件位圖,與打開文件file指針數組對應,每個比特位對應數組項是否為空,1表示數組項關聯了file實例
fdt:fdtable結構體指針,初始值指向fdtab成員
fd_array[]:file指針數組,數組項指向file實例,指針數組項索引為文件描述符,無符號整數。數組項數NR_OPEN_DEFAULT與整型數比特位數相同。
fdtab:fdtable結構體成員,用于管理文件位圖,其定義如下(include/linux/fdtable.h)
struct fdtable {unsigned int max_fds; /fdtable能管理的打開文件最大數量,由位圖大小決定/struct file __rcu **fd; /指向file指針數組的指針/unsigned long *close_on_exec; /執行execve()系統調用時關閉文件的位圖/unsigned long *open_fds; /進程打開文件位圖/unsigned long *full_fds_bits;struct rcu_head rcu;
};
文件位圖就是file指針數組對應的位圖,每位對應指針數組中一項,比特位位置就是數組項索引,即文件描述符
進程打開的文件由file結構體表示,結構體定義在include/linux/fs.h頭文件:
struct file {union {struct llist_node fu_llist; /*單鏈表成員*/struct rcu_head fu_rcuhead;} f_u;struct path f_path; /*文件路徑信息*/struct inode *f_inode; /*指向內核文件inode實例*/const struct file_operations *f_op; /*文件操作結構指針,通常在打開文件時設為inode->i_fop*/ /** Protects f_ep_links, f_flags.* Must not be taken from IRQ context.*/spinlock_t f_lock;atomic_long_t f_count;unsigned int f_flags; /*系統調用傳遞的flags標記參數*/fmode_t f_mode; /*標記進程以何種模式打開文件*/struct mutex f_pos_lock;loff_t f_pos; /*文件當前讀寫位置,相對于文件開頭處的字節偏移量*/struct fown_struct f_owner;const struct cred *f_cred;struct file_ra_state f_ra;u64 f_version;
#ifdef CONFIG_SECURITYvoid *f_security;
#endif/* needed for tty driver, and maybe others */void *private_data; /*文件私有數據指針,例如設備文件指向驅動程序定義的數據結構*/#ifdef CONFIG_EPOLL/* Used by fs/eventpoll.c to link all the hooks to this file */struct list_head f_ep_links;struct list_head f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */struct address_space *f_mapping; /*文件地址空間指針 */
} __attribute__((aligned(4)));
系統調用
open(), read(), write() 等函數都是以 file descriptor 為對象。而實際上這件事牽扯到 3 個對象:
- 每個進程自己看到的 file descriptor (進程視角)
- open file table (系統視角)
- inode:文件真正的 inode (文件視角)
open
open負責在內核生成與文件相對應的struct file元數據結構,并且與文件系統中該文件的struct inode進行關聯,裝載對應文件系統的操作回調函數,然后返回一個int fd給用戶進程。后續用戶對該文件的相關操作,會涉及到其相關的struct file、struct inode、inode->i_op、inode->i_fop和inode->i_mapping->a_ops等。
在讀寫文件之前,我們必須打開文件,從應用程序的角度來看,這是通過標準庫的open函數來完成的,該函數返回一個文件描述符,會調用fs/open.c中的sys_open函數,代碼流程如下所示:
- PathWalk找到目標文件
- 構造并初始化inode
- 構造并初始化file
do_filp_open()函數要完成打開文件操作最重要、最繁重的工作,函數內需要創建文件file實例,遍歷文件路徑中每個分量,在內核根文件系統中搜索/創建對應的dentry和inode結構體實例,當到達最末尾分量時(文件名稱),將其inode實例(文件inode)與file實例建立關聯。因此,do_filp_open()函數執行的主要工作可概括為從路徑到節點,即由文件路徑確定文件inode實例,賦予file實例
write
用戶進程寫文件內容操作的系統調用為write(),其實現與讀操作非常相似,系統調用定義如下:
_vfs_write()函數內優先調用file->f_op->write()函數執行寫文件操作,如果沒有定義此函數則調用通用的同步寫函數**new_sync_write()**完成寫操作。同步寫操作通常是先將數據寫入文件內容緩存,然后在適當的時候同步(寫入)到介質文件系統
read
read的讀邏輯中包含預期readahead的邏輯,其可以通過與fadvise的配合達到文件預取的效果。用戶進程讀文件內容的read()系統調用定義如下(/fs/read_write.c):
總體框架
進程1和進程2都打開同一文件,但是對應不同的file 結構體,因此可以有不同的File Status Flag和讀寫位置。file 結構體中比較重要的成員還有f_count,表示引用計數(Reference Count),如dup 、fork 等系統調用會導致多個文件描述符指向同一 個file 結構體,例如有fd1 和fd2 都引用同一個file 結構體,那么它的引用計數就是2,,當close(fd1) 時并不會釋放file 結構體,而只是把引用計數減到1,如果再close(fd2) ,引用計數 就會減到0同時釋放file 結構體,這才真的關閉了文件。
每個file 結構體都有一個指向dentry結構體的指針,“dentry”是directory entry(目錄項)的縮寫。 我們傳給open 、stat 等函數的參數的是一個路徑,如/home/akaedu/a ,需要根據路徑找到文件 的inode。為了減少讀盤次數,內核緩存了目錄的樹狀結構,稱為dentry cache,其中每個節點是一 個dentry結構體,只要沿著路徑各部分的dentry搜索即可,從根目錄/找到home 目錄,然后找 到akaedu目錄,然后找到文件a。dentry cache只保存最近訪問過的目錄項,如果要找的目錄項 在cache中沒有,就要從磁盤讀到內存中。
每個dentry結構體都有一個指針指向inode 結構體。inode 結構體保存著從磁盤inode讀上來的信 息。在上圖的例子中,有兩個dentry,分別表示/home/akaedu/a 和/home/akaedu/b ,它們都指向同 一個inode,說明這兩個文件互為硬鏈接。inode 結構體中保存著從磁盤分區的inode讀上來信息,,例如所有者、文件大小、文件類型和權限位等。每個inode 結構體都有一個指向inode_operations結 構體的指針,后者也是一組函數指針指向一些完成文件目錄操作的內核函數。
和file_operations 不同,inode_operations所指向的不是針對某一個文件進行操作的函數,而是影響文件和目錄布局的函數,例如添加刪除文件和目錄、跟蹤符號鏈接等等,屬于同一文件系統的 各inode 結構體可以指向同一個inode_operations結構體。 inode 結構體有一個指向super_block結構體的指針。super_block結構體保存著從磁盤分區的超級塊 讀上來的信息,例如文件系統類型、塊大小等。super_block結構體的s_root成員是一個指 向dentry的指針,表示這個文件系統的根目錄被mount 到哪里,在上圖的例子中這個分區 被mount 到/home 目錄下。
address_space結構體,一個address_space管理了一個文件在內存中緩存的所有pages。address_space 結構其中的一個作用就是用于存儲文件的 頁緩存,一個inode對應一個page cache對象,一個page cache對象包含多個物理page。詳細的可以參考Linux內核學習筆記(八)Page Cache與Page回寫
host:指向當前 address_space 對象所屬的文件 inode 對象(每個文件都使用一個 inode 對象表示)。
page_tree:用于存儲當前文件的 頁緩存。
tree_lock:用于防止并發訪問 page_tree 導致的資源競爭問題。
其對應詳細的數據結構如下圖所示
Linux 磁盤高速緩存機制
緩存I/O又被稱作標準I/O,目前大多數操作系統中的文件系統的默認I/O操作都是緩存I/O。在Linux的緩存I/O機制中,數據先從磁盤復制到內核空間的緩沖區,然后從內核空間緩沖區復制到應用程序的地址空間。緩存I/O使用操作系統內核緩沖區,在一定程度上分離了應用程序空間與實際的物理設備,它能夠減少讀取磁盤的次數,進而提高I/O效率。
?? 讀操作:操作系統檢查內核的緩沖區有沒有需要的數據,如果已經緩存了,那么就直接從緩存中返回;否則從磁盤中讀取,然后緩存在操作系統的緩存中。
讀取: 硬盤 ->內核緩沖區 -> 用戶緩沖區
?? 寫操作:將數據從用戶空間復制到內核空間的緩存中。這時對用戶程序來說寫操作就已經完成,至于什么時候再寫到磁盤中由操作系統決定,除非顯示地調用了sync同步命令。
寫入: 用戶緩沖區->內核緩沖區 ->硬盤
正常的系統調用read/write的流程如下:
read: 硬盤 ->內核緩沖區 -> 用戶緩沖區
write: 數據會從用戶地址空間拷貝到操作系統內核地址空間的page cache中,這時write就會直接返回,操作系統會在恰當的時候將其刷至磁盤。
?? 緩存I/O的缺點:數據在傳輸過程中需要在應用程序地址空間和緩存之間進行多次數據拷貝操作,這些數據拷貝操作所帶來的CPU以及內存開銷是非常大的。
標準文件訪問
在 Linux 操作系統中中,通過兩個系統調用( read() 和 write())來實現文件訪問。。當應用程序調用 read() 系統調用讀取一塊數據的時候,如果該塊數據已經在內存中了,那么就直接從內存中讀出該數據并返回給應用程序;如果該塊數據不在內存中,那么數據會被從磁盤 上讀到頁高緩存中去,然后再從頁緩存中拷貝到用戶地址空間中去。如果一個進程讀取某個文件,那么其他進程就都不可以讀取或者更改該文件;對于寫數據操作來 說,當一個進程調用了 write() 系統調用往某個文件中寫數據的時候,數據會先從用戶地址空間拷貝到操作系統內核地址空間的頁緩存中去,然后才被寫到磁盤上(圖1)。但是對于這種標準的訪問文件的 方式來說,在數據被寫到頁緩存中的時候,write() 系統調用就算執行完成,并不會等數據完全寫入到磁盤上。在Linux 中稱為延遲寫機制( deferred writes )。
同步文件訪問
同步訪問文件的方式與上述標準訪問文件方式相類似,這兩種方法最大區別就是:同步訪問文件的時候,寫數據的操作是在數據完全被寫回磁盤上才算完成的(圖2);而標準訪問文件方式的寫數據操作是在數據被寫到頁高速緩沖存儲器中的時候就算執行完成了。
異步文件訪問
Linux 異步訪問文件其本質思想:進程發出數據傳輸請求之后,進程不會被阻塞,也不用等待任何操作完成,進程可以在數據傳輸的時候繼續執行其他的操作(圖5)。相比于同步訪問文件的方式來說,異步訪問文件的方式可以提高應用程序的效率,并且提高系統資源利用率。
buffer_head
正常的文件訪問都是先寫入內存緩存并不會直接落盤,buffer_head就是實現這個操作的關鍵。
buffer_head是磁盤塊的一個抽象,一個buffer_head對應一個磁盤塊,buffer_head中保存對應的磁盤號
buffer_head把page與磁盤塊聯系起來,由于page和磁盤塊的大小可能不一樣,所以一個page可能管理多個buffer_head
這里假設page大小4K,塊大小為1K, buffer_head,page和磁盤塊關系如下:
如何實現一個簡單的文件系統
blkdevfs
以blkdevfs為例 先看一下如何實現一個簡單的文件系統
編寫文件系統涉及一些基本數據結構。需要建立一個結構,4個操作表,如下所示。
文件系統類型結構(file_system_type);
超級塊操作表(super_operations);
索引結點操作表(inode_operations);
頁緩沖區表(address_space_operations);
文件操作表(file_operations)。
以上基本數據結構和操作函數,貫穿了整個文件系統的主要過程,下面具體分析這幾個結構和文件系統實現的要點。
一個通常意義上的文件系統驅動可以單獨被編譯成模塊動態加載,也可以被直接編譯到內核中,為了調試的方便,本文中的文件系統采用動態加載的方式實現。實現一個文件系統必須遵照內核的一些“規則”,以下我將以遞進的順序闡述文件系統的實現過程。
文件系統既然基于可加載內核模塊,自然也需要實現module_init以及mocule_exit,就從module_init函數開始入手。
首先,必須建立一個文件系統類型(file_system_type)來描述文件系統,它含有文件系統的名稱、類型標志以及get_sb()等操作。當安裝文件系統時,系統會對該文件系統進行注冊,即填充file_system_type結構,然后調用get_sb()函數來建立該文件系統的超級塊。
對于特定的文件系統, 該文件系統的所有的superblock 都存在于file_sytem_type中的fs_supers鏈表中,而所有的文件系統,都存在于file_systems鏈表中。通過調用register_filesystem接口來注冊文件系統,將一個新的文件系統類型加入到鏈表中。
注冊文件系統
int register_filesystem(struct file_system_type * fs)
注冊成功以后,需要對文件系統進行掛載,因為是基于內存的文件系統,沒有實際的磁盤,無法使用命令進行掛載,所以在模塊初始化的時候使用內核函數kern_mount進行掛載。掛載主要完成的任務是調用file_system_type中的 mount方法,通過該方法獲取該文件系統的根目錄dentry,同時也獲取super_block.。file_system_type的mount方法kernel也提供了已經實現的函數:mount_single,mount_pseudo等。
接下來創建若干文件和目錄,用于后面進行讀寫操作。創建文件和目錄會在向內核申請inode、dentry結構體,并且對其中的主要成員變量進行初始化。
當實現完成這個數據結構之后,就可以直接mount一個塊設備了。
在mount的時候 blkdevfs具有這樣的調用流程
.mount
->blkdevfs_mount
->blkdevfs_fill_supper
在blkdevfs_fill_supper中必須要填充一個全局的supper_block,和一個象征著掛載第一級目錄的root_inode。
上圖是blkdevfs_fill_supper 的具體實現。
產生一個文件
完成上面的步驟之后因為對于根目錄的inode和file_inde_operations都還沒有實現,所以雖然文件系統可以成功掛載但是還是無法進行任何操作,ls看不到任何文件。
所以下一步需要產生一個文件。
需要填充兩個結構體。
這兩個結構體就是目錄的主要操作接口
其中
blkdevfs_iterate的作用主要就是查找該目錄下存在的文件
blkdevfs_lookup的作用在于查找每一個文件的基本信息,如果該文件對應的inode還沒有生成則需要生成該文件對應的inode
因為只會實現一個單文件的文件系統所以這兩個函數的實現就變得非常簡單
當你在目錄中第一次運行ls的時候,就會先后調用iterate和lookup,之后再調用ls就只會調用iterate。
讓文件變得可讀可寫
通過上面的實現我們可以發現當運行ls的時候你掛載的文件系統就可以顯示出一個文件就像這樣:
但是僅僅是這樣這個文件系統還是沒有作用的我們需要讓這個文件系統變得可讀可寫。
和目錄的操作一樣 為了讓你的文件變得具有作用也要實現兩個結構體分別是:
這兩個結構體需要在第一次創建inode的時候完成填充。其中file_operations中填充的函數都是通用的,系統已經完成了具體實現,那么我們需要做些什么呢。
我們需要實現aops接口
這個結構體也是在lookup 第一次創建Inode的時候進行填充,它是用于管理文件(struct inode)映射到內存的頁面(struct page)的,其實就是每個file都有這么一個結構,將文件系統中這個file對應的數據與這個file對應的內存綁定到一起;與之對應,address_space_operations 就是用來操作該文件映射到內存的頁面,比如把內存中的修改寫回文件、從文件中讀入數據到頁面緩沖等。
在read 這個文件的時候 blkdevfs具有這樣的調用流程
mpage_readpage是系統實現的一個通用的讀取一個page的接口,而readpage會調用文件系統提供的函數指針。
這個函數實現的功能十分簡單,就是將想要訪問的page進行map操作。
如此這個簡單的文件系統就可以讀了,寫也是類似的。