Linux学习基础IO——理解缓冲区 | 理解文件系统

Posted 一只大喵咪1201

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux学习基础IO——理解缓冲区 | 理解文件系统相关的知识,希望对你有一定的参考价值。

🐱作者:一只大喵咪1201
🐱专栏:《Linux学习》
🔥格言:你只管努力,剩下的交给时间!

基础IO

☕理解缓冲区

🧃缓冲区的共识

缓冲区存在的现象:

  1. 在我们写的第一个Linux程序中,当时就存在一个刷新缓冲区的操作,为了能够在屏幕上立刻打出我们要输出到内容,如下图所示。

  1. 还有在使用C语言scanf函数的时候,为了不让空格影响获取字符串,经常会使用getchar把空格字符串跳过,而且并没有接收getchar的返回值,仅仅是为了从缓冲区中将空格拿走。

在我们学习的过程中,种种迹象表明是存在缓冲区的,但是它具体是什么,一直都没有一个答案,今天本喵就给大家详细介绍一下缓冲区。

缓冲区存在的意义:


当一个进程要向文件中写入数据的时候,有两种方案:

  1. 进程直接将数据写入到文件中,如上图中蓝色箭头所示。
  2. 进程将数据写入到缓冲区中,然后再由缓冲区将数据写入到文件中,如上图黑色箭头所示。

这里两种方式哪种好呢?看起来像是第一种方案好,因为比较简单,数据直接从进程流向文件就行,但事实上不是这样。

  • 第一种方案中,无论是在向磁盘上的文件写入数据,还是向显示器等其他硬件写入数据,都需要很长的时间,因为硬件的访问相对于CPU的速度来说是非常慢的,此时CPU就需要进行等待。
  • 第二种方案中,将数据写入到缓冲区中,缓冲区的访问速度肯定要比访问硬件快的多,数据写入到缓冲区以后,CPU就可以去干其他的事情了,而缓冲区中的数据会由操作系统在合适的时间写入到文件中。

从上面的分析可以得出结论,缓冲区的存在是为了给发送方节省时间。

既然缓冲区的存在是为了给CPU节省时间,那么它的访问速度肯定是比文件要快的多的,所以它只能是内存。所以说,缓冲区本质上就是一段内存。

🧃缓冲区的位置

既然缓冲区是一段内存,那么这段内存是谁申请的,它是属于谁的?

来看一个现象:

如上图所示的代码,使用C语言提供的打印函数和系统调用输出重定向到log.txt文件中,发现各个接口只调用了一次。

在程序执行完毕,但是进程没有结束的时候,使用fork创建子进程,再将运行结果输出重定向到log.txt文件中,发现C语言提供的接口调用了两次,而系统调用接口只调用了一次。

这是什么原因?从这个现象中能过得到什么呢?

  • 这个现象肯定是和缓冲区有关。
  • 缓冲区必然不在操作系统内核中。

既然缓冲区不在操作系统内核中,也就是不是由操作系统来维护的,那么它只能有进程去维护,也就是编程语言本身来维护。

拿C语言来说,和文件相关的操作,FILE*类型的指针是至关重要的,我们已经知道,FILE是一个结构体,它里面有文件描述符fd,在结构体中定义的变量名是_fileno。

所以我们大胆猜测,所谓缓冲区就在FILE这个结构体中。

来大概看看Linux的源码:


在源码中,和文件有关的结构体中有很多的指针变量,如上图中红色框所示,这些指针就是在维护缓冲区。

此时我们就可以知道,缓冲区是由要打卡文件的进程申请的,也是由这个进程来维护的,缓冲区存在于FILE结构体中。

🧃缓冲区的刷新策略

光知道缓冲区存在于FILE结构体中还不足以回答上面那个现象提出的问题,接着本喵再介绍一下缓冲区的刷新策略。


同样上面的代码,但是没有进行输出重定向,而是直接打印,虽然有fork,但是仍然是各个接口只调用了一次。


将上面代码中字符串的换行符去掉,不进行重定向,直接打印,发现C接口也被调用了两次。

这是什么原因?从这个现象中又可以看出什么呢?

  • 缓冲区如果及时刷新,那么各个接口只调用一次。
  • 缓冲区的刷新和换行符\\n有关。

这种缓冲区的刷新和换行符\\n相关的策略叫做行缓冲。


再看,同样的代码,都是有换行符的,进行输出重定向以后,C接口就调用了两次,没有进行重定向C接口就只调用了一次。

这又是为什么?从这个现象中可以看出什么?

  • 输出重定向后,输出终端变成了文件,没有重定时,输出终端是显示器。
  • 行缓冲的策略在文件和显示器上作用效果不同。

文件采用的是全缓冲的方式,只有当缓冲区满了以后,操作系统才会刷新缓存区。


在程序中,在C语言的打印函数接口调用完之后,使用了fflush将缓冲区立刻刷新,然后进行输出重定向到log.txt文件中,此时C语言接口也是只打印了一次。

进行了重定向,又仅打印了一次,和上面进行重定向后只打印一次的结果完全不同。

这是为什么?从这个现象中又可以看出什么?

  • fflush进行缓冲区的刷新。
  • 没有遵循行缓冲或者全缓冲的策略。

这种使用fflush进行刷新缓冲区的刷新策略叫做**误缓冲。**它是由用户控制的,直接将缓冲区中的全部内容都刷新都对应的终端上去。

还有两种情况下,缓冲区同样也会刷新,其一就是当一个进程结束后,操作系统会自动将属于该进程的缓冲区进行刷新,并且将对应的内存空间释放。

其二就是当一个文件被关闭的时候,操作系统也会自动将属于该进程的缓冲区进行刷新。

来总结一下缓冲区的刷新策略:

体现策略适用范围
立即刷新无缓冲通常由用户控制进行强制刷新
行刷新行缓冲显示器
满了刷新全缓冲磁盘文件
进行结束后刷新所有进程
文件关闭时所有文件

