一文搞定Linux I/O系统基础知识(绝对干货!!!)

1. C语言文件操作回顾

1.1 fopen && fclose

在学习了C语言后,我们就对文件操作有了一定的了解和认识,并且可以通过一些库函数来完成对文件的一些简单操作。其中必然少不了下面这俩个最基础的库函数:

1. fopen:打开文件

FILE * fopen ( const char * filename, const char * mode );

其中,filename表示你想打开的文件名称(需要带路径,不带路径默认为当前路径),而mode则是文件的打开方式,有一系列的选项可供选择,如下表所示。

返回值:FILE * 为文件流指针,在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系,后续就可以通过这个文件流指针来操作文件。

文件使用方式含义如果指定文件不存在
“r”(只读)为了读数据,打开一个已经存在的文本文件出错
“w”(只写)为了写入数据,打开一个文本文件建立一个新的文件
“a”(追加)向文本文件尾添加数据建立一个新的文件
“rb”(只读)为了读数据,打开一个二进制文件出错
“wb”(只写)为了写入数据,打开一个二进制文件建立一个新的文件
“ab”(追加)向一个二进制文件尾添加数据建立一个新的文件
“r+”(读写)为了读和写,打开一个文本文件出错
“w+”(读写)为了读和写,建立一个新的文件建立一个新的文件
“a+”(读写)打开一个文件,在文件尾进行读写建立一个新的文件
“rb+”(读写)为了读和写,打开一个二进制文件出错
“wb+”(读写)为了读和写,新建一个二进制文件建立一个新的文件
“ab+”(读写)打开一个二进制文件,在文件尾进行读和写建立一个新的文件

2. fclose:关闭文件

int fclose ( FILE * stream );

其中stream为使用fopen函数打开文件所返回的文件流指针
返回值:成功执行返回 0,否则返回 EOF 并设置全局变量 errno 来指示错误发生。

1.2 fwrite && fread

1. fwrite:二进制写入

size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream );

参数含义如下:
ptr:表示要往文件中写入的内容
size:表示一次需要写入“块”的大小 ,单位为字节
count:表示需要写入“块”的个数,单位为字节
stream:写入目标文件的文件流指针

返回值:

  • 如果写入成功,则返回成功写入文件的“块”的个数,也就是成功写入的count,这个很坑,需注意!!!也就是说不一定是总共写入的字节数量。
  • 失败返回-1

由于以上返回值的设定,我们一般在实际使用时习惯将size总设置为1,此时,count就是我们实际想要写入内容总大小,在这种情况下,返回值也就是成功写入的字节数量,比较符合我们的认知。

2. fread:二进制读出

size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );

这个函数各个参数的含义及返回值与fwrite及其相似,只不过是功能刚好相反,fwrite把ptr中的内容写入到文件流指针stream所指向的文件当中,而fread则是从stream所指向的文件当中读出内容放到ptr中。

1.3 others

C语言中对文件的相关操作并不仅限于上述几个函数,有关顺序读写的函数还有fgetc,fputc,fgets ,fputs,fscanf,fprintf,比较常用的还有fseek(根据文件指针的位置和偏移量来定位文件指针),ftell(返回文件指针相对于起始位置的偏移量),rewind(让文件指针的位置回到文件的起始位置)。

由于这些内容不是我这篇博客想谈的重点内容,所以这里就不在详细介绍,有兴趣的读者可以自行查阅资料或者可以简单参考下我在总结C语言文件操作时写过的一篇博客,上述的相关内容有所提到,并且有相关的实例代码帮助理解,附上传送门,有兴趣的小伙伴自行浏览。
理解文件及相关操作(C语言)

1.4 代码实例

上面一堆文字虽然个人认为都是干货,但看起来的确有点枯燥,想要更深刻的理解这些函数,代码实战必然是少不了的,所以废话不多说,上代码!

#include <stdio.h>
#include <string.h>

int main()
{
  //以w+的方式打开当前目录下的文件”linux“
  FILE *fp = fopen("./liunx","w+");
  if(fp == NULL) {
    perror("fopen");
    return -1;
  }

  printf("open sucess!\n");
  char buf[1024] = {0};
  strcpy(buf, "Linux is cool");
  //将buf中的内容写入到该文件中
  size_t w_ret = fwrite(buf, 1, strlen(buf), fp);
  printf("w_ret = %d\n", w_ret);

  //清空buf中的内容
  memset(buf, 0 ,sizeof(buf));
  //使文件流指针偏移到文件头部
  fseek(fp, 0, SEEK_SET);

  //将文件中的内容读至buf中
  size_t r_ret = fread(buf, 1, sizeof(buf)-1, fp);
  printf("r_ret = %d\n", r_ret);
  printf("buf : %s\n", buf);

  //关闭文件流指针
  fclose(fp);

  return 0;
}

