linux基础IO


前言


一、基础IO

1、文件预备知识

经过前面的学习,我们知道了文件 = 文件内容 + 属性(也是数据),所以当我们创建了一个文件并没有给里面写内容时,此时该文件也占有了空间,因为文件的属性也需要空间来存储。所以我们对文件的所有操作就是:(1). 对内容。 (2). 对属性。

1.1 文件类的系统调用接口

我们知道文件在磁盘(硬件)上放着,当我们想要通过代码来访问文件时的步骤为:写代码 -> 编译 -> 生成可执行程序.exe -> 运行 -> 访问文件,并且我们的程序访问文件的本质其实是该进程在访问文件。
磁盘是一个硬件,只有操作系统可以对其进行写入,所以如果我们想直接使用代码来对文件进行写入是不可能的,此时我们就需要使用操作系统提供的接口,操作系统提供的这些关于文件读写的接口就是文件类的系统调用接口
但是操作系统提供的文件类的系统调用接口使用起来比较难,所以每种语言都对这些操作系统提供的接口做了封装,以便这些接口可以被用户更好的使用,但是这也导致了不同的语言有不同的语言级别的文件访问接口(都不一样),但是,这些语言封装的其实是同一套操作系统提供的文件类的系统调用接口,只不过每个语言都规范了自己所写的封装函数,这使得每个语言的封装函数都不相同。例如Linux系统中提供的文件类的系统调用接口就只有一套,而这些语言都对这一套操作系统层面的文件接口进行了封装。
如果语言不提供对文件的系统接口的封装,那么所有的访问文件操作,都必须直接使用操作系统提供的文件类的系统调用接口,而使用这些语言的用户需要访问文件时,也使用这些操作系统提供的文件类的系统调用接口。但是如果用户使用了Linux操作系统提供的系统接口来编写访问文件的代码,那么这个程序就无法在windows等其它平台中直接运行了,因为Linux操作系统和windows操作系统提供的文件类系统调用接口不一样,所以这个程序就不具备跨平台性了。而当语言把所有的平台的代码都实现一遍,然后使用条件编译,当该程序在Linux系统中就使用Linux操作系统提供的系统调用接口,当该程序在windows系统中就使用windows操作系统提供的系统调用接口。
我们知道磁盘是硬件,而文件都存在磁盘中,所以向文件中写数据其实就是向磁盘中写数据;那么显示器也是硬件,printf向显示器打印其实也是向硬件中写入数据。
在Linux中,一切皆文件。
在这里插入图片描述

1.2 复习c语言接口

我们在前面学过c语言中使用fopen函数来打开文件并进行访问。可以看到fopen函数的第一个参数为要打开的文件的路径,第二个参数为打开该文件的模式,其中r为只读模式,w为只写模式,r+为读写模式,w+为读写模式,r+和w+的区别就是,使用r+模式时当打开的文件不存在时会报错,而使用w+模式时当打开的文件不存在时则会自动创建。使用w模式为覆盖式写入,而使用a模式为追加写入,a+模式为读写模式,并且写入为追加写入。
在这里插入图片描述
当前路径的概念
当我们在调用fopen函数时,第一个参数没有写绝对路径时,那么这个文件会被创建在该程序所在的目录下。
在这里插入图片描述
我们执行该程序后,然后在 /proc/目录下查看该进程的相关文件,我们可以看到该进程当前的目录。其实当一个进程运行起来的时候,每个进程都会记录自己当前所处的工作路径,这就是当前路径。
在这里插入图片描述
我们在使用fwrite存数据时,需要传入写入的字符串的长度,我们知道c语言中字符串后面都有一个\0字符串结束符,但是我们将字符串存入文件中时,是不会存这个\0字符串结束符的,因为\0是c语言的规定,而文件只保存有效数据,所以文件中不会存\0字符。
在这里插入图片描述
在这里插入图片描述
我们看到上面的代码中使用w模式打开文件,然后向log.txt文件中写入了一些数据。我们将代码中文件写入的操作都注释掉,然后重新编译并执行该程序,该程序就只是将log.txt文件打开再关闭,但是log.txt文件中原来的内容被清空。由此可以推断出w模式在向文件中写内容之前会先将文件中的内容清空,即在打开文件后马上先将文件内容清空。

在这里插入图片描述
在这里插入图片描述
并且我们看到输出重定向和w模式的写入类似,打开log.txt文件时会先将log.txt中的内容清除。
在这里插入图片描述
我们修改代码将log.txt以a追加模式打开,此时我们看到当向log.txt文件中写入内容时,不会将原来的内容删除了,而是在文件的最后追加新的内容。
在这里插入图片描述
在这里插入图片描述
下面我们再来复习读取文件的接口。可以看到log.txt文件中的内容被读取出来并打印到屏幕上了。
在这里插入图片描述
在这里插入图片描述
然后我们稍微将代码修改一下,将读取的文件路径改为从命令行获取,这时这个程序就实现了类似于cat命令的功能了。
在这里插入图片描述
在这里插入图片描述
我们知道c语言程序中会默认打开三个标准输入输出流:
(1). stdin。
(2). stdout。
(3). stderr。
即相当于c语言程序会默认执行下面的语句。
在这里插入图片描述
在这里插入图片描述

2、文件类的系统调用接口

c语言中操作文件的库函数在底层其实都是调用了文件类的系统调用接口。
在这里插入图片描述

2.1 open系统调用

可以看到open系统调用的第一个参数是要打开的文件的路径,第二个参数flags是一个标志位,用来表示open的一些选项,这些大写的选项都是宏定义,
在这里插入图片描述
在这里插入图片描述

那么为什么open系统调用的第二个参数要设置为这样呢?
下面我们来看一个案例,如何给函数传递标志位。当我们调用一个函数需要传入多种选项时,如果我们将每一个选项都定义为形参,当调用函数时将这些形参都一一传值,那么这样调用函数就会很麻烦,所以我们一般使用一个int类型的形参,因为一个int类型数据有32位,每一位都可以标识一种状态。
可以看到下面的代码中,当我们调用show函数时传入ONE,则就会执行ONE相关的语句,当传入ONE | TWO,则就会执行ONE相关的语句和TWO相关的语句。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
而open系统调用的第二个参数其实就是采用的这种方法,我们可以在下面的文件中查看到这些宏定义。当我们调用open系统调用时,需要open执行什么样的功能,就可以将对应的宏加进去,然后open函数中就会执行这些功能。
在这里插入图片描述
在这里插入图片描述

下面我们使用open系统调用。
我们使用open系统调用打开log.txt文件,然后我们给第二个参数传入O_WRONLY,即只写的选项,然后我们当前目录下是没有log.txt文件的,我们运行程序后发现打开文件失败,因为没有这个文件。这是因为当只有O_WRONLY选项时,只会打开文件进行写入,当没有这个文件时并不会自动创建文件。只有再加上O_CREAT才会检测如果没有该文件就先创建文件。
在这里插入图片描述
在这里插入图片描述
当加上了O_CREAT选项后,我们发现log.txt文件被创建了出来。并且我们看到此时打印出来了open的返回值fd为3。
在这里插入图片描述
在这里插入图片描述
我们发现上面的log.txt文件被创建出来后,该文件的权限是随机的。如果我们想要设置新建文件的权限,此时我们就需要用到open系统调用的第三个参数来进行设置。我们向open的第三个参数中传入新建文件的权限。
在这里插入图片描述
在这里插入图片描述
但是我们发现新建的log.txt文件的权限并不是我们设置的权限,这是因为有umask码而导致的。
在这里插入图片描述
如果我们不想要被系统的umask码改变创建文件的权限,可以使用下面的系统调用将该进程的umask码设置为0。下面的open创建文件时采用就近原则使用umask,所以会将umask当作为0。此时我们看到新建的log.txt文件的权限就和我们设置的权限一致了。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
当我们已经知道了文件存在后,就不需要传入第三个参数了,因为此时传入第三个参数文件的权限后,已经存在的log.txt文件的权限也不会改变。
在这里插入图片描述
在这里插入图片描述