不同的缓冲策略是根据一定的情况定死的,我们一般情况下是不会进程重新定义的。

  • 显示器:直接给用户看的,一方面要照顾到效率,另一方面要考虑到用户是一行一行看文本的,所以次用行缓冲策略。
  • 磁盘文件:用户不需要立马看见文件中的内容,为了效率,采用全缓冲的方式。

缓冲区刷新一次是很耗费时间的,比如1000个字节的数据,刷新一次是1000个,刷新十次也是1000个,但是十次使用的时间会必一次长的多的多。

在进行缓冲区刷新的时候,数据量的大小不是主要矛盾,和外设预备IO的过程才是最耗费时间的。

解答疑惑:


此时这个现象就可以解答了。

只有C接口被调用的次数发生了变化,系统调用一直都是只调用一次,说明系统调用不存在缓冲区。

  • 父进程创建以后,在调用C接口时,将数据写到了它的缓冲区中,并且通过页表在内存中映射了一段物理空间。
  • 在执行到return 0 之前的fork时,创建了子进程,子进程会拷贝父进程缓冲区中的全部内容,并且通过页表映射到相同的物理空间。
  • 在fork之后,父子两个进程什么都没有干进程将结束了,在进程结束的时候会刷新它们各自缓冲区中的数据到磁盘文件中。
  • 因为有两个进程要结束,所以缓冲区就会刷新两次,而且内容是一样的。
  • 然后释放这块物理空间,由于父子进程都没有对各自的缓冲区进行修改,所以没有发生写时拷贝。

在没有重定向到程序中,打印终端是显示器,采用的是行缓冲的方式,每个C接口打印的字符串中都有换行符,所以每次调用完C接口后都会刷新缓存区中的内容。

在fork之后,父子两个进程各自的缓冲区中什么都没有,都已经被刷新走了,所以它们两在结束的时候也不会再次刷新缓冲区,所以表现出来C接口各自打印一次。

🧃简单模拟用户缓冲区

为了能够对缓冲区有更深的了解,下面本喵带大家简单的模拟实现一下用户缓冲区。

首先需要简历FILE结构体,根据我们学习到的内容,有文件描述符fd,缓冲区。


同样需要一个刷新策略标志,用32位中的3个比特来表示无缓冲,行缓冲,全缓冲。

这里仅仅是模拟一个缓冲区,实际的缓冲区肯定不是一个数组。

打开文件函数:


对于不同的打开方式,给打开标志flags不同比特位赋值,如上图中代码。


只读方式打开的话,调用只有两个参数的系统调用open,其他以写方式打开时,调用有三个参数的open。从这里也可以看出,无论上层语言是什么,打开文件时最终都会调用系统调用open函数。

将文件成功打开以后,对我们自定义的my_FILE结构体初始化。

  • 结构体中的刷新方式默认采用行缓冲方式。
  • 将使用系统调用open返回的文件描述符fd赋值给结构体中的fd。
  • 将缓冲区(数组)进行初始化。

最后返回动态开辟的my_FILE指针。

写入函数:


无论写入到内容是什么,都要放在my_FILE结构体中的缓冲区中。

  • 这里使用了memcpy函数,从这里可以看出:
  • 使用write系统调用后,与其认为将数据写入到了文件中,不如认为是将数据复制到了文件中。
  • 与其认为write是一个写入函数,不如认为它是一个复制函数。


根据设定的不同刷新策略,将my_FILE结构体中缓冲区里的数据通过系统调用write写到Linux内核中,也就是写到文件中。

缓冲区刷新函数:


如果缓冲区中有数据,调用该函数时,立刻将缓冲区中的数据写到Linux内核中。再将内核中的数据写入到文件中。


这里调用了一个fsync函数,该函数的作用就将内核缓冲区中的数据刷新到文件描述符fd所执行的文件中。

  • 我们使用系统调用write时,其实是将数据写入到了内核缓冲区中,而不是直接写入到了文件中。
  • 操作系统会将内核缓冲区中的数据再写入到文件中。

这里使用该函数来强制刷新内核缓冲区中的数据,而没有让操作系统自主去刷新数据,是为了防止内核缓冲区中的数据还没有刷新出去的时候系统就宕机了,此时会导致数据的丢失。

至于操作系统是如何将内核缓冲区中的数据刷新到文件中的,这是操作系统的事情了,我们不需要再了解,我们要掌握的是用户层语言所维护的缓冲区。

文件关闭函数:


在关闭文件时,将缓冲区中的数据刷新到内核中,然后再通过系统调用关闭文件描述符所指向的文件。最后再释放my_FILE结构体,以防造成内存泄露。

验证:


可以看到,使用我们自己模拟的fwrite函数,可以实现和C接口一样的功能。


同样也可以实现追加。

☕理解文件系统

在前面我们一直学习到都是被打开的文件和进程间的关系。事实上除了被打开的文件,还有需要没有被打开的文件,这些没有被打开的文件,它们放在哪里呢?又是如何被管理的呢?

  • 没有被打开的文件都静静的在磁盘上放着。
  • 磁盘上有大量的文件,都被管理着,方便我们随时打开。

要想了解文件时如何被管理的,就需要对磁盘有一定的认识。

🧃认识磁盘

物理结构:

磁盘属于外设,是一个机械结构,所以相对CPU,内存而言,它相当的慢。

来看它的物理结构,如上图所示,之所以叫做磁盘,是因为它是盘状的,而且不止一片,有很多片叠放在一起。

  • 主轴和马达电机:在主轴上套着多张盘片,它们和轴相固定,通过马达电机来驱动这些盘片一起转动。
  • 磁头:每一张盘片都有两个盘面,每一个盘面上都有一个磁头,该磁头是用来向磁盘中读写数据的。多个磁头也是叠放在一起的,它们的运动是一致的。
  • 音圈马达:该马达驱动磁头组进行摆动,它可以从盘片的内圈滑到外圈,再结合盘片自身的转动,从而向磁盘读写数据。

