基础IO --- 上

目录

1. 复习C文件IO相关操作

1.1. fopen && fclose

1.2. fputs 

1.3. fgets

1.4.  "w" 和 "a"的区别

1.4.1. 以 "w" 方式打开文件

1.4.2. 以"a"方式打开文件

1.4.3. 总结

2. 为什么要存在文件的系统调用接口

3. 什么叫做当前路径

4. stdin && stdout && stderr

5. 文件的Linux系统调用接口

5.1. 用户层面给内核(kernel)传递标志位的常见做法

5.2. open

演示一:

演示二:

演示三:

5.3. close

5.4. write

5.5. read

5.6. 关于这个ssize_t是什么类型

6. 认识文件描述符

6.1. 验证一

6.2. 验证二

6.3. 如何理解被打开的文件

注意:struct file 和 FILE的区别

6.4. 什么是文件描述符呢

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

6.5.1. 验证一

6.5.2. 验证二

6.5.3. 验证三

6.5.4. 结论:

6.6. 输出重定向:

6.6.1. 输出重定向的代码示例: 

6.6.2. 如何理解输出重定向

6.6.3.  命令行上如何实现输出重定向 

 6.7. 输入重定向

6.7.1.  输入重定向的代码示例

6.7.2. 输入重定向的分析和理解 

6.7.3. 命令行上如何实现输入重定向

6.8. 追加重定向

6.8.1. 追加重定向的代码示例

6.8.3. 命令行上如何实现追加重定向

6.9. dup2系统调用 

6.9.1. 用dup2实现输出重定向

6.9.2. 用dup2实现输入重定向

6.10. 1号文件描述符和2号文件描述符的区别

6.10.1. 关于perror的认识

6.10.2. perror函数的简单模拟实现

7. 如何理解一切皆文件

8. 缓冲区

8.0. 如何体现缓冲区是存在的

8.1. 缓冲区是什么

8.2  为什么要有缓冲区

8.3. 缓冲区的刷新策略

8.4. 解释现象

8.4.1. 解释现象一

8.4.2. 解释现象二

8.4.3. 解释现象三

9. 模拟实现

9.1. 模拟简陋版本的shell,包含重定向功能


1. 复习C文件IO相关操作

1.1. fopen && fclose

/*
* man 3 fopen 
* #include <stdio.h>
* path:        代表你要打开的文件所在的路径
* mode:        代表你打开文件的模式   
* return val:  返回一个文件指针 ---> FILE*
*/
FILE *fopen(const char *path, const char *mode);

/*
* fp: 你要关闭的那个文件指针
* return val:
* on success: return 0;
* on failure: return EOF;
*/

int fclose(FILE *fp);
"r"Open text file for reading.  The stream is positioned at the beginning of the file.
"r+"Open for reading and writing.  The stream is positioned at the beginning of the file.
"w"Truncate file to zero length or create text file for writing.  The stream is positioned at the beginning of the file.
"w+"Open for reading and writing.  The file is created if it does not exist, otherwise it is truncated. The stream is positioned at the beginning of the file.
"a"Open for appending (writing at end of file).  The file is created if it does not exist.  The stream is positioned at the end of the file.
"a+"Open  for reading and appending (writing at end of file).  The file is created if it does not exist.  The initial file position for reading is at the beginning of the file, but output is always appended to the end of the file.

1.2. fputs 

/*
* s 代表被写入的字符串
* stream 代表你要向特定的文件流写入
*/
int fputs(const char *s, FILE *stream);

fputs的代码演示:

void Test1(void)
{
  //第一个接口
  // 以w的方式在cwd代开log.txt,如果log.txt不存在该目录下,该进程会自动创建一个log.txt文件,并返回一个FILE* 
  FILE* fp = fopen("log.txt","w");
  if(fp == NULL)
  {
    perror("open failed");
    exit(1);
  }
  else
  {
    const char* str = "cowsay hello\n";
    int count = 5;
    while(count--)
    {
      fputs(str,fp);
    }
    fclose(fp);                                                                             
  }
}

现象如下: 

1.3. fgets

/*
* s:          缓冲区,将读取的内容存放到这个s缓冲区里
* size:       缓冲区的大小
* stream:     特定的文件流
* on success: 返回s的起始地址  
* on failure: 返回NULL
* fgets 会从特定的文件流中读取内容放到s这个缓冲区里,size代表缓冲区的大小
*/
char *fgets(char *s, int size, FILE *stream);
void Test2(void)                                                              
{                                              
  // 以读的方式打开一个已经存在的文件
  FILE* fp = fopen("log.txt","r");
  if(NULL == fp){   
    perror("open failed");                                                     
    exit(1);                                                          
  }                                                                   
  else                                                                
  {                                                                   
    char buffer[64] = {0};                                            
    while(fgets(buffer,sizeof buffer,fp))                             
    {                                                                 
      printf("%s",buffer);                                            
    }
    // 注意: feof 并不是用来判断文件读取是否结束
    // 而是用来当文件已经读取结束,用来判断是遇到了EOF结束,还是读取错误结束的
    // 如果是遇到了EOF导致读取结束,那么feof 返回 !0值
    // otherwise 返回0
    if(!feof(fp)){                                                    
      printf("fgets quit not normal!\n");
    }
    else{
      printf("fgets quit normal!\n");
    }
    fclose(fp);                                                                             
  }                                  
}

现象如下: 

C的接口复习就到这里,我们今天的主题当然就不是这些接口啦,我们只是简单的看看罢了。不过在这之前,我们要解决一个小问题; 

1.4.  "w" 和 "a"的区别

1.4.1. 以 "w" 方式打开文件

void Test3(void)                                      
{                                                     
  // "w" 代表着 写入
  // 即此时返回的文件指针指向文件的起始位置
  FILE* fp = fopen("log.txt","w"); 
  if(NULL == fp)                                      
  {                                                   
    perror("open failed");                            
    exit(1);                                          
  }                                                   
  else                                                
  {                                                   
    const char* str = "cowsay world\n";               
    int count = 3;                                    
    while(count--)                                    
    {                                                 
      fputs(str,fp);  
    }  
    fclose(fp);                                                                             
  }       
}

现象如下: 

在此基础之上,我们在以 "w" 的方式打开一下这个文件,不做其他的动作,如下: 

void Test3(void)
{
  // 以"w"的方式打开一个文件
  FILE* fp = fopen("log.txt","w");
  // 不做其他的动作,直接关闭这个文件指针
  fclose(fp);
}

现象如下: 

1.4.2. 以"a"方式打开文件

void Test4(void)                                      
{                                                     
  // "a" 代表着 追加写入
  // 即此时返回的文件指针指向文件的末尾
  FILE* fp = fopen("log.txt","a"); 
  if(NULL == fp)                                      
  {                                                   
    perror("open failed");                            
    exit(1);                                          
  }                                                   
  else                                                
  {                                                   
    const char* str = "cowsay world\n";               
    int count = 3;                                    
    while(count--)                                    
    {                                                 
      fputs(str,fp);  
    }  
    fclose(fp);                                                                             
  }       
}

现象如下:

1.4.3. 总结

以"w"方式打开文件和以"a"方式打开文件,如果这个文件不存在,那么二者都会创建这个文件;如果这个文件存在,那么前者会先清空文件的内容,在进行写入,并且返回的文件指针指向文件的起始位置,而后者不会清空文件的内容,返回的文件指针指向文件的结束位置,在进行写入!!!

2. 为什么要存在文件的系统调用接口

我们知道,未打开的文件(例如可执行程序,C/C++源文件,配置文件等等)是在磁盘(硬件)上,那么我们访问这些文件,本质上是谁在访问呢??? 答案是:进程在访问这些文件,更确切地说是进程通过接口访问文件!!!而我们知道,接口实际上又分为:语言级别的接口操作系统级别的接口

那么既然未打开的文件是在磁盘上的,那么向文件写入是不是等价于向硬件写入!!!既然是硬件,那么请问,在计算机体系中,谁可以真真正正地访问硬件???

答案是:只有操作系统可以访问硬件!!!

可是,如果我是站在语言级别,我也想访问硬件呢??? 那么必须让操作系统提供文件的系统调用接口!!!因此,在用户和OS之间必须提供一层中间软件层,这一层就是OS提供的各种系统调用接口!!!

我们首先可以确定的是,语言级别的文件接口本质上是进程通过这些接口访问硬件 ,而只有OS才有访问硬件的权力!!!因此,语言级别的文件接口必然要对OS提供的系统调用接口进行各种封装!!!

可是,既然OS都提供了文件的系统调用接口,那么,为什么语言还要自己对OS的系统调用接口进行各种封装形成自己的语言级别的接口,且各个语言封装后的接口不一样!为什么呢???为什么语言不直接使用OS提供的接口呢???

我们今天主要谈论两个理由!

理由一系统调用接口的使用成本太高!!!例如进程的程序替换,如果要深刻的理解和运用其相关的接口(即exec系列的函数) ,那么我们需要知道PCB、地址空间、页表、物理内存等等它们之间的关联,还需要知道命令行参数、环境变量等等,即系统调用接口的使用成本是非常高的!!!因此,各个语言几乎都会对系统调用接口进行封装,在可以达到目的的前提下,将使用成本降低!!!

理由二语言是需要具有跨平台性的!!!如果语言不提供对文件的系统调用接口的封装,那么是不是所有的访问文件的操作,都必须直接使用OS的系统调用接口!!! 也就是说,在Linux平台下,只能使用Linux提供的系统调用接口,而在Windows平台下,需要使用Windows提供的系统调用接口,那么语言也就不具备了跨平台性!!!故语言为了具有跨平台性,必须对系统调用接口进行封装!!!在Linux平台下,对Linux的系统调用接口进行封装,在Windows平台下,对Windows的系统调用接口进行封装,而对于上层用户来说,他们使用的都是库提供的统一接口,只不过这些接口在不同的操作系统平台下,其底层实现不一样(因为OS不一样,那么OS提供的系统调用接口自然也不一样),上层用户可以使用统一的接口来访问操作系统的功能,而无需关心不同操作系统底层的差异。这使得程序具有跨平台的能力,可以在不同的操作系统上运行,而无需针对每个操作系统编写不同的代码,而这就实现了语言的跨平台性!!!

3. 什么叫做当前路径

进程的当前工作路径(Current Working Directory,缩写为CWD)是指操作系统认为当前正在执行的进程所基于的默认路径。换句话说,它是操作系统在解析相对路径时使用的起点路径

当前工作路径在进程级别上是唯一的,意味着每个运行的进程都有自己的当前工作路径。当进程需要打开文件、寻找资源或执行其他与路径相关的操作时,默认情况下,操作系统会将相对路径解析为相对于当前工作路径的绝对路径

