ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

《从零入门Linux系统篇(三十):文件篇·三——深入VFS:从标准错误、struct file到“一切皆文件”的本质》

《从零入门Linux系统篇(三十):文件篇·三——深入VFS:从标准错误、struct file到“一切皆文件”的本质》 这是文件篇里承上启下的一篇。我们要做两件事。第一把重定向和struct file相关理解做一次收尾。前面挖过的坑该填的填该夯实的夯实。第二也是本文的重头戏深度拆解Linux VFS虚拟文件系统与设备模型的立体交叉架构。我们要看穿“一切皆文件”这个口号背后到底靠什么撑起了一个统一的抽象层为什么读普通文件、读磁盘、读键盘、读网卡在系统调用眼里全都长一个样。这背后的核心其实是一个词多态。Linux内核用一套统一的接口把千差万别的底层设备全“伪装”成了文件。这份抽象功力是整个Linux设计美学里最浓墨重彩的一笔。好的我们直接开始。目录一、重新认识重定向——标准输出与标准错误1.1 为什么需要标准错误stderr1.2 深入理解重定向——输出究竟去了哪里1.3 命令行重定向的进阶用法1.3.1 标准输出与错误输出分流1.3.2 将标准输出与标准错误合并到同一个文件二、深入理解struct file——进程与文件之间的桥梁2.1 从“文件本质”重新认识文件2.1.1 文件读写位置——f_pos2.2 进程与文件的解耦三、深入Linux VFS——理解“一切皆文件”的真正含义3.1 VFS的整体架构与设计思想3.2 VFS如何统一不同类型的文件3.3 从源码理解VFS的核心机制3.3.1 struct file_operations——统一文件操作接口3.3.2 从文件到设备——底层设备管理的连接关系3.3.3 加餐——设备文件背后的源码实现3.4 VFS 背后的设计思想——统一抽象与解耦一、重新认识重定向——标准输出与标准错误1.1 为什么需要标准错误stderr前面我们反复提过Linux进程一启动就有三个标准流自动就位标准输入0、标准输出1、标准错误2。对应到C/C的上层接口分工很明确冲1号stdout去的printf、std::cout冲2号stderr去的perror、std::cerr问题来了既然标准输出1和标准错误2默认都往屏幕上打那何必多此一举硬拆出个2号来核心原因一句话把常规消息和错误消息分开方便日志的收集和治理。想象一个大型生产环境程序一边吐着常规运行日志一边夹着灾难性的错误信息两条流混在一起排查问题就像在一锅粥里捞针。眼睛都看花了也分不清哪行是正常心跳哪行是死亡信号。但如果从底层就把它们剥成两股流呢我们就可以靠重定向各取所需把常规日志导进access.log慢慢看把错误日志单独导进error.log重点盯。该监控监控该告警告警两不耽误。这才有了2这种“只要错误”的重定向玩法没有stderr的分离这种操作根本无从谈起。所以2号文件描述符不是冗余设计它是生产环境里排障救命的家伙。1.2 深入理解重定向——输出究竟去了哪里为了把标准输出和标准错误的分工看个真切我们写一段测试程序#include iostream #include cstdio int main() { // 往标准输出1号打印 std::cout hello cout std::endl; printf(hello stdout\n); // 往标准错误2号打印 std::cerr hello cerr std::endl; fprintf(stderr, hello stderr\n); return 0; }直接运行./a.out屏幕会毫无保留地吐出四行内容常规信息和错误信息混在一起热热闹闹。接着我们执行那条最熟悉的基础重定向./a.out log.txt神奇的一幕出现了屏幕上居然还赖着两行字不肯消失。hello cerr hello stderr而我们再打开log.txt一瞧里面只躺着另外两行hello cout hello stdout看明白了吗这个符号只对1号标准输出下手。它把stdout的内容一股脑导进了文件却对2号标准错误视而不见。所以 cerr 和 stderr 依然固执地打在屏幕上这就是重定向的边界也是标准错误存在的意义。它不跟标准输出同流合污关键时刻才分得开排得了雷。为什么标准错误没有被拦截到文件里因为在Linux命令行里我们平时敲的那个完整写法其实是1。它的真实含义是“把本该输出到1号标准输出的内容重定向到文件里去。”套用我们前面反复讲的底层原理上层不变底层变。这个操作只动了一个地方把1号文件描述符下标指向的struct file地址从标准显示器换成了log.txt。而2号下标呢压根没人碰它它还是死死指着屏幕纹丝不动。所以stdout和stderr虽然平时都往同一个屏幕上凑但在内核里它们走的是两条完全独立的通道。只改了1号通道的终点2号通道该去哪还去哪。这正是标准错误能“幸免于难”的根本原因。那如果我们反过来想让常规输出继续留在屏幕上只把错误信息单独导进文件里怎么办明确指定2就行./a.out 2 err.txt这么一写2号标准错误被乖乖送进了err.txt而1号标准输出还是照常打在屏幕上。想同时分离两路也简单./a.out 1 out.txt 2 err.txt标准输出进out.txt标准错误进err.txt从此井水不犯河水。这就是1号和2号分开设计的意义想让它们同流合污容易想让它们分道扬镳也只是一条命令的事。1.3 命令行重定向的进阶用法实际开发和运维里我们常常需要把常规日志和错误日志分开处理。基于前面的原理衍生出了几种主流写法。1.3.1 标准输出与错误输出分流我们可以让1号流和2号流各自重定向到不同的文件实现清爽的日志分流./a.out 1 normal.txt 2 error.txt跑完之后屏幕上一片干净。常规输出进了normal.txt错误信息进了error.txt谁在哪个文件一眼看得明明白白。1.3.2 将标准输出与标准错误合并到同一个文件有些场景下我们懒得区分什么常规、什么错误只想把所有输出一股脑儿塞进同一个文件里存着。这时候该写什么Linux命令行提供了一种非常经典的“合并重定向”写法./a.out log.txt 21这串命令怎么理解关键就在最后那个 21。1代表的不是“文件名叫1”而是1号文件描述符本身。用我们前面dup2的逻辑来翻译21干的就是这件事把1号文件描述符表项里的内容拷贝覆盖到2号文件描述符表项中。而注意顺序log.txt已经先把1号位指向了log.txt所以这时1号表项里存的是log.txt的struct file地址。接着21再把这个地址复制给2号结果就是原本都指着显示器的1号和2号现在齐刷刷都指向了log.txt。从此cout也好cerr也罢不管哪个流数据都源源不断地汇进同一个文件。屏幕上干干净净文件里满满当当。一句话总结21就是让标准错误“跟着标准输出走”标准输出去哪它就去哪。这是日志合并最经典、也最常用的手法。二、深入理解struct file——进程与文件之间的桥梁这一节放在这里可能稍显臃肿但很多东西实在想一次讲透舍不得砍。2.1 从“文件本质”重新认识文件从最底层的物理存储来看在操作系统的眼里世界上所有文件不管它是代码、图片、音乐还是视频本质都是一串连续的、由0和1组成的字节流。而在C/C的底层语境里char就是一个字节。所以说“所有文件本质上都是一个char arr[]”这个理解一点不夸张反而非常精准。文件不过是一个巨大的字符数组躺在磁盘上等着被读、被写、被移动。2.1.1 文件读写位置——f_pos既然文件是一个char arr[]那读写文件这件事说到底就是在操作这个数组的“当前位置 偏移量”。打个比方文件就像一本极长的书你用一个手指按在某一行上。这个手指就是读写指针。每次读或写都是从这个手指按着的位置开始读一点手指往后挪一点写一点手指也往后挪一点。手指不会跳来跳去它始终标记着下一次读写从哪开始。这个“手指”在内核里有个正式名字就藏在struct file的源码里loff_t f_pos; // 当前读写偏移量f_pos干的就是这件事。每次你调用read或write系统都会自动更新它让它跟着数据流向前推进。你读10个字节它往后走10格你写20个字节它再往后挪20格。所以一个文件被打开之后它内部其实记着三样关键状态我有多少内容、我现在读/写到哪了、我是只读还是可写。其中“读/写到哪了”就完全押在f_pos这一个变量上。没有它读到的永远是开头写出来的永远是覆盖文件操作就全乱了套。这个小小的偏移量是整个文件I/O流能够连续、有序推进的关键。2.2 进程与文件的解耦Linux内核的设计向来信奉“各管各的别瞎掺和”。进程管理模块管好它的task_struct文件系统模块管好它的struct file两者在底层分得干干净净谁也不过问谁的家事。在文件系统这边一个文件需要维护的东西归根结底就两块文件结构体struct file负责描述这个文件的“身份信息”它叫什么、多大、什么权限、当前读写到哪了、打开方式是什么。一句话它管的是“文件的户口本”。内存结构体内核文件缓冲区负责存放这个文件的“实际内容”。文件从磁盘读进来内容就暂存在这块缓冲区里要写出去的数据也先在这里集结再由操作系统定期刷到磁盘。它管的是“文件的血肉”。一个管身份一个管内容分工明确。进程那边也一样它只关心自己的task_struct和文件描述符表至于文件本身怎么组织、怎么缓冲它一概不操心。需要访问文件时就通过fd这个“门牌号”找过去文件系统自会把该给的内容准备好。这种解耦最直接的好处就是灵活。同一个文件可以被多个进程同时打开各自持有各自的struct file但底层共享同一份缓冲区而一个进程也可以同时打开成百上千个文件每个文件都拥有独立的struct file互不干扰。进程和文件就像两条并行的轨道靠文件描述符这块“转接板”连接需要时合流不需要时各走各路。整个系统的复杂度就这样被拆成了一个个独立的模块清清爽爽。三、深入Linux VFS——理解“一切皆文件”的真正含义3.1 VFS的整体架构与设计思想在Linux操作系统中有一句话被奉为圭臬一切皆文件。不管是老老实实躺在磁盘上的普通文件还是那些看起来跟“文件”八竿子打不着的物理设备——键盘、鼠标、磁盘、网卡在内核眼里统统都被抽象成了文件。可问题来了我们计算机上挤着这么多设备每种设备的读写方法都天差地别。键盘靠中断报键码磁盘靠磁头扫扇区网卡靠DMA搬数据包。它们的底层实现调用的驱动、走的协议、操作的寄存器完全不是一个世界的东西。这是所有人都知道的事实也正是麻烦的根源。那操作系统怎么管理这一堆五花八门的设备还是那句老话先描述再组织。先描述一个设备就对应一个struct device。这个结构体里塞着设备的类型、设备的状态、各种属性还有设备之间的连接键以及最关键的东西这个设备专属的读写方法。每个设备都给自己建了一份档案。再组织设备与设备之间用list_head结构体首尾相连串成一条链表也可以换用其他连接方式组织成一棵设备树。总之散落的设备被结构化了操作系统想找哪个设备顺着链子或树摸过去就行。这跟进程管理、内存管理是同一套哲学。继续往上走就来到了文件层。一个个struct file里藏着两个极其重要的函数指针void (*read)(int fd, char *, int); void (*write)(int fd, char *, int);别小看这两个指针。它们就是“一切皆文件”的真正支点。当上层调用read(fd, ...) 时内核会顺着fd找到对应的struct file然后通过这个read函数指针跳到具体设备自己的读取实现上。读磁盘有读磁盘的读法读键盘有读键盘的读法但上层的调用方式完全一样。这就是多态。不同的底层实现共享同一套接口。而这套接口的载体正是这两个函数指针。有了它们文件系统才能把普通文件、设备文件、管道、socket全部伪装成“文件”让用户用同一套read/write走天下。接下来我们就把这套多态机制彻底拆开。3.2 VFS如何统一不同类型的文件现在我们把前面说的三个点串起来一个结论就浮出水面了。底层所有设备的访问接口各不相同键盘有键盘的读法磁盘有磁盘的读法网卡又有网卡的一套。但在上层不管是进程还是用户访问这些设备时用的都是同一套read和write。你不需要知道底层这个设备到底是磁头扫出来的还是中断报上来的你只需要面向“文件”这一个概念编程。这背后最大的功臣就是VFS虚拟文件系统。那它到底是靠什么完成这场“欺骗”的答案就藏在struct file里的那两个函数指针里read和write。它们指向底层不同设备各自的读写方法。为什么可以指向又是在什么时候指向的回想一下open函数的工作原理打开文件时内核会创建一个新的struct file把这个结构体的指针填进文件描述符表然后把下标返回给用户。关键就在“创建struct file”这一步就在此刻内核根据你打开的是什么把对应的读写函数指针填进去。打开磁盘文件就填磁盘的读写方法打开显示器就填显示器的读写方法打开键盘就填键盘的读取方法。同一个struct file两个指针指向不同的底层实现。现在向上看“一切皆文件”的欺骗就完成了我们可以用完全相同的一套调用方式去访问各式各样的设备。具体访问过程一路走下来是这样的open(文件名)→ 内核找到对应文件创建struct file填入该设备的读写方法→ 返回文件描述符fdread(fd, ...) / write(fd, ...)→ 用fd在文件描述符表里找到struct file的指针→ 从struct file里取出read/write函数指针→ 顺着指针跳到底层设备的真正实现→ 设备开始干活数据流通过来3.3 从源码理解VFS的核心机制3.3.1 struct file_operations——统一文件操作接口上面我说“每个struct file内部有两个函数指针直接指向不同设备的读写方法”这个说法不完全准确得往深里再修正一层。真实的内核源码里struct file内部并没有直接塞read和write两个函数指针。它塞的是一个指向struct file_operations结构体的指针也就是我们前面草草提过的f_op。真正的重头戏在这个file_operations结构体里。它才是一张完整的“函数指针大表”里面不止read和write还密密麻麻排着几十个函数指针open打开文件release关闭文件read读取数据write写入数据llseek调整读写位置ioctl设备控制还有一堆跟异步I/O、内存映射、锁操作相关的接口所以准确的关系链是这样struct file└── const struct file_operations *f_op;├── int (*open)(...)├── ssize_t (*read)(...)├── ssize_t (*write)(...)├── int (*release)(...)├── loff_t (*llseek)(...)├── long (*ioctl)(...)└── ... 还有几十个struct file本身只负责“指路”真正的操作实现全部由它指向的那张file_operations表提供。你打开一个磁盘文件f_op就指向磁盘文件那一整套函数打开一个socketf_op就换成socket那一套。指针一换整套行为全变而上层调用依然纹丝不动。这个修正让“一切皆文件”的多态机制更严谨了不是两个指针在玩花样而是一整张操作表在背后撑腰。文件的每种行为都能被替换成对应设备的专属实现。这才是VFS真正的底气所在。3.3.2 从文件到设备——底层设备管理的连接关系上面提到设备之间到底靠什么组织我们当时说得有些含糊。这里把它彻底说清楚。你可能会问到底是用list_head链表还是设备树其实两者都存在而且都对。它们只是分管不同的维度。list_head负责“运行时怎么排队”在内核里几乎一切同类对象都靠struct list_head串成双向链表。这是内核最惯用的组织手段。比如系统里所有打开的文件、所有加载的驱动、所有注册的块设备在底层都会被挂到全局链表或者哈希表上方便遍历、查找和管理。一句话list_head解决的是“这些结构体在内存里怎么组织”。设备树负责“硬件上有什么”设备树Device Tree, DT是另一套体系。它本质上是一份硬件描述文件在系统启动时告诉内核这台机器上有几个CPU、几块磁盘、某个外设的寄存器物理地址是多少、中断号是几号。内核解析完设备树会生成对应的platform_device等结构体。一句话设备树解决的是“这台机器上到底有哪些硬件”。总结设备树负责“告诉内核有什么硬件”而内核在运行时再用list_head、红黑树这些数据结构把生成的设备结构体一个个组织起来。一个是静态的硬件清单一个是动态的内存管理。一个管“户口普查”一个管“日常排队”。两套机制各司其职从来不冲突。3.3.3 加餐——设备文件背后的源码实现这里我们以Linux内核2.6.18版本为例从struct platform_device结构体出发点进它内部的device结构体一路转到定义。最终整个struct device的全貌会摊开在你面前。其中有一个成员叫struct list_head dma_pools;这说明设备结构体本身可以被list_head串起来形成设备链表。但真正让人豁然开朗的是下面这个结构设备不仅能串成链还能长成一棵树而且是一棵多叉树。[父设备 struct device]└── 内部成员struct klist klist_children;└── 其中的链表头struct list_head k_list;│├── 链接到 ──► [大儿子 struct device] 里的 knode_parent├── 链接到 ──► [二儿子 struct device] 里的 knode_parent└── 链接到 ──► [小儿子 struct device] 里的 knode_parent一个父设备下面挂着若干个knode_parent每个knode_parent又指向一个子设备。父与子之间就靠klist_children里的那个k_list链表头连接起来。于是设备之间的关系从“一排平铺的链表”升级成了“一层套一层的树”。这就是 Linux 设备模型里非常经典的组织方式它既支持横向的链表遍历又支持纵向的父子层级管理。内核里那些复杂的设备拓扑靠的就是这套“链表 树”的组合拳。3.4 VFS 背后的设计思想——统一抽象与解耦技术往往是现实世界的映射。多态从来不只是面向对象语言里的一个特性它本来就是这个世界运转的方式。同一个“吃”的动作人可以吃狗可以吃鲸鱼也可以吃接口一样内里各有乾坤。VFS做的正是把这种现实规律搬进了内核。内核里的“多态”说穿了就八个字接口统一灵魂自理。VFS给所有设备定下了一条铁律你们都必须提供统一的read和write接口。但接口统一不代表行为统一。当你open一个鼠标、一块磁盘、一张网卡时内核会动态地把对应硬件驱动写好的函数地址填进file-f_op指针里。上层应用看到的永远是同一个read可它每一次调用的实际上是完全不同的底层实现。鼠标的read在等中断事件磁盘的read在挪磁头网卡的read在抓包同一套接口千般灵魂。更妙的是这一切是用纯C语言实现的。没有类没有虚函数没有语法糖。就靠一张函数指针表硬生生在C里搭出了面向对象才有的扩展性和多态哲学。这是内核开发者最引以为傲的“手工多态”。再往上一层看这背后藏着一句计算机界的至理名言任何计算机问题都可以通过在中间添加一层软件层来解决。VFS就是那一层。它横在“五花八门的底层设备”和“只想简单读写的上层应用”之间把所有差异全部屏蔽掉。越往上走抽象级别越高使用者看到的越简单。操作系统最核心的价值正是把复杂的现实折叠起来只给上层一个干净、统一的接口。这种“中间层”的艺术从VFS到网络协议栈从数据库引擎到图形渲染管线无处不在。它解决的不是“能不能做”而是“能不能让做的人不用关心背后有多乱”。所以VFS不只是套文件系统它是一套操作系统设计理念的浓缩标本把复杂留给自己把简单交给世界。从标准输出与标准错误的“分道扬镳”到struct file里那张举足轻重的file_operations函数指针表从设备树与链表在底层各司其职到VFS用纯C硬生生撑起的“多态帝国”这一篇我们把文件篇里最后几块关键拼图严丝合缝地按了进去。几个核心认知值得在翻页前再刻一遍标准错误不是多余的设计。它是日志治理的底线。常规信息与错误信息同流合污排障就是大海捞针1号、2号分开走才能该看的看该拦的拦。21的本质是“让2号跟着1号走”。先让1号指向文件再把1号的指针复制给2号。一个dup2逻辑一条命令行妙用。struct file不直接管操作它是指路的。真正的实现全在file_operations那张函数指针大表里。打开什么设备就换上一整套对应的操作方法这就是“一切皆文件”的底气。设备树管“有什么硬件”链表管“怎么排队”。一个描述静态清单一个组织动态管理。Linux 设备模型的秩序就建立在这两条腿之上。VFS的多态是纯C的手工艺术。没有虚函数没有继承只有函数指针和中间层。它把底层的千差万别折叠成一个统一的read和write。这背后藏着的正是那句经典的工程哲学任何问题都可以通过添加一层软件层来解决。文件篇走到这里重定向、fd、struct file、VFS这几座山头我们已经从山脚爬到山顶再从山顶看清了整条脉络。接下来该往更深处钻了下一次我们要正面迎击磁盘级文件的存储真相inode、目录项、位图、块组这些名词到底是怎么组织起来把一块冷冰冰的硬盘变成一棵能查能改、能删能建的文件树的。如果这个系列对你有帮助欢迎点赞、收藏、关注三连支持。你的每一个正反馈都是我继续硬核输出的最大动力。磁盘深处我们下篇见。
返回列表