【Linux】基础IO(一)Linux 文件操作从入门到实践:系统调用、文件描述符、重定向,为自定义Shell添加重定向

文章目录


一、背景补充

1、我们知道一个文件有文件内容和文件属性(元数据),所以对文件进行操作要么就是对内容做操作,要么就是对属性做操作。

2、访问一个文件要先把文件打开,因为访问文件本质就是CPU对文件的内容或者属性做增删改查,根据冯诺依曼体系结构,CPU要访问文件必须要先把文件加载进内存,打开文件本质就是加载文件到内存。

3、没有被打开的文件会待在磁盘上。

4、打开文件是用户通过bash,启动进程,进程调用系统调用打开的。因为打开文件一定需要操作系统出手,只有操作系统有资格访问硬件。所以C语言提供的fopen函数底层一定封装了打开文件的系统调用。

5、一个进程可以打开多个文件,所以操作系统中一定同时存在大量的被打开的文件,所以我们需要管理这些被打开的文件,管理文件要遵循先描述再组织。那么我们可以提前预言操作系统中一定存在对应的内核数据结构来描述并管理这些文件,如同PCB。

6、进程有task_struct,文件是由进程打开的,所以未来研究打开文件就是研究进程与文件的关系。

二、回顾C文件接口

fopen

打开文件接口,第一个参数是打开的文件名,如果当前路径下不存在该文件,以"w"打开会在当前路径下创建一个该文件,若以"r"打开会报错。如果需要也可以带路径,第二个参数是打开文件的方式, 打开成功会返回一个 FILE* 类型的指针,打开失败返回NULL。
打开方式介绍:

"r":以读方式打开,若文件不存在则报错。

"r+":以读、写方式打开,若文件不存在则报错。

"w":以写方式打开,若在指定路径下文件不存在会新建一个文件,若指定路径下存在该文件,会把该文件内容清空。

w+":以读、写方式打开,若文件不存在,会新建文件。

"a":以写方式打开,追加写(append),不清空原文件,在文件末尾追加写。若文件不存在,会新建文件。

"a+":以读、写方式打开,追加写(append),不清空原文件,在文件末尾追加写。若文件不存在,会新建文件。读的时候读写位置在文件开头,写的时候读写位置在文件结尾。
上面是语言层面的理解,在系统层面我们以前学习的输出重定向本质就是以"w"方式打开文件并写入,因为根据前面是背景补充要访问一个文件必须要打开文件,所以输出重定向底层一定要先打开文件。

bash 复制代码
#输出重定向
echo "hello wusaqi" > log.txt

追加重定向本质就是以"a"方式打开文件并写入。

bash 复制代码
#追加重定向
echo "hello wusaqi" >> log.txt

fclose

关闭文件接口,参数类型为FILE*指针,指向要关闭的文件,返回值类型为int,关闭成功返回0,关闭失败返回EOF。

fwrite

对文件写入数据的接口。第一个参数是待写入数据所在空间的起始地址,第二个参数是要写入数据的基本单元大小,第三个参数是要写入几个基本单元,第四个参数是要往哪个文件流里写,返回值是成功写入的基本单元个数。下面是往文件中写一串字符串的示例代码:

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

int main()
{
    printf("我是一个进程,pid: %d\n", getpid());
    FILE* pf = fopen("log.txt", "w");
    if(!pf)
    {
        perror("fopen");
        return 1;
    }
    
    const char* str = "hello wusaqi\n";
    fwrite(str, strlen(str), 1, pf);

    fclose(pf);
    return 0;
}

这里小编有个问题,当我们往文件写入字符串时,要不要strlen()+1往文件中写入'\0'呢?其实不用,字符串最后有'\0'是C语言的规定,文件中最好不要带'\0',因为'\n'会以乱码是形式在文本文件中存在。所以我们向文件写入字符串时不用加'\0',从文件中读取字符串时自己手动加上'\0'就行了。

fread(自定义cat)

首先区分fgets和fread,fgets会在读取size-1个数据,并数据末尾自动添加'\0',fread会读取size个数据,不会在数据末尾自动添加'\0',因为fgets是专门用于读取文本字符串的函数,而 fread 是通用的二进制数据读取函数。

读取文件数据的接口,第一个参数是用于存放读取数据的内存空间的起始位置,第二个参数是要写入参数的基本单元大小,第三个参数是要写入几个基本单元,第四个参数是要从哪个文件流里读取,返回值是成功读取的基本单元个数。
下面我们来自定义一个cat指令来深刻理解一下以读方式打开文件和fread。
1、 C语言中将字符串数组清空的最快方式是把数组的一号下标置为0:buffer[0] = 0。

