【Linux】文件操作/文件描述符/重定向原理/缓冲区

目录

一.文件的概念

1.什么是内存级文件?

2.什么是磁盘级文件?

3.文件IO的过程

4.linux下, 一切皆文件

二.文件操作(C语言接口)

1.语言级文件接口与系统级文件接口

1).什么是语言级别的文件接口?

2).为什么要有语言级别文件接口, 直接用系统接口不好吗?

3).系统级别文件接口缺点很多, 但我们为什么还要学习呢?

2.什么叫做当前路径

3.C语言文件接口的使用

1).fopen -- 打开文件

2).fwrite -- 输出到文件

3).fread -- 从文件输入

4).fclose -- 关闭文件

三.文件操作(系统接口)

1.C语言接口对应系统接口

2.标记位的传入方式(open系统接口)

3.系统文件接口的使用

1).write

2).read 

3).一个简易版的cat命令

四.文件描述符

1.用OS管理进程的方式 VS 进程管理文件的方式

2.文件如何被组织? 文件描述符又是什么? 与组织这些文件有关系吗?

3.对比C语言中的FILE*与fd

4.用代码验证以上结论

验证一: 每个进程默认打开三个文件

验证二: 文件描述符的分配规则

验证三: stdout对应1号文件描述符

五.重定向

1.重定向的使用

2.重定向的基本原理

2.重定向的模拟实现(dup2系统调用)

六.linux一切皆文件

七.用户级缓冲区

1.对于缓冲区的认识

2.缓冲区的刷新策略

3.如何证明语言级缓冲区的存在

1).用户级缓冲区被封装在哪里

2).代码验证用户级缓冲区的存在

4.自己设计一个用户级缓冲区

八.标准错误

1.进程会默认打开三个文件, fd分别对应0, 1, 2

2.如何将正确信息与错误信息打印到不同文件

3.如何将正确信息与错误信息打印到同一文件

4.模拟实现perror


一.文件的概念

文件 = 内容 + 属性

想要对文件进行操作, 要么对内容, 要么对属性

1.什么是内存级文件?

文件是存放在磁盘上的, 只有操作系统有权限来操作文件, 那么如果做为普通用户的我们, 如何去操作文件呢? 所以, 如果想要让普通用户访问文件, 操作系统必须提供相应的接口, 普通用户通过编写程序的方式, 让程序执行起来加载到内存成为进程, 通过进程去调用文件操作接口, 再去通过系统文件接口操作文件, 我们就通过进程间接的操作了文件, 所以, 程序被加载到内存成为进程, 通过进程打开的文件也从硬盘加载到了内存, 这种加载到内存的文件就被称为内存级文件

2.什么是磁盘级文件?

所有的文件一般情况下都是存储在磁盘上的, 文件被操作则就会被加载到内存, 相反, 那些没有被使用或打开的文件就静静的呆在磁盘上, 这种文件称为磁盘级文件

3.文件IO的过程

IO的含义: input/output, 输入/输出, 这里所讲的输入与输出, 是站在内存角度来看待的

例如: scanf/fread/fgets是一个输入的过程, 这是要把某文件的数据, 输入到内存中

        printf/fwrite/fputs是一个输出的过程, 这是要把内存中的数据, 输出到某文件中

一次文件IO的过程:

普通文件 -- fread -- 内存 -- fwrite -- 另一个普通文件

4.linux下, 一切皆文件

站在系统的角度, 只要是能进行输入输出的设备就被称之为文件

linux下, 一切皆文件: 普通的磁盘上的文件是文件, 显示器, 键盘, 网卡, 声卡, 磁盘几乎所有的外设, 他们都至少可以拥有输入输出一种功能, 都可以称之为文件

以上只是一些基础概念, 在本篇博客的中后期, 会深度理解什么是: linux下, 一切皆文件

二.文件操作(C语言接口)

1.语言级文件接口与系统级文件接口

1).什么是语言级别的文件接口?

每一个操作系统有一套独属于自己的文件操作接口(系统调用), 不同的语言基于这套系统文件接口进行了一定程度封装, 来做为每种语言自己的文件操作接口

2).为什么要有语言级别文件接口, 直接用系统接口不好吗?

总体来说, 有两大原因