进程的当前工作路径通常在进程启动时由操作系统设置为可执行程序所在的路径。这意味着,如果从命令行或文件管理器启动一个可执行程序,进程的当前工作路径将与可执行程序所在的路径相同。但是,这个值可以在进程运行时通过特定的系统调用或函数进行更改。

了解进程的当前工作路径对于程序设计和文件操作很重要。进程可以根据当前工作路径来确定相对路径的解析位置,或者根据需要更改当前工作路径来执行特定的文件操作或资源访问。

当我将这个可执行程序切换路径后,那么当运行起来后,这个进程的工作路径也随之改变了: 

那么就有人说进程的工作路径和可执行程序所在的路径是一致的!!!这句话对吗???答案是,不完全正确,因为有时候,可执行程序所在的路径和进程的工作路径不相等,为什么这样说呢???进程的工作路径可以在进程运行期间被修改。例如,通过  cd 命令或者 chdir 函数,我们可以改变进程的当前工作路径,使其不同于可执行程序所在的路径。这意味着,可执行程序所在的路径并不决定进程的工作路径

如下面的例子:

void Test5(void)
{
  printf("PID: %d, 当前工作做路径为:\n",getpid());

  sleep(30);

  printf("更改当前进程的工作路径: \n");

  chdir("/home/Xq");

  sleep(500);
}

现象如下: 

可以看到,在更改路径之前,可执行程序的路径和进程的当前工作做路径是一致的

可以看到,在更改路径之后,可执行程序的路径和进程的当前工作做路径是不一致的

因此,我们只能说,在大部分情况下,可执行程序的路径和进程的工作路径是一致的但并不绝对,因为有些情况下,进程在运行期间可以更改进程自身的工作路径,故我们的结论是: 可执行程序所在的路径并不决定进程的当前工作路径!!!此外,在大多数操作系统中,可执行文件路径和进程的工作路径都可以通过不同的方式被修改。一些操作系统允许在命令行中声明进程的工作路径,而不影响可执行程序的路径。因此,在特定的环境下,进程的工作路径和可执行程序所在的路径可能会出现不一致的情况。

4. stdin && stdout && stderr

我们需要知道,Linux的设计哲学是:一切皆文件;也就是说,显示器、键盘、磁盘、网卡等硬件我们都可以看作为一个一个的文件!而上面经过我们对C语言的一些接口的复习,我们至少知道一点:向一个文件写入/读取数据,是不是应该先打开一个文件呢???答案是:是的!!!

可是,我们看看下面的代码:

void Test1(void)
{
  printf("hello,you can see me!\n");
}

这段代码的现象,就是向显示器写入一段信息,以前,我们可能对这没有什么疑惑,但现在,就有问题了! 我们知道Linux的设计哲学:一切皆文件,那么显示器是文件吗??? 答案是:是的!!! 既然是文件,要向文件内写入数据,难道不应该先打开这个显示器文件吗???可是在我们以前的学习中,我写这种代码时,从来没有打开这个显示器文件啊,但是,最后当跑起来的时候,结果却可以打印!!!那么这该如何解释呢??? 首先,我们要明确一点!要向某个文件写入数据,那么必须先打开这个文件!!!可是,在这里我们却没有打开这个显示器文件,却能正常写入数据,那是怎么回事呢??? 答案是:当一个C可执行程序变成进程的时候,该进程会默认打开三个文件流,它们分别是:stdinstdoutstderr;如下:

#include <stdio.h>

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

这三个文件流分别对应着各自的硬件,如下:

stdin    对应的硬件设备叫做   键盘

stdout  对应的硬件设备叫做  显示器

stderr   对应的硬件设备叫做  显示器      

而我们之前说过,fputs可以像特定流写入数据,那么你在这又说了,stdout这个流对应的就是显示器,那么向stdout写入数据,会发生什么呢???

​​​​​​​​void Test1(void)
{
  // 本质上是: 向stdout这个流写入数据
  // 而stdout这个流默认对应的是显示器这个硬件
  fputs("hello fputs\n",stdout);
}

现象如下: 

可以看到,的确如我们所想,因为stdout这个流对应的硬件就是显示器,向stdout这个流写入内容,就是向显示器写入数据,而显示器是一个硬件啊!!!也就是说,fputs可以向一般文件或者硬件设备写入数据,这里的一般文件是在磁盘上,而磁盘也是硬件,即体现了一切皆文件,最终都是在访问硬件:显示器、键盘、文件(磁盘),然而,我们知道OS是硬件的管理者,我们以前看过这样的图:

从上面的图,我们也可以看出,只要语言上的文件操作涉及到访问硬件,那么这个文件操作就必须通过OS去访问硬件,而由于OS不相信任何用户,因此要访问OS,那么必须需要通过OS提供的系统调用接口(system call),几乎所有的语言文件接口(例如C的fopen、fclose、fread、fwrite、fgets、fputs、fgetc、fputc等)底层一定需要使用OS提供的系统调用,因此我们必须要学习文件的系统调用接口;

5. 文件的Linux系统调用接口

5.1. 用户层面给内核(kernel)传递标志位的常见做法

在以前学习C/C++的时候,我们传递标志位可能大部分情况下都是以一个整形标识一个状态的,例如:

void Test2(int flag)
{
  if(flag == 1)
    printf("hehe\n");
  if(flag == 0)
    printf("haha\n");
}

那么如果,我想要一个整形表示不同状态,该如何表示呢???

我们知道,一个int类型,有4个字节,那么就有32个bit位;

而我们的思路就是:利用这32个bit中的每一位标识不同状态!!!

例如:

#define ONE 0x1
#define TWO 0x10
#define THREE 0x100

void Test3(int flag)
{
  if(flag & ONE) printf("service logic1\n");
  if(flag & TWO) printf("service logic2\n");
  if(flag & THREE) printf("service logic3\n");
}

int main()
{
  Test3(ONE);
  printf("-------------------------------\n");
  Test3(ONE | TWO);
  printf("-------------------------------\n");
  Test3(ONE | TWO | THREE);
  printf("-------------------------------\n");
  return 0;
}

现象: 

我们就可以通过一个整型的不同bit位定义每一个位所代表的状态的具体含义!!! 而这就是OS通常在用户层面给内核(kernel)传递标志位的常见做法,其实,这种做法本质上是利用了位图的思想!

5.2. open

// man 2 open  系统调用接口,位于man2号手册
// open and possibly create a file or device

#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);

return val:
// on success: return fd;
// on failure: return -1;

上面的函数的flags就是我们在 5.1 讲的通过一个整型传递多个标志位的做法,其本质是,每个bit位代表着不同的数据,且不重复,在内核中有这样的定义:

grep -ER 'O_CREAT' /usr/include/

在下面,有这样的定义:

查看你这个头文件,会发现有如下的内容: 

/* open/fcntl.  */
#define O_ACCMODE	   0003
#define O_RDONLY	     00
#define O_WRONLY	     01
#define O_RDWR		     02
#ifndef O_CREAT
# define O_CREAT	   0100	/* Not fcntl.  */
#endif
#ifndef O_EXCL
# define O_EXCL		   0200	/* Not fcntl.  */
#endif
#ifndef O_NOCTTY
# define O_NOCTTY	   0400	/* Not fcntl.  */
#endif
#ifndef O_TRUNC
# define O_TRUNC	  01000	/* Not fcntl.  */
#endif
#ifndef O_APPEND
# define O_APPEND	  02000
#endif

其中,你会看到:O_CREAT、O_RDONLY、O_WRONLY、O_RDWR、O_TURNC、O_APPEND等等

  • O_CREAT:如果文件不存在,则创建文件,否则直接打开原有文件。通常情况下,如果文件内不存在,此时需要指明文件的访问权限。
  • O_RDONLY:只读方式打开文件。如果文件不存在,则打开失败。
  • O_WRONLY:只写方式打开文件。如果文件不存在,则打开失败。
  • O_RDWR:读写方式打开文件。如果文件不存在,则打开失败。
  • O_TRUNC:如果文件已存在,则打开该文件后会先清空该文件中的内容。
  • O_APPEND:将文件指针定位到文件末尾,以便进行追加写入。

演示一:

当打开一个未存在的文件的时候,如果此时没有带第三个参数(没有指明文件的初始访问权限),即用的第一个open,那么会发生什么呢???

void Test4(void)
{
  int fd = open("./log.txt",O_CREAT);
  if(fd == -1)
  {
    perror("open failed:");
    exit(1);
  }
  else
  {
    printf("open success,fd = %d\n",fd);
  }
}

现象如下:

观察到的现象是:此时是可以成功创建该文件的!但是我们发现,此时这个文件的权限是乱的!!!因此,当我们用 open 打开一个未存在的文件的时候,最好将文件的权限也设置一下,也就是说,调用第二个 open ,如下: 

void Test5(void)
{
  int fd = open("./log.txt",O_CREAT,0664); //0664 代表这个数字是一个八进制数
  if(fd == -1)
  {
    perror("open failed:");
    exit(1);
  }
  else
  {
    printf("open success,fd = %d\n",fd);
  }
}

现象如下: 

可以看到,此时这个文件的权限就是我们设置的权限,即0664,因此,当我们用open打开一个未存在的文件的时候,是需要设该置该文件的访问权限的!!!

演示二:

我们之前在学习语言的时候说过,例如C的fopen,当我们以"w"的方式打开一个文件的时候,如果该文件不存在,那么会自动创建该文件,那么系统调用open也会这么做吗???

void Test6(void)
{
  int fd = open("./log.txt",O_WRONLY,0664); //0664 代表这个数字是一个八进制数
  if(fd == -1)
  {
    perror("open failed:");
    exit(1);
  }
  else
  {
    printf("open success,fd = %d\n",fd);
  }
}

咦,怎么回事,怎么打开失败了呢??? 不是说好的,以写的方式打开一个未存在的文件,会先去创建这个文件吗???可是这里却失败了!!!

在这里我们要说的一点就是:你在语言层看到的一个简单的操作,实际上在系统调用接口甚至OS层面上会做更多的工作!!!

实际上,当我们用open这个系统调用接口的时候,如果想和语言层面上的 "w" 选项达到同样的结果,那么实际上应该如下操作:

void Test7(void)
{
  int fd = open("./log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0664); //0664 代表这个数字是一个八进制数
  if(fd == -1)
  {
    perror("open failed:");
    exit(1);
  }
  else
  {
    printf("open success,fd = %d\n",fd);
  }
}

可以看到,实际上,语言层面上的 "w" 选项,对应到操作系统层面就是如下的代码:

int fd = open("./log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0664);

本质上,语言的 "w" 就是对上面的这个操作进行了封装罢了!!! 故我们学习系统调用接口的理由就更加充分了,只有对系统调用接口有了一定的理解,我们才能真真正正的理解C为我们提供的接口是如何做到的,才可以做到知其然知其所以然!!!!

演示三:

相信,有了前面的理解,我们就可以知道C语言以 "a" 的方式打开文件本质上也是通过系统调用接口 open 实现的,那么具体如何操作呢???

其实非常简单, "a" ,不就是代表着追加写入吗?既然是追加写入,自然再打开文件后,不会先去清空文件内容,此时只需要将清空选项替换成追加选项即可!因此,我们可以有如下操作:

void Test8(void)
{
  int fd = open("./log.txt", O_CREAT | O_WRONLY | O_APPEND, 0664); //0664 代表这个数字是一个八进制数
  if(fd == -1)
  {
    perror("open failed:");
    exit(1);
  }
  else
  {
    printf("open success,fd = %d\n",fd);
  }
}

这就是我们在C语言上面的"a"选项!!!其实对系统调用接口有了一定的理解,也可以帮助我们去理解C语言接口的本质!!!

5.3. close

DESCRIPTION

       closes  a file descriptor, so that it no longer refers to any
       file and may be reused.  Any record locks (see fcntl(2)) held  on  the
       file  it  was  associated  with, and owned by the process, are removed
       (regardless of the file descriptor that was used to obtain the lock).

       If fd is the last file descriptor referring  to  the  underlying  open
       file description (see open(2)), the resources associated with the open
       file description are freed; if the descriptor was the  last  reference
       to a file which has been removed using unlink(2) the file is deleted.

// man 2 close
// close a file descriptor

#include <unistd.h>
int close(int fd);

return val:
// on success: return 0;
// on failure: return -1;

在 Linux 系统中,close() 是一个系统调用函数,用于关闭一个打开的文件描述符(file descriptor)。关闭文件描述符后,该文件描述符将不再可用于对文件的读写操作。

要关闭文件描述符,只需要提供相应的文件描述符作为参数传递给 close() 函数即可。例如,close(fd) 将关闭文件描述符 fd 所关联的文件。

close() 函数的主要作用是释放内核中与文件描述符相关的资源,包括关闭文件引用计数、释放文件使用的内存等。在使用完打开的文件后,及时关闭文件描述符是一种良好的编程实践,可以避免资源泄漏和不必要的资源占用

需要注意的是,关闭文件描述符并不会自动关闭文件本身。如果有其他文件描述符仍然打开着同一个文件,文件将继续保持打开状态。只有当所有文件描述符都关闭后,才会真正关闭文件。

因此,良好的编码逻辑:打开文件后,会得到一个文件描述符,当被打开的文件的访问逻辑处理完后,需要 close(对应的文件描述符);如下:

void Test9(void)
{
  int fd = open("./log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0664); //0664 代表这个数字是一个八进制数
  if(fd == -1)
  {
    perror("open failed:");
    exit(1);
  }
  else
  {
    printf("open success,fd = %d\n",fd);
    close(fd);  // 关闭对应的文件描述符
  }
}

5.4. write

#include <unistd.h>
/*
* fd     文件描述符
* buf    写入的字符串
* count  你期望写入的字节数
* return val:
* on success: 返回实际写入的字节数
* on failure: return -1, 并设置合适的退出码
*/
ssize_t write(int fd, const void *buf, size_t count);

write的演示:

void Test1(void)
{
  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  else
  {
    const char* message = "write a message!\n";
    //在向文件写入内容的时候,不需要写入'\0','\0'做为字符串的结束标志位,只是C的规定
    ssize_t ret = write(fd,message,strlen(message));
    if(ret == -1)
    {
      perror("write failed");
      exit(-2);
    }
    else
    {
      printf("write success!\n");
      printf("expected to write: %ld\n",strlen(message));
      printf("actually written: %ld\n",ret);
    }

    close(fd);
  }
}

结果: 

5.5. read

#include <unistd.h>
/*
* fd   对应的文件描述符
* buf  用户层的缓冲区
* count 用户期望读取的字节数
* return val:
* on success : return 实际读取的字节数
* on failure: return -1
*/
ssize_t read(int fd, void *buf, size_t count);

read的演示:

void Test2(void)
{
  // 因为log.txt是一个存在的文件,因此可以调用不带权限设置的open
  int fd = open("log.txt",O_CREAT | O_RDONLY);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  else
  {
    char buffer[128] = {0};
    ssize_t ret = read(fd,buffer,sizeof(buffer) - 1);
    if(ret == -1)
    {
      perror("read failed");
      exit(-2);
    }
    else
    {
      // 由于文件内容是没有'\0'的(一般情况下),那么读取文件后,没有'\0',因此这里要设置一下
      buffer[ret] = 0; 
      printf("read success!\n");
      printf("expected to read: %ld\n",sizeof(buffer)-1);
      printf("actually read: %ld\n",ret);
      printf("read data: %s\n",buffer);
    }

    close(fd);
  }
}

结果: 

5.6. 关于这个ssize_t是什么类型

如果想了解ssize_t究竟是什么类型,可以看看下面的过程:

grep -ER 'ssize_t' /usr/include/unistd.h

我们得到如下结果:

#ifndef	__ssize_t_defined
typedef __ssize_t ssize_t;
# define __ssize_t_defined

可以看到,ssize_t 实际上是__ssize_t的重命名,那么__ssize_t又是什么类型呢? 

查看 unistd.h这个头文件,发现其包含了这个 bits/types.h这个头文件,于是我们继续探索: 

grep -ER '__ssize_t' /usr/include/bits/types.h

得到如下信息: 

__STD_TYPE __SSIZE_T_TYPE __ssize_t; /* Type of a byte count, or error.  */

进入bits/types.h这个头文件,发现其包含了 bits/typesizes.h这个头文件,于是继续探索:

grep -ER '__SSIZE_T_TYPE' /usr/include/bits/typesizes.h

得到如下信息: 

#define __SSIZE_T_TYPE		__SWORD_TYPE

发现,这个__SSIZE_T_TYPE实际上是一个符号常量,其会被替换为__SWORD_TYPE,我们进入bits/typesize.h头文件中,又发现了bits/types.h的痕迹,于是继续探索:

grep -ER '__SWORD_TYPE' /usr/include/bits/types.h

我们发现了重要信息,如下: 

#define	__S16_TYPE		short int
#define __U16_TYPE		unsigned short int
#define	__S32_TYPE		int
#define __U32_TYPE		unsigned int
#define __SLONGWORD_TYPE	long int
#define __ULONGWORD_TYPE	unsigned long int
#if __WORDSIZE == 32
# define __SQUAD_TYPE		__quad_t
# define __UQUAD_TYPE		__u_quad_t
# define __SWORD_TYPE		int
# define __UWORD_TYPE		unsigned int
# define __SLONG32_TYPE		long int
# define __ULONG32_TYPE		unsigned long int
# define __S64_TYPE		__quad_t
# define __U64_TYPE		__u_quad_t
/* We want __extension__ before typedef's that use nonstandard base types
   such as `long long' in C89 mode.  */
# define __STD_TYPE		__extension__ typedef
#elif __WORDSIZE == 64
# define __SQUAD_TYPE		long int
# define __UQUAD_TYPE		unsigned long int
# define __SWORD_TYPE		long int
# define __UWORD_TYPE		unsigned long int
# define __SLONG32_TYPE		int
# define __ULONG32_TYPE		unsigned int
# define __S64_TYPE		long int
# define __U64_TYPE		unsigned long int
/* No need to mark the typedef with __extension__.   */
# define __STD_TYPE		typedef
#else
# error
#endif

其实,观察后,我们也可以猜测__WORDSIZE是什么意思了,当是32位机器时,这个__SWORD_TYPE本质上就是一个int,即ssize_t就是一个int;当64位机器时,这个__SWORD_TYPE本质上是一个long int,即ssize_t是一个long int;至此,我们的探索结束!

6. 认识文件描述符

在上面的分析过程中,我们知道了open的返回值就是一个文件描述符,可是它究竟是什么呢?要解决这个问题,我们先看看下面的代码:

void Test3(void)
{
  int fd1 = open("log.txt1",O_CREAT | O_WRONLY | O_TRUNC,0664);
  printf("fd1: %d\n",fd1);
  int fd2 = open("log.txt2",O_CREAT | O_WRONLY | O_TRUNC,0664);
  printf("fd2: %d\n",fd2);
  int fd3 = open("log.txt3",O_CREAT | O_WRONLY | O_TRUNC,0664);
  printf("fd3: %d\n",fd3);
  int fd4 = open("log.txt4",O_CREAT | O_WRONLY | O_TRUNC,0664);
  printf("fd4: %d\n",fd4);

  close(fd1);
  close(fd2);
  close(fd3);
  close(fd4);
}

上面的代码:连续打开4个文件,并打印open的返回值,即对应的文件描述符,我们观察现象:

观察结果发现,这个返回值非常有意思,它是从3开始,连续递增的整数!可是我们之前说过open的返回值,当open失败后会返回-1,而我们现在创建的文件的返回值即文件描述符是从3开始的,那么0、1、2去哪里了呢?而我们之前也同样说过,一个C的可执行程序当成为进程后,会默认打开三个文件流,分别是stdin、stdout、stderr;这就不禁让人遐想,这二者是否有关联呢???

答案是:没错,这二者的确存在着关联;我们之前说过,stdin、stdout、stderr的类型都是FILE*,而文件描述符的类型是int;那么这个FILE是什么呢?在学习C语言的时候,我们知道,FILE本质上是一个struct 结构体,那么这个结构体谁提供的呢?答案是:C标准库!!!而众所周知,结构体内部一般都会有很多成员!我们在之前同样说过,C文件的库函数内部一定要调用系统调用,那么站在操作系统的角度,是认识FILE,还是文件描述符呢?答案是:只认识文件描述符!!!那么C文件接口会封装系统调用接口,那么C提供的FILE需不需要封装文件描述符fd呢??? 答案是:必须要封装!!!那么也就是说,fd文件描述符是struct FILE内部中的一个成员对吗? 没错!!!

OK,你说了这么多,但是不是应该用代码验证一下呢?当然,接下来我们就用代码实例验证一下:

6.1. 验证一

你不是说,stdin、stdout、stderr分别对应的文件描述符是0、1、2吗???验证1如下:

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

void Test4(void)
{
  printf("stdin->fd: %d\n",stdin->_fileno);
  printf("stdout->fd: %d\n",stdout->_fileno);
  printf("stderr->fd: %d\n",stderr->_fileno);
}

在Linux下的struct FILE结构体中封装的文件描述符发具体为: _fileno; 这个成员就是文件描述符!!!通过上面的现象,我们也可以观察到,stdin、stdout、stderr对应的文件描述符分别是:0、1、2;与我们的预期一致!!!

6.2. 验证二

刚刚说过C语言的文件接口要封装系统调用接口,那么语言级别的文件接口用FILE*,系统调用接口用fd,即文件描述符,是不是这样呢???我们在这里以写举例,验证如下:

void Test5(void)
{
  // 语言级别的接口
  const char* message1 = "hello fwrite\n";
  // stdout 对应的就是显示器文件(默认情况下)
  fwrite(message1,strlen(message1),1,stdout);

  // 系统调用接口
  const char* message2 = "hello write\n";
  //文件描述符1对应的就是显示器文件(默认情况下)
  write(1,message2,strlen(message2));
}

现象如下:

通过验证,我们可以明确FILE是处于语言层面的,而文件描述符才是处于系统层面的!!!

6.3. 如何理解被打开的文件

一个空文件(内容为空),在磁盘上也是会占有空间的,磁盘文件 = 文件内容 + 文件属性,文件是有属性的,而属性也是数据。

因此对文件的所有操作可以分为:a.对文件的内容的操作;b.对文件的属性的操作;

我们知道,所有的文件操作,表现上都是进程执行对应的函数,文件操作换种说法就是进程对文件的操作,一个进程要访问文件,那么必须要先打开这个文件(未打开的文件在磁盘上),而文件要被访问,前提是需要加载到内存中,才能被直接访问(打开文件的本质是将文件相关的属性信息(部分属性)从磁盘加载到内存)!!!那么一个进程可以打开多个文件吗???答案是:可以!既然一个进程可以打开多个文件,那么一般情况下,进程 : 打开的文件可以是 1 : n 的!!!而我们知道,操作系统中是会存在大量的进程的,而进程 : 被打开的文件是 1 : n 的,那么操作系统中必然会存在着大量的打开的文件!既然打开的文件如此之多,那么请问打开的文件需不需要被管理呢?答案是:肯定需要的,所以OS需要把打开的文件在内存中(OS中)管理起来;那么OS如何管理这些打开的文件呢?我们称之为六字真言:先描述、在组织;而我们知道 Linux 是用C语言写的,因此在内核中:OS内部要为了管理每一个被打开的文件,需要构建 struct file 结构体。如下:

struct file
{
    //描述
    //包含了打开的文件的相关属性信息
    //链接属性
    //eg: 
    int fd;  
 
    //组织
    //利用特定的数据结构将它们组织起来   
    //eg: 用双链表的形式组织起来 
    struct file* _prev;
    struct file* _next;  
};

创建struct file对象,充当一个被打开的文件。如果有很多呢,可以用双链表的形式组织起来

注意:struct file 和 FILE的区别

struct file 和 C 标准库中的 FILE 结构体在概念上和功能上有一些联系,但也存在一些区别。

联系:
1. 文件描述符:struct file 和 FILE 结构体都用于在操作系统层面和C语言层面描述打开的文件。它们都存储了用于操作文件的相关信息,如文件描述符、读写位置等。
2. 文件操作:struct file 和 FILE 结构体都用于进行文件的读取和写入等操作。通过操作 struct file 对象或 FILE 对象,可以进行文件读写操作。

区别:
1. 创建方式struct file 是由操作系统内核创建和管理的,用于表示每个打开文件的内核对象。而 FILE 结构体是由 C 标准库定义的,通过 fopen 函数等标准库函数间接创建。
2. 实现细节struct file 的具体实现是由操作系统内核提供的,其内部结构和字段可能在不同的操作系统和内核版本中有所不同。 FILE 结构体的实现和内部字段是由 C 标准库提供的,具体实现可能会因编译器和标准库版本而有所不同。
3. 上下文struct file 存在于操作系统的内核空间,用于进行操作系统级别的管理和控制。而 FILE 结构体是用于用户程序的文件操作,存在于用户空间,用于简化文件操作的接口。
4. 直接性:在 Linux 内核编程中,可以直接操作 struct file 对象进行文件操作,而在普通的 C 应用程序中,一般通过使用标准库中的函数间接操作 FILE 对象。

总的来说,struct file 和 FILE 结构体都用于描述打开的文件,但前者由操作系统内核创建和管理,用于实现内核级别的文件控制和管理;后者是 C 标准库提供的用于简化文件操作的接口,用于应用程序的文件访问。

6.4. 什么是文件描述符呢

可是现在问题又来了,我们说了这么多,这个文件描述符究竟是什么东西呢???它到底代表了什么呢??? 

而在之前的测试过程中,我们发现,文件描述符是从0开始,递增的整数,那么我们会联想到什么呢???

答案是:数组的下标!!! 

在内核中,为了维护进程和被打开的文件的关系,会创建一个数组,这个数组是一个指针数组!!!操作系统可以借助这个指针数组的数组下标通过哈希索引找到对应的 struct file 内核结构对象,这个结构对象包含了文件的文件相关的许多属性和状态!!!

总之,所谓的 fd 即文件描述符本质上就是内核中的一个数组下标!!!

如下图所示:

当打开一个新文件后,如下图:

 

因此我们新打开一个文件的本质是内核会为我们重新创建一个 struct file 这个内核结构对象,将这个 struct file 内核结构对象的地址填入struct file* fd _array[] 这个指针数组里面的3号下标(因为0、1、2被占用了,3是未被占用且最小的下标),然后OS将这里面的下标(即文件描述符)返回给上层用户,我们也就得到了这个fd。

而现在我们知道了,文件描述符的本质就是指针数组fd_array的数组下标,当我们打开一个文件后,操作系统会在内存中创建对应的内核数据结构描述这个被打开的文件,而这个内核数据结构我们称之为 struct file 结构体,用于描述一个被打开的文件的对象,当进程执行open 系统调用时,OS需要将该进程和这个被打开的文件关联起来,而我们知道每个进程都会有自己的PCB,因此,在每个进程的PCB中都会有一个指针即struct files_struct* fs,这个指针会指向一个内核数据结构,即files_struct,这个数据结构中最重要的一部分就是其包含一个指针数组fd_array,这个指针数组中的每一个元素都是一个被打开文件的指针,因此,本质上,文件描述符就是该指针数组的下标,所以,只要拿着对应的文件描述符,我们就可以找到对应的被打开的文件!!!

在Linux内核源码中的 sched.h 中关于 task_struct 有如下定义:

struct task_struct
{
    int exit_code, exit_signal;
    struct mm_struct *mm, *active_mm;
    pid_t pid;

    /* open file information */
	struct files_struct *files;

    // 等等其他成员(这是一个非常大的内核数据结构)
};

而Linux内核源码中的 fdtable.h 头文件关于 files_struct 中有如下定义:

/*
 * Open file table structure
 */
struct files_struct {
  /*
   * read mostly part
   */
	atomic_t count;
	struct fdtable *fdt;
	struct fdtable fdtab;
  /*
   * written part on a separate cache line in SMP
   */
	spinlock_t file_lock ____cacheline_aligned_in_smp;
	int next_fd;
	struct embedded_fd_set close_on_exec_init;
	struct embedded_fd_set open_fds_init;
	struct file * fd_array[NR_OPEN_DEFAULT];
};

 而Linux内核源码中的 fs.h 头文件关于 file 中有如下定义:

struct file {
	/*
	 * fu_list becomes invalid after file_free is called and queued via
	 * fu_rcuhead for RCU freeing
	 */
	union {
		struct list_head	fu_list;
		struct rcu_head 	fu_rcuhead;
	} f_u;
	struct path		f_path;
#define f_dentry	f_path.dentry
#define f_vfsmnt	f_path.mnt
	const struct file_operations	*f_op;
	spinlock_t		f_lock;  /* f_ep_links, f_flags, no IRQ */
	atomic_long_t		f_count;
	unsigned int 		f_flags;
	fmode_t			f_mode;
	loff_t			f_pos;
	struct fown_struct	f_owner;
	const struct cred	*f_cred;
	struct file_ra_state	f_ra;

	u64			f_version;
#ifdef CONFIG_SECURITY
	void			*f_security;
#endif
	/* needed for tty driver, and maybe others */
	void			*private_data;

#ifdef CONFIG_EPOLL
	/* Used by fs/eventpoll.c to link all the hooks to this file */
	struct list_head	f_ep_links;
#endif /* #ifdef CONFIG_EPOLL */
	struct address_space	*f_mapping; 
#ifdef CONFIG_DEBUG_WRITECOUNT
	unsigned long f_mnt_write_state;
#endif
};

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

到了这里,我们已经知道了文件描述符的本质是什么了,那么它的分配规则是如何分配的呢?????验证如下:

6.5.1. 验证一

void Test1(void)
{
  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0644);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  else
  {
    // 为了更明显一点,在这里多打印几次
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    close(fd);
  }
}

现象:

 0、1、2都被占用了,此时在打开一个文件是3,没有任何问题!!!

6.5.2. 验证二

void Test2(void)
{
  // 提前关闭 0号文件描述符
  close(0);

  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0644);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  else
  {
    // 为了更明显一点,在这里多打印几次
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    close(fd);
  }
}

现象:

我们惊奇的发现,此时打开一个文件,其文件描述符竟是0,为什么呢??? 我们查看代码,发现,我们是先将0关闭,然后再打开一个文件,那么此时0就没有被占用,故此时的文件描述符就是0。

那么也就是说,文件描述符的规则是:fd_array这个指针数组中未被占用且最小的数组下标

6.5.3. 验证三

为了进一步验证这个结论,我们进行第三次验证:

void Test3(void)
{
  // 提前关闭 1号文件描述符
  close(1);

  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0644);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  else
  {
    // 为了更明显一点,在这里多打印几次
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
    printf("fd = %d\n",fd);
  }
}

 现象如下:

咦,怎么回事,为什么没有显示器没有打印的内容呢???我们查看log.txt,发现如下内容:

6.5.4. 结论:

首先,通过上面的内容,我们可以确定,我们总结的文件描述符的分配规则是正确的,即使用未分配且最小的数组下标!!!但是我们同时又发现了一个非常奇怪的现象,我们通过printf 函数写入的内容并没有写入到显示器文件中,而是写入了我们新打开的这个文件!!!

而我们将 "本来应该写入到显示器的内容,却写入了其他文件中" 这种现象称之为,输出重定向!!!

6.6. 输出重定向:

在操作系统中,输出重定向是一种将进程的输出流传递到其他位置或设备的技术。通俗的来说:本来应该写入到显示器的内容,却写入了其他文件中;

6.6.1. 输出重定向的代码示例: 

示例代码: 

void Test4(void)
{
  // 预先关闭1号文件描述符
  close(1);

  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }

  // normal open

  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fputs("hello fputs\n",stdout);
  const char* str = "hello fwrite\n";
  fwrite(str,strlen(str),1,stdout);

  fflush(stdout); 
  close(fd);
}

现象:

输出重定向:本来应该写入显示器的内容,却写入了其他文件中!!!

6.6.2. 如何理解输出重定向

众所周知,一个C的可执行程序形成的进程会默认打开三个文件流,分别是:stdin、stdout、stderr;并且它们对应的文件描述符分别是0、1、2;