2、因为我们是以fread读取,所以读取时要预留一个空间给'\0',如果用fgets读取就不需要预留,它会自动为我们预留并在末尾添加'\0'。

3、int feof(FILE *stream); feof用于判断是否读到文件结尾,也就是文件指针是否指到了文件结尾,当已经读取到文件末尾会返回一个非0值,通常为1,若还没读到文件末尾则返回0。

4、需要一直循环读取文件内容,因为文件内容可能会超过一个buffer的容量,所以我们需要一直读取直到读取到文件结尾为止。

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

// ./mycat filename
int main(int argc, char* argv[])
{
    printf("我是一个进程,pid: %d\n", getpid());
    if(argc != 2)
    {
        //输入错误,应该这样输入,argv[0]表示该文件文件名
        printf("Usage: %s filename\n", argv[0]);
    }

    FILE* pf = fopen(argv[1], "r");
    if(!pf)
    {
        perror("fopen");
        return 1;
    }
    
    char buffer[1024];
    while(1)
    {
        buffer[0] = 0;//每次读取之前把buffer置为空
        int n = fread(buffer, 1, sizeof(buffer) - 1, pf);
        if(n > 0)
        {
            //读取成功,buffer[n]默认为随机值,需要置为0
            buffer[n] = 0;
            printf("%s", buffer);
        }
        else if(feof(pf))
        {
            break;
        }
    }

    fclose(pf);
    return 0;
}

进程属性cwd对于文件操作的作用

我们前面介绍了如果fopen只传入文件名,当前路径下不存在该文件,以"w"打开会在当前路径下创建一个该文件,那系统只拿到了文件名是怎么知道当前路径在哪的呢?因为fopen是由进程打开的,进程有一个cwd属性,它是指向当前进程运⾏⽬录的⼀个符号链接(指向该进程的绝对路径),所以进程打开文件时就会默认将文件在该路径下打开。所以如果我们chdir修改进程的当前工作路径后,进程再打开文件,那么文件就会在修改后的路径下打开。之前小编已经写过这一理论的示例代码了:点这里
无论是文本文件还是二进制文件,文件本质就是一个一维数组,多个行之间可以看成一行,只不过就是多了一个换行符,文件的读写位置,就是数组下标。

三、stdin & stdout & stderr

在介绍这三个标准输入输出之前小编先补充一些知识:

我们平时在显示器上打印12345这样的数字,本质不是打印的int类型的数字,而是字符'1' '2' '3' '4' '5',同理我们在键盘上输入的12345也不是整型数字,而是字符,所以键盘和显示器都被叫做字符设备。

cpp 复制代码
int x = 12345;
printf("%d", x);

而像上面这样像显示器输出数字时,不会直接把4个字节的int类型变量12345输出,显示器无法识别,所以printf函数内部会先把12345转换成五个字符'1' '2' '3' '4' '5',然后间接调用putchar函数:int putchar(int c);(参数是ASCII码值,一般直接传字符),把五个字符'1' '2' '3' '4' '5'按顺序打印到显示器上。所以printf是格式化输出。scanf同理,它会把我们在键盘上敲的'1' '2' '3' '4' '5'字符转换成整数存在对应的变量中,所以scanf是格式化输入。如果本身就是输入输出字符类型数据就不用进行转换。
下面我们开始进入主题,进程在启动的时候,默认会打开三个输入输出流(其实就是打开stdin & stdout & stderr这三个文件),stdin是标准输入,对应键盘文件,stdout是标准输出,对应显示器文件,stderr是标准错误,也是对应显示器文件。
为什么要有这三个标准输入输出文件呢?因为计算机本质就是用来帮我完成计算任务的,要让计算机计算首先需要把数据输入传给计算机,计算机计算完毕后再把数据输入出来让我们看到,计算出错了也需要把错误打印出来让我们看到。
下面来串讲一下把数据输出到显示器的几种常规方法:

cpp 复制代码
int printf(const char *format, ...);

把格式化输入的若干可变参数输出到标准输出流里。

cpp 复制代码
int fprintf(FILE *stream, const char *format, ...);

把格式化输入的若干可变参数输出到指定的文件流里。

cpp 复制代码
int fputs(const char *s, FILE *stream);

把字符数据输出到指定的文件流里,它自身不做格式化输出,因为它输出的参数已经就是字符串了,如果要输入非字符串数据需要自己先把数据转成字符串才能调用fputs。

cpp 复制代码
size_t fwrite(const void *ptr, size_t size, size_t nmemb,
                     FILE *stream);

它也可以向指定文件流里进行写入,它输出不会做格式化转化,传入字符串就输出字符串,传入二进制就输出二进制。
示例代码:

cpp 复制代码
    const char* s1 = "hello printf\n";
    printf(s1);

    const char* s2 = "hello fprintf\n";
    fprintf(stdout, s2);

    const char* s3 = "hello fputs\n";
    fputs(s3, stdout);

    const char* s4 = "hello fwrite\n";
    fwrite(s4, strlen(s4), 1, stdout);

四、系统调用open/close/write


前面我们介绍的各种打开文件的C语言接口本质底层都封装了open系统调用(关闭文件close也一样)。第一个参数传要打开哪个文件,一般要传文件路径和文件名,和fopen第一个参数一样,第二参数表示打开文件的打开方式标志位。如果打开的文件本身存在,用前两个参数打开即可,如果打开的文件不存在,需要用到第三个参数,给新建的文件设置权限。打开文件成功会返回一个新的文件描述符(后面讲),打开文件失败则返回-1。
有关第二个参数小编认为需要拎出来细讲一下,我们可以看到它的类型的int,传给它的参数都是下面的类似形式:

它们每一个都代表一个标志位。我们知道int类型变量有32个比特位,而这些标志位都是其中一个比特位为1,其他为0,所以理论上可以有32个标志位。所以第二个参数本质就是位图传参,为了更好理解,小编编写了一份位图传参的示例代码:

cpp 复制代码
#define VERSION1 (1<<0) //1
#define VERSION2 (1<<1) //2
#define VERSION3 (1<<2) //4
#define VERSION4 (1<<3) //8
#define VERSION5 (1<<4) //16

void ShowVersion(int flags)
{
    if(flags & VERSION1)
        printf("Version1\n");
    if(flags & VERSION2)
        printf("Version2\n");
    if(flags & VERSION3)
        printf("Version3\n");
    if(flags & VERSION4)
        printf("Version4\n");
    if(flags & VERSION5)
        printf("Version5\n");
}

int main()
{
    ShowVersion(VERSION1); // 显示版本
    printf("---------------\n");
    ShowVersion(VERSION1 | VERSION2);
    printf("---------------\n");
    ShowVersion(VERSION1 | VERSION5);
    printf("---------------\n");
    ShowVersion(VERSION1 | VERSION2 | VERSION4);
    printf("---------------\n");
    ShowVersion(VERSION1 | VERSION2 | VERSION5);
    printf("---------------\n");
    ShowVersion(VERSION1 | VERSION2 | VERSION3 | VERSION4 | VERSION5);
}

下面我们尝试用open来打开文件试试:

1、如果我们打开的文件本身不存在,需要在第二个参数传入一个O_CREAT,还有传入用哪种方式打开,比如以写方式打开:O_WRONLY。

2、我们还需要传第三个参数设置文件权限,我们会发现传0666后呈现出的文件权限是0664,这是因为有系统的umask(0002)存在。而系统的umask是可以自己设置的,设置umask的系统调用接口如下:

系统调用打开关闭文件的系统调用:

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

int main()
{
    //将umask默认设置为0
    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if(fd < 0)
    {
        //打开失败
        perror("open");
    }


    //关闭文件
    close(fd);
    return 0;
}

现在我们已经可以打开关闭文件了,接下来就要对文件进行写入了,对文件写入的系统调用接口是write:

第一个参数是要写入文件的文件描述符,第二个参数是指向待写入数据所在空间的指针,第三个参数是要写入的数据大小。

小编再多介绍一个标志位O_TRUNC,它在打开文件时会清空原来的内容,不传O_TRUNC时write的默认行为是对原内容进行覆盖写入。下面是模拟==fopen(filename, "w")==的示例代码:(fopen打开的文件权限默认是0666,但之后会受到掩码影响,最后呈现出的权限是0664,但是这里我们修改了掩码,所以文件权限最后呈现出0666)

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

int main()
{
    //将umask默认设置为0
    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT |O_TRUNC, 0666);
    if(fd < 0)
    {
        //打开失败
        perror("open");
    }

    //修改文件
    const char* str = "abcdef\n";
    write(fd, str, strlen(str));

    //关闭文件
    close(fd);
    return 0;
}

下面是模拟==fopen(filename, "w")==的示例代码,O_APPEND标志位表示从文件末尾开始追加写:

cpp 复制代码
int main()
{
    //将umask默认设置为0
    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT |O_APPEND, 0666);
    if(fd < 0)
    {
        //打开失败
        perror("open");
    }

    //修改文件
    const char* str = "abcdef\n";
    write(fd, str, strlen(str));

    //关闭文件
    close(fd);
    return 0;
}

语言的库与系统调用