1.系统接口比较复杂, 使用成本高, 每种语言都进行一定程度封装, 来简化文件操作接口, 从而降低使用成本

2.系统接口不具备移植性, 不具备跨平台性, 每套操作系统都有独属于自己的系统文件操作接口, 如果使用系统接口的话, linux下的程序就不能在windows环境下运行了, 其他OS也同理, 那么为了解决这个问题, 语言级别的封装就显得尤为重要, 每种语言会把所有OS的系统文件接口全部封装一遍, 再通过条件编译的方式来控制在特定的OS下使用封装好的属于这个OS的语言级别文件接口, 这样看来, 有了语言级别的文件操作接口, 我就不必使用系统接口, 直接使用语言接口, 支持了跨平台性, 一份代码可以在多个OS多个平台下运行

3).系统级别文件接口缺点很多, 但我们为什么还要学习呢?

从使用角度来看, 在实际开发中为了保证可移植性, 跨平台性, 我们一定会使用语言给我们封装好的文件接口, 但是在学习阶段, 仍然需要去深度理解和挖掘系统接口的设计方式, 理解了底层的系统文件接口, 再去学习语言级别文件接口就会清楚很多了, 不管是哪种语言便都很容易入手

2.什么叫做当前路径

FILE* fp = fopen("log.txt", "w");

如果以这种方式打开一个文件, 在打开文件时, 只写出文件名, 并不带路径, 那么系统会默认为当前路径去操作这个文件

也就是说, 如果这个文件不存在, 该文件会被创建, 被创建到哪里呢? 既然系统默认为当前路径去操作这个文件, 那么当然是创建到当前路径

我们看到的现象是: 我们的程序写在哪个路径下, 文件就被创建在哪个路径下, 但其实这并不正确, 这并不是相对路径! 

如果我将生成的可执行程序mybin移动到上一路径, 并且在上一路径的下一路径去执行这个mybin, log.txt会被创建到哪里呢?

结论是: 如果我将mybin剪切到上一路径, 我在上一路径的下一路径去执行这个mybin, 文件会被创建到我所执行程序的那个路径下

什么是当前路径: 当一个进程运行起来的时候, 每个进程都会记录自己当前所处的工作路径!

那么我们如何去查看这个工作路径呢? 在/proc目录, 会存有每一个当前正在运行的进程, 去/proc查看对应进程即可

我们现在给上面的代码添加一个死循环, 来观察运行起来的进程的当前路径

依旧采用将生成的可执行程序拷贝到上一路径, 然后在当前路径去运行的方式, 来观察它的"相对路径"

在proc目录下观察到的该进程中的cwd即是该进程的工作路径也就是该进程的相对路径!

我们可以再去上一路径执行一次, 再来观察一次mybin进程的相对路径

可以得出结论了: 正在运行的进程的相对路径取决于我们在哪里去运行这个可执行程序, 与可执行文件在哪, 程序在哪本身并没有关系

3.C语言文件接口的使用

1).fopen -- 打开文件

返回值FILE*, 返回一个结构体指针

第一次参数path, 文件名及文件所在路径, 如果只写文件名, 默认为进程的当前路径

第二个参数mode, 打开文件的方式

几种打开文件的方式:

注意: w系列的打开文件的方式, 如果文件不存在则会新建文件, 如果文件存在则会先清空文件, 再向文件中写入内容 

输入输出函数

2).fwrite -- 输出到文件

值得一提的是: 在调用fwrite函数, 传第二个参数时, 意思是要写入多少个字符

'\0'是字符串结束标志, 是C语言规定的, 而OS是不需要遵守的, 所以fwrite最终还是要去调用系统接口的, 在传入第二个参数时strlen(str)不能+1, 不能把'\0'也带上! 在向文件写入时, 只可写入有效字符

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

int main()
{
  FILE* fp = fopen("log.txt", "w");
  if(fp == NULL)
  {
    perror("fopen:");
    return 1;
  }

  const char* str1 = "hello fwrite\n";
  const char* str2 = "hello fputs\n";
  const char* str3 = "hello fprintf\n";

  fwrite(str1, strlen(str1), 1, fp); 
  fputs(str2, fp);
  fprintf(fp, "%s", str3);

  fclose(fp); 
  return 0;
}