2.2 close系统调用

close系统调用很简单,只需要向close中传入调用open打开文件时的返回值fd即可关闭该文件。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.3 write系统调用

可以看到write系统调用中也需要传入调用open打开文件时的返回值fd,然后需要传入要写入的数据的地址,然后传入要写入的数据的个数,而write的返回值ssize_t是成功并且完整的写入到文件中的数据的个数。如果发生错误,则write就返回-1。
在这里插入图片描述
下面我们调用write系统调用向log.txt文件中写入数据。我们看到数据成功写入到了log.txt文件中。
在这里插入图片描述
在这里插入图片描述
当我们再次向log.txt文件中写入新的数据时,我们发现log.txt文件中原来的数据并没有被清除,而新数据也没有追加到原数据后面,而是新数据替换了原数据的一部分内容。
在这里插入图片描述
在这里插入图片描述
如果我们在调用open时再加上O_TRUNC选项后,此时就相当于fopen的w模式了。可以看到此时test02程序向log.txt文件中写入数据时都会先将原来的数据先进行清空。
在这里插入图片描述
在这里插入图片描述
当想要实现追加添加数据时,即fopen的a模式,就需要将O_TRUNC选项换为O_APPEND选项。此时可以看到test02程序向log.txt文件中写入数据时是追加写入。
在这里插入图片描述
在这里插入图片描述

2.4 read系统调用

我们看到read系统调用的参数和write的相似,read系统调用中也需要传入调用open打开文件时的返回值fd,然后需要传入读取的数据要存入的地址,然后传入要读取的数据个数,而read的返回值ssize_t是成功并且完整的从文件中读取的数据的个数。如果发生错误,则read就返回-1。
在这里插入图片描述
下面我们来使用read读取数据。
因为需要读取数据,所以open的第二个参数需要传入O_RDONLY选项。然后我们创建一个buffer数组用来存读取到的数据,因为read只会读取数据,并不会自动添加\0,所以我们需要手动添加\0,即先将buffer中都初始化为\0。我们看到read成功读取到log.txt文件中的数据了,并且将这些数据打印了出来。
在这里插入图片描述
在这里插入图片描述

3、文件描述符

3.1 文件描述符fd介绍

通过上面对文件类系统调用接口的使用,我们发现了这些接口都离不开一个变量,即open系统调用的返回值fd,那么这个fd是什么呢?通过上面的使用我们可以知道close、write、read接口就是靠这个fd来找到要操作的文件,其实这个fd就是文件描述符。
下面的代码中我们打开了4个文件,然后分别打印open的返回值,然后发现这些文件的文件描述符从3开始,那么0,1,2.去哪里了呢?还记得我们前面提到的c语言程序中会默认打开三个文件流吗,其实这三个文件流的fd就是0,1,2。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
下面我们证明文件描述符1就是标准输出流。
我们调用fprintf函数向stdout标准输出流中写入数据,然后数据会显示到屏幕上。然后我们调用write向文件描述符为1的文件中写入数据,发现数据也会显示到屏幕上,所以文件描述符1对应的就是标准输出流。
在这里插入图片描述
在这里插入图片描述
然后我们证明文件描述符0就是标准输入流。
我们调用read系统调用从文件描述符为0的文件流中读取数据,然后再打印出来。当我们执行test05可执行程序后,我们在键盘上输入内容然后按回车后发现输入的内容显示到了屏幕上,这说明文件描述符0对应的就是标准输入流。
在这里插入图片描述
在这里插入图片描述

我们知道stdin、stdout、stderr都是c语言默认创建的三个FILE*类型的变量,那么FILE是一个什么类型呢?
FILE其实是一个c标准库提供的结构体,即在c语言中每一个打开的文件都会对应一个FILE结构体,这个结构体里面存储了关于打开的文件的相关信息。我们知道c语言中提供的fopen、fwrite、fread、fclose等函数的底层其实都调用了open、write、read、close系统调用接口,而调用这些系统调用都需要传入文件描述符,所以我们可以推测出FILE结构体里面必定封装了fd,因为文件类系统调用接口只认fd,而不认c语言的FILE结构体。

在这里插入图片描述
下面我们证明FILE结构体里面封装了fd。
我们知道stdin、stdout、stderr就是三个FILE * 类型的指针,所以我们可以通过stdin->变量的方式来访问stdin指向的FILE结构体里面的变量。
我们使用下面的代码打印stdin、stdout、stderr指针指向的FILE结构体里面的_fileno变量的值,可以看到stdin、stdout、stderr指针指向的FILE结构体里面的_fileno变量的值就是stdin、stdout、stderr这三个文件流的文件描述符。所以在调用系统调用时,其实是将这三个文件流的fd传入到了系统调用中。
在这里插入图片描述
在这里插入图片描述
经过上面的验证我们知道了c语言的FILE结构体中封装了文件描述符fd,那么这个文件描述符fd到底是什么呢?
我们知道进程要访问文件,必须先打开文件,并且一个进程可能需要打开多个文件,所以一般而言
进程 : 打开的文件 = 1 : n。文件可以被进程访问的前提是该文件已经加载到了内存中,然后才能被进程直接访问。那么如果有多个进程都需要访问文件时,此时内存中就会加载了大量被打开的文件,而这些文件都需要通过操作系统提供的系统调用接口来进行操作,所以操作系统需要管理这些被打开的文件。我们知道操作系统对进程的管理通过task_struct,操作系统对进程地址空间的管理通过mm_struct,操作系统对资源的管理都是采用先描述,再组织的方式管理资源,所以操作系统对文件的管理也构建了一个struct file结构体,用来管理内存中被打开的文件,即内存中每一个被打开的文件操作系统都会创建一个struct file结构体对象来记录这个文件的所有内容(不仅仅包含属性内容)。然后操作系统将每个被打开文件对应的struct file结构体对象使用双链表组织起来,然后又使用一个strcut file * array[32]的指针数组来存储这些被打开文件对应的struct file结构体对象的地址。而fd就是每个被打开文件对应的strcut file结构体对象的地址在strcut file* array[32]数组中的下标。所以fd在内核中,本质就是一个数组下标。一个进程的task_struct中有一个指针指向了strcut file* array[32]这个指针数组,这就使这些被打开的文件与进程建立了对应的关系。
在这里插入图片描述
我们知道文件 = 内容 + 属性。
通常我们称:
(1). 被进程打开的文件(内存文件)。
(2). 没有被进程打开的文件,即在磁盘上的文件(磁盘文件)。

下面我们看Linux内核的源码,来看源码中的struct file和task_struct是什么关系。
我们查看内核源码中描述进程的task_struct结构体中有一个struct files_struct * files类型的指针变量。
在这里插入图片描述
然后我们查看struct files_struct结构体中,可以看到struct files_struct结构体中有一个struct file * fd_array[NR_OPEN_DEFAULT]指针数组,这个指针数组里面存储的都是struct file * 类型的指针。
在这里插入图片描述
然后我们查看struct file结构体,可以看到struct file结构体中存的就是内存文件相关的信息。当进程打开文件时,即当一个文件被加载到内存中时,操作系统就会为这个内存文件创建一个struct file结构体对象。
在这里插入图片描述
通过上面查看源码我们可以得出这些结构体之间的关系如下图所示。
在这里插入图片描述
每当创建一个进程都会创建相应的task_struct,在task_struct中会有一个mm_struct * 类型的指针指向该进程的地址空间,也会有一个 files_struct * 类型的指针指向该进程的文件结构体,在该文件结构体中会有一个指针数组。当从磁盘中打开一个文件时,此时该文件就变为内存文件,操作系统就会为该文件创建一个struct file来记录该文件的属性。(类似于一个进程加载到内存时操作系统创建一个对应的task_struct来管理该进程一样),操作系统为每个内存文件创建一个struct file,以便来管理打开的内存文件。然后操作系统将这个打开的文件的struct file的地址填入到文件结构体的指针数组中,这个文件的文件描述符就是该内存文件的struct file在数组中的下标。当该进程中进行read、write文件操作时,需要传入文件描述符,通过这个文件描述符在该进程的文件结构体中的数组找到中找到要修改的内存文件的 struct file,然后修改该内存文件的内容,以此来进行文件的修改。所以本质上文件描述符就是数组下标。
文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针 * files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
在这里插入图片描述
下面为在c语言中调用文件类的函数时,操作系统底层所做的处理。
在这里插入图片描述

