Linux的檔案系統及檔案快取知識點整理
阿新 • • 發佈:2020-06-07
https://www.luozhiyun.com/archives/291
## Linux的檔案系統
### 檔案系統的特點
1. 檔案系統要有嚴格的組織形式,使得檔案能夠以塊為單位進行儲存。
2. 檔案系統中也要有索引區,用來方便查詢一個檔案分成的多個塊都存放在了什麼位置。
3. 如果檔案系統中有的檔案是熱點檔案,近期經常被讀取和寫入,檔案系統應該有快取層。
4. 檔案應該用資料夾的形式組織起來,方便管理和查詢。
5. Linux核心要在自己的記憶體裡面維護一套資料結構,來儲存哪些檔案被哪些程序開啟和使用。
總體來說,檔案系統的主要功能梳理如下:
![img](https://img.luozhiyun.com/blog20200607162442.png)
## ext系列的檔案系統的格式
### inode與塊的儲存
硬碟分成相同大小的單元,我們稱為塊(Block)。一塊的大小是扇區大小的整數倍,預設是4K。在格式化的時候,這個值是可以設定的。
一大塊硬碟被分成了一個個小的塊,用來存放檔案的資料部分。這樣一來,如果我們像存放一個檔案,就不用給他分配一塊連續的空間了。我們可以分散成一個個小塊進行存放。這樣就靈活得多,也比較容易新增、刪除和插入資料。
inode就是檔案索引的意思,我們每個檔案都會對應一個inode;一個資料夾就是一個檔案,也對應一個inode。
inode資料結構如下:
```
struct ext4_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size_lo; /* Size in bytes */
__le32 i_atime; /* Access time */
__le32 i_ctime; /* Inode Change time */
__le32 i_mtime; /* Modification time */
__le32 i_dtime; /* Deletion Time */
__le16 i_gid; /* Low 16 bits of Group Id */
__le16 i_links_count; /* Links count */
__le32 i_blocks_lo; /* Blocks count */
__le32 i_flags; /* File flags */
......
__le32 i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
__le32 i_generation; /* File version (for NFS) */
__le32 i_file_acl_lo; /* File ACL */
__le32 i_size_high;
......
};
```
inode裡面有檔案的讀寫許可權i_mode,屬於哪個使用者i_uid,哪個組i_gid,大小是多少i_size_io,佔用多少個塊i_blocks_io,i_atime是access time,是最近一次訪問檔案的時間;i_ctime是change time,是最近一次更改inode的時間;i_mtime是modify time,是最近一次更改檔案的時間等。
所有的檔案都是儲存在i_block裡面。具體儲存規則由EXT4_N_BLOCKS決定,EXT4_N_BLOCKS有如下的定義:
```
#define EXT4_NDIR_BLOCKS 12
#define EXT4_IND_BLOCK EXT4_NDIR_BLOCKS
#define EXT4_DIND_BLOCK (EXT4_IND_BLOCK + 1)
#define EXT4_TIND_BLOCK (EXT4_DIND_BLOCK + 1)
#define EXT4_N_BLOCKS (EXT4_TIND_BLOCK + 1)
```
在ext2和ext3中,其中前12項直接儲存了塊的位置,也就是說,我們可以通過i_block[0-11],直接得到儲存檔案內容的塊。
![img](https://img.luozhiyun.com/blog20200607162447.jpeg)
但是,如果一個檔案比較大,12塊放不下。當我們用到i_block[12]的時候,就不能直接放資料塊的位置了,要不然i_block很快就會用完了。
那麼可以讓i_block[12]指向一個塊,這個塊裡面不放資料塊,而是放資料塊的位置,這個塊我們稱為間接塊。如果檔案再大一些,i_block[13]會指向一個塊,我們可以用二次間接塊。二次間接塊裡面存放了間接塊的位置,間接塊裡面存放了資料塊的位置,資料塊裡面存放的是真正的資料。如果檔案再大點,那麼i_block[14]同理。
這裡面有一個非常顯著的問題,對於大檔案來講,我們要多次讀取硬碟才能找到相應的塊,這樣訪問速度就會比較慢。
為了解決這個問題,ext4做了一定的改變。它引入了一個新的概念,叫作Extents。比方說,一個檔案大小為128M,如果使用4k大小的塊進行儲存,需要32k個塊。如果按照ext2或者ext3那樣散著放,數量太大了。但是Extents可以用於存放連續的塊,也就是說,我們可以把128M放在一個Extents裡面。這樣的話,對大檔案的讀寫效能提高了,檔案碎片也減少了。
Exents是一個樹狀結構:
![img](https://img.luozhiyun.com/blog20200607162454.jpeg)
每個節點都有一個頭,ext4_extent_header可以用來描述某個節點。
```
struct ext4_extent_header {
__le16 eh_magic; /* probably will support different formats */
__le16 eh_entries; /* number of valid entries */
__le16 eh_max; /* capacity of store in entries */
__le16 eh_depth; /* has tree real underlying blocks? */
__le32 eh_generation; /* generation of the tree */
};
```
eh_entries表示這個節點裡面有多少項。這裡的項分兩種,如果是葉子節點,這一項會直接指向硬碟上的連續塊的地址,我們稱為資料節點ext4_extent;如果是分支節點,這一項會指向下一層的分支節點或者葉子節點,我們稱為索引節點ext4_extent_idx。這兩種型別的項的大小都是12個byte。
```
/*
* This is the extent on-disk structure.
* It's used at the bottom of the tree.
*/
struct ext4_extent {
__le32 ee_block; /* first logical block extent covers */
__le16 ee_len; /* number of blocks covered by extent */
__le16 ee_start_hi; /* high 16 bits of physical block */
__le32 ee_start_lo; /* low 32 bits of physical block */
};
/*
* This is index on-disk structure.
* It's used at all the levels except the bottom.
*/
struct ext4_extent_idx {
__le32 ei_block; /* index covers logical blocks from 'block' */
__le32 ei_leaf_lo; /* pointer to the physical block of the next *
* level. leaf or next index could be there */
__le16 ei_leaf_hi; /* high 16 bits of physical block */
__u16 ei_unused;
};
```
如果檔案不大,inode裡面的i_block中,可以放得下一個ext4_extent_header和4項ext4_extent。所以這個時候,eh_depth為0,也即inode裡面的就是葉子節點,樹高度為0。
如果檔案比較大,4個extent放不下,就要分裂成為一棵樹,eh_depth>0的節點就是索引節點,其中根節點深度最大,在inode中。最底層eh_depth=0的是葉子節點。
除了根節點,其他的節點都儲存在一個塊4k裡面,4k扣除ext4_extent_header的12個byte,剩下的能夠放340項,每個extent最大能表示128MB的資料,340個extent會使你的表示的檔案達到42.5GB。
### inode點陣圖和塊點陣圖
inode的點陣圖大小為4k,每一位對應一個inode。如果是1,表示這個inode已經被用了;如果是0,則表示沒被用。block的點陣圖同理。
在Linux作業系統裡面,想要建立一個新檔案,會呼叫open函式,並且引數會有O_CREAT。這表示當檔案找不到的時候,我們就需要建立一個。那麼open函式的呼叫過程大致是:要開啟一個檔案,先要根據路徑找到資料夾。如果發現資料夾下面沒有這個檔案,同時又設定了O_CREAT,就說明我們要在這個資料夾下面建立一個檔案。
建立一個檔案,那麼就需要建立一個inode,那麼就會從檔案系統裡面讀取inode點陣圖,然後找到下一個為0的inode,就是空閒的inode。對於block點陣圖,在寫入檔案的時候,也會有這個過程。
### 檔案系統的格式
資料塊的點陣圖是放在一個塊裡面的,共4k。每位表示一個數據塊,共可以表示$4 * 1024 * 8 = 2^{15}$個數據塊。如果每個資料塊也是按預設的4K,最大可以表示空間為$2^{15} * 4 * 1024 = 2^{27}$個byte,也就是128M,那麼顯然是不夠的。
這個時候就需要用到塊組,資料結構為ext4_group_desc,這裡面對於一個塊組裡的inode點陣圖bg_inode_bitmap_lo、塊點陣圖bg_block_bitmap_lo、inode列表bg_inode_table_lo,都有相應的成員變數。
這樣一個個塊組,就基本構成了我們整個檔案系統的結構。因為塊組有多個,塊組描述符也同樣組成一個列表,我們把這些稱為塊組描述符表。
我們還需要有一個數據結構,對整個檔案系統的情況進行描述,這個就是超級塊ext4_super_block。裡面有整個檔案系統一共有多少inode,s_inodes_count;一共有多少塊,s_blocks_count_lo,每個塊組有多少inode,s_inodes_per_group,每個塊組有多少塊,s_blocks_per_group等。這些都是這類的全域性資訊。
最終,整個檔案系統格式就是下面這個樣子。
![img](https://img.luozhiyun.com/blog20200607162501.jpeg)
預設情況下,超級塊和塊組描述符表都有副本儲存在每一個塊組裡面。防止這些資料丟失了,導致整個檔案系統都打不開了。
由於如果每個塊組裡面都儲存一份完整的塊組描述符表,一方面很浪費空間;另一個方面,由於一個塊組最大128M,而塊組描述符表裡面有多少項,這就限制了有多少個塊組,128M * 塊組的總數目是整個檔案系統的大小,就被限制住了。
因此引入Meta Block Groups特性。
首先,塊組描述符表不會儲存所有塊組的描述符了,而是將塊組分成多個組,我們稱為元塊組(Meta Block Group)。每個元塊組裡面的塊組描述符表僅僅包括自己的,一個元塊組包含64個塊組,這樣一個元塊組中的塊組描述符表最多64項。
我們假設一共有256個塊組,原來是一個整的塊組描述符表,裡面有256項,要備份就全備份,現在分成4個元塊組,每個元塊組裡面的塊組描述符表就只有64項了,這就小多了,而且四個元塊組自己備份自己的。
![img](https://img.luozhiyun.com/blog20200607162505.jpeg)
根據圖中,每一個元塊組包含64個塊組,塊組描述符表也是64項,備份三份,在元塊組的第一個,第二個和最後一個塊組的開始處。
如果開啟了sparse_super特性,超級塊和塊組描述符表的副本只會儲存在塊組索引為0、3、5、7的整數冪裡。所以上圖的超級塊只在索引為0、3、5、7等的整數冪裡。
### 目錄的儲存格式
其實目錄本身也是個檔案,也有inode。inode裡面也是指向一些塊。和普通檔案不同的是,普通檔案的塊裡面儲存的是檔案資料,而目錄檔案的塊裡面儲存的是目錄裡面一項一項的檔案資訊。這些資訊我們稱為ext4_dir_entry。
在目錄檔案的塊中,最簡單的儲存格式是列表,每一項都會儲存這個目錄的下一級的檔案的檔名和對應的inode,通過這個inode,就能找到真正的檔案。第一項是“.”,表示當前目錄,第二項是“…”,表示上一級目錄,接下來就是一項一項的檔名和inode。
如果在inode中設定EXT4_INDEX_FL標誌,那麼就表示根據索引查詢檔案。索引項會維護一個檔名的雜湊值和資料塊的一個對映關係。
如果我們要查詢一個目錄下面的檔名,可以通過名稱取雜湊。如果雜湊能夠匹配上,就說明這個檔案的資訊在相應的塊裡面。然後開啟這個塊,如果裡面不再是索引,而是索引樹的葉子節點的話,那裡面還是ext4_dir_entry的列表,我們只要一項一項找檔名就行。通過索引樹,我們可以將一個目錄下面的N多的檔案分散到很多的塊裡面,可以很快地進行查詢。
![img](https://img.luozhiyun.com/blog20200607162509.jpeg)
## Linux中的檔案快取
### ext4檔案系統層
對於ext4檔案系統來講,核心定義了一個ext4_file_operations。
```
const struct file_operations ext4_file_operations = {
......
.read_iter = ext4_file_read_iter,
.write_iter = ext4_file_write_iter,
......
}
```
ext4_file_read_iter會呼叫generic_file_read_iter,ext4_file_write_iter會呼叫__generic_file_write_iter。
```
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
......
if (iocb->ki_flags & IOCB_DIRECT) {
......
struct address_space *mapping = file->f_mapping;
......
retval = mapping->a_ops->direct_IO(iocb, iter);
}
......
retval = generic_file_buffered_read(iocb, iter, retval);
}
ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
......
if (iocb->ki_flags & IOCB_DIRECT) {
......
written = generic_file_direct_write(iocb, from);
......
} else {
......
written = generic_perform_write(file, from, iocb->ki_pos);
......
}
}
```
generic_file_read_iter和__generic_file_write_iter有相似的邏輯,就是要區分是否用快取。因此,根據是否使用記憶體做快取,我們可以把檔案的I/O操作分為兩種型別。
第一種型別是快取I/O。大多數檔案系統的預設I/O操作都是快取I/O。對於讀操作來講,作業系統會先檢查,核心的緩衝區有沒有需要的資料。如果已經快取了,那就直接從快取中返回;否則從磁碟中讀取,然後快取在作業系統的快取中。對於寫操作來講,**作業系統會先將資料從使用者空間複製到核心空間的快取中**。這時對使用者程式來說,寫操作就已經完成。至於什麼時候再寫到磁碟中由作業系統決定,除非顯式地呼叫了sync同步命令。
第二種型別是直接IO,就是應用程式直接訪問磁碟資料,而不經過核心緩衝區,從而減少了在核心快取和使用者程式之間資料複製。
如果在寫的邏輯__generic_file_write_iter裡面,發現設定了IOCB_DIRECT,則呼叫generic_file_direct_write,裡面同樣會呼叫address_space的direct_IO的函式,將資料直接寫入硬碟。
### 帶快取的寫入操作
我們先來看帶快取寫入的函式generic_perform_write。
```
ssize_t generic_perform_write(struct file *file,
struct iov_iter *i, loff_t pos)
{
struct address_space *mapping = file->f_mapping;
const struct address_space_operations *a_ops = mapping->a_ops;
do {
struct page *page;
unsigned long offset; /* Offset into pagecache page */
unsigned long bytes; /* Bytes to write to page */
status = a_ops->write_begin(file, mapping, pos, bytes, flags,
&page, &fsdata);
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
flush_dcache_page(page);
status = a_ops->write_end(file, mapping, pos, bytes, copied,
page, fsdata);
pos += copied;
written += copied;
balance_dirty_pages_ratelimited(mapping);
} while (iov_iter_count(i));
}
```
迴圈中主要做了這幾件事:
* 對於每一頁,先呼叫address_space的write_begin做一些準備;
* 呼叫iov_iter_copy_from_user_atomic,將寫入的內容從使用者態拷貝到核心態的頁中;
* 呼叫address_space的write_end完成寫操作;
* 呼叫balance_dirty_pages_ratelimited,看髒頁是否太多,需要寫回硬碟。所謂髒頁,就是寫入到快取,但是還沒有寫入到硬碟的頁面。
對於第一步,呼叫的是ext4_write_begin來說,主要做兩件事:
第一做日誌相關的工作。
ext4是一種日誌檔案系統,是為了防止突然斷電的時候的資料丟失,引入了日誌**(Journal)**模式。日誌檔案系統比非日誌檔案系統多了一個Journal區域。檔案在ext4中分兩部分儲存,一部分是檔案的元資料,另一部分是資料。元資料和資料的操作日誌Journal也是分開管理的。你可以在掛載ext4的時候,選擇Journal模式。這種模式在將資料寫入檔案系統前,必須等待元資料和資料的日誌已經落盤才能發揮作用。這樣效能比較差,但是最安全。
另一種模式是order模式。這個模式不記錄資料的日誌,只記錄元資料的日誌,但是在寫元資料的日誌前,必須先確保資料已經落盤。這個折中,是預設模式。
還有一種模式是writeback,不記錄資料的日誌,僅記錄元資料的日誌,並且不保證資料比元資料先落盤。這個效能最好,但是最不安全。
第二呼叫grab_cache_page_write_begin來,得到應該寫入的快取頁。
```
struct page *grab_cache_page_write_begin(struct address_space *mapping,
pgoff_t index, unsigned flags)
{
struct page *page;
int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;
page = pagecache_get_page(mapping, index, fgp_flags,
mapping_gfp_mask(mapping));
if (page)
wait_for_stable_page(page);
return page;
}
```
在核心中,快取以頁為單位放在記憶體裡面,每一個開啟的檔案都有一個struct file結構,每個struct file結構都有一個struct address_space用於關聯檔案和記憶體,就是在這個結構裡面,有一棵樹,用於儲存所有與這個檔案相關的的快取頁。
對於第二步,呼叫iov_iter_copy_from_user_atomic。先將分配好的頁面呼叫kmap_atomic對映到核心裡面的一個虛擬地址,然後將使用者態的資料拷貝到核心態的頁面的虛擬地址中,呼叫kunmap_atomic把核心裡面的對映刪除。
```
size_t iov_iter_copy_from_user_atomic(struct page *page,
struct iov_iter *i, unsigned long offset, size_t bytes)
{
char *kaddr = kmap_atomic(page), *p = kaddr + offset;
iterate_all_kinds(i, bytes, v,
copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
v.bv_offset, v.bv_len),
memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
)
kunmap_atomic(kaddr);
return bytes;
}
```
第三步中,呼叫ext4_write_end完成寫入。這裡面會呼叫ext4_journal_stop完成日誌的寫入,會呼叫block_write_end->__block_commit_write->mark_buffer_dirty,將修改過的快取標記為髒頁。可以看出,其實所謂的完成寫入,並沒有真正寫入硬碟,僅僅是寫入快取後,標記為**髒頁**。
第四步,呼叫 balance_dirty_pages_ratelimited,是回寫髒頁。
```
/**
* balance_dirty_pages_ratelimited - balance dirty memory state
* @mapping: address_space which was dirtied
*
* Processes which are dirtying memory should call in here once for each page
* which was newly dirtied. The function will periodically check the system's
* dirty state and will initiate writeback if needed.
*/
void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
struct inode *inode = mapping->host;
struct backing_dev_info *bdi = inode_to_bdi(inode);
struct bdi_writeback *wb = NULL;
int ratelimit;
......
if (unlikely(current->nr_dirtied >= ratelimit))
balance_dirty_pages(mapping, wb, current->nr_dirtied);
......
}
```
在balance_dirty_pages_ratelimited裡面,發現髒頁的數目超過了規定的數目,就呼叫balance_dirty_pages->wb_start_background_writeback,啟動一個背後執行緒開始回寫。
另外還有幾種場景也會觸發回寫:
* 使用者主動呼叫sync,將快取刷到硬碟上去,最終會呼叫wakeup_flusher_threads,同步髒頁;
* 當記憶體十分緊張,以至於無法分配頁面的時候,會呼叫free_more_memory,最終會呼叫wakeup_flusher_threads,釋放髒頁;
* 髒頁已經更新了較長時間,時間上超過了設定時間,需要及時回寫,保持記憶體和磁碟上資料一致性。
### 帶快取的讀操作
看帶快取的讀,對應的是函式generic_file_buffered_read。
```
static ssize_t generic_file_buffered_read(struct kiocb *iocb,
struct iov_iter *iter, ssize_t written)
{
struct file *filp = iocb->ki_filp;
struct address_space *mapping = filp->f_mapping;
struct inode *inode = mapping->host;
for (;;) {
struct page *page;
pgoff_t end_index;
loff_t isize;
page = find_get_page(mapping, index);
if (!page) {
if (iocb->ki_flags & IOCB_NOWAIT)
goto would_block;
page_cache_sync_readahead(mapping,
ra, filp,
index, last_index - index);
page = find_get_page(mapping, index);
if (unlikely(page == NULL))
goto no_cached_page;
}
if (PageReadahead(page)) {
page_cache_async_readahead(mapping,
ra, filp, page,
index, last_index - index);
}
/*
* Ok, we have the page, and it's up-to-date, so
* now we can copy it to user space...
*/
ret = copy_page_to_iter(page, offset, nr, iter);
}
}
```
在generic_file_buffered_read函式中,我們需要先找到page cache裡面是否有快取頁。如果沒有找到,不但讀取這一頁,還要進行預讀,這需要在page_cache_sync_readahead函式中實現。預讀完了以後,再試一把查詢快取頁。
如果第一次找快取頁就找到了,我們還是要判斷,是不是應該繼續預讀;如果需要,就呼叫page_cache_async_readahead發起一個非同步預讀。
最後,copy_page_to_iter會將內容從核心快取頁拷貝到使用者記憶體空間。