【Linux文件操作的底层原理:文件描述符与缓冲区】

本节重点

  • 复习C文件IO相关操作

  • 认识文件相关系统调用接口

  • 认识文件描述符,理解重定向

  • 对比fd和FILE,理解系统调用和库函数的关系

铺垫概念

一、复习C文件IO相关操作

1.fputs函数和文件打开方式

先来段代码回顾C文件接口,先来写文件

#include <stdio.h>

int main()
{
  FILE* fp = fopen("./log.txt", "w");//以写方式打开
  if(fp == NULL)
  {
    perror("fopen fail");
    return -1;
  }
  //文件操作    
  fclose(fp);
  return 0;
}

此时我们就发现以"w"方式打开文件,原本没有文件,当执行了程序后就出现了该文件,现在我们再向该文件写一点数据,写数据可以使用fputs函数。

运行结果:

此时我们就能看到向log.txt写入了内容,现在我们再来做一个测试,将我们刚刚的向文件写入数据的那行代码注释掉。

运行结果:

此时我们又发现log.txt里面的内容不见了,我们上面的代码操作仅仅是打开然后再关闭,为什么文件的内容没有了呢?现在我们直接打开文件log.txt,直接写入,然后运行程序会怎么样呢?

我们发现即使我们向里面写入了数据,但是当我们运行上面的程序文件里面的内容照样没有了,难道说只要我们以"w"的方式打开文件然后再关闭文件,文件的内容都会被清空,如果我们打开文件没有任何操作,文件内容会被清空,如果打开文件向文件写入新内容,那么之前的内容就会被覆盖,可是为什么呢?我们先来看看fopen的几种打开方式。

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

我们来看看w介绍哪里Truncate file to zero length or create text file for writing.的意思:将文件截断为零长度或创建用于写入的文本文件。它这个意识就是当我们以"w"的方式打开文件时,该文件就会被自动清空,我们再来看看之前学习的重定项。

重定向的本质也是向文件里面写入,写入也就意味着要打开文件,上面我们向log.txt重定项写入两次字符串,但是第二次写入之后,第一次写入的字符串就不见了。这就说明我们在输出重定项的时候,首先需要先打开文件,第二次我们需要再次写入,说明此时是以"w"的方式打开,此时log.txt里面的内容就会被清空,以便能显示第二次输入的字符串。

当我们输出重定项什么也不带的时候,此时也就相当于打开文件什么也不做,此时我们也能观察到文件的内容被清空了。然后我们再来看看以"a"的方式打开文件。

#include <stdio.h>

int main()
{
  FILE* fp = fopen("./log.txt", "a");//以a方式打开
  if(fp == NULL)
  {
    perror("fopen fail");
    return -1;
  }
  //fputs("hello file!\n", fp);
  fclose(fp);
  return 0;
}

运行结果:

我们以"a"的当时打开文件是在文件末尾写入数据,也就意味着我们不能清空原始数据,必须保留才能在末尾写入数据,上面的运行结果能很好的证明这一点。

此时我们可以观察到追加重定项">>"也就是以"a"的方式打开文件进行文件内容追加。

2.fwrite和fread函数

fwritefread 是 C 语言中用于文件 I/O(输入/输出)的函数,它们通常用于对文件进行二进制数据的读写。

fwrite 函数

  • 目的:将数据块写入文件。
  • 原型size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
  • 参数
    • ptr:指向要写入的数据块的指针。
    • size:每个数据项的大小(以字节为单位)。
    • count:要写入的数据项的数量。
    • stream:文件指针,指向要写入的文件。
  • 返回值:成功写入的数据项数,如果写入成功其值就是count
  • 功能:将 ptr 指向的数据块写入到由 stream 指向的文件中,每个数据项的大小为 size 字节,共写入 count 个数据项。