3.2 文件描述符fd分配规则与重定向

我们看到每次新打开的文件的文件描述符fd的值都为3,这是因为c语言中默认会打开stdin、stdout、stderr三个文件流,这三个内存文件的文件描述符分别为0、1、2。所以每次新打开的文件描述符的值都为3。
在这里插入图片描述
在这里插入图片描述
下面的代码中我们在刚开始就使用close将文件描述符为0的内存文件关闭,此时可以看到新打开的内存文件的文件描述符就为0了。
在这里插入图片描述
在这里插入图片描述
下面的代码中我们在刚开始使用close将文件描述符为2的内存文件关闭,此时可以看到新打开的内存文件的文件描述符的为2了。综上我们可以得出fd的分配规则为:找到最小的,没有被占用的文件描述符。
在这里插入图片描述
在这里插入图片描述
下面的代码中我们在刚开始使用close将文件描述符为1的内存文件关闭,此时发现该程序就不会打印内容到显示器上了,并且在log.txt文件中我们也没有看到打印的内容。
在这里插入图片描述
在这里插入图片描述
我们将clost(fd)注释掉,然后我们发现虽然程序同样没有在屏幕上打印内容,但是log.txt文件中有了要打印的内容了。
在这里插入图片描述
在这里插入图片描述
然后我们将close(fd)不注释掉了,但是在close(fd)前面加一个fflush(stdout)语句,然后我们会发现log.txt文件中也有了要打印的内容。
在这里插入图片描述
在这里插入图片描述
下面我们多调用几个接口向stdout中写入数据。然后我们同样发现显示器上没有内容打印,而log.txt文件中被写入了本该打印到显示器上的数据。这几个案例中本来数据都是向stdout(标准输出),即显示器中写入的,但是我们看到这些数据都被写入到了log.txt文件中,其实这就是输出重定向,即本该输出到显示器中的数据重定向输出到了log.txt文件中。
在这里插入图片描述
在这里插入图片描述

3.3 重定向原理

我们知道在c语言程序中默认会打开三个文件,即创建三个FILT* 类型的变量,分别为stdin、stdout、stderr,这三个FILE* 类型的指针变量指向的的FILE结构体中有一个_fileno变量,该变量记录了c语言中内存文件的文件描述符,这三个内存文件的文件描述符分别为0、1、2。
例如stdout -> FILE* -> FILE -> _fileno(1),所以当调用fprintf(stdout,…)函数传入stdout时,其实fprintf函数在底层调用write系统调用,并且传入的文件描述符为stdout指向的FILE结构体里面的_fileno变量的值,即向write系统调用中传入的文件描述符为1。此时write系统调用操作的就是文件描述符为1的内存文件,即修改的就是fd_array指针数组中下标为1的元素指向struct file结构体对象中的数据。而因为我们在代码的起始时就将文件描述符为1的内存文件关闭了,所以当我们调用open(“log.txt”, O_WRONLY | O_CREAT | O_TRUNC, 0666);打开log.txt文件时,此时操作系统为log.txt内存文件分配的文件描述符为1,所以下面的代码中我们调用fprintf(stdout,…)函数,其底层都是调用write(1)对文件描述符为1的内存文件进行了操作,而此时文件描述符为1的内存文件是打开的log.txt文件,所以使用fprintf(stdout,…)就将数据写入到了log.txt文件中。
我们看下面的图知道正常的代码中stdout的文件描述符为1。然后fprintf(stdout,…)函数会将数据写入到显示器上。
在这里插入图片描述

但是当我们刚开始就将文件描述符为1的内存文件关闭了。然后使用open系统调用打开log.txt文件,此时log.txt文件的文件描述符fd就是1,所以fprintf(stdout,…)函数将数据写到文件描述符为1的文件中,其实就是将数据写入到了log.txt文件中了。
在这里插入图片描述

3.4输入重定向和追加重定向

下面的图片中代码都忘了在最后关闭被打开的文件log.txt了,都应该加上一句close(fd);。
输入重定向:
下面的代码中我们可以看到使用fgets函数从stdin文件中读取数据,即从键盘中读取数据,然后打印到显示器上。
在这里插入图片描述
在这里插入图片描述
然后我们调用open系统调用将log.txt文件打开,此时可以看到打开的log.txt文件的文件描述符fd为3。然后我们在键盘中输入的数据也打印在了显示器上。
在这里插入图片描述
在这里插入图片描述

下面我们在代码的开始使用close将文件描述符为0的内存文件关闭,此时可以看到打开的log.txt文件的文件描述符fd就是0了,然后我们使用fgets函数从键盘中读取数据变成了从log.txt文件中读取数据了,这是因为发生了输入重定向。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
追加重定向:
下面的代码为输出重定向,将本该输出到显示器的数据输出到了log.txt文件中,我们发现每次输出重定向向log.txt文件中写入数据时,都会先将log.txt文件中的数据清空。
在这里插入图片描述
在这里插入图片描述

我们将open的O_TRUNC选项换为O_APPEND选项,此时每次向log.txt文件中写入数据时就不会将原来的数据先清空了,这就是追加重定向。

在这里插入图片描述
在这里插入图片描述

4、重定向系统调用dup2

上面的代码实现重定向之前需要先使用close关闭文件,然后再使用open打开一个文件,使该文件使用刚才关闭文件的文件描述符,这样的操作感觉很麻烦。其实操作系统还提供了关于重定向的系统调用接口dup、dup2、dup3接口。下面我们来学习dup2系统调用接口。

在这里插入图片描述
我们可以看到dup2有两个参数,一个为oldfd,一个为newfd。如果我们想让本来应该输出到文件描述符为1的显示器文件的数据输出重定向到文件描述符为3的log.txt文件中。我们应该这样使用dup2(3,1)。即表示将fd_array数组中下标为3的元素的内容拷贝到下标为1的元素中,那么此时文件描述符为1的文件也是log.txt文件了。
下面为没有调用dup2系统调用时,每个内存文件和对应的文件描述符的情况。
在这里插入图片描述
当调用dup2系统调用后,将fd_array数组中下标为3的元素的内容拷贝到下标为1的元素中,那么此时文件描述符为1的文件也是log.txt文件了,即fd_array[]数组中下标为1的元素存的也是log.txt内存文件的struct file结构体的地址了。
在这里插入图片描述
下面我们来使用dup2实现输出重定向。
下面的代码中我们将每次执行该程序的命令行参数给打印了出来,此时我们没有调用dup2,可以看到数据都输出到了显示器上。

在这里插入图片描述
在这里插入图片描述
下面我们调用dup2,即将文件描述符为1的内容拷贝到文件描述符为3的内容中。此时我们可以看到本来应该输出到显示器的数据发生了输出重定向,输出到了log.txt文件中。并且输出重定向在写数据之前会将log.txt文件中的旧数据先清空。
在这里插入图片描述
在这里插入图片描述
使用dup2实现追加重定向。
我们将代码中调用open时选项O_TRUNC改为O_APPEND时。此时每次向log.txt文件中写入数据时,就不会将原来的数据清空了,这就是追加重定向。
在这里插入图片描述
在这里插入图片描述

5、一切皆文件