存储结构:

  • 磁头:向磁盘中读写数据,如上图中有三个盘片,那么就有六个磁头,给它编号从0到5。
  • 柱面:从俯视图中来看,一个盘面可以看做是多个同心圆,每一个同心圆被叫做一个磁道,一叠盘片中的相同磁道所组成的圆柱就这里的柱面,从内到外给柱面编号从0到3。
  • 扇区:在俯视图中,以相同圆心角将盘片分为多个扇形,每个扇形和每个磁道相交产生的区域就被叫做扇区。一个盘面上每个磁道所包含的扇区个数是相同的,同样给每个扇区编号。

每个扇区的大小是512K字节,所以内磁道的扇区密度高,外磁道的扇区密度低。

这样一来,我们就可以定位任意一个扇区,然后进行读写数据。比如,0号磁头,0号柱面,0号扇区,此时,磁头就会摆动到0号柱面处,当0号磁头对应的盘面中的0号磁道里的0号扇区旋转到磁头位置时,就可以向磁盘中读写数据。

这种定位方法称为CHS定位法。

逻辑结构:


每个磁面上都有多个磁道,每个磁道上有多个扇区,类比磁带,扇区就可以看成一圈一圈缠绕在一起的。

  • 将缠绕在一起的扇区,像拉磁带一样全部拉出来,拉成一条直线。
  • 多个磁面可以拉成多个直线,将所有面拉成的直线首尾相连组成一条长直线。
  • 这条长直线可以看成一个数组,这个数组是以扇区为单位的,所以每个数组元素的大小是512K。


此时,磁盘就被我们抽象成了上图所示的数组,并且给每一个扇区进行编号。站在操作系统的角度,操作系统访问这个数组就是在访问磁盘。

那么这个数组的下标是怎么和磁盘的CHS对应起来的呢?


如上图所示,可以根据给定的逻辑数组下标转换成CHS定位法,定位到磁盘上具体的某个扇区。

  • 其中,数组的下标被叫做逻辑块地址,简称LBA。操作系统使用的就是逻辑块地址来访问磁盘的。

采用LBA而不用CHS的原因:

  1. 便于管理,因为数组管理起来更加方便。
  2. 不想让操作系统的代码和硬件强耦合。

🧃文件管理

操作系统看到的磁盘就是一个数组,这个数组每个元素的大小是512K字节(一个扇区),同样我们也知道,每次向磁盘中读写数据都很耗费时间。

  • 为了提高效率,磁头每次访问磁盘的基本单位是4KB(绝大多数情况下)。
  • 即使访问磁盘的一个bit,磁头也是将包过这一个bit在内的周围4KB大小的数据加载到内存。

正因为磁头每次访问的是4KB大小的数据块,所以内存也被划分成了多个4KB大小的空间,每一个空间被叫做页框

同样的,磁盘中的文件,尤其是可执行文件,也被划分成了多个4KB大小的数据块,每一个块被叫做页帧

假设现在有一个500GB大小的磁盘,操作系统如果统一管理的话成本会很高,所以采用分治的思想来管理整个磁盘。

  • 将500GB的磁盘分成4个区,只需要管理好一个区,其他三个区便可以复用这套方法。
  • 再将每个区分为多个组,只需要管理好一个组,其他剩下的组便可以复用这套方法,从而管理好这个区。

每个分区以及每个分组是多大要看具体情况。

这种思想有点像递归的思想,所以我们要学习到重点就是如何管理好一个组。

最小分组的管理


每个分组中又分为这6个区域。

  • Super Block:文件系统的属性信息,整个分区属性的属性集,多个组都有 ,但不一定是每个组都有。是为了防止磁盘被刮伤而找不到文件属性。
  • inode Table:存放了这个分组中所有的inode(已经使用的和没有使用的),每个分组中inode的个数是确定的。
  • inode Bitmap:inode位图,该分组中有多少个inode,这个位图就有多少个bit,并且每一个比特位都与一个inode一一对应。每使用一个inode,对应的位图就会被置1。
  • Data blocks:保存这该分组内,所有文件的内容,该块区又被分为多个数据块。
  • Block Bitmap:数据块位图,该分组的Data blocks中有多少个数据块,这个位图就有多少个bit,并且每一个比特位都和一个数据块一一对应。每使用一个数据块,对应的位图就会被置1。
  • GDT描述表:记录该分组中inode和数据块的使用率等宏观属性。

这样来看肯定是一头雾水,下面本喵继续来解释。

文件 = 属性 + 内容,所以在磁盘上管理一个文件,也要管理它的属性和内容,而文件的属性就放在一个叫inode的结构体中,文件的内容就放在数据块中。

文件属性存储:

struct inode

	int id;
	mode_t mode;
	size;
	.......
	//多种属性
	int blocks[15];

一个文件的所有属性都在inode中,但是唯独没有文件名。

查找一个文件的时候,统一使用的是:inode编号。


可以看到,每个文件都有一个独一无二的编号,这就是inode编号,这个编号其实就是一个结构体对象。

每创建一个文件,就会在inode Table中申请一个未被使用的inode,并且将对应的位图置1。

  • 每一个inode的大小都是128B,并且每个分组中inode的个数都是固定的。

文件内容存储:

文件的内存就存储在这个Data blocks中,而这个块区中又有多个数据块,并且有相应的编号。

现在属性被存放好了,内容也被存放好了,下面就是将一个文件的属性和内容对应起来。

inode结构体中的数字blocks[15]就是干这个事情的。

  • 数组中每个元素存放着一个一个数据块的block id(编号)。
  • 每个数据块中存放着内容数据。

一个文件对应着一个ionde,该文件的内容存放在多个数据块中,所以inode中的数组中记录着这些数据块的block id。