所以, 如果我们以w方式打开一个文件, 并且什么都不写入, 那么这个文件内的内容就全部被清空了

同样, 如果我们输出重定向一个文件, 但并不向文件内写入任何内容, 文件也是被清空了

3).fread -- 从文件输入

#include<stdio.h>

int main()
{
  FILE* fp = fopen("log.txt", "r"); 
  if(fp == NULL)
  {
    perror("fopen: ");
    return 2;
  }
  //这里可以不需要对buffer全部初始化为0
  //因为读取到文件内容后,会自动在字符串结尾添加'\0'
  char buffer1[64];
  char buffer2[64];
  char buffer3[64];
  fread(buffer1, sizeof(buffer1), 1, fp);
  fseek(fp, 0, SEEK_SET);//将指针归位到初始位置
  fgets(buffer2, sizeof(buffer2), fp);
  fscanf(fp, "%[^\n]s", buffer3);//fscanf读取到'\n'结束

  printf("buffer1: %s", buffer1);
  printf("buffer2: %s", buffer2);
  printf("buffer3: %s\n", buffer3);

  fclose(fp);

  return 0;
}

从文件中读字符串时, 不需要关心'\0', 因为'\0'是字符串的结束标志这是C语言的规定, 与文件无关, 所以文件读取到C语言进程中时会自动在字符串末尾添加'\0'

4).fclose -- 关闭文件

在一个程序中, 只要打开了一个文件, 必须对应的要在对这个文件操作结束时, 关闭这个文件

为什么一定要关闭文件

1.为了避免内存泄漏, 如果一个进程打开了文件而操作结束后却不关闭这个文件, 在进程还未结束之前, 占用了资源却不再去使用, 这就属于内存泄漏

2.fclose底层封装了close, 在C语言级别的fclose在封装时, 也将C语言库级别的缓冲区封装进去了, 所以在调用fclose不仅仅是关闭文件, 也刷新了C语言库级别的缓冲区, 将数据刷新到内核级缓冲区, 再由调用的close从内核级缓冲区刷新到内存

三.文件操作(系统接口)

1.C语言接口对应系统接口

C语言文件接口 vs 系统文件接口 (一一对应关系)

        fopen                  open

        fwrite                   write

        fread                   read

        fclose                  close

所有的语言级别的接口, 底层都一定封装了系统接口, 为了降低使用成本与支持平台之间的兼容性

2.标记位的传入方式(open系统接口)

先了解一下open系统接口

注: C语言也可以支持类似C++中的函数重载, 功能大体类似但并不是函数重载

第一个参数: 与fopen的第一个参数相同, 路径 + 文件名, 如果不带路径, 默认就是当前路径(关于什么是当前路径在上面已经给出解释与验证)

第二个参数: 一个可以标记多个标记位的有符号整数

常用的标记: O_CREAT(若文件不存在则创建), O_RDONLY(只读), O_WRONLY(只写), O_RDWR(读写), O_TRUNC(清空), O_APPEND(追加)

例如在语言层面上的不同的打开方式, fopen("log.txt", "w"); 实际在封装时, w就代表了O_CREAT|O_WRONLY|O_TRUNC

这些标记全部为大写, 且全部都是宏定义

重点解析: 如何使用一个int类型的数来标记多个标记位

如何标记多个状态?

一个int类型整数占4Bytes, 一共是32bit, 每一个bit可以标记一种状态(0或1), 那么32个bit即最多可标记32种状态

如何传入多个状态?

每种状态分别占用不同的位, 然后将这些状态用位运算或("|")到一起, 就可以将多种状态传入到flags中了

如果验证某种状态是否存在?

用flags与("&")上那个状态对应的数值, 若结果不变, 因为只有当1&1=1, 所以说明该状态是存在的

用一段简易代码来说明以上原理

#include<stdio.h>

#define A 0x1
#define B 0x2
#define C 0x4

void func(int flags)
{
  printf("flags: %x\n", flags);
  if((flags & A) == A)
  {
    printf("A is ok\n");
  }
  if((flags & B) == B)
  {
    printf("B is ok\n");
  }
  if((flags & C) == C)
  {
    printf("C is ok\n");
  }
}

