Linux系统编程——基础IO与文件描述符(管理已打开的内存文件)

目录

一,文件预备

二,C语言文件操作函数

2.1 默认打开的三个流

2.2 写文件 

2.3 读文件

2.4 再次理解当前路径

三,Linux操作文件系统调用

3.1 open()和close()

3.1.1 第一个参数

3.1.2 *第二个参数

3.1.3 第三个参数 

3.2 write()

3.3 read()

四,文件描述符

4.1 是什么?

4.2 一些现象

4.3 理解fd

4.4 fd与FILE的关系

4.5 fd分配规则

五,重定向

5.1 重定向原理

5.2 输出重定向

5.3 输入重定向

5.4 追加重定向

5.5 重定向系统调用dup2()

六,FILE缓冲区

6.1 关于缓冲区

6.2 深入理解缓冲区刷新策略

6.3 缓冲区在哪里呢?

七,一些问题解答

7.1 文件是进程创建的吗?

7.2 重定向时不fflush会发生什么?

7.3 标准输出和标准错误有什么关系

7.4 重新理解“一切皆文件”


一,文件预备

①前面说过,“文件= 内容 + 属性”,而我们对文件的所有操作,无外乎“对内容” “对属性”做操作;而文件的内容和属性本质也是数据,所以对文件的操作也变成了对文件的数据做操作。

②文件在磁盘上放着,我们访问文件需要先写代码-->编译形成可执行程序-->运行后访问文件,所以访问文件本质是“进程在访问文件”,但是进程也是调用了操作系统的接口访问文件,所以也可以说访问文件的本质是“操作系统在访问文件

③但是访问磁盘上的文件,只有OS有权利通过磁盘驱动访问,因为OS才是管理者。但是OS也是为用户服务的,那么OS必须要为用户提供各种访问文件的系统调用接口;但是这些接口使用起来比较困难,所以各种高级语言对这些接口进行了封装,推出了函数供用户使用。

虽然各种语言不一样,导致了不同的语言有着不同语言级别的文件访问函数,但是在底层上用的都是系统接口,这也是我们要学习OS层面的文件接口的原因,因为OS层面的接口只有一套,一台设备上也只有一个OS。

跨平台性:我写出一份C/C++代码,这个代码在Windows,Linux等各种能支持C/C++的环境下都能编译运行,就叫做C/C++具有跨平台性。市面上绝大多数的高级语言都是跨平台的,而且也必须是跨平台的,假如C/C++步对文件的系统接口进行封装,那么用户就要使用系统调用去操作文件,那么用户编写的代码就无法在其它平台运行了,可移植性和跨平台性都会很差。(比如linux的接口在Windows上不能用,因为两个系统的接口实现不一样)

所以语言要想有跨平台性,就必须把所有环境的系统调用都给封装一遍,然后通过条件编译动态裁剪

⑤磁盘是硬件,显示器也是硬件,所以printf往显示器打印,也是一种写入,和磁盘写入到文件没有本质区别

Linux下,一切皆文件

我们曾经理解的文件的操作就是读和写

站在用户的角度:printf/cout --> 一种写,往显示器写数据;scanf/cin --> 一种读,从键盘读数据

站在内存的角度:从硬件(键盘input)读数据交给内存,然后内存再把数据刷新到文件(显示器output)中

所以我们的C语言接口:普通文件 --> fopen.fread --> 内存 --> fwrite --> 文件中

                                       |<--------------input------------->|   |<--------output--------->|

问题:什么是文件呢?

解答: 站在系统的角度,能够被input读取,或者能偶output写入的设备就叫做文件

狭义文件:普通磁盘上的文件

广义文件:显示器,键盘,网卡,声卡,显卡,磁盘等几乎所有的外设都可以称之为文件

二,C语言文件操作函数

关于C语言文件操作之前已经详细讲解过,这里只做简单回顾:

C语言进阶——文件操作_如果文件名随时间变更,怎么通过c打开文件-CSDN博客

2.1 默认打开的三个流

文件预备上说,Linux下一切皆文件,那么也就是说显示器和键盘当然也可以看作文件。我们能看到显示器上的数据是因为我们“操作系统向显示器文件写入了数据”,电脑能读取到我们键盘上的数据是因为“操作系统从键盘文件读取了数据”,那么问题来了:为什么我们写的C语言向“显示器写入数据”,和“从键盘读取数据”不用fopen打开它俩的文件呢?