#include <stdio.h>
#include <string.h>
int main()
{
  FILE *fp = fopen("log.txt", "w");
  if(!fp)
  {
    perror("fopen error!");
    return -1;
  }
  const char *msg = "hello world!\n";
  int count = 5;
  int n = 0;
  while(count--)
  {
    n = fwrite(msg, strlen(msg), 1, fp);
    printf("write %d block\n",n);
  }
  fclose(fp);
  return 0;
}

运行结果:

我们上面提到当我们以"w"的方式打开一个文件的时候,当文件不存在的时候并且此时我们不带路径,此时就会在当前工作目录下新建该文件,那什么叫做当前路径???新创建的文件为什么会在当前路径下创建呢?此时我们来写一个测试代码!

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

int main()
{
  FILE *fp = fopen("log.txt", "w");
  if(!fp)
  {
    perror("fopen error!");
    return -1;
  }
  const char *msg = "hello world!\n";
  int count = 5;
  int n = 0;
  while(count--)
  {
    n = fwrite(msg, strlen(msg), 10, fp);
    printf("write %d block, pid:%d\n",n,getpid());
    sleep(20);
  }
  fclose(fp);
  return 0;
}

随后我们执行程序并执行:

运行结果:

随后我们就能发现此时在proc下该进程存在一个cwd,进程在启动的时候,会自动记录自己启动时所在的路径,我们把这个路径称之为:当前路径。当我们在程序在新建的文件时,此时操作系统会自动拼上当前路径给我们的新建文件,此时我们的文件就会建在当前进程运行的同级目录下。如果我们现在去修改当前运行进程的cwd,那么新建的文件的建立的路径也必然会随之变化。

运行结果:

当前工作路径发生改变

并且在当前程序所在路径查询不到log.txt文件,而能在刚刚修改的那个工作路径下查询到log.txt文件已被建立,但是此时文件大小却为0,因为此时我们的程序还在运行当中,此时写入的内部被写到缓冲区了。

当程序运行完的时候,此时文件的内容已经被写入了。

fread 函数

  • 目的:从文件中读取数据
  • 块。
  • 原型size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
  • 参数
    • ptr:指向存储读取数据的缓冲区的指针。
    • size:每个数据项的大小(以字节为单位)。
    • count:要读取的数据项的数量。
    • stream:文件指针,指向要读取的文件。
  • 返回值:成功读取的数据项数。
  • 功能:从由 stream 指向的文件中读取数据块到 ptr 指向的缓冲区,每个数据项的大小为 size 字节,共读取 count 个数据项。
#include <stdio.h>
#include <string.h>
int main()
{
    FILE *fp = fopen("myfile", "r");
    if(!fp){
        printf("fopen error!\n");
    }
    char buf[1024];
    const char *msg = "hello world!\n";    
    while(1){
        //注意返回值和参数,此处有坑,仔细查看man手册关于该函数的说明
        ssize_t s = fread(buf, strlen(msg), 1, fp);
        if(s > 0){
            buf[s] = 0;//把文件内容当做字符串
            printf("%s", buf);
        }
    }
    fclose(fp);
    return 0;
}

3.fgets函数

  • str 是一个指向字符数组的指针,用于存储读取的字符串数据。
  • n 是要读取的最大字符数(包括 null 字符)。
  • stream 是指向 FILE 结构的指针,表示要读取的文件流。

fgets 从指定的文件流中读取一行数据,直到达到指定的字符数 n、遇到换行符('\n')或者到达文件末尾。读取的数据存储到 str 中,并且自动在末尾添加 null 字符('\0'),以表示字符串的结束。

函数返回值是成功读取的字符串的指针,如果发生错误或者到达文件末尾,则返回 NULL。

#include <stdio.h>
int main()
{
  FILE* fp = fopen("/home/xyc/log.txt","r");
  if(fp == NULL)
  {
    perror("fopen fail!\n");
    return -1;
  }
  char buffer[64];
  while(1)
  {
    char* r = fgets(buffer,sizeof(buffer),fp);
    if(!r) break;

    printf("%s",buffer);//写文件带了'\n',这里输出就不用带了。
  }

  return 0;
}