系统调用open/close/write是linux操作系统独有的,所以C语言要封装一套C标准库(fopen/fclose/fwrite)让文件操作接口在不同操作系统平台下都能使用,其他编程语言也一样。库也是代码编写的,所以每个语言都不止一套标准库,因为不同的操作系统平台都有各自的系统调用接口,语言对于每个不同的平台都有一套各自的标准库,但是用户在上层使用库相关接口时是感觉不到的,因为用户都是使用的是同一套库接口。

五、文件描述符

接下来我们再来详细介绍open系统调用返回的整型变量:文件描述符。

首先打开一个文件需要进程作为发起者,操作系统作为执行者,进程打开一个文件操作系统就会在内存中创建一个struct file结构,struct file内部会包含被打开文件的属性和内容。
当内存中有多个被打开的文件时这些文件对应的struct file会以链表的形式组织起来。
我们知道文件是被进程打开的,那么文件就一定需要和进程产生对应关系,所以进程的task_struct中会存在一个指向files_struct结构体的指针,files_struct结构体是该进程的文件描述符表,该结构体中会有一个struct file* 类型的指针数组,数组的元素会指向每一个该进程打开的文件所对应的struct

file结构体,而文件描述符就是进程文件描述符表中指针数组的下标。所以在操作系统角度,识别打开的文件只认文件描述符(int fd)。
进程管理和文件管理就是通过文件描述符表联系起来的。

有了上面的认识我们来一次创建4个文件看它们的文件描述符分别是多少:

cpp 复制代码
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("%d\n", fd1);
    printf("%d\n", fd2);
    printf("%d\n", fd3);
    printf("%d\n", fd4);

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

运行结果:

我们看到文件描述符是从3开始的,文件描述符是数组下标,
那0、1、2去哪了呢?敏锐的读者应该可以意识到这三个数字不就对应操作系统自动为我们打开的三个标准输入输出吗?但是这三个标准输入输出不是FILE*类型的指针指向FILE结构体吗?那FILE有是什么呢?前面不是说了操作系统只认文件描述符吗?为什么C标准库库接口传FILE*类型变量照样也能打开文件呢?
这里小编想说的是系统不仅对文件接口open/write做了封装,也对文件描述符做了封装,fopen/fwrite是对open/write的封装,FILE结构体是对文件描述符的封装。也就意味着FILE一定有一个整型变量,这个变量表示文件描述符。

我们来看下面示例代码(_fileno就是FILE内部封装的文件描述符):

cpp 复制代码
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("stdin->%d\n", stdin->_fileno);
    printf("tdout->%d\n", stdout->_fileno);
    printf("stderr->%d\n", stderr->_fileno);

    printf("%d\n", fd1);
    printf("%d\n", fd2);
    printf("%d\n", fd3);
    printf("%d\n", fd4);

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

运行结果:

file struct

file struct的部分字段:

f_mode:表示打开文件的权限

f_pos: 表示文件的读写位置,我们谈过文件本质就是一个一维数组,所以读写位置本质就是数组下标
file struct内部还会有指针指向内存的"文件属性"区域,还会有指针指向文件内核缓冲区(一个个的内存块),我们打开文件时系统会把磁盘中的文件属性加载到内存的"文件属性"区域中,当我们对文件进行写操作时系统会把文件内容加载到内存的文件内核缓冲区中,对文件写操作的本质就是把数据从内存的用户空间拷贝到文件的内核缓冲区中,至于什么时候将文件内核缓冲区的数据写入到磁盘中,由操作系统自己决定。

读文件本质也是只能读文件内核缓冲区中的数据,把文件内核缓冲区中的数据读到用户空间中,若文件内核缓冲区中没有数据,进程则需要等待磁盘把数据加载到内存中。

修改文件也需要先从文件缓冲区中读数据,再将数据读到用户内存空间中,把数据修改后再把数据写入文件缓冲区,然后把数据写入磁盘。

也就是说我们对文件内容进行增、删、改、查都需要将文件的内容预加载到文件内核缓冲区中。

文件描述符的分配规则

分配规则:从文件描述符数组中找没有被使用的最小的数组下标作为新打开的的文件的fd。

所以我们自己打开文件的文件描述符默认是从3开始的,因为0、1、2被标准输入,标准输入、标准错误占据了。而如果我们把0、2号文件关闭了,那么打开的第一个文件的文件描述符就变成了0、2。(文件描述符数组下标永远不会变,变的永远是数组中的元素,也就是数组下标中的内容)

下面示例代码以关闭0号文件为例:

cpp 复制代码
int main()
{
    //关闭0号文件
    close(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if(fd < 0)
    {
        perror("open");
    }

    printf("hello, wusaqi,fd:%d\n", fd);

    close(fd);
    return 0;
}

运行结果:

这时候有的读者会问了,为什么不关闭1号文件呢?如果我们关闭1号文件,再运行程序我们会发现显示器上什么也没打印出来,这时因为printf只知道往1号文件打印数据(操作系统只认文件描述符导致库函数和系统调用也只认文件描述符),而1号数据已经被我们狸猫换太子换成我们自己打开的文件了,所以显示器上当然就不会打印出来,而是打印到我们打开的"log.txt"文件中了,这个过程就是下面我们要介绍的重定向。

但是这时我们 cat log.txt 是看不到任何东西的,需要在关闭文件前先fflush(stdout),原因我们目前还无法解释,因为和后面要讲的语言缓冲区有关。

六、重定向

前面我们已经知道了输入、输出重定向本质就是偷偷该文件描述符0、1、2的底层指向。

前面我们进行重定向操作是手动关闭文件,还是有一些不方便,还有一些局限,因为可能对已经打开了的文件进行重定向。所以操作系统为我们提供了专门用于重定向的系统调用:

我们现在只关注dup2,含义是用oldfd的内容(文件指针)拷贝覆盖newfd的内容(文件指针),dup2之后newfd会被系统自动关掉,oldfd由用户决定是否要关闭。重定向成功返回0,失败返回-1。

用dup2进行输出重定向的示例代码:

cpp 复制代码
int main()
{
    // 1、打开文件
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if(fd < 0)
    {
        perror("open");
    }

    // 2、输出重定向
    dup2(fd, 1);

    // 3、正常打印
    printf("hello, wusaqi\n"); //stdout->1

    close(fd);
    return 0;
}

运行结果:

子进程继承父进程文件

父进程创建子进程后会把自己的文件描述符表传给子进程,也就是说子进程会自己创建一个文件描述符表内存空间,然后把父进程的文件描述符表内容拷贝给子进程。

这里的拷贝是浅拷贝,因为系统不会为子进程重新创建一套父进程的文件,因为这里是创建子进程,不是新建文件,所以父子进程的文件描述符表对应的文件指针会指向同一套文件。

这也就是为什么父子进程printf的时候会同时向同一个显示器文件打印。

也就是为什么子进程没有自己打开3个标准输入输出,但是却默认打开的原因。这些都是通过子进程继承的方式实现的。

file_struct的引用计数

前面我们讲了子进程会继承父进程的文件,那么子进程把其中一个文件关闭后会影响父进程吗?

答案是不会,因为文件的file_struct内部会有一个承担引用计数功能的字段:

它会记录有多少进程指向该文件,当父子进程同时存在是那么父子进程共用文件的引用计数就是2,当子进程关闭文件时只会将该文件的引用计数改为1,只有当父进程也关闭该文件使文件计数由1变为0时才会真的关闭这个文件。

进程程序替换对进程文件的影响

我们知道进程程序替换时只会更改进程的代码和数据,进程是由内核数据结构和代码和数据组成,进程的文件是被tast_struct维护的,所以程序替换不会对进程的文件产生任何影响。

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

1、首先自定义一些宏表示是否有重定向,若有是哪种重定向。

cpp 复制代码
#define NONE_REDIR 0
#define OUTPUT_REDIR 1
#define APPEND_REDIR 2
#define INPUT_REDIR 3

2、每次读取新的指令之前需要把全局的filename和redir_type初始化。

cpp 复制代码
void InitGlobal()
{
    gargc = 0;
    memset(gargv, 0, sizeof(gargv));
    filename.clear();
    redir_type = NONE_REDIR;
}

3、在获取用户输入的字符串到解析字符串之前需要创建一个checkredir用来检测用户输入的字符串是否有重定向,并把字符串进行转化: "ls -a -l > log.txt" -> "ls -a -l\0log.txt" 还需要把filename设置为log.txt,把redir_type设置为OUTPUT_REDIR。

4、checkredir内部逻辑:用两个指针start、end维护整个字符串,start指向字符串的第一个字符,end指向字符串的最后一个字符,用start遍历整个字符串,遇到 ">" ">>" "<" 就把">" ">>" "<" 置为'\0',这样后面strtok截取字符串时遇到'\0'就停止了,不会继续截取'\0'后面的文件名字符串内容。 5、">" ">>"

"<"这些字符的左边有空格不影响,因为strtok截取字符串时会忽略空格,但是这些字符后面的空格需要处理,因为我们要拿到字符文件名的首元素,例如:

需要把 '>' 后面的空格跳过,让start指向文件名的首元素字符 'l'。这里小编用一个while(0)宏来解决,每行代码最后的斜杠不能少,表示宏定义还没结束,isspace用来判断字符是不是空格字符,需要包ctype.h头文件,int isspace(int c);,参数传字符,如果是空格返回非0值,如果是空格返回0。下面是宏定义的示例代码和checkredir代码:

cpp 复制代码
#define TrimSpace(start) do{\
        while(isspace(*start))\
    {\
        start++;\
    }\
}while(0)