任何进程在运行时都会默认打开三个输入输出流,即“标准输入”,“标准输出”,“标准错误”,对应到C语言中就是stdin,stdout,stderr,我们可以查看man文档,它们都是FILE*类型的:

 这三个东东在C++中就是cin,cout,cerr,而且其它所有的语言都有类似的实现,因为这种特性是操作系统所有的,所以每个语言设计的时候也要支持对应概念。

对于stdin,stdout,stderr的具体使用,我们到下面的重定向部分再详细讲解

2.2 写文件 

#include<stdio.h>
#include<string.h>
int main()
{
  FILE* fp = fopen("log.txt","w"); //用w打开就是一次性写入,下次再以w写入会删掉之前写的数据重新写入,以a方式打开就是追加写入
  if(fp == NULL) //打开失败
  {
    perror("fopen");
    return 1;
  }
  //进行文件操作
  const char *s1 = "hello fwrite\n";
  fwrite(s1,strlen(s1),1,fp); 
  //问题:fwrite写数据时,由于字符串会默认在最后加个 '\0',所以我们strlen(s1) 时要不要+1?
  //不要,因为\0是C语言的规定,和文件没关系,文件要保存的是有效数据!上面的字符串就是有效数据,如果+1了,会出现乱码

  const char *s2 = "hello fprintf\n";
  fprintf(fp, "%s",s2);

  const char *s3 = "hello fputs\n";
  fputs(s3,fp);

  fclose(fp);
  return 0;
}

2.3 读文件

#include<stdio.h>
#include<string.h>
int main()
{
  FILE* fp = fopen("log.txt","r");
  if(fp == NULL) //打开失败
  {
    perror("fopen");
    return 1;
  }
  char line[64];
  printf("log.txt内容如下:\n");
  while(fgets(line,sizeof(line),fp) != NULL)
  {
    fprintf(stdout, "%s", line);
  }

  fclose(fp);
  return 0;
}

2.4 再次理解当前路径

当fopen以写入方式打开一个文件时,如果文件不存在,就会自动在当前路径创建该文件,那么这里说的当前路径是指什么呢?

我们先把log.txt删除,然后回到上级目录,在上级目录再运行test创建log.txt,可以发现如下情况:

 通过上面的实验我们可以发现,之前我们理解的“当前路径”绝对不是可执行程序所在路径那么简单

我们先在test.c里面加一个while(1){} 的空死循环,然后获取进程的PID并查看,如下命令:

ps ajx | head -1 && ps ajx | grep test

所以实际上,我们这里所说的当前路径不是可执行程序所处的路径,而是指该可执行程序成为进程时我们所处的路径 

三,Linux操作文件系统调用

3.1 open()和close()

前面说过,C/C++等语言都是封装了系统接口,然后设计出了函数供用户使用,所以这也是我们需要学习系统接口的原因。

3.1.1 第一个参数

man 2 open

前面两个参数和C语言一样,第一个参数表示要打开或创建的目标文件,可以是路径也可以单独是文件名,用法也和C语言一样,如果pathname是路径,就在pathname路径下创建或打开文件,如果是文件名,就在当前路径下创建或打开文件。

3.1.2 *第二个参数

第二个参数flags表打开文件的方式,有15个可选值,这里只显示5个常用的,其它的如果有场景需要用,只需要man 2 open然后把界面往下拉就可以查询

flags部分可选参数具体含义

O_RDONLY

只读方式打开文件
O_WRONLY只写方式打开文件
O_RDWR读写方式打开文件
O_APPEND追加方式打开文件
O_CREAT当目标文件不存咋时,创建文件

Linux规定,如果要使用open接口,第二个参数必须传入O_RDONLY,  O_WRONLY,  or O_RDWR 三个中的至少一种,那么这也意味着可以传入多种参数,比如如果我想以只写方式创建并打开文件,第二个参数就要这样传:

O_WRONLY | O_CREAT

 那么是如何做到的呢?我们都知道Linux是用C语言写的,而在C语言中像这样的纯大写字母组合起来的字符一般都是宏定义,我们可以看一下头文件中对这些选项的定义:

vim /usr/include/bits/fcntl-linux.h

 这些宏定义代表的数值有个共同点,那就是它们的二进制序列当中都只有一个值为1(第一个只读为0表示默认选项),而且每个1在32个比特位中的位置都不一样,这也就意味着我们可以用与操作来间接传入多个选项了,如下代码:

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

#define ONE 0x1   //0000 0001
#define TWO 0x2   //0000 0010
#define THREE 0x4 //0000 0100

//解释open可以传入多个标记位
//向flags一次性传入多个标记位
void show(int flags)
{
  printf("-----------\n");
  if(flags & ONE)   printf("hello 1\n"); //(flags)0000 0011 & (ONE)0000 0001
  if(flags & TWO)   printf("hello 2\n"); //(flags)0000 0101 & (TWO)0000 0010
  if(flags & THREE) printf("hello 3\n"); //(flags)0000 0111 & (THREE)0000 0100
}

int main()
{
  show(ONE);
  show(TWO);
  show(ONE | TWO);   //0000 0001 | 0000 0010 --> 0000 0011
  show(ONE | THREE); //0000 0001 | 0000 0100 --> 0000 0101
  show(ONE | TWO | THREE); // --> 0000 0111
}

3.1.3 第三个参数 

第三个参数就是我们的老朋友:文件的权限了,和之前将的一样,我们传入十进制数字,编译器帮我们转化成二进制数然后把比特位为1的位置的权限给文件设置。

我们将mode设置为0666,则文件权限为“-rw-rw-rw-”,但是由于有umask文件掩码存在,它的默认值一般为0002,所以实际上我们创建出来的文件的权限实际为0664,也就是“-rw-rw-r--”。

所以要想创建出来的文件的权限值不受umask影响,就要在C/C++代码中使用open前先用umask(0); 函数将掩码设置为0。当不需要创建文件时,open的第三个参数可以不设置,所以前面man函数查看的open有两个

3.1.4 close()

这位也是我们的老盆友了,文件打开操作完后也要关闭

这个参数fd就是文件描述符,这里我们先不讲,可以直接理解为就是open函数的返回值,就和C语言fopen函数返回的FILE*指针一样。如果关闭文件成功close返回0,若关闭失败返回-1。 

3.2 write()

第一个参数是文件描述符,现在先不管,第二个参数是要进行写入的缓冲区,其实就是个指针,第三个参数表示要写入的长度

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
  umask(0);
  int fd = open ("log.txt", O_WRONLY | O_CREAT | O_APPEND);
  //上面三个标识符的最后一个O_APPEND加上才会在写入的时候往后面追加字符
  //当选项为O_WRONLY | O_CREAT时,文件里面已经有hello world时,往里面写入aa,最后cat log.txt只会显示aallo world,写入前没有清空原数据,需要再加上O_TRUNC会写入前清空数据
  //总结:我们再C语言看到的操作很简单,其实它在系统层面做了很多事情的
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  char buffer[64];
  const char* msg = "hello world\n";
  int i = 0;
  for(i = 0;i < 3; i++)
  {
    write(fd, msg, strlen(msg));
  }
  close(fd);
  return 0;
}

 

3.3 read()

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
  umask(0);
  int fd = open("log.txt",O_RDONLY); //只读
  if(fd < 0) //打开失败
  {
    perror("open");
    return 1;
  }
  char buffer[64];
  memset(buffer,'\0',sizeof(buffer)); //把buffer初始化为0
  read(fd, buffer, sizeof(buffer)); //从文件描述符里面读数据到buffer,再打印buffer
  printf("%s",buffer);
  
  printf("open success,fd:%d\n",fd);
  close(fd);
  return 0;
} 

read和write函数的使用比较简单,最主要的还是要搞清楚open第二个参数,要传什么,传多少,怎么传,都要有一定认识 

四,文件描述符

4.1 是什么?

最简单的说法就是:“文件描述符就是open接口的返回值”。我们可以先看下man文档对open返回值的介绍:

上面介绍接口时也提到了文件描述符但是没有细讲,只说了它在代码中的作用与FILE*类似,用法也确实和FILE*类似。

4.2 一些现象

在深入探讨文件描述符前我们先看几个现象:

如下代码

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
  umask(0);
  int fd1 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd1:%d\n",fd1);
  int fd2 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd2:%d\n",fd2);
  int fd3 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd3:%d\n",fd3);
  int fd4 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd4:%d\n",fd4);
  close(fd1);
  close(fd2);
  close(fd3);
  close(fd4);
  return 0;
}