运行结果:

4. 程序默认打开的文件流

stdin & stdout & stderr

  • C默认会打开三个输入输出流,分别是stdin, stdout, stderr
  • 仔细观察发现,这三个流的类型都是FILE*, fopen返回值类型,文件指针

stdin(标准输入):

  • stdin 是标准输入流,通常用于从键盘或其他输入设备读取数据。

键盘上输入信息,你有哪些方法?

stdout(标准输出):

  • stdout 是标准输出流,通常用于向屏幕或其他输出设备输出数据。

输出信息到显示器,你有哪些方法?

stderr(标准错误):

  • stderr 是标准错误流,用于输出错误消息,通常用于将错误信息输出到屏幕或日志文件。

二、系统文件I/O

我们的c程序能不能直接把数据写在硬件显示器上吗,c程序能不能直接从键盘上读取数据呢?这肯定是不可以的,在一般的情况下,C程序本身不能直接操控硬件设备,包括显示器和键盘,操作系统是软硬件资源的管理者,所以操作系统不允许应用层的进程绕过操作系统直接访问硬件,所以这就决定了我们在文件读写的同时,必定要贯穿操作系统,此时操作系统必须要为上层语言提供访问文件的系统调用接口,然后才有了我们c语言访问文件的接口,除了上述C接口(当然,C++也有接口,其他语言也有),我们还可以采用系统接口来进行文件访问。

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: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
参数:
     O_RDONLY: 只读打开
     O_WRONLY: 只写打开
     O_RDWR : 读,写打开
     这三个常量,必须指定一个且只能指定一个
     O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
     O_APPEND: 追加写
     O_TRUNC: 如果文件存在且是可写的,则截断文件为零长度。

mode : 仅在 O_CREAT 被设置时才有效,用于指定新创建文件的权限。
       在大多数情况下,可以使用八进制表示的权限值,例如 0644。

返回值:
     成功:新打开的文件描述符
     失败:-1

我们先来介绍一下flag参数,它的参数类型是int,我们通过手册可以知道flag可以传入多个选项,但是一个整型只能保存一个,那怎么办呢?此时可以利用int类型的32个比特位,再加上按位或操作即可,利用不同的比特位进行多个传参,我们来写一个测试代码。

#include <stdio.h>

#define ONE 1
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
#define FIVE (1<<4)

void Print(int flag)
{
  if(flag & ONE)  printf("1\n");
  if(flag & TWO)  printf("2\n");
  if(flag & THREE) printf("3\n");
  if(flag & FOUR) printf("4\n");
  if(flag & FIVE) printf("5\n");
}

int main()
{
  Print(ONE);
  Print(ONE|TWO);
  Print(ONE|TWO|THREE);
  Print(ONE|TWO|THREE|FOUR);
  Print(ONE|TWO|THREE|FOUR|FIVE);
  return 0;
}

Print函数接受一个整数参数 flag,并通过按位与运算 (&) 判断 flag 的哪些位被设置为 1。如果某个位为 1,就使用 printf 打印对应的数字。因此,Print 函数的作用是根据传递的参数打印出对应位上为 1 的数字。例如,第二次调用 Print(ONE|TWO) 会打印出 "1" 和 "2",因为在二进制表示中,ONETWO 对应的位都被设置为 1。通过上面的测试,我们就知道可以通过设置比特位来向我们的目标函数同时传递多个标记位,通过多个宏进行按位或操作组合式的同时通过位图向Print函数多个传参,我们的flag设置的原理也是这样。

运行结果:

因为我们上面的程序仅仅只是打开文件,并没有指定没有该文件要干什么?所以我们想如果没有该文件的是时候就创建该文件。

运行结果:

此时就创建了该文件,但是我们发现这个文件的权限乱码了,因为我们创建文件的时候没有设置文件的权限,此时就需要open接口的第三个参数传入文件权限。

