ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

文件描述符fd与VFS:Linux“一切皆文件”内核机制深度解析

文件描述符fd与VFS:Linux“一切皆文件”内核机制深度解析 1. fd是什么一张表引出的“一切皆文件”1.1 从open()到fd你拿到的其实是一张“取餐小票”大家写Linux程序时第一次接触fdfile descriptor文件描述符基本都是在学文件操作的时候。int fd open(/etc/passwd, O_RDONLY);然后拿着这个fd去read(fd, buf, sizeof(buf))用完再close(fd)。这个流程就像去餐馆吃饭你进门说我要一份宫保鸡丁open服务员给你一张号牌fd厨房做好了按号牌叫号read/write吃完把号牌还回去close。fd本质上就是一张“取餐小票”但它背后藏着一整套内核的文件管理机制。之前我刚开始接触Linux驱动和内核模块的时候对fd的理解就停留在“int整数”这个层面直到有一次排查一个文件描述符泄漏的问题被折磨了整整两天才真正把fd、file结构体、inode之间的关系彻底搞明白。fd本身是一个非负整数在用户态看就是一个普通的int。进程每次打开一个文件内核就会在进程的文件描述符表里分配一个最小的空闲编号返回给用户态。这个编号是从0开始的而且0、1、2默认被标准输入、标准输出、标准错误占用新打开的fd默认从3开始。如果你看到哪个程序打印出来第一个打开的fd是3不要惊讶这是很正常的现象。1.2 三张表的协作fd表、文件表、inodefd只是一个索引号真正干活的是一套三层结构。搞清楚这个三层结构基本就能看透Linux内核里“文件”这个词的全部含义。第一层进程级的文件描述符表fd table。每个进程在内核里都有一个struct files_struct里面的fdtable维护了一张数组数组下标就是fd。这张表记录了每个fd指向哪个打开的文件描述struct file。两个不同的进程即使打开的是同一个文件它们各自的fd表也是独立的——A进程的fd 5和B进程的fd 5可能指向完全不同的文件也可能指向同一个文件的同一个打开实例这种情况后面会说。第二层系统级的打开文件表open file table。每次open()成功内核都会创建一个struct file实例。这个结构体里包含什么文件当前的读写位置偏移量f_pos、文件打开时的标志f_flags比如O_RDONLY、O_NONBLOCK、引用计数f_count等。多个fd可以指向同一个struct file典型场景就是dup()和dup2()复制fd时复制出来的新fd和原fd共享同一个struct file共享同一个偏移量。第三层inode。inode代表文件本身不是打开实例。无论这个文件被open了多少次inode始终只有一个。inode里面存的是文件的元数据文件类型、权限、大小、时间戳、数据块位置等。struct file中有一个指针f_inode指向对应的inode还有一个f_op指针指向struct file_operations。这三层的关系可以用一个表格来总结层级作用域核心结构典型属性fd表进程级struct files_struct → fdtablefd编号、指向struct file的指针打开文件表系统级全局struct filef_pos偏移量、f_flags标志、f_count引用计数inode文件系统级struct inode文件类型、权限、大小、数据块地址这张三层结构图给我最大的冲击是fd并不等于文件fd只是一个索引真正的打开文件实例在系统级共享inode才是文件本体。理解了这张图后面遇到fork之后父子进程共享偏移量、多次open同一个文件却各自独立偏移量这类问题就非常清晰了。2. “一切皆文件”落到内核VFS和file_operations2.1 VFS把硬件差异全都抹平了“一切皆文件”Everything is a file这个口号在很多Linux入门文章里都被当作哲学理念来讲。但我想说这句话根本不是哲学它是内核代码里实实在在实现出来的东西。它的基石就是VFSVirtual File System虚拟文件系统。VFS说白了一层中介层。上层是系统调用接口比如read()、write()、open()、close()中间是VFS提供的统一抽象下层是各种具体的文件系统实现——ext4、xfs、btrfs、procfs、sysfs、devtmpfs甚至网络文件系统NFS。VFS定义了一套标准接口让上层的系统调用不再直接跟具体文件系统扯皮只管跟VFS对接。这套标准接口的核心就是四个对象super_block代表一个已挂载的文件系统实例、inode代表文件元数据、dentry代表目录项路径解析用的缓存节点、file代表打开的实例。对应到我们平常的认知大概是这样的你在终端敲ls -l /etc/passwd看到的是inode层面的信息文件类型、权限、大小。你用open()打开它操作接触的是file层面的东西偏移量、读写模式。你在文件系统里找到它的路径/etc/passwd走的是dentry和super_block的路子。正因为有了这层抽象Linux才能实现“一个read()系统调用既可以从普通文件读数据也可以从U盘读数据还可以从socket读数据甚至可以从传感器设备节点读数据”。你写应用层代码时根本不用关心底下访问的到底是个什么实体你只需要知道它有fd能read、能write。2.2 file_operations驱动和文件系统共用的一套“接口协议”把“一切皆文件”落到实处最关键的是struct file_operations结构体。这是Linux驱动开发和文件系统开发都绕不开的一个核心结构。struct file_operations { loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); int (*open) (struct inode *, struct file *); int (*release) (struct inode *, struct file *); unsigned int (*poll) (struct file *, struct poll_table_struct *); long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); int (*mmap) (struct file *, struct vm_area_struct *); int (*fsync) (struct file *, loff_t, loff_t, int datasync); // ... 还有更多 };这个结构体的本质就是一组函数指针。文件系统把read指向自己的读取函数驱动也把read指向自己的读取函数。用户态调用read(fd, ...)时内核通过fd找到struct file再通过f_op找到file_operations然后调用f_op-read。至于这个read函数最终是去磁盘读数据、从网络收包、还是读一个内核变量VFS完全不关心。你仔细品一下这个设计file_operations就是一份统一的“接口合同”不管你是ext4、是SD卡驱动、是/proc/cpuinfo只要你想以文件的形式暴露给用户态你就得照着这份合同来实现函数。这种设计跟C里的虚函数、Go里的interface是一个思路只不过Linux内核用C语言把它做得非常地道。2.3 伪文件系统/proc、/sys是怎么变成“文件”的说到“一切皆文件”很多初学者最容易困惑的是/proc/meminfo、/sys/class下面的东西既不是硬盘上的文件打开也不读磁盘它们怎么也能open和read答案就是伪文件系统。procfs和sysfs根本没有把数据写到磁盘上它们只是实现了VFS规定的那些接口。你cat /proc/meminfo时实际发生的事是内核打开/proc/meminfo这个“文件”的inode然后调用procfs注册的proc_meminfo_read函数这个函数现场把内存信息拼成一段字符串拷贝到用户态缓冲区返回给你。这就是为什么你每次cat同一个proc文件内容可能不一样——因为数据本来就是“现算出来的”根本不存在磁盘上的实体文件。伪文件系统的存在直接验证了“一切皆文件”的含义Linux所谓的“文件”本质上是实现了file_operations接口的抽象对象不一定有实体数据存储。网络socket网络协议栈也实现了自己的struct file_operationssockfs所以socket()系统调用返回的也是一个fd也可以被read()和write()直接操作。3. 动手验证写一个自己的“文件”内核模块3.1 最小内核模块创建/proc/hello并实现read/write光谈理论容易飘。为了让你彻底相信“文件就是一组接口”我建议自己动手写一个最简单的procfs内核模块。这个模块不依赖任何硬件只需要你有一台能编译内核模块的Linux机器Ubuntu/CentOS都行装好linux-headers即可。先看代码一个标准的最小实现#include linux/module.h #include linux/kernel.h #include linux/proc_fs.h #include linux/uaccess.h #include linux/string.h #define PROC_ENTRY_NAME hello_fd static char module_buf[128] hello, this is a proc file created by module!\n; static int buf_len; static ssize_t hello_read(struct file *file, char __user *user_buf, size_t size, loff_t *offset) { // 这一段是很多初学者容易漏掉的关键逻辑 if (*offset buf_len) { return 0; // EOF告诉用户态读完了 } size_t remain buf_len - *offset; size_t to_copy (size remain) ? size : remain; if (copy_to_user(user_buf, module_buf *offset, to_copy)) { return -EFAULT; } *offset to_copy; return to_copy; } static ssize_t hello_write(struct file *file, const char __user *user_buf, size_t size, loff_t *offset) { if (size sizeof(module_buf)) { return -EINVAL; } if (copy_from_user(module_buf, user_buf, size)) { return -EFAULT; } module_buf[size] \0; buf_len size; return size; } static int hello_open(struct inode *inode, struct file *file) { pr_info(hello_fd: open called\n); return 0; } static int hello_release(struct inode *inode, struct file *file) { pr_info(hello_fd: release called\n); return 0; } static const struct file_operations hello_fops { .owner THIS_MODULE, .open hello_open, .read hello_read, .write hello_write, .release hello_release, }; static int __init hello_init(void) { buf_len strlen(module_buf); struct proc_dir_entry *entry proc_create(PROC_ENTRY_NAME, 0666, NULL, hello_fops); if (!entry) { pr_err(hello_fd: proc_create failed\n); return -ENOMEM; } pr_info(hello_fd: module loaded\n); return 0; } static void __exit hello_exit(void) { remove_proc_entry(PROC_ENTRY_NAME, NULL); pr_info(hello_fd: module unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL);编译的Makefile如下obj-m : hello_fd.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean然后依次执行make sudo insmod hello_fd.ko模块加载成功后/proc/hello_fd就出现了。3.2 验证与调试从用户态看内核态的文件接口模块加载成功后在终端操作一下cat /proc/hello_fd你会看到输出hello, this is a proc file created by module!。然后试试往里面写echo something /proc/hello_fd cat /proc/hello_fd你会发现内容变成了something。这就是我们自己的file_operations中的write和read在配合工作echo把数据从用户态拷贝到内核copy_from_usercat把数据从内核拷贝回用户态copy_to_user。这里特别想强调一下read函数里*offset的处理逻辑。很多第一次写驱动的人总是漏掉这个偏移量判断结果就是cat工具疯狂输出同一段数据停不下来。为什么因为cat会不停地调用read()直到内核返回0EOF才认为文件读完了。如果每次read()都不更新*offset每次返回同样的长度cat就会一直循环读永远不结束。sudo dmesg | tail -n 20查看内核日志你会看到hello_fd: open called和hello_fd: release called。这验证了一件重要的事你在用户态执行open()和close()时内核确实会回调到我们注册的hello_open和hello_release函数。这个模块跑通之后再看一切皆文件这个概念感觉完全不一样了。所谓的“文件”真的就是只要你把file_operations这一组函数指针填好、注册到VFS里用户态就能用标准的open/read/write/close来操作你自定义的东西。3.3 引申如果想做文件透明加密或read/write拦截顺着上面这个模块的思路稍微提一个实际工程里常见的需求拦截read/write做透明加密。这个方向在数据安全产品里非常常见经常有文章讨论“linux 内核 透明加密”或“动态加载file_operations拦截read write”。一种思路是找到目标文件的struct file把它的f_op指向你自己实现的file_operations在你自己的read里先解密再返回数据在你自己的write里先加密再写到磁盘。例如你可以把原始的read保存到orig_fop_read然后在自己的函数里先调用原始read读出密文再解密放回用户缓冲区。ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) { ssize_t ret; char *tmp_buf kmalloc(count, GFP_KERNEL); // 先调用原始read读出密文 ret orig_fop_read(filp, tmp_buf, count, f_pos); if (ret 0) { // 对tmp_buf里的数据进行解密 decrypt(tmp_buf, ret); copy_to_user(buf, tmp_buf, ret); } kfree(tmp_buf); return ret; }这类做法的难点在于怎么找到目标文件的struct file *指针、怎么安全地替换f_op而不引起并发问题、怎么处理mmap和direct IO绕过read/write的情况。这些细节展开又是一大篇经验是先做拦截验证再考虑加密算法最后处理边界情况。4. 常见问题与排查技巧实录4.1 fd泄漏/proc/PID/fd一眼看穿fd泄漏就是进程不停地打开文件却忘了关闭最终把fd表耗尽。用专业点的话说struct file的引用计数f_count永远不能归零内核无法回收这个打开实例。fd泄漏的后果很直接进程无法再打开新的文件报EMFILE错误日志里输出Too many open files。排查fd泄漏最快捷的方式是看/proc/PID/fd目录ls -l /proc/12345/fd | wc -l这个命令统计的是进程12345当前打开的fd数量。正常情况一个服务进程打开的fd可能是个位数到几十个如果你发现这个数字持续上涨、超过几千甚至几万那基本可以断定有fd泄漏。你还可以具体看看每个fd指向哪里ls -l /proc/12345/fd输出里每一行就是一个符号链接指向实际打开的目标。你会看到/dev/null、/var/log/app.log、socket:[123456]这样的内容。看到同一个文件出现很多条记录就能知道哪个逻辑出了问题。还有一个命令也非常好用——lsof -p 12345需要安装lsof工具它会比/proc/PID/fd展示得更友好会显示fd编号、文件类型、文件路径还能排序筛选。4.2 EAGAIN、O_NONBLOCK和文件状态标志的共享在做网络编程或者管道读写时EAGAIN这个错误码应该是老朋友了。它的含义是“现在没有数据可读/写”你要是用阻塞模式内核会一直挂住等待但如果你把fd设置成了非阻塞O_NONBLOCK内核不会等直接返回EAGAIN告诉你“工作没做完下次再来”。关于O_NONBLOCK有一个很隐蔽的坑这个标志挂在struct file上而不是进程上。前面说过dup()和fork()可以让多个fd共享同一个struct file那么只要其中一个fd把这个文件设置成非阻塞所有共享这个struct file的fd都会变成非阻塞。我在实际项目里就碰到过子进程fork之后父进程把一个fd设成了O_NONBLOCK子进程那边的同fd没有主动设置却也跟着变成非阻塞了导致子进程的阻塞读直接返回EAGAIN数据没读到。排查这类问题可以在代码里用fcntl(fd, F_GETFL)打印一下当前的状态标志int flags fcntl(fd, F_GETFL, 0); if (flags O_NONBLOCK) { printf(fd %d is nonblock\n, fd); }在写网络代理、事件循环程序的时候这个细节特别容易害人。4.3 fd耗尽与socket的特殊性最后聊一下socket和fd的关系。socket()返回一个fd但它走的不是普通的文件系统而是sockfs这个伪文件系统。socket在“文件化”之后最大的好处是可以复用fd所关联的所有基础设施你可以用select/poll/epoll监听它可以用read/write收发数据可以用close关闭它这些操作和其他fd完全一致。但socket的fd也有一些和普通文件fd不一样的地方。最典型的是fork()之后父进程创建的socket fd子进程也能用——因为你fork的时候复制了fd表父子进程共享同一个socket文件实例引用计数增加。如果不注意close两边都继续保持打开状态就会出现socket永远无法真正关闭的情况。这种情况在HTTP服务器“惊群”问题的分析里经常被翻出来多个子进程共享同一个监听socket一个连接到来时多个进程同时被唤醒。还有一个值得注意的点fd是进程级的概念不是线程级的。多线程程序里一个线程创建的fd其他线程也可以用因为线程共享进程的fd表。但这也意味着如果有线程在关闭fd另一个线程正拿着这个fd做IO程序行为是不确定的也容易引发bug需要自己用锁或者refcount机制来保护。关于fd的坑还有很多比如O_CLOEXEC标志exec之后自动关闭fd、epoll自身也会吃一个fd、eventfd本质也是一个特殊的文件。核心思路是一样的记住fd是索引真正释放资源的时候必须理解struct file的引用计数机制。踩过几次坑之后你会发现自己再看到everything is a file这句话不再觉得它是一个宣传口号而是真的在代码层面量身定做了一套统一机制。
返回列表