再如下代码

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
  fprintf(stdout, "hello stdout\n");
  const char *s = "hello\n";
  write(1,s,strlen(s));
  return 0;
}

 再再如下代码

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
  char input[16];
  ssize_t s = read(0, input, sizeof(input)); //read的返回值代表成功读取到的字符数
  if(s > 0) //表示读取成功
  {
    input[s] = '\0'; 
    //read的返回值表示读取的字符数,假设我输入12345,下标就是0-4,那么s就是5,我们把5设置成\0表示读取结束
  }
  return 0;
}

可以发现,我们在代码中并未实现任何打印语句,但是我们往键盘输入时,值也给我们打印在了屏幕上 。为什么会有这些现象呢,接下来就开始详细介绍fd文件描述符

4.3 理解fd

那么fd到底是什么呢?

①进程要访问文件,必须先打开文件,并且一个进程可以打开多个文件,所以,一般而言,进程与打开的文件的数量关系:1 :n。

②那么一个文件要被访问,也要先加载到内存中才能访问,那么多个进程一定就会打开多个文件,所以系统中也会存在大佬被打开的文件,那么OS就要把这些文件“先描述,再组织”,OS为了管理多个被打开的文件,要先构建struct_file结构体,打开文件时先创建struct_file对象,充当一个被打开的文件,这个结构体最想中包含了一个被打开的文件几乎所有的内容,当这个结构体数量很多时,用双链表组织起来,之后的管理也就是对链表的操作了。

③为了区分打开的文件与特定进程的对应关系,进程的PCB里面有一个指针指向struct_file结构体,struct_file里面有一个struct_file* fd_array指针数组,这个数组有很多struct_file*指针,每个指针指向一个特定的文件,而因为是数组,所以通过下标来访问fd_array中各指针指向的文件,这个下标我们就叫做文件描述符,它们的关系简单来说就是这样的:

④当进程调用open打开log.txt文件时,先将文件从磁盘加载到内存,然后构建对应的struct_file结构体并将其插入到链表,然后将该结构体的地址尾插入到fd_array指针数组中,最后把其在fd_array数组中下标位置返回给open,用户就拿到了log.txt的文件描述符了。

4.4 fd与FILE的关系

简单来说,就是FILE包含fd,因为FILE是C语言的,fd是系统层面的,C语言封装系统调用,那么FILE就封装了fd喽

为什么FILE要封装fd呢?因为系统只认fd,不认FILE,而且C语言的各种函数其底层也是调用的系统接口,整个大致过程如下:

fwrite() --> FILE* --> fd -->write --> write(fd, ...) --> 自己执行操作系统内部的write方法 -->能找到进程的task_struct --> files_struct --> fd_array[] -->fd_array[fd] -->struct_file --> 找到内存文件 --> 执行代码中的文件操作

4.5 fd分配规则

前面我们依次直接打印文件描述符的时候,是直接从3开始打印的,012去哪里了呢?

前面讲过键盘和显示器对于操作系统来说也是文件,而前面又讲过进程运行时会默认打开三个输入输出流,这里正好对应上了,012这三个文件描述符就是被分配给了标准输入,标准输出和标准错误这三个文件,标准输入对应键盘,标准输出和标准错误都对应屏幕,原因后面讲。

所以之前从3开始打印就是因为012已经被占用了,所以从3开始打印,那么我先把文件描述符0关闭再去打开文件会变成什么样呢?如下代码:

int main()
{
  umask(0);
  close(0); //先把标准输入关掉
  int fd1 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd1:%d\n",fd1);
  int fd2 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd2:%d\n",fd2);
  int fd3 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd3:%d\n",fd3);
  int fd4 = open("log.txt",O_WRONLY | O_CREAT | O_APPEND, 0666);
  printf("open success,fd4:%d\n",fd4);
  close(fd1);
  close(fd2);
  close(fd3);
  close(fd4);
  return 0;
}

 

同样的,如果我们再先把标准输入和标准错误关了,会有什么现象呢?

 

所以文件描述符的分配规则是:从最小且没有被使用的fd_array数组下标开始进行分配的 

五,重定向

5.1 重定向原理