代码运行结果如下:
在这里插入图片描述

2. 系统文件I/O

操作文件,不仅可以使用以上的库函数,还可以采用相关的系统调用接口来实现,而说到这里,我们不妨顺带了解一下库函数与系统调用的异同:

系统调用接口:操作系统给用户提供的访问内核的接口。
库函数:一些大佬们觉得某些系统调用接口并不是那么容易理解或者想让其变得更加容易操作,所以就在系统调用的基础之上实现了一些对用户更友好的库函数。
库函数与系统调用接口的关系: 库函数封装了系统调用接口。

2.1 open && close

1. open:打开文件

//头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
//函数原型
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

参数含义:
pathname:要打开或创建的目标文件(路径+名称)
flags:以何种方式打开文件,使用时,可传入一个或多个参数选项,而这些参数本质是一些特殊的宏,其中包括必须的宏和可选的宏。

必须的三个宏(必须有且只能有其中之一):

  • O_RDONLY:只读方式
  • O_WRONLY:只写方式
  • O_RDWR:读写方式

可选的若干宏(只列出常用的三个):

  • O_APPEND:追加
  • O_TRUNC:截断
  • O_CREAT:如果文件不存在则创建

使用时,必须的宏和可选的宏按照按位或的方式进行。如:

O_RDWR | O_CREAT // 以读写方式打开文件,如果文件不存在则创建。

mode:为创建出来的文件设置权限,给出八进制数字。

返回值:

  • 如果打开成功,返回该文件的文件描述符(后边详解),是一个大于等于0的数字
  • 打开失败,则返回-1

2. close : 关闭文件描述符

#include <unistd.h>
int close(int fd);

fd为open函数的返回值。

2.2 write && read

1. write

ssize_t write(int fd, const void *buf, size_t count);

参数含义:
fd:文件描述符,open的返回值
buf:往文件中去写的内容
count:成功写入的字节数
返回值:

  • 如果写入成功,返回写入成功的字节数,大于等于0
  • 失败返回-1

2. read

ssize_t read(int fd, void *buf, size_t count);

对应write理解即可。

2.3 lseek

off_t lseek(int fd, off_t offset, int whence);

在回顾C语言文件操作时没有详解fseek,而fseek与lseek及其类似,也是在文件操作中经常使用的函数,区别就是fseek是库函数,而lseek为系统调用接口。除此之外,二者几乎没有区别,可以对应起来理解。

作用: 将与文件描述符fd相关联的打开文件的偏移量重新定位
参数含义:
fd:文件描述符,open的返回值
offset:偏移量,与whence配合使用,传入正数表示向后偏移,传入负数表示向前偏移
whence:从什么位置开始偏移,与offset配合使用,有以下三个可选的宏

  • SEEK_SET:表示从文件头部开始偏移
  • SEEK_CUR:表示从当前位置开始偏移
  • SEEK_END:表示从文件尾开始偏移

返回值是偏移量,我们一般不关心。

2.4 代码实例

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
  //以读写方式打开文件,若不存在则创建,文件权限为rw-rw-r--
  int fd = open("./linux", O_RDWR | O_CREAT, 0664);
  if(fd < 0) {
    perror("open");
    return -1;
  }
  printf("ret = %d\n", fd);

  char buf[] = "Linux is cool!";
  write(fd, buf, strlen(buf));//写入文件

  lseek(fd, 0, SEEK_SET);//使文件指针偏移到头部
  memset(buf, 0, sizeof(buf));//清空字符串

  printf("read_before_buf:%s\n", buf);
  read(fd, buf, sizeof(buf) - 1);//将文件中的内容读到buf中
  printf("read_after_buf:%s\n", buf);

  close(fd);//关闭文件描述符

  return 0; 
}

运行结果如下:
在这里插入图片描述

3. 文件描述符fd

通过对上面一些接口的学习,我们现在知道这个fd就是open函数的返回值,也被称之为文件描述符,那么对于这样一个文件描述符,它究竟代表什么含义呢?我们必须得好好探究探究!

3.1 深入理解文件描述符

我们知道,linux中进程一旦启动,操作系统就会为每一个进程在磁盘上创建一个以进程号命名的文件夹,这个文件夹位于根目录下的proc/ 目录下,该以进程号命名的文件夹下保存着各种与进程相关的信息,而其中有个叫fd的文件夹,其中保存的信息就是该进程打开的文件描述符信息。
而进程一旦启动,就会默认打开三个文件描述符,分别是0(标准输入),1(标准输出),2(标准错误)。而0,1,2对应的物理设备一般是键盘,显示器,显示器Linux下一切皆文件,而这个三个文件描述符所对应的设备,也可以说是三个文件。