int main()
{
  func(A | B | C);
  func(A | B);
  func(B | C);
  return 0;
}

第三个参数: 传入权限, 只有当新建文件时, 才需要传入这个权限, 例如: 0666, 0644

当文件创建完成时, 这个mode并不是文件最终的权限, mode & (~umask) 才是新创建出的文件的最终权限, 所以如果我们想完全控制新建文件的权限, 需要在程序内部使用umask()来设置权限掩码, 创建出来后的权限仍是 mode & (~umask) 只不过这时这个umask是我们自己设置的

用程序验证以上结论:

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
  umask(0000);
  //使用系统接口操作文件
  int fd = open("log.txt", O_CREAT|O_WRONLY|O_TRUNC, 0666); 
  if(fd < 0)//创建或打开文件失败
  {
    perror("open: ");
    return 2;
  }
  //...
  close(fd);
  return 0;
}

4.返回值: 返回一个整数fd, 若打开或创建文件失败则返回-1

对于返回值是什么, fd是文件描述符, 也是本篇最重要的内容, 在本篇文章后面会重点详谈 

3.系统文件接口的使用

1).write

#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
  umask(0000);
  //使用系统接口操作文件
  int fd = open("log.txt", O_CREAT|O_WRONLY|O_TRUNC, 0666); 
  if(fd < 0)//创建或打开文件失败
  {
    perror("open: ");
    return 2;
  }
  const char* str1 = "hello write\n";
  
  write(fd, str1, strlen(str1));

  close(fd);
  return 0;
}

2).read 

#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
  //使用系统接口操作文件
  int fd = open("log.txt", O_RDONLY); 
  if(fd < 0)//创建或打开文件失败
  {
    perror("open: ");
    return 2;
  }
  char buffer[64];
  
  read(fd, buffer, sizeof(buffer));

  printf("%s", buffer);

  close(fd);
  return 0;
}

3).一个简易版的cat命令

#include<stdio.h>

int main(int argc, char* argv[])
{
  if(argc != 2)
  {
    printf("输入参数有误, 必须输入一个参数");
    return 3;
  }
  FILE* fp = fopen(argv[1], "r");
  if(fp == NULL)
  {
    perror("fopen: ");
    return 2;
  }
  char buffer[64];
  fread(buffer, sizeof(buffer), 1, fp);
  printf("%s", buffer);
  fclose(fp);
  return 0;
}

四.文件描述符

1.用OS管理进程的方式 VS 进程管理文件的方式

一台启动着的电脑内会有很多进程, 操作系统为能够正确且有序的调度和管理每一个进程, 就必须对进程进行管理

那么操作系统如何管理这么多进程呢? 先描述, 再组织. 先将进程抽象成一个个的task_struct(PCB), 然后再将这些结构体以链表的形式组织起来

一个正在执行的进程, 可以打开很多文件, 那么进程想要对文件进行读写就必须管理每一个文件

那么进程如何管理这么多文件呢? 先描述, 再组织. 先将文件抽象成一个个的file结构体, 然后再将这些结构体组织起来, file结构体内部几乎存储了对应的文件的全部内容!

2.文件如何被组织? 文件描述符又是什么? 与组织这些文件有关系吗?

对于系统接口, 每打开一个文件, 系统会返回一个int类型的值, 我们通常是这样写的int fd = open(...);

这个fd就是文件描述符, 每个进程为了管理好自己打开的全部文件, 就需要将这些file结构体组织起来

在进程中, 组织好这些file结构体的方式如下:

array指针数组的下标就是文件描述符!

array这个指针数组中的每一个元素存储的是指向文件(file结构体)的指针, 有了文件描述符就可以找到指针, 有了指针就可以找到指定文件

文件描述符的分配规则: 从数组下标值的最低处开始分配

每个进程默认打开三个文件: 标准输入, 标准输出, 标准错误, 这三个文件分别对应键盘, 显示器, 显示器

fd: 0 --- 标准输入 --- 键盘

fd: 1 --- 标准输出 --- 显示器

fd: 2 --- 标准错误 --- 显示器

在linux下, 一切皆文件, 只要是能读或写的设备都可以称之为文件, 所以像键盘, 显示器在linux的设计理念中, 也是文件