void CheckRedir(char cmd[])
{
    char* start = cmd;
    char* end = start + strlen(cmd) - 1;
    while(start <= end)
    {
        if(*start == '>')
        {
            if(*(start + 1) == '>')
            {
                // 追加重定向
                redir_type = APPEND_REDIR;
                *start = '\0';
                start += 2; //需要跳过两个字符
                TrimSpace(start);
                filename = start;
                break;
                
            }
            else{
                // 输出重定向
                redir_type = OUTPUT_REDIR;
                *start = '\0';
                start++; //指针指向后半部分
                TrimSpace(start);
                filename = start;
                break;
            }
        }
        else if(*start == '<')
        {
            // 输入重定向
            redir_type = INPUT_REDIR;
            *start = '\0';
            start++;
            TrimSpace(start);
            filename = start;
            break;
        }
        else{
            //遍历到非重定向相关字符,直接start++
            ++start;
        }
    }
}

6、有了checkredir后重定向的准备工作就做完了,解析命令行和检查内建命令不需要做变化,只有在执行命令ForkAndExec时需要添加执行重定向功能,在子进程的程序替换之前需要判断是哪种重定向,根据具体的重定向方式打开filename文件,然后调用dup2函数将文件重定向到特点文件描述符就完事了。后面程序替换是系统会按预定程序访问已经被我们重定向替换后的文件,然后我们就会看到重定向后的效果。

cpp 复制代码
void ForkAndExec()
{
    pid_t id = fork();
    if(id < 0)
    {
        //fork失败
        perror("fork");
        return;
    }
    else if(id == 0)
    {
        //子进程
        // 1、判断重定向,若是重定向则打开对应重定向文件并dup2重定向
        if(redir_type == OUTPUT_REDIR)
        {
            //输出重定向,输出到filename文件中
            int outputfd = open(filename.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);
            if(outputfd < 0)
                perror("open");
            dup2(outputfd, 1);
        }
        else if(redir_type == APPEND_REDIR)
        {
            //追加重定向,追加到filename文件中
            int appendfd = open(filename.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
            if(appendfd < 0)
                perror("open");
            dup2(appendfd, 1);
        }
        else if(redir_type == INPUT_REDIR)
        {
            //输入重定向,原本从键盘读取数据,重定向后从文件读取数据
            int inputfd = open(filename.c_str(), O_RDONLY);
            if(inputfd < 0)
                perror("open");
            dup2(inputfd, 0);
        }
        else{
            //非重定向,donothing
        }
        
        // 2、程序替换,程序替换不会对前面打开的文件产生影响
        execvp(gargv[0], gargv);
        exit(0);
    }
    else{
        //父进程
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if(rid > 0)
        {
            //获取子进程退出码
            lastcode = WEXITSTATUS(status);
        }
    }
}

重定向后shell源码

main.cc:

cpp 复制代码
#include "myshell.h"

#define SIZE 1024

int main()
{
    char commandstr[SIZE];

    while(true)
    {
        // 0、初始化全局变量
        InitGlobal();

        // 1、打印命令行提示符
        PrintCommandPrompt();

        // 2、读取用户输入的命令
        //如果用户没有输入,直接回车
        //会返回false,此时直接continue
        if(!GetCommandString(commandstr, SIZE))
            continue;

        // 3、重定向检查和设置
        //commandstr:"ls -a" -> "ls -a"
        //commandstr:"ls -a > log.txt" -> "ls -a"并设置redir_type和filename 
        CheckRedir(commandstr);

        // 4、解析命令行字符串
        ParseCommandString(commandstr);

        // 5、检查命令,若为内建命令由父进程运行
        if(BuildInCommandExec())
            continue;
        
        // 6、执行命令
        ForkAndExec();
    }
    return 0;
}

myshell.cc:

cpp 复制代码
#include "myshell.h"

//全局定义命令行参数表
char* gargv[ARGS] = {NULL};
int gargc = 0;

//用于存储环境变量PWD
char pwd[1024];

//用于存储上一个子进程的退出码
int lastcode;

//支持重定向功能
// ls -a -l > "log.txt"
// ls -a -l >> "log.txt"
// cat < "log.txt"

#define NONE_REDIR 0
#define OUTPUT_REDIR 1
#define APPEND_REDIR 2
#define INPUT_REDIR 3

std::string filename;
int redir_type = NONE_REDIR; 



void Debug()
{
    printf("hello shell!\n");
}

void InitGlobal()
{
    gargc = 0;
    memset(gargv, 0, sizeof(gargv));
    filename.clear();
    redir_type = NONE_REDIR;
}

static std::string GetUserName()
{
    string username = getenv("USER");
    return username.empty() ? "None" : username;
}

static std::string GetHostName()
{
    string hostname = getenv("HOSTNAME");
    return hostname.empty() ? "None" : hostname;
}

static std::string GetPwd()
{
    //string pwd = getenv("PWD");
    //return pwd.empty() ? "None" : pwd;
    char temp[1024];
    getcwd(temp, sizeof(temp));
    //顺便更新一下自己shell的环境变量
    snprintf(pwd, sizeof(pwd), "PWD=%s", temp);
    putenv(pwd);

    //命令行提示符中输出单个路径
    std::string pwd_label = temp;
    const std::string pathsep = "/"; //路径分隔符
    //查找长路径中最后一个'/'的位置
    size_t pos = pwd_label.rfind(pathsep);
    if(pos == std::string::npos)
    {
        //整个路径都没有'/',返回None
        return "None";
    }
    //从pos位置的下一个位置开始截取,相当于跳过pathsep截取后续子串
    pwd_label = pwd_label.substr(pos + pathsep.size());

    //如果此时size为0说明什么都没截取到,说明截取前pwd_label中只有"/"
    //则返回"/"
    return pwd_label.size() ? pwd_label : "/";
}

static std::string GetHomePath()
{
    std:string home = getenv("HOME");
    //若环境变量缺失或被篡改home为空,为空则回退到家目录
    return home.empty() ? "/" : home;
}

void PrintCommandPrompt()
{
    std::string username = GetUserName();
    std::string hostname = GetHostName();
    std::string pwd = GetPwd();
    printf("[%s@%s %s]# ", username.c_str(), hostname.c_str(), pwd.c_str());
} 

bool GetCommandString(char cmdstr_buff[], int len)
{
    if(cmdstr_buff == NULL || len <= 0)
    {
        //参数不合法
        return false;
    }
    char* res = fgets(cmdstr_buff, len, stdin);
    if(res == NULL)
    {
        //读取字符串失败
        return false;
    }
    //把输入的回车也就是'\n'置为'\0'
    // "ls -a -l\n" -> "ls -a -l\0"
    cmdstr_buff[strlen(cmdstr_buff) - 1] = 0;
    return strlen(cmdstr_buff) == 0 ? false : true; 
}

// while(0)宏,用于跳过字符串中的空格字符
// 例如 "ls -a -l >     log.txt"
#define TrimSpace(start) do{\
        while(isspace(*start))\
    {\
        start++;\
    }\
}while(0)

