1. 回顾 C 的文件操作

1.1 C语言写文件

  我们来看这一段代码:

#include <stdio.h>

int main() {
    // 打开文件:write 模式(没有就创建,有就清空)
    FILE* fp = fopen("test_c.txt", "w");

    // 写入内容(和 printf 用法一样)
    fprintf(fp, "我是 C 语言写入的内容\n");
    fprintf(fp, "第二行内容\n");

    // 关闭文件
    fclose(fp);

    return 0;
}

  在C语言中,如果想要写入文件,首先需要打开文件,然后才能执行写入操作。而我们提到过,文件 = 文件内容 + 文件属性,对文件的操作可以直接分为对文件内容的操作和对文件属性的操作,打开文件就需要文件的属性里的路径和文件名。

1.2 文件操作的本质

  那么我们调用fopen函数之后,是什么时候打开文件的?是谁打开的?首先我们代码写完之后,肯定会编译链接形成一个可执行程序文件,再运行,然后才会调用fopen函数打开文件,可以说是程序运行之后打开的。当开始运行的时候就会产生一个进程,因此可以说是进程打开的文件。

  这是我们自己写的一个打开文件的代码,但是这段代码当中只有文件名,并没有包含文件路径,那是通过什么方式打开的?

  并且我们观察到:这个文件打开之后就会自动放在我们当前所处的工作路径下,而不是其他路径,这是为什么?

  首先每一个进程启动的时候,都会从父进程继承task_struct信息,这里面就包含了当前工作路径,用 cwd 变量存储,所以在打开文件的时候,即使没有写文件路径,系统也会帮你自动写入就相当于是 fopen(cwd/filename,"w")。

  所以我们可以得出一个结论:当我们要访问文件的时候,必须要带上文件路径。要么用户自己写入,要么系统自动写入进程的cwd。

  那么打开文件这一操作,本质上是什么呢?结合我们之前讲过的冯诺依曼体系:CPU无法直接从外设上获取数据,必须得先从内存获取数据。因此打开文件。本质上是将文件加载到内存当中。又因为文件分为文件内容和文件属性,所以加载到内存的时候,分为加载文件内容和加载文件属性,或者都加载。具体加载哪一个,取决于我们到底要访问文件的什么内容。

  因此我们可以得到一个结论:对文件开始操作,本质是进程通过CPU访问内存中的文件。

  而在整个系统中,不管是Windows还是Linux,都有大量的文件,这些文件分为打开的和未打开的。从位置的角度去说就是:内存级已经被打开的文件和磁盘文件。本文我们谈论的文件,都是内存级被打开的文件,而磁盘文件属于文件系统,我们后续会讲到。

1.3 C语言读文件

  因为使用 fopen 打开文件的时候,需要设置打开文件的模式:

  介绍一下打开文件的六种不同模式:

  r → 只能读,文件必须存在

  w → 只能写,文件不存在则创建,存在则清空

  a → 只能写,在文件末尾添加内容,不清空

  r+ → 可读可写,文件必须存在

  w+ → 可读可写,创建 / 清空文件

  a+ → 可读可写,末尾追加,可读

  来看这段代码,这是写一次,关文件,读一次,再关文件的写法:

#include <stdio.h>

int main()
{
    const char *filename = "log.txt";

    // 第一步:只写模式打开,写入数据
    FILE *fp = fopen(filename, "w");
    if(fp == NULL) 
    {
       perror("fopen w"); 
       return 1; 
    }

    int cnt = 0;
    while(cnt <= 10) 
    {
        cnt++;
        const char* s = "hello world!\n";
        fputs(s, fp);
    }

    fclose(fp); // 写完立刻关闭,确保数据落盘

    // 第二步:只读模式重新打开,读取数据
    fp = fopen(filename, "r");
    if(fp == NULL)
    {  
       perror("fopen r"); 
       return 1; 
    }

    char buffer[128];
    while(fgets(buffer, sizeof(buffer), fp)) 
    {
        printf("文件内容: %s", buffer);
    }

    fclose(fp);
    return 0;
}

  还有另一种写法,这里我们把写入和读数据放在一起写了,所以在一开始打开文件的代码当中,模式选择的是 w+ ,这表示同时支持读和写。使用 fputs 函数将内容填入到文件当中,然后调用 fflush 函数刷新缓冲区,因为此时我们的 fp 指针是是在文件的末尾的,所以我们需要调用 rewind 函数将 fp 指针放到文件的最开始,这样才方便后续去读取文件。

  1 #include <stdio.h>
  2 
  3 int main()
  4 {
  5   const char *filename = "log.txt";
  6   FILE *fp = fopen(filename,"w+");
  7   if(fp == NULL)
  8   {
  9     perror("fopen");
 10     return 1;
 11   }
 12   int cnt = 0;
 13   while(cnt <= 10)
 14   {
 15     cnt++;
 16     const char* s = "hello world!\n";
 17     fputs(s,fp);
 18   }
 19 
 20   fflush(fp);
 21   rewind(fp);
 22 
 23   while(1)
 24   {
 25     char buffer[128];
 26     if(!fgets(buffer,sizeof(buffer),fp))
 27     {
 28       break;
 29     }
 30     printf("文件内容:%s\n",buffer);
 31   }
 32                                                                                                                                                                                        
 33   fclose(fp);
 34              
 35   return 0;  
 36 }      

  我们去思考一个问题,一个文件里面存储的所有东西,不都是一个个的字符吗?那也就可以把一个文件理解成一个 char 类型的数组,那么这个 FILE* fp ,就是指向数组中的下标代表的位置。所以在读和写两个模式里面,相当于是循环遍历下标执行操作。一旦把插入操作执行完了,那fp的位置就变到了末尾,所以才需要上面提到的函数。并且因为把读写权限都打开的话,也不利于文件的安全性,所以一般来说不太会采用这样的方式。