3.对比C语言中的FILE*与fd

在C语言中 标准输入 --- stdin, 标准输出 --- stdout, 标准错误 --- stderr

所以以fopen打开文件时, 例如: FILE* fp = fopen(...)

stdin, stdout, stderr也都是FILE*类型的指针

这个FILE*是一个结构体指针, FILE这个结构体内部也一定封装了fd, 因为操作系统必须通过fd去找到对应文件, 无论何种语言的封装, 最终一定要回归底层, 一定会调用系统调用, 想要调用系统调用就一定遵守操作系统的规则

也就是说在正常情况下, stdin内封装0描述符, stdout内封装1描述符, stderr内封装2描述符

4.用代码验证以上结论

验证一: 每个进程默认打开三个文件

首先观察到的现象是, 系统为我们新建的文件log.txt分配的文件描述符是3, 那么0, 1, 2去哪里了?

我们从0中读取后, 向1中打印

分别使用系统接口, 从0读入数据到buffer, 再将buffer的数据打印到1中, 对应的是从键盘输入数据后又打印到显示器上 

验证二: 文件描述符的分配规则

先关闭0号文件描述符, 在打开一个新建文件, 观察这个打开的新文件的文件描述符

关闭了0, 文件描述符从最小开始分配, 所以log.txt就分配到了0号文件描述符

验证三: stdout对应1号文件描述符

分别使用C语言接口向stdout打印, 系统接口向1号文件描述符打印

但由于标准输出和标准错误对应的都是显示器, 所以要将这些打印结果输出重定向到一个指定文件, 若文件中存在两次打印结果, 则验证成功

五.重定向

1.重定向的使用

1).输入重定向

原本应该从键盘读入的内容, 变为从文件中读

2).输出重定向

原本应该向显示器输出的内容, 变为向文件中输出

3).追加重定向

原本应该追加到显示器的内容, 变为追加到文件

4).文件内容拷贝

本质: 先做了一次输入重定向 --- cat从log.txt文件中读, 再将cat打印的内容做了一次输出重定向 --- cat输出到copy.txt文件中

2.重定向的基本原理

重定向的本质: 那标准输出举例, 原本应该输入到标准输出(显示器)的内容, 输出到了指定文件中

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