运行结果:

我们上面设置的权限是666,但是我们此时创建的文件的权限是664,为什么呢?因为我们在新建文件的时候还受umask权限掩码的约束,系统默认的umask是2,所以创建的文件权限是664,如果我们不想使用系统的权限掩码,我们可以自己设置。

运行结果:

此时文件的权限就是666了,所以未来我们在使用open函数的时候,如果我们打开的文件已经创建好了,我们就不需要传入第三个参数了。

open 函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要open创建,则第三个参数表示创建文件 的默认权限,否则,使用两个参数的open。

如果我们现在不想使用该文件了,就需要关闭该文件,

然后我们就可以向文件进行写入了,这里使用我们的write接口。

  • fd 是文件描述符,表示要写入数据的目标文件或设备。
  • buf 是一个指向要写入数据的缓冲区的指针。
  • count 是要写入的字节数。

write 函数返回写入的字节数,如果出现错误,则返回 -1。写入的实际字节数可能少于请求的字节数,这是正常的。

运行结果:

然后我们可以测试一下拷贝'\0'会怎样

运行结果:

现在我们向字符串重新写入内容aaaa,后面先不带'\n',看看结果会怎样。

运行结果:

我们发现之前写入的字符串并没有被清空,而是在之前的字符串上进行了覆盖操作,所以我们这里flag还要再传入一个参数:O_TRUNC

运行结果:

上面的open就模拟c语言fopen函数实现了以"w"的方式清空文件,现在我们想要以"a"的方式呢?

运行结果:

2. open函数返回值

在认识返回值之前,先来认识一下两个概念: 系统调用库函数

  • 上面的 fopen fclose fread fwrite 都是C标准库当中的函数,我们称之为库函数(libc)。
  • 而, open close read write lseek 都属于系统提供的接口,称之为系统调用接口
  • 回忆一下我们讲操作系统概念时,画的一张图

系统调用接口和库函数的关系,一目了然。 所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。

我们发现上面几乎的都需要传入fd,fd到底是什么呢?我们来看看文件写入成功fd是多少?

运行结果:

那我们多次打开文件呢?fd的值又该如何呢?

运行结果:

根据上面的运行结果,我们知道一个文件如果被成功打开,那么返回值fd肯定是大于0的,并且它是连续的小整数,那么问题来啰,0、1和2去哪里了?

3. 0 & 1 & 2

  • Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2.
  • 0,1,2对应的物理设备一般是:键盘,显示器,显示器
  • 所以输入输出还可以采用如下方式:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
    char buf[1024];
    ssize_t s = read(0, buf, sizeof(buf));
    if (s > 0) {
        buf[s] = 0;
        write(1, buf, strlen(buf));
        write(2, buf, strlen(buf));
    }
    return 0;
}

根据C语言的只知识吗,我们可以知道stdin它是FILE这个结构体的指针,那么我们就可以通过    【->】去访问结果体内部的元素,看看内部是否存在fd这个整型变量。

运行结果:

此时我们就知道一个新的结论,我们标准输入0,标准输出1和标准错误2文件描述符,在操作系统启动的时候,需要在底层把这三个文件描述符打开,然后为我们构建stdin,stdout和stderr这样的FILE类型的结构体,把标准输入0,标准输出1和标准错误2文件描述符分别传入这个FILE结构体里,不仅仅系统调用函数进行封装,同时还对系统调用返回值fd进行了封装,这样我们就在C语言上对类型做了全面封装。那问题又来啰,为什么我们要对系统调用接口进行封装呢?感觉上面的系统调用接口也不复杂呀!这主要是为了可移植性(跨平台性)!!!保证各个平台能访问同样的函数,不用关心底层操作系统的差异性!!!

4. 认识fd

上面这个fd到底是什么呢?为什么要从0开始呢?我们的数组下标也是从0开始,那是不是这个fd有可能是数组的下标呢?

