昨天,我們介紹了虛擬檔案系統 (Virtual File System, VFS) 的各種操作介面,包括 file_operations
、inode_operations
和 dentry_operations
,並提到了 super block 和檔案系統驅動程式。
接下來,我們將以 cat /var/log/dpkg.log
為例,來探討這整個過程的工作流程。
從 strace
的結果可以看到,cat
指令主要呼叫了 openat
和 read
這兩個系統呼叫 (system call) 來讀取該檔案。
> strace cat /var/log/dpkg.log
...
openat(AT_FDCWD, "/var/log/dpkg.log", O_RDONLY) = 3
read(3, "2022-08-29 21:46:05 startup pack"..., 131072) = 131072
...
openat
系統呼叫類似於 open
系統呼叫,差別在於 openat
多了一個目錄文件描述符 (directory file descriptor, dfd) 的參數。這個參數用來指定要從哪個目錄作為相對路徑來找檔案。在此例中,AT_FDCWD
代表的是當前執行程式的位置。
openat
和 open
的系統呼叫在 Linux 核心中,open
和 openat
系統呼叫都會最終轉發到同一個內部函數 do_sys_open
進行處理。以下為這些系統呼叫的定義:
// fs/open.c
COMPAT_SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
COMPAT_SYSCALL_DEFINE4(openat, int, dfd, const char __user *, filename, int, flags, umode_t, mode)
{
return do_sys_open(dfd, filename, flags, mode);
}
無論是 open
還是 openat
,最終都會呼叫到 do_sys_open
。open
只是將 AT_FDCWD
作為 dfd
參數傳入,剛好與 cat
使用的參數相同。
do_sys_open
的流程do_sys_open
函數會進一步呼叫 do_sys_openat2
來完成實際的打開操作。
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
struct open_how how = build_open_how(flags, mode);
return do_sys_openat2(dfd, filename, &how);
}
do_sys_openat2
和 open_how
是 Linux Kernel 為了增強安全性而設計,主要用來避免惡意訪問。
static long do_sys_openat2(int dfd, const char __user *filename,
struct open_how *how)
{
...
int fd
struct filename *tmp;
...
tmp = getname(filename); // 複製檔案名稱到 kernel space
...
fd = get_unused_fd_flags(how->flags); // 申請 fd
struct file *f = do_filp_open(dfd, tmp, &op);
fd_install(fd, f); // 將file struct設置到 process
...
return fd;
}
這裡的流程可以分為幾個步驟:
getname
函數將使用者空間的路徑字串複製到 kernel space,並保存在 filename
結構體中。get_unused_fd_flags
函數分配一個尚未使用的文件描述符 (file descriptor, fd)。do_filp_open
函數來找到指定路徑的檔案,並建立對應的 file
結構。fd_install
將 file
結構保存到當前進程的結構中。fd_install
的詳細操作fd_install
函數會將分配到的 file
結構放入當前進程的 files_struct
中的 fd
索引位置:
// fs/file.c
void fd_install(unsigned int fd, struct file *file)
{
struct files_struct *files = current->files;
struct fdtable *fdt;
...
fdt = rcu_dereference_sched(files->fdt);
rcu_assign_pointer(fdt->fd[fd], file);
...
}
這裡的 current
指向當前進程的 task_struct
,然後將 file
結構放入進程的文件描述符表 (fdtable
) 中。
在 linux 處理打開檔案過程,會涉及到 inode, dentry 還有很多不同的系統元件跟資料,linux 將這些搜尋上下文資料保存在一個叫做 nameidata
的結構體中。
// fs/namei.c
struct file *do_filp_open(int dfd, struct filename *pathname,
const struct open_flags *op)
{
struct nameidata nd; // 建立搜尋檔案上下文
int flags = op->lookup_flags;
struct file *filp;
set_nameidata(&nd, dfd, pathname, NULL);
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
...
return filp;
}
do_filp_open
函數會實例化nameidata
結構體,將目標檔案路徑放入nameidata
結構體,接著呼叫 path_openat
函數來進行進一步的處理。
static struct file *path_openat(struct nameidata *nd,
const struct open_flags *op, unsigned flags)
{
struct file *file;
int error;
file = alloc_empty_file(op->open_flag, current_cred()); // 建立空的file結構
..
const char *s = path_init(nd, flags);
while (!(error = link_path_walk(s, nd)) && // link_path_walk: 找到檔案的dentry和inode
(s = open_last_lookups(nd, file, op)) != NULL)
;
if (!error)
error = do_open(nd, file, op); // 實際打開檔案
terminate_walk(nd);
...
}
path_openat
函數主要分成三個步驟來打開檔案:
alloc_empty_file
建立一個空的 file
結構。link_path_walk
,根據路徑字串找到對應的檔案的 dentry
和 inode
。do_open
函數來實際打開檔案,並將結果填充到 file
結構中。static int link_path_walk(const char *name, struct nameidata *nd)
{
...
for(;;) {
...
walk_component(nd, WALK_MORE);
}
...
}
為了找到檔案,link_path_walk
函數不斷呼叫 walk_component,沿著使用者輸入的路徑,從當前目錄 (dfd) 的 dentry
或根目錄 (/
) 的 dentry
開始,逐層找到對應的目錄或檔案的 dentry
和 inode
。
假設,原本目標是打開/var/logs/dpkg.log
,當前 nd
指向 /var
,那搜尋的剩餘字串會是logs/dpkg.log
。那walk_component 就會從 /var
的dentry出發,找到下一層目錄 logs
的 dentry
和 inode
,並將結果寫回 nameidata
結構中。持續到剩餘的搜尋字串為空,那nd指向的dentry跟inode就會是目標 /var/logs/dpkg.log
。
static const char *walk_component(struct nameidata *nd, int flags)
{
struct dentry *dentry;
dentry = lookup_fast(nd);
...
if (unlikely(!dentry)) {
dentry = lookup_slow(&nd->last, nd->path.dentry, nd->flags); // 讀出下一層目錄名稱及當前目錄
}
...
return step_into(nd, flags, dentry); // 更新 nd
}
// 更新 nd
static const char *step_into(struct nameidata *nd, int flags,
struct dentry *dentry)
{
// 簡化版本
nd->path->dentry = dentry; // 保存 dentry
struct inode *inode = dentry->d_inode
nd->inode = inode; // 保存 inode
}
前面提到,dentry
是保存在記憶體中的目錄結構,考慮到記憶體空間還有時間成本,所以不可能所有硬碟的所有目錄都載入到記憶體中。所以子目路的 dentry
可能會出現尚未載入的狀況。所以 walk_component 就分成 fast path 跟 slow path,差別就在於能不能直接從記憶中取得下一層目錄的 dentry
。 如果沒辦法,就需要呼叫slow path,讀取硬碟的目錄資料,把當前目錄下的檔案和子目錄的 dentry
及 inode
實例建立出來,並與當前目錄的dentry連結。
我們主要是要看 kernel 與檔案系統驅動的互動,所以我們只看 slow path,這邊會呼叫到 lookup_slow。
呼叫 lookup_slow 主要是兩個參數 nd->last
是下一層目錄名稱,根據例子是 "logs" , nd->path.dentry
是當前目錄的 dentry
,例子中是 /var
的 dentry
。
static struct dentry *lookup_slow(const struct qstr *name,
struct dentry *dir,
unsigned int flags)
{
struct dentry *dentry, *old;
struct inode *inode = dir->d_inode;
...
dentry = d_alloc_parallel(dir, name, &wq);
inode->i_op->lookup(inode, dentry, flags);
...
return dentry;
}
lookup_slow
的運作方式與 file
結構的建立過程頗為相似,首先會建立一個空的 dentry
結構。這裡的「空」是指僅包含檔名與位置信息,但尚未綁定到 inode
與整個VFS的目錄樹。
然後我們的inode_operations出現了,這邊會呼叫當前目錄的 inode->i_op->lookup
。此時,檔案系統的驅動必須負責定義 lookup
函數,並檢查該目錄是否真實存在。若該目錄存在,驅動將把對應的檔案資訊載入到 Kernel 中,並建立相關的 inode
結構,處理好 dentry
結構的綁定。
為了更好理解,我們可以參考 ext4 檔案系統的驅動:
// ext4/namei.c
const struct inode_operations ext4_dir_inode_operations = {
...
.lookup = ext4_lookup,
...
};
在 ext4 檔案系統中,目錄的 inode 使用的 inode_operations
是 ext4_dir_inode_operations
,其中包含了 lookup 的函數定義 ext4_lookup
。
lookup 函數的參數是父目錄的 inode,與目標子目錄的空 dentry
(帶有名稱資訊)。
static struct dentry *ext4_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags)
{
struct inode *inode;
struct ext4_dir_entry_2 *de;
struct buffer_head *bh;
bh = ext4_lookup_entry(dir, dentry, &de);
inode = NULL;
if (bh) {
__u32 ino = le32_to_cpu(de->inode);
inode = ext4_iget(dir->i_sb, ino, EXT4_IGET_NORMAL);
}
return d_splice_alias(inode, dentry);
}
在 ext4_lookup
函數中,首先呼叫 ext4_lookup_entry
函數,從硬碟中查找目錄資料,並將其保存在 de
(即 ext4_dir_entry_2
) 中。ext4_dir_entry_2
結構中包含了該目錄的 inode number
,因此接著會呼叫 ext4_iget
,從中取得 inode
資料並建立對應的 inode
結構。
最後,函數會呼叫 Kernel 提供的 d_splice_alias
函數,將 inode
與 dentry
綁定在一起,並把 dentry
結構接入整個VFS目錄樹。到此,我們成功找到了下一層目錄的 dentry
與 inode
。
透過 link_path_walk
,nameidata 最終會指向到我們要開啟的目標檔案的 dentry
和 inode
,回到 path_openat
,我們接下來需要使用 do_open
函數來填充預先建立好的 file
結構實例:
// fs/namei.c
static int do_open(struct nameidata *nd,
struct file *file, const struct open_flags *op)
{
...link_path_walk... // walk 走完了
...
error = vfs_open(&nd->path, file); // nd->path 指向最終我們要打開的檔案的 dentry
...
return error;
}
// fs/open.c
int vfs_open(const struct path *path, struct file *file)
{
file->f_path = *path;
return do_dentry_open(file, d_backing_inode(path->dentry), NULL);
}
static int do_dentry_open(struct file *f,
struct inode *inode,
int (*open)(struct inode *, struct file *))
f->f_inode = inode; // 綁定 file 跟 inode
f->f_mapping = inode->i_mapping;
f->f_wb_err = filemap_sample_wb_err(f->f_mapping);
f->f_sb_err = file_sample_sb_err(f);
...
f->f_op = fops_get(inode->i_fop); // 取得 file operations
if (!open)
open = f->f_op->open;
if (open) {
error = open(inode, f);
}
...
}
do_open
最終會呼叫到do_dentry_open
。do_dentry_open
會根據要打開的檔案 (inode),對file結構填充資料。
包含幾個重要步驟,首先會把f->f_inode
設置為目標檔案的inode
,另外會設置f->f_op
也就是file_operations
,並且是從 inode
繼承過來,由此可知,file_operations實際上也是由底層檔案系統驅動決定的。
過程中,do_dentry_open
也會呼叫到 file_operations
中定義的 open
函數,允許檔案系統驅動在檔案打開的過程中進行額外的處理。最終,整個檔案的打開過程到此結束。
當檔案開啟完成後,像 cat
這樣的程式會取得一個 file descriptor (fd
),然後使用 read
系統呼叫,將 fd
作為參數來讀取檔案的內容。
接下來,我們來看看 read
system call的實作。
// fs/read_write.c
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count);
}
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd); // 從 fd 取得對應的 file 結構
ssize_t ret = -EBADF;
...
ret = vfs_read(f.file, buf, count, ppos); // 呼叫 vfs_read 進行檔案讀取
...
return ret;
}
read
的實作就比 open
簡單很多。在 read
系統呼叫的實作中,首先透過 fdget_pos
從目前的Process資訊 (task_struct) 中取得對應的 file
結構。接著,呼叫 vfs_read
函數來執行檔案的讀取操作。
// fs/read_write.c
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
ssize_t ret;
...
ret = file->f_op->read(file, buf, count, pos);
...
return ret;
}
在 vfs_read
函數中,核心步驟是呼叫 file->f_op->read
,進入到檔案系統驅動定義的read函數,由驅動去完成實際的檔案讀取。
我們今天透過 cat /var/log/dpkg.log
的例子,了解到了 open 及 read 這兩個 system call 是如何與 vfs 檔案系統還有檔案系統驅動協作,完成檔案的讀取操作。