int main()
{
  //先关闭标准输出
  close(1);//stdout --- 1
  int fd = open("log.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
  if(fd < 0)
  {
    perror("open: ");
    return 2;
  }
  printf("log.txt分配到的fd: %d\n", fd);
  //向stdout中输出一些内容, 实际上printf内部默认指定向stdout输出
  fprintf(stdout, "hello OutputRedirection\n");

  fflush(stdout);//fflush是语言级别的接口, 在使用系统接口关闭文件描述符前, 需要刷新语言级别的缓冲区到指定文件
  close(fd);
  return 0;
}

一张图来说明以上代码都做了哪些事情, 简单来说这就是输出重定向

2.重定向的模拟实现(dup2系统调用)

但这是在明确知道了文件描述符的分配规则后, 才能够以这种方式来进行重定向操作, 在linux中的输入/输出/追加重定向并不是以这种形式, 而是使用dup2系统调用来实现的

dup2函数的使用: 传入两个int类型参数, 分别是两个fd

dup2会将以oldfd为下标的元素中的内容拷贝给以newfd为下标的元素中的内容

例如: int fd = open(...);

         dup2(fd, 1);

解释: 将fd下标中的内容拷贝到1下标中的内容中, 也就是将标准输出重定向到log.txt

 代码验证:

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

int main()
{
  int fd = open("log.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
  if(fd < 0)
  {
    perror("open: ");
    return 2;
  }
  
  dup2(fd, 1);

  printf("log.txt分配到的fd: %d\n", fd);
  //向stdout中输出一些内容, 实际上printf内部默认指定向stdout输出
  fprintf(stdout, "hello OutputRedirection\n");

  fflush(stdout);//fflush是语言级别的接口, 在使用系统接口关闭文件描述符前, 需要刷新语言级别的缓冲区到指定文件
  close(fd);
  return 0;
}

六.linux一切皆文件

在linux中, 一切皆文件

在宏观上, 所有能够支持读或写的设备, 全部都是文件

在微观上, 每一个能够支持读或写的设备, 底层都是不同的硬件, 其对应不同的操作方法

故在linux中, 把每一个支持读或写的设备, 抽象成一个文件, 即一个struct file结构体, 结构体内部封装有其自己的读或写的方法

注: linux内核使用C语言编写, 在C语言中struct结构体可以有变量也可以有"方法", 对比C++的类, C语言通过函数指针的形式支持了让结构体内拥有函数

在上层看来, 文件与多种硬件之间没有任何差别, 因为在OS看来, 它们都是一个个的struct file, 所以这些硬件读写的底层实现肯定是不一样的, 但是由于操作系统对硬件进行了封装(一切皆文件), 进程就可以通过操作系统来以操作文件的方式去操作硬件, 所以在上层看来, 这些硬件的调用方式没有区别, 但底层实现是截然不同的

七.用户级缓冲区

1.对于缓冲区的认识

缓冲区一般情况下分为两种:

用户级缓冲区 vs 内核级缓冲区

用户级缓冲区由语言提供 vs 内核级缓冲区由系统提供

缓冲区存在的意义: 为了减少IO次数, 更少次的外设访问, 提高机器效率

具体的提高效率的方式体现在了减少IO次数, 例如: 如果要写入100条数据, 如果没有缓冲区, 一共有100次IO, 如果有缓冲区, 且此时采用全缓冲的形式, 也就是将100条数据全部写入到缓冲区内, 缓冲区写满或程序退出时, 统一刷新缓冲区, 这样就只有1次IO, 提高效率具体不是体现在写入数据量的多少, 而是IO次数的多少, IO的时间消耗大, 主要是准备IO的时间相比之下比较费时

总结: 缓冲区分为两种, 缓冲区的存在为了减少IO提高效率, 且和外部设备访问时消耗的时间数据量不是主要矛盾, 而是准备IO的过程比较耗时

本篇重点讨论用户级缓冲区

2.缓冲区的刷新策略

缓冲区的刷新策略分为: 一般情况与特殊情况

一般情况下, 缓冲区有三大刷新策略

1).满刷新 --- 缓冲区被写满时才会刷新

2).行刷新 --- 遇到'\n'就刷新

3).立即刷新 --- 字面意思, 输入一个刷新一个, 立刻刷新

特殊情况下, 缓冲区多种刷新策略

1).程序退出时自动刷新

2).用户强制刷新

3).缓冲区的刷新策略是可以由我们自己, 根据不同的需求来实现自己的缓冲区刷新策略

一般的, 所有的设备都更倾向于满刷新, 因为这种刷新策略是相较于其他刷新策略而言IO次数最少的

但是, 由于需求不同, 并不是所有的设备都要用满刷新这种刷新方式

例如: 向显示器输出, 采用行刷新的刷新策略; 向文件中输出, 采用满刷新的刷新策略

3.如何证明语言级缓冲区的存在

1).用户级缓冲区被封装在哪里

用户级缓冲区是由语言提供的, 那么它就是一定存在于某语言库中

用C语言举例, 如果用户想要强制刷新缓冲区到指定文件, 就需要调用fflush, 这是语言级别的接口, 需要#include<stdio.h>

然而这个C语言接口fflush的参数只有一个FILE*类型的指针, 通过阅读本篇博客, 我们已经知道了FILE内部封装了fd, 是C语言对于系统级别接口的封装, 既然FILE是一个结构体, 那么结构体内部就一定封装了其他东西, 所以用户级缓冲区又是由语言提供, 且每个文件都会有自己的缓冲区, 那么FILE结构体内部不仅封装了文件描述符fd, 同时也封装了用户级缓冲区

2).代码验证用户级缓冲区的存在

这一块的理解需要有对fork创建子进程有很深刻的认识作为铺垫

fork详解, 传送入口: 

http://t.csdn.cn/oHVvi

http://t.csdn.cn/1Kmsk