这个数组一共才能放15个编号,如果这个文件的内容有很多呢,需要很多的数据块(超出了15个)呢?

数组最后的三个位置,下标为12,13,14,它们存放的数据块编号所指向的数据块中存放的不是文件内容,同样是属于该文件数据块的编号。

虽然一个数组中的一个元素只能存放一个数据块的下标,但是指向的数据块中可以存放多个数据块的下标,这样一来,再大的文件也能存放的下。

  • 每使用一个数据块,就会将它所对应的位图置1。

现在我们知道了文件在磁盘上是如何存放的,以及操作系统是如何管理它们的。根据前面所讲,inode是文件的唯一标识,但是我们在使用文件的时候并没有使用inode啊,我们使用的是文件名,这是为什么?

  • 一个目录中,可以包含多个文件,但是这些文件的名字不能重。
  • 目录也是文件,它也有自己的inode,也有自己的数据块。

目录的data blocks中存放的是:它所包含文件的文件名和inode之间的映射关系。

所以我们在使用一个文件的文件名时,就会自动映射到它的inode,本质上还是在使用一个文件的inode。

此时我们就清楚了为什么inode中包含文件的所有属性,但是就是没有文件名了,因为文件的文件名和它对应的inode存在上级目录的data blocks中。

🧃操作未被打开文件

创建文件:

  • 在创建文件的时候,会向inode Table中申请为被使用的inode,并且将相应的inode Bitmap置1,然后将该文件的各种属性存入到inode中。
  • 还会将这个文件的文件名和inode的映射关系写入到上级目录的data blocks中。

向文件中写入:

  • 根据文件名和inode的映射关系,找到文件对应的inode。
  • 根据inode中blocks数组,找到存放文件内容的数据块进行数据的写入,如果发生数据块数量上的变化,还要将对应的Blocks Bitmap位图的相应位改变。
  • 再改变inode中对应的属性信息。

读取文件内容:

  • 根据文件名和inode的映射关系,找到文件对应的inode。
  • 再从inode中找到文件对应的数据块。
  • 将数据块中内容加载到内存中供进程使用。

文件删除:

  • 根据文件名和inode的映射关系,找到文件对应的inode。
  • 再根据inode找到数据块所对应的Blocks Bitmap,将对应位清0。
  • 最后再将inode对应的inode Bitmap清0。

文件的删除并不会去清理磁盘上数据块中的内容,只是将对应的位图清0,后续再来的内容进行覆盖就可以。这也是为什么拷贝一个文件比较慢,但是删除一个文件很快的原因。

  • 当你误删一个文件的时候,最好的做法就是什么都不要做,只要对应的inode和data blocks没有被覆盖,这个文件时可以恢复的。

如此一来,磁盘的一个分组就能被操作系统井井有条的管理好了,这也意味着整个磁盘也就被管理好了。

☕总结

在平时我们看不见摸不着的缓冲区,此时便揭下了它神秘的面纱,它的位置,刷新策略,以及因为它而导致的种种异常现象,此时便都明白了。虽然文件系统的讲解更多的是理论,但是这对于我们更好的理解文件系统有很大的帮助,尤其是每个分组中的那个六个区域至关重要。

Linux系统编程:基础IO 下dup2 实现输出重定向输入重定向追加重定向 | 理解磁盘 | 理解文件系统中inode的概念 | 软硬链接

写在前面

这里先接着《基础IO 上》中的缓冲区的内容作些补充,这里主要补充 dup2 接口。

✔ 测试用例一:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()

	close(1);
	int fd = open("log.txt", O_WRONLY|O_CREAT, 0644);
	if(fd < 0)
	
		perror("open");
		return 1;	
	
	fprintf(stdout, "hello world!: %d\\n", fd);
	close(fd);
		
	return 0;

  • close 1 后,1 就不再表示显示器文件,而 open log.txt 后,1 就表示 log.txt 文件,所以 fprintf 并不会往显示器上输出,而是会往 log.txt 里输出,可是 log.txt 中没有内容。通常数据流动过程是:先把语言上的数据写到用户层的缓冲区 ➡ 然后数据通过文件描述符在操作系统内核中,找到自己的 task_struct ➡ 然后通过 task_struct 中的 struct files_struct* files 指针找到 struct files_struct 中 struct files* fd_array[] 以文件描述符为下标的位置 ➡ 然后再通过下标的内容找到要写的 struct_file,并把用户层缓冲区的数据拷贝到内核层缓冲区 ➡ 操作系统再由自己的刷新策略和时机通过磁盘驱动刷新到磁盘设备。注意因为用的是 C,所以这里的用户层缓冲区是 C 提供的,如果是其它语言,那么用的缓冲区就是其它语言提供的。所以之所以操作系统没有由用户层把数据刷新到内核层是因为现在 1 指向的是磁盘文件。显示器是行刷新策略,磁盘是全缓冲策略,这两种策略既可以被用户层采纳,也可以被内核层采纳。

  • 为什么语言都要在用户层提供一个缓冲区,printf 直接把数据刷新到内核缓冲区不行吗

    上层只要把数据写到用户层缓冲区中就不用管了,剩下的就由操作系统来完成,所以对用户来讲,就完成了用户层和内核层之间的完全解耦。而用户要自己拷贝数据到内核层,还需要提升权限,效率太低。

    所以用户层中存在缓冲区可以让用户和底层之间的差异屏蔽掉,以此来提升效率。同理内核层中存在缓冲区也有着解耦、提高效率的意义。

✔ 测试用例二:

#include<stdio.h>  
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>