我们知道在c语言中使用struct来实现面向对象的过程,即struct就类似于class,但是class中有成员属性和成员方法,而struct中不能定义函数,但是struct中可以使用函数指针来指向定义好的函数。Linux就是使用c语言写的,在Linux的内核中,struct file结构体的定义就类似于下面这样定义的,struct file结构体中不仅记录了每个内存文件的相关信息,还使用函数指针指向了这些内存文件对应的方法。
在这里插入图片描述
经过了上面对文件的学习,我们知道了像磁盘、显示器、键盘、网卡等硬件设备的读写操作,其实就类似于对文件进行读写操作,只不过这些底层不同的硬件,一定对应不同的操作方法,即不同的读写方法。这些外设的核心访问函数其实都是read、write等IO函数,并且因为这些硬件的功能不同,所以这些设备都有自己的read和write方法,并且每个设备之间的read和write方法的代码实现一定不同,因为这些外设都有着不一样的功能。
在这里插入图片描述
那么这些硬件的读写方法都不一样,Linux操作系统是怎么为它们创建struct file结构体对象的呢?
Linux内核中的struct file结构体中有对应的函数指针,当给不同的硬件创建struct file结构体对象时,就将这些函数指针指向这个硬件自己的对应函数。
这些硬件的核心函数就是read和write,而Linux中对普通文件进行的操作也是read和write操作。这样看来普通文件和硬件的操作都是read和write操作,不同的是每个硬件的read和write函数的实现都不相同,而普通文件的read和write函数的实现相同,Linux中想要将管理硬件也和管理普通文件一样,但是硬件的read和write函数和普通文件的read和write函数不一样。所以Linux在struct file结构体中创建了readp和writep函数指针。当创建的为操作硬件的struct file结构体对象时,该结构体对象的readp和writep函数指针就指向该硬件自己的read和write函数,而当创建的为操作普通文件的struct file结构体对象时,该结构体对象的readp和writep函数指针就指向普通文件的read和write函数。这样对于上层来说,操作硬件和操作普通文件都是对struct file结构体对象里面的内容进行修改,即在上层看来操作硬件就好像也是和操作普通文件一样了,这就实现了Linux中一切皆文件。
在这里插入图片描述

6、缓冲区

6.1 缓冲区

缓冲区其实就是一段内存空间,这个空间由c标准库提供并维护。
我们知道当一个进程等待IO设备时,会进入阻塞状态。在我们写的c语言程序中,如果要将数据打印到显示器上时,其实就是一次IO操作,而如果我们每打印一个字符就调用一次write系统调用将这个数据写到显示器中,然后等待显示器的回应,这样就需要频繁的调用write系统调用来向显示器中写入数据,因为显示器为外设,所以每一次向显示器中写入数据都是很慢的,而且等待显示器的回应也是很慢的,会影响用户交互,这种为写透模式(WT,即数据写到目标文件后才向下执行命令)。所以c标准库中提供了缓冲区的概念,即进程中要放到显示器中打印的数据先放到缓冲区中,然后缓冲区马上给进程回应,告诉进程这个数据已经写到显示器中了,其实这个数据现在还在缓冲区中存放,但是对进程来说这个数据已经到显示器中了,所以进程就可以继续执行下面的命令了,这种为写回模式(WB,即不管数据有没有写到目标文件中,只要数据写到缓冲区中,就认为已经写到了目标文件中),所以缓冲区的存在可以提高整机效率,并且提高用户的响应速度。缓冲区中会有刷新策略,例如立即刷新(缓冲区有数据就马上调用write接口将数据写到目标文件中)、行刷新(遇到\n才会调用write接口将缓冲区的数据写到目标文件中)、满刷新(当缓冲区的数据存满后才调用write接口将数据写到目标文件中)。除了这三个常规的刷新策略,还有特殊情况时的刷新策略,例如用户使用fflush强制刷新缓冲区数据、进程退出时会刷新缓冲区数据。所以缓冲策略 = 一般 + 特殊。

我们看下面的代码中,先使用了c语言提供的关于文件操作的函数向显示器中写入数据,然后又使用操作系统提供的系统调用向显示器中写入数据。并且我们在最后调用了fork系统调用创建了一个子进程。
在这里插入图片描述
当我们执行test05程序时,可以看到显示器中打印了这些数据。然后我们test05程序输出的数据输出重定向到log.txt文件中,我们cat查看log.txt文件的内容时发现使用c语言函数向显示器写入的数据被写到log.txt文件中两次,而使用write系统调用向显示器中写入的数据只在log.txt文件中写入一次。
在这里插入图片描述
当我们将代码最后的使用fork创建子进程的语句注释掉时,再次运行test05程序,我们看到显示器中只打印了一遍数据,当我们将test05输出的数据输出重定向到log.txt文件中后,我们看到此时log.txt文件中这些数据也只被写入了一次。
在这里插入图片描述
在这里插入图片描述
我们将上面的两个情况进行对比,为什么会发生这种现象呢?
我们需要先知道所有的外部设备永远都倾向于全缓冲刷新(满刷新),因为进程和外部设备进行IO操作时,数据量的大小不是主要矛盾,进程和外部设备预备IO的过程是最耗时间的,所以全缓冲可以更少次的外设的访问,以此来提高效率,所以磁盘文件都会使用全缓冲刷新策略。
虽然所有的外部设备永远都倾向于全缓冲刷新,但是刷新策略还需要结合具体的情况而进行更改。例如显示器这个外部设备,它是直接给用户看的,所以它不止要照顾效率,还要考虑用户的响应时间,所以显示器的刷新策略是行刷新。
c语言程序中使用c语言提供的关于文件操作的函数时,这些数据都会存放在c标准库维护的缓冲区中。
下面我们就来解释上面的现象,我们知道当代码执行到fork时,上面的printf、write等函数都已经执行完了,但是并不代表这些数据现在已经都被写入到了目标文件中。
c语言中如果是将这些数据写到显示器中,因为显示器的刷新策略是行刷新,所以当这些数据在缓冲区中时,如果遇到了\n就会将\n之前的数据写入到显示器文件中。所以此时当代码执行到fork的时候,函数执行完了,并且数据已经被刷新了,此时缓冲区中是没有数据的,此时执行fork创建子进程,子进程在写时拷贝时拷贝父进程的缓冲区数据时并没有拷贝到数据,因为此时缓冲区数据已经刷新出去了,所以test05程序在向显示器中写入数据时只写入了一次。
而当程序重定向向磁盘中的文件中写入数据时,此时刷新策略变为了全缓冲,所以在fork的时候,函数执行完了,但是缓冲区中的数据还没有被刷新,此时这些数据就在缓冲区中,而fork创建的子进程在写时拷贝时也会将缓冲区的数据进行拷贝。所以就可以看到调用c语言函数写的数据被写入了两次,因为这些数据都存在缓冲区中,而调用系统调用写入的数据只写入了一次,因为这些数据直接写到了内核缓冲区中。所以log.txt文件中父子进程都将缓冲区的数据写入到了log.txt文件中。
在这里插入图片描述
如果我们在fork之前调用fflush函数将缓冲区中的数据都刷新后,那么当fork创建子进程后,子进程写时拷贝时拷贝父进程的缓冲区数据就为空,此时再向log.txt文件中输出重定向数据时,就只有父进程的数据写入到了log.txt文件中,而子进程的缓冲区为空,所以没有数据写入到log.txt文件中。
在这里插入图片描述
在这里插入图片描述

在前面我们说过,c语言程序中默认打开三个文件流,即stdin、stdout、stderr,我们可以在/usr/include/stdio.h头文件中看到这三个文件流的定义。
在这里插入图片描述
我们在前面说过c语言中的FILE结构体中封装了文件描述符fd,其实FILE结构体中不只是封装了文件描述符fd,还包含了该文件fd对应的c标准库中的缓冲区结构。我们可以在/usr/include/libio.h文件中看到FILE结构体中包含了缓冲区的开始地址和结束地址。
在这里插入图片描述

6.2 问题解决

