虚拟文件系统
关于 VFS,有一篇 不错的介绍。这是 2.6.39 的代码,很简洁
Virtual File System Data Structure
超级块、Inode、目录项、文件对象 被称为文件系统的四大数据结构。无论底层的真实文件系统中是否有这些数据结构,在 VFS 中都会有对应的数据结构。
超级块 (struct super_block) 代表了整个文件系统,超级块是文件系统的控制块,有整个文件系统信息,一个文件系统所有的 inode 都要连接到超级块上,可以说,一个超级块就代表了一个文件系统。
// 这个list上边的就是所有的在linux上记录的文件系统
struct list_head s_list;
// 挂载点
struct dentry *s_root;
// 文件系统类型 也就是当前这个文件系统属于哪个类型 ext4/fat32
// 一个文件系统类型下可以包括很多文件系统即很多的super_block
struct file_system_type *s_type;
// Filesystem private info 也就是说你可以在这里存一些你想要的自定义的东西
void *s_fs_info;
Inode(struct inode) 保存的其实是实际的数据的一些信息,这些信息称为“元数据”(也就是对文件属性的描述)。例如:文件大小,设备标识符,用户标识符,用户组标识符,文件模式,扩展属性,文件读取或修改的时间戳,链接数量,指向存储该内容的磁盘区块的指针,文件分类等等。
// 这个就是我们平时看到的drwxrwxr-x的数据
umode_t i_mode;
//
union {
struct hlist_head i_dentry;
struct rcu_head i_rcu;
};
struct super_block *i_sb;
// 这就是 inode numebr
unsigned long i_ino;
// fs or device private pointer 也就是说你可以在这里存一些你想要的自定义的东西
void *i_private;
struct address_space *i_mapping;
struct address_space i_data;
目录项 (struct dentry):目录项是 描述文件 的逻辑属性,只存在于内存中,并没有实际对应的磁盘上的描述,更确切的说是存在于内存的目录项缓存,为了提高查找性能而设计。注意 不管是文件夹还是最终的文件,都是属于目录项,所有的目录项在一起构成一颗庞大的目录树。例如:open 一个文件/home/xxx/yyy.txt,那么/、home、xxx、yyy.txt 都是一个目录项,VFS 在查找的时候,根据一层一层的目录项找到对应的每个目录项的 inode,那么沿着目录项进行操作就可以找到最终的文件。
// small names 文件的名字,还有一个结构是d_name里面有一个指针指向了d_iname
unsigned char d_iname[DNAME_INLINE_LEN];
// Where the name belongs to
struct inode *d_inode;
// 对应的super_block
struct super_block *d_sb;
// fs-specific data 也就是说你可以在这里存一些你想要的自定义的东西
void *d_fsdata;
// 下面三个entry可以构成一个树形结构
// list_head是一个双向链表,指向的是其他d_entry对应的list_head entry,然后再用hack的宏完成指向真正的d_entry
// (struct dentry*)((char *)me->d_child.head -(char *)&((struct dentry *)0)->d_child))
// 对于非目录文件,d_subdirs 就指向他自己(这个entry)
struct dentry *d_parent; /* parent directory 父 */
struct list_head d_child; /* child of parent list 兄 */
struct list_head d_subdirs; /* our children 子 */
文件对象 (struct file):注意文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。但是由于文件是唯一的,那么 inode 就是唯一的,目录项也是定的。struct path
// path是一个简单的结构体,里面有两项,其中一项就是dentry
struct path f_path;
struct inode *f_inode; /* cached value */
// 一些flag规定文件的可读、可写、可执行权限
fmode_t f_mode;
const struct file_operations *f_op;
struct address_space *f_mapping;
// 你可以在这里存一些你想要的自定义的东西
void *private_data;
关于他们之间的转换:
- inode -> dentry: 利用
hlist_for_each_entry
宏遍历i_dentry
字段,可以参考 Linux 中的 一个例子。 - dentry -> inode: 利用
d_inode
函数,或者直接使用d_inode
字段。 - path -> dentry: 利用
dentry
字段。 - dentry -> char * 路径: 利用
dentry_path
函数。 - path -> char * 路径: 利用
d_path
函数。 - char * 路径 -> path: 利用 kern_path 函数。
- superblock -> inode:
iget_locked
或者iget5_locked
。
Virtual File System Operation
虚拟文件系统是类似于 Trait 一样的东西,肯定是要暴露一些接口供真正的实现。在 C 语言中就是一堆函数指针,有下面这些方法 (这些 struct 是一大堆方法的集合)
- file_system_type ->
mount
: the method to call when a new instance of this filesystem should be mounted - file_system_type ->
kill_sb
: the method to call when an instance of this filesystem should be shut down - super_block ->
struct super_operations
: This describes how the VFS can manipulate the superblock of your filesystem - statfs:
- inode ->
struct inode_operations
: This describes how the VFS can manipulate an inode in your filesystem. - dentry ->
struct dentry_operations
: This describes how a filesystem can overload the standard dentry operations. - file ->
struct file_operations
: This describes how the VFS can manipulate an open file. - address_space ->
struct address_space_operations
: This describes how the VFS can manipulate mapping of a file to page cache in your filesystem.
所以这些方法都要重写吗?没有默认实现吗?并不是的,有些可以是 NULL,表示不需要或者采用默认实现。这篇文章 提供了很好的参考,不过如果自己想要实现,需要注意内核版本。
下面的内容是一些零碎的笔记
mount
mount [-t vfstype] [-o options] [设备名称] [挂载点]
挂载点
: 必须是一个已经存在的目录,这个目录可以不为空,但挂载后这个目录下以前的内容将不可用,umount 以后会恢复正常设备名称
: 可以是一个分区,一个 usb 设备,光驱,软盘,网络共享等-t
指定文件系统的类型,通常不必指定。mount 会自动选择正确的类型。常用类型有:
从代码实现的角度,挂载的过程就是将代表这个文件系统的 "super_block" 结构体,加入由之前已经挂载的所有 filesystems 组成的双向链表中(即 s_list
)。
同一文件系统可以被挂载到多个 mount point,这被称为 "bind mount"(多个路径是 bind 在一起的):
mount --bind
其实这并不难理解,它就像是文件系统层面的一个 symbol link。
不同的文件系统也可以共用同一个 mount point,新挂载的文件系统会 覆盖 掉这个位置之前的文件系统(覆盖,指暂时不可见,并不是把内容覆盖了)。
rootfs
之前说挂载点必须是一个已存在的目录,所以 /
是怎么挂载上去的?蛋生鸡鸡生蛋?
rootfs 就是所谓的根文件系统。Linux 启动时,第一个必须挂载的是根文件系统,若系统不能从指定设备上挂载根文件系统,则系统会出错而退出启动。根文件系统包含系统启动时所必须的目录和关键性的文件
- init 进程的应用程序必须运行在根文件系统上
- 根文件系统提供了根目录
/
- linux 挂载分区时所依赖的信息存放于根文件系统
/etc/fstab
这个文件中
分析 inode 的生命周期
super_operations->alloc_inode
:this method is called byalloc_inode()
to allocate memory for struct inode and initialize it. If this function is not defined, a simple ‘struct inode’ is allocated. Normally alloc_inode will be used to allocate a larger structure which contains a ‘struct inode’ embedded within it.- 那么,什么地方调用了
alloc_inode
呢?有三个函数new_inode_pseudo
,iget5_locked
,iget_locked
new_inode_pseudo
是一个残废的函数,在proc
这个仅存在于内存的文件系统中有使用。iget5_locked
和iget_locked
主要功能都是根据 ino 在 super_block 中找 inode,不过iget5_locked
可以带上回调函数。- 研究 ext2 文件系统。
iget_locked
被ext2_iget
调用。虽然有若干个场景使用了这个函数,我们重点关注的是ext2_lookup
,他试图通过ext2_inode_by_name
,通过 dentry 的路径信息,去寻找 ino,然后再去寻找 inode。 - 所以,关键是
inode_operations->lookup
函数,这个函数实现了 dentry 到 inode 的转变,换句话说就是我们提供一个字符串路径,然后解析成 dentry,再去 lookup - lookup 可能会找到已经在内存中的 inode,还不在内存中的 inode,或者硬盘上也没有的 inode?
- alloc_inode 中会调用
inode_init_always
中会初始化 inode 的一些 entry,在 ext2_iget 中会根据硬盘上的 inode 去初始化 inode 的一些 entry。 super_operations->destroy_inode
:this method is called bydestroy_inode()
to release resources allocated for struct inode. It is only required if->alloc_inode
was defined and simply undoes anything done by->alloc_inode
.
归纳一下,路径 -> filename_lookup -> ext4_lookup(在这里会根据文件名查 ino) -> ext4_iget -> iget_locked(ino -> inode) -> alloc_inode(分配内存,初始化)
#0 alloc_inode (sb=0xffff888006106800) at fs/inode.c:226
#1 0xffffffff812ebe9e in iget_locked (sb=0xffff888006106800, ino=0x134) at fs/inode.c:1192
#2 0xffffffff81399ee0 in __ext4_iget (sb=0xffff888006106800, ino=0x134, flags=<optimized out>, function=0xffffffff82081c00 <__func__.72414> "ext4_lookup", line=<optimized out>) at fs/ext4/inode.c:4878
#3 0xffffffff813b2bf7 in ext4_lookup (flags=<optimized out>, dentry=<optimized out>, dir=<optimized out>) at fs/ext4/namei.c:1701
#4 ext4_lookup (dir=0xffff8880066e1a98, dentry=0xffff88800673dd80, flags=<optimized out>) at fs/ext4/namei.c:1676
#5 0xffffffff812d7ee2 in __lookup_slow (name=0xffffc90000393cf0, dir=0xffff8880066d5000, flags=0x1) at fs/namei.c:1664
#6 0xffffffff812d7feb in lookup_slow (name=0xffffc90000393cf0, dir=0xffff8880066d5000, flags=0x1) at fs/namei.c:1681
#7 0xffffffff812d81ea in walk_component (nd=0xffffc90000393ce0, flags=0x0) at fs/namei.c:1801
#8 0xffffffff812d90d0 in lookup_last (nd=<optimized out>) at fs/namei.c:2264
#9 path_lookupat (nd=0xffffc90000393ce0, flags=0x0, path=<optimized out>) at fs/namei.c:2309
#10 0xffffffff812ddb3e in filename_lookup (dfd=<optimized out>, name=0xffff8880075f1000, flags=0x1, path=0xffffc90000393e30, root=<optimized out>) at fs/namei.c:2339
#11 0xffffffff812ddcea in user_path_at_empty (dfd=0xffffff9c, name=<optimized out>, flags=0x1, path=0xffffc90000393e30, empty=<optimized out>) at fs/namei.c:2599
#12 0xffffffff812d033d in user_path_at (path=<optimized out>, flags=<optimized out>, name=<optimized out>, dfd=<optimized out>) at ./include/linux/namei.h:49
#13 vfs_statx (dfd=0xffffff9c, filename=0x555f6ae2e8e0 "local", flags=0x800, stat=<optimized out>, request_mask=0x7ff) at fs/stat.c:187
#14 0xffffffff812d093e in vfs_stat (stat=<optimized out>, filename=<optimized out>) at ./include/linux/fs.h:3253
#15 __do_sys_newstat (filename=<optimized out>, statbuf=0x7ffd5ddbc3c0) at fs/stat.c:341
#16 0xffffffff812d0996 in __se_sys_newstat (statbuf=<optimized out>, filename=<optimized out>) at fs/stat.c:337
#17 __x64_sys_newstat (regs=<optimized out>) at fs/stat.c:337
#18 0xffffffff81004017 in do_syscall_64 (nr=<optimized out>, regs=0xffffc90000393f58) at arch/x86/entry/common.c:290
#19 0xffffffff81c0008c in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:175
内核读写文件
Since version 4.14 of Linux kernel, vfs_read
and vfs_write
functions are no longer exported for use in modules. Instead, functions exclusively for kernel's file access are provided:
# Read the file from the kernel space.
ssize_t kernel_read(struct file *file, void *buf, size_t count, loff_t *pos);
# Write the file from the kernel space.
ssize_t kernel_write(struct file *file, const void *buf, size_t count,
loff_t *pos);
Also, filp_open
no longer accepts user-space string, so it can be used for kernel access directly (without dance with set_fs
).
打开文件,可以参考一个 驱动的例子。
Registering and Mounting a Filesystem
这是用于注册 file system type 的接口。
extern int register_filesystem(struct file_system_type *);
extern int unregister_filesystem(struct file_system_type *);
一般的,我们可以定义一个我们 file_system_type
然后注册他。虽然它有很多字段,但是看起来下面的字段是必须的,代码来自 5.17
static struct file_system_type ext2_fs_type = {
.owner = THIS_MODULE,
.name = "ext2",
.mount = ext4_mount,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};
上面我们提到了,mount
和 kill_sb
是“接口”,也就是函数指针。我们应该怎么去实现他们呢?参考一些代码,一般来说,会是简单封装下面几个函数之一,ext4_mount
就是对 mount_bdev
的简单封装。
extern struct dentry *mount_bdev(struct file_system_type *fs_type,
int flags, const char *dev_name, void *data,
int (*fill_super)(struct super_block *, void *, int));
extern struct dentry *mount_single(struct file_system_type *fs_type,
int flags, void *data,
int (*fill_super)(struct super_block *, void *, int));
extern struct dentry *mount_nodev(struct file_system_type *fs_type,
int flags, void *data,
int (*fill_super)(struct super_block *, void *, int));
参照 ext4_mount
的代码。所以,看起来 fill_super
函数的实现才是重点所在。
我们之前提到,每个挂载的文件系统和 super_block
是一一对应的。顾名思义 fill_super
就是装填 super_block
。他的三个参数分别是
struct super_block *sb
the superblock structure. The callback must initialize this properly.void *data
arbitrary mount options, usually comes as an ASCII string (see “Mount Options” section)int silent
whether or not to be silent on error
fs_struct 和 files_struct
顺带一提,task_struct
中有两个结构
/* Filesystem information: */
struct fs_struct *fs;
/* Open file information: */
struct files_struct *files;
我们常说的 打开文件表 指的是后者,files_struct -> fdt
指向了打开文件表,fdtable -> fd
是一个 file
数组。我们获得的 文件描述符 其实就是这个数组的下标,fd[fd]
就是一个 file *
类型。
// include/linux/fdtable.h of linux-2.6.37
struct files_struct {
/*
* read mostly part
*/
atomic_t count;
struct fdtable __rcu *fdt;
struct fdtable fdtab;
/*
* written part on a separate cache line in SMP
*/
spinlock_t file_lock ____cacheline_aligned_in_smp;
int next_fd;
struct embedded_fd_set close_on_exec_init;
struct embedded_fd_set open_fds_init;
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};
struct fdtable {
unsigned int max_fds;
struct file __rcu **fd; /* current fd array */
fd_set *close_on_exec;
fd_set *open_fds;
struct rcu_head rcu;
struct fdtable *next;
};
数据结构示意图如下,转载自知乎的陈硕大佬。
一个迷惑的点是 fdtab
和 fdt
有什么区别?为什么一个结构里有两个表?我看到一个解释是下面这样的,调试的时候发现有时 fdt
并不指向 fdtab
,有时确实是一个东西。
为什么有两个 fdtable 呢?这是内核的一种优化策略。fdt 为指针,而 fdtab 为普通变量。一般情况下,fdt 是指向 fdtab 的,当需要它的时候,才会真正动态申请内存。因为默认大小的文件表足以应付大多数情况,大多数进程不会打开很多的文件,因此这样就可以避免频繁的内存申请。这也是内核的常用技巧之一。在创建时, 使用普通的变量或者数组,然后让指针指向它,作为默认情况使用。只有当进程使用量超过默认值时,才会动态申请内存。
分析 open 系统调用
open
, openat
, creat
都可以起到打开一个文件的作用。他们是不同的系统调用,不过真正的执行的代码是差不多的,进过一些检查,最后调用 do_sys_open()
。
long do_sys_open(int dfd, const char __user *filename, int flags,
umode_t mode);
// dfd: dirfd
// filename
// flags
// mode
阅读 do_sys_open()
源代码,执行流程大致如下(先忽略 flags):
- 先调用
getname
, 主要功能是在使用文件名之前将其拷贝到内核数据区,正常结束时返回内核分配的空间首地址。 - 在调用
get_unused_fd_flags
,顾名思义是取得系统中可用的文件描述符 fd。 - 然后是重头戏,
struct file *f = do_filp_open(dfd, tmp, &op);
fd_install
,感觉是把f
存下来,再把fd
和f
关联起来。
阅读 do_filp_open()
源代码。
set_nameidata
: 调用set_nameidata()
保护当前进程现场信息,nameidata
这个结构用来辅助路径查找。- 然后是重头戏,
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
restore_nameidata
阅读 path_openat()
源代码。重点关注忽略了所有失败情况的下面的代码
file = alloc_empty_file(op->open_flag, current_cred());
const char *s = path_init(nd, flags);
while (!(error = link_path_walk(s, nd)) &&
(error = do_last(nd, file, op)) > 0) {
nd->flags &= ~(LOOKUP_OPEN|LOOKUP_CREATE|LOOKUP_EXCL);
s = trailing_symlink(nd);
}
terminate_walk(nd);
return file;
利用 Wrapfs 管理权限
限制文件读写:
- 从
file_operations.read
和file_operations.write
入手,并不好使,具体原因有待进一步发现。(可能是file_operations.read_iter
和file_operations.write_iter
的关系。因为就现在的文件系统,大部分都实现了后两者而不是前两者。) - 从
inode_operations.permission
入手,相当于是可以动态的修改一个文件的rwx
权限了,这样确实可以对读写进行有效控制,但是阻止不了移动、删除,因为移动和删除相当于是目录的权限管理了。 - 从
inode_operations.fileattr_get
入手(还有一个很具有迷惑性的方法getattr
)(4.4 没有这个方法!) - 从
file_operations.unlocked_ioctl
和file_operations.compat_ioctl
入手,结果发现在修改一个被我加了i
属性的文件时根本没有调用过,只在lsattr
的时候会调用,奇怪。 -
跟踪发现是
faccessat
这个调用族做的检查faccessat(AT_FDCWD, "aaa", W_OK) = -1 EPERM (Operation not permitted) faccessat(AT_FDCWD, "aaa", W_OK) = 0
源代码https://elixir.bootlin.com/linux/v4.4.295/source/fs/open.c#L337
-
考虑
inode_operations.rename
和inode_operations.unlink
. 因为跟踪发现他们和文件的移动和删除密切相关,用strace
跟踪 mv 和 rm 的执行。
mv => renameat2(AT_FDCWD, "../i.md", AT_FDCWD, "../i1.md", RENAME_NOREPLACE) = 0
rm => unlinkat(AT_FDCWD, "aaa", 0) = 0
总结:从 permission
,rename
,unlink
入手。
执行权限
以 Linux5.4 为例,跟踪 inode_permission
函数在 execve
的何时被调用进行权限检查。
[#0] 0xffffffff812d8690 → inode_permission(inode=0xffff8880064f1650, mask=0x81)
[#1] 0xffffffff812d8aed → may_lookup(nd=<optimized out>)
[#2] 0xffffffff812d8aed → link_path_walk(name=0xffff888004ce2021 "bin/ls", nd=0xffffc9000020fd10)
[#3] 0xffffffff812db4c6 → link_path_walk(nd=<optimized out>, name=<optimized out>)
[#4] 0xffffffff812db4c6 → path_openat(nd=0xffffc9000020fd10, op=0xffffc9000020fe24, flags=<optimized out>)
[#5] 0xffffffff812ddfa1 → do_filp_open(dfd=<optimized out>, pathname=<optimized out>, op=0xffffc9000020fe24)
[#6] 0xffffffff812d16a7 → do_open_execat(fd=<optimized out>, name=0xffff888004ce2000, flags=<optimized out>)
[#7] 0xffffffff812d35c8 → __do_execve_file(fd=<optimized out>, filename=0xffff888004ce2000, flags=0x0, file=0x0 <fixed_percpu_data>, argv=<optimized out>, envp=<optimized out>)
[#8] 0xffffffff812d39a9 → do_execveat_common(flags=<optimized out>, filename=<optimized out>, fd=<optimized out>, argv=<optimized out>, envp=<optimized out>)
[#9] 0xffffffff812d39a9 → do_execve(__envp=<optimized out>, __argv=<optimized out>, filename=<optimized out>)
添加权限字段
怎么向 inode
结构添加东西呢?
- inode 的诞生:
super_operations->alloc_inode
: this method is called byalloc_inode()
to allocate memory for struct inode and initialize it. If this function is not defined, a simple ‘struct inode’ is allocated. Normallyalloc_inode
will be used to allocate a larger structure which contains a ‘struct inode’ embedded within it.
我们定制他来产生 wrapfs_inode
<fsname_iget>
: 从设备中读取 inode
- inode 的使用,从场景出发
- 用户如果需要使用一个文件,他会使用
open
系列的系统调用,得到一个文件描述符。对于 kernel 来说,我们产生了一个file
代表打开的文件,在上面介绍的进程的files
字段中会存储所有打开的文件。之后的读写就针对file
进行。 - 所以
open
是怎么找到inode
的呢?简单的说,根据路径进行遍历。 - inode 的回收:
super_operations->destroy_inode
: this method is called bydestroy_inode()
to release resources allocated for struct inode. It is only required ifsuper_operations->alloc_inode
was defined and simply undoes anything done bysuper_operations->alloc_inode
. - 由于我们的文件系统是完全存在于内存的,可以忽略 inode 的 dirty,write 等和设备同步的操作。
和用户交换信息
从用户获得的信息最好是 filename
+ permission
。利用 kern_path()
函数可以根据文件名获得 struct path
,里面有 mnt
还有 dentry
,这样拿到对应的 inode 就不是问题。
发送给用户的信息可以是 uuid+inode
,或者可以利用 inode
中的 i_dentry
链表选择一个文件名来保护,反正最后的权限会落到 inode。
FUSE
fuse - Filesystem in Userspace (FUSE) device.
这个模型可以让你 在 User Level 实现文件系统,原理大致是 Kernel 作为 Client,而你的进程(往往是 Daemon)作为 Server,双方进行通信,你可以 Hook 一些操作。有一个 Python 的 API 绑定。
https://www.kernel.org/doc/html/latest/filesystems/vfs.html#struct-file-system-type
https://zhuanlan.zhihu.com/p/34280875