int main()

    //c call
    printf("hello printf\\n");
    fprintf(stdout, "hello fprintf\\n");
    fputs("hello fputs\\n", stdout);
    //system call
    const char* msg = "hello write\\n";
    write(1, msg, strlen(msg));
                                       
    fork();

    return 0;                          
                                      
  • 这里的 fork 看起来没有什么价值,也不会影响到什么,fork 之后,父子进程马上就退出了。

    但是当我们重定向后,就会出现很诡异的现象。

    我们发现往显示器上输出的结果是合理的,但是往普通文件上输出的结果却很诡异,它输出了 7 条信息,且使用 C 语言接口的都输出了两次,使用系统调用接口的输出了一次。毫无疑问这种现象是和随手写的 fork 是相关联的,因为去掉 fork 再重定向是合理的。

    这里先考虑 C 语言接口,我们都知道,这里输出的三条数据并不会直接写到操作系统里,而是先写到 C 语言的缓冲区中,fork 之后,程序就分流了,但不幸的是,程序马上要退出了,而 C 语言缓冲区中的数据也要被刷新,此时父子进程谁先运行,谁就先刷新,而缓冲区中的数据也是数据,即使是 C 语言的数据,也不能凌驾于进程之上,当父或子想刷新时,那么立马要发生写时拷贝。至此,我们就能理解重定向后,刷新策略由行刷新变为全缓冲,也就是说 fork 时,数据还在 C 缓冲区中,所以重定向后,C 接口的数据输出了两份;而向显示器输出时,因为显示器的刷新策略是行刷新,且这里的每条数据都有 \\n,所以每执行完 printf,数据就立马刷新出来,最后 fork 时便无意义了。

    而重定向后,系统接口没有受影响的原因是 write 会绕过语言层缓冲区,写到内核层缓冲区,而其实只要是数据都要写时拷贝,但大部分情况只针对用户数据,对于内核数据,数据属于操作系统不会写时拷贝,属于进程会写时拷贝,但这种情况很少考虑,现在我们就认为写时拷贝主要拷贝的是用户数据。

  • 通常我们不建议所语言接口和系统接口混合使用,因为可能会出现一些难以理解的现象。

一、dup2

✔ 测试用例三:

#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>

//输出重定向
int main01()

    int fd = open("log.txt", O_WRONLY|O_CREAT, 0644);
    if(fd < 0)
    
        perror("open");
        return 1;
    

    dup2(fd, 1);//此时再写入就不是标准输出,而是fd                             
    const char* msg = "hello dup2->output\\n";
    int i = 0;
    while(i < 5)
    
        write(1, msg, strlen(msg));
        i++;
    

    close(fd);

    return 0;


//输入重定向
int main02()

    int fd = open("log.txt", O_RDONLY);
    if(fd < 0)
    
        perror("open");
        return 1;
    

    dup2(fd, 0);//此时再读就不是从标准输入读,而是fd
    char buffer[1024];
    ssize_t sz = read(0, buffer, sizeof(buffer) - 1);
    if(sz > 0)                                       
    
        buffer[sz] = 0;
        printf("%s", buffer);
    

    close(fd);

    return 0;


//追加重定向  
int main03()

    int fd = open("log.txt", O_WRONLY|O_APPEND);
    if(fd < 0)
    
        perror("open");
        return 1;
    

    dup2(fd, 1);//此时再写入就不是标准输出,而是fd
    const char* msg = "hello dup2->append\\n";
    int i = 0;
    while(i < 5)
    
        write(1, msg, strlen(msg));
        i++;
                                                

    close(fd);

    return 0;                                    
                                                
  • 我们之前自己实现重定向时是先 close 被重定向的文件,再 open 想重定向的文件。有没有可以不 close 被重定向的文件,直接重定向,此时系统提供了类似的接口 dup2 来解决 close 多此一举的行为。

  • 使用 dup2,需要包含 unistd 头文件,

    要输出的文件描述符是 1,而要重定向的目标文件描述符是 fd(echo “hello” > log.txt),dup2 应该怎么传参 —— dup2(1, fd) || dup2(fd, 1)

    很明显,依靠函数原型,我们就能认为 dup2(1, fd),因为 1 是先打开的,而 fd 是后打开的,可实际上并不是这样的。文档中说 newfd 是 oldfd 的一份拷贝,这里拷贝的是文件描述符对应数组下标的内容,所以数组内容,最终应该和 oldfd 一致。换言之,这里就是想把 1,不要指向显示器了,而指向 log.txt,fd 也指向 log.txt。所以这里的 oldfd 对应 fd,newfd 对应 1,所以应该是 dup2(fd, 1)。

  • 运行结果

    输出重定向

    输入重定向

    追加重定向

  • 所以现在我们就明白了:

    echo "hello world" > log.txt —— echo 是一个进程;“hello world” 默认是调用 printf 或 write 往显示器上输出;log.txt 是调用 open 使用 O_WRONLY|O_CREAT 打开;> 是调用 dup2,将默认标准输出 1 的内容改为 log.txt;

    < 就是 dup2(fd, 0),且 open 文件的方式是 O_RDONLY;

    >> 同 >,都是 dup2(fd, 1),只不过它打开文件的方式是 O_WRONLY|O_APPEND;

  • 进程替换时,是否会干扰重定向对应的数据结构

    换言之,将来 fork,创建子进程,子进程会以父进程的大部分数据为模板,子进程进行程序替换时,并不会影响曾经打开的文件,也就不会影响重定向对应的数据结构。

二、理解文件系统

从头到位我们都在说打开的文件,磁盘中包含了上百万个文件,肯定不可能都是以打开的方式存在,其实文件包含打开的文件和普通的未打开的文件,接下来我们重点谈未打开的文件。我们知道打开的文件是通过操作系统被进程打开,一旦打开,操作系统就要维护多个文件,所以它是需要被操作系统管理的,也就是说这种方式,磁盘上和内存上都有这个文件,它们不是完全一样的,内存中的文件更强调的是属性和方法,磁盘中的文件更强调的是数据,它们是通过缓冲区关联的;而普通的未打开的文件在磁盘上,未被加载到内存中,它当然也要被管理;其中管理打开的文件和管理未打开的文件在操作系统中有一个功能模块叫做文件系统。之前我们谈过进程 vs 程序,一个被打开的程序就是进程,只不过我们在解释进程时不是严格把它当作文件来解释,需要明白的是进程是要被加载到内存的,程序就是一个磁盘文件,打开的文件是进程,而普通未打开的文件是程序。