void CheckRedir(char cmd[])
{
    char* start = cmd;
    char* end = start + strlen(cmd) - 1;
    while(start <= end)
    {
        if(*start == '>')
        {
            if(*(start + 1) == '>')
            {
                // 追加重定向
                redir_type = APPEND_REDIR;
                *start = '\0';
                start += 2; //需要跳过两个字符
                TrimSpace(start);
                filename = start;
                break;
                
            }
            else{
                // 输出重定向
                redir_type = OUTPUT_REDIR;
                *start = '\0';
                start++; //指针指向后半部分
                TrimSpace(start);
                filename = start;
                break;
            }
        }
        else if(*start == '<')
        {
            // 输入重定向
            redir_type = INPUT_REDIR;
            *start = '\0';
            start++;
            TrimSpace(start);
            filename = start;
            break;
        }
        else{
            //遍历到非重定向相关字符,直接start++
            ++start;
        }
    }
}

bool ParseCommandString(char cmd[])
{
    if(cmd == NULL)
    {
        //安全检查
        return false;
    }
    //可以在函数内部定义,SEP表示分隔符
#define SEP " "
    //"ls -a -l" -> "ls" "-a" "-l" 
    
    //把第一个子串写入gargv[0],然后gargc++
    gargv[gargc++] = strtok(cmd, SEP);
    //把子串全部写入gargv数组里,并且以NULL结尾
    while(gargv[gargc++] = strtok(NULL, SEP))
        ;//循环空语句
    //回退一次命令行参数的个数
    --gargc;
    
    //条件编译,测试代码
    //#define DEBUG
    #ifdef DEBUG

    printf("gargc: %d\n", gargc);
    printf("--------------------------\n");
    for(int i = 0; i < gargc; i++)
    {
        printf("gargv[%d]: %s\n", i, gargv[i]);
    }
    printf("--------------------------\n");
    for(int i = 0; gargv[i]; i++)
    {
        printf("gargv[%d]: %s\n", i, gargv[i]);
    }

    #endif

    return true;
}

void ForkAndExec()
{
    pid_t id = fork();
    if(id < 0)
    {
        //fork失败
        perror("fork");
        return;
    }
    else if(id == 0)
    {
        //子进程
        // 1、判断重定向,若是重定向则打开对应重定向文件并dup2重定向
        if(redir_type == OUTPUT_REDIR)
        {
            //输出重定向,输出到filename文件中
            int outputfd = open(filename.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0666);
            if(outputfd < 0)
                perror("open");
            dup2(outputfd, 1);
        }
        else if(redir_type == APPEND_REDIR)
        {
            //追加重定向,追加到filename文件中
            int appendfd = open(filename.c_str(), O_CREAT | O_WRONLY | O_APPEND, 0666);
            if(appendfd < 0)
                perror("open");
            dup2(appendfd, 1);
        }
        else if(redir_type == INPUT_REDIR)
        {
            //输入重定向,原本从键盘读取数据,重定向后从文件读取数据
            int inputfd = open(filename.c_str(), O_RDONLY);
            if(inputfd < 0)
                perror("open");
            dup2(inputfd, 0);
        }
        else{
            //非重定向,donothing
        }
        
        // 2、程序替换,程序替换不会对前面打开的文件产生影响
        execvp(gargv[0], gargv);
        exit(0);
    }
    else{
        //父进程
        int status = 0;
        pid_t rid = waitpid(id, &status, 0);
        if(rid > 0)
        {
            //获取子进程退出码
            lastcode = WEXITSTATUS(status);
        }
    }

}

bool BuildInCommandExec()
{
    //不能:gargv[0] == "cd" 这样比
    //这样比是比较指针是否相同,而非字符内容
    std::string cmd = gargv[0];
    bool ret = false; //默认不是内建命令
    if(cmd == "cd")//这里"cd"会被隐式类型转换为string
    {
        if(gargc == 2)
        {
            std::string target = gargv[1];
            if(target == "~")
            {
                //"cd ~"返回家目录
                chdir(GetHomePath().c_str());   
                lastcode = 0;
                ret = true;
            }
            else{
                chdir(gargv[1]);
                lastcode = 0;
                ret = true;
            }
        }
        else if(gargc == 1)
        {
            chdir(GetHomePath().c_str());
            lastcode = 0;
            ret = true;
        }
        else{
            //错误
        }
    }
    else if(cmd == "echo")
    {
        if(gargc == 2)
        {
            std::string args = gargv[1];
            if(args[0] == '$')
            {
                if(args[1] == '?')
                {
                    printf("%d\n", lastcode);
                    lastcode = 0;
                    ret = true; 
                }
                else{
                    const char* name = &args[1];           
                    printf("%s\n", getenv(name));
                    lastcode = 0;
                    ret = true;
                }
            }
            else{
                printf("%s\n", gargv[1]);
                ret = true;
            }
        }
    }

    return ret;
}

myshell.h:

cpp 复制代码
#ifndef __MYSHELL_H__
#define __MYSHELL_H__

#include <stdio.h>
#include <iostream>
#include <string>
#include <cstdlib>
#include <cstring>
#include <cstdbool>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <ctype.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

//命令行参数表的大小
#define ARGS 64

void Debug();
//初始化全局变量
void InitGlobal();
//输出命令行字符串
void PrintCommandPrompt();
//读取用户输入字符串
bool GetCommandString(char cmdstr_buff[], int len); 
//重定向的检测与设置
void CheckRedir(char cmd[]);
//解析命令行字符串
bool ParseCommandString(char cmd[]);
//执行命令
void ForkAndExec();
//检查是否是内建命令,若为内建命令交由父进程运行
 bool BuildInCommandExec();

#endif

以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~

相关推荐
liujing102329297 小时前
stm32大项目阶段20251015
linux
嵌入式郑工8 小时前
LINUX驱动开发: 设备和驱动是怎么匹配的?
linux·运维·服务器
郭式云源生法则9 小时前
归档及压缩、重定向与管道操作和综合使用,find精确查找、find处理查找结果、vim高级使用、vimdiff多文件使用
linux·运维·服务器
一张假钞9 小时前
Ubuntu 24.04 安装 Jenkins
linux·ci/cd·jenkins
getExpectObject()9 小时前
【jenkins】构建安卓
运维·jenkins
tuokuac10 小时前
查看你电脑上某个端口正在被哪个进程占用
linux
小池先生10 小时前
服务请求出现偶发超时问题,经查服务本身没问题,问题出现在nginx转发。
运维·服务器·nginx
java_logo10 小时前
vllm-openai Docker 部署手册
运维·人工智能·docker·ai·容器
MANONGMN10 小时前
Linux 通配符与正则表达式(含实战案例+避坑指南)
linux·运维·正则表达式