为了更好地理解输出重定向,我们就以6.6.1. 的代码为例!分析过程如下:

close(1)前

 close(1)后

close(1)会将fd_array这个指针数组1号下标对应的元素的值即fd_array[1]置为NULL;

打开一个新文件,在这里即log.txt文件,如图所示:

打开log.txt后,会将log.txt这个文件的地址赋值给fd_array[1]

 

此时该进程会调用 printf、fprintf、fputs、fwrite 函数,printf 是默认向 stdout 这个流写入数据的,而其它函数通过参数设置也是向stdout这个流写入数据的,而我们知道 stdout 本质是C标准库提供的,其类型为 FILE* ,而 FILE 也是C标准库提供的,是用户级别的数据结构,这个数据结构内部封装了文件描述符(具体为_fileno),而对于 stdout 来说,它里面的 _fileno 即文件描述符就是1,而我们知道,这些接口都是C标准库为我们提供的,其底层一定调用了系统调用接口write,而系统调用接口write只认文件描述符, 也就是说,上面这些C标准库函数当向 stdout 这个流写入时,并不会关心这个1号文件描述符对应的是什么文件,它并不关心这些,它只做一件事,就是通过系统调用接口 write 向1号文件描述符所指向的文件流写入数据,而此时,如上图所示,这个1号文件描述符对应的文件就是log.txt,因此,这些函数都会将数据写入这个log.txt中,而这就是输出重定向,其本质就是:1号文件描述符指向的文件由显示器文件变更为其他文件(例如这里的log.txt)。

6.6.3.  命令行上如何实现输出重定向 

在 Unix 或 Linux系统中,一般使用 > 符号来进行输出重定向。以下是一些常见的用法示例

// 将 "haha hehe" 覆盖写入 data.txt中
// 输出重定向本质上是以写的方式打开data.txt
// 在打开文件后,会先清空文件的原始内容
echo "haha hehe" > data.txt   


// 清空data.txt文件的内容
> data.txt    


// 将特定命令的标准输出和标准错误都重定向到data.txt中(覆盖式的写入)
特定命令 > data.txt 2>&1

现象如下: 

 6.7. 输入重定向

有了输出重定向的认识,我们完全可以类比到输入重定向!输入重定向:本来应该从键盘文件读取数据,现在却从其他文件流中读取数据!!!

6.7.1.  输入重定向的代码示例

示例代码:

void Test5(void)
{
  // 预先关闭0号文件描述符
  close(0);

  int fd = open("log.txt",O_RDONLY);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  
  // normal open
  
  char buffer[128] = {0};

  scanf("%s",buffer);
  printf("scanf: %s\n",buffer);
  rewind(stdin); // 将当前文件指针回到文件的起始位置
  buffer[0] = 0;

  fscanf(stdin,"%s",buffer);
  printf("fscanf: %s\n",buffer);
  rewind(stdin);
  buffer[0] = 0;

  fgets(buffer,sizeof buffer,stdin);
  printf("fgets: %s\n",buffer);
  rewind(stdin);
  buffer[0] = 0;
  
  fread(buffer,sizeof buffer,1,stdin);
  printf("fread: %s\n",buffer);
  
  close(fd);
}

现象: 

6.7.2. 输入重定向的分析和理解 

该进程调用了scanf、fscanf、fgets、fread这些函数,scanf 默认是从 stdin 这个文件流读取数据的,而其他的三个函数通过参数设置也是从 stdin 这个文件流读取数据的!正常情况下, stdin 这个 FILE* 的文件指针指向的是键盘文件,因此这些函数都会等待用户向键盘文件输入数据,进而得到键盘的数据,但是,在这里我们先关闭了 0号文件描述符,也就切断了 0号文件描述符和键盘文件的关联,当我们再次打开 log.txt 时,根据文件描述符的分配规则此时这个 0号下标就是未使用且最小的下标,因此 open 打开 log.txt 返回的文件描述符就是0,即此时 0号文件描述符指向的文件就是 log.txt,而上面的scanf、fscanf、fgets、fread这些函数(默认或者通过参数设置)都是从 stdin 文件流中读取数据,而这个 stdin 的类型是 FILE* ,而我们知道FILE是C标准库提供的一个用户级别的数据结构,这个FILE数据结构里面封装了文件描述符(具体为 _fileno),又因为上面的C标准库的函数其底层一定要调用系统调用接口即 read,而系统调用接口只认文件描述符,那么也就是说,这些函数当从 stdin 中读取数据时,会通过相应的系统调用接口从 stdin 内部封装的文件描述符即 0号文件描述符所指向的文件读取数据,而并不会关心这个 0号文件描述指向的具体是什么文件,而对应到我们上面的例子来说,当 close(0) 后,再打开 log.txt ,此时0号文件描述符指向的文件就是 log.txt ,因此当调用 scanf、fscanf、fgets、fread 时,会从 log.txt 这个文件流中读取数据!!!

因此,我们看到的现象就是:本来应该从键盘文件读取数据,现在却从其他文件流中读取数据,而这种现象我们称之为输入重定向!!!

我们也可以将这个过程用图表示一下

close(0)之前

close(0)后: 

close(0)会将fd_array这个指针数组0号下标对应的元素的值即fd_array[0]置为NULL

打开一个新文件,在这里即log.txt文件,如图所示:

打开log.txt后,会将log.txt这个文件的地址赋值给fd_array[0]

此时当向 stdin 这个流取数据的时候,本质是向 log.txt 这个文件读取数据,因为此时0号文件描述符指向的就是log.txt文件!!!

输出重定向的本质0号文件描述符指向的文件由键盘文件变更为其他文件(例如这里的log.txt)。

6.7.3. 命令行上如何实现输入重定向

输入重定向是操作系统的一种功能,它允许用户将标准输入从键盘重定向到其他文件流,如文件或其他命令的输出。在命令行环境下,通常使用特定的符号来实现输入重定向。

在Linux系统中,通常使用  <  符号来进行输入重定向操作。例如,可以使用以下命令将文件 data.txt 中的内容作为命令 ./my_test的输入

./my_test < data.txt
void Test6(void)
{  
  char buffer[128] = {0};

  scanf("%s",buffer);
  printf("scanf: %s\n",buffer);
  rewind(stdin); // 将当前文件指针回到文件的起始位置
  buffer[0] = 0;

  fscanf(stdin,"%s",buffer);
  printf("fscanf: %s\n",buffer);
  rewind(stdin);
  buffer[0] = 0;

  fgets(buffer,sizeof buffer,stdin);
  printf("fgets: %s\n",buffer);
  rewind(stdin);
  buffer[0] = 0;
  
  fread(buffer,sizeof buffer,1,stdin);
  printf("fread: %s\n",buffer);
  
}

现象如下: 

6.8. 追加重定向

追加重定向和输出重定向非常相似,只不过有一点不同,输出重定向,打开文件会带 O_TRUNC,即清空选项,因此输出重定向会在打开文件后会先将文件内容清空!!!而追加重定向会将 O_TRUNC 替换为 O_APPEND,即打开文件后,文件指针会在文件的末尾,此时就会追加写入,而这就是追加重定向和输出重定向的不同之处 !!!

具体的分析过程,就不做了,在这里就简单实现一下追加重定向。

6.8.1. 追加重定向的代码示例

void Test7(void)
{
  // 预先关闭1号文件描述符
  close(1);

  // 追加重定向: 将 O_TRUNC 清空选项 更改为 O_APPEND 追加选项
  int fd = open("log.txt",O_CREAT | O_WRONLY | O_APPEND,0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }

  // normal open

  // Library calls
  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fputs("hello fputs\n",stdout);
  const char* str = "hello fwrite\n";
  fwrite(str,strlen(str),1,stdout);

  fflush(stdout); 
  close(fd);
}

现象: 

6.8.3. 命令行上如何实现追加重定向

在Unix和Linux系统中,用于追加重定向的符号是 >>。以下是一些示例用法:

// 1. 将特定命令的输出追加到文件末尾
特定命令 >> data.txt

// 2. 将特定命令的标准输出和标准错误都追加到文件末尾
特定命令 >> output.txt 2>&1

 演示:

6.9. dup2系统调用 

上面我们实现重定向的方式都是预先 close 某个文件描述符,可是这种做法太不规范了,实际上我们上面的做法是利用了文件描述符的分配规则,但是,如果想要实现重定向,我们完全可以借助系统调用 dup2 实现重定向!!!

因此,我们先看看 dup2 在 man 手册的描述,如下: 

NAME
dup2 - duplicate a file descriptor

SYNOPSIS
#include <unistd.h>
int dup2(int oldfd, int newfd);

DESCRIPTION 
dup2() makes newfd be the copy of oldfd, closing newfd first if necessary, 
but note the following:

  *  If oldfd is not a valid file descriptor, then the call fails, 
  and newfd is not closed.

  *  If oldfd is a valid file descriptor, and newfd has the same value as oldfd,
  then dup2() does nothing, and returns newfd.

RETURN VALUE
On success, these system calls return the new descriptor. 
On error, -1 is returned, and errno is set appropriately.

经过上面的描述,我们可以看出,dup2会将 oldfd copy to newfd那么最后的值必然是和oldfd一致的!!!但现在有一个问题:拷贝的内容是什么呢? 难道是将这两个整数进行拷贝吗???

答案是: dup2 不是将这两个整数进行拷贝,拷贝整数没有任何意义!!!我们知道,文件描述符本质上是struct files_struct内核数据结构中的fd_array这个指针数组的下标,那么这里的拷贝实际上是将这个指针数组中元素的拷贝,即指针的拷贝,具体来说,是将fd_array[oldfd]拷贝给fd_array[newfd],那么最后fd_array[oldfd]和fd_array[newfd]的值是一样的,并且都是原来fd_array[oldfd]的值!!!

6.9.1. 用dup2实现输出重定向

那么根据上面的一些分析,如果我们想要用 dup2 实现输出重定向,该如何实现呢???

输出重定向的本质,我们之前已经说过了,本质上是:1号文件描述符所关联的文件流从显示器变更为其他文件流,通俗点说,就是fd_array[1] 从 显示器文件的地址 变更为 其他文件的地址 ! 那么用dup2该如何做呢???

dup2 会将 oldfd 对应的指针拷贝给 newfd 对应的指针,那么最后两个指针都会和 oldfd 对应的指针一样,而输出重定向最后的结果就是:1号文件描述符所关联的文件流指向的是其他文件(一般是我们新打开的文件),那么就是说新打开的文件的地址就是 oldfd 所对应的地址,而 newfd 所对应的地址就是显示器文件的地址