💦 磁盘

  • ls -l 可以看到当前路径下文件的元数据,也就是文件的属性。其中这里的硬链接数我们还没有谈过,一会我们会谈。

    这里在命令行上输入 ls -l,bash 解析 ls -l,fork 创建子进程,让子进程通过进程替换执行 ls -l,ls -l 会在当前路径下把文件的属性通过磁盘读到内核,再由内核读到用户空间显示出来。stat 命令还可以查看更详细的信息。

    如果不深入的话,这块也没啥价值,谁不知道文件在磁盘上,谁不知道 ls -l 读取当前路径下文件的属性,所以我们还要研究它的原理。但是在此之前,我们需要先认识磁盘,关于磁盘这个话题,还会在数据库中再谈一次。

  • 众所周知,磁盘分为机械硬盘 (HDD) 和固态硬盘 (SSD),现在很多的电脑都是机械硬盘和固态硬盘组合使用,但服务器上大多都是固态硬盘,只有一些高效率的存储集群会用到固态硬盘,机械硬盘和固态硬盘在存储技术上肯定是不同的,而我们主要了解机械硬盘,因为它多用于服务器上,其次虽然固态硬盘要比机械硬盘快不少,但在 CPU 看来,都很慢,所以我们就了解最慢的。

    如下图,虽然磁盘的盘面看起来很光滑,但是它上面有一些同心圆,这些同心圆用圆白线划分,每一圈叫做磁道,数据写在这些有颜色的区域上。实际上你并不是把一圈的空间都用完,所以这里还使用了一些直白线划分,被圆白线和直白线划分出来的区域叫做扇区。所以当盘片在旋转、磁头摆动就可以找到这个盘面的任何一个扇区进行读写。

    盘面是有两面的,且两面都是同心圆,根据配置不同,有些磁盘可能还有多组盘片,我们可以从上至下的分为不同的盘面,也叫做你是第几个盘面。

  • 虽然在 C 语言中我们知道访问内存的基本单位是字节,但是在操作系统的角度认为内存的基本单位一般是 4kb,在操作系统看来,内存就是一个数组,每一个元素是 4kb,之前在谈进程地址空间时也说过它叫做页框,4kb 是页帧,所以操作系统申请内存时是按 4kb 为单位进行分配和加载的,语言层面上并不关心底层是怎么做的。磁盘存储也有基本单位,一个基本单位是一个扇区,它是磁盘读取的最小单元,大部分磁盘的一个扇区是 512byte,你会发现虽然这里好像越靠近圆心,扇区越小,其实它们都是 512byte,原因是越靠近圆心的虽然扇区越小,但是比特位也相对外圈更密集。内存和磁盘之间也是有交互的,它们之间的交互我们称为 output、input,也叫做 IO,一般内存和磁盘之间 IO 交互时,不是纯硬件级别的交互,而是要通过文件系统完成,也就是通过操作系统。这里用户和内存之间交互的基本单元大小是 1byte,一般内存和磁盘之间交互时的基本单元大小是 4kb,所以文件系统在往磁盘读数据时,要读 8 个扇区,这就是数据由磁盘加载到内存的过程。

  • 其中我们再看 stat 中展示的信息,我们把内存和磁盘之间交互时的基本单元大小 4kb 叫 Blocks,这里的 IO Block:4096 就是 8 × 512。

  • 一般像这样的机械硬盘,物理上是圆状,操作系统很难去管理它,因为操作系统如果不对它进行抽象化处理,那么操作系统中的代码可能就是 read(盘面,磁道,扇区),操作系统需要知道这三个参数的话,那么一定要在操作系统读取磁盘的代码中以硬编码的形式写到操作系统中。如果有一天,你给自己的电脑加了一块固态硬盘,你要对固态硬盘进行读操作,就不能再用以前的方法了,因为固态硬盘与机械硬盘的结构不一样,它没有盘面、磁道、扇区,所以操作系统中曾经设计好的代码就得修改。很显然,这样的设计导致它们之间出现了强耦合,这是很不合理的。

    所以我们需要对磁盘抽象化处理,将圆状结构的磁盘空间抽象成线性结构的磁盘空间,很多人就纳闷了,这里举两个例子方便理解,a) 其实在 C 语言中我们见过的 int arr[3][4] 二维数组就是把线性的数据结构抽象成了好理解的有行有列的结构。 b) 曾经风靡一时的磁带是把数据存储于那条黑色的带子上,可能是为了空间的原因,把带子卷起来形成一个圆状,所以磁带在物理上,既可以是圆状,也可以是线状。

    同样的,也能把磁盘抽象成线性结构。把磁盘上的磁道抽象成线性形状,比如磁盘的所有磁道被我们抽象成了一条 500GB 的线性空间,我们可以把它看作一个很大的数组 —— 扇区 array[NUM],其中每一个元素是 512byte,操作系统要申请 4kb,那就给数组的 8 个元素。所以将磁盘抽象后,操作系统就摆脱盘面、磁道、扇区的束缚了,操作系统只关心你想访问的哪个下标,这里的地址我们称为逻辑区块地址(Logical Block Address, LBA),这里抽象出来的数组下标是和机械硬盘中盘面、磁道、扇区构成映射关系的,这里的映射关系是由对应的机械磁盘驱动维护的,操作系统想往 2 下标处写数据,最终 2 下标一定是能对应到具体磁盘中某个扇区上。如果要往固态硬盘中写数据,也是把它抽象成线性的数组,它也有自己的固态硬盘驱动维护数组下标和固态硬盘之间的映射关系。至此,通过抽象的方法,就完成了操作系统和磁盘之间的解耦。所以最终操作系统对磁盘的管理,转换成了对数组的管理。