下面我们来看一下以前提出的一个问题。
我们在代码刚开始将文件描述符为1的文件关闭,然后打开log.txt时,此时log.txt为1,printf和fprintf和write不再是向显示器中写数据,而是向普通文件log.txt中写数据。我们在代码最后使用fork创建一个子进程并且将close(fd)注释掉,然后我们发现在将test01的数据输出重定向到log.txt文件中时,发现c语言函数输出的数据被写入了两次,系统调用函数的输出被写入了一次,这是因为c语言函数的输出数据都先存放在c标准库的缓冲区中,而系统调用的输出数据存放在内核缓冲区中,因为此时是向普通文件写数据,所以遇到\n时不再会刷新,而是当缓冲区满时才刷新。fork创建子进程后,子进程的缓冲区中也有父进程的c标准库的缓冲区的数据。然后在进程结束后将强制将缓冲区的数据进行刷新,所以log.txt中的数据才是这样。
所以我们需要使用fflush来手动刷新。而当不使用ffluse手动刷新缓冲区的数据时,
在这里插入图片描述
在这里插入图片描述
如果我们将close(fd)不进行注释,此时将test01输出的数据重定向到log.txt文件中后,我们发现log.txt文件中只有系统调用write的数据,这是因为当创建子进程后,虽然父子进程的缓冲区中都有数据,但是当执行close(fd)后,此时数据还在缓冲区中,但是对应的文件已经关闭了,所以当进程关闭时进行缓冲区数据强制刷新时没有刷新出来数据,因为文件已经关闭了,缓冲区的数据已经清空了。
在这里插入图片描述
在这里插入图片描述
如果我们在关闭文件之前使用fflush函数刷新缓冲区数据,此时看到数据又正常写入到log.txt文件中了。
在这里插入图片描述
在这里插入图片描述

7、练习

7.1 自己设计用户层缓冲区

通过上面的学习我们知道了c标准库中提供了一个缓冲区,并且缓冲区的一些信息就包含在FILE结构体中,下面我们自己也模拟实现一下缓冲区。
我们创建一个MyFILE结构体,里面封装了文件描述符fd,然后创建了一个长度为1024的字符串数组buffer,这个buffer就是我们模拟实现的缓冲区,然后我们定义一个end来表示这个数组的有效数据个数。这样当我们想要打开文件,写数据或者关闭文件时就调用我们自己写的接口,对我们自己定义的MyFILE结构体进行操作。
在这里插入图片描述
在这里插入图片描述
下面我们就来一一实现这些文件操作函数,首先我们实现fopen_函数,该函数的第一个形参为要打开文件的路径,第二个形参为要以什么模式打开这个文件。我们先创建一个MyFILE* 类型的结构体指针并且置为NULL,然后判断该函数的第二个参数为以什么模式打开这个文件,然后我们调用open系统调用,传入不同的选项来实现不同模式打开文件的效果,如果open系统调用打开文件成功,那么就使用malloc申请一片空间创建一个struct MyFILE结构体对象,然后将fp指向这片空间,并且将这个struct MyFILE结构体对象的数据都进行修改。最后将结构体指针fp返回。

在这里插入图片描述
然后我们实现fputs_函数,该函数第一个参数为要写入的数据,第二个参数为要操作的MyFILE结构体对象的指针。我们将fputs_并不会直接将fputs_要写入的数据写到目标文件中,而是将数据存到我们的buffer数组中,即缓冲区中。
在这里插入图片描述
然后我们再实现fflush_函数,该函数可以刷新缓冲区数据。当我们调用write系统调用时,其实数据并没有被直接写到磁盘中,而是写到了内核缓冲区中,而syncfs系统调用会直接将数据写到磁盘中。
在这里插入图片描述
在这里插入图片描述
然后我们使用下面的代码进行测试,我们在fputs_函数中打印buffer数组中的内容,即缓冲区中的内容。然后我们可以看到buffer数组里面的内容是调用fputs_函数每次写入的内容,这说明我们向缓冲区中写入数据成功。并且因为在fclose_函数中我们调用了fflush_函数,所以最后buffer数组中的内容会被写到log.txt文件中。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

下面我们就可以继续写fputs_函数,并且在该函数内实现一些刷新策略,例如当遇到’\n’时就调用write系统调用写数据或者当buffer数组全满时才调用write系统调用写数据。下面实现的是行刷新策略,即当向文件描述符为1的文件中写数据时采用行刷新策略。我们还可以在文件描述符为其它值时写不同的刷新策略。
在这里插入图片描述

然后我们在测试代码开始将文件标识符为1的文件关闭,然后再打开log.txt文件,此时log.txt的文件标识符就为1,然后我们在前两个要写入的数据中加入\n,则在fputs_函数中就会先将第一个数据写入到log.txt文件中,然后将第二个数据写入到log.txt文件中,最后将第三个和第四个数据一起写到log.txt文件中。因为此时stdout已经被我们关闭,所以我们将测试的内容显示到stderr中来验证。我们看到buffer数组里面的数据遇到\n时就会刷新了。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
下面我们再演示使用fork创建子进程后,log.txt文件中会有两份数据写入的情况。
当重定向写入到lot.txt文件中时,没有带\n时,在fork时,虽然代码执行完了,但是缓冲区buffer里面的数据还没有写入到文件中,所以子进程写时拷贝父进程数据时会将buffer也拷贝一份。然后我们的fclose函数中都会调用fflush函数将数据刷新,即将数据写入到文件中。所以父子进程都会将数据写入到文件中,故log.txt中有两个数据。
在这里插入图片描述
在这里插入图片描述

7.2 myshell中加入重定向功能

我们之前写的myshell脚本并没有重定向功能,下面我们将myshell脚本加上重定向功能。
首先我们需要得到命令行命令cmd_line,然后分析这个命令中是否有重定向。
我们使用CheckRedir函数来检测cmd_line中是否有重定向命令,如果有重定向命令,CheckRedir函数中就将重定向的文件名返回,如果没有重定向命令,则CheckRedir命名就返回NULL。
在这里插入图片描述
因为CheckRedir函数中检查是否有重定向命令有四种结果,即输出重定向、输入重定向、追加重定向、无重定向。所以我们设置四个宏来表示这四个重定向,然后设置一个全局变量来记录该重定向的状态。然后我们实现CheckRedir函数,在该函数中我们将end指针指向cmd_line命令行字符串的最后一个字符,然后向前遍历cmd_line,如果遇到了重定向就接着判断,并且将重定向命令的位置变为字符串结束符’\0’,这样重定向命令前面的命令和后面的命令就分开了,然后将重定向命令后面的命令字符串返回。如果没有检测到重定向命令,就返回NULL。
在这里插入图片描述

然后我们在子进程中判断命令中是否有重定向命令,如果有重定向命令就先执行重定向命令,并且我们根据redir_status的不同状态来决定open系统调用中以什么选项打开文件。
在这里插入图片描述
我们可以看到输出重定向中每一次写入数据前都会先将原来的数据清空。
在这里插入图片描述
我们看到追加重定向不会将文件中原来的数据清空,而是在文件末尾追加写入数据。
在这里插入图片描述
我们看到输入重定向功能也可以正常使用。
在这里插入图片描述

在这里插入图片描述

8、stdout和stderr

8.1 错误重定向

我们知道stdout和stderr打开的都是显示器,即都是向显示器中打印数据。下面我们分别向stdout和stderr中写入数据,然后我们看到向文件描述符为1的stdout文件和文件描述符为2的stderr文件中写入的数据都在显示器中打印了出来,但是当进行输出重定向时,只有文件描述符为1的stdout的数据被重定向输出到了log.txt文件中。
在这里插入图片描述
在这里插入图片描述
文件描述符1 和 2 对应的都是显示器文件,但是它们两个是不同的,可以认为是同一个显示器文件被打开了两次。
在这里插入图片描述