2. Linux系统中的文件操作

2.1 打开文件操作

  前面的内容讲解的都是C语言内部的关于文件操作的代码,现在我们来讲讲Linux操作系统中提供的关于文件操作的系统调用。

  首先最重要的就是打开文件的系统调用:

  调用open函数就可以打开文件,第一个参数就是文件名称,第二个参数就是打开模式,O_RDONLY就是以读方式打开,O_WRONLY就是以写方式打开,O_RDWR就是以读写方式打开。第二个函数的第三个参数和权限有关,主要应用于打开文件失败的情况,如果失败的话就需要创建新文件,但是创建新文件就需要给新文件配置权限。比如我要一个文件的权限是:rw- r-- r-- 就传 0644 ,第一个0代表八进制的意思。

  另外,这个系统调用的返回值不同于C语言里的 FILE* ,而是 int ,这代表什么意思呢?

2.2 文件描述符

  因为open() 直接和操作系统内核打交道,所以内核用一个非负整数来唯一标识进程打开的文件,这个整数就叫文件描述符(fd)

  它是内核级别的 “句柄”,是一个数字(比如 3、4、5…),进程默认就有 3 个打开的文件描述符:

    0 → 标准输入(stdin)

    1 → 标准输出(stdout)

    2 → 标准错误(stderr)

  后续打开的文件,内核会按顺序分配下一个可用的整数(比如 3、4…)。

  所以 open() 返回 int,就是返回这个数字标识。有点类似于进程结束时的退出码。

  我们来测试一下:

  但是当我们要运行的时候,却说没有 log.txt 这个文件,可我们不是调用 open 函数打开文件了吗?这是因为 open 默认只读 / 只写,不创建文件;想自动新建,必须加 O_CREAT。并且如果写了O_CREAT就代表要创建文件,就必须要传第三个参数,所以我们要这么写:

  不过在创建文件的时候,可能会受到权限掩码的影响,导致你传参的权限和最后创建的文件的权限不一样,关于权限掩码我之前的文章中做过讲解:

https://blog.csdn.net/2502_91842264/article/details/159429520?fromshare=blogdetail&sharetype=blogdetail&sharerId=159429520&sharerefer=PC&sharesource=2502_91842264&sharefrom=from_link

  大家直接跳转到“关于权限的三个问题”这一段去查看即可,在此就不过多赘述。

  然后我们查看一下open函数调用后的返回值:

  这就表示文件打开成功!

2.2.1 写操作

  打开文件之后,我们来谈一谈写入操作,要用到write系统调用:

1. 第一个参数:int fd

含义:文件描述符

  • 你要把数据写到哪里,就传哪个文件的编号
  • open 成功打开后返回,比如 3、4、5...
  • 如果是 1,就写到屏幕(标准输出)
  • 失败会返回 -1

作用:告诉内核 “往哪个文件写”

2. 第二个参数:void *buf

含义:数据的内存起始地址

  • 可以是字符串、字符数组、任意数据的地址
  • void* 代表通用指针,不认类型,只认字节
  • 系统会从这个地址开始,往外拿字节

作用:告诉内核 “数据从哪里来”

3. 第三个参数:size_t count

含义:要写入多少字节

  • 是一个数字,比如 7、100、1024
  • 如果你写字符串,一般用 strlen(内容)