有了对文件描述符的详细理解后,然后就是对文件描述符的运用了。在之前的章节讲过重定向可以将指定的数据定向写入到一个文件当中,比如下面这个就是把hello world追加写入log.txt :

echo 'hello world' >> log.txt

Linux是C语言写的,各种命令其实就是设计者写好的存在特定路径下的C语言编译好的可执行程序,所以重定向也是通过C语言实现的,所以我们也可以用重定向的逻辑对文件描述符fd进行操作,使其满足我们的需求,请看后面代码实践。 

5.2 输出重定向

输出重定向的原理:进程运行时默认打开3各文件,就标准输入,标准输出,标准错误,我们最开始把标准输出关掉就是close(1),但是我们只是将进程与1号文件描述符的关联关系去掉了,然后当我们打开log.txt时,fd的分配规则会给log.txt分配1,但是前面也讲过,操作系统只认fd,所以我们fprintf往stdout也就是标准输出写文件时,那么本来应该输出到屏幕上的内容给我写到log.txt里去了,如下代码:

#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/fcntl.h>

int main()
{
  close(1);
  int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  printf("fd:%d\n",fd); //本来应该打印到屏幕上的,最后写到log.txt里了
  printf("fd:%d\n",fd);
  fprintf(stdout, "hello fprintf\n"); //往标准输出打印,最后往log.txt写数据了
  const char *s = "hello fwrite\n";
  fwrite(s, strlen(s), 1, stdout); //也是往log.txt写数据了
  fflush(stdout); //刷新缓冲区,后面再讲
  close(fd);
  return 0;
}

 

C语言实现的fprintf和fwrite函数都会有一个缓冲区,所以C语言的数据不是立马写入到文件里,而是写到了C语言的缓冲区当中,所以最后要用fflush刷新缓冲区,才会把数据从缓冲区刷新到文件里,缓冲区后面会详细讲解。 

5.3 输入重定向

输出重定向是先关掉标准输出,那么输入重定向一样的就是关掉标准输入喽。

fgets函数是从键盘上获取数据,键盘上stdin标准输入,一开始我们close(0),在打开log.txt时,就会把log.txt的文件描述符搞为0,所以fgets本来是从键盘读数据的,最后就会变成从log.txt读数据了

如下代码:

int main()
{
  close(0); //一开始关掉标准输入
  int fd = open("log.txt", O_RDONLY); //只读打开
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  char buffer[64]; //缓冲区
  fgets(buffer, sizeof(buffer), stdin); //fgets从标准输入读数据,这里变成了从log.txt里读数据了
  printf("%s\n",buffer); //打印缓冲区内容
  return 0;
}

 

5.4 追加重定向

追加重定向也差不多,就是把标准输出关了,如下代码:

int main()
{
  close(1);
  //int fd = open("log.txt", O_WRONLY | O_TRUNC | O_CREAT); //这一行会把文件内容清空再写入
  int fd = open("log.txt", O_WRONLY | O_APPEND | O_CREAT);  //这一行就是追加
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  fprintf(stdout, "you can see me, success\n");
  return 0;
}

5.5 重定向系统调用dup2()

上面几个重定向的代码实现明显感觉太挫了,使用体验极差,又是关又是开。所以操作系统提供了一种接口可以一步到位完成重定向,就是dup2(),可以man dup2查看文档:

 根据文档可知:dup2是将oldfd拷贝给newfd,使它们两个最终要和oldfd一样。假设我打开log.txt,它的fd为3,然后我们要输出重定向,我们就要dup2(3, 1); 表示把3的内容拷贝到1里面,这样就把输出重定向的文件描述符变为了log.txt的文件描述符,最后如果要往标准输出打印的数据就会被写入到log.txt里,如下代码:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/stat.h>
int main(int argc, char *argv[])
{
  if(argc != 2)
  {
    return 2;
  }
  //int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); //这是输入重定向 
  int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);   //这是追加重定向
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  dup2(fd, 1);  
  fprintf(stdout, "%s\n",argv[1]);

  close(fd);  
  //如果我们注释掉close(fd)这一条时,数据有时候刷新不出来,这和我们后面要讲的缓冲区有关系
  return 0;
}

 

六,FILE缓冲区

6.1 关于缓冲区

关于缓冲区我们要探讨的问题主要是:是什么,谁提供的,为什么要有缓冲区

从本质来看,缓冲区就是一段内存空间

列个场景:

两所大学,湘潭大学与北京大学,然后我在湘潭大学有一本书,我想把这本书送给我在北京大学的盆友。我有下面两种方式

我可以选择从湘大开始走路,骑车或者打车将书本亲手交给北京大学的朋友,这种叫做“写透模式(WT)”,这种模式缺点很明显:成本高,而且效率十分低下

所以学一般会有一个叫做“菜鸟驿站”的建筑,我们只需要把书打包好交给菜鸟驿站,填写好地址就可以让顺丰快递帮我把书本发送到北京大学菜鸟驿站,这种模式叫做“写回模式”,优点就是:快速并且成本低

这个场景里的菜鸟驿站,我们就可以称为缓冲区

缓冲区的意义就是:提高用户响应速度 

缓冲区的刷新策略之前也讲过,分为两种:

①一般情况:1,立即刷新  2,行刷新(把\n前面的刷新)  3,满刷新(数据把缓冲区写满了才刷新)

②特殊情况:1,用户强转刷新 --> fflush()  2,进程退出时刷新

6.2 深入理解缓冲区刷新策略

如下代码:

#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<fcntl.h>
#include<sys/stat.h>

int main()
{
  //C语言提供的
  printf("hello world\n");
  fprintf(stdout, "hello fprintf\n");
  const char *s = "hello fputs\n";
  fputs(s, stdout);

  //OS提供的
  const char *ss = "hello write\n";
  write(1, ss ,strlen(ss));

  fork(); //创建子进程
  return 0;
}

 同样的一个程序,往直接运行往标准输出打印时打印的是正常的四句话,但是往文件重定向写入时变成了7句话,多了三句话,为什么?一定和fork()有关。

先再谈谈缓冲区的认识

①行缓冲的设备文件一般是显示器,采用全缓冲的文件一般是磁盘文件(比如我在txt文档写字就只是显示在显示器上,至于Ctrl+s才会真正保存到磁盘上),实际上,所有的设备都“倾向于”全缓冲,因为缓冲区满了才刷新,意味着更少的IO次数(更少的外设访问),这样就能提高整机效率

②和外设进程IO的时候,数据量大小不是主要矛盾,你的外设预备IO的过程才是最耗时间的(列个场景:家里有各穷亲戚找你借钱,花了两天时间找你谈判,最后借了10块钱。所以可以看见这里沟通时间非常长,但是我们微信转账可能就几秒,所以IO的准备是需要很长时间的,所以如果这个亲戚经常找你借钱,依次借10块,花费的时间成本太高了。于是我干脆一口气给他100块钱,借此联想到全缓冲,就直接减少了大量的IO次数,提高整机效率)

③其它的刷新策略是结合具体情况做的“妥协”,比如显示器是给用户看的,一方面要照顾效率,一方面又要照顾用户体验,所以才有行刷新,因为它符合人的阅读习惯,(除了fflush)。

 回答上面代码的情况

①根据两次打印的结果我们可以看到C语言的函数是打印了两次的,系统调用的只打印了一次,fork()之前的打印函数已经执行过了,但是不代表数据已经从缓冲区刷新出来了,而且4条信息变成7条一定是做了拷贝(写时拷贝)

②如果有所谓的缓冲区,那么我们谈的“缓冲区”一定不是OS提供的,因为系统调用的只打印了一次,那么只能是C语言提供的了。上面的代码调用fputs把数据写到了C标准库的缓冲区中,然后C标准库再调用write系统接口才把数据搞到内核中;但是如果进程直接调用write就不会经过缓冲区了。

如果不做重定向,那么就是往标准输出打印,此时刷新策略就是行刷新,每写一次就刷新到屏幕上,因为要照顾用户体验;然后最后执行到fork()的时候,一定是前面的函数执行完了并且缓冲区也刷新玩了,此时fork()就无意义

如果做重定向,那么就是要往磁盘文件写数据了,刷新策略变成了全缓冲,此时我们函数里面的‘ \n ’ 就无意义了,最后fork()的时候,函数执行完了,但是数据还在当前进程对应的C标准库的缓冲区中没有刷新,缓冲区是为进程服务的,所以缓冲区里的数据属于父进程,然后fork创建子进程后,子进程写时拷贝父进程的数据,也就把父进程缓冲区的数据也拷贝了一份,接着就是父子进程各自退出,刚好符合缓冲区刷新的特殊情况:进程退出强转刷新,于是C标准库缓冲区的数据刷新到文件时,文件里就有了两份数据

