前言
在学习C语言时,我们接触过如fopen、fclose、fseek、fgets、fputs、fread、fwrite等函数,实际上,这些函数是对于底层系统调用的封装。C默认会打开三个输入输出流,分别是stdin,stdout,stderr。执行man stdin
后,会展示如下描述:
#include
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
可以看到,这三个流类型都是FILE*,也就是说指向了某个文件。实际上,以上三者分别对应的文件为键盘、显示器、显示器。
那么,操作系统是如何管理文件,并进行文件IO呢?
1. 文件描述符及基本IO接口介绍
1.1 什么是文件描述符
在第一讲中,我们知道了当进程被创建后,系统会给该进程分配对应的PCB,在Linux中,进程的PCB是task_stuct,里面有一项files_struct——打开文件表。打开文件表的源码如下:
struct files_struct {
atomic_t count; /* 共享该表的进程数 */
rwlock_t file_lock; /* 保护以下的所有域,以免在tsk->alloc_lock中的嵌套*/
int max_fds; /*当前文件对象的最大数*/
int max_fdset; /*当前文件描述符的最大数*/
int next_fd; /*已分配的文件描述符加1*/
struct file ** fd; /* 指向文件对象指针数组的指针 */
fd_set *close_on_exec; /*指向执行exec( )时需要关闭的文件描述符*/
fd_set *open_fds; /*指向打开文件描述符的指针*/
fd_set close_on_exec_init;/* 执行exec( )时需要关闭的文件描述符的初值集合*/
fd_set open_fds_init; /*文件描述符的初值集合*/
struct file * fd_array[32];/* 文件对象指针的初始化数组*/
};
进程是通过文件描述符(file descriptors,简称fd)而不是文件名来访问文件的,文件描述符是一个整数。
在打开文件表中,最重要的一项是fd_array[32],这是一个指针数组,通常,fd_array包括32个文件对象指针,如果进程打开的文件数目多于32,内核就分配一个新的、更大的文件指针数组,并将其地址存放在fd域中,内核同时也更新max_fds域的值。
每当打开一个文件时,系统就会分配fd_array中的某项,将其指向打开的文件结构体。从下图我们可以看出,fd实际上就是fd_array的索引。只要有fd,就可以找到对应文件的位置。
1.2 基本IO接口
在认识返回值之前,需要先区分两个概念: 系统调用和库函数。 在用户程序中,凡是与资源有关的操作(如存储分配、进行I/O传输及管理文件等),都通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。在执行系统 调用的过程中,操作系统会由用户态进入到内核态。系统为了防止应用程序能随意修改系统数据,只给用户提供接口,用户要使用,那就通过提供的接口来调用。
fopen、fclose、fread、fwrite 都是C标准库当中的函数,我们称之为库函数(libc),而open close read write lseek 都属于系统提供的接口,称之为系统调用接口。实际上,库函数往往是对系统调用接口的进一步封装,方便程序员进行二次开发。
1.2.1 open/close接口
函数原型:
头文件:
#include
#include
#include
接口1:int open(const char *pathname, int flags);
:
接口2:int open(const char *pathname, int flags, mode_t mode);
接口3:int close(int fd);
如果打开的文件存在,则使用接口1,如果打开的文件不存在,则使用接口2。
pathname:待打开或创建的文件
flags:以何种方式打开。打开文件时,可以传入多个常量进行“或”运算。这些常量有:
0_RDONLY:只读打开;O_WRONLY:只写打开;O_RDWR:读写打开。这三个常量,必须指定且只能指定一个。
O_CREAT:若文件不存在,则创建该文件,需要使用mode选项,来指明新文件的访问权限。
O_APPEND:追加写入。
O_TRUNC:截断文件(清空文件内容)
O_NONBLOCK :使用非阻塞方式读写设备文件,如果不添加,默认情况下读写为阻塞方式。
以上的选项是按照按位或的方式进行组合的,O_RDWR|O_CREAT|O_APPEND
意思是以读写的方式打开文件,如果文件不存在则创建文件,打开文件后写入方式为追加写入。
mode:当创建一个新文件时,需要给文件设置权限,一般通过传递一个8进制的数字。关于文件权限,请读者自行查阅相关文章。
返回值:
创建成功返回一个文件描述符
创建失败返回-1。
1.2.2 read/wirte接口
ssize_t read(int fd, void *buf, size_t count);
fd:文件描述符
buf:将文件读到buf指向的空间中
count:期望读取的字节数
ssize_t write(int fd, const void *buf, size_t count)
fd:文件描述符
buf:将buf中的内容写到文件当中去
count:期望写入的字节数,size_t被定义为unsigned long。
返回值:返回读出或者写入的字节数。需要注意的是read和write的返回值都是有符号数,ssize_t被定义为long,出错的时候返回-1。有趣的是返回一个-1的可能性使得读到或者写入的最大值减小了一半。
通过下例来感受一下上面几个接口的使用:
#include
#include
#include
#include
#include
#include
char buff[1024];
int main()
{
int fd = open("wrfile",O_RDWR|O_CREAT|O_APPEND,0644);
if(fd == -1)
{
return 1;
}
else{
const char* str = "Hello world\n";
strcpy(buff,str);
write(fd,buff,strlen(buff));
printf("%d\n",fd);
close(fd);
}
return 0;
}
执行该段代码后,可以看到如下输出结果
第一,打开文件后,将返回wrfile的fd,执行write函数,会将buff中的内容写入到wrfile中。
第二,输出wrfile的内容,发现如果只执行一次,输出一行“Hello world”,如果执行两次,输出两行“Hello world”,这是因为我们是以追加的方式打开的文件。
第三,打开文件后,返回的fd号码是3。为什么会是3?这就与文件描述符的分配规则有关。
1.3 文件描述符的分配规则
根据我们在1.1中知道的,fd是fd_array[ ]的索引。通常,数组的第一个元素(索引为0)是进程的标准输入文件,数组的第二个元素(索引为1)是进程的标准输出文件,数组的第三个元素(索引为2)是进程的标准错误文件,分别对应的键盘,显示器,显示器。在Linux中,万物皆文件,因此各种外设也会当做文件进行处理。
是不是瞬间明白了为什么用户自己打开第一个文件的时候分配的fd是3而不是0?是的,这是因为对于任何进程,标准输入文件、标准输出文件和标准错误文件会被默认打开。当再次分配的时候,操作系统会采用最小未分配原则——即先分配当前fd_array中未被使用的最小索引。
如果我们先关闭了fd为1的文件,会是什么情况呢?请看下例:
#include
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd = open("./myfile",O_RDWR|O_CREAT,0644);
if(-1 == fd)
{
return -1;
}else{
printf("The fd is : %d\n",fd);
fflush(stdout);
close(fd);
}
return 0;
}
执行该段代码后,结果如下:
执行./test后,并没有在屏幕上打印出结果。但是当我们查看myfile文件中的内容时发现,原来是内容都输出在了myfile文件中。我们可以看到,打开myfile的fd为1,这是因为我们之前关闭了fd为1的文件,当打开新的文件时,系统会分配最小未使用的fd——1。
为什么执行printf后内容会输出到myfile中而不是屏幕上,这就需要提到输出重定向。
1.4 输入重定向与输出重定向的本质
1.4.1重定向的原理
printf是格式化输出函数,当调用printf时,数据会被默认输出到缓冲区中,当换行符或者缓冲区满时,会将数据刷新到stdout中。stdout是标准输出设备,其fd默认为1。
在上例中,关闭fd为1的文件后,当打开新的文件,会给其分配最小未使用的fd。此时执行printf函数,会将数据输出到磁盘文件myfile中,我们将这种现象称为输出重定向。常见的重定向有:>, >>, <,分别为输出重定向,追加重定向,输入重定向。重定向的原理如下图所示:
需要注意的是,我们在执行完printf后,使用了fflush函数来刷新缓冲区。如果不使用这个函数,当执行./test后,我们会发现数据并未输出到myfile中,这就需要提到缓冲区。
在默认情况下,stdout是行缓冲的,他的输出会放在一个buffer里面,只有到换行的时候,才会输出到屏幕,stderro是无缓冲,会直接进行io操作。而平时使用的磁盘文件是全缓冲(或称满缓冲)的,只有缓冲区满的时候才会将缓冲区里面的内容刷新。当关闭stdout,打开myfile后,会变成全缓冲,因此需要我们执行fflush强制刷新缓冲区。缓冲区的类型如下:
需要注意的是,这里的缓冲区指的是c程序中的用户缓冲区,而不是内核缓冲区!
1.4.2 重定向在命令行的应用
如下图所示,当执行cat指令后,myfile中的内容会输出到屏幕上。当我们再次执行cat myfile,并使用>进行输出重定向后,可以看出myfile中的内容输出到了pfile中,当使用>>后,会发现pfile中有两行数据,这就是追加重定向,即再原来文件的末尾继续输出。
1.4.3 dup2系统调用
如果我们想在IO时进行重定向操作,难道每次都需要先close一个文件,再申请对应的fd吗?这样无疑增加了编码的复杂程度,因此,如果想进行重定向操作,推荐使用dup2系统调用。
接口描述:
int dup2(int oldfd, int newfd);
该接口用来复制新的文件描述符。通俗地说,fd_array[ ]中存放着指向若干打开的文件结构体file,比如此时某个文件的fd为3,我们想对这个文件进行输出重定向,让本应该输出到fd为3的文件中的数据输出到屏幕上,就可以通过调用dup2(3,1)。原理是把fd为3的文件结构体指针复制到fd为1的单元中,这样3和1都指向了同一个文件结构体。
示例如下:
#include
#include
#include
#include
#include
#include
int main()
{
int fd = open("./myfile",O_RDWR|O_CREAT,0644);
if(-1 == fd)
{
return -1;
}else{
printf("Hello world!\n");
dup2(fd,1);
close(fd);
printf("The fd is : %d\n",fd);
fflush(stdout);
}
return 0;
}
输出结果如下:
在程序中,有两处printf函数,当执行./test后,屏幕上只打印出一行"Hello world!",而第二行的数据打印到了myfile中。我们明明已经对新打开的文件执行了close(fd),为什么数据还是会打印到myfile中?
这是因为调用dup2的时候,将oldfd中的值复制到了newfd中。
注意:复制的是数组下标为fd中存储的指针值而不是fd本身!若newfd指向的文件已经被打开,会先将其关闭。若newfd等于oldfd,就不关闭newfd,newfd和oldfd共同指向一份文件。
1.5 fd与C库中FILE的关系
C库中的函数本质上是对系统调用的封装,所以本质上,所有对于文件的操作都是通过文件描述符fd来实现的。那么,C库的FILE中一定有对应文件的fd!
2. 文件系统
2.1 磁盘简介
传统的硬盘盘结构是像下面这个样子的,它有一个或多个盘片,用于存储数据。中间有一个主轴,所有的盘片都绕着这个主轴转动。一个组合臂上面有多个磁头臂,每个磁头臂上面都有一个磁头,负责读写数据。
每个磁道划分为若干个弧段,每个弧段就是一个扇区 (Sector),是硬盘的最小存储单位。每个扇区储存512字节(相当于0.5KB)。
操作系统读取硬盘的时候,不会一个个扇区地读取,因为这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个”块”(block)。这种由多个扇区组成的”块”,是文件存取的最小单位。”块”的大小,最常见的是4KB,即连续八个sector组成一个block。一个block的大小是由格式化的时候确定的,并且不可以更改。
2.2 inode(索引结点)
输入ls -l指令后,我们能看到如下内容
对于一个文件而言,一个文件= 文件属性+文件内容。图中所标识的部分就是文件的各种属性,那这些属性是存储在哪里?又是怎样存储的呢?文件的数据又存放在哪里呢?
上图是一个简易的磁盘系统分区图,一般来说,在一个文件系统内,一般将磁盘分为如下几个区域:
超级块:里面存放该文件系统本身的结构信息,如bolok 和 inode(马上会讲)的总量, 未使用的block和inode的数量,一个block和inode的大小等。
block位图:先来看一下位图的结构
block位图有点像是一个超大型数组,每个比特位所在位置可以看成数组下标,而这个“下标”就是对应的block号,0代表该块未分配,1代表该块已经被分配。如在上图中,表示9号块已经分配。每分配一个block,要将对应位置的Bitmap置为1。
inode表:inode中存放的是一个文件的元信息,一般来说有以下信息:
-
该文件的inode号,用来标识一个inode,而每个inode对应一个文件,Linux系统内部不使用文件名,而使用inode号码来识别文件。对于系统来说,文件名只是inode号码便于识别的别称或者绰号。
查看inode号码的指令:
ls -i
-
文件的字节数
-
文件拥有者的User ID ,所属组的Group ID
-
文件的读、写、执行权限
-
文件的时间戳
-
链接数,即有多少文件名指向这个inode 文件数据block的位置
可以通过
stat
指令查看对应文件的inode信息,如下图所示:
inode中还会为该文件维护一个索引表,类似于内存管理中的页表记录了逻辑块号到物理块号之间的对应关系。索引表的目录项中记录的是每个文件的索引块地址。一般有直接索引,多层索引或混合索引等。
inode位图:一个文件系统能分配的inode数量是一定的,因此也可以用记录block的方式来记录inode的分配情况。原理与block Bitmap的原理一样,inode Bitmap中的“下标”就是inode号。当分配了某个inode号时,将对应inode Bitmap比特位置为1。
2.3 目录文件的理解
在Linux中,目录(directory)也是一种文件。
目录文件是一系列目录项(dirent)组成的。每个目录项,由两部分组成:所包含文件的文件名,以及该文件名对应的inode号码。如下图所示
既然目录也是文件,那么目录本身也一定有其inode,现代操作系统一般采用树形结构来组织文件。因此要打开一个文件,需要先找到该文件的目录,并在目录中找到对应的目录项,获取到该文件的inode号码,再将磁盘中的内容加载到内存。
进入一个目录需要什么权限?
显示目录下的内容是读权限(r) ,由于目录文件内只有文件名和inode号码,所以如果只有读权限,只能获取文件名,无法获取其他信息,因为其他信息都储存在inode节点中。进入是可执行权限 (x),一个目录默认需要有可执行权限。
2.4 软链接与硬链接
我们已经知道,文件的唯一标识符是inode而非文件名,文件名仅仅是方便用户使用的一个“绰号”。那么,只能通过一个文件名来找到对应的inode,获取文件的元信息吗?在Linux中,为了解决文件共享的问题,提出了链接。链接分为硬链接和软链接:
2.4.1 硬链接
让不同的文件名访问同样的内容,这种方式被称为硬链接。
可以使用ln指令创建硬链接:ln 源文件 目标文件
,如下图所示
该案例中为test可执行文件创建了一个硬链接linktest,通过ls -il指令可以看到,linktest和test具有相同的inode号码,也就是说实际上是同一个文件,此时链接数为2。执行test和linktest后,都会输出同样的结果。而当删除test文件后,由于linktest的存在,该文件实际上并未被删除,删除的仅仅是该文件名!此时,链接数会变为1。
因此我们可以总结出硬链接的如下特点:
- 不同的文件名访问同样的内容。
- 对文件内容进行修改,会影响到所有文件名。
- 删除一个文件名,不影响另一个文件名的访问。当链接数变为0时,该文件才算真正删除。
这里需要注意目录文件的链接数!
创建一个目录文件,输入ls -il命令后,会看到如下现象:
该目录文件的链接数居然是2!这是为什么?当我们进入dir,并创建一个目录文件后,会发现,“."的inode和dir的inode是一样的,也就是说dir目录下的“."是dir的硬链接。这是因为创建目录时,默认会生成两个目录项,".“和"..",”." 代表当前文件,".." 代表上一级文件。
如果此时我们在dir下再创建一个目录,会发现dir的链接数变为3。这是因为新创建的文件dir/dir1中包含”.." ,该文件名也是dir的硬链接。
综上,任何一个目录的"硬链接"总数,总是等于2加上它的子目录总数(含隐藏目录)。
2.4.2 软链接
软链接类似于Windows下的快捷方式。Linux下通常会将一些目录层次较深的文件链接到一个更易访问的目录中。
用ln -s 命令创建一个文件的软链接:ln -s 源文文件或目录 目标文件或目录
可以看到创建test的软链接slinktest后,slinktest的inode号与test的inode号不一样。这说明软链接本身也是一个文件,且文件类型为l。test的链接数始终为1,当删除test后,执行slinktest会报出No such file or directory的错误。也就是说,软链接依赖于原文件存在。
硬链接和软链接最大的不同在于:
- 软链接指向的是原文件的文件名而不是inode,保存了其代表的文件的绝对路径,不会改变原文件的链接数,删除原文件,软链接将不能使用。
- 而硬链接指向的原文件的inode,只有当链接数变为0是,整个文件才能被真正删除。
3.动静态库
在学习动静态库前,我们需要先明白什么是库。库(Library)就是一段编译好的二进制代码,加上头文件后就可以供别人使用。一般有两种情况会用到库:
- 第一种情况是某些代码需要给别人使用,但是我们不希望别人看到源码,就编译好并以库的形式进行封装,只暴露出头文件。别人要使用,只需要加上头文件即可。
- 第二种是在实际工程中,编译一个大型项目往往要花费很多时间,因为很多文件都需要从源文件编译,链接。对于某些不会进行大的改动的代码,我们想减少编译的时间,就可以把它打包成库,因为库是已经编译好的二进制了,编译的时候只需要 Link 一下,不会浪费编译时间。
那么,我们必须明白一个概念——目标文件。目标文件有三种形式:
- 可执行目标文件。即我们通常所认识的,可直接运行的二进制文件。
- 可重定位目标文件。包含了二进制的代码和数据,可以与其他可重定位目标文件链接,并创建一个可执行目标文件。
- 共享目标文件。它是一种在加载或者运行时进行链接的特殊可重定位目标文件。当程序执行到一定程度,需要调用该目标文件中的某个接口时,才会将该目标文件与运行中的文件链接。
通过上面的表述,我们发现链接的方式有两种:一种是提前链接好,生成可执行目标文件;另一种是运行过程中才链接。前者被称为静态链接,后者被称为动态链接。于是便产生了两种库——静态库与动态库。
静态库在Linux中前缀为lib,后缀为.a,因此一个静态库的名字为libxxx.a。动态库在Linux中前缀为lib,后缀为.so,则一个动态库的名字为libxxx.so。
静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库 。
当我们make后,会发现报如下错误:
/usr/bin/ld: cannot find -lc
collect2: error: ld returned 1 exit status
make: *** [test_static] Error 1
不要着急,这是因为我们没有安装静态库!
输入如下指令:sudo yum install glibc-static
即可解决。接下来编译,会发现出现了我们想要的test_static。发现没有,通过静态库生成的目标文件非常大!还请大家忍一下。
这是因为通过静态库生成的可执行文件时,在链接的过程中将静态库中需要的部分都“拷贝”到了最终的可执行文件中,因此这个可执行文件在一个没有其需要的库的linux系统中也能正常运行。
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。 一般默认生成的可执行程序都是动态的,动态库体积小,运行时加载,只有一份。可以看到,动态链接生成的test的大小只有静态链接生成的test_static的百分之一左右!
- 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)。
- 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
可以通过file命令查看文件的链接信息:
通过以上描述,我们可以看出动态库与静态库有以下区别:
- 可执行文件大小不同。动态库比静态库小得多。
- 扩展性不同。如果静态库中某个函数的实现变了,那么这个可执行文件必须重新编译,比较耗时。而动态库只需要更新动态库本身,不需要重新编译可执行文件。
- 加载速度不同。由于静态库在运行时才链接,因此从时间效率上会稍慢一些。不过由于程序运行的局部性原理,时间损失并不会很多。
- 依赖性不同。静态链接的可执行文件不需要依赖其他的内容即可运行,而动态链接的可执行文件必须依赖动态库的存在。一般情况下,系统中有大量的动态库,不会有太大问题。
总结
学习完系统IO后,我们再来思考最后一个问题。
当文件打开加载进内存后,该文件在内存中的位置为什么不放在inode中,而是存放在file结构体中?
Linux中的文件是能够共享的,假如把文件位置存放在索引节点中,则如果有两个或更多个进程同时打开同一个文件时,它们将去访问同一个索引节点。如果一个进程对该文件进行写操作,而另一个同时进行读操作,显然,这是不被允许的。
另一方面,打开文件时有如下特点。
- 一个文件不仅可以被不同的进程分别打开,而且也可以被同一个进程先后多次打开。
- 一个进程如果先后多次打开同一个文件,则每一次打开都要分配一个新的文件描述符,并且指向一个新的file结构。
引入file结构体有利于文件的共享。当两个进程共享同一个文件时,两个进程的fd可以指向同一个file结构体。file结构体中记录着文件的在内存中的偏移量,当一个进程进行写操作后,文件的偏移量可能发生改变,此时只需要修改file结构体中的偏移量。当该进程写结束,另一个进程需要进行写操作时,是在新的偏移量的基础上进行写操作,这样防止了第二个进程重写第一个进程的输出内容。
进程可以共享同一个打开的文件,那进程之间是否能够进行通信呢?答案是肯定的。下一章我们将会讲述《Linux系统编程之进程通信》,如果觉得有用,欢迎您一键三连!