注意:此时 oldfd 和 newfd 所关联的文件 都是新打开的文件!oldfd 并不会断掉和新打开文件的关联!!!

因此,输出重定向的实现代码如下:

void Test1(void)
{
  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);;
  }
  // normal open
  
  // 实现输出重定向
  // dup2(int oldfd, int newfd); 结果: 最后与oldfd所关联的地址一样
  // 输出重定向: 1号文件描述符对应的文件流是我们新打开的文件
  // 在这里具体就是log.txt, 即fd_array[1] = log.txt的地址
  dup2(fd,1); //  dup2实现输出重定向

  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fputs("hello fputs\n",stdout);
  const char* str = "hello fwrite\n";
  fwrite(str,strlen(str), 1,stdout);

  close(fd);
}

现象: 

结果符合预期, 本来应该写入显示器的内容,现在却写入到了其他文件中,这就是输出重定向!

追加重定向,就不举例了,本质上就是在输出重定向的基础之上,将打开文件的选项更改即可,具体是:将O_TRUNC 变更为 O_APPEND

6.9.2. 用dup2实现输入重定向

根据之前的分析,输入重定向的本质是:将0号文件描述符所关联的文件由键盘文件变更为其他文件!!!具体来说就是fd_array[0] = 其他文件的地址。

那么用 dup2 该如何实现呢?

dup2 不是将 oldfd 所关联的地址 拷贝给 newfd 所关联的地址吗?那么最后这两个文件描述符夫所关联的地址都是 oldfd 关联的地址,那么具体来说,oldfd 所关联的地址就是其他文件的地址(一般情况下这个文件都是新打开的文件),而 newfd 所关联的地址就是键盘文件的地址,分析到此,输入重定向的代码实现如下:

void Test2(void)
{
  int fd = open("log.txt",O_RDONLY);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  // normal open
  
  dup2(fd,0);
  char buffer[128] = {0};
  scanf("%s",buffer);
  printf("%s\n",buffer);

  rewind(stdin);   // 让stdin这个文件指针回到对应文件的起始位置
  buffer[0] = 0;
  fscanf(stdin,"%s",buffer);
  printf("%s\n",buffer);

  rewind(stdin);
  buffer[0] = 0;
  fgets(buffer,sizeof buffer, stdin);
  printf("%s\n",buffer);

  rewind(stdin);
  buffer[0] = 0;
  fread(buffer,sizeof buffer,1,stdin);
  printf("%s\n",buffer);

  close(fd);
}

现象:

符合预期, 本来应该该从键盘读取数据的,现在却从其他文件流中读取数据,而这就是输入重定向!!!

6.10. 1号文件描述符和2号文件描述符的区别

我们知道,默认情况下,1和2号文件描述符所对应的硬件都是显示器文件,但是它们两个是不同的!

请看如下代码:

void Test6(void)
{
  const char* str1 = "标准输出\n";
  const char* str2 = "标准错误\n";

  write(1,str1,strlen(str1));
  write(2,str2,strlen(str2));
}

现象如下:

 

我们发现,当发生输出重定向后,只有向1号文件描述符写入数据的 write 被重定向到了log.txt 中,向2号文件描述符夫写入数据的 write 没有被重定向到 log.txt ,那么原因也显而易见,因为这是输出重定向啊,那么自然只会更改1号文件描述符所关联的文件,2号文件描述符不受影响,那么此时2号文件描述符自然还是关联的是显示器文件!!!故我们看到的现象就是,向1号文件描述写入数据的 write 重定向到了log.txt中,向2号文件描述符写入数据的write依旧还是向显示器文件写入数据!!!

但是我们也可以采取如下操作,将标准输出的信息重定向到true.txt文件中,将标准错误的信息重定向到error.txt中:

// 对下面的这条命令我们可以这样理解:
// 前半部分
// ./my_test > true.txt 就是一个输出重定向,本质就是将1号文件描述符与true.txt关联起来

// 后半部分可以如下理解
// 2> error.h 即让2号文件描述符与error.txt文件关联起来
// 相当于一个标准错误输出重定向

./my_test > true.txt 2> error.txt

现象如下:

 我们也可以将标准输出的信息和标准错误的信息都重定向到一个文件中,操作如下:

// 下面这条命令可以如下理解

// 前半部分:
// 就是一个输出重定向,即将1号文件描述符与 log.txt文件关联起来

// 后半部分:
// 2>&1 即让2号文件描述 与 1号文件描述符所关联的文件 关联起来
// 即让 1号文件描述符 和 2号文件描述符 关联同一个文件

./my_test > log.txt 2>&1

现象如下:

关于标准输出和标准错误的总结:

  1. 目的地不同:

    • 标准输出流(stdout)被用于向用户或其他程序显示正常的输出。
    • 标准错误输出流(stderr)被用于向用户或其他程序显示错误和异常信息
  2. 默认输出位置不同:

    • 标准输出(stdout)默认情况下输出到控制台或终端窗口,可以用来展示程序的正常输出结果
    • 标准错误输出(stderr)默认情况下也输出到控制台或终端窗口,但它是直接输出,不会被缓冲,通常用于报告错误、异常和警告信息
  3. 输出重定向:

    • 可以使用重定向符号将标准输出或标准错误输出重定向到其他文件或设备中,而不是输出到控制台。
  4. 处理方式不同:

    • 标准输出流(stdout)的内容通常被程序设计为适合正常输出,可能包含程序运行结果、打印的信息等
    • 标准错误输出流(stderr)的内容通常被设计为表示错误、异常或警告的信息,以便能够快速定位和解决问题

6.10.1. 关于perror的认识

我们以前学习过perror函数,该函数会打印errno值对应的错误信息!!!如下:

void Test7(void)
{
  perror("haha");
}

现象如下:

经过我们上面的测试,我们发现,当发生输出重定向后,perror 打印的信息并没有重定向到log.txt中,而是向显示器文件写入数据了,那么其潜台词就是这个 perror 一定是向2号文件描述符所关联的文件写入数据的!!!

而在C语言学习的过程中,我们学习过 errno,errno 是 C 语言的一个全局变量,它位于 <errno.h> 头文件中,在程序执行期间,用于记录系统或库函数出错时的错误码信息。当程序调用一个函数失败时,其对应的错误码将会被写入 errno 变量中。同时,errno 变量通常是线程安全实现的,因为它是每个线程独有的变量,每个线程都有自己的错误码信息。

知道了这个,我们看看下面的代码:

void Test8(void)
{
  for(size_t i = 0; i < 3;i++)
  {
    errno = i;
    perror("error message: ");
  }
}

 现象如下:

通过上面的现象,我们可以看到,当 errno 这个全局变量是不同的值的时候,就会向 2号文件描述符所关联的文件(在这里具体为显示器文件)写入不同的错误信息!!!

因此我们在看man手册的时候,许多函数都会介绍,如果出错,那么会设置合适的 errno 值,即为用户在标准错误中输出一个含有相关错误信息的字符串,方便用户调试程序 !!!

6.10.2. perror函数的简单模拟实现

我们在这里也可以简单模拟实现一下perror函数,代码如下:

void my_perror(const char* message)
{
  fprintf(stderr,"%s : %s\n",message,strerror(errno));
}

void Test9(void)
{
  int fd = open("data.txt",O_RDONLY);
  if(fd == -1)
  {
    my_perror("open");
  }
}

 现象如下:

7. 如何理解一切皆文件

我们知道,一切皆文件是Linux的设计哲学!!!可是该如何理解一切皆文件呢???

首先,众所周知,Linux操作系统是由C语言写的!!!那么如何用C语言实现面向对象,甚至是运行时多态呢???

我们在学习C++中的类的时候,说过一个类会包含成员属性以及成员方法!!!

而在C里面,是没有类的概念的,但是却有类似的结构,即struct 结构体!!!而我们以前写struct 结构体的时候,一般情况下,是只包含成员属性,没有成员方法的!!!但是如果我就想让 struct 结构体里包含成员方法的功能呢???

答案是:函数指针!!!在 C 语言中,可以使用函数指针来模拟类似于成员方法的功能。

理解了上面,我们继续。

首先,我们有一点可以肯定的是:不同的硬件对应的操作方法在底层层面上一定是不一样的!例如,磁盘的访问和键盘的访问一定在底层的操作上是不一样的!而一切皆文件是体现在操作系统的软件设计层面的!但是,这些设备都是外设啊,即每一个外设的核心函数都可以是read、write等等,因此所有的设备,都可以有自己的 read 和 write 等等,但是,这些设备对应的函数代码的实现,一定是不一样的!!!而在上层看来,看待所有的设备(文件)的方式,都统一称之为 struct file{}

如下图所示:

从上图也可以看出,在 struct file 上层看来,根本不关心你具体是什么硬件每一个硬件都会被抽象成一个 struct file 对象,故体现了一切皆文件!!!通过将所有文件和设备抽象为 struct file 对象,Linux 操作系统实现了一种统一的接口,使得上层程序可以使用相同的方法来处理不同类型的文件和设备。从上层的角度来看,它们操作的只是一个文件对象,无需关心底层是磁盘、网络接口还是其他设备。

我们也可以看看Linux的内核源码:

struct file
{
    // 在内核数据结构struct file 有一个成员
    const struct file_operations	*f_op;
    ...
};
struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
	ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
	int (*readdir) (struct file *, void *, filldir_t);
	unsigned int (*poll) (struct file *, struct poll_table_struct *);
	int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, struct dentry *, int datasync);
	int (*aio_fsync) (struct kiocb *, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
	int (*setlease)(struct file *, long, struct file_lock **);
};

可以看到,在file_operations这个内核数据结构中包含了文件的一系列访问操作(每一个方法都是借助函数指针实现的) !! 操作系统将硬件统一抽象为 struct file 对象, 那么每一个硬件都有这些方法,虽然不同的硬件对应的方法在底层上实现可能不相同 ,但是在struct file上层看来,已经不重要了,上层通过统一的接口访问这些硬件,进而在 struct file 的上层体现了一切皆文件!!!

8. 缓冲区

8.0. 如何体现缓冲区是存在的

为了体现缓冲区是具体存在的,我们看看下面的代码:

void Test3(void)
{
  close(1);

  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  
  // normal open
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  close(fd);
}

现象如下: 

我们发现一个非常奇怪的现象,当我们先 close(1),然后再打开 log.txt 时,那么此时fd_array[1] 的值就是 log.txt 的地址,也就是说,此时 log.txt 所关联的文件描述符就是1,那么 fprintf 向 stdout 这个文件流写入, 本质是向 1号文件描述符所关联的文件写入数据,而这是一个 输出重定向,那么按道理来说,此时经过输出重定向,会将本来应该向显示器写入 变更为 向 log.txt 这个新打开的文件写入,可是最后的结果,我们发现,log.txt 里面并没有内容!!!