作用:告诉内核 “写多少字节”

  很多人在这里会产生一个疑惑:对于一个字符串来说肯定是会以 '\0'  结尾,那么在这里要传参的时候是不是要写strlen(message) + 1 呢?还是直接写strlen(message)。

  答案是直接写 strlen(message) 即可,因为  message 是一个字符串常量 "abcdefg",它在内存里实际是:'a' 'b' 'c' 'd' 'e' 'f' 'g' '\0'8 个字节,其中:前 7 个是你要写的可见字符。第 8 个是字符串结束符 \0 。

  而 strlen 计算的是字符串中有效字符的长度,并不包含末尾的 '\0' 结束符,而我们向文本文件中写入的,也正是这些可见字符本身。write 会按照你给定的长度,将内存中的数据原封不动地写入文件,一旦加上 +1,就会把 '\0' 也一并写进去,这个不可见的控制字符会让文本编辑器出现乱码,也可能导致其他程序读取文件时出现内容截断的问题。因此,在普通文本写入场景下,直接用 strlen (message) 就足够了,只有在处理二进制协议、需要把字符串的结束符也作为数据的一部分时,才需要额外加上 1。

  然后我们再来做一些修改:

  我们再重新向 log.txt 文件里面写入信息,但是这次的内容比第一次的要短,来看看结果:

  我们发现第二次输入的内容,竟然是在第一次的内容上直接覆盖,并没有先进行清空操作,这和C语言里的w模式不太一样。所以我们如果也要有清空操作,还需要添加这个参数:

  它的作用是:打开文件时,把文件原有内容清空,变成空文件。 

  

  还有另一种模式:O_APPEND,是直接在原先的文本后追加内容:

  我们之前讲到过库函数和系统调用的关系其实就是上下级关系,库函数就是对系统调用的封装。所以我们刚刚使用open系统调用,实际上对应的库函数是这样的:

+

  另外,在我们刚刚提到的文件描述符当中,有三个文件描述符已经被提前占用,代表的信息分别是:

                 0 → 标准输入(stdin)

                 1 → 标准输出(stdout)

                 2 → 标准错误(stderr)

  标准输入就是键盘,标准输出就是显示器,标准错误也是显示器。并且write写入函数的第一个参数就是文件描述符,那我们是不是可以直接使用这个文件描述符进行写入呢?

  int main()
  {
    const char* msg = "hello world!";
    write(1,msg,strlen(msg));
  
    return 0;
  }

  我们用这段代码去写,得到的结果是这样的:

2.2.2 文件描述符本质

  根据前面的内容,说明使用文件描述符,可以不用手动写打开文件的操作而直接向显示器写入。我们又想到,在C语言中,标准输入输出错误,对应这三个指针变量:

  那么C语言的指针变量和系统调用的 0 ,1 ,2 文件描述符有什么关系呢?首先要看FILE这个类型,其实它是一个结构体类型,既然是结构体,那肯定就有成员变量,又因为系统调用和库函数的上下级关系,所以我们可以得到结论:

  FILE 结构体内部会封装:底层 fd、缓冲区大小、读写位置、刷新标记等一堆成员。C 语言标准库提供的 stdin、stdout、stderr 是三个预先定义好的全局 FILE 类型指针,用来代表程序默认的输入、输出与错误输出流,属于上层封装的 IO 抽象。而 Linux 系统内核层面,会为每个进程默认分配三个固定的文件描述符,分别为 0、1、2。二者是层层封装的绑定关系,stdin 底层对应文件描述符 0,stdout 对应 1,stderr 对应 2。C 标准库的带缓冲读写函数,都是依托这三个 FILE 指针完成操作,其内部最终会取出封装在内的文件描述符,调用 read、write 这类底层系统调用完成真正的 IO 交互。

  简单来说,0、1、2 是操作系统原生的底层标识,stdin、stdout、stderr 是 C 语言为了开发便捷封装出的上层指针变量,二者一一对应。

  像我们平时用 printf / puts / fwrite 这类库函数时:表面操作的是 stdout 这个指针,函数内部最终还是会拆解、拿到里面存的文件描述符 1,最后调用内核 write 系统调用完成输出。同理,scanf / fgets 依赖 stdin,内部拿到底层 fd=0,调用 read 完成读取。

  来印证一下:

  这里的 stdin->_fileno 等,是 Linux 系统下,C 标准库 FILE 结构体中用来存储底层文件描述符的成员变量。

  果然和我们说的一模一样。

  讲到这里,大家会有一些疑惑,一个进程是可以打开多个文件的,但是怎么知道哪些文件属于这个进程,哪些文件又是属于别的进程的呢?系统在内部是怎样维护的?这就要谈到一下文件描述符的本质:

  我用这张图给大家解释:

  先下一个结论:进程和文件之间通过一套内核数据结构完成映射。

  首先,每个进程都有一个 task_struct 结构体,里面包含了一个 files_struct 指针,这个指针指向进程打开文件的管理结构 files_struct。files_struct 里维护了一个 fd_array 数组,数组的每个元素都是一个指向 struct file 结构体的指针,而我们常说的文件描述符,其实就是这个数组的下标。

  这个 fd_array 数组中的前三个下标 0 , 1, 2 就默认存储标准输入、输出、错误。

  当我们调用 open 打开一个文件时,内核会为这个文件创建一个 struct file 结构体,里面包含了文件的状态信息、缓冲区指针和文件位置偏移量等核心属性,并将这个结构体的地址存入 fd_array 数组中,然后返回对应的数组下标给用户。进程后续的 read、write 等操作,都是通过这个文件描述符找到对应的 struct file,再通过它内部的缓冲区与磁盘上的文件内容和属性进行交互。磁盘上的文件只有被加载到内核缓冲区后,进程才能进行读写操作,这也是图中 “对文件进行任何操作,都必须先把文件加载到内存中” 的含义。

  整个流程形成了一个 1 对 n 的关系,一个进程可以打开多个文件,每个文件对应一个 struct file,而文件描述符就是进程访问这些文件的入口,这也是文件描述符的本质。因此在Linux中,操作系统要操作文件的时候,必须使用文件描述符。

  那么既然如此,C语言为什么还要对文件描述符进行一次封装呢?这是因为不同操作系统对标准输入输出的底层实现存在巨大差异,Linux 使用文件描述符,Windows 使用句柄机制,其他平台也有各自独立的设计。若程序直接使用数字 0、1、2,会与特定操作系统强绑定,无法在其他平台正常编译与运行。C 标准库通过FILE*类型将这些系统级差异隐藏在底层,向上提供统一名称、统一接口的标准流对象,程序员只需面向stdinstdoutstderr编写代码,无需关心底层系统细节。这种封装让同一份代码可以在 Linux、Windows、macOS 等多种平台上无缝运行,真正实现了一次编写、多处运行,是 C 语言保持高可移植性的关键设计之一。