#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
  int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
  if(fd < 0)
  {
    //打开or创建文件失败
    perror("open: "); 
    return 2;
  }
  //输出重定向
  dup2(fd, 1);
  
  //向显示器写入, 但由于已经做了输出重定向
  //所以向显示器写入的内容都写入到了文件中
  //既然最终写入到了文件中, 那么就要遵守文件的缓冲区刷新规则:满刷新
  
  //以下每个写入的字符串都带上'\n'这里可以演示不做输出重定向,直接向显示器写入,会发生不同的现象
  //系统接口
  const char* str1 = "hello write\n";
  write(1, str1, strlen(str1));

  //语言接口
  const char* str2 = "hello fwrite\n";
  const char* str3 = "hello fprintf\n";
  const char* str4 = "hello fputs\n";
  fwrite(str2, strlen(str2), 1, stdout);
  fprintf(stdout, "%s", str3);
  fputs(str4, stdout);
  
  //以上逻辑全部执行完毕之后,创建子进程,调用fork函数
  fork();

  return 0;
}

先回顾一下, 显示器的刷新策略 -- 行刷新, 文件的刷新策略 -- 满刷新

这里虽然是向显示器输出, 但是我们是在输出之前做了输出重定向的, 所以我们所看到的向显示器输出, 就变为向文件输出

缓冲区的刷新策略也自然就是满刷新

以上这段代码, 在fork函数调用之前, 是没有刷新缓冲区的, 也就是说此时缓冲区中的内容还存在

缓冲区是属于父进程的, 自然也就是属于父进程中的数据, 那在创建子进程时, 子进程会拷贝父进程的数据, 会连同打开的文件的缓冲区以写时拷贝的方式拷贝下来

当父子进程结束时, 一定会有一个进程先结束, 假设在这里是子进程先结束, 进程结束就会刷新子进程的缓冲区

重点: 缓冲区是子进程中的数据, 将缓冲区的内容刷新出去, 是一种修改操作(也可以理解为是一种写的操作), 此时父进程中的缓冲区发生拷贝, 复制了一份映射到了新的物理内存中, 当父进程再刷新时, 又将父进程中缓冲区的内容刷新出去了, 这就解释了为什么语言级别的接口会被打印两次

那么, 为什么系统接口write只打印了一次呢?

因为语言级别接口是要经过一层用户级缓冲区的, 也就是先向用户级缓冲区内输出, 而系统级别的接口直接向内核级缓冲区写入

并且语言级别的缓冲区底层也是要调用系统接口的, 例如fflush底层调用write将数据刷新出去

系统接口不会向用户级缓冲区进行写入, 所以也就解释了为什么只有write只会打印一次

所以, 经过以上的验证, 还可以说明一个问题: 尽量在向一个文件写入完毕之后手动刷新缓冲区, 避免出现奇怪现象 

4.自己设计一个用户级缓冲区

目的: 模拟实现文件操作接口,理解用户级缓冲区的存在,以及理解用户级缓冲区如何控制刷新策略

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

//模拟实现FILE,内部封装文件描述符与缓冲区
typedef struct MyFile
{
  int fd;//文件描述符
  char buffer[64];//缓冲区
  int end;//记录缓冲区中存储字符个数
}MyFile;

MyFile* MyFopen(const char* pathname, const char* 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_CREAT | O_TRUNC | O_WRONLY, 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
  {
    //do nothing
  }
  return fp;
}
int MyFflush(MyFile* fp);
int MyFputs(const char* str, MyFile* fp)
{
  //本质上是先写入到用户级缓冲区
  strcpy(fp->buffer+fp->end, str);
  fp->end += strlen(str);

  //还未刷新时
  //for debug
  //这里由于一会原本1号文件描述符已经关闭,后打开的是文件,所以为了验证打印到显示器上,就直接打印到标准错误上
  fprintf(stderr, "缓冲区中的内容: %s", fp->buffer);
  sleep(1);

  //根据不同的打开文件,采用对应的刷新策略
  if(fp->fd == 0)
  {
    //键盘的刷新策略
  }
  else if(fp->fd == 1)
  {
    //显示器的刷新策略
    //显示器 -- 行刷新 -- 即遇到'\n'就刷新
    if(fp->buffer[fp->end - 1] == '\n')
    {
      MyFflush(fp);
      fp->end = 0;
    }
  }
  else if(fp->fd == 2)
  {
    //显示器的刷新策略
  }
  else if(fp->fd == 3)
  {
    //一般情况下,新打开的文件的刷新策略
  }
  //else if(){...}
  else
  {

  }
  return 1;
}

int MyFflush(MyFile* fp)
{
  //刷新缓冲区
  if(fp->end != 0)
  {
    write(fp->fd, fp->buffer, strlen(fp->buffer));
    fp->end = 0;
    fsync(fp->fd);//将数据从内核级缓冲区刷新到磁盘(硬件)
  }
  return fp->end;
}

int MyClose(MyFile* fp)
{
  //刷新缓冲区
  MyFflush(fp);
  //关闭文件流
  close(fp->fd);
  return 0;
}

int main()
{
  //由于上面只实现了fd为1的缓冲区刷新策略,这里先关闭1,将新打开的文件分配到1号文件描述符
  close(1);
  MyFile* fp = MyFopen("log.txt", "w");
  if(fp == NULL)
  {
    perror("MyFopen: ");
    return 2;
  }
  //fp->fd == 1,采用显示器的行刷新策略
  MyFputs("hello world 1", fp);
  MyFputs("hello world 2\n", fp);
  MyFputs("hello world 3", fp);
  MyFputs("hello world 4\n", fp);
  
  MyClose(fp);
  return 0;
}

结论: 用C语言举例, 文件的用户级缓冲区是被封装在struct FILE中的, 文件用户级缓冲区的刷新策略是在fwrite/fputs/fprintf中实现的

补充一: fsync系统调用 -- 真正将数据写入到磁盘硬件上

补充二: 显示器采用的刷新策略是行刷新, 那么为什么我们在打字的时候, 打出去的每一个字我们都可以从显示器看到呢

当我们在输入是, 本质是进程从键盘文件读数据, 而我们从显示器上面看到了, 并且是立即刷新的刷新策略, 这是通过显示器回显的方式, 提供用户的使用体验, 那么也就是说同样的都是显示器文件, 却同时拥有两种不同的刷新策略, 当进程向显示器输出时一般采用行刷新, 当进程读取用户输入的数据而又通过显示器文件回显到显示器上, 采用立即刷新策略, 也就是说, 显示器或者是同一个文件可以同时具有不同的刷新策略

八.标准错误

1.进程会默认打开三个文件, fd分别对应0, 1, 2

0 --- 标准输入 --- 键盘

1 --- 标准输出 --- 显示器

2 --- 标准错误 --- 显示器

其中, 标准输出与标准错误对应的是同一个显示器文件, 如果一个进程会打印出正常信息(向1输出)与错误信息(向2输出), 最终都会输出到显示器中

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

int main()
{
  //C语言接口
  fprintf(stdout, "hello 1\n");  
  fprintf(stderr, "hello 2\n");

  //C库函数, 用来打印错误信息
  perror("hello perror 2: ");

  //系统接口
  const char* str = "hello write 1\n";
  write(1, str, strlen(str));
  const char* str2 = "hello write 2\n";
  write(2, str2, strlen(str2));

  return 0;
}

2.如何将正确信息与错误信息打印到不同文件

因为输出重定向只会重定向标准输出, 而标准错误仍然会打印到显示器上

以下, 分别将输出重定向到right.txt, 错误信息重定向到err.txt

./mybin > right.txt 2> err.txt

注意, 2和>之间没有空格, 语法规定

3.如何将正确信息与错误信息打印到同一文件

./mybin > all.txt 2>&1

结果: 将全部打印内容输出到all.txt中

原理: 先输出重定向, 使1文件描述符中的内容改变为all.txt对应的fd, 后把1文件描述符中的内容拷贝给2文件描述符, 使标准输出与标准错误都指向all.txt

原理展示图:

4.模拟实现perror

如果进程执行期间出现错误, 进程会设置一个errno全局变量记录错误码

需要包头文件: errno.h

perror内部实现配合着strerror函数

strerror会根据传入的errno错误码打印出错误信息

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

void MyPerror(const char* str)
{
  printf("%s:%s\n", str, strerror(errno));
}

int main()
{
  int fd = open("ForTest.txt", O_RDONLY);
  if(fd < 0)
  {
    MyPerror("open");
  }
  return 0;
}

 

  • 6
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值