而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。

这也就解释了为什么要系统调用接口的函数都要传入fd参数,因为系统调用需要通过fd下标去找到对应的文件。文件描述符的本质就是数组下标!!!如何理解Linux下,一切皆文件!

在上层我们通过file结构体就可以去对底层硬件进行读取,并且还屏蔽了底层硬件的差距,通过使用每一个硬件的file对象,就可以做到对底层硬件的读取,Linux下,一切皆文件!是我们站在文件层,对底层硬件的读取有函数指针去屏蔽底层硬件的差异,通过file结构体就可以统一去对底层硬件进行读取,就可以做到Linux下,一切皆文件!我们来看一下Linux的源码,看看是否存在我们上面提到的结构,首先我们肯定是要从struct task_struct中去找。

5. 文件描述符的分配规则

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
    int fd = open("log.txt", O_RDONLY);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

输出发现是 fd: 3,关闭0或者2,在看

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
    close(0);
    //close(2);
    int fd = open("log.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

发现是结果是: fd: 0 或者 fd 2,可见,文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。

那如果关闭1呢?看代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    fflush(stdout);

    close(fd);
    exit(0);
}

运行结果:

此时,我们发现,本来应该输出到显示器上的内容,输出到了文件 log.txtx 当中,其中,fd=1。这种现象叫做输出 重定向。常见的重定向有:>, >>, < 那重定向的本质是什么呢?我们的printf默认是向stdout打印,并且我们上面也验证了stdout->_fileno = 1,当我们通过系统调用把文件标识符关掉,此时下标为1的标识符就不再指向显示器文件了,而是指向我们的"log.txt"文件,由于我们仅仅只是对底层指向做了修改,但是上层C语言printf并不关心底层变化,它只认识 fd  = 1,依然拿着fd所指向的文件去写入,刚好就写到了log.txt里面,所以我们所谓的重定向本质就是更改数组所指向的文件,就可以达到重定向的功能。

我们现在就来模拟一下输入重定向:< ,读取时不再从键盘上读取,而是从我们的log.txt文件中读取。

直接看代码:

运行结果:

此时我们就不需要从键盘上读取了,scanf直接从文件上读取到了我们的数据。那怎么能少了我们的输出重定向:> 呢?

运行结果:

那还有追加重定向:>>呢?也不能少。

运行结果:

但是上面的写法对用户不太友好,用户必须要了解文件描述符的规则才能更好的使用,所以上面的方法不推荐,OS为了方便用户使用,提供了一个dup2系统调用接口去拷贝,从而达到文件的新指向。

函数原型如下:

#include <unistd.h>

int dup2(int oldfd, int newfd);
  • oldfd:要复制的文件描述符。
  • newfd:指定的新文件描述符。

dup2 的作用是将 oldfd 复制到 newfd,如果 newfd 已经打开,则先关闭 newfd。这个函数通常用于重定向标准输入、标准输出或标准错误。

这里的dup2传入的参数有点浑人,我们来解释一下,本来1号描述符所指向的文件是显示器,我们是要完成输出重新项,所以最后1号描述符的所指向的文件肯定就是log.txt了,而log.txt的文件描述符是fd,如果要完成输出重新项,那么最后1号描述符拷贝之后也变成fd指向的文件了,所以最后两个都是fd,那么最终只剩下oldfd,所以fd即使oldfd。

运行结果:

那输入重定向呢?本来应该是从键盘中读取的,使用dup2会不会就从log.txt中读取呢?

理论上如果我们屏蔽上面的dup2输入重定向,当我们运行程序的时候,程序应该是被阻塞的。

此时程序需要从键盘上获取用户输入的信息才能继续指行程序,由于我们的程序写的是死循环,输入ctrl + d表示从键盘上输入完毕,如果我们取消注释dup2,那么此时标准输入不是我们的键盘了,而是我们重定向的log.txt文件了。

我们再来看看运行结果。