2.2.3 重定向

  我们知道了文件描述符的前三个默认描述符:

                  0 → 标准输入(stdin)

                  1 → 标准输出(stdout)

                  2 → 标准错误(stderr)

  那如果我们将这三个文件关掉会怎么样,因为我们在关闭文件的时候,都是 close(fd),从来没有尝试过 close(1)这样的操作,所以我们来试一试:

  我们会发现 fda 这个文件描述符按理来说应该是 3 ,因为前三个文件描述符是默认被占用的,但是我们关掉标准输入的时候,fda 就变成了 0 ,这就证明了文件描述符在进行分配的时候,采用的是最小原则,就相当于从数组的第 0 个位置开始遍历找到的第一个为空的位置。

  但是当我把标准输出 1 关掉的时候,会发现程序在执行时打印不出任何内容。

  并且在log 1.txt这个文件当中竟然会有内容,当我们打开这个文件的时候,会发现本应该打印在显示屏上的内容,出现在了log 1.txt这个文件当中。这,就叫做输出重定向。

  那为什么会出现这样的现象呢?首先根据前面讲的,因为 1 这个文件描述符被关掉了,那么在维护文件的 fd_array  数组里面,就会把下标为 1 的位置的内容释放,并将该位置设为 NULL,当打开文件 log1.txt 的时候,open函数将这个文件放到了 fd_array 数组中下标为 1 的位置,即文件描述符 1 。而 printf 是 C 标准库的输出函数,这个函数也有一个参数是表示文件描述符,它底层默认使用的就是文件描述符 1(也就是 stdout)。当 1 原本指向终端时,printf 就把内容输出到屏幕上。当我们关闭 1,再让 1 绑定到 log1.txt 后,printf 还是会往 1 这个文件描述符里写数据,但此时 1 已经指向文件,所以内容就被写入了 log1.txt

  关于重定向,有一个系统调用:

int dup2(int oldfd, int newfd);

  dup2 是 Linux 提供的文件描述符复制系统调用,主要用于实现文件描述符的重定向。它接收两个参数,分别是源文件描述符 oldfd 和目标文件描述符 newfd,执行时会先自动关闭已占用的 newfd,再将 oldfd 对应的文件结构体复制绑定到 newfd 上,使两个描述符指向同一个文件。该函数最典型的用途是实现标准输入输出的重定向,例如将标准输出 1 绑定到一个磁盘文件上,让原本输出到屏幕的内容全部写入文件。相比于手动关闭再打开的方式,dup2 更加简洁、安全且原子性更强,是 Linux 中实现重定向功能的标准接口。 

  总结一下,它的作用是:把一个文件描述符,强行 “复制 / 绑定” 到另一个指定的文件描述符数字上。

  不过最主要的是要区分dup2的两个参数,到底应该谁作为 oldfd ,谁作为 newfd。大家可以直接这么记:

       int  dup2 (从哪来,到哪去)

  • oldfd = 你真正想用的东西(来源、本体、不变的)
  • newfd = 你要强行覆盖掉的那个编号(目标、被顶替的)

  就比如我们刚刚代码中的 close (1)的操作,可以这样写:

int fd = open("log.txt", ...);
dup2(fd, 1); // 直接把 fd 绑定到 1,自动 close(1)