首先,我们需要明确一点:上面的代码是一个输出重定向,当向 stdout 这个流写入数据的时候,其本质就是向 log.txt 这个文件写入,但是现在我们却没有看到预期的结果!

而要理解这个现象,我们必须要引出缓冲区的概念!!!

在这之前,我们看看C标准为我们提供的FILE结构是怎样的:

C标准提供的FILE结构在 /usr/include/libio.h 中有这样的定义 

typedef struct _IO_FILE FILE;
struct _IO_FILE {
 int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

 /* The following pointers correspond to the C++ streambuf protocol. */
 /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
 char* _IO_read_ptr; /* Current read pointer */
 char* _IO_read_end; /* End of get area. */
 char* _IO_read_base; /* Start of putback+get area. */
 char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
 char* _IO_write_end; /* End of put area. */
 char* _IO_buf_base; /* Start of reserve area. */
 char* _IO_buf_end; /* End of reserve area. */
 /* The following fields are used to support backing up and undo. */
 char *_IO_save_base; /* Pointer to start of non-current get area. */
 char *_IO_backup_base; /* Pointer to first valid character of backup area */
 char *_IO_save_end; /* Pointer to end of non-current get area. */
 struct _IO_marker *_markers;
 struct _IO_FILE *_chain;
 int _fileno; 
#if 0
 int _blksize;
#else
 int _flags2;
#endif
 _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
 /* 1+column number of pbase(); 0 is unknown. */
 unsigned short _cur_column;
 signed char _vtable_offset;
 char _shortbuf[1];
 /* char* _save_gptr; char* _save_egptr; */
 _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

 我们可以看到其中有_fileno文件描述符,还有C标准提供的用户缓冲区!!!

8.1. 缓冲区是什么

缓冲区本质上是一段内存空间,用于临时存储数据,以提高数据传输的效率,进而提高整机效率。

缓冲区又分为用户缓冲区 ( User Buffer ) 和内核缓冲区 ( Kernel Buffer):

  1. 用户缓冲区:也称为用户空间缓冲区,是应用程序所管理的内存空间,用于临时存储数据。

  2. 内核缓冲区:也称为内核空间缓冲区,是操作系统内核管理的内存空间,用于临时存储从设备读取或写入设备的数据。

而具体来说,C语言也为我们提供了缓冲区,而这个缓冲区就是用户缓冲区。相应的来说,OS,也会提供一个缓冲区,被称之为内核缓冲区;如图所示:

8.2  为什么要有缓冲区

  1. 提高性能缓冲区可以减少对底层资源的访问次数(减少IO的次数),从而提高读取和写入操作的效率。通过将数据先读入缓冲区或将数据先存储在缓冲区中,可以减少对慢速外部设备(如磁盘)的直接访问次数。当需要连续读写大量数据时,使用缓冲区可以显著减少系统的开销。

  2. 数据流平滑缓冲区可以平滑数据流,使数据在不同速度的处理单元之间更加协调。例如,在音视频流处理中,数据可以先存储在缓冲区中,然后由解码器在适当的时间进行处理,以避免因数据的闪烁或丢失而导致的不良用户体验。

  3. 数据临时存储缓冲区还可以用作数据的临时存储区域。当数据一次性无法直接处理完毕时,可以将其存储在缓冲区中,待稍后的处理。这在很多情况下都是很有用的,例如网络传输时的数据包缓冲。

  4. 异步处理缓冲区也可以用于实现异步处理。数据可以在一个线程或进程中放入缓冲区,而其他线程或进程可以从缓冲区中读取和处理这些数据。这种机制可以提高系统的并发性和响应性。

8.3. 缓冲区的刷新策略

常见的缓冲区策略(一般情况下):

  1. 无缓冲:数据直接写入到目标输出设备或文件中,立即显示或写入,没有延迟;这种策略通常用于需要实时性的输出,如错误信息输出。

  2. 行缓冲:数据在换行符(‘\n’)出现时会立即刷新到设备或文件中。这意味着每当遇到换行符时,缓冲区的数据都会被及时刷新, 例如: 显示器文件刷新;

  3. 全缓冲:数据在缓冲区填满后才会刷新到设备或文件中; eg: 像磁盘文件写入刷新

上面的刷新策略同样适用于操作系统向硬件刷新数据(刷新本质上给就是一种写入) ;

特殊情况:

  1. 用户强制刷新(eg. fflush);
  2. 进程退出(缓冲区的数据立即刷新到OS);

8.4. 解释现象

有了上面的认识,我们就可以解释一些现象了!

8.4.1. 解释现象一

void Test3(void)
{
  close(1);

  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  
  // normal open
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  close(fd);
}

现象: 

可以看到,上面的代码是一个输出重定向,正常情况下,会将数据写入到新打开的文件里,在这里具体为 log.txt ,但是从结果来看,并没有达到我们的预期;而我们现在就可以解释这个现象了!!!

解释:dup2(fd,1);我在这里就不细说了,结果就是1号文件描述符所关联的文件是 log.txt ,而我们知道 fputs 是C标准库为我们提供的一个文件接口,我们通过参数设置,它会向 stdout 这个流写入数据,其本质上会向 log.txt 这个文件写入数据,但由于 fputs 是C标准库的接口,所以进程会先通过 fputs 将数据写入C标准提供的用户缓冲区,当不发生重定向的时候,此时就是向显示器文件写入数据,那么用户缓冲区的刷新策略就是行刷新,而当发生重定向后,此时就是向 log.txt 写入数据,而 log.txt 是一个磁盘文件,此时的刷新策略就会从行刷新变更为满刷新,那么当close(fd) 之前,这些数据就会一直在C标准提供的用户缓冲区里!!!而 close(fd) 后,进程在结束之前,需要刷新用户级别的缓冲区,可是我们知道,用户级别的缓冲区的刷新需要通过系统调用接口,可是系统调用接口只认 fd,而我们都已经close(fd)了,此时相当于fd_array[1] = NULL了,那么此时就找不到 log.txt 的地址了,那么系统调用 write 就无法将用户的缓冲区的数据刷新到内核缓冲区里,导致数据也无法最终写入进 log.txt 中,因此我们看到的现象就是 log.txt 中没有数据!!!本质上就是因为数据一直在用户缓冲区里,没有刷新到内核缓冲区,致使数据丢失!!!

而对于这种情况,我们的解决方案就是在 close(fd) 之前,将C标准提供的用户缓冲区内的数据强制刷新到内核缓冲区内!!!具体实现代码如下:

void Test1(void)
{
  close(1);
  int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0664);
  if(fd == -1)
  {
    perror("open failed");
    exit(1);
  }
  
  // normal open
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);
  fputs("cowsay hello\n",stdout);

  // 在close(fd)之前,将用户缓冲区的数据强制刷新到内核缓冲区里
  fflush(stdout); 
  close(fd);
}

现象:

此时就符合我们的预期,因为我们在 close(fd) 之前,就将C标准提供的用户缓冲区里的数据刷新到了内核缓冲区,接下来在由OS将内核数据刷新到某种外设中(在这里就是磁盘文件,具体为log.txt中)。

8.4.2. 解释现象二

void Test2(void)
{
  const char* str1 = "hello 标准输出\n";
  write(1,str1,strlen(str1));
  const char* str2 = "hello 标准错误\n";
  write(2,str2,strlen(str1));

  printf("hello printf\n");
  fputs("hello fputs\n",stdout);

  close(1);
}

 现象如下:

根据现象来看,我们不是 close(1) 了吗,为什么数据还会正常刷新到内核缓冲区呢???

答案是因为,我们没有发生输出重定向,那么此时C标准库的接口就是向显示器文件写入数据,那么C标准库提供的用户缓冲区的刷新策略就是行刷新,因此这两个语言接口在close(1)之前,就已经将数据从用户缓冲区刷新到了内核缓冲区内!而系统调用,是直接将数据刷新到了内核缓冲区内!!!最后OS在将内核缓冲区的数据刷新到显示器文件中,因此我们看到的现象就是所有接口都将数据写入了显示器文件!!!

那么此时如果我发生输出重定向呢???

现象如下:

首先,我们发现输出重定向后,hello 标准错误 并没有写入到 log.txt 中,而是直接写入了显示器文件中 ,这很好理解,因为这是输出重定向,只会把数据写入到 1号文件描述符,2号文件描述符不受影响,因此2号文件描述符继续向显示器文件写入数据;但是奇怪的是我明明向 1号文件描述符写入了3条信息啊,怎么 cat log.txt只有一条信息了?并且这条信息还是系统调用打印的!为什么?

当发生输出重定向后,其潜台词就是C标准提供的用户缓冲区的刷新策略从行刷新变更为满刷新,而 printf 和 fputs 首先会将数据写入C标准提供的的用户缓冲区,进程结束之前,close(1),意味着C缓冲区里面的数据就无法刷新到内核缓冲区了,即数据就丢失了,而write是系统调用接口,它并不会先写入C标准提供的用户缓冲区里,而是直接写入OS的内核缓冲区里,所以最后 close(1) 对它来说没有影响;因此我们看到的现象就是:语言级别的接口的数据没有写入到log.txt中,本质是数据一直在C提供的用户缓冲区里,没有刷新到内核缓冲区,而系统调用直接将数据刷新到了内核缓冲区,而一般情况下,只要数据写入了内核缓冲区里,那么OS就会根据相应的缓冲区刷新策略,将内核缓冲区的数据刷新到某种外设中,在这里具体为log.txt磁盘文件!! 故系统调用的数据写入进了log.txt中!!!

8.4.3. 解释现象三

void Test3(void)
{
  const char* str = "hello write\n";
  write(1,str,strlen(str));

  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fputs("hello fputs\n",stdout);

  fork();
}

现象: 

首先,我们说系统调用接口 write ,它会直接将数据写入到OS内部的内核缓冲区里;然后是剩下三个库函数,它们都是向显示器文件写入数据,那么其C标准提供的用户缓冲区的刷新策略就是行刷新,也就是说,在 fork 之前,所有的数据都已经刷新到了内核缓冲区里,那么最后在由OS将内核缓冲区的数据刷新到显示器文件中!!!于是我们看到的现象就是所有信息都写入了显示器文件!!!

那么,如果此时发生输出重定向呢???

现象如下:

我们发现了一个非常奇怪的现象,系统调用接口只打印了一次,而每一个库函数都打印了两次!!!为什么呢?

首先,我们分析,库函数为什么打印了两次,分析如下