上述内容验证方法:

  1. 随便写一个简单的死循环程序(为了使进程一直存在方便操作),如:
#include <stdio.h>
#include <unistd.h>

int main()
{
 while(1) {
   printf("Let's learn something about fd!\n");
   sleep(1);
 }
 return 0;
}
  1. 另一个终端使用ps aux | grep [进程名] 查看进程号
    在这里插入图片描述

  2. cd /proc/[进程号]/fd

  3. ll查看当前目录下的详细信息
    在这里插入图片描述

通过上述概念性的描述,我们大概知道了文件描述符的部分含义,比如它是虽然进程的产生而被创建,并且对应相应的文件信息,但依然有大量疑问存在,这所谓的文件描述符,究竟从何而来,其本质到底是什么?又怎么根据它与文件产生联系呢?

带着这些问题,我们通过观察内核源码,最终成功得到解答了上述疑问,并且可以画出这样一张图,一目了然!
在这里插入图片描述
看完上图,你还敢说你不懂什么是文件描述符吗?不过,我们倒不妨在进一步解释解释:

  1. 首先,进程一旦启动,与之相关的struct task_struct结构体就会被随之创建,这个结构体中有许多关于进程的信息,但这里我们不关心其他,着重研究一个叫struct files_struct *files的指针
  2. 这个指针在内存中指向一个名为struct files_struct的结构体,而在这个结构体中,有一个很重要的数组:fd_array,这个数组实质上也是一个指针数组,其内部的每一个元素都是一个struct file *类型的指针
  3. 说到这里,我们的“主人公”也就出现了,所谓的文件描述符,正是这个数组(fd_array)的下标啊
  4. 而这个数组中的每一个元素,都指向了一个叫做struct file的结构体,这个结构体正是保存着对应文件的元信息,同时对应着每一个具体的文件。
  5. 也就是说,通过fd_array的下标,就可以找到对应的文件,以实现对文件的各种操作

3.2 文件描述符的分配规则

看完以上内容,相信大家都对文件描述符有了较为深刻的理解了,那么我们不妨在探讨一下它的分配规则。

在进程中每打开一个文件,对应的文件描述符就会随之产生,那么,它的分配规则究竟如何呢?很好验证,我们使用open函数在进程中打开文件,通过观察它的返回值fd来研究其分配规则即可。

细心的读者可能会发现,我在2.14的实例代码中打开了./linux文件,并打印了其返回值,打印结果为3,也就是说系统为我们新打开的这个文件分配了3这个文件描述符,而我们知道,在打开这个文件描述符之前,还会默认打开0,1,2这三个文件描述符,那是不是意味着,我们所想要得知的分配规则就是从正整数3开始的呢?

带着这样的假设,我们试着循环在多打开几个文件,观察分配给我们的文件描述符:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
  int fd[3];
  int i = 0;
  for(; i < 3; i++) {
    fd[i] = open("./linux", O_RDWR | O_CREAT, 0664);
    if(fd[i] < 0) {
      perror("open");
      return -1;
    }
    printf("fd[%d] : %d\n", i, fd[i]);
  } 
  i = 0;
  for(; i < 3; i++) {
    close(fd[i]);
  }

  return 0;
}

运行结果:
在这里插入图片描述
似乎是符合我们的预期,虽然以上测试结果有限,但实际上,在这种情况下,增大数据量,也依旧会是同样的规律:从3开始,依次增大,那么,的确是这样的吗?如果现在就下定论,那就错啦!!!

这时我们把代码稍加改动,在最开始时分别关闭0号或者2号文件描述符在试试,也就是在main函数的开头分别增加以下代码:

close(0);
close(2);

关闭0:
在这里插入图片描述
关闭2:
在这里插入图片描述
通过以上代码验证我们发现,关闭现存的文件描述符会影响我们之后打开的文件描述符的分配情况,而到这里我们才可以得出结论:文件描述符的分配规则是最小未占用原则!!!

也就是说,在需要打开新的文件描述符时,会在fd_array这个数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。

3.3 文件描述符与文件流指针的关系

我们以上内容我们知道,文件流指针是fopen的返回值,而文件描述符则是open的返回值,我们通过这俩个返回值都可以操作文件,那么,这二者之间又有着什么样的区别和关系呢?

从表面上来看:

  1. 文件流指针是fopen所返回,而fopen是库函数,所以文件流指针是C库在维护的
  2. 文件描述符则是open所返回,而open是系统调用接口,所以文件描述符是内核在维护的

而从源码的角度来看:
我们所熟知的FILE在C库的源码中其实是一个结构体,而这个结构体,其实是通过typedef重命名过的:

typedef struct _IO_FILE FILE;

也就是说这个结构体其实本身是struct _IO_FILE,而这个结构体位于 /usr/include/stdio.h 中,很容易找到,其中的内容我们目前只需要关注俩部分。

其一是缓冲区相关的内容:
在这里插入图片描述
这也正是我们使用文件流指针(库函数)相关的函数(如fwrite)进行相关操作时,会涉及到缓冲区的概念,而与文件描述符(系统调用)相关的函数(如write)则并没有缓冲区。

从这一点也可以看出,库函数其实是对系统调用接口的进一步封装,这里缓冲区的就是在封装时二次加上的,系统调用中并没有,而这个缓冲区正是C库在维护的。

而其二则是如下一个变量:

int _fileno;

这个名叫_fileno的整型变量,其中保存的内容正是内核中的文件描述符fd! 也就是说,文件流指针与文件描述符的关系可以简单理解成下图所示的一种联系:
在这里插入图片描述
至此我们可以得出以下结论:

  1. 在进程中,每产生一个新的文件流指针,在C库当中就会创建出不同的struct _IO_FILE结构体
  2. 文件流指针中包含 了文件描述符
  3. 操作系统中广泛存在着缓冲区,但如果是针对文件流指针而言的缓冲区,是C库在进行维护的。

4. 重定向

4.1 什么是重定向

不知道有没有细心的小伙伴发现,在刚才研究文件描述符的分配规则时我们分别关闭了0号文件描述符和2号文件描述符,那为什么刚刚不关闭1号文件描述符呢?

我们知道,1号文件描述符所表示的内容为标准输出,一般对应显示器设备。那如果关掉一号文件描述符,会发生什么呢?我们来试一试:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
  close(1);
  int fd = open("./myfile", O_RDWR | O_CREAT, 0664);
  if(fd < 0) {
    perror("open");
    return -1;
  }
  //打印
  printf("fd = %d\n", fd);
  printf("Linux is cool!\n");
  return 0;
}

运行结果:
在这里插入图片描述
我们发现本该打印到显示器的俩条内容,并没有按照我们预想的情况出现,那么他们去了哪里呢??答案是打印到了我们打开的文件myfile当中:
在这里插入图片描述
我们将上面这种本应输出到显示器却输出到了其他地方的这种现象叫做输出重定向

在命令的方面感受重定向,常见的重定向有:

  • >”:清空重定向
  • >>”:追加重定向

通过echo + “>>” 来验证一下:
在这里插入图片描述
我们发现,同样是本该输出到显示器的内容却输出到了文件myfile中,而我们刚使用的“>>”为追加重定向,并没有清空文件中原本就有的内容,如果使用“>”则在输出到文件之前会清空文件内容,如:
在这里插入图片描述

4.2 重定向原理(本质)

那么,在了解是什么是重定向以及观察到重定向的现象之后,我们有必要思考一个问题:为什么通过上述操作就能完成重定向的功能?重定向的本质到底是什么?

而这个问题我们同样通过上面用来介绍文件描述符的那张图来解释,只需将上图稍作修改,就可以清楚的使我们弄懂这个问题:
在这里插入图片描述

我们知道,一个进程会默认开启三个文件描述符,分别是0(标准输入),1(标准输出),2(标准错误),而打开的其他文件则会根据最小未占用原则为其分配新的文件描述符,指向每个新打开的文件。而重定向的本质,其实就是更改了原本的文件描述符所对应的指向,让其不在指向本来所应指向的文件,从而指向新的文件。

4.3 重定向接口dup2

除了使用命令和预先关闭某些文件描述符的方法,我们还可以使用dup2这个系统调用接口来完成重定向操作:

int dup2(int oldfd, int newfd);

比如我们同样想要完成以上的输出重定向,使用dup2只需采用如下代码即可:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main()
{
  int fd = open("./linux", O_RDWR | O_CREAT, 0664);
  if(fd < 0) {
    perror("open");
    return -1;
  }
  printf("fd = %d\n", fd);
  //oldfd:我们自己打开的文件的文件描述符
  //newfd:想要更改其指向的文件描述符
  dup2(fd, 1);

  printf("It's dup2!!!\n");

  return 0;
}

运行结果: 重定向前printf的内容打印在显示器上,而重定向之后printf的内容就打印到了我们自己打开的文件当中:
在这里插入图片描述
dup2在执行时会做俩件事:

  1. 关闭newfd,如果关闭成功就会去完成第二步,若失败则表示重定向失败(比如去关闭一个无效的文件描述符)
  2. newfd拷贝oldfd的值,这样一来newfd就不在指向原本指向的文件,而是指向oldfd所指向的文件,这就完成了重定向

(全文完)

  • 11
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值