3. 修改自主模拟实现的Shell

  学习了文件操作和重定向的概念知识,我们现在就可以丰富一下我们上一篇文章中所写的自主模拟实现的Shell程序,上篇文章链接在这里,大家可以自行翻阅:

https://blog.csdn.net/2502_91842264/article/details/160405313?fromshare=blogdetail&sharetype=blogdetail&sharerId=160405313&sharerefer=PC&sharesource=2502_91842264&sharefrom=from_link

  首先我们在之前解析字符串的时候只考虑了常规字符,但因为还有重定向,所以我们在解析字符串之前,还要判断该命令是否需要执行重定向操作。

  在判断是否需要重定向之前,我们先定义一些变量:

  然后来搭建解析是否为重定向指令的函数框架。首先可以分为四个方向:没有重定向、输入重定向、追加重定向、输出重定向,而判断有重定向的3种模式最主要的特征是看他们符号是什么,所以我们函数可以这么写:

  当我们输入指令比如说是 ls -a -l >> xxx.txt 的时候,会先存入到 command_line 数组当中,我们采用的方式是遍历整个数组,查看有没有代表重定向的特征符号。对于追加重定向来说,如果找到第 start 和 第start+1 个位置都是 > 的时候,简单一点直接让该位置的内容变为 \0 ,这样就达到了分割字符的目的,当第二个 > 也被修改成 \0 时,让 start ++ ,此时会出现两种情况,因为有的人写指令的时候 ,比如 ls -a -l >>xxx.txt ,在重定向方式后面不跟空格直接跟文件名,有的人会加空格,所以此处需要做一个判断。判断结束之后,确定了 start 的位置,此时就一定处于文件名的首个字符,那么我们直接让该地址等于 filename 即可。当这一系列操作执行完毕之后,就可以得到重定向方式和目标文件了。

  对于TrimSpace这个函数,我们更推荐写成宏函数的形式,而不是采用普通函数。因为普通函数如果传参的话,传的是 *start ,那么在让 start 这个指针移动的时候,只有函数体内部的这个临时创建的 start 变量移动,而函数体内部的变量生命周期只存在于函数体内部,所以对于我们函数外部真正想要移动的 start 来说,并不会执行任何操作。

  即使要写成函数的形式,也需要写成 void TrimSpace(**start) 的二级指针的形式,这样在移动start位置时写法就是 (*start)++ ,那么通过地址可以实现start的移动。如果想要更好一点,可以把这个二级指针的函数形式写成内联函数,就是 static inline void TrimSpace(**start) 这样的话就可以像宏一样,执行到TrimSpace函数的时候,不用跳转,而是直接把 TrimSpace 函数的内容直接贴过来,提高效率。

  综上所述,我们还是直接写成宏函数的形式最佳。

  首先,在这里我们用到了一个函数 isspace () 它是用于检查某个变量是否为空。其次宏函数里面有一个 do{...}while(0) 的格式,这其实是 do_while(0) 宏包裹写法,利用 do { } while(0) 只会强制执行一次、语法闭合完整、支持末尾加分号的特性,将多行宏语句封装为单一复合语句,解决宏在 if/else、分支、循环场景下的语法断裂、分号歧义问题。

  可以用这个场景去理解 do-while(0) 宏包裹写法,这是我用 deepseek 搜索的样例:

  至此,我们关于是否执行重定向操作的检查函数就写完了。  来测试一下:

  这样就能提取出重定向模式、目标文件、实际指令三种信息,然后在执行命令时做处理:

  我们通过读取 redir_type 和 filename 的信息,完成了重定向的操作。

4. 缓冲区

4.1 基本概念

  缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,在「慢速设备」和「快速 CPU / 内存」之间做数据中转,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。

