【Linux系统】基础 IO

目录

一、回顾C语言中的文件IO操作

[1. 打开与关闭文件](#1. 打开与关闭文件)

[2. 文件读写操作](#2. 文件读写操作)

二、文件系统调用

[1. open](#1. open)

[2. wirte](#2. wirte)

[3. close](#3. close)

三、访问文件的本质

四、理解文件的重定向

[1. 文件描述符](#1. 文件描述符)

[2. 文件描述符的分配规则](#2. 文件描述符的分配规则)

[3. 重定向](#3. 重定向)

理解重原理

[dup2 函数](#dup2 函数)

与进程替换中的关系

[4. 为自定义shell添加重定向功能](#4. 为自定义shell添加重定向功能)

代码实现

细节问题:stdout和stderr

[五、如何理解 "一切皆文件"?](#五、如何理解 "一切皆文件"?)

六、缓冲区

[1. 理解缓冲区](#1. 理解缓冲区)

缓冲区在哪里?

内核也有缓冲区

缓冲刷新的策略

[2. 实现一个简易的C语言内部缓冲区代码](#2. 实现一个简易的C语言内部缓冲区代码)


一、回顾C语言中的文件IO操作

1. 打开与关闭文件

在学习C语言中,我们知道对于文件,在读写之前应该先打开文件(使用 fopen 函数),在使用结束之后应该关闭文件(使用 fclose 函数)。使用代码示例如下:

cpp 复制代码
#include <stdio.h>

int main()
{
    FILE *fp = fopen("log.txt", "w"); // 打开文件
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    
    printf("打开文件成功\n");
    // 进行文件读写的操作
    // ......

    fclose(fp); // 关闭文件
    return 0;
}

其中fopen函数原型如下:

要注意:其中的 path 参数,可以是相对/绝对路径+文件名。当只有文件名时,会默认文当前路径,但是如果文件不存在要新建文件时,则当执行带这个代码时,会以进程当前的工作路径来建立文件(在一些改变了当前路径的情况下,比如fopen之前使用了chdir函数的情况等)。

其中的mode参数表示的是打开文件的模式,它有很多取值,可以实现不同的效果,如下表所示:

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

我们最常使用的就是 r(只读),w(只写),a(追加)。其中通过w来打开文件,都会先清空这个文件。

2. 文件读写操作

对于文件读写,在C语言会涉及一下函数:

|---------|----------------|---------|
| 函数名 | 功能 | 适用于 |
| fgetc | 从输入流中读取一个字符 | 所有输入流 |
| fputc | 向输出流中写入一个字符 | 所有输出流 |
| fgets | 从输入流中读取一个字符串 | 所有输入流 |
| fputs | 向输出流中写入一个字符串 | 所有输出流 |
| fscanf | 从输入流中读取带有格式的数据 | 所有输入流 |
| fprintf | 向输出流中写入带有格式的数据 | 所有输出流 |
| fread | 从输入流中读取一块数据 | 文件输入流 |
| fwrite | 向输出流中写入一块数据 | 文件输出流 |

除此之外,还有fprintf,fseek, ftell, rewind 等函数。

本文主要会使用到 fread 和 fwrite 。如下所示:

fread 函数用于读取文件,其参数说明如下:

  • void *ptr:指向接收数据的内存缓冲区的指针。
  • size_t size:每个数据项的大小(以字节为单位)。
  • size_t nmemb:要读取的数据项个数。
  • FILE *stream:指向 FILE 对象的指针,表示要读取的输入流。
  • 返回值:成功读取的数据项个数。

fwrite 函数用于写入数据到文件中去,其参数说明如下:

  • const void *ptr:指向要写入数据的内存缓冲区的指针。
  • size_t size:每个数据项的大小(以字节为单位)。
  • size_t nmemb:要写入的数据项个数。
  • FILE *stream:指向 FILE 对象的指针,表示要写入的输出流。
  • 返回值:成功写入的数据项个数。

C默认会打开三个输入输出流,分别是stdin, stdout, stderr,这三个流的类型都是FILE*,即文件指针,也是fopen的返回值。在上面这些读写操作其中的 FILE *stream 参数就可以是这三个流。

示例:显示器是标准输出流,所以我们可以不使用printf库函数来打印详细,可以使用fwrite来打印详细到显示器上,代码使用如下:

cpp 复制代码
const char *message = "hello Linux!";
fwrite(message, strlen(message), 1, stdout); // 打印数据到显示器上

虽然,我们学习过C语言,但我们只是知道了文件操作是怎么用的,同时也一定还有很多对文件的疑问,比如为什么要怎么用?为什么是怎么用的?......


二、文件系统调用

文件实际上是存储在磁盘上的,磁盘属于外部设备(硬件),因此,访问磁盘上的文件,本质上就是访问硬件。而用户的程序无法直接操作硬件,因此必须通过系统调用 来完成从用户态到内核态的过渡。具体而言,就是当用户在程序中调用库函数(如 fopen、fread 等)时,这些库函数内部会封装 相应的系统调用接口;通过这一接口,请求被传递给操作系统 ,再由操作系统指挥硬件驱动 最终控制硬件完成读写操作。这也是操作系统的体系架构图体现的结构。

下面,我们来看看其中的三个重要系统调用接口:open,write,close。通过这三个接口,我们来逐步认识一下文件系统的底层原理。

1. open

函数原型如图所示:

这里主要研究这个函数:

cpp 复制代码
int open(const char *pathname, int flags, mode_t mode);

功能:用于打开一个已存在的文件,若不存在则创建一个新文件。

参数如下:

  1. 第一个参数 pathname:表示你要打开或创建的文件的路径名。

  2. 第二个参数 flags:表示用于指定文件的打开或创建模式。这个参数可以通过按位或( | )运算符将多个标志组合在一起使用。

    基础模式(必须且只能选其一):
    O_RDONLY :以只读方式打开。
    O_WRONLY :以只写方式打开。
    O_RDWR :以可读写方式打开。

    可选附加模式(常用,可以组合多个使用):
    O_CREAT :如果文件不存在,则自动创建该文件。
    O_TRUNC :如果文件存在,则会清空原有内容。
    O_APPEND :每次写操作前,将文件指针移到文件末尾,实现追加写入。
    ... ...

    原理:比特位传递方式,即每一个标志其实都代表一个独立的二进制位(Bit)。

  3. 第三个参数 mode :用于指定新创建文件的访问权限。 只有当 flags 中包含了 O_CREAT 标志,且文件确实被创建时,该参数才会生效。如果文件已存在,mode 参数会被直接忽略。最终创建的文件权限还会受到当前进程 umask(权限掩码)的影响。

返回值 :成功时返回一个非负整数,表示的是 文件描述符 (fd);失败时返回 -1。当前理解为是一个数字,这个数字对应的是一个文件即可,具体的待会解释访问文件本质时再说。

如何使用open?

pathname很好理解,就是路径+文件名;而flags则是一个个的选项,这些选项的本质就是是一个个的比特位,如下是Linux的源码部分:

所以可以以按位或运算符来进行传递。最后是mode,一般是需要创建文件时使用。

fopen封装了open,怎么做的?

open作为系统调用,那么它是如何被fopen封装的呢?我们知道fopen有很多个选项,那它的功能是怎么通过open实现的呢?比如如何实现以w,a,r 通过fopen打开文件,它们底层使用的分别具体代码如下:

cpp 复制代码
int fd1 = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666); // 对应 fopen("log.txt", 'w');
int fd2 = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666); // 对应 fopen("log.txt", 'a');
int fd3 = open("log.txt", O_RDONLY); // 对应 fopen("log.txt", 'r');

其中666这里是一个八进制数,表示新创建文件的访问权限中 mode 的值,而实际最终权限为: mode & ~umask 。所以因为umask=2,则这里如果设置为666,则其权限就是 rw-rw-rw-

2. wirte

如图所示:

功能:将数据从 buf 对应的缓冲区中写入到由文件描述符指定的文件中。

参数:

  • fd:由 open 调用返回的文件描述符。
  • buf:指向要写入数据的缓冲区指针。
  • count:希望写入的字节数。

返回值:成功时返回实际写入的字节数(可能小于 count);失败时返回 -1。

使用示例:

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

int main()
{
    int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666); // 对应 fopen("log.txt", 'w');
    if(fd == -1)
    {
        printf("open error\n");
        return 1;
    }

    const char *message = "hello Linux!\n";
    write(fd, message, strlen(message));

    close(fd); // 关闭文件的系统调用
    return 0;
}

可以写,也就有读的接口,也就是 read,如图所示:

它的功能是从由文件描述符 fd 指定的文件中,最多读取 count 个字节的数据,并存入用户提供的缓冲区 buf 中。读取成功则返回实际读取的字节数。 失败则返回 -1。示例:

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

int main()
{
    int fd = open("log.txt", O_RDONLY); // 对应 fopen("log.txt", 'r');
    if(fd == -1)
    {
        printf("open error\n");
        return 1;
    }

    char buffer[1024];
    read(fd, buffer, sizeof(buffer));
    printf("buffer: %s\n", buffer);

    close(fd); // 关闭文件的系统调用
    return 0;
}

通过上面代码可以发现,这个文件描述符fd很重要,这个两个函数都需要使用这个fd。

3. close

如图:

参数是一个文件描述符 fd,函数功能是关闭一个与文件描述符fd对应的文件。

注意:因为存在多个fd都对应同一个文件对象的情况。所以执行close时,会递减对应文件对象的引用计数;若引用计数归零,才会真正关闭底层文件,并释放相关内核资源。


三、访问文件的本质

文件的数据结构 struct file

我们知道,文件是放在磁盘上的,打开文件,这是进程做事。根据冯洛伊曼体系,文件被打开,必须先被加载到内存中!一个进程会打开多个文件,所以操作系统中一定存在大量的被打开的文件,OS要管理这些文件,就得先描述,再组织,所以内核中,先进行描述,一个被打开的文件都必须有自己的文件打开对象(包含文件的很多属性,如基本属性,权限,大小读写位置等),然后当有多个文件被打开时,操作系统会将其进行组织,形成一个文件对象的双链表的结构 。正如每个进程都有一个 PCB(在 Linux 中体现为 task_struct)来描述其属性一样,每个打开的文件也有对应的内核数据结构来进行描述和管理。这个数据结构叫做struct file。所以,内核必须在内存里为每一次文件的打开操作创建一个 struct file。

那么进程PCB是如何联系到文件的呢?

PCB也就是task_struct中包含了一个指向 files_struct 结构体的指针(通常命名为 *files)。而这个 files_struct 结构体内部维护着一个非常重要的数组,叫做 文件描述符表 (名字叫 fd_array)。在这个fd_array中存储的数据就是指向 struct file 对象的指针。 我们在代码中使用的文件描述符(fd)也就是这个数组的下标。 在Linux源码中有以下信息:所以,整个访问文件的过程就可以以如下这个图来解释:

所以访问文件的本质就是进程通过文件描述符表间接操作内核文件对象的过程。每个进程的 task_struct 包含指向 files_struct 的指针,其内部的 fd_array\[\] 数组以文件描述符(fd)为下标,存储着指向 struct file 的指针。当进程访问文件时,系统利用 fd 在数组中查找对应的 struct file 对象。该对象是操作系统对打开文件的描述,包含了读写位置、权限及操作函数等信息。最终,内核通过这些 struct file 对象来完成对实际数据的读写。x


四、理解文件的重定向

1. 文件描述符

在上面的内容中,介绍系统调用是我们是认为文件描述符就是open函数的返回值,对应的是一个文件的数字,而在认识访问文件的本质时,我们已经认识到,文件描述符其实是存储文件数据结构对象指针的文件描述符表数组的下标,是用来专门找到我们要访问的文件的。所以,只要拿着文件 描述符,就可以找到对应的文件。

但是我们如果使用open来这种打开一个文件,然后观察它的返回值,会发现:fd的值是从3开始的。这是因为C程序默认启动时会自动打开三个默认标准输入输出流,stdin代表的是键盘文件,stdout代表的是显示器文件,stderr代表的也是显示器文件。而这三个文件形成的 struct file 的指针分别存储在文件描述符表数组的 0,1,2 下标处

验证:通过在 open 传递这个0~2来验证它们是不是键盘和显示器。

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

int main()
{
    // 验证0号下标是 stdin
    char buffer[1024];
    ssize_t len = read(0, buffer, sizeof(buffer));
    if(len < 0) return 1;
    buffer[len] = '\0';
    printf("echo : %s\n", buffer);

    // 验证1号下标是 stdout; 2号是 stderr
    const char *str = "Hello Linux!\n";
    write(1, str, strlen(str));
    write(2, str, strlen(str));
    return 0;
}

其中stdout和stderr虽然都是显示器,但是也有一些不同,具体的在理解 重定向 后再解释。

补充:FILE中一定包含了文件描述符

在fopen这些库函数中,返回值是FILE* ,而这个FILE则是C语言自己封装的结构体。通过上面的理解,我们认识到,Linux访问文件时,系统调用只认文件描述符fd,所以我们可以认识到在 FILE 结构体中一定封装了文件描述符。在FILE中的文件描述符的名字叫 _fileno。证明如下:

我们知道C程序默认的三个标准输入输出流的类型就是FILE*,如图:所以,我们可以产看其中的成员信息即可,如下代码:

cpp 复制代码
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;
}

执行结果:从这个结果中也可以看出stdin,stdout,stderr对应的文件描述符分别为0,1,2。

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

通过之前的学习,我们知道文件描述符的取值是从0开始的正整数(因为是下标),那么当我们通过打开一个文件后,得到的文件描述符的取值是怎么算的呢?比如谁先谁后?从几开始?......

答案就是:

文件描述符是从最小的且没有被 fd_array 数组使用的值开始分配的。

细节:

  • 谁先被打开就谁先分配。
  • 因为我们有默认打开的三个标准输入输出流(即数组下标0~2已经使用了),所以一般我们现在打开的文件下标都是从3开始分配的。如果关闭了,0,1,2,则就从被关闭的一个下标开始分配。

验证:

代码1:通过open函数来打开文件,观察各自的fd值。

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

int main()
{
    int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    int fd2 = open("log2.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    int fd3 = open("log3.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    int fd4 = open("log4.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);
    return 0;
}

结果如下:

代码2:关闭一个其中的 stdin (标准输入流,文件的 fd 的值为0),再观察代码结果:

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

int main()
{
    close(0); // 关闭下标为0对应的文件,也就是stdin

    int fd1 = open("log1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    int fd2 = open("log2.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    int fd3 = open("log3.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    int fd4 = open("log4.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);
    return 0;
}

结果如下:因为0关闭了,所以0就没有被使用了,第一个打开的文件的fd也就是0,但1(stdout)和2(stderr)没有关闭,所以,第二个文件的fd就是从下标3开始了。

代码3:关闭0和2文件,即上代码2中的 " close(0); " 代码之后,添加一个 " close(2); ",执行结果如下:关闭了0和2,则0和2的位置就空闲了,所以第一第二个被打开的文件下标就是0和2了,后续的会从3开始逐步使用。

3. 重定向

理解重原理

重定向的核心思想就是改变命令的输入来源或输出目的地。在学习基础指令的时候,我们知道重定向可以分成3类:

  • 输入重定向:将本来我们要从一个文件中读取数据重定向为从另一个指定的文件中去读取。
  • 输出重定向:将本来我们要写入到一个文件中的数据,重定向为写入到一个指定的文件中。
  • 追加重定向:本来我们要写入一个文件中的数据,重定向为添加到一个指定文件的末尾。

在认识 基本指令介绍_lightqjx-CSDN博客 的时候,我们认识到了它们的指令:输入重定向是 < ,输出是 >;追加是 >> 。比如使用指令 echo "hello" > log.txt 就是将要打印的显示器文件上的数据重定向为写入到 log.txt 中去。

下面我们以输出重定向为例来理解一下重定向的原理。

首先以下代码和运行结果:

通过图中解释我们可以清除明白其中现象就是想显示器打印字符串。但是如果我们在这个代码之前将1对应文件关闭了呢?即如下代码及运行结果所示:

现象就是:原本向显示器打印的字符串却向文件log.txt打印了。为什么?原因如下:

通过之前的学习,我们已经知道文件描述符的分配规则了,由于这个进程创建时默认打开的三个标准输入输出流的文件描述符分别是0,1,2,其中标准输出流,也就是显示器的文件描述符是1,原本write是向1进行写入的,但是,由于代码开头执行了close(1)将fd_arrays数组中的1位置的stdout关闭了,此时1没有被使用,则当通过open函数打开文件时,此时新文件对应的文件描述符也就分配到了下标1对应的位置。所以fd_array对应的文件就变成了log.txt,所以此时对1文件进行写入,也就变成了对log.txt文件进行写入。图示:

总而言之,就是将fd_array数组下标1位置的 struct file* 由显示器文件对象的地址改成了log.txtd 的文件对象地址。

这个现象也就是输出重定向,由此我们也就能知道重定向的本质:修改了fd_array数组特定下标中的指针内容,让其指向了需要重定向的文件

dup2 函数

我们如何才能实现一个重定向效果呢?在系统中有一个叫dup2的函数,如下图所示:这个函数的功能是:在内核的文件描述符表中,将 newfd 下标对应的指针替换为 oldfd 对应的指针。这就是进行一个拷贝操作罢了,将第一个参数中的指针拷贝到第二个参数中的位置。

示例:我们手动实现一个重定向代码

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

int main()
{
    int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    
    // 重定向
    dup2(fd, 1); // 将1中的指针替换为fd中的指针
    const char *str = "Hello Linux!\n";
    write(1, str, strlen(str));
    write(1, str, strlen(str));
    write(1, str, strlen(str));
    write(1, str, strlen(str));
    write(1, str, strlen(str));

    close(fd);
    return 0;
}

这样我们就不需要先关闭文件1(即close(1)),就可以得到将写入内容重定向到 log.txt 的效果了。

与进程替换中的关系

当我们fork创建子进程之后,先进行了重定向,然后再执行exec系列接口进行程序替换的时候,会有影响吗?答案是:不会。

看下面这个图:

因为进程替换发生在页表右边的内存部分,修改的是页表物理内存的映射,不会改变进程的task_struct;而重定向发生在左边,修改的是 task_struct 下属的 files_struct(文件描述符表)。由于 exec 执行时默认会继承这份文件描述符表,因此之前设置好的重定向关系(如 fd 1 指向文件而非屏幕)会被新程序完整继承。它们之间也是一种解耦状态。

所以进程历史打开的文件与进行的各种重定向关系都和未来进行程序替换无关!!程序替换,并不影响文件访问!!

4. 为自定义shell添加重定向功能

代码实现

基于【Linux系统】进程控制-CSDN博客五、实现一个shell 实现的shell代码。

本次主要是为普通命令添加了重定向功能,可以实现输入的指令如 ls -al -n >/>>/< myfilename.txt 这样的指令执行。大致修改地方有:

  • 首先在获取指令时,会先判断是否为重定向,若是,则将 >/>>/< 前面的字符串和后面的分割开
  • 然后是在普通指令执行时,在执行exec接口之前会先进行重定向,即执行dup2函数。
  • 最后是全局变量关于文件名和重定向类型的存储和初始化

实现源码如下:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define MAX_SIZE 1024 // 命令行参数的最大长度
#define LEFT "["
#define RIGHT "]"
#define PROMPT "#"  // 命令行提示符
#define DELIM " " // 字符串分隔符 
#define ARGV_SIZE 32 // 命令行参数的最大个数
#define EXIT_CODE 4 // 普通命令执行失败的退出码

// 重定向类型
#define NONE -1
#define IN_RDIR     0
#define OUT_RDIR    1
#define APPEND_RDIR 2

int quit = 0; // 判断是否退出程序
char commandline[MAX_SIZE]; // 整个命令行字符串
char *argv[ARGV_SIZE]; // 存储命令行分隔之后的字符串
char pwd[MAX_SIZE]; // 当前目录--因为

// 保存环境变量的缓冲区-局限:只能添加一个环境变量
char myenv[MAX_SIZE];

int last_exit_code = 0; // 保存上一个进程的退出码


char *rdirfilename = NULL; // 重定向的文件名
int rdir = NONE; // 重定向的类型


void undatepwd()
{
    getcwd(pwd, sizeof(pwd)); // 获取当前目录
}

// 核实重定向
// ls -al -n >/>>/< filename.txt 
void check_redir(char* cmd)
{
    char *pos = cmd; // 通过\0来分隔
    while(*pos)
    {
        if(*pos == '>')
        {
            // 判断是否为追加
            if(*(pos+1) == '>')
            {
                *pos = '\0'; pos++;
                *pos = '\0'; pos++;
                
                // 跳过空格
                while(isspace(*pos)) pos++;

                rdirfilename = pos;
                rdir = APPEND_RDIR; // 追加重定向

                break;
            }
            else
            {
                *pos = '\0'; pos++;
                
                // 跳过空格
                while(isspace(*pos)) pos++;

                rdirfilename = pos;
                rdir = OUT_RDIR; // 输出重定向

                break;
            }
        }
        else if(*pos == '<')
        {
            *pos = '\0'; pos++;
            
            // 跳过空格
            while(isspace(*pos)) pos++;

            rdirfilename = pos;
            rdir = IN_RDIR; // 输入重定向

            break;
        }
        else pos++;
    }
}

// 1. 获取命令行
void interact(char* cline, int sz)
{
    undatepwd();
    printf(LEFT"%s@%s %s"RIGHT""PROMPT" ", getenv("USER"), getenv("HOSTNAME"), pwd);
    
    // 获取信息
    char *str = fgets(cline, sz, stdin);
    if(!str) assert(str == NULL); // 返回值为空则读取失败
    cline[strlen(cline) - 1] = '\0'; // 最后一个字符时'\n',需要修改

    // 判断,如果是重定向,则需要将指令分隔
    check_redir(cline);
}

// 2. 解析命令行
int split_string(char *cline, char *_argv[]) // 将cline中的字符串通过空格分隔后存入_argv中
{
    int i = 0;
    _argv[i++] = strtok(cline, DELIM); // 第一次调,需要传入原始字符串

    // 后续调用,第一个参数必须传NULL  
    while(_argv[i++] = strtok(NULL, DELIM)); // 找不到时,返回NULL;找到了就返回分隔出来的子串

    return i - 1; // 如果输入为空,则i-1==0
}

// 3. 内建命令,直接在函数内部实现
int is_buildCommand(int _argc, char *_argv[])
{
    // cd 命令
    if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
    {
        chdir(_argv[1]);
        undatepwd();
        sprintf(getenv("PWD"), "%s", pwd); // 更新环境变量中的目录
        return 1;
    }
    // export 命令,导入环境变量
    else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        strcpy(myenv, _argv[1]);
        putenv(myenv);
        return 1;
    }
    // echo 命令
    else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        // echo $?
        if(strcmp(_argv[1], "$?") == 0)
        {
            // 打印上一个进程的退出码
            printf("%d\n", last_exit_code);
            last_exit_code = 0;
        }
        // echo $环境变量
        else if((*_argv[1]) == '$')
        {
            char *val = getenv(_argv[1] + 1);
            if(val) printf("%s\n", val); 
        }
        // 打印字符串的功能
        else 
        {
            printf("%s\n", _argv[1]);
        }
        return 1; 
    }

    // 对ls做特殊处理,显示有颜色
    if(strcmp(_argv[0], "ls") == 0)
    {
        // 命令后加上 --color
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }

    return 0;
}

// 4. 创建子进程执行普通命令
void ordinary_command(char *_argv[])
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork"); // 创建子进程失败
        return;
    }
    else if(id == 0) // 子进程
    {
        // 进行重定向工作
        int fd = 0;

        if(rdir == IN_RDIR)
        {
            fd = open(rdirfilename, O_RDONLY);
			dup2(fd, 0);
        }

        if(rdir == OUT_RDIR)
        {
            fd = open(rdirfilename, O_CREAT|O_WRONLY|O_TRUNC, 0666);
			dup2(fd, 1);
		}
        if(rdir == APPEND_RDIR)
        {
            fd = open(rdirfilename, O_CREAT|O_WRONLY|O_APPEND, 0666);
			dup2(fd, 1);
		}

        int ret = execvp(_argv[0], _argv);
        if(ret == -1) 
        {
            printf("进程替换失败\n");
            perror("execvp");
            exit(EXIT_CODE); // 执行失败 
        }
    }
    else // 父进程要等子进程
    {
        int status = 0;
        int ret = waitpid(id, &status, 0);
        if(ret == id && WIFEXITED(status))
        {
            // 等待成功
            last_exit_code = WEXITSTATUS(status);
            return;
        }
    }
}


int main()
{
    // 不退出就一直执行
    while(!quit)
    {
        // 初始化
        rdirfilename = NULL;
        rdir = NONE;

        // 1. 交互问题--获取命令行
        interact(commandline, sizeof(commandline));

        // 2. 分隔字符串--解析命令行
        int argc = split_string(commandline, argv);
        if(argc == 0) continue; // 输入为空

        // 验证
        // for(int i = 0; argv[i]; i++) printf("[%d]: %s\n", i, argv[i]);

        // 3. 判断指令--内建命令直接就执行了
        int n = is_buildCommand(argc, argv);  // 是内建命令返回 1


        // 4. 普通命令就需要创建子进程执行
        if(!n) ordinary_command(argv); 
    }

    return 0;
}

细节问题:stdout和stderr

stdout 和 stderr 这两个标准流,指向的都是显示器文件。所以我们需注意以下这种情况:

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

int main()
{
    const char *str1 = "hello normal message\n";                                                                                          
    write(1, str1, strlen(str1)); // 向stdout中写数据

    const char *str2 = "hello error message\n";                                                                                          
    write(2, str2, strlen(str2)); // 向stderr写数据
    return 0;
}

如果执行运行这个代码,则它们都会打印在显示器上,即:

但是如果我们将它们打印的信息重定向到一个文件中呢?就会出现以下现象:文件中有通过stdout打印的信息,但没有通过stderr打印的信息。为什么?

因为在执行 ./myfile > text.txt 发生的是输出重定向,默认重定向的是stdout文件。在bash的子进程中会执行dup2(3, 1),将3位置的指针拷贝到1位置,所以向1位置打印的数据都会打印向文件text.txt中,而2位置却没有进行重定向。如图所示:所以打印向stdout的会写入进入文件,而写入到strerr的会写入到显示器中。

如果要让它们都进行重定向,在指令阶段都需要指定一下,比如执行以下代码:

bash 复制代码
#方法1
./myfile 1>normal.txt 2>err.txt  # 将1重定向到normal.txt,2重定向到err.txt

#方法2
./myfile 1>all.txt 2>&1 # 全部写入到同一个文件
# 2>&1的意思是:将文件描述符 2(标准错误)复制为文件描述符 1 当前的指向

这里的1可以省略,因为默认不写,系统会认为它的 fd 就是 1 。


五、如何理解 "一切皆文件"?

为什么说我们在Linux下,可以认为一切皆文件?

在系统中一定存在着各种设备,比如磁盘、显示器、键盘、网卡等等,大部分都是外设。操作系统要管理它们,每一个设备都要有自己的描述结构体对象。由于硬件特性的巨大差异,不同设备的对象是完全不同的,各自对象中也都封装了针对该设备的特定读写方法。

我们之所以能将这些千差万别的资源统称为"文件",关键在于内核引入了一个统一的抽象层------虚拟文件系统(VFS),以及其中的核心数据结构 struct file 。

对于每一个打开的设备,操作系统都会为它创建一个struct file 。当要对设备进行读写时,由于底层的设备都不一样,所以读写方法也不一样,那么要怎么让它们关联呢?在创建的struct file 中,有一个函数指针表 ,在struct file 中有一个指针的名字叫做 f_op,如图所示:这个 f_op 是一个指向 file_operations 结构体的指针。该结构体定义了一组函数指针 ,如图所示:这些函数指针指向的就是硬件设备结构体中自己的读写函数地址。

所以当使用系统调用接口的,比如read(fd, buf, count) 的时候,内核会先根据文件描述符 fd 在task_struct中的文件描述符表中找到对应的 struct file。 通过 struct file 找到函数指针表 f_op ,最后再通过f_op 中的函数指针来执行具体设备的读取函数。所以我们可以得到如下这个结构:

因此,在进程看来,所有设备都是被抽象为 struct file 结构体进行操作,这正是 Linux "一切皆文件"的体现。

仔细观察,可以发现这其实是就一个多态的机制,相当于将struct file 作为了基类,而将 struct file_operations 作为了虚函数表。虽然 C 语言本身不是面向对象语言,但 Linux 内核通过这种设计确实是实现了一种"运行时多态"。


六、缓冲区

1. 理解缓冲区

首先来看以下代码:通过三个库函数printf/fprintf/fwrite和一个系统调用write向显示器上打印信息,最后还调用了一次 fork 。

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

int main()
{
    const char *fstr = "hello fwrite\n";
    const char *str = "hello write\n";

    // 库函数,向stdout打印
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    fwrite(fstr, strlen(fstr), 1, stdout);

    // 系统调用,向stdout打印,stdout的文件描述符为 1
    write(1, str, strlen(str));

    fork();
    return 0;
}

运行程序之后,可以看到到如下结果:每个函数都输出了一遍到显示器上。但是,当我们将结果重定向到一个文件(比如log.txt)中时,输出结果又会不一样,如下所示:其中,库函数在文件中写了两次,系统调用在文件中只写了一次。那么这是为什么呢 ?要理解以上这个现象,我们就必须认识到缓冲区是什么?

缓冲区在哪里?

缓冲区,我们知道它是一段空间。那么我们再来看以下代码:将上面代码的字符串中的\n去掉,不使用fork,而是在代码最后通过close(1)来关闭显示器文件。

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

int main()
{
    const char *fstr = "hello fwrite";
    const char *str = "hello write";

    // 库函数,向stdout打印
    printf("hello printf");
    fprintf(stdout, "hello fprintf");
    fwrite(fstr, strlen(fstr), 1, stdout);

    // 系统调用,向stdout打印,stdout的文件描述符为 1
    write(1, str, strlen(str));

    close(1); // 关闭显示器文件
    return 0;
}

此时运行结果如下:我们知道因为\n会刷新缓冲区,而这里我们没有带\n,所以只有当程序结束时才会刷新缓冲区,因此在调用了printf/fprintf/fwrite之后,数据已经被写入缓冲区了,只有当程序结束才会显示,但是我们在代码最后关闭了显示器文件,所以什么也不打印。printf/fprintf/fwrite是库函数,其内部都会再调用系统调用write,但是这里却打印了系统调用write的内容。所以我们可以得出一个结论:这里的缓冲区一定不在操作系统内部,而是C语言自己提供的一个缓冲区

C语言自己提供具体提供,那具体在哪里呢?上面都是向stdout打印,而 stdout 的类型都是 FILE* 指针,所以C语言通过的缓冲区其实是在FILE这个结构体中,如下就是FILE中维护缓冲区的字段和维护信息的部分代码:可以发现其中FILE中不仅有文件描述符_fileno,还有一系列的各个缓冲区指针。

这个C语言提供的缓冲区时用户级别的,所以是用户缓冲区。

内核也有缓冲区

当然,以上所说的知识C语言自己的缓冲区,也就是用户态的C语言标准库缓冲区(即用户缓冲区 )。除了这个,在操作系统内核中也有一个内核态的缓冲区(内核缓冲区)

库函数是封装了系统调用的,所以当执行了库函数之后,首先会将数据加入到用户缓冲区,然后再通过系统调用,将其刷新到内核缓冲区,最后再刷新到硬件(磁盘或显示器)上。其中从系统调用到硬件的操作都是有操作系统自己完成的。

所以整个缓冲区的结构位置大致如下图所示:

缓冲刷新的策略

C语言自己的缓冲区中,刷新缓冲区也分情况和方法,缓冲的刷新类型可以分成三种:

  1. 无缓存:数据不经过用户态缓冲区,直接通过系统调用(如write)传递给内核
  2. 行缓冲:当数据加入缓冲区时,不刷新,只有当遇到 \n 才刷新。比如显示器显示数据就是行刷新
  3. 全缓冲:当数据加入缓冲区,只有当缓冲区满了才刷新。比如文件的读写。

除此之外,程序退出,\n,fflush、fclose 也都可以刷新缓冲区。刷新缓冲区的时机是在调用系统调用之前,因此这个缓冲区的几个可以以如下这个图来理解:系统内核的缓冲机制 没有像用户态那样"行缓冲/全缓冲"的固定分类,它的刷新策略是是操作系统自己决定的,一般情况下可以认为:只要系统资源允许(有机会),它就会把数据写入磁盘;若系统繁忙,则会暂缓写入。

所以之前为什么出现以下情况:

就可以得到解释了,原因如下:

情况1:因为显示器打印数据是行缓冲,又因为每一次调用函数打印的数据结尾都有\n,所以每执行一次函数都会将数据刷新出缓冲区,从而显示到显示器上,所以当代码执行到fork时,缓冲区的数据全部都已经被刷新了,所以只看到了一次。

情况2:当我们重定向到一个文件中的时候,此时刷新方法就变成了全缓冲。当执行printf/fprintf/fwrite时,数据都会加入到C语言自己的缓冲区中,不会刷新出去(因为是全缓冲,且这里缓冲区没有满),之后,当执行fork创建子进程后,子进程拷贝父进程的代码和数据,所以子进程也有一份和当前父进程一样的缓冲区数据(即printf/fprintf/fwrite的放入缓冲区的数据子进程也有一份),然后程序结束才会刷新C语言的缓冲区,会调用底层的write将数据写入内核缓冲区,最后再由操作系统将数据写入文件,所以此时父子进程都各自会刷新一份数据到文件中去,最终我们就看到了重定向到文件中的库函数打印的数据会打印两份。而write是系统调用接口,具体是由操作系统完成的,可以认为无缓冲(条件好的情况下)的。

2. 实现一个简易的C语言内部缓冲区代码

理解了C语言自己的缓冲区,下面我们来模拟实现一个C语言内部的代码,以此来掌握C语言内部到底是怎么使用缓冲区的。

主要需要认识的有两点:

  • 模拟一下FILE结构体内部的内容,其中包含了缓冲区和文件描述符。
  • 模拟实现函数fopen,fwrite,fclose 这三个库函数

以下就是我们实现的一个简易C语言库的代码:

头文件 Mystdio.h

cpp 复制代码
#pragma once
#include <string.h>

#define SIZE 1024

// 刷新缓冲的策略
#define FLUSH_NOW 1
#define FLUSH_LINE 2
#define FLUSH_ALL 4

// FILE 结构体的信息
typedef struct IO_FILE
{
    int fileno;
    int flag; 
    // 输入缓冲区
    char inbuffer[SIZE];
    int in_pos;
    // 输出缓冲区
    char outbuffer[SIZE]; // 主要使用这个
    int out_pos;
}_FILE;

// 模拟实现
_FILE * _fopen(const char*filename, const char *flag);
int _fwrite(const char *s, int len, _FILE *fp);
void _fclose(_FILE *fp);

源文件 Mystdio.c

cpp 复制代码
#include "Mystdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>

#define FILE_MODE 0666  // 默认创建的文件权限

// mode: r / w / a
_FILE* _fopen(const char *filename, const char *mode)
{
	int flags = 0; // 文件打开/创建的模式(比特位)
	int fd = 0; // 文件描述符	
	
    if(strcmp(mode, "r") == 0)
    {
        flags = O_RDONLY;
        fd = open(filename, flags);
    }
    else if(strcmp(mode, "w") == 0)
    {
        flags = (O_CREAT | O_WRONLY | O_TRUNC);
        fd = open(filename, flags, FILE_MODE);
    }
    else if(strcmp(mode, "a") == 0)
    {
        flags = (O_CREAT | O_WRONLY | O_APPEND);
        fd = open(filename, flags, FILE_MODE);
    }
    else return NULL;

    // 创建并更新FILE中的信息
    _FILE *fp = (_FILE*)malloc(sizeof(_FILE));
    if(fp == NULL) return NULL;

    fp->fileno = fd;
    // fp->flag = FLUSH_ALL; // 这里以设置为全缓冲为例,原本是要通过判断来决定是什么刷新的
    fp->flag = FLUSH_LINE; // 行刷新
    
    fp-> out_pos = 0;

    return fp;
}
#include <stdio.h>
// size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
int _fwrite(const char *s, int len, _FILE *fp) // 为了方便就不写nmemb了
{
   // 先写入缓冲区
   memcpy(&fp->outbuffer[fp->out_pos], s, len);
   fp->out_pos += len;
   // 判断刷新缓冲的方法来写入数据
    if(fp->flag & FLUSH_NOW)
    {
        write(fp->fileno, fp->outbuffer, fp->out_pos);
        fp->out_pos = 0;
    }
    else if(fp->flag&FLUSH_LINE)
    {
        if(fp->outbuffer[fp->out_pos-1] == '\n') // 这里假设只有结尾存在\n,不考虑其他情况
		{
            write(fp->fileno, fp->outbuffer, fp->out_pos);
            fp->out_pos = 0;
        }
    }
    else if(fp->flag & FLUSH_ALL)
    {
        if(fp->out_pos == SIZE)
        {
            write(fp->fileno, fp->outbuffer, fp->out_pos);
            fp->out_pos = 0;
        }
    }
    return len;
}

void _fflush(_FILE *fp)
{
	if(fp->out_pos > 0)
	{
		write(fp->fileno, fp->outbuffer, fp->out_pos);
		fp->out_pos = 0;
	}
}
void _fclose(_FILE *fp)
{
	if(fp == NULL) return;
	_fflush(fp);
	close(fp->fileno);
	free(fp);
}

感谢各位观看!希望能多多支持!

相关推荐
我星期八休息1 小时前
扩展— TCP 全连接队列与 tcpdump 抓包
linux·服务器·开发语言·前端·网络·tcp/ip·tcpdump
RisunJan2 小时前
Linux命令-skill(发送信号给进程)
linux·运维·服务器
潘正翔2 小时前
k8s基础_kubeadm搭建k8s集群
linux·运维·docker·云原生·容器·kubernetes
孪生质数-2 小时前
AI 应用实践篇——让大模型真正开始工作
linux·运维·服务器·人工智能·深度学习·语言模型·llama
亿道电子Emdoor2 小时前
【Arm】MSP和PSP的区别简介
c语言·arm开发·单片机·mcu
2301_777998342 小时前
Linux线程同步与互斥(三):信号量与环形队列实现生产者消费者模型
linux·c语言·c++
实心儿儿3 小时前
Linux —— 应用层协议HTTP
linux·网络·http
c238563 小时前
《枚举算法 “翻译官”:用 enum class 给 “游游的礼盒” 算分》
c语言·c++·算法
noipp3 小时前
推荐题目:洛谷 P5843 [SCOI2012] Blinker 的噩梦
c语言·数据结构·c++·算法·游戏·洛谷·luogu