使用下面的命令可以将错误信息和输出信息分别写入到不同的文件中。其中./test02 > log.txt 2> err.txt表示在将test02的数据输出重定向到log.txt文件之前,先将test02的数据中本该写入到文件描述符为2的文件中的数据输出重定向到err.txt文件中,然后再进行test02 > lot.txt,所以此时会将本该写入到文件描述符为1的文件中的数据输出重定向到log.txt文件中。

./test02 > log.txt 2> err.txt

在这里插入图片描述
如果我们想让test02的错误信息和输出信息都写入到log.txt文件中,我们可以使用下面的命令。
./test02 > log.txt 2>&1命令表示先将文件描述符为1的文件输出重定向到log.txt文件中,然后将文件描述符为1的内容拷贝给文件描述符为2的内容,此时文件描述符为1和2的文件都输出到log.txt文件中内容。

./test02 > log.txt 2>&1

在这里插入图片描述
在这里插入图片描述
当我们想要将一个文件的内容拷贝到另一个文件时,可以使用下面的命令。

cat < log.txt > back.txt

在这里插入图片描述

8.2 模拟实现perror

我们在上面的程序中看到perror打印的信息后面多出来了一个Success。我们在c语言中学过一个全局变量errno,该变量记录了程序的错误码,下面我们修改errno的值。可以看到当errno为不同的值时,perror所打印出来的错误信息也不一样。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
下面我们自己模拟实现perror函数。我们在学习c语言时学到了一个strerror函数,只要向该函数中传入错误码,该函数就会返回对应的错误信息,我们模拟实现perror函数底层就调用这个函数来实现打印错误信息。
在这里插入图片描述
下面的代码中我们打开log.txt文件,当没有log.txt文件时,就会报错,而我们自己写的myperror正确将错误信息打印了出来。
在这里插入图片描述
在这里插入图片描述

9、磁盘文件

通过前面的学习我们知道了当磁盘中的文件加载到内存中,并且被打开时,此时该文件变为内存文件。而那些在磁盘中没有被打开的文件叫磁盘文件。那么这些磁盘文件当我们使用时该如何找到呢?并且我们怎样查看这个磁盘文件的大小、文件属性等信息呢?而且磁盘中有很多磁盘文件,操作系统又是怎样存储并且管理这些磁盘文件的,当查找一个文件时是如何快速找到指定的文件的。这就需要我们先来了解一个存储数据的硬件——磁盘。

9.1 磁盘介绍

我们的电脑中内存是掉电易失存储介质,所以当关闭电脑时,内存中的数据都会丢失。
而磁盘是永久性存储介质,当关闭电脑时磁盘中的数据也不会丢失,并且我们常见的 SSD、U盘、flash卡、光盘、磁带也都是永久性存储介质。
磁盘是一个外设,它是计算机中唯一的一个机械设备,所以当从磁盘中存取数据时是很慢的,远远比不上电脑CPU的速度,所以操作系统就需要通过一些方式来将访问磁盘数据的过程加快。
下面就是磁盘的物理结构
我们可以看到磁盘中后很多盘面,每一个盘面被划分为一个个磁道,而每一个磁道又被划分成一个个扇区,每个扇区就是一个磁盘块,各个扇区存放的数据量是相同的,一般都是512字节。操作系统从磁盘中取数据时一次取4KB大小的数据,即8个扇区的数据。
看到下面磁盘的物理结构后,我们会想计算机是如何将数据写到指定的扇区中的呢?
我们想要将数据写到指定扇区之前要先找到这个扇区,通过上面的介绍我们知道了要确定一个扇区需要先知道它所在的盘面,然后知道它所在的磁道,然后才能确定这个扇区的具体位置。这就需要用到CHS寻址模式:CHS寻址模式将硬盘划分为磁头(Heads)、柱面(Cylinder)、扇区(Sector)。通过CHS我们就可以找到任意一个扇区,那么所有的扇区我们就都可以找到了。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
知道了磁盘的物理结构后,那么磁盘的逻辑结构是什么样的呢。
我们可以通过另一个永久性存储介质磁带来理解磁盘的逻辑结构。
磁带中也是圆形结构,但是当我们将磁带抽出来时看到磁带变为了线性结构,由此我们可以分析得出磁盘其实也是一个线性结构的数组。

在这里插入图片描述
我们看到磁盘的虚拟结构就变为了一个数组,操作系统从磁盘中找指定的扇区就变为了从数组中找扇区的下标。而操作系统管理磁盘就变为了管理数组。
当将数据存储到磁盘中时就是将数据存储到数组中,找到磁盘的特定扇区的位置就是找到数组特定的位置,对磁盘管理就变为了对数组的管理。
在这里插入图片描述

一个磁盘的内存是很大的,所以计算机会将磁盘中的内存进行分区,这就是分区的过程,我们的计算机中C盘、D盘就是磁盘分区而产生的。当磁盘进行分区后,在每个分区中都有一块Boot Block的启动块,该启动块中写死了磁盘的启动程序等。每一个分区内又会分为多个块组。这样对磁盘的管理就变为了对分区的管理,而对分区的管理就变为了对块组的管理。

在这里插入图片描述
而每个块组中的结构是下面这样的。
Linux在磁盘上存储文件的时候,是将文件的内容和属性分开存储和管理的。
Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成。
超级块 (Super Block):存放文件系统本身的结构信息。记录的信息主要有:block 和 inode的总量、未使用的block和inode的数量、一个block和inode的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了。
GDT (Group Descriptor Table):块组描述符,描述块组属性信息。例如这个块组多大,已经使用了多少,有多少个inode,已经占用了多少等。
块位图(Block Bitmap):Block Bitmap中记录着Data Blocks中哪个数据块已经被占用,哪个数据块没有被占用。假设有10000+个blocks,10000+比特位:比特位和特定的block是一一对应的,其中比特位为1表示该block被占用,否则表示可用。
inode位图(inode Bitmap):inode Bitmap中记录着哪个inode已经被占用,哪个inode没有被占用。假设有10000+个inode结点,就有10000+个比特位,比特位和特定的inode是一一对应的。其中bitmap中比特位为1,代表该inode已被占用,否则表示可用。
inode节点表(inode Table):inode是一个大小为128字节的空间,保存的是对应文件的属性,一个块组内,所有文件的inode空间的集合需要标识唯一性,所以每一个inode块都有一个inode编号,一般而言一个文件,一个inode,一个inode编号。inode Table存放文件属性 如 文件大小,所有者,最近修改时间等。
数据区(Data blocks):存放文件内容,多个4KB(扇区*8)大小的集合,存储特定文件的内容。

虽然磁盘的基本单位是扇区(512字节),但是操作系统(文件系统)和磁盘进行IO的基本单位是:4KB(8*512byte)。这是因为:
(1). 如果基本单位太小了,操作系统和磁盘就要进行多次IO,进而导致效率的降低。
(2). 而如果操作系统使用和磁盘一样的基本单位,那么当磁盘的基本单位变化了之后,操作系统的源码就需要更改了,所以这样规定可以使软件(OS)和硬件进行解耦。
通过上面的这些东西就能够让一个文件的信息可追溯,可管理。
我们将块组分割成为上面的内容,并且写入相关的管理数据,如果每一个块组都这样做,那么整个分区就被写入了文件系统信息,这就是格式化。
在这里插入图片描述
我们知道一个文件对应一个inode属性结点,也只对应一个inode编号,那么一个文件只能有一个block吗?
答案肯定是不一定,因为有一些大文件,一个block肯定存储不完,所以一个文件可能有很多个block,并且block中不只是存文件数据,也可以存其它块的块号。
在这里插入图片描述

9.2 inode和文件名

