从 fopen 到 struct file:从零开始拆解 Linux 文件 I/O

文章目录

  • [1. 理解文件的本质](#1. 理解文件的本质)
    • [1.1 文件 = 内容 + 属性(元数据)](#1.1 文件 = 内容 + 属性(元数据))
    • [1.2 进程、工作路径与文件访问](#1.2 进程、工作路径与文件访问)
    • [1.3 打开文件的本质:加载到内存](#1.3 打开文件的本质:加载到内存)
    • [1.4 文件操作模式详解](#1.4 文件操作模式详解)
  • [2. Linux 系统级文件操作](#2. Linux 系统级文件操作)
    • [2.1 位图与标志位机制(`|` 与 `&`)](#2.1 位图与标志位机制(|&))
    • [2.2 核心系统调用:`open`、`close`、`write`](#2.2 核心系统调用:openclosewrite)
      • [2.2.1 `open`打开文件](#2.2.1 open打开文件)
      • [2.2.3 `close`关闭文件](#2.2.3 close关闭文件)
      • [2.2.2 `write`写入文件](#2.2.2 write写入文件)
    • [2.3 文件描述符(fd):本质是数组下标](#2.3 文件描述符(fd):本质是数组下标)
      • [2.3.1 文件描述符是什么?](#2.3.1 文件描述符是什么?)
      • [2.3.2 系统文件操作与C等语言的关联](#2.3.2 系统文件操作与C等语言的关联)
      • [2.3.3 证明C语言库函数是系统调用封装来的](#2.3.3 证明C语言库函数是系统调用封装来的)
    • [2.4 C 语言库函数封装的意义(跨平台与缓冲)](#2.4 C 语言库函数封装的意义(跨平台与缓冲))
  • [3. 深入理解重定向](#3. 深入理解重定向)
    • [3.1 文件描述符的分配规则](#3.1 文件描述符的分配规则)
    • [3.2 深入理解重定向](#3.2 深入理解重定向)
      • [3.2.1 输出重定向的本质](#3.2.1 输出重定向的本质)
      • [3.2.2 追加重定向的本质](#3.2.2 追加重定向的本质)
      • [3.2.3 输入重定向的本质](#3.2.3 输入重定向的本质)
    • [3.3 dup2 系统调用](#3.3 dup2 系统调用)
      • [3.3.1 输入重定向(使用 dup2)](#3.3.1 输入重定向(使用 dup2))
      • [3.3.2 输出重定向(使用 dup2)](#3.3.2 输出重定向(使用 dup2))
      • [3.3.3 追加重定向(使用 dup2)](#3.3.3 追加重定向(使用 dup2))
  • [4. 理解一切皆文件](#4. 理解一切皆文件)
  • [5. 缓冲区深度剖析](#5. 缓冲区深度剖析)
    • [5.1 文件读写的基本过程与数据流转](#5.1 文件读写的基本过程与数据流转)
    • [5.2 用户缓冲区 vs 语言级缓冲区 vs 内核缓冲区](#5.2 用户缓冲区 vs 语言级缓冲区 vs 内核缓冲区)
      • [5.2.1 用户缓冲区->语言级缓冲区->内核级别缓冲区](#5.2.1 用户缓冲区->语言级缓冲区->内核级别缓冲区)
      • [5.2.2 内核缓冲区](#5.2.2 内核缓冲区)
    • [5.3 标准IO提供了三种缓冲方式](#5.3 标准IO提供了三种缓冲方式)
      • [5.3.1 全缓冲](#5.3.1 全缓冲)
      • [5.3.2 行缓冲](#5.3.2 行缓冲)
      • [5.3.3 无缓冲](#5.3.3 无缓冲)
    • [5.4 理解语言级缓冲区的刷新策略与内核缓冲区的刷新机制](#5.4 理解语言级缓冲区的刷新策略与内核缓冲区的刷新机制)
      • [5.4.1 实验一:重定向后直接关闭文件描述符](#5.4.1 实验一:重定向后直接关闭文件描述符)
      • [5.4.2 实验二:进程终止时的缓冲区行为](#5.4.2 实验二:进程终止时的缓冲区行为)
      • [5.4.3 实验三:`fork` 后的缓冲区现象](#5.4.3 实验三:fork 后的缓冲区现象)
    • [5.4 补充:如何理解标准错误](#5.4 补充:如何理解标准错误)
  • [6. 实战:手写简易 Stdio 库](#6. 实战:手写简易 Stdio 库)
    • [6.1 头文件定义(`mystdio.h`)](#6.1 头文件定义(mystdio.h))
    • [6.2 核心逻辑实现(`mystdio.c`)](#6.2 核心逻辑实现(mystdio.c))
    • [6.3 测试与验证(`main.c`)](#6.3 测试与验证(main.c))
  • 总结

1. 理解文件的本质

1.1 文件 = 内容 + 属性(元数据)

提出问题:我现在新建一个空文件,需不需要占据磁盘空间呢?

答案是需要的只要你创建一个空文件,那这个文件在磁盘上是客观存在的,也就是文件的属性(文件的元信息是存在的)只是内容为空。

文件 = 文件的内容 + 文件的属性(元数据)

未来我们对文件的操作就两类:1. 修改内容 2.修改属性

1.2 进程、工作路径与文件访问

  1. 访问文件之前,必须先打开文件,更需要先找到对应的文件。所以我们需要通过路径唯一标识文件。但是有时候我们访问文件不带路径仅仅使用文件名?那么打开文件是谁打开的呢?又是怎么打开的?
  2. 文件若仅仅只是被编译好,只要没有运行起来,不执行 fopen 根本也就没有打开文件,文件是进程打开的
  3. 我们在学习文件的哪些操作,都是文件变成进程了才开始操作的。我们本质是在学习进程和文件的关系
    测试代码:利用代码创建文件
c 复制代码
#include<stdio.h>
int main()
{
    const char *filename = "log.txt";
    FILE *fp = fopen(filename,"w");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    fclose(fp);
    return 0;
}

提出问题

  1. 上述代码并没有带路径,OS怎么知道新建在哪里?程序一旦跑起来,就有自己的 pid,每一个进程都会默认记录下来自己所在的工作路径。进程一旦启动,会动态维持住自己的当前工作路径 cwd
  2. 若你不带路径,则系统层面会 cwd/log.txt。所以同样的如果我们改变当前工作路径,新建文件的位置就会发生变化。
  3. 系统会在当前工作路径下找你的文件,所以你不带路径的时候,不是因为不需要路径,而是系统知道路径。访问任何文件,必须有路径,要么用户自己提供,要么用户使用进程的 cwd。这就正面的回答了上面的问题

1.3 打开文件的本质:加载到内存

打开文件,其实是在做什么?打开的本质是将文件从磁盘加载到内存。那到底是加载内容还是属性呢?进程要访问文件的什么,那就把什么加载进来。

对文件的操作,本质是进程通过 CPU 访问内存中的文件

Linux 存在大量的文件,我们可以将文件理解为两类:打开的文件没有被打开的文件

文件从位置上可以分为:

  1. 内存级文件--被打开的文件
  2. 磁盘文件--文件系统
    Linux 系统当中,可以同时存在很多个被打开的文件。OS 要不要对这些被打开的文件进行管理?肯定是要的。如何管理这些被打开的文件?答案是先描述,再组织。我们一定会有一个结构体来保存文件属性,我们可以定义链接结构将各个文件链接,就可以将对文件的管理变成对链表的增删查改。

1.4 文件操作模式详解

回顾文件操作:fopen打开文件

打开文件的方式:

如果以写的方式打开文件:如果不存在则新建,存在则清空。实际上输出重定向打开文件的方式就是 w。既如此,我们可不可以利用此来清空文件?

示例代码:清空文件

c 复制代码
#include<stdio.h>
int main(int argc,char *argv[])
{
    if(argc != 2){
        printf("Usage: %s filename\n",argv[0]);
        return 1;
    }
    const char *filename = "log.txt";
    FILE *fp = fopen(filename,"w");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    return 0;
}

这个实际上是 w 选项在起作用。实际上,这个命令的含义是:

bash 复制代码
>log.txt

即将该文件以 w 的形式打开,再立马关掉,文件就被清空了。

a 也就是 appending 也就是追加的意思,也就是往文件的末尾写入,存在就追加,不存在就创建。

追加示例代码

c 复制代码
#include<stdio.h>
int main(int argc,char *argv[])
{
    if(argc != 2){
        printf("Usage: %s filename\n",argv[0]);
        return 1;
    }
    const char *filename = "log.txt";
    FILE *fp = fopen(filename,"a");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    int cnt = 1;
    while(cnt <= 10)
    {
        cnt++;
        const char *s = "hello word\n";
        fputs(s,fp);
    }
    return 0;
}

这就想起了我们的追加重定向:

bash 复制代码
echo "bbbb" >> log.txt

r+ 选项是即为了读又为了写,要是不存在 r+ 不会新建。a+ 是为了读和追加。

读入文件内容示例:

c 复制代码
#include<stdio.h>
int main()
{
    const char *filename = "log.txt";
    FILE *fp = fopen(filename,"r");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    while(1)
    {
        char buffer[128];
        if(!fgets(buffer,sizeof(buffer),fp))
            break;
        printf("from file: %s\n",buffer);
    }
    fclose(fp);
    return 0;
}

从操作角度理解文件:

读写位置,本质就是一个整数。这就引入了你是如何看待文件内容的?文件内容实际上都是字符,那么文件实际上就类似于一个一维数组,所以所谓的读写位置实际上就是数组下标

2. Linux 系统级文件操作

OS 也能对文件进行操作。

2.1 位图与标志位机制(|&

使用宏定制输出功能:

c 复制代码
#include<stdio.h>
#include<unistd.h>
#define ONE   (1<<0)
#define TWO   (1<<1)
#define THREE (1<<2)
#define FOUR  (1<<3)

void Print(int flag)
{
    if(flag & ONE)
        printf("one\n");
    if(flag & TWO)
        printf("two\n");
    if(flag & THREE)
        printf("three\n");
    if(flag & FOUR)
        printf("four\n");
}
int main()
{
    Print(ONE);
    printf("\n");
    Print(ONE|TWO);
    printf("\n");
    Print(ONE|TWO|THREE);
    printf("\n");
    Print(ONE|TWO|THREE|FOUR);
    printf("\n");
    Print(ONE|FOUR);
    printf("\n");
    return 0;
}

通过位图,一次向一个函数传递若干个标志位,那些标志位被设置了用与操作检测,同时传递位操作用或操作来集联。

这一段操作就足以说明我们可以用与操作和或操作了。一个位代表一个信息,或起来就可以将信息进行组合。

2.2 核心系统调用:openclosewrite

2.2.1 open打开文件

打开存在的文件,两个参数的 open 就够了。如果文件不存在?就创建它,新建一个文件是要有权限的。

文件创建成功会返回一个文件描述符,错误会返回一个 -1

示例代码:

c 复制代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    // Linux系统下,你自己不添加写自己是不会写的,你必须按位或上一个创建
    // 第三个参数是其权限,如果不指定创建出来的新文件的权限就是乱的
    if(fd < 0)
    {
        perror("open");
    }
    return 0;
}

我第三个参数的权限是 666,为什么该文件权限最后是 664?因为 umask 权限掩码(0002),凡是出现的选项最终都应该去掉,所以我的权限是 664。所以我们创建一个文件还受到系统的影响。

那如果我就想让他的权限就是 666 呢?

c 复制代码
umask(0); // 在代码内部直接将权限掩码设置为 0

手动设置掩码,但是系统掩码依旧是 002,但是掩码的使用遵循就近原则,所以使用我自己设置的。

文件描述符:

打开文件后,返回值是多少?

c 复制代码
printf("fd:%d\n", fd);

为什么是 3?因为 0、1、2 被占用了:

  • 0:标准输入(对应键盘)
  • 1:标准输出(对应显示器)
  • 2:标准错误(对应显示器)
    程序启动时默认会打开三个流:标准输入流,标准输出流,标准错误流。

2.2.3 close关闭文件

c 复制代码
close(fd);

2.2.2 write写入文件

第一个参数:文件描述符;第二个参数:写的起始位置;第三个参数:写几个数,真实写入几个,返回值就是几。

测试代码:

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if(fd < 0)
    {
        perror("open");
    }
    printf("fd: %d\n", fd);
    const char *msg = "asdf";
    write(fd, msg, strlen(msg)); // 要不要加一?不需要,\0 是我们 C 语言的规定,和你的文件有什么关系?
    close(fd);
    return 0;
}

写入加一会出现乱码:

要是我写入字符串(log.txt 本来就有内容):

c 复制代码
const char *msg = "12";

C 语言写的时候,写之前会清空,但是这个没有清空,将 as 覆盖了。

所以写之前,要将选项先清空(类似于C 语言中的 w):

c 复制代码
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

以追加的形式写入,不要覆盖(C 语言中的 a):

c 复制代码
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);

我们使用系统调用实现在 C 语言中一样的效果。

2.3 文件描述符(fd):本质是数组下标

2.3.1 文件描述符是什么?

对文件做任何操作,都必须把文件加载到缓冲区中。进程与文件之间是1:n的,进程和文件之间是如何产生关联的?可以说进程通过文件描述符找到文件。文件描述符的本质:数组下标。

2.3.2 系统文件操作与C等语言的关联

C 语言库函数是系统调用封装来的, C 语言中不需要关心权限,是因为系统调用封装好了。

测试代码:

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd1 = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    int fd2 = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if(fd < 0)
    {
        perror("open");
    }
    printf("fd: %d\n", fd);
    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    close(fd);
    return 0;
}

出错了是 -1,上面当然没有错,那 0、1、2 去哪里了?

0、1、2 被占用了,这几个保留数字的含义实际上可以与C语言对应:

  • 0:标准输入 键盘
  • 1:标准输出 显示器
  • 2:标准错误 显示器 --内核视角
    程序启动时默认会打开的三个流,对应:

2.3.3 证明C语言库函数是系统调用封装来的

今天我要向显示器打印,有几种方法?按照上述的说法,进程通过文件描述符来找到文件并对文件进行操作,1 号文件是不是就可以直接使用?

测试代码:向显示器写入

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    const char *msg = "hhhhhhh\n";
    write(1, msg, strlen(msg)); // 这个是系统调用
    fputs(msg, stdout);         // 等价于这个库函数
    return 0;
}

答案是,是的!!!将目标文件换为1,就实现了直接在显示器上的输出:

向显示器写入,本质上是向硬件写入,只有 OS 才有资格向硬件写入,用户想要写入就必须使用 OS 提供的系统调用!

所以我们上面的 FILE* 文件对象的本质是一个结构体对象,这个实际上是一个结构体指针。这个结构体会拥有什么成员?它一定封装了文件描述符 ,OS 使用文件只认文件描述符!

如何证明?测试代码:

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    printf("stdin->fd: %d\n", stdin->_fileno);
    printf("stdout->fd: %d\n", stdout->_fileno);
    printf("stderr->fd: %d\n", stderr->_fileno);
    return 0;
}

所以上述三个函数封装的就是系统调用!

2.4 C 语言库函数封装的意义(跨平台与缓冲)

FILE -> fd

fopen / fwrite / fclose与系统中的open / write / close

C++ 也有文件操作:ifstreamofstream,它们是类,是类就有成员属性,成员属性里就有 fd。

任何语言在 Linux 在访问文件的时候,都要调用操作系统,因为你只要向文件里去写,本质都是向操作系统去写,都会转化成文件描述符,都要通过系统调用。所以各个语言的 IO 库的本质都是将系统调用封装了一遍。

直接用不好吗?为什么要封装?

直接用 Linux 系统调用,能在 Windows 下用吗?系统不一样,要是不封装,Linux 的系统调用只能在 Linux 系统下使用,直接用系统调用,不具有很好的跨平台性。

为什么语言要有跨平台性?每个平台下对应的是成千上万的用户,是要有人用的。跨平台性是在提升潜在的用户数!

所以,就不能让用户直接使用系统调用,库函数直接将系统调用各自封装一份,适配各个 OS,C 语言要将操作系统平台的各种常见的系统调用各自封装一份,不同平台使用的时候你直接迁移过去就行,这就支持了很好的跨平台性。所以你安装的时候会有 Windows 版、macOS 版、Linux 版,虽然接口不一样,但是底层封装的库不一样。

所以 C/C++、Java、Python 每一种语言都要搞一个自己的文件操作,本质都是为了跨平台性而对系统调用的封装。

3. 深入理解重定向

3.1 文件描述符的分配规则

文件描述符的分配规则是什么?用一个代码来体现:

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    close(0);
    int fda = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    printf("fda: %d\n", fda);
    int fdb = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    printf("fdb: %d\n", fdb);
    int fdc = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    printf("fdc: %d\n", fdc);
    return 0;
}

如果不关掉是 3,4,5。将 0 关掉,0 文件描述符的位置会被占掉。对于 0、1、2 关掉,都会被占用掉。

所以,分配规则是:在文件描述符表数组中,找最小且没有被使用的 fd。

3.2 深入理解重定向

3.2.1 输出重定向的本质

但是把 1 关掉(标准输出),就没有输出了。本来应该被打印出来的内容,竟然内容跑到了 log1.txt 里面。

像这样,原本要输出在显示器上的内容,突然跑到了 log1.txt 中,这个叫什么?--输出重定向!

可是为什么会触发这样的问题呢?

首先,三个打印的 printf 函数是 C 标准库中的函数,其本质上是对 stdout 打印,其实就相当于调用 fprintf(stdout, ...)stdout 封装的是文件描述符 1。

其次,上面我们说,实际上进程管理内存级文件是用指针指向文件描述符表。原本指向 1 的内容被关掉,在数组层面上也就是内容被释放然后置为空了。后面你创建文件的时候,新建的文件就按照规则分配到了 1 的位置。但问题在于 stdout 底层封装的文件描述符为 1 这一点没有改变,但是 1 所对应的文件已经变了,所以向显示器文件写入就变成了向你补进去的 log1.txt 文件内写入。

属于是狸猫换太子,"芯"变了,语言层只认数字 1。这就是这里发生输出重定向的本质!

重定向的本质:其实就是只改变数组特定下标内的内容。

3.2.2 追加重定向的本质

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    close(1);
    int fda = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    return 0;
}

本质区别是打开方式不一样(O_TRUNC vs O_APPEND)。

3.2.3 输入重定向的本质

先引入一个新代码,从键盘中获取字符并输出:

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    char buffer[64];
    fgets(buffer, sizeof(buffer), stdin);
    printf("%s\n", buffer);
    return 0;
}

多输入了一个回车,空了一行没毛病。我们修改代码:

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    close(0);
    int fda = open("log1.txt", O_RDONLY);
    printf("fda: %d\n", fda); // 0
    char buffer[64];
    fgets(buffer, sizeof(buffer), stdin); // stdin->0,只认数字 0
    printf("%s\n", buffer);
    return 0;
}

预先要往 log1.txt 中写点东西。

本来应该从键盘中读数据,现在转向了从文件中读取数据,这个叫做输入重定向

3.3 dup2 系统调用

上述,我们使用系统调用模拟得 出了重定向的实现原理。但是上述方法很粗糙:能不能直接将数组里面的内容拷贝到对应的下标处实现重定向?这样就不用关了。

关于对已经打开的文件进行重定向,OS 为我们提供了系统调用:

使用说明:这里拷贝的是文件描述符数组下标的内容,newfd 将来是 oldfd 的一份拷贝,oldfd 拷贝到 newfd 本质上是 newfd 消失的内容,fd 对应的就是 oldfd

3.3.1 输入重定向(使用 dup2)

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    int fda = open("log1.txt", O_RDONLY);
    printf("fda: %d\n", fda); // 3
    dup2(fda, 0);
    char buffer[64];
    fgets(buffer, sizeof(buffer), stdin); // stdin->0,只认数字 0
    printf("%s\n", buffer);
    return 0;
}

3.3.2 输出重定向(使用 dup2)

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    int fda = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    printf("fda: %d\n", fda); // 3
    dup2(fda, 1);
    printf("hahahahahaha\n");
    printf("hahahahahaha\n");
    printf("hahahahahaha\n");
    printf("hahahahahaha\n");
    fprintf(stdout, "hhhhhhhhhhhh\n");
    return 0;
}

可以看出,既没有关闭,也实现了重定向。

3.3.3 追加重定向(使用 dup2)

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    int fda = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    printf("fda: %d\n", fda); // 3
    dup2(fda, 1);
    printf("lalalalalala\n");
    return 0;
}

重定向的本质是进行数组内容的拷贝。

那么我们之前学的:

bash 复制代码
echo "aaaaaa" >> log1.txt

算什么?比命令更早执行的是 shell,shell 是可以分析命令行字符串的,所以系统可以通过 >> 直接得到文件名,再进行相应的程序替换。

程序替换,会不会影响进程打开的文件?答案是不会的。

4. 理解一切皆文件

首先,在windows中是文件的东西,他们在Linux中也是文件;其次一些在windows中不是文件的东西,比如进程、显示器、键盘这样的硬件设备也被抽象成了文件,我们可以使用访问文件的而方式访问他们获得信息;甚至管道也是文件,将来我们要学习网络编程中的Sorket(套接字)这样的东西,使用的接口和文件的接口也是一样的。

可以使用结构体对硬件进行管理,站在方法的角度,对硬件进行读写都叫做 I/O。

我们打开硬件也要有对应的 struct filestruct file 里面的函数指针就是实现一切皆文件的底层原理,在我们看来,我们操作每一种硬件的方式都是相同的。进程在访问文件的时候是使用文件描述符访问 file 对象的,在进程角度看,一切皆文件的底层含义就是,一切皆 struct file 对象 。进程是用户,用户也认为一切皆文件。

实际上它是用 C 语言模仿多态的形式。

5. 缓冲区深度剖析

5.1 文件读写的基本过程与数据流转

文件打开和读写的基本过程:

  1. 读文件要求将数据载入到内核级缓冲区,如果进程在读取文件的时候发现文件并没有在内核级缓冲区中,那么操作系统会将这个进程阻塞住,然后自己将数据从磁盘搬到缓冲区中。read 函数本质是拷贝函数,将文件数据从内核级缓冲区中拷贝到用户级缓冲区 buffer 中。
  2. 写文件,你需要向缓冲区中写,写入也就是拷贝到文件内核级缓冲区中,write 函数的本质也是拷贝。
  3. 拷贝到文件内核级缓冲区中,OS 会自动刷新。
  4. 从磁盘加载或者刷新用的是一切皆文件的读写方法,本质上也是一个 I/O 的过程。
  5. 如何修改?在操作系统中,你就算要修改也需要先将内容先写到缓冲区中,然后再刷新。系统中更改文件读写位置的函数:lseek,更改读和写位置的偏移量,其实也就是更改文件读写的位置:

修改其实也是先加载、再修改、再刷新。

在文件 I/O 中,大部分情况下都是要先加载,再操作、再刷新。内容的拷贝工作,必须是内存级的。数据流转的本质其实也就是拷贝。

一段测试代码:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
    const char *filename = "log.txt";
    int fd = open(filename, O_RDONLY);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    char buffer[1024]; // 用户级缓冲区
    ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
    if(n > 0)
    {
        buffer[n] = 0; // 0 == '\0'
        printf("buffer: %s\n", buffer);
    }
    close(fd);
    return 0;
}

用户级缓冲区最大的特点是可以通过指针去访问里面的内容。

调用系统调用是有成本的,一次 read 就是一次系统调用。说人话也就是比较慢。

5.2 用户缓冲区 vs 语言级缓冲区 vs 内核缓冲区

5.2.1 用户缓冲区->语言级缓冲区->内核级别缓冲区

理解一下为什么慢:我们在使用 malloc 或者 new 时必须调用系统调用,底层封装 brk 等系统调用来帮助我们做空间申请,操作系统腾空间的这个过程是需要花费时间的。vector 的两倍扩容能有效地减少系统调用的次数。

如何解决慢这个问题?核心思想就是尽可能的减少系统调用的次数,减少 read 或者 write 的次数。

有没有一种方式能同时让用户写入,并减少系统调用的方法?C 语言就出场了。我们以前所说的缓冲区,本质不是内核缓冲区,而是语言级缓冲区 (类似于菜鸟驿站送快递,你将你的快递送达驿站,驿站是不会立马送出的,肯定要凑凑数才划算的嘛),提高 C 语言的 I/O 函数运行效率。fputs 实际上就是先将内容从用户级缓冲区加载到语言缓冲区中,等到一定程度再从语言缓冲区加载到内核缓冲区中。

输入输出缓冲区在哪里?我们输入内容实际上就是将内容拷贝到 FILE 结构体当中,后续再刷新到缓冲区中减少我们系统调用的次数。所以缓冲区在每一个文件的 FILE 对象中

FILE 对象在哪里创建的?在 fopen 内部创建的。所以 fclose 到底在做什么?

  1. 刷新缓冲区
  2. 关闭文件描述符
  3. 释放指针所指向的 FILE 对象
    所以调用 fputs 快还是调用 write 快?肯定是调用 fputs 快,从缓冲区中读写。

C++ 中的 I/O 流本质上是类,类里面必然包括:

  1. 文件描述符
  2. 缓冲区

5.2.2 内核缓冲区

细节一:只要把数据从用户缓冲区,拷贝到了内核文件缓冲区,就相当于交给了硬件。

细节二:客观上,就是写给了 file 对应的文件内核缓冲区 -> OS -> 磁盘。

OS 有自己的刷新策略:

  • 立即刷新
  • 等 OS 不忙了,再自主刷新
    若是要求 OS 立即刷新,我们也提供了系统调用 fsync,要立即把缓冲区的内容刷新到硬件上。

在内核态将数据同步到外部存储设备上。

查看标准库的源代码:

bash 复制代码
vim /usr/include/stdio.h

5.3 标准IO提供了三种缓冲方式

5.3.1 全缓冲

这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。

5.3.2 行缓冲

在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准 I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行 I/O系统调用操作,默认行缓冲区的大小为1024。

5.3.3 无缓冲

无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。

除了上述列举的默认刷新方式,下面特殊情况也会引发缓冲区的刷新:

  1. 缓冲区漫时
  2. 执行flush语句
  3. 进程结束

5.4 理解语言级缓冲区的刷新策略与内核缓冲区的刷新机制

5.4.1 实验一:重定向后直接关闭文件描述符

c 复制代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
    close(1);
    int fda = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    printf("fda: %d\n", fda);
    close(fda);
    return 0;
}

这个时候,问题来了,重定向到 log1.txt 的内容怎么没有了?

上述内容被写到了 stdout 的缓冲区里,直接调用 closeclose 会关闭文件描述符,是一个系统调用。还没来得及刷新,调用 write 写给操作系统,你就直接 close 了。所以进程结束时,想把内容刷新到外设上,你这就刷不过去了,write 出现了系统调用报错,因为文件描述符被关掉了,它是空的。

所以在关闭之前刷新一下就可以看到了:

c 复制代码
fflush(stdout); // 强制性将缓冲区中的内容通过系统调用写到内核里

语言级缓冲区刷新的本质是 write(fd),拷贝到特定文件内核缓冲区里。再往深了讲,是将数据交给操作系统,潜台词是不一定写到磁盘文件中。

语言级缓冲区的刷新方式:

  1. 进程结束时会自动刷新
  2. 如果目标文件是显示器,刷新策略是行刷新(因为人看东西是一行一行看的)
  3. 普通文件,一般是全缓冲(缓冲区写满了,才会刷新)

5.4.2 实验二:进程终止时的缓冲区行为

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
    printf("hello word");
    sleep(3);
    _exit(0);
    return 0;
}

_exit(0)printf 的数据消失,是因为缓冲区在进程内部的语言层,而不在操作系统内核里。进程一死,没刷新的缓冲区数据就跟着没了。

5.4.3 实验三:fork 后的缓冲区现象

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
int main()
{
    // C 库函数
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    const char *s = "hello fputs\n";
    fputs(s, stdout);
    // 系统调用
    const char *ss = "hello write\n";
    write(1, ss, strlen(ss));
    fork();
    return 0;
}

为什么上面出现了三个打印结果,下面出现了七个?

首先重复的是 C 库函数打印的方法,printffwrite库函数+会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后,进程退出之后,会统一刷新,写入文件当中。但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。

内核的系统调用为什么没有刷新两次?因为它把数据已经写给操作系统了,所以只会刷新一次。要明白重定向的潜台词:重定向改变了缓冲区的刷新策略:从"行缓冲"变成了"全缓冲"。

刷新缓冲区的数据本质就是修改缓冲区的数据。

5.4 补充:如何理解标准错误

如何理解标准输出?编程的本质还是进程对数据进行处理,进程->是完成任务的,完成任务实际上是对数据进行加工处理,对数据进行处理就有一个数据源头,和一个处理结果,出错就是方便debug。

标准输入 0 stdin

标准输出 1,stdout

标准错误 2 ,stderr 显示器

2存在的意义是分离错误信息,验证代码

C 复制代码
#include<stdio.h>
#include<unistd.h>
#include<string.h>

int main()
{
        //标准输出:1
        printf("这是一个正常消息\n");
        fprintf(stdout,"这也是一个正常的日志消息\n");
        const char *s1 = "这是一个正常消息,write\n";
        write(1,s1,strlen(s1));

        //标准错误:2
        fprintf(stderr,"这是一个错误消息\n");
        const char *s2 = "这是一个错误消息,write\n";
        write(2,s2,strlen(s2));
        perror("perror,hello");
        return 0;
}

为什么重定向之后,为什么normal.txt中就只有正确信息

因为你重定向的时候只是改变了标准输出,将文件标识符为1的转到了文件normal.txt中,所以normal.txt只会保存标准输出的内容

我们可以这样:

bash 复制代码
./text 2>err.txt

(注意不能写空格呦~)

上述指令的意思就是,将err.txt的文件描述符打开之后,内核的文件对象拷贝到2号下标里,所以这个时候你打印的都是正常消息,错误消息都被写到err.txt中了,所以第一个示例的重定向是少了一个数字1的:

二者综合起来就相当于这个:

bash 复制代码
./a.out 1>normal.txt 2>err.txt

理解分离错误信息:

我们在使用程序的时候,会打印大量的输出,可以将大量的调试信息写到标准错误上,正常的执行写道标准输出上,想看到什么信息,就把什么信息重定向到文件里。

bash 复制代码
./text >ok.txt 2>&1

1重定向到ok,1里面的内容也写到2里面

6. 实战:手写简易 Stdio 库

6.1 头文件定义(mystdio.h

c 复制代码
#pragma once
#include <stdio.h>

#define SIZE 1024

#define NON_BUFFER  1   // 1
#define LINE_BUFFER 2   // 10
#define FULL_BUFFER 4   // 100 位图的形式表示

#define MODE 0666       // 默认模式

typedef struct myFILE
{
    int fd;               // 文件描述符
    int flags;            // 打开模式是什么?
    int flush_mode;
    char outbuffer[SIZE]; // 缓冲区
    int pos;              // buffer 位置
    int cap;
} myFILE;

myFILE *myfopen(const char *pathname, const char *mode); // r, w, a, r+, w+
void myfclose(myFILE *fp);
int myfputs(const char *str, myFILE *fp);
void myfflush(myFILE *fp);

6.2 核心逻辑实现(mystdio.c

c 复制代码
#include "mystdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

#define TRY_FLUSH  1
#define MUST_FLUSH 2

myFILE *myfopen(const char *pathname, const char *mode) // r, w, a, r+, w+..
{
    int fd = -1;
    int flags = 0;

    if(strcmp(mode, "r") == 0)
    {
        flags = O_RDONLY;
        fd = open(pathname, flags);
    }
    else if(strcmp(mode, "w") == 0)
    {
        flags = O_WRONLY | O_CREAT | O_TRUNC;
        fd = open(pathname, flags, MODE);
    }
    else if(strcmp(mode, "a") == 0)
    {
        flags = O_WRONLY | O_CREAT | O_APPEND;
        fd = open(pathname, flags, MODE);
    }
    else
    {
        // TODO
    }

    if(fd < 0)
        return NULL;

    myFILE *fp = (myFILE *)malloc(sizeof(myFILE));
    if(fp == NULL)
        return NULL;

    fp->fd = fd;
    fp->flags = flags;
    fp->flush_mode = LINE_BUFFER;
    fp->cap = SIZE;
    fp->pos = 0;

    return fp;
}

static void myfflushcore(myFILE *fp, int flag)
{
    if(fp->pos == 0)
        return;

    if((fp->flush_mode & LINE_BUFFER) || (flag & MUST_FLUSH))
    {
        // "abcd\n"
        if((fp->outbuffer[fp->pos-1] == '\n') || (flag & MUST_FLUSH))
        {
            // 写到内核中
            write(fp->fd, fp->outbuffer, fp->pos);
            fp->pos = 0; // 清空缓冲区
        }
    }
    else if(fp->flush_mode & FULL_BUFFER)
    {
        // if(fp->pos == fp->cap)
        // 只有当缓冲区写满(pos >= cap) 或者 强制刷新时才写入 
	        if (fp->pos >= fp->cap || (flag & MUST_FLUSH)) 
	        { 
		        write(fp->fd, fp->outbuffer, fp->pos); 
		        fp->pos = 0; memset(fp->outbuffer, 0, SIZE); 
	        }
    }
    else if(fp->flush_mode & NON_BUFFER)
    {
        // write();
    }
}
void myfflush(myFILE *fp)
{
    myfflushcore(fp, MUST_FLUSH);
}

int myfputs(const char *str, myFILE *fp)
{
    if(strlen(str) == 0)
        return 0;

    // step 1: 向文件流里面写,本质是:文件缓冲区 -> 拷贝
    memcpy(fp->outbuffer + fp->pos, str, strlen(str));
    fp->pos += strlen(str);

    // step 2: 如果条件允许,可以自己刷新
    myfflushcore(fp, TRY_FLUSH);

    return strlen(str);
}

void myfclose(myFILE *fp)
{
    // 1. 强制刷新到内核
    myfflush(fp);

    // 2. 强制刷新到磁盘(不是必选的)
    fsync(fp->fd);

    // 3. 关闭文件
    close(fp->fd);

    // 4. free
    free(fp);
}

6.3 测试与验证(main.c

c 复制代码
#include "mystdio.h"
#include <unistd.h>

int main()
{
    myFILE *fp = myfopen("log.txt", "a");
    if(fp == NULL)
    {
        printf("myfopen error!\n");
        return 0;
    }

    const char *msg = "hello";
    int cnt = 10;
    while(cnt--)
    {
        myfputs(msg, fp);
        sleep(1);
        printf("debug: outbuffer = %s, pos = %d\n", fp->outbuffer, fp->pos);
    }

    myfclose(fp);
    printf("write file done!\n");
    return 0;
}

总结

核心结论

  1. 文件 = 内容 + 属性,对文件的操作就是对这两类的操作。
  2. 文件描述符本质是数组下标,0、1、2 默认被 stdin/stdout/stderr 占用。
  3. 重定向的本质是改变数组特定下标内的内容dup2 实现数组内容的拷贝)。
  4. 一切皆文件的本质是 C 语言模拟多态struct file_operations 函数指针)。
  5. 语言级缓冲区是为了减少系统调用次数,提高 I/O 效率
  6. 封装的本质是为了跨平台性
相关推荐
DeeplyMind7 小时前
Linux 深入 per-VMA lock:Linux 缺页路径如何摆脱 mmap_lock
linux·per-vma lock
爱写代码的阿森7 小时前
鸿蒙三方库 | harmony-utils之PreferencesUtil首选项数据监听详解
服务器·华为·harmonyos·鸿蒙·huawei
爱写代码的森7 小时前
蒙三方库 | harmony-utils之FileUtil文件重命名与属性查询详解
linux·运维·服务器·华为·harmonyos·鸿蒙·huawei
中微极客7 小时前
2026主流AI Agent框架技术选型与性能对比
运维·网络·人工智能
吠品8 小时前
Zabbix Web界面误报Server未运行的排查与解决
java·服务器·数据库
XMAIPC_Robot8 小时前
软硬协同实时控制|RK3588业务调度+FPGA硬件时序,ethercat实现半导体设备微秒级响应(125us)
linux·arm开发·人工智能·fpga开发
AOwhisky8 小时前
Python 学习笔记(第十五期)——运维自动化(下·后篇):堡垒机实战——paramiko高阶篇
运维·python·学习·云原生·自动化·运维开发
重生的黑客8 小时前
Linux 进程优先级、切换与调度:从孤儿进程到 O(1) 调度模型
linux·运维·服务器·进程优先级·nice
Urbano8 小时前
夹克与商务单西口袋工序自动化技改研究报告
运维·自动化