如果文件没有内容,我们也会阻塞,但是此时由于文件里面有内容,程序就不再阻塞,直接输入log.txt文件里面的内容了。我们再来看看之前重定向的用法。

那么指令的重定向和我们上面学习的文件描述符重定向之间又有什么关系呢?任何指令的执行都是直接或者间接的以进程的方式在运行的,上面的指令在变成进程之前,我们要根据命令行的符号判断是输出,输入还是追加重定向,然后再决定以什么方式打开这个这个文件,程序替换只会替换代码和数据,对于内核数据结构都是没有变化的,比如文件描述符表,所以最后才是程序替换,这样就是基于命令行形式的重定向,因此上面的指令的重定向的底层实现其实就是文件描述符重定向。于是我们就可以完善之前的自定义shell的重定向功能。

myshell · 3adaf94 · 小鱼钓猫/Linux_Warehouse - Gitee.com

我们来看一下运行结果

printf是C库当中的IO函数,一般往 stdout 中输出,但是stdout底层访问文件的时候,找的还是fd:1, 但此时,fd:1 下标所表示内容,已经变成了myfile的地址,不再是显示器文件的地址,所以,输出的任何消息都会往文件中写入,进而完成输出重定向。

三、缓冲区

  • 因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。
  • 所以C库当中的FILE结构体内部,必定封装了fd。

来段代码在研究一下:

#include <stdio.h>      
#include <string.h>      
#include <unistd.h>       
int main()      
{      
 const char *msg0="hello printf\n";      
 const char *msg1="hello fwrite\n";      
 const char *msg2="hello write\n";      
       
 printf("%s", msg0);      
 fwrite(msg1, strlen(msg0), 1, stdout);      
 write(1, msg2, strlen(msg2));      
       
 fork();      
       
 return 0;      
} 

运行出结果:

但如果对进程实现输出重定向呢? ./hello > file , 我们发现结果变成了:

如果我们去掉fork呢?

我们发现加上fork之后, printf 和 fwrite (库函数)都输出了2次,而 write 只输出了一次(系统调用)。为什么呢?肯定和 fork有关!

  • 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
  • printf fwrite 库函数会自带缓冲区(进度条例子就可以说明),此时向显示器写入由于字符串带了'\n'是行刷新,此时立马就会刷新缓冲区。
  • 当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
  • 而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后
  • 但是进程退出之后,缓冲区会统一刷新(清空缓冲区),写入文件当中。
  • 但是fork的时候,父子数据会发生写时拷贝,父子刷新各自的缓冲区,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
  • write 没有变化,说明没有所谓的用户级的缓冲。

综上: printf fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区, 都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。 那这个缓冲区谁提供呢? printf fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统 调用的“封装”,但是 write 没有缓冲区,而 printf fwrite 有,足以说明,该缓冲区是二次加上的,又因为是 C,所以由C标准库提供。

stdin和stdout都是我们的FLIE*类型,是由C语言提供的。所以对于stdin必定存在输入缓冲区,对于stdout也必定存在输出缓冲区。我们之前在使用scanf和printf函数的时候,我们会经常使用到格式化输入或者格式化输出

现在我们来手动模拟一下C标准库中的方法

makefile文件

filetest:filetest.c mystdio.c
    gcc -o $@ $^
.PHONY:clean
clean:
    rm -rf filetest  

mystdio.h文件

#pragma once    

#include <stdio.h>    

#define SIZE 4096    
#define NONE_FLUSH (1<<1)    
#define LINE_FLUSH (1<<2)    
#define FULL_FLUSH (1<<3)    


typedef struct _myFILE
{
	int fileno;
	//char inbuffer[SIZE];    
	char outbuffer[SIZE];
	int pos;
	int capacity;
	int flush_mode;
}myFILE;

myFILE* my_fopen(const char* pathname, const char* mode);
int my_fwrite(myFILE* fp, const char* s, int size);
void my_fclose(myFILE* fp);
void my_fflush(myFILE* fp);
void DebugPrint(myFILE* fp);