4.2 缓冲区的作用

  读写文件时,如果不开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作 (读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到 CPU 状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的 CPU 时间,频繁的磁盘访问对程序的执行效率造成很大的影响。

  为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制。比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。

  又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的 CPU 可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和 CPU 之间,用来缓存数据。它使得低速的输入输出设备和高速的 CPU 能够协调工作,避免低速的输入输出设备占用 CPU,解放出 CPU,使其能够高效率工作。

4.3 访问文件的流程

  当应用程序在用户空间调用read函数发起文件读取请求时,系统会通过文件描述符完成文件定位,并依托内核缓冲区高效完成数据获取。

  应用程序使用 read 函数,并传入文件描述符、用户空间缓冲区地址与数据长度后,程序从用户态切换至内核态,即程序主动请求操作系统内核来帮它完成一些 “只有内核才能做的事情”,内核接管 CPU,开始替程序干活。内核以文件描述符作为进程文件描述符表的下标,定位到代表已打开文件的struct file结构体。

  struct file并不存储文件数据,仅通过指针关联由内核独立管理的内核级缓冲区(页缓存),内核会优先检查目标数据是否已加载至该缓冲区:若数据已缓存,直接将数据从内核缓冲区拷贝至用户指定的缓冲区;若未缓存,先会让当前进程进入阻塞状态,然后向磁盘发起 I/O 请求,将文件数据从磁盘加载到内核缓冲区后,再执行数据拷贝操作。待数据传输完成,系统从内核态切回用户态,read函数执行完毕。

  因此,我们可以说:read函数的本质是拷贝函数。

  其中,内核级缓冲区是 Linux 内核空间的独立内存区域,由内核统一调度、所有进程共享,并非存储在struct file结构体内部,仅通过指针与该结构体建立关联。

  关于上面的 read 函数,我们来介绍一下并且运行一段代码试试看效果:

  read函数中的 fd 为文件描述符,用于定位目标文件;buf 为用户态缓冲区地址,用于存放读取的数据;count 为期望读取的字节数。函数返回值为 ssize_t 类型,若返回值大于 0,表示实际读取到的字节数;返回值为 0,表示已到达文件末尾;返回值为 -1 则表示读取失败,错误原因由全局变量 errno 标识。它的作用是:直接向内核发起读取请求,将数据从文件 / 设备读入用户缓冲区

  对于这段代码,我们先定义了一个用户态缓冲区 buffer ,大小为 1024 个字节,之所以叫做用户态缓冲区,是因为根据进程地址空间,我们在 main 函数内部定义的变量都是存储在栈上的,如果不在main函数内部,定义成全局变量,这叫做初始化数据,但不管是初始化数据还是栈上的数据,对应的都是用户空间。

  然后read 的第三个参数写成  sizeof(buffer) - 1 是为了让 buffer 变成合法的 C 字符串,因为 read 只会拷贝原始字节,不会自动添加结束符,如果不手动添加,printf("%s") 会一直读取内存,直到遇到随机的 \0,这样导致乱码或程序崩溃。

4.4 用户态和内核级缓冲区

  我们前面提到了用户态缓冲区和内核级缓冲区  那么这两个到底是什么呢?

  首先是用户态缓冲区,它由C/C++ 标准库(stdio) 提供的缓冲区,属于应用程序层面的缓存,我们前面提到过,对于C语言标准库里的 fopen 函数等等,都有一个类型是 FILE* ,我们探讨过这个FILE是一个结构体,存储了文件描述符等内容:

  实际上还有输入输出缓冲区,也就是说,当我们调用 fwrite 等函数的时候,会先把内容拷贝到这个文件内部结构体的输入输出缓冲区当中,等进程结束时再统一刷新。

  而内核级缓冲区是由 Linux 内核 统一管理的高速文件缓存,只要用 read、write 系统调用,一定会经过它,是操作系统自带的底层缓冲。它是内核维护的一整片连续 / 离散的物理内存页集合,用来临时存放磁盘文件、设备的读写数据;打开文件的 struct file 结构体不存数据,只通过内部指针,指向这片内核缓存空间。

  总结一下:语言级缓冲区存在于用户进程的用户态空间,是标准库维护的私有内存空间,依附于 FILE 结构体,仅对库函数生效;内核级缓冲区位于内核态空间,由操作系统统一调度、全局共享,通过 struct file 内部指针完成关联,所有文件读写的系统调用都必须经过该缓冲区。

  大家用这个问题可以更好的理解用户态缓冲区和内核级缓冲区:请问C语言库函数 fputs 和系统调用 write 在使用时,哪个函数的效率更高速度更快?

  答案是:绝大部分情况下 fputs 更快,当数据较大时,二者相差甚微。

    首先,我们之前提到过:库函数是对系统调用的封装,也就是说 fputs 函数到最后肯定会执行write 系统调用。 另外 ,fputs 属于 C 语言标准库函数,调用时不会直接执行内核操作,而是先将数据拷贝至 FILE 结构体维护的用户态缓冲区中暂存,只有满足缓冲区刷新条件时,才会统一调用 write 系统调用,切换至内核态完成数据落盘;而直接使用 write 属于裸系统调用,每执行一次就会立刻发生一次用户态与内核态的切换。由于态切换开销较大,频繁小块写入时,write 会反复产生高额损耗,fputs 依靠用户缓冲区合并多次 IO、减少系统调用次数,因此绝大多数场景下执行效率远高于 write。

  最后展示一下在Linux内核里的源代码:

  这里还做了一个重命名处理,typedef _IO_FILE FILE 所以我们所看到的FILE*就是它,这里面确实包含了文件描述符和输入输出缓冲区等等。

4.5 理解缓冲区刷新问题

  我们用一段代码来引入一个问题:

  第一段执行结果是没有追加close(fd) 这一行代码的结果;第二段执行结果是加上close (fd)之后的结果。我们在之前的讲解中提到过,把文件描述符 1 关闭之后,fd 就会顺位到 fd_arrary 下标为 1 的位置。然后将 fd 这个文件描述符作为标准输出,所以会将 fd :%d 这一行内容打印到 log.txt 文件中。

  但是当关闭 fd 之后,为什么 log.txt 就显示不出来了呢?

  这是因为:printf 把数据写入了用户态行缓冲区,close(fd) 会直接关闭文件描述符 1切断了用户态缓冲区和内核文件对象的关联。此时,printf 的用户态缓冲区里的数据,还没来得及被 write 系统调用刷入内核缓冲区。当文件描述符被关闭后,printf 后续的刷新动作(比如程序退出时库的自动刷新),已经找不到对应的文件对象了,数据就永远留在了用户态缓冲区里,无法被写入磁盘。

  此时如果要保持 close(fd) 代码不变,又想要看到 log.txt 里的内容,就要手动刷新一下:

  这里fflush(stdout) 的本质,是强制触发 stdout 关联的用户态缓冲区的刷新操作,通过调用底层 write 系统调用,将数据一次性写入内核缓冲区,而 fflush(stdout) 内部调用 write 时所需的文件描述符,来源于 stdout 对应的 FILE 结构体内部存储的 fd 成员变量。标准库在初始化或重定向时,会将文件描述符保存到 FILE 结构体中,刷新时直接取出该 fd 传入 write 完成内核写入。

  刚刚的情况是我们手动调用fflush函数进行手动刷新,但实际在环境当中会有4种刷新方式,首先最主要的一种就是:所有语言级缓冲区(stdout/fopen 打开的文件),在进程正常结束时,都会 自动强制刷新

  还有另外三种缓冲刷新方式:

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

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

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

  接下来我们来看一段代码:

  对于这两段代码,我都是用C语言中库函数和系统调用分别去打印内容,但唯一的区别是第一段代码里面没有创建子进程,第二段代码里面创建了子进程。打印的结果大不相同。

  按理来说我们知道子进程会继承父进程的代码还有task struct等其他信息,但是这里的fork( ) 是在打印函数之后的,应该不会再重新打印相同的内容才对。并且奇怪的是只打印了C语言库函数调用时打印的内容,而系统调用的内容没有重复出现。这是为什么?

  这是因为调用 fork(),创建子进程,子进程会复制父进程的用户态内存,包括语言级缓冲区里还没刷出去的内容,即C语言库函数调用后打印的内容。这些未刷新的数据会被子进程继承。当父进程继续执行到 return 0,进程正常退出,C 标准库会自动刷新缓冲区,把用户态里的三条数据 write 到内核,写入 log.txt。因为子进程复制了父进程的缓冲区,所以执行到 return 0 时,也会自动刷新缓冲区,把同样的三条数据再 write 一次,所以库函数的内容重复了。

  而 write 是直接调用系统调用,数据在调用时就已经写入内核缓冲区了,不会停留在用户态内存里。并且 fork() 复制的是用户态内存,内核缓冲区的数据不会被复制。父进程的 write 已经把 hello write 写入内核,子进程没有再执行 write,所以这条内容不会重复。

  如果想要避免这种情况,可以在 fork() 前主动调用 fflush(stdout) 刷新缓冲区,就能让数据提前写入内核,避免子进程继承。

5. 自主模拟封装文件操作函数

  经历了前面的关于文件的学习,我们现在对C语言库又有了进一步的了解,所以我们来依靠函数调用,以及部分C语言库内的函数,尝试自主模拟实现一个关于文件操作函数的静态库。

  首先创建三个文件,main.c 用于测试代码,mystdio.c 和 mystdio.h 分别用来定义和声明函数。

  在头文件当中先定义一个结构体类型myFILE和四个函数:

  分别是打开文件、写入数据、刷新缓冲区、关闭文件。

  对于myFILE结构体我们初步是这样设计的,对于刷新模式我们定义三个宏。

5.1 myfopen函数

  然后我们先编写myfopen函数:

  首先我们这里的打开函数底层依靠的是 open 系统调用。先判断是哪个打开文件方式,再获取打开的文件的文件描述符。这里比较容易绕的是 fp->fd = fd ;

  首先因为我们的函数是 myFILE* 类型,最后要返回一个打开的文件的结构体指针。所以我们一定要自己创建一个myFILE*类型的指针,用于存储信息,也就是说我们最后要返回的一定是 fp 这个指针。而我们的根本目的是获取 fp 这个指针内部的数据信息,所以 fp -> fd 指的就是要返回的这个结构体的文件描述符,让它等于函数内部打开文件时获取到的文件描述符 fd 。后续的两行也是一个逻辑。

  对于标准库里的缓冲区,在刷新完之后会将整个缓冲区全置为 0 以达到清空缓冲区的目的,我们可以直接移动 pos ,让它到首位实现下一次写入缓冲区时的覆盖。

5.2 myfputs函数

  我们前面提到过,向缓冲区内写入数据,实际上是将这个数据直接拷贝到缓冲区内。因此写入函数的本质就是拷贝,这就可以借助memcpy函数实现一下:

  参数意思分别是:目标地址、源地址、拷贝字符长度。

  对于这段代码,我们memcpy里的第一个参数,在拷贝时需要考虑到写入的位置,因为有可能这一次写入时,当前缓冲区内还没有清空,所以还需要加上 fp->pos 。另外,对于写入时遇到的要刷新的情况我们也要考虑,如果是行缓冲,并且写入的数据带有 \n 我们再调用write系统调用刷新缓冲区。

  要注意的是这里的判断条件用到了 按位与 操作,因为我们在 stdio.h 文件当中 将 LINE_BUFFER设置成了 1 ,它的二进制为 0b1 ,当我们用 fp->flush_mode 与其进行按位与时,只有当 fp->flush_mode 本身也是LINE_BUFFER,按位与的结果才能是 1 ,否则就是 0。因为按位与的逻辑就是比较比特位,两者的比特位都为1 的时候,此位结果为 1 ,否则为 0 。

  这里之所以这样操作,是因为:刷新不是无条件的,需要根据缓冲模式和写入内容判断是否需要触发,只有满足条件时才调用 write,减少系统调用开销。

  不过现在的写法是把判断逻辑内嵌在了 myfputs 里,只是因为方便大家看代码,更规范的做法是把刷新逻辑封装成独立的 myfflush 函数,然后在 myfputs 里调用它。

5.3 myfflush函数

  在这里我们将刷新函数设计成两种不同模式,对应强制刷新和尝试刷新。

  尝试刷新作为缓冲机制的核心逻辑,仅在满足预设条件时触发系统调用,目的是最大化减少 write调用次数、降低用户态与内核态切换开销:行缓冲模式下仅当写入内容包含换行符\n时,才将缓冲区数据刷入内核;全缓冲模式下仅当缓冲区被写满时,才批量写入内核;无缓冲模式则每次写入后直接刷新,保证数据实时输出,这种模式完全遵循标准 C 库的缓冲行为,在不破坏性能优化目标的前提下实现了缓冲的自动管理。

  强制刷新则是对外暴露的接口逻辑,通过myfflush函数实现,用于处理必须立即落盘的场景,不依赖任何缓冲条件:既支持用户主动调用myfflush强制将缓冲区中所有数据写入内核,也能在缓冲区空间不足时强制刷新以腾出空间避免后续写入越界,同时保证文件关闭前剩余数据被完整刷入内核,防止数据丢失。

  我们将两种模式的刷新逻辑封装为统一的myfflushcore函数,通过标志位区分执行逻辑:内部自动刷新(如myfputs写入后的条件判断)使用尝试刷新模式以保证缓冲性能,外部主动控制(如用户调用myfflush、文件关闭)则使用强制刷新模式以保证数据可靠性。

  综上所述翻译成人话就是:当用户主动调用myfflush函数的时候,肯定是想让缓冲区的数据立即刷新,所以我们对外暴露的函数是强制刷新,不管是全缓冲还是行缓冲还是无缓冲,直接调用write函数。但是对于myfputs内部的刷新操作,我们采用尝试刷新,这样就会按照刷新模式去刷新,可以降低函数调用 write 系统调用转为内核态的次数,提高效率。

5.4 myfclose函数

  这个函数封装比较简单,就不过多介绍了,大家自行观看即可。

6. 理解标准错误

  标准错误是 系统进程默认打开的三个标准流之一,对应文件描述符 2,它是专门为错误信息设计的独立输出通道,与负责正常业务信息的标准输出(stdout,文件描述符 1)相互分离

  这种设计的核心意义,首先是实现输出的逻辑隔离,让程序的正常输出与报错、警告等异常信息互不混杂,既避免日志混乱,也为运维场景下的日志分类管理提供了基础;

  可以把程序想象成一家门店:

  • 标准输出 stdout 是正常业务窗口,负责给用户推送正常结果、普通日志;
  • 标准错误 stderr 是异常投诉 / 故障窗口,只管报故障、抛错误、弹警告;两条通道分开走,互不干扰,正常业务是正常业务,错误是错误,不会混在一起。

  其次,它支持重定向分离,可将正常日志与错误日志分别定向到不同文件,实现独立归档与监控,这在自动化脚本与服务运维中至关重要;更关键的是,标准错误默认采用无缓冲模式,不会像标准输出那样因用户态缓冲而延迟输出,能保证错误信息实时写入终端,即便程序崩溃也能完整输出关键故障日志,为问题排查提供可靠依据。

  它的设计逻辑,本质上就是为了在普通输出追求性能的缓冲优化之外,为异常场景保留一条高可靠、低延迟的独立通道,这也是我们实现无缓冲模式时可以直接参考的设计范式。

  本文到此结束,感谢各位读者的阅读,如果有讲解的不到位或者错误的地方,欢迎各位读者的批评或指正。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