6.3 缓冲区在哪里呢?

上面的代码,如果我们再fork()前fflush强制刷新缓冲区,重定向就不会出现两份数据了。那么fflush是如何找到缓冲区并刷新它的呢?

C语言的fopen函数调用后会返回一个FILE指针,FILE也是一个结构体,前面我们直到了FILE包含了fd,但不只是包含了fd,还包含了该文件fd对应的语言层的缓冲区结构,如下:

grep -ER 'struct _IO_FILE {' /usr/include/

 再打开这个文件往下拉,最终我们在第246行看到了这个结构:

所以这个缓冲区远在天边,实则近在眼前,就在FILE结构体里面。所以C++中cout,cin是一个类,这个类也包含了fd和buffer,我们使用cout输出时调用了它的operator <<()重载,然后它就把数据拷贝到它的缓冲区里再进行的操作

(附加:write写数据到磁盘上也不是直接写的,OS维护的struct_file也有自己的内核缓冲区,write写数据是拷贝到内核的缓冲区中,这部分操作用户0感知) 

7.4 C语言文件接口拟实现

预备实现:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>

#define NUM 1024

struct MyFILE_ {
  int fd;
  char buffer[NUM];
  int end; //当前缓冲区的结尾
  
};

typedef struct MyFILE_ MyFILE; //要typedef是因为C语言中结构体关键字struct不能省略,C++可以
//为什么C语言中所有的接口都要带FILE*呢? 因为无论是写还是读还是刷新,对应的文件描述符和缓冲区都在FILE*里面

fopen拟实现

MyFILE* fopen_ (const char* pathname,const char* mode)
{
  assert(pathname);
  assert(mode); //两个参数都不能为空
  MyFILE *fp = NULL;

  if(strcmp(mode, "r") == 0)
  {
    
  }
  else if(strcmp(mode, "r+") == 0)
  {

  }
  else if(strcmp(mode, "w") == 0)
  {
    int fd = open(pathname, O_WRONLY | O_TRUNC | O_CREAT, 0666);
    if(fd > 0) //打开成功才做下面的操作,打开失败的话直接跳转到最后,什么都不做
    {
      fp = (MyFILE*)malloc(sizeof(MyFILE)); //先开一段空间
      memset(fp, 0, sizeof(MyFILE));
      fp->fd = fd;
    }
  }
  else if(strcmp(mode, "w+") == 0) /后面的都是差不多的实现
  {

  }
  else if(strcmp(mode, "a") == 0)
  {

  }
  else if(strcmp(mode, "a+") == 0)
  {

  }
  else
  {

  }
  return fp;
}

fputs拟实现

 

void fputs_ (const char* message, MyFILE *fp)
{
  assert(message && fp);
  //向缓冲区进行写入
  strcpy(fp->buffer+fp->end, message); //假如输入abcde\0  (strcpy拷贝后会在后面默认添加\0,不用担心\0被覆盖的问题)
  fp->end += strlen(message);          //使end指向最后的\0,下次再输入的时候从\0位置往后拷贝

  //for debug
  //printf("%s\n",fp->buffer);
  
  //fputs是C标准库实现的,所以用户通过执行C标准库中的代码逻辑,来完成刷新动作
  //因为C提供了缓冲区,那么我们通过策略,减少IO的次数(不是数据量),提高效率
  if(fp->fd == 0) //标准输入
  {
    
  }
  else if(fp->fd == 1) //标准输出
  {
    if(fp->buffer[fp->end-1] == '\n') //行刷新,将\n之前的数据都刷新出去
    {
      write(fp->fd, fp->buffer, fp->end);
      fp->end = 0;
    }
  }
  else if(fp->fd == 2) //标准错误
  {

  }
  else //其他文件
  {

  }
}

fflush拟实现 

void fflush_ (MyFILE* fp)
{
  assert(fp);
  if(fp->end != 0) //end不为0时表示缓冲区的有数据,有数据才值得刷新
  {
    write(fp->fd, fp->buffer, fp->end); //把数据写到了内核里面,暂被认为刷新了
    syncfs(fp->fd); //也是系统接口,作用是将数据刷新到文件描述符对应的文件里
  }
}