mystdio.c文件

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


const char* toString(int flag)
{
	if (flag & NONE_FLUSH) return "NONE_FLUSH";
	else if (flag & LINE_FLUSH) return "LINE_FLUSH";
	else return "FULL_FLUSH";
}

void DebugPrint(myFILE* fp)
{
	printf("outbuffer:%s\n", fp->outbuffer);
	printf("fd:%d\n", fp->fileno);
	printf("pos:%d\n", fp->pos);
	printf("capacity:%d\n", fp->capacity);
	printf("flush_mode:%s\n", toString(fp->flush_mode));
}

myFILE* my_fopen(const char* pathname, const char* mode)
{
	int flag = 0;
	if (strcmp(mode, "r") == 0)
	{
		flag |= O_RDONLY;
	}
	else if (strcmp(mode, "w") == 0)
	{
		flag |= O_WRONLY | O_CREAT | O_TRUNC;
	}
	else if (strcmp(mode, "a") == 0)
	{
		flag |= O_WRONLY | O_CREAT | O_APPEND;
	}
	else
	{
		return NULL;
	}

	int fd = 0;
	if (flag & O_WRONLY)
	{
		fd = open(pathname, flag, 0666);
		if (fd < 0) return NULL;
	}
	else
	{
		fd = open(pathname, flag);
		if (fd < 0) return NULL;
	}

	myFILE* fp = (myFILE*)malloc(sizeof(myFILE));
	if (fp == NULL) return NULL;
	fp->fileno = fd;
	fp->capacity = SIZE;
	fp->pos = 0;
	fp->flush_mode = LINE_FLUSH;//行刷新
	return fp;
}
void my_fflush(myFILE* fp)
{
	//缓冲区无数据,直接返回
	if (fp->pos == 0) return;
	write(fp->fileno, fp->outbuffer, fp->pos);
	fp->pos = 0;
}

int my_fwrite(myFILE* fp, const char* s, int size)
{
	//缓冲区
	//写入
	memcpy(fp->outbuffer + fp->pos, s, size);
	fp->pos += size;

	//判断刷新
	if (fp->flush_mode & LINE_FLUSH && fp->outbuffer[fp->pos - 1] == '\n')
	{
		my_fflush(fp);
	}
	else if (fp->flush_mode & FULL_FLUSH && fp->pos == fp->capacity)
	{
		my_fflush(fp);
	}
	return size;
}
void my_fclose(myFILE* fp)
{
	//关闭文件之前刷新缓冲区
	my_fflush(fp);
	close(fp->fileno);
	free(fp);
	fp = NULL;
}

filetest.c文件

#include "mystdio.h"
#include <string.h>
#include <unistd.h>

const char* filename = "./log.txt";

int main()
{
    myFILE* fp = my_fopen(filename, "w");
    if (fp == NULL)
    {
        return 1;
    }

    //file operator    
    //const char* s = "hello stdio!\n";//行刷新    

    int cnt = 5;
    char buffer[64];
    while (cnt--)
    {
        snprintf(buffer, sizeof(buffer), "hello world,hello Linux,%d!!!!!", cnt);
        my_fwrite(fp, buffer, strlen(buffer));
        sleep(2);
    }
    my_fclose(fp);
    return 0;
}

我们来看一下运行结果

此时我们运行程序发现,前10秒的时候文件里面什么内容都没有,但是当程序一运行完的时候,此时文件的内容才被刷新出来,说明我们的fwrite并没有将内容写到操作系统,而是写到了C语言的缓冲区上面了,当程序一运行完,此时才调用了我们刷新缓冲区的函数,此时的刷新既没有行刷新,缓冲区也没有写满,所以我们的fwrite函数就仅仅做了将数据拷贝到缓冲区outbuffer中,然后就返回了,这样不仅提高了C语言函数的效率,同时还减少了系统调用write的次数。现在我们来看一下是不是每次缓冲区的内容都在不断增加。