在Linux中,inode属性里面并没有文件名这样的说法,但是找到文件需要这个文件的inode编号,然后就知道了该文件的分区和块组等信息,然后就能找到文件的data block中的内容了。那么一个的inode中并没有文件名,我们该怎么找到文件的inode编号呢?
其实我们是通过该文件所在的目录的内容来找到该文件的,我们知道Linux下一切皆文件,那么目录也是一个文件,目录文件也有自己的inode,并且也有自己的data block,其实在目录文件的data block中就存储了该目录下的所有文件名和对应inode编号的映射关系,这样我们就能通过目录文件的data block里面的文件名和对应inode编号的映射关系来找到该目录下的文件了。所以当我们想要进入目录时,其实就是执行目录这个文件,所以我们需要该目录文件的执行权限x;而当我们想要在目录中创建文件时,需要将文件名和inode编号的映射关系写到目录文件的data block中,所以需要目录文件的写权限w;当我们想要查看目录下的文件信息时,需要读取目录文件的data block中的数据,所以需要目录文件的读权限r。

创建文件,系统做了什么:
根据文件要创建的目录,找到这个目录文件所在的分区,并且找到这个目录文件所在的块组,然后遍历这个块组的inode Bitmap,找到第一个为0的比特位,将这个比特位置为1,在遍历时进行累加,那么在找到第一个为0的比特位的同时也找到了一个inode编号,然后在inode Table里面将这个文件的inode的属性都写进去,然后将这个inode的block块都先清零,因为该文件为新创建,还没有数据。此时就创建好了inode的编号,然后通过用户的输入得到了文件名,在目录文件的data block中将这一组文件名和inode编号的映射存入。

删除文件,系统做了什么:
删除文件时,一定已经确定了要删除哪个目录下的文件,然后找到这个目录文件的data block,通过文件名在目录文件的data block中找到这个要删除文件的inode编号,然后根据inode编号就可以找到这个文件所在的分区和块组,然后将这个inode在块组内的inode Bitmap置为0,并且将这个inode的block Bitmap占用的block块置为0,然后将目录文件中的data block中的文件名和inode的映射关系删除,这个文件就算删除了。
通过上面的过程我们发现删除文件其实并没有将该文件存储数据的data block清空,而只是将该文件的inode和block置为无效,所以删除的文件其实是能恢复的,前提是这个inode编号没有被使用,并且inode和data block没有被重复占用。
inode是固定的,data block也是固定的,所以有时候可能还有内存,但是创建文件不成功,这是因为inode用完了。

10、软硬链接

我们可以通过下面的命令来为文件建立一个软链接。-s 为 soft选项。

ln -s testLink.txt soft.link

在这里插入图片描述
下面的命令为建立一个硬链接。

ln testLink1.txt hard.link

在这里插入图片描述

从上面的结果中我们可以看到软硬链接的本质区别是有没有独立的inode,我们看到软链接有独立的inode,所以软链接是一个独立的文件;而硬链接没有独立的inode,所以硬链接不是一个独立的文件。

10.1 软链接

当在当前目录下执行另一个目录下的可执行程序时,每次执行都要打出完整的路径,所以可以建一个软链接。这就相当于windows下的快捷方式。
我们在test25目录下执行test24目录下的可执行文件时需要加上完整目录。
在这里插入图片描述
在这里插入图片描述
此时我们可以在test25目录中建立一个test可执行程序的软链接。使用这个软链接就可以执行test程序。即这个软链接就相当于windows下的快捷方式。可以理解成为软链接的文件内容是指向文件对应的路径。
在这里插入图片描述

10.2 硬链接

我们在创建硬链接时看到有一个数组从1变为了2,这个属性就是文件的硬链接数。我们创建硬链接不是真正的创建新文件,而是在指定的目录下,建立了文件名 和 指定inode的映射关系。所以硬链接数会加1。当我们删除这个硬链接时,这个文件的硬链接数就会减1。
在这里插入图片描述
在这里插入图片描述
那么硬链接数是什么呢?
在这里插入图片描述

所以在Linux中还有一个删除文件的命令unlink,即将文件的硬链接数减1。

unlink

在这里插入图片描述
下面我们新建一个文件和新建一个目录。我们看到新建立的文件的硬链接数为1,因为这个文件的文件名和inode就是一组映射。而新建立的目录的硬链接数为2,因为在当前目录下该目录的目录名和inode是一组映射,而当进入该newdir目录里面时,有一个. 文件,这个文件名和inode也是一组映射,即一个硬链接。
在这里插入图片描述
在这里插入图片描述
下面我们在newdir目录下再新建一个目录d1。发现newdir目录的硬链接数变为3,因为在d1目录下有一个. . 目录,为d1的上级目录newdir,. . 和 inode又是一组映射。
在这里插入图片描述
在这里插入图片描述
当我们在newdir目录里面建一个目录,newdir的硬链接数就加1,因为每个目录里面都有一个 . . 。所以可以通过newdir的硬链接数-2得到该目录下有多少个目录。
在这里插入图片描述
在Linux下有很多的可执行程序都以符号为名字,例如下面我们也可以使用符号来作为可执行程序的名字。
在这里插入图片描述

11、动静态库

静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库。
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码
在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

11.1 制作静态库

我们在lib目录下的mklib目录下创建四个文件。

在这里插入图片描述
然后在.h文件中写方法的声明,在.c文件中实现方法。
在这里插入图片描述
在这里插入图片描述

然后我们再创建一个main.c文件,在这个文件中写main函数,并且调用上面实现的两个函数。我们将这三个.c文件共同编译为可执行文件my.exe。此时程序可以正常执行。
在这里插入图片描述
在这里插入图片描述
然后我们将mymath.c文件和myprint.c文件编译为.o文件。
在这里插入图片描述
此时我们再创建一个uselib目录,并且将mklib目录下生成的.o文件和.h文件拷贝过来。然后我们将main.c编译为main.o文件,然后将三个.o文件编译为可执行程序my.exe,该程序中可以使用mymath.o和myprint.o中的方法。
在这里插入图片描述
在这里插入图片描述
可以看到当uselib想使用mklib里面的方法时,需要将方法的.o文件和.h文件都拷贝到uselib目录下。这样当方法多了时,就需要拷贝很多文件,所以就有了打包。下面为将两个方法的.o文件打包为libhello静态库,注意静态库名称以lib开头,以.a为后缀。此时就形成了静态库。
在这里插入图片描述
下面我们来编写makefile文件自动生成库。我们看到makefile文件成功生成了静态库。
在这里插入图片描述
在这里插入图片描述
但是库中只有.o文件也是不行的,还需要.h头文件。我们创建一个hello目录,将所有.h头文件都放到include目录下,将所有.a文件都放在lib目录下。然后我们再进行生成库,这样hello目录下include目录中是库的所有头文件,lib目录中是对应的库文件。下面我们在makefile中编写上面生成库的过程。
在这里插入图片描述

当我们指令make和make hello命令后可以看到生成了一个hello库,该库的include目录下为库的所有头文件,lib目录下为对应的库文件。
在这里插入图片描述
在这里插入图片描述
然后我我们将hello这个静态库拷贝到uselib目录下,那么在uselib目录中该如何使用hello这个静态库呢?直接使用会报出找不到头文件的错误。
在这里插入图片描述
在这里插入图片描述
如果想要使用一个库,需要将这个库的头文件和库文件添加到系统库的头文件和库文件所在的目录中,这样才可以搜索到我们的库的头文件和库文件。将库拷贝到系统的默认路径下的过程就叫做库的安装
头文件gcc的默认搜索路径是:/usr/include。
库文件的默认搜索路径是:/lib64 或 /usr/lib64。
在这里插入图片描述
然后我们编译main.c时发现还会报错,这是因为c语言自带的库不需要手动链接,gcc会自动链接到c语言的静态库,而我们自己写的库为第三方库,所以需要手动链接。-l为链接库,后面为库的名字,库的名字要去掉前面的lib和后缀.a。然后我们发现main.c就成功编译生成了a.out可执行程序。
在这里插入图片描述
在这里插入图片描述
需要注意的是,我们刚刚自己写的库没有经过测试,也没有发布,所以如果我们的库和系统中的库重名,就会污染系统中的库,所以我们测试完后就需要将自己的库删除掉。把头文件和库删除的过程就叫做卸载。
在这里插入图片描述

所以我们一般都是直接使用静态库。当想要直接使用库时,需要 -I 然后后面跟头文件的路径,即告诉c语言如果在系统库和当前目录下没有找到头文件,就去这个路径下找。

-I

在这里插入图片描述
然后我们发现报出了没有定义Print和addToTarget的错误,这是因为我们没有指定库文件所在路径,所以我们还需要使用 -L 指定库文件所在路径。

-L

在这里插入图片描述
然后还是报错,这是因为我们没有指定库文件的名称,因为lib路径下可能有很多库文件,我们需要写出具体要引用的库文件的名称。使用 -l 选项后面跟上库名,并且库名称要去掉前面的lib和.a后缀。然后我们就在编译中使用了指定的库。

-l

在这里插入图片描述
在这里插入图片描述

11.2 制作动态库

静态库在程序编译时会被连接到目标代码中,程序运行时将不再需要该静态库。
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行时才被载入,因此在程序运行时还需要动态库存在。
使用下面的-fPIC选项可以形成一个与位置无关的二进制文件。

gcc -fPIC -c mymath.c -o mymath.o

与位置无关的二进制文件的意思就是这个二进制文件内部从0x000000开始,当这个动态库加载到内存中,映射到进程的地址空间,映射的的位置可能是不一样的。但是因为动态库里面形成的是与位置无关的二进制文件,即库里面都是相对地址,每一个函数定位采用的是偏移量的方式找的,所以只要知道了这个库的相对地址,库的起始地址+函数偏移量就可以在自己的地址空间中访问库中的所有函数了。
可以使用下面的命令来读取一个二进制文件的内容

readelf -S mymath.o

在这里插入图片描述
-shared选项就是生成动态库,即将mymath.o和myprint.o两个文件生成一个动态库libhello.so。注意动态库是以.so结尾。

gcc -shared myprint.o mymath.o -o libhello.so

在这里插入图片描述
下面编写makefile文件来生成动态库和静态库。
在这里插入图片描述
当我们执行make命令时,可以看到就生成了以.a为后缀的静态库和以.so为后缀的动态库。
在这里插入图片描述
然后我们执行make output命令,将动态库和静态库打包到一个目录下面。此时output目录下的include目录下都是.h的头文件,output目录下的lib目录下都是库文件。
在这里插入图片描述
在这里插入图片描述
我们还可以将自己写的这个库进行压缩,这样当其他人下载时就会更快了。

tar czf mylib.tgz output

在这里插入图片描述
下面我们将这个压缩文件拷贝到uselib目录下,并且使用解压命令将这个压缩文件进行解压,可以看到mylib.tgz中的内容被成功解压。

tar -xzf mylib.tgz

在这里插入图片描述
在这里插入图片描述
然后我们像使用静态库一样,在编译main.c文件时告诉gcc头文件在哪个目录下,动态库在哪个目录下。并且指定要使用的库文件。但是我们发现虽然成功编译并生成了可执行程序a.out,但是当执行时发现并没有执行成功,并且我们使用ldd命令查看a.out可执行程序依赖的动态库时,发现libhello.so动态库找不到,所以才会出现错误。

gcc main.c -I output/include -L output/lib -lhello
//查看可执行程序依赖的动态库
ldd a.out

在这里插入图片描述
我们知道output/lib目录下有静态库hello和动态库hello,当我们执行-lhello命令时默认是连接到动态库的。只有当output/lib目录下没有hello动态库,只有hello静态库时,才会默认链接静态库。
在这里插入图片描述
当output/lib目录下有静态库和动态库hello时,如果还想要链接到静态库,那么可以在库名的后面加上 -static,表示链接到静态库。如果报出下面的/usr/bin/ld:cannot find **,说明当前机器没有配置相关的编译环境,所以需要执行下面的命令来安装相应的环境。然后我们编译main.c并链接到静态库,可以看到生成的a.out可执行程序可以正常运行。

sudo yum install glibc-static

在这里插入图片描述

gcc main.c -I output/include -L output/lib -lhello -static

在这里插入图片描述
所以使用gcc编译时动静态库的链接规则为:
如果只有静态库,那么gcc只能链接静态库。
如果动静态库都存在时,gcc默认链接动态库。
如果动静态库都存在时,想要链接静态库,可以使用-static选项。

下面我们接着分析动态库链接,我们看到当我们链接动态库进行编译生成的a.out可执行程序,当运行时发现出现了错误,并且使用ldd a.out命令查看该程序链接的动态库时,发现libhello.so => not found,
在这里插入图片描述
我们知道动态库在程序编译时并不会被连接到目标代码中,而是在程序运行时才被载入,因此在程序运行时还需要动态库存在。并且动态库和可执行程序可以分批加载到内存中,动态库在栈区和堆区之间的共享区中存在,当a.out可执行程序中遇到动态库的内容时,就会去共享区中找到动态库的地址,然后通过这个地址在页表中的映射找到内存中动态库的地址,然后得到动态库中函数的代码。我们在编译时虽然已经说明了动态库的路径,但是那时对gcc编译器说的,使gcc编译时通过这个路径找到动态库。但是当我们执行a.out可执行程序时,并不知道动态库所在的位置,所以才会报错。
在这里插入图片描述
我们有多种方法来解决上面可执行程序a.out找不到动态库的问题。
第一种方法:将动态库拷贝到lib64目录下
可以看到a.out可以正常运行了。但是这样的方法因为直接将库添加到了系统的库所在的路径中,所以会污染系统的库,所以不建议使用。
在这里插入图片描述

第二种方法:将自己的库所在的路径添加到环境变量中,这样搜索库时也会去这个路径下搜索。
在linux中有一个LD_LIBRARY_PATH环境变量,LD_LIBRARY_PATH环境变量用于在程序加载运行期间查找动态链接库时指定除了系统默认路径之外的其他路径。所以我们将自己的库所在的路径添加到这个环境变量中,这样程序运行时也会去这个路径下查找动态链接库。但是这种方法当退出这次登录时,环境变量的改变就没了,此时还需要重新改变环境变量。

echo $LD_LIBRARY_PATH

在这里插入图片描述
下面我们将自己写的动态库的路径添加到LD_LIBRARY_PATH环境变量中。

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/drh/linux-learning/test26/lib/uselib/output/lib

在这里插入图片描述
然后我们将/lib64目录下的libhello.so动态库删除,此时a.out程序还可以正常运行,因为我们将libhello.so动态库所在的路径已经添加到LD_LIBRARY_PATH环境变量中了。
在这里插入图片描述

第三种方法:在配置文件中添加路径。

在/etc/ld.so.conf.d目录下创建一个以.conf为后缀的配置文件,然后在该文件中将动态库所在的路径添加进去。

ls /etc/ld.so.conf.d/

在这里插入图片描述
我们看到当在test.conf文件中添加完路径后执行a.out时还是报错了,这是因为我们还没有更新配置文件,我们执行sudo ldconfig命令更新配置文件后,此时可以看到a.out程序成功执行了。并且我们退出登录后再登录时也可以正常执行a.out程序。

sudo ldconfig

在这里插入图片描述
在这里插入图片描述
当我们将test.conf配置文件删除时,我们发现此时a.out程序还可以正常运行,这是因为我们没有更新配置文件,此时缓存中还有test.conf这个配置文件,当我们再次执行sudo ldconfig命令更新配置文件后,a.out就不可以正常运行了。
在这里插入图片描述

第四种方法:在lib64目录下建立软链接
这种方法在/lib64目录下建立libhello.so动态库的软链接。这样当去/lib64目录下查找libhello.so动态库时就会根据软链接去libhello.so动态库所在的路径下查找了。

sudo ln -s /home/drh/linux-learning/test26/lib/uselib/output/lib/libhello.so /lib64/libhello.so

在这里插入图片描述

  • 5
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值