💦 inode

  • 500G 的磁盘空间抽象成每个元素是 512byte 的数组,那样非常大,不易管理,所以操作系统还要对这 500G 的数组进行拆分,比如这里拆分成了 100G、100G、150G、150G,所以这里只要管理好了第一个 100G 的空间,然后把管理的方法复制到其它空间,其它的空间也能被管理好。这里我们把拆分的过程叫做分区,这也就是我们的电脑上为什么会有 C 盘、D 盘、E 盘。至此我们仅仅是对空间进行划分,要把空间管理好,还需要写入相关的管理数据,比如把中国 960 万平方公里,划分了不同大小的省份,你要管理好一个省,我们不考虑地质差异等因素,只要一个领导、一个团队他们把一个省管理好了,那么他们的管理方法就可以复制到其它省,同样的,刚刚我们分区的工作只是把中国划分成不同的省份,接下来我们还要分配每个省的省长、省中每个市的市长、市中每个镇的镇长等,以此来管理一个省。这里我们把分配的过程叫做格式化过程,所谓的格式化在计算机中就是写入文件系统,也就是说我们要把文件系统写入某个分区中,这个文件系统的核心包括数据 + 方法,数据就类似这个省有多少人口、粮食等,方法就类似这个省有生育政策、耕种政策等。同样文件系统包含的数据就是文件系统的类型等,方法就是操作各种文件的方法。

    当然不同的分区当然可以使用不同的文件系统,Linux 下就使用五六种不同的文件系统,Linux 可以支持多种文件系统,包括 Ext2、Ext3、fs、usb-fs、sysfs、proc。这就好比,各个省份需要因地制宜的分配不同的团队。我们今天谈的都是 Ext 系列的文件系统,另外也不谈其它的文件系统如何,我们就认为磁盘上不同分区的文件系统是一样的。

    因为一个省也很大,为了更好的管理,还要分配市长、镇长等,同样的分区后的 100G 空间还要再划分,比如这里划分了 10 组 10G 的空间,然后把它看作成一个一个的块组(Block group),一个块组中又有多个 4kb 空间,而磁盘存储是有块基本单位的,文件系统认为一块是 4kb,我们只要把一个块组管好,整个文件系统内的块组就能管好,所以问题又转换为怎么把这 10G 的空间管好,所以接下来划分的才是文件系统写入的相关细节,也是我们要研究的,这个区域的信息,大家都有,可能略有差异。

    这里 Linux 文件系统以 Ext 系列的为话题, 因为不同的文件系统可能略有差异。在块组之前,有一个 Boot Block,它是启动的意思,一般一个磁盘的 0 号分区的 0 号块组上的第一扇区存储着一些启动信息,这里不是重点。这里我们重点谈一个块组细分下来的后四个信息:

    A) Super Block 是文件系统的核心结构,用于描述文件系统的属性,包括文件系统名、文件系统版本、块组中有哪些使用和未使用,一般计算机启动时,Super Block 会被加载到操作系统,其中每一块组好像都有一个 Super Block,但实际可能 10 个块组中只有两三个有 Super Block。

    B) Group Descriptor Table 是块组描述符表,Super Block 描述的是整个块组相关的信息,这里描述的是一组的信息,每一个块组都必需要有一个 Group Descriptor Table。

    C) 我们说过文件 = 内容 + 属性。这里的内容和属性采用分离存储,属性放在 inode Table 中。一个组中可以放多少个 inode 是一定的,基本上,一个文件或目录一个 inode,inode 是一个文件的所有属性集合,属性也是数据,也要占用空间,所以即便是一个空文件,它也要占用空间,这里的属性集合包含文件权限、大小等,但不包含文件名,这个下面再说。

    内容放在 Date blocks 中。比如这里的块组是 10G,那么inode Table 占 1G,Date blocks 占了 8G, 一个 inode 是 512byte,粗略的算一下,1G 大概 42 亿多字节,除以 512 大概也有几千万,所以这样一个块组能保存几千万文件的 inode 信息,这里 inode Table 和 Data blocks 的划分可能会出现你用完了,我没用完,你没用完了,我用完了的情况,这种情况并没有有效的方法解决。

    Date blocks 相当于一个数据块集合,以 4k 为单位,对应的数据块属于哪些文件,是由 Data Blocks 和 inode Table 维护的。如下图,inode Table 包含了若干大小相同的块,这些块有不同的编号,对应就是文件的属性,Data blocks 也包含了若干大小相同的块,这些块也有不同的编号,对应就是文件的内容。此时新建文件或目录,就给文件申请 1 号 inode,并把文件的各种属性写入到 1 号 inode,1 号 inode 中包含了一个数组 block b[32],比如 1 号 inode 需要 2 个数据块,所以 [0] = 2,[1] = 3,所以 1 号 inode 就可以找到对应的数据块。换言之,要在磁盘上查找一个文件,我们只需要知道这个文件的 inode 是多少,至此,我们知道真正标识文件的不是文件名,而是文件的 inode 编号。既然 inocde 大小是确定的,万一文件是 10 个 T,此时数据块就不够了,文件系统的处理策略是数据块不仅可以保存数据的内容,还可以保存其它数据块的编号,它类似于 b+ 树。换言之,对于保存较大的文件,可能就需要多级索引的形式。

    这里 ls - i 就可以查看文件或目录对应的 inode 了,可以看到这里的 inode 并不是连续申请的,它依然能看到文件名,是因为我们需要识别。

    如何理解目录

    我们知道程序员定位一个文件,是通过绝对路径或相对路径定位的,但不管是绝对路径还是相对路径最终一定是要有一个目录。目录当然是一个文件,也有独立的 inode,也有自己的数据块,目录中的 block 数组能找到对应的数据块,目录的数据块维护的是文件名和 inode 的映射关系。换言之,在目录下创建文件时,除了给文件申请 inode、数据块之外,还要把文件名和申请创建成功之后文件的 inode 编号写到目录的数据块中。所以现在就能理解为什么大多数操作系统下同一个目录中不允许存在同名文件。所以只要我们找到了目录就可以找到文件名,根据映射然后可以找到文件 inode,通过 inode 读取文件的属性,也可以通过 inode 中的数组读取文件的内容。所以 ls -l 时就可以读到文件的属性信息,它是在当前目录对应的 inode 下找到对应数据块中文件名和文件名映射的 inode,再去找对应文件的 inode,此时就看到文件的属性了。所以 echo “hello world” > file.txt 是先启动进程,这个进程当然知道自己所在的目录,所以它就可以拿着 file.txt 文件名找它对应的 inode,把数据追加到对应的数据块中。所以我们说 inode 不存储文件名,只是往目录的数据块中写入文件名和文件对应的 inode。

    D) Block Bitmap 和 inode Bitmap 是位图,就是用比特位 0 1 来表示。Block Bitmap 用来标识数据块的使用情况,inode Bitmap 用来标识 inode 的使用情况,每个比特位都对应一个块。换言之,当你新建文件时,它并不是遍历 inode 区域,这样太慢了,它只需要在系统启动时,将 Blok Bitmap 和 inode Bitmap 预加载到系统中,你要新建文件,就把 inode Bitmap 的比特位由 0 至 1,文件需要多少数据块,就把 Block Bitmap 的比特位由 0 至 1。所以我们可以通过位图,可以快速的完成 inode 的申请和释放,同时也能确认当前磁盘的使用情况。但是位图依然还是需要去遍历哪些使用和未使用,以及做位操作等,所以这里通过 Group Descriptor Table 来管理。

    如何理解删除文件

    之前我们说过,计算机中删除一个文件并不是真正的删除,而是把那块空间标识为无效,就像房子上的 “ 拆 ” 字。而现在理解的是不用把 inode 属性清空,不用把 inode 对应的数据块清空,只要把两个位图中对应的比特位由 1 到 0,再把所在的目录下中的对应的映射关系去掉,此时空间就是无效的,下一次再新建文件时,就可以直接把无效的空间覆盖。

    按上面这样说,删除后的文件当然可以恢复,Windows 下的回收站就是一个目录,当你删除时就是把文件移动到回收站目录下,移动时就是把其它目录下数据块中的映射关系移动到回收站目录下的数据块中。Windows 下就算把回收站的内容删除也是能恢复的,Linux 下,如果要恢复删除的文件是有一些恢复工具的,但有可能在恢复过程中,创建各种临时文件,可能就会把想恢复的文件的信息覆盖掉,你想自己恢复删除的文件,就需要更深入的了解文件系统原理。

三、软硬链接

💦 软链接

ln -s file.txt soft_link 给 file.txt 建立软链接 soft_link。 file.txt 的 inode 是 790929,soft_link 的 inode 是 790955,也就是说软链接 soft_link 就是一个普通的正常文件,有自己独立的 inode,soft_link 中的数据块中保存着它指向的文件 file.txt 的路径,就类似于 Winodws 下的快捷方式,比如桌面看到的软件保存的是其它的路径,在系统中可能你要运行的可执行程序在一个很深的目录下,就可以在较上层的目录中建立软链接。

💦 硬链接

ln file.cpp hard_link 给 file.cpp 建立硬链接 hard_link。硬链接和它链接的文件的 inode 是一样的,硬链接没有独立的 inode,所以严格来说硬链接不是一个文件,硬链接本质就是在 file.cpp 文件所在目录的数据块中重新创建一个映射关系,也就是给 file.cpp 的 inode 重新起了一个别名,我们发现了链接后的 file.cpp 的有一个属性信息由 1 变为 2,所以这里 ls -l 显示的这一列数据表示的不是软链接,而是硬链接。

硬链接的应用

为什么创建普通目录的硬链接是 2 ?创建普通文件的硬链接是 1 ?—— 普通文件是 1 好理解,因为当前目录中只包含一组 file 和 file 的 inode;

而普通目录是 2 的原因是因为除了当前目录下包含了 dir 和 dir 的 inode,还有 dir 目录下中隐藏的 " . ",这个点叫做当前路径,此时我们发现这个点的 inode 和 dir 的 inode 是一样的,所以 dir 的 inode 编号是 2。 这个点就是 dir 的别名,因为当前路径的使用频率很高,所以它是为了方便我们对当前路径的索引,如果没有这个别名,那就只能是 " dir/xxx/… ",完全没有 " ./xxx/… " 方便。

我们再在 dir 下建立一个目录 other,此时 dir 的硬链接数就变成了 3,other 的硬链接数就是 2。—— other 的是 2 能理解,因为 other inode 和 . inode;

而 dir 之所以是 3,是因为要 " cd … ",所以 other 下还有一个点点,它是 dir 的别名。

所以硬链接最典型的应用场景就是方便进行路径转换。

以上是关于Linux学习基础IO——理解缓冲区 | 理解文件系统的主要内容,如果未能解决你的问题,请参考以下文章

Linux入门基础IO

Linux 基础IO——文件(中)

Linux系统编程:基础IO 下dup2 实现输出重定向输入重定向追加重定向 | 理解磁盘 | 理解文件系统中inode的概念 | 软硬链接

Linux系统编程:基础IO 下dup2 实现输出重定向输入重定向追加重定向 | 理解磁盘 | 理解文件系统中inode的概念 | 软硬链接

Linux系统编程:基础IO 下dup2 实现输出重定向输入重定向追加重定向 | 理解磁盘 | 理解文件系统中inode的概念 | 软硬链接

Linux系统编程:基础IO 下dup2 实现输出重定向输入重定向追加重定向 | 理解磁盘 | 理解文件系统中inode的概念 | 软硬链接