我们知道,这些库函数(默认亦或者通过参数设置)都会向 stdout 这个文件流写入数据,而  stdout 里面封装的文件描述符就是1,因此默认情况下,这些库函数会向显示器文件写入数据,但是,此时发生了输出重定向,那么结果就是这些库函数都会向新打开的文件写入数据,根据现象,具体为 log.txt ,而我们又知道输出重定向的潜台词就是用户缓冲区的刷新策略从行刷新 (行缓冲) 变更为满刷新 (全缓冲) ,而我们之前说过,printf 、 fprintf 、 fputs 这些库函数会先将数据写入到C标准提供的用户缓冲区里(具体来说,应该是父进程的用户缓冲区里),也就是说,在fork()之前,这些数据都在用户缓冲区里,当fork()之后,那么父子进程通过写实拷贝的方式共享数据(此时用户缓冲区的数据父子进程共享 ),当父子进程结束之前,需要将用户缓冲区的数据刷新到内核缓冲区刷新的本质其实就是一种写入!!!那么此时无论父子进程哪一个先被CPU调度,进行刷新用户缓冲区,那么就会发生写实拷贝(拷贝用户缓冲区的数据,父子进程各自私有一份) ,因此,父子进程会先后将各自的用户缓冲区的数据刷新到OS的内核缓冲区里,因此我们看到的现象就是库函数都打印了两次!!!

那么为什么系统调用接口只打印了一次呢??原因如下

系统调用接口并不会将数据先写入C标准提供的用户缓冲区里,而是会直接将数据写入OS提供的内核缓冲区!!!也就是说,在 fork() 之前,这些数据已经在OS的内核缓冲区里了,并没有在C标准提供的用户缓冲区里,那么fork之后,父子进程的用户缓冲区实际上没有数据,那么自然只有一份这样的数据即父进程在fork之前调用write向内核缓冲区写入的数据,因此我们看到的现象就是,系统调用接口只有一次写入!!!

而如果我们想,发生输出重定向后,其所有接口都只写入一次,那么就必须在fork()之前,将用户缓冲区的数据强制刷新到内核缓冲区内 (就避免了父子进程通过写实拷贝的方式将用户缓冲区的数据各自私有一份,因为如果父子进程各自私有一份,那么当父子进程结束后,就会将各自的用户缓冲区的数据刷新到内核缓冲区里,即刷新了两份数据 ),具体实现如下:

void Test4(void)
{
  const char* str = "hello write\n";
  write(1,str,strlen(str));

  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fputs("hello fputs\n",stdout);

  // 在fork()之前就将数据刷新到内核缓冲区内
  fflush(stdout);
  fork();
}

 现象如下:

由于我们在fork()之前,通过 fflush 将用户缓冲区的数据强制刷新到内核缓冲区内,那么当父子进程先后结束时,需要刷新用户缓冲区,发现用户缓冲区根本没数据,那么自然内核缓冲区只有一份数据,即父进程调用这些接口的数据,再由OS将内核缓冲区的数据刷新到 log.txt磁盘文件中,至此,我们看到的现象就是,所有调用接口都只写入了一次信息!!!

9. 模拟实现

9.1. 模拟简陋版本的shell,包含重定向功能

代码如下:

enum MAX
{
  CMD_MAX = 128,  // 在命令行上输入字符串的最大长度
  ARGV_MAX = 64,  // 命令行参数的最大个数
  MYENV_MAX = 64  // 环境变量的最大个数
};

enum CHOICE
{
  INPUT_REDIRECTION = 0,  // 输入重定向
  OUTPUT_REDIRECTION = 1, // 输出重定向
  APPEND_REDIRECTION = 2  // 追加重定向
};


// 该结构用于标识重定向所在的位置,即具体是什么重定向
typedef struct pair
{
  char* argv;   
  int pos;
  int choice;
}my_pair;

// 处理输出重定向
int solve_output_redirection(my_pair* kv,char* argv[])
{
  int fd = open(argv[kv->pos + 1],O_CREAT | O_WRONLY | O_TRUNC,0664);
  assert(fd != -1);

  //case 1: > log.txt
  if(kv->pos == 0)
  {
    close(fd);
    return 0;  // 0代表着第一种情况
  }
  // case 2: command > log.txt
  else
  {
    dup2(fd,1);
    argv[kv->pos] = NULL;
    close(fd);
    return 1; // 1代表着第二中情况
  }
}

// 处理追加重定向
int solve_append_redirection(my_pair* kv,char* argv[])
{
  int fd = open(argv[kv->pos + 1],O_CREAT | O_WRONLY | O_APPEND,0664);
  assert(fd != -1);

  //case 1: >> log.txt
  if(kv->pos == 0)
  {
    close(fd);
    return 0;  // 0代表着第一种情况
  }
  // case 2: command >> log.txt
  else
  {
    dup2(fd,1);
    argv[kv->pos] = NULL;
    close(fd);
    return 1; // 1代表着第二中情况
  }
}


// 处理输入重定向
int solve_input_redirection(my_pair* kv,char* argv[])
{
  int fd = open(argv[kv->pos + 1],O_RDONLY);
  printf("fd : %d\n",fd);
  assert(fd != -1);

  //case 1: < log.txt
  if(kv->pos == 0)
  {
    close(fd);
    return 0;  // 0代表着第一种情况
  }
  // case 2: command < log.txt
  else
  {
    dup2(fd,0);
    argv[kv->pos] = NULL;
    close(fd);
    return 1; // 1代表着第二中情况
  }
}


 
char myenv[MYENV_MAX];
 
void Test5(void)
{
  char command[CMD_MAX] = {0};
  while(1)
  {
    // step 1: 打印提示符
    printf("[Xq#MY-LOCAL-LINUX]$ ");
 
    // step 2: 获取命令行字符串
    command[0] = 0; // 以O(1)的方式清空字符串
    fgets(command,CMD_MAX,stdin); 
    // 注意我们上面的这个字符串,是有一个'\n'的
    // 例如ls\n\0; 因此我们要将这个'\n' 置为 '\0'
    command[strlen(command) - 1] = 0; // 将'\n' --> '\0'
 
    // step 3: 解析字符串
    
    // 用于存放命令行参数的指针数组
    char* argv[ARGV_MAX] = {NULL};
    // 定义分隔符
    const char* delim = " "; // 一般情况下,分隔符都是空格
    int i = 0;
    argv[i++] = strtok(command,delim);
    
    // 特殊处理: ls
    if(strcmp(argv[0],"ls") == 0)
      argv[i++] = (char*)"--color=auto";  // 配色方案
 
    // 特殊处理: ll
    if(strcmp(argv[0],"ll") == 0)
    {
      argv[0] = (char*)"ls";
      argv[i++] = (char*)"-l";
      argv[i++] = (char*)"--color=auto";  // 配色方案
    }
 
    while((argv[i++] = strtok(NULL,delim)));
 
    // 打印当前解析的字符串
    // 检测是否正确
    for(int i = 0; argv[i]; ++i)
    {
      printf("argv[%d]: %s\n",i,argv[i]);
    }
 
    // step 5:处理内建命令
    // 例如cd 以内建命令的方式进行运行,不创建子进程,让父进程shell自己运行
    // 内建命令---不创建子进程,让父进程自己运行
 
    if(0 == strcmp("cd",argv[0]) && argv[1] != NULL)
    {
      chdir(argv[1]);  // chdir 是一个系统调用,更改进程当前的工作目录
      continue;
    }
 
    if(0 == strcmp("export",argv[0]) && argv[1] != NULL)
    {
      // 父进程导环境变量
      // 如果想既不覆盖之前的环境变量,又可以新增一个环境变量 
      // 那么需要借助putenv这个接口
      // man 3 putenv     在<stdlib.h>
      // int putenv(char* string);
      // 需要借助这个全局字符数组myenv,argv中的内容是一个个的字符指针
      // 这些字符指针指向的command这个数组的内容
      // 而argv这个指针数组每次循环都会被清空,故原始内容就找不到了
      // 因此在这里借助这个全局的字符数组,保存环境变量
      strcpy(myenv,argv[1]); 
      putenv(myenv);
      continue;
      // 当发生程序替换时,环境变量和相关的数据,会被替换吗??? 
      // 答案是: 不会,故全局变量具有全局属性!!!
      // 那么问题又来了,shell的环境变量又是如何来的呢???
      // 答案是: 环境变量,是写在配置文件中的!shell启动的时候,
      // 通过读取配置文件,获得的起始环境变量!!!
    }



    // 判断是否有 输出重定向
    my_pair kv;
    kv.argv = NULL;
    kv.pos = -1;
    kv.choice = -1;
    for(size_t i = 0; argv[i];i++)
    {
      if(strcmp(">",argv[i]) == 0)
      {
        kv.argv = argv[i];
        kv.pos = i;
        kv.choice = OUTPUT_REDIRECTION;
        break;
      }
      else if(strcmp(">>",argv[i]) == 0)
      {
        kv.argv = argv[i];
        kv.pos = i;
        kv.choice = APPEND_REDIRECTION;
        break;
      }
      else if(strcmp("<",argv[i]) == 0)
      {
        kv.argv = argv[i];
        kv.pos = i;
        kv.choice = INPUT_REDIRECTION;
        break;
      }
    }
 
    // step 4: 处理第三方命令
    // 子进程通过进程的程序替换执行新的可执行程序
    // 父进程充当shell,回收子进程,如果子进程结果不正确,应该得到子进程的退出结果
    
    pid_t id = fork();
    if(id == 0)
    {
      if(kv.argv)
      {
        int ret = -1;
        // 如果有重定向(输出重定向、追加重定向、输入重定向)
        if(kv.choice == INPUT_REDIRECTION)
        {
          // 处理输入重定向
          ret = solve_input_redirection(&kv,argv);
          if(ret == 0)
            continue;
        }
        else if(kv.choice == OUTPUT_REDIRECTION)
        {
          // 处理输出重定向
          ret = solve_output_redirection(&kv,argv);
          if(ret == 0)
            continue;
        }
        else if(kv.choice == APPEND_REDIRECTION)
        {
          // 处理追加重定向
          ret = solve_append_redirection(&kv,argv);
          if(ret == 0)
            continue;
        }
      }
      // 处理第三方命令
      execvp(argv[0],argv);  //execvp 太合适不过了! ! !
      exit(1); // 如果走到这,那么execvp一定出错了
    }
    else
    {
      // 父进程等待子进程退出即可
      int status = 0;
      waitpid(-1,&status,0);
      if(WIFEXITED(status))
      {
        if(WEXITSTATUS(status) != 0)
        {
          printf("child process exit code: %d\n",WEXITSTATUS(status));
        }
      }
      else
      {
        printf("child process get a exit signal\n");
      }
    }
 
  }
}

我们的基础IO部分 --- 上 到此结束!!!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值