#include "mystdio.h"
#include <string.h>
#include <unistd.h>

const char* filename = "./log.txt";

int main()
{
    myFILE* fp = my_fopen(filename, "w");
    if (fp == NULL)
    {
        return 1;
    }

    //file operator    
    //const char* s = "hello stdio!\n";//行刷新    

    int cnt = 5;
    char buffer[64];
    while (cnt--)
    {
        snprintf(buffer, sizeof(buffer), "hello world,hello Linux,%d!!!!!", cnt);
        my_fwrite(fp, buffer, strlen(buffer));
        DebugPrint(fp); 
        sleep(2);
        //my_fflush(fp);//每写一条信息,就刷新缓冲区
    }
    my_fclose(fp);
    return 0;
}

我们来看一下运行结果:

我们再来测试一下写入的字符串带上'\n'会不会进行行刷新。

#include "mystdio.h"
#include <string.h>
#include <unistd.h>

const char* filename = "./log.txt";

int main()
{
    myFILE* fp = my_fopen(filename, "w");
    if (fp == NULL)
    {
        return 1;
    }

    //file operator    
    //const char* s = "hello stdio!\n";//行刷新    

    int cnt = 5;
    char buffer[64];
    while (cnt--)
    {
        snprintf(buffer, sizeof(buffer), "hello world,hello Linux,%d\n", cnt);
        my_fwrite(fp, buffer, strlen(buffer));
        sleep(2);
        my_fflush(fp);//每写一条信息,就刷新缓冲区
    }
    my_fclose(fp);
    return 0;
}

此时文件就是按照行刷新进行刷新缓冲区的

我们这里如果不使用行刷新,可以使用我们的函数进行刷新。

#include "mystdio.h"
#include <string.h>
#include <unistd.h>

const char* filename = "./log.txt";

int main()
{
    myFILE* fp = my_fopen(filename, "w");
    if (fp == NULL)
    {
        return 1;
    }

    //file operator    
    //const char* s = "hello stdio!\n";//行刷新    

    int cnt = 5;
    char buffer[64];
    while (cnt--)
    {
        snprintf(buffer, sizeof(buffer), "hello world,hello Linux,%d!!!!!", cnt);
        my_fwrite(fp, buffer, strlen(buffer));
        sleep(2);
        my_fflush(fp);//每写一条信息,就刷新缓冲区
    }
    my_fclose(fp);
    return 0;
}

我们来看下运行结果:

我们来测是一下fork之后的结果

#include "mystdio.h"
#include <string.h>
#include <unistd.h>

const char* filename = "./log.txt";

int main()
{
    myFILE* fp = my_fopen(filename, "w");
    if (fp == NULL)
    {
        return 1;
    }

    //file operator    
    //const char* s = "hello stdio!\n";//行刷新    

    int cnt = 5;
    char buffer[64];
    while (cnt--)
    {
        snprintf(buffer, sizeof(buffer), "hello world,%d  ", cnt);
        my_fwrite(fp, buffer, strlen(buffer));
        sleep(2);
        my_fflush(fp);//每写一条信息,就刷新缓冲区
    }
    fork();
    my_fclose(fp);
    return 0;
}

我们这里可以使用动态监测:while :;do echo "###############";cat log.txt; sleep 1;done

我们发现此时输出了两遍,因为父子进程各有一份缓冲区,所以数据就有两份,符合预期。所以我们平常所使用的fwrite函数,并不会把数据写给外设,甚至是操作系统,它会直接把数据拷贝到C语言的那个缓冲区中,只要拷贝完成并且当前不满足刷新的条件,fwrite函数就完成了自己的使命,待下次写入的时候满足刷新的条件,此时才会去调用我们的wirte系统函数接口,也就有效减少了向文件写入的次数。

​​​​​​​

  • 27
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值