fclose拟实现

 

void fclose_ (MyFILE *fp)
{
  assert(fp);
  fflush_(fp);   //进程退出时刷新缓冲区
  close(fp->fd); //系统调用 
  free(fp);   //释放malloc出来的空间
}

测试 :

int main()
{
  MyFILE *fp = fopen_("./log.txt","w");
  if (fp == NULL)
  {
    printf("open file error\n");
    return 1;
  }
  fputs_("one: hello world", fp);
  fputs_("two: hello world", fp);
  fputs_("three: hello world", fp);
  fputs_("four: hello world", fp);
  fclose_(fp);
}

七,一些问题解答

7.1 文件是进程创建的吗?

文件本质是程序运行起来后用操作系统的接口创建的,所以本质上文件是操作系统创建的,单独的说文件是进程创建的这种说法没错,但不全面和准确

7.2 重定向时不fflush会发生什么?

如下代码:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>

int main()
{
  close(1);
  int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
  if(fd < 0)
  {
    perror("open");
    return 1;
  }
  printf("hello world:%d\n",fd);
  //fflush(fd);
  close(fd);
  
  return 0;
}

一开始我们把标准输出关掉,printf就会把数据写到log.txt里去,printf时数据会赞数存在stdout的缓冲区里,但是我们先把文件描述符关掉了,也就是把进程和log.txt的关联关系断掉了,这时候数据就无法刷新了 

7.3 标准输出和标准错误有什么关系

标准输出stdout对应的文件是显示器,标准错误stderr对应的文件也是显示器,那么它们有啥区别呢?先如下代码:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
#include<iostream>
#include<unistd.h>

int main()
{
  printf("hello printf -> 1\n"); //stdout -> 1
  fprintf(stdout, "hello fprintf -> 1\n");
  perror("hello perror -> 2"); //stderr -> 2

  const char* s1 = "hello write 1\n";
  write(1, s1, strlen(s1));
  
  const char* s2 = "hello write 2\n";
  write(2, s2, strlen(s2));

  std::cout << "hello cout -> 1" <<std::endl; //cout -> 1
  std::cerr << "hello cerr -> 2" <<std::endl; //cerr -> 2
  return 0;
}

 

 可以看出,1和2文件描述符都对应着显示器文件,但是是两个不同的显示器文件,可以认为是同一个显示器文件被打开了两次。

C语言提供stdout和stderr的意义:一般而言如果程序运行有问题的话,建议使用stderr和cerr来打印,因为它们打印的结果和我们标准输出的结果是分离得,不会互相影响,如果是常规的文本内容,我们建议进行cour和stdout打印。

(./test > log.txt 2 ? &2 这个就是把fd为1的地址拷贝一份给2,然后2也指向1的文件了)

7.4 重新理解“一切皆文件”

①这句话是Linux的设计哲学,体现在操作系统的软件设计层面。Linux是C语言写的,如何用C语言实现面向对象甚至是运行时多态呢?

②C++类中包含:1,成员属性  2,成员方法  那C语言有没有能够把一种或多种相同的属性放在一起呢?

③C语言的struct算数所有面向对象于洋的起点,算数老祖宗级别的类型,但是传统的struct里面只能包含成员属性,不能包含函数

④所以要想让struct里面包含成员方法,就可以用:函数指针 --> 类型名(*函数指针名)(函数参数),这样通过函数指针就可以把struct封装成一个类

⑤底层不同的硬件,一定对应的是不同的操作方法(比如访问声卡的方法不能访问显卡),但是你像磁盘,显示器,键盘等等外设都可以是read读,write写和I/O操作。所以所有的设备都可以有自己的read和write,但是代码的实现一定是不一样的

⑥那么如何把所有的外设统一规划起来呢?

每个外设都有自己的read和write函数,比如我今天要打开磁盘,创建磁盘的struct_file,然后结构体里的两个函数指针指向磁盘的read和write函数,然后每个外设都这样搞,最后把这些struct_file“先描述,再组织”管理起来,这之后,在代码层面上就没有硬件之前的差别了,这样看待所有文件的方式,都统一成为了struct_file,这个东西是OS维护的,而外设以及它的函数就是“驱动开发”(这个就类似于嵌入式开发)

⑦把上面的概念结合起来,就有了“Linux下,一切皆文件”的说法

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值