当前位置:   article > 正文

Linux文件

Linux文件

系统接口

文件可以分为内存文件(已经打开的文件)和磁盘文件(还未打开的文件),我们对一切文件的修改都是通过执行代码完成的,由操作系统将文件加载到内存,接着由进程打开文件(一个进程可以同时打开多个文件),因此在了解文件之前有必要了解一些系统接口,这些系统接口都可以使用man的第2本手册查到。

1.open
用于打开或者创建一个文件
在这里插入图片描述
.参数:
pathname:需要打开或创建的目标文件

flags:参数选项,可以传入多个,常用的有一下几个:
① O_RDONLY:只读打开
② O_WRONLY:只写打开
③ O_RDWR:读写打开
④ O_CREAT:如果文件不存在就创建文件,使用这个选项需要用mode参数指明要创建的新文件的访问权限
⑤ O_APPEND:以追加方式打开文件
需要注意的是选项①②③必须指定且只能指定一个。

mode:指明文件权限,设置权限时也会受到权限掩码影响

返回值:
成功返回文件的描述符,失败则返回-1.

2.read
用于从文件描述符fd指向的文件中读取count个字节的数据到buf中。
在这里插入图片描述

其他类似的还有read、close、lseek接口:
在这里插入图片描述
如果是向文件写入字符串,不要将’\0’写入字符串中,否则会产生乱码。如果打开文件时没有使用追加选项,在向文件写入时不会直接清空文件内容,而是写入多少就覆盖多少数据,若希望写入前清空文件内容,可以在打开文件时增加O_TRUNC选项。

在这里插入图片描述

在这里插入图片描述

这些系统接口与C语言提供的文件访问接口极其类似,是因为C接口底层就是调用了这些系统接口,对其进行了封装以供二次开发。

文件描述符

文件描述符

当我们(进程)打开一个文件时,其实是由进程执行open系统调用并返回一个文件描述符,同时操作系统会创建一个file结构体来记录打开文件的相关inode元信息。在进程的pcb中有一个files*指针,指向一个files_struck结构体,files_struck中又有一个file*指针指向一个file*类型指针数组fd_array[],这些指针指向一个个的file结构体,而文件描述符就是数组fd_array的下标(即大于等于0的小整数),因此只要拿到文件的描述符就可以找到对应文件进行访问。
在这里插入图片描述

Lnix默认情况下会打开3个文件描述符0:标准输入、1:标准输出、2:标准错误,对应的设备分别是键盘、显示器、显示器,当创建新文件时fd的分配规则是:在fd_array[]中找到最小那个没有被使用的数组下标作为作为该文件的描述符fd返回。

由上我们也可以更加深入理解为什么Linux下一切皆文件:
在这里插入图片描述

当某个进程发出读写请求后,在底层会调用操作系统提供的读写系统接口,接着操作系统找到这个进程的pcb,再根据文件描述符找到对应file结构体,最后操作系统只要执行里面的读写方法就行了,这些读写方法会指向特定的驱动程序里面的读写方法,从而使操作系统可以以统一的方式对文件读写。
在这里插入图片描述

我们已经知道C语言操作系统提供的读写系统接口进行了封装,其实其同时也对数据类型进行了封装,其将文件描述符fd封装到了FILE结构体中,然后默认创建3个FILE*指针stdin、stdout、stdero,即C语言默认的3个标准输入输出流,分别指向3个FILE结构体,其结构体里面的fd分别设为0、1、2,即标准输入、标准输出、标准错误。

重定向

1.通过修改文件描述符实现重定向

close(1);
int fd=open("testFile.txt",O_WRONLY|O_CREAT,00644)
if(fd < 0)
{
	perror("can not open testFile.txt");
	exit(-1);
}

/*
将文件描述符1的文件流关闭后再打开文件testFile.txt,
根据文件描述符分配规则,新打开的文件分配到的文件描述
符是1,而键盘等文件的默认输出是文件描述符1指向的文件,
这样就会将从键盘等文件输出的数据输出到文件testFile.txt
中,而不再是显示屏,这样就实现了输出重定向功能。
*/
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

以上代码就实现了简单的输出重定向,即将本来是输出到显示屏的数据变为输出到文件testFile.txt中。

在这里插入图片描述

2.使用dup2系统调用
在这里插入图片描述
使用dup2系统调用后,会在fd_array[]数组中用下标为oldfd的内容覆盖下标为newfd的内容,这样也可以实现重定向功能。

文件缓冲区

缓冲区有用户层缓冲区和内核级缓冲区之分,用户层缓冲区一般由各种语言的标准库提供,不同语言会略有不同,内核级缓冲区由操作系统提供,归操作系统管理,我们这里只讨论用户层缓冲区。
在这里插入图片描述

用户层缓冲区的刷新策略有3种:
1.无缓冲,直接进行系统调用
2.行刷新,即碰到‘\n’就刷新,这种策略一般用于显示器
3.全缓冲,即等到缓冲区满了再进行刷新,一般用于向普通文件写入数据
除此之外,还存在2种特殊情况也会进行刷新
1.用户进行了强制刷新
2.进程退出

所以write系统调用并没有将数据直接写入文件,而是将数据从用户缓冲区写入到内核级缓冲区,这样做可以减少系统调用的次数,用户直接将数据放到缓冲区就可以了,从而提高用户效率,同时可以聚集数据,一次拷贝,从而提高整体效率

需要注意的是,当我们使用fork创建子进程时,父子进程数据会发生写时拷贝,意味着子进程对父进程缓冲区的数据也进行了拷贝。

文件系统

文件系统

我们已经知道OS是如何管理和访问加载到内存中的文件的了,那OS是怎么管理磁盘中的文件的呢,又是如何将磁盘文件加载到内存的呢,这就需要我们对文件系统进行学习。

在了解文件系统之前,我们需要知道文件在磁盘中的存储,这就意味着我们需要先简单了解一下磁盘的物理结构,需要注意这里所说的硬盘是指机械硬盘,不要与固态硬盘(SSD)混淆。

在这里插入图片描述
机械磁盘的存储介质是盘片,两面都可以进行存储,其被划分成一片一片的扇区,一块扇区的存储空间大小一般为512字节,这是磁盘进行读写的基本单位(不一定是系统读写的基本单位),而磁盘可以有多个盘片。
在这里插入图片描述
每个磁头(或者盘面)都有唯一编号,在盘面上每个磁道和扇区也有唯一的编号,因此只要我们知道了磁道、磁头和扇区的编号就可以精确的定位到某个扇区,即:磁道(cylinder)–>磁头(head)–>扇区(sector),这种定位方法称为CHS定位法。
由于对底层硬件的操作必定是通过操作系统来完成的,因此用户对磁盘文件的访问必然需要操作系统从磁盘中读取数据,OS为了提高对磁盘数据的存取效率,选择了以8个扇区(4KB)大小的数据块(Block)为存取单位,这就意味着OS不采用磁盘的CHS地址,而是用一种被称为LBA的地址,即操作系统对磁盘进行管理时,将其抽象成一个大数组,一个元素就是一个数据块,OS只要获得了这个数组某个下标N,就能将其转换成CHS地址(只需要简单的取模运算),然后在将CHS地址映射到磁盘对应位置。
在这里插入图片描述
虽然现在OS已经可以对磁盘寻址了,但让OS直接管理(可以理解为对这个大数组进行增删查改)这么大一个数组(或者是磁盘)肯定是十分不方便的,所以其采用分治的思想,将磁盘的往下进行分区,再将分区往下进行分组(Block group),而一个分组里面包含许多的数据块。在这里插入图片描述
OS只要将一个分组管理好了,就可以以同样的方式对所有分组进行管理,因而一个分区只需对其分组进行管理就可以了,不必理会一个分组下面是怎么管理的,进而一个分区就可以进行管理了,依此进行,就可以对所有分区直至整个磁盘进行管理了。

在了解OS是如何对一个分组进行管理(即OS是怎么管理分组里的文件的)前,我们需要了解文件是如何存储在磁盘中的。

我们已经知道一个文件包括内容和属性两个部分,在磁盘里面,文件的内容和属性是分开存储的(物理地址相差很远),由于文件的属性的大小是可以确定的,因此可以用一个大小为128个字节的结构体inode来描述它,里面包括文件的大小、创建时间等等属性,最重要的是还包含一个整型数组block[15],里面记录的是存放文件内容的数据块编号。而OS会在创建文件时给每一个文件分配一个inode编号,inode编号在分区里是唯一的,可以唯一标识一个文件,OS可以通过inode编号映射到对应的LBA地址。从而找到该文件的属性部分,进而找到文件的内容。

由于所有的组的空间可以看成是连续的,我们可以让每一个组都有一个inode起始编号,类似于下面这样:
在这里插入图片描述
那么我们只需要将偏移量inode编号加上起始的inode编号就可以获得真实的inode编号,这样就可以保证真实的inode编号在一个分区里是唯一的了。

文件是在一个分组里面存储着的,一个分组包含了大量的数据块,而OS将一个分组进行了划分,用来存储文件的不同部分。
在这里插入图片描述

各个划分的作用如下:
超级块(Super Block):记录了该组所在的整个分区的情况,如数据块的块数、inode的数量、没有使用的inode和数据块的个数等等,由于其一旦被破坏,那么整个分区都无法使用,同时为了减少空间浪费,OS只会在某几个分组里存放超级块进行备份。

块组描述符(GDT,Group Descriptor Table):记录当前的组的情况

块位图(Block Bitmap):用于记录数据区中哪些数据块已经被使用,那些还未被使用。

inode位图(inode Bitmap):用一个比特位标识一个inode值是否闲置,即比特位的位置表示inode值的大小,0和1表示该inode是否闲置。

i节点表(inode Table):存放文件的属性,在文件系统格式化时就已经确定了这个表的大小,意味其只能存储有限个文件属性,表中的每一个数据元都对应一个inode编号。

数据区(Data Blocks):存放文件的内容

现在我们已经初步了解文件是如何存储在磁盘中的了,但还有一个问题:操作系统是怎么知道用户想打开的文件的inode编号的?

我们可以确定的是一个目录一定也是一个文件,而文件就在目录中。既然目录是文件,就一定也有内容和属性两个部分,其属性部分是确定的,而其内容部分存放的是该目录下的文件名和其inode编号的映射关系(意味着文件名不属于文件本身的属性,而是属于目录的内容),因此只要知道了目录的inode编号就可以找到目录的内容部分,进而通过里面建立的映射关系用文件名就找到其对应的inode编号,就可以对文件进行访问了。而当前目录也是上一层目录的子目录,因此只要知道了上一层目录的inode编号,就可以找到当前目录的inode编号,如此往上进行,直至根目录为止,而OS一定知道根目录的inode编号。

这样我们就可以大概知道一个文件写入到磁盘和从磁盘中读取的过程:从根目录开始,我们每次进入一个目录,OS就利用获取到的inode编号找文件的属性部分,再从里面的block[15]数组获取到存放文件内容的数据块编号进而访问文件内容,如果想进入下一层目录,只需要用户提供文件名就可以在文件内容中找到该文件名对应的inode编号。当用户想在其中一个目录下创建新文件时,OS从inode位图中申请一个没有使用过的inode编号,并将其中对应的比特位置为1,表明该inode编号已经被使用,然后在i节点表找到对应的位置填充文件的各个属性,最后将文件名和inode编号保存在当前目录的内容部分并建立映射关系。如果用户对创建的文件进行了写入,OS就为该文件分配适合大小的数据块个数,接着将块位图对应的比特位置为1表示这些数据块已经被使用,再将用户写入的内容存储到分配到的数据块中,最后在属性部分的block[15]保存分配的数据块编号。
倘若用户想要删除某个目录下的文件,只需要在块位图中将分配给该文件的数据块编号对应的比特位置为0,表示这些数据块是闲置的,就等同于将文件的内容部分删除掉了,在将inode编号在inode位图中对应的比特位置为0,表示该inode编号是闲置的,就等同于将文件的属性给删除了,最后将该目录下文件名和inode的映射关系删除,至此整个文件的删除就完成了。

通过上面我们也就可以解释一个目录的r、w权限为什么会影响到该目录下文件是增删查改:没有r权限,用户自然就读取不了目录的内容,也就不能查看目录下的文件了和修改文件了,没有w权限,用户不能在目录的内容部分建立和删除文件名与inode编号的映射关系,也就创建不了文件了。
我们也可以发现由于一个inode分区的inode编号是有限的,倘若我们建立的大量的小文件,就会导致inode编号被用完了,但数据块还没有使用完的情况,此时虽然还有闲置的数据块,但也无法创建新文件了。

现在还有一个问题,文件的属性部分的block[15]只有15个元素,那它是怎么存储大文件的呢?实际上,block数组只有0-11号元素是直接存储数据块的编号,12-13号元素也是指向对应的数据块,但这些数据块不存放文件内容,而是存放其他数据块的编号,这些编号存放的才是文件的内容,14号元素也类似,指向的数据块存放的是其他数据块的编号,这些编号对应的数据块存放的还是其他数据块的编号,这时的数据块存的才是文件的内容,这有点类似于多级指针,这样一个文件就可以拥有十分巨大的体积了(可能导致文件跨越多个分组)。

由上我们就可以知道OS如何管理一个分组的了:通过将一个分组划分为几个不同的区域,以记录里面文件的内容属性和未使用的空间,OS又知道当前目录的inode编号,就可以访问文件了。而Super Block管理记录着所有分组(或者整个分区)的信息,只需要将Super Block加载到内存就可以对整个分区进行管理了。由于一个分区对应一个文件系统,而每个分区都有一个Super Block,只要将各个分区的Super Block加载到内存,利用某种数据结构将其连接起来,就可以对整个磁盘进行管理了。
在这里插入图片描述
图中Boot Block是在开机时加载操作系统数据的存储块。
我们可以使用ls -i xxx指令查看文件的inode编号。

文件的软硬链接

通过前面的学习我们也知道目录和普通文件在磁盘中并没有任何区,只有将文件从磁盘加载到内存中才进行文件类型的区分,而在Linux是通过inode找到磁盘中的文件的,而不是文件名,因此Linux允许多个文件名指向同一个inode。
利用指令ln可以让一个文件和指定文件拥有相同的inode编号
3bc035679.png)
在这里插入图片描述
以上指令建立了一个mylink文件且inode与Bar.c的inode相同,这种链接方式被称为硬链接。内核会记录文件的硬链接数,普通文件创建时其硬链接数为1,添加一个硬链接其硬链接数就自加1。
在这里插入图片描述
硬链接的本质是在当前目录下建立了一个文件名和目标文件的inode的映射关系。当我们删除文件时,OS只是将目录下文件名个和inode映射删除,然后将硬链接数减1,只有当硬链接数为0时才删除文件。
创建目录文件时其硬链接数为2,是因为目录创建时默认创建了两个硬链接文件:’ . ’ 和 ’ .. ‘,其中 ’ . ’ 指向新目录自身,这使得目录的硬链接数加1变成2,’ .. '指向上级目录,使得上级目录的硬链接数加1。
Linux是不允许用户自己对目录建立硬链接的,这是因为倘若用户对目录本身或者根目录建立了硬链接,将会导致OS在查找文件时产生无穷递归的问题。

除了硬链接,我们也可以使用ln -s指令建立软连接
在这里插入图片描述
以上指令建立了一个软连接mysolftlink指向Bar.c文件,软连接的本质是创建了一个新文件,它有自己的inode编号,文件的内容部分存放的是目标文件的路径,这样我们就可以通过软连接文件直接访问目标文件,这类似于windows系统下的快捷方式。

动态库和静态库

在了解动静态库之前我们首先需要了解什么是库,假设A写了一个程序,里面包含2个.h头文件和2个.c文件,A将其编译成了2个.o文件:
在这里插入图片描述
现在B想直接用A写好程序,但A不想让B知道程序的源代码,所以A就只将头文件a.h、b.h和目标文件a.o、b.o发给了B,此时B只需要将这些文件放到自己程序的对应路径下,并在自己的程序中包含这两个头文件,B就可以查看头文件调用相应的函数接口了。当.o文件很多时,这样一个一个的将.o发给对方,不经麻烦,还很容易遗漏文件导致出错,因此人们就想将所有的.o文件打包到一个文件中连同头文件发给对方就可以了,这个打包了.o的文件就形成了库。由上我们可以知道库有2个明显的优点:一是可以提高开发效率,大家都可以直接用别人写好的现成代码,不需要重复造轮子了;二是可以隐藏源代码,因为我们不需要将源文件发给对方对方就可以使用我们写好的程序。
在Linux中,我们可以使用ldd xxx指令查看文件xxx所依赖的库,执行指令的输出如下:
在这里插入图片描述
其中.so表示动态库,.a表示静态库
我们也可以使用ar指令建立一个自己的静态库:
在这里插入图片描述
执行该指令则将a.o和b.o目标文件封成一个名为myc的静态库,其中指令的选项r(replace)如果库已经存在就进行替换,c(create)表示如果库不存在就创建库。
在这里插入图片描述

该指令表示在对mycode文件编译链接时,除了链接指定的库,还需要链接xxx路径下名为myc的第三方库。其中选项l(L的小写)用于指定库名,L 用于指定库路径。

我们也可以利用Linux相关指令生成动态库:
在这里插入图片描述
其中fPIC表示产生位置无关码,shared选项表示生成共享库格式,执行以上指令就可以生成名为myc的动态库。
在这里插入图片描述
同静态库一样,执行该指令就可以链接xxx路径下的myc动态库。

库所需要的头文件一般放在路径/usr/include路径下,如果头文件不放在该头文件下,则需要用户在编译时使用-I(i的大写)选项指定路径:
在这里插入图片描述
该指令表示编译器除了在默认路径下查找头文件外,还要到xxx路径下查找头文件。

由于动态库是在程序由操作系统加载到内存的,所以动态库应该包含2套路径,一套用于编译时的路径搜索(编译器用,静态库是直接加载到程序中去的,故只需要提供编译时的路径即可),一套用于运行时进行库搜索(OS用),否则就算我们在指令中指明了库路径,运行依旧出错,因为这样做只是让编译知道了库的路径,但操作系统不知道,自然就无法将库加载到内存中进行动态链接。我们一共有4中方法可以为编译器和OS提供库的搜索路径:

1.将库安装到系统中,即路径/lib64下,该路径下的库编译器和OS都可以找到

2.将库的路径加到环境变量LD_LIBRARY_PATH中,由于环境变量只是内存级别的(即环境变量的内容是机器开机后从磁盘的bash.-profile文件或.bashbrc文件加载的),所以一旦机器退出,这个新添加的路径就失效了,如果我们将路径加到bash.-profile文件或.bashbrc文件中,那么添加的路径就是永久有效的。

3.在/lib64路径下建立软连接,即:
ln -s xxx /lib64/libxxx.so
xxx表示我们建立的动态库名

4.在目录/etc/ld.so.conf.d下建立一个以.conf为后缀的配置文件,然后将库的路径放到该配置文件中,最后使用指令sudo ldconfig使该配置文件生效。

如果是官方提供的动态库建议使用方法1,如果是其他用户或自己写的动态库建议使用方法3。

这里补充一下库的默认搜索路径:
静态库搜索路径(编译时):
1.当前目录:编译命令执行时的当前目录。
2.编译器默认路径:通常包括/usr/lib64和/usr/local/lib64,对于32位系统,则是/usr/lib和/usr/local/lib。
3.LIBRARY_PATH环境变量:如果设置了此环境变量,编译器会在这些路径中查找静态库。
4.-L编译器选项:使用-L选项可以指定编译器在链接时搜索的额外路径。

动态库搜索路径(编译时):
1.当前目录:编译命令执行时的当前目录。
2.编译器默认路径:通常包括/usr/lib64和/usr/local/lib64,对于32位系统,则是/usr/lib和/usr/local/lib。
3.LD_LIBRARY_PATH环境变量:虽然这个环境变量主要用于运行时动态链接器搜索动态库,但在某些情况下,编译器可能会使用它来查找库。
4.-L编译器选项:使用-L选项可以指定编译器在链接时搜索的额外路径。
-Wl,-rpath编译器选项:这个选项用于指定在生成的可执行文件中嵌入的运行时搜索路径(DT_RPATH或DT_RUNPATH段)。

动态库搜索路径(运行时):
1.可执行文件的DT_RPATH或DT_RUNPATH段:这些段可以指定搜索动态库的路径列表。
2.环境变量LD_LIBRARY_PATH:这是用户可以设置的环境变量,用于指定动态链接器搜索动态库的额外路径。
3./etc/ld.so.conf.d/目录:包含多个配置文件,每个文件都列出了一个或多个目录路径,动态链接器会按照这些路径搜索动态库。
4./etc/ld.so.conf:这是一个配置文件,动态链接器会按照这个文件中列出的路径搜索动态库。
5.默认路径:通常包括/lib64、/usr/lib64和/usr/local/lib64,对于32位系统,则是/lib、/usr/lib和/usr/local/lib。

如果同时提供了动态库和静态库(动态库和静态库的库名相同),编译器优先使用这个2个库中的动态库,倘若用户想要使用这个2个库中的静态库,则需要在编译时添加 -static 选项:gcc mycode.c -static。

那一个可执行程序是怎么使用动静态库的呢?
如果一个程序使用了静态库,静态库是在编译期间从磁盘加载到内存,然后直接拷贝到可执行程序中的,即每一个使用了静态库的可执行程序都需要将静态库从磁盘将库加载到自己的代码中,使其成为可执行程序的一部分,这样下次再运行这个可执行程序时就不在依赖这个静态库了。而当一个可执行程序(进程)使用了动态库时,动态库被OS从磁盘加载到内存中,进程的地址空间的共享区部分利用页表映射到动态库的物理地址,当进程在执行过程中需要使用动态库时,就会从代码段跳到共享区,通过页表在内存中找到对应的共享库数据。如果有多个进程使用了同一个动态库,动态库也只加载一份到内存中,不会每个进程都加载一份动态库数据到内存中,OS自己会管理那些被加载到内存中的动态库(先描述,再组织),如果这个动态库已经加载到内存中了,使用了该动态库的每个进程只需要利用自己进程的页表将共享区和动态库的物理地址建立映射关系即可。

以上只是一个简略的动静态库的使用过程,需要再往下深挖一层。
其实在可执行程序(本质是文件,Linux下为ELF格式)还没有加载到内存之前,其里面的每一条指令和数据就已经拥有自己的地址了(这些地址采用绝对编址,其实就是进程地址空间的虚拟地址),且里面已经被划分成了不同的区域,先简单理解成代码区和数据区,在ELF文件的头部记录了这些划分的起始和结束地址等信息。而进程的地址空间是由pcb中的mm_struct结构体描述的(里面类似于用start1,end1,start2,end2…这样描述各个划分的起始结束地址),当可执行程序被加载到内存时,直接用ELF文件里数据段代码段的起始结束地址填充mm_struct就可以了,而mm_struct中堆栈等区的填充由操作系统决定,大小可以变化,接着用ELF文件里指令对应的地址和指令在内存中的物理地址填充页表即可。

对于静态库来说,其直接就按绝对编址方式被拷贝到可执行程序中成为可执行程序的一部分了,之后就与可执行程序中原来的指令和数据没有什么区别,也有自己的地址。
对于动态库来说,其内部的指令和数据也有自己的地址,但这些地址都是相对编址,这些相对编址的起始地址在没有建立页表映射关系前是不能确定的(相当于只有一个偏移量,基地址不能确定),一旦某个进程使用了这个动态库,OS就会在该进程地址空间的共享区找一个起始地址作为该动态库虚拟地址的起始位置,OS只需要将这个起始地址作为基地址,将基地址加上动态库中对应指令和数据的相对地址就获得了对应指令和数据的绝对地址,接着就可以用这个绝对地址和对应指令和数据的物理地址建立映射关系填充页表了。尽管各个进程在共享区为动态库提供的虚拟地址的起始地址不一样,但都可以采用上面的方式填充页表建立映射关系。

现在库已经加载到内存中了,页表映射关系也建立了,那么程序就可以开始执行了。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/羊村懒王/article/detail/429894
推荐阅读
相关标签
  

闽ICP备14008679号