Linux操作系统
Linux--基础IO]
- C文件 IO 相关操作
- 系统调用接口
- 文件描述符 fd
- 重定向
- 重定向的三种情况
- 缓冲区
- dup2系统调用
- 文件系统
- 动态库和静态库
C文件 IO 相关操作
有关c语言的IO函数
fopen、fread、fwrite、f等等
我们先来看一个简单的例子:打开一个文件
- fopen函数的两个参数,第一个参数为文件路径,如果我们不加路径,只写文件名则指当前路径 —cwd
什么是当前路径呢?本质是进程运行时所处的路径
我们可以通过命令的形式看一看进程究竟在什么路径下
我们知道除了 axj aux 这样的查看进程命令,还可以通过proc文件夹 来查看进程
我们可以清晰的看到,有一个exe 指 可执行文件所处的路径,cwd 指进程运行时的路径
- 第二个参数为打开方式,总的来说,有b则是打开二进制文件,有+则一定是可读可写,其中r+为打开w+为新建有b为二进制
这个小小的例子就是打开一个用来写入的文件,要注意操作完要记着关闭文件,接下来我们尝试操作文件
fputs 将字符写入流中
fgets,将流中的内容写到缓冲区中,其中第一个参数为缓冲区,第二个参数则是我们预期输入缓冲区的大小
至此我们完成了大致的c语言文件操作,这里有几个细节
FILE* 是我们打开文件时的返回值,这里的文件类型可以是普通文本文件、二进制文件、设备文件等
还需要注意的是 ,进程运行时,会自动打开三个流文件,分别叫做标准输入、标准输出、标准错误,他们都在头文件中被声明,分别对应的设备为键盘、显示器、显示器。
因此,我们就可以通过fgets和fputs 来模拟printf和scanf
第三点:a和w分别为追加写入和覆盖写入,也就是从文末开始写 与 从文件开头开始写
以上就是大致c语言相关的io操作 ,当然还有fseek、ftell、frewind等等函数,我们这里不深入探究。
系统调用接口
我们上文中所使用的C语言IO函数,也就是c语言的库函数,它通过对系统调用接口的封装来让程序员轻易使用,接下来我们就聊聊更加底层的系统调用接口。
我们先来看看如何打开文件,叫做 open函数
它的参数有三个,第一个为我们所要打开的文件路径和文件名,这个与fopen相同
第二个参数为标志位参数,可以有O_RDONLY , O_WRONLY , O_RDWR 分别对应了只读 、只写、 可读可写。
除此之外,还有其他的标志位,比如 O_CREAT 我们可以发现 我们的系统调用接口与fopen有一些不同,fopen在文件不存在的情况下会自动创建,而open不可以,你需要写标志位O_CREAT 来增加这一功能。否则会打开失败,返回-1
第三个参数为我们创建文件时的权限值,所以如果文件已经存在 ,就不需要第三个参数了。
在此,我们来对第二个参数的标志位进行深入的了解:
我们可以认为有32个不同的标志位,因为我们在传参时,理论上可以传32bit位,标志位的本质就是一位为1,其余31位全部为0,来分别标志不同的状态。这也就解释了我们在进行标志时用 或 (|)这个运算符来处理。
这样比特位传递参数的方式就给我们许多的选线。
最后一个参数也有一些小细节,我们一定要输入四位的权限 ,第一位我们默认为0就好了,否则我们仅仅输入 666这样的码是无法完成权限的赋值的。
其中在赋值权限时,还要注意 umask这样的掩码,我们可以在打开文件前将掩码赋为0。
我们就可以轻易的赋值权限了。
最后我们来谈谈open的返回值,让我们意想不到的是它为int ,而我们fopen的返回值为FILE * 。
open的返回值:成功 返回file descriptor – 文件描述符 ,失败则返回 -1
我们可以打开多个文件,发现他们的fd值是从3开始的连续整数。
有两个问题:1.为什么没有0、1、2
2.为什么它是连续的,很容易让人想到数组
文件描述符 fd
我们回答第一个问题,我们还记得之前说过进程运行时会自动打开3个流,其中0就是 stdin的 fd, 1就是stdout 的 fd, 2 就是 stderr 的 fd。所以我们在打开第四个文件时,其返回值fd会为3 。我们可以想到,这三个流也是被打开的文件,所有文件都会在打开时有一个fd值。我们所谓的文件描述符就是数组下标,之后打开的文件会依次被赋予fd值。当然这是大致的了解。
我们现在就来看看文件描述符的底层逻辑:
我们的文件可以分为两种:1.磁盘文件 2.内存文件
磁盘文件:
就是我们保存在磁盘中的文件,这个文件会在进程调用它的时候进行加载到内存的操作,类似于我们的程序替换这样的加载器。这个文件的组成为:内容+属性,属性比如创建时间、文件大小、所属组、修改日期等等这样的信息 , 也称作 元信息 ,而内容就是我们打开所看到的内容。
内存文件:
当我们进程需要一个打开或是操作一个文件的时候,我们操作系统会为这个文件生成一个struct file 这样的文件描述结构体,其中大多包含了文件的元信息。一个个这样的结构体会形成双链表,让我们管理。在这之前,在进程创建的时候,还会生成一个struct files_struct 这样的结构体,这个结构体被pcb内的指针所指向,用来管理文件。其中这个结构体中有一个部分为一个指针数组,struct file* fd array[32] ,这里的每一个指针就会指向我们文件的struct file。而这里的数组下标就是我们所知道的文件描述符。其中这个文件描述符所在的数组是可以扩展的。所以我们每打开一个文件,都会有文件描述符指针指向它,并且它的指向是有特性的:也就是依次从小到大指。
我们都知道,一个系统中会有无数已经被打开的文件,所以我们通过这样的方式对文件进行管理。
当然这些结构体都处在内存中。
如果我们需要对一个文件进行操作,不是一下将磁盘中的全部文件拷贝到我们的内存中,而是通过缓冲区,延后式的加载到内存中让我们操作。
比如我们打开一个log.txt文件,并被赋予了3号fd , 我们现在read(3,xx,yy),此时执行到read这一步时才会通过我们的文件描述信息struct file 将文件加载到内存中。
此时如果我们close (1)这个stdout文件,那么我们在打开文件时,log.txt会被分配到 1号fd。
以上就是我们文件描述符的底层实现,它的分配规则就是从最小但是没有被使用的fd开始分配。
重定向
重定向的本质:就是修改文件描述符fd下标所对应的 struct file * 的内容。
比如原本 1 号fd 默认打开 stdout ,而现在让其指向了 log .txt ,以至于我们想要printf打印的内容 ,写入到了log.txt中。这就叫做重定向
我们在正式开始重定向的原理之前,我们需要明白一个道理,C语言的文件操作和系统调用的文件操作 ,究竟谁优谁劣
C库函数是一个fp指针,指向一个FILE对象。而这个FILE 对象中 就存在了 fd文件描述符 以及 缓冲区信息
我们经常使用的stdout 就是一个fp指针 ,指向了一个FILE对象,这个FILE对象中 存储了fd =1 这样的信息,然后进程对这个fd进行检索,在file_struct中 找到这个文件。也就是说:
C库函数是无法对fd进行直接操作的,而系统调用可以。
所以我们推荐使用C库函数等语言级别的函数来对文件进行操作,原因如下:
- 跨平台性
- 省去对于fd的直接操作
其中,这也就解释了,当我们关闭一个文件,而printf为什么不会依旧打印在stdout“显示器”中,因为stdout,它只认识FILE* ,当我们关闭文件导致 fd =1 指向其它文件,但是stdout所指向的结构体中依旧指向 fd = 1 的文件,因为C语言无法直接改变fd ,所以此时fd = 1 指向了log.txt ,printf 也就会往这个文件中打印信息了。
重定向的三种情况
输出重定向:
在这份代码中,我们混用了系统调用和c库,这是完全没有问题的
现象:本应该打印到显示其中的内容,打印到了文件中 --这就叫做输出重定向
本质:由于我们的关闭操作,fd = 1 这个 指针,被我们新打开的文件占用了。由于stdout这个 FILE * 没有鉴别能力,致使其指向的结构体依旧指向fd = 1 这个指针。致使printf打印到了文件中。
此时我们需要了解几个问题:
请问fopen究竟做了什么?!
答案:
- 给调用的用户申请 struct FILE 结构体变量,并返回FILE* 指针。
这就是为什么我们总写 FILE * fp = fopen() - 在底层 ,通过open 打开文件,并返回fd,并把fd填充到FILE对象的fileno中
之后我们对于文件的操作 fread fwrite fclose fputs fgets 都是通过这个返回的指针FILE*,找到fd,并对文件进行操作。
输出重定向:以写的方式打开文件,写到哪? 写到stdout中,我们在底层修改stdout所指的文件。达到输出重定向。
输入重定向:
所有的重定向道理都是一样的,所以我们简单看一下例子就好了。
输入重定向: 这里我们通过读的方式打开文件,从哪读?键盘磁盘,还是文件?这种改变就叫做输入重定向。
追加重定向:
追加重定向是输出重定向的一种,我们只需要在打开文件时添加上 追加标识符 O_APPEND ,其余和输出重定向完全一样
在这里,我们写了三个不同的输出,其中fputs 是 没有格式化输出的概念的,你给他写什么都会原模原样的打印
我们需要在了解一个概念:
我们键盘显示器都叫做字符设备,输入的还是显式的都是字符。而printf的功能就是将其它类型的数据,转换为字符,并打印到显示器。我们所写的%d 之类的,就是告诉编译器 这个数据是整数,你把整数转换成字符在输出吧。 —这就叫做格式化输出
格式化输入也是相同的道理,而我们转换的依据 ,就是ascll 码表。
缓冲区
我们先来看一段简单的代码:
当我们不带\n时,现象:休眠3s后才显示我们打印的字符串。
而当我们带上\n后,就是直接显示出来。
结论:不论什么情况,都是按代码步骤,先打印后sleep,但是打印的数据去了哪里,我们并没有看到。这就是缓冲区。
缓冲区有三种:无缓冲、行缓冲、全缓冲
行缓冲:一般用于显示器率刷新数据时的策略:遇到**\n** 或是 一行缓冲区满了 或是 进程结束了,才会刷新出数据,让数据从缓冲区中流出来。
全缓冲:只有当缓冲区文件写满了才会刷新,将数据流入磁盘中。
一个很简单的道理:全缓冲的效率是最高的,一次积攒很多,一起送达目的地,少去了很多来回路上的时间。但是这样虽然高效,我们在看显示器时,无法等待太久,所以采取了折中的方法,行缓冲,其效率和可用性是一种平衡
缓冲区究竟在哪里呢?缓冲区是谁提供的呢?接下来我们就来研究这两个问题。
依旧是一份简单的代码
操作:用C库和系统调用写入数据,写入到显示器和文件中,并通过fork 创建进程,使子进程和父进程有相同的代码和数据
现象:当正常打印到显示器中时,且不论有没有fork创建进程,两份代码都是正常打印我们想要的字符。而当我们重定向到文件中同时fork子进程,我们发现 只有系统调用所打印的字符串打印了一遍,C库打印的字符串都打印了两遍
分析:显示器 --行缓冲 文件-- 全缓冲 重定向改变了缓冲方式 ;c接口打印了两次 ,系统接口打印了一次。
解释:
向显示器打印,由于我们带了 \n 所以fork 前,这些字符都被打印并且被刷新出来。
此时重定向到文件中,缓冲区变为了全缓冲,也就意味着,我们这C语言的两行字符串在fork前,都没有刷新,全部被打印在了全缓冲区中。缓冲区一直没满且进程也没退出。
此时我们创建子进程,我们需要知道 ,缓冲区是在内存中,所以当我们创建子进程时,子进程会与父进程有相同的数据和代码。所以此时父子进程会有相同的缓冲区,由于进程之间的独立性,当子进程结束时,需要刷新缓冲区,也就是改变内存中的数据,就要发生写时拷贝。子进程会开辟新的空间并把之前的数据拷贝一份。随着进程结束,缓冲区刷新出一份字符串,之后父进程也刷新一份字符串。所以打印出了两份字符串。
大家一定很疑惑,为什么全缓冲区中有两行代码,而不是三行呢?
因为write是没有缓冲区的,只有两个C库函数存在缓冲区,所以write只打印了一份。
我们这里谈到的缓冲区 指 : 用户级缓冲区
结论:
为什么被打印两次:1、全缓冲区机制 2、写时拷贝 (OS)机制
至此,我们解释了为什么会出现上面代码的情况,而为什么系统调用没有缓冲区呢?C为什么有?
这个原因我们之后再谈,但是我们现在可以解决的是,缓冲区是C自带的,而不是系统提供的,因为C是对系统调用的封装,系统都没有缓冲区,你C自创的。
什么是流:数据流入缓冲区 在流出 缓冲区。缓冲区 相当于一个河床。
所以我们现在理解了,为什么FILE 对象,是由 fd 和用户缓冲 组成的了。
我们来看看FILE结构体吧!
刷新的本质:用户级别无法与硬件直接交互,更别提将数据直接刷新到硬件上。冯诺依曼体系决定了,用户层之下,一定有操作系统层,来对下,管理硬件。所以我们的刷新,就是将我们的用户级数据刷新到 kernel级缓冲区中,这就是操作系统的缓冲区,再由操作系统刷新到硬件中。
我又有问题了,为什么我们再写代码的时候,必须要加fflush 才能刷新出数据。
解释:当我们写入数据到文件中,全部写入了用户层缓冲区中,还没有流入系统级别缓冲区,此时进程还没有结束,我们在代码的最后就用close关闭了文件,那系统级缓冲区没了。之后随着进程的结束,用户缓冲区终于准备自动刷新了,可是此时系统文件已经被close了,数据就全部消失了。
所以面对这样的,写入无法刷新的情况,我们通常采用这样方法:
- fflush,手动刷新
- fclose 关闭,而不是close
这里需要清楚的是close关闭的是系统文件,而不是用户层文件,也就是说,close清空了fd的指向。而fclose 清空了FILE *的内容。fclose 清空fd和用户缓冲区 ,然后数据流入系统缓冲区,fclose还会调用close 对系统文件进行关闭。
至此,我们再来系统了解一下重定向:
文件描述符本质–数组的下标
重定向:用户层不变,也就是FILE* 层不变,改变的是系统层file* 的指向。语言级别感受不到底层的变换。这也就是重定向的根本。
stdout 和stderr 虽然都是显示器,但是两个是不同的fd,也就是说同一份文件,被同一个进程,打开了两次,fd分别对应了1和2
为什么说,linux下一切皆文件
我们进程控制硬件时 , 磁盘有read读取,write 写入,显示器没有read读出,只有write写入,网卡有读出和写入,键盘只有读出数据给进程,而没有写入。那么这么多读写操作,每一个都不同。那我们如何管理呢?我们只写了一份代码 ,一个read和write 就可以控制这么多硬件。本质在于 我们所使用的read 和 write 都是函数指针,指向了底层不同的硬件的read和write。我们只需要管理这个函数指针,就可以完成对所有硬件的操作。而这中函数指针,存在于struct_file 中,也就是元信息中,每一个文件打开后,都有对应的函数指针,指向底层硬件的接口。
所有一切皆文件,指一切皆 struct file { } ;
dup2系统调用
我们上文中写了很多重定向,都是用close关闭这样的函数实现的,这也太麻烦了。dup来了。
dup就是通过对fd指针的拷贝,实现重定向。也可以理解为,拷贝之后,有两个fd都指向同一个文件。
我们重点讲dup2,
先来读一段英文: dup2 makes newfd be the copy of oldfd 也就是说newfd是oldfd的拷贝
比如我们想要实现输出重定向,只需要 dup2 (fd ,1),此时 fd 和 1 都指向我们打开的文件。
这就是dup函数的基本使用了。