【Linux】第二十二站:文件(二)深入理解重定向

一、重定向

1.文件描述符对应的分配规则

我们先看如下代码

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>

#define filename "log.txt"

int main()
{
    int fd = open(filename,O_CREAT|O_WRONLY|O_TRUNC,0666);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd : %d\n",fd);
    const char* msg = "hello linux\n";
    int cnt = 5;
    while(cnt)
    {
        write(fd,msg,strlen(msg));
        cnt--;
    }

    close(fd);
    return 0;
}

运行结果为,一切都符合我们的预期

image-20231127162842345

紧接着,我们将代码改为如下

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>

#define filename "log.txt"

int main()
{
    close(0);
    int fd = open(filename,O_CREAT|O_WRONLY|O_TRUNC,0666);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd : %d\n",fd);
    const char* msg = "hello linux\n";
    int cnt = 5;
    while(cnt)
    {
        write(fd,msg,strlen(msg));
        cnt--;
    }

    close(fd);
    return 0;
}

运行结果为

image-20231127165617384

如果我们将代码改为如下,即关闭一号文件

image-20231127165926548

那么运行结果为,将没有任何东西可以打印出来

image-20231127170010669

这是因为1号文件对应的是stout输出流,而printf里面是用到了这个流的。当我们关闭了以后,自然就出现问题了

如果我们关闭的是二号文件

image-20231127172002501

那么结果为

image-20231127172026414

我们可以发现如下现象

当我们关0的时候,为这个新文件分配的文件描述符是0

当我们关1的时候,为这个新文件分配的文件描述符是1

当我们关2的时候,为这个新文件分配的文件描述符是2

这就说明,文件描述符的分配规则很简单,从0下标开始,寻找最小的没有使用的数组位置,它的下标就是新文件的文件描述符

我们来看下面的代码

image-20231127172615148

最终运行结果为

image-20231127172650731

这是因为我们的东西并没有写到这个文件中,而是写入到了显示器文件中。所以才会打印出来。

我们再来看以下代码

image-20231127172836589

运行结果为

image-20231127172917076

这是因为,我们关闭了一号文件,而由于我们又打开了一个新文件,那么最终这个新文件的文件描述符变为了1。所以最终变为了向该文件写入

而这里,我们会发现,本来应该写入到显示器上的内容写入到了文件中,这不就是输出重定向吗?

如下图所示,是我们一开始的状态

image-20231127173921374

后来我们关闭了1号文件,然后打开了一个新的文件。就会将原来的引用计数减减,然后将该指针置空。随后我们创建新文件的时候,会让1号下标的位置指向log.txt这个文件中

image-20231127174302710

而在我们前面的代码中,上层并不知道我们已经将1号文件给改掉了。它只知道要向一号文件写,所以最终变为了向log.txt文件中去写

而上面所说的就是重定向的原理。

所以重定向的本质就是对文件描述符表里面的数组的内容进行修改

2.重定向的接口

我们会发现上面的方法其实有点麻烦,因为我们还需要关闭文件之后,才去打开一个新的文件。

所以操作系统本身就提供了系统调用

image-20231127175304336

int dup2(int oldfd, int newfd);

它的作用是直接将新的文件描述符表数组中的oldfd下标的内容直接拷贝到newfd处。即newfd是要被oldfd所覆盖的

如下图所示,fd代表的是oldfd,1代表的是newfd。

fd的内容最终被拷贝到1号的内容当中。最终保留的就是fd的内容

image-20231127181112181

所以我们就可以写出这个代码了

image-20231127181702696

运行结果为

image-20231127181723935

这样就同样实现了重定向的效果

如果我们将打开的方式换为了O_APPEND

image-20231127181908347

image-20231127181948257

我们会发现这个其实就是追加重定向

我们再来看下面的这段代码

注意这个接口的意思是:从fd中读取count字节个数据到buf中,count是期望读取的数量,返回值是实际读取的数量。

注意它的读取之后,最终不会加上’\0’字符,而fread是C语言的接口,它会自己加上的。所以我们最终需要自己加上这个’\0’字符。

ssize_t read(int fd, void *buf, size_t count);

代码为

image-20231127183759201

运行结果为

image-20231127183823732

然后我们让log.txt的内容如下

image-20231127183946696

代码如下

image-20231127184109563

运行结果为

image-20231127184155076

我们会发现直接读取了,因为我们当前的文件内本身就有内容,所以就默认从文件中读取了

我们会发现它就相当于输入重定向

image-20231127184425755

所以重定向的本质就是对进程的指定文件描述符表中内容拷贝的问题

如果我们的代码是这样子的

image-20231127195455195

运行结果为,符合我们的预期

image-20231127195516197

如果我们将代码改为这样的

image-20231127195817659

那么结果为

image-20231127195804453

他是符合我们的预期的

所以C语言的printf,和fprintf都是往1号文件里写的,不过我们已经提前改了一号文件了。所以就会显示如上的结果

如果我们改为O_APPEND

image-20231127200057332

那么结果也是一样的,符合我们的预期

image-20231127200123154

二、再次实现myshell

1.实现细节

我们知道,像我们平时在命令行中写的重定向是这样的

image-20231127200448554

那么它与我们前面所演示的重定向有什么关系呢?

我们知道,我们前面的代码中,myshell并没有实现重定向功能

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>


#define LEFT "["
#define RIGHT "]"
#define LABLE "#"
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 44

char commandline[LINE_SIZE];
char* argv[ARGC_SIZE] = {NULL};
extern char** environ;
int last_code = 0;
int quit = 0;
char pwd[LINE_SIZE];
char myenv[LINE_SIZE];



const char* getusername()
{
    return getenv("USER");
}
const char* gethostname()
{
    return getenv("HOSTNAME");
}
void getpwd()
{
    getcwd(pwd,sizeof(pwd));
}

void Interact(char* cline,int size)
{
    getpwd();
    printf(LEFT"%s@%s %s"RIGHT""LABLE" ",getusername(),gethostname(),pwd);
    char* s = fgets(cline,size,stdin);
    (void)s;
    assert(s);
    commandline[strlen(cline) - 1] = '\0';
}
int splitstring(char cline[],char* _argv[])
{
    if(strcmp(cline,"") == 0) return 0;
    int i = 0;
    _argv[i++] = strtok(cline,DELIM);
    while(_argv[i++] = strtok(NULL,DELIM));
    return i - 1;
}

void NormalExcute(char* _argv[])
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return;
    }
    else if (id == 0)
    {
        //子进程执行命令
        execvpe(_argv[0],_argv,environ);
        exit(EXIT_CODE);
    }
    else
    {
        int status = 0;
        pid_t rid = waitpid(id,&status,0);
        if(rid == id)
        {
           last_code = WEXITSTATUS(status);
        }
    }
}

int BuildCommand(char* _argv[],int _argc)
{
    if(_argc == 2 && strcmp(_argv[0],"cd") == 0)
    {
        chdir(_argv[1]);
        getpwd();
        sprintf(getenv("PWD"),"%s",pwd);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"export") == 0)
    {
        strcpy(myenv,_argv[1]);
        putenv(myenv);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"echo") == 0)
    {
        if(strcmp(_argv[1],"$?") == 0)
        {
            printf("%d\n",last_code);
            last_code = 0;
        }
        else if(*_argv[1] == '$')
        {
            char* val = getenv(_argv[1] + 1);
            if(val) printf("%s\n",val);
        }
        else 
        {
            printf("%s\n",_argv[1]);
        }
        return 1;
    }


    if(_argc > 0 && strcmp(_argv[0],"ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    return 0;
}
int main()
{
    while(!quit)
    {
        //2.交互问题,解决命令行
        Interact(commandline,sizeof(commandline));
        //3.子串分割问题,解析命令行
        int argc = splitstring(commandline,argv);
        if(argc == 0) continue;
        //4.指令的判断(内建命令和普通命令)
        int n = BuildCommand(argv,argc);
        //5.普通命令的执行
        if(!n) NormalExcute(argv);
    }
    return 0;
}

那么我们现在可以为他添加上重定向功能,要解决重定向,我们可以交互函数函数中进行处理一下字符串,当该指令进行执行的时候,处理即可。

如下代码所示

#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/stat.h>
#include<fcntl.h>

#define LEFT "["
#define RIGHT "]"
#define LABLE "#"
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 44
#define NONE       -1
#define IN_RDIR     0
#define OUT_RDIR    1
#define APPEND_RDIR 2

char commandline[LINE_SIZE];
char* argv[ARGC_SIZE] = {NULL};
extern char** environ;
int last_code = 0;
int quit = 0;
char pwd[LINE_SIZE];
char myenv[LINE_SIZE];
char* rdirfilename = NULL;
int rdir = NONE; 

const char* getusername()
{
    return getenv("USER");
}
const char* Gethostname()
{
    return getenv("HOSTNAME");
}
void getpwd()
{
    getcwd(pwd,sizeof(pwd));
}
void check_rdir(char* cmd)
{
    char* pos = cmd;
    while(*pos!='\0')
    {
        if(*pos == '>')
        {
            if(*(pos + 1) == '>')
            {
                *pos++ = '\0';
                *pos++ = '\0';
                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';  //ls -a -l < file.txt
            pos++;
            while(isspace(*pos)) pos++;
            rdirfilename = pos;
            rdir = IN_RDIR;
            break;
        }
        else 
        {
           // do nothing
        }
        pos++;
    }
}
void Interact(char* cline,int size)
{
    getpwd();
    printf(LEFT"%s@%s %s"RIGHT""LABLE" ",getusername(),Gethostname(),pwd);
    char* s = fgets(cline,size,stdin);
    (void)s;
    assert(s);
    commandline[strlen(cline) - 1] = '\0';
    check_rdir(cline);
}
int splitstring(char cline[],char* _argv[])
{
    if(strcmp(cline,"") == 0) return 0;
    int i = 0;
    _argv[i++] = strtok(cline,DELIM);
    while(_argv[i++] = strtok(NULL,DELIM));
    return i - 1;
}

void NormalExcute(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);
        }
        else if(rdir == OUT_RDIR)
        {
            fd = open(rdirfilename,O_WRONLY|O_CREAT|O_TRUNC,0666);
            dup2(fd,1);
        }
        else if(rdir == APPEND_RDIR) 
        {
            fd = open(rdirfilename,O_WRONLY|O_CREAT|O_APPEND,0666);
            dup2(fd,1);
        }
        //子进程执行命令
        execvpe(_argv[0],_argv,environ);
        exit(EXIT_CODE);
    }
    else
    {
        int status = 0;
        pid_t rid = waitpid(id,&status,0);
        if(rid == id)
        {
           last_code = WEXITSTATUS(status);
        }
    }
}

int BuildCommand(char* _argv[],int _argc)
{
    if(_argc == 2 && strcmp(_argv[0],"cd") == 0)
    {
        chdir(_argv[1]);
        getpwd();
        sprintf(getenv("PWD"),"%s",pwd);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"export") == 0)
    {
        strcpy(myenv,_argv[1]);
        putenv(myenv);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"echo") == 0)
    {
        if(strcmp(_argv[1],"$?") == 0)
        {
            printf("%d\n",last_code);
            last_code = 0;
        }
        else if(*_argv[1] == '$')
        {
            char* val = getenv(_argv[1] + 1);
            if(val) printf("%s\n",val);
        }
        else 
        {
            printf("%s\n",_argv[1]);
        }
        return 1;
    }


    if(_argc > 0 && strcmp(_argv[0],"ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    return 0;
}
int main()
{
    while(!quit)
    {
        rdirfilename = NULL;
        rdir = NONE;
        //2.交互问题,解决命令行
        Interact(commandline,sizeof(commandline));
        //3.子串分割问题,解析命令行
        int argc = splitstring(commandline,argv);
        if(argc == 0) continue;

        //4.指令的判断(内建命令和普通命令)
        int n = BuildCommand(argv,argc);
        //5.普通命令的执行
        if(!n) NormalExcute(argv);
    }
    return 0;
}

在上面的代码中,当我们输入完指令字符串以后,然后去寻找是否存在重定向的符号,如果有,改变当前状态为输入/输出/追加重定向,然后将重定向的文件名给记录下来。最终达到分开指令与文件的目的。

分开以后,当我们进行指令的执行的时候,我们暂时只考虑子进程,如果是存在重定向的话,那么就打开对应的文件,然后将该文件的文件描述符放到对应的输入或输出位置上。就可以了。

最终的结果为如下

image-20231128164958787

image-20231128165006593

2.盘点文件与进程替换的一个细节

我们在前面的代码中

在后面我们做了重定向的工作,后面我们在进行程序替换的时候,难道不会影响吗???

在我们之前,我们已经了解了如下的东西

当一个可执行程序加载到内存的时候,会创建出对应的PCB结构体,在tash_struct这个结构体里面,有一个指针,会指向进程地址空间,然后进程地址空间根据页表找到实际的物理内存。

然后CPU就会去找到这个进程,从而进行去调度

image-20231128170345068

而现在,我们知道当我们打开一个文件的时候,会创建出它的结构体struct file

image-20231128172612430

随后为了管理起来,task_struct中有一个指针,指向文件描述符表,在这个表中的下标对应着每一个文件。

image-20231128173022187

其中左侧的这一堆,我们都把他叫做,内核数据结构

image-20231128173215796

当我们再度创建一个文件的时候

image-20231128174328772

而我们上面进程替换替换是右边的部分,并不会对左边的部分有影响

image-20231128174423713

这里就是内存管理与文件管理的解耦

进程历史打开的文件与进行的各种重定向关系都和未来进行程序替换无关

程序替换,并不影响文件访问

3.代码

#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/stat.h>
#include<fcntl.h>

#define LEFT "["
#define RIGHT "]"
#define LABLE "#"
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 44
#define NONE       -1
#define IN_RDIR     0
#define OUT_RDIR    1
#define APPEND_RDIR 2

char commandline[LINE_SIZE];
char* argv[ARGC_SIZE] = {NULL};
extern char** environ;
int last_code = 0;
int quit = 0;
char pwd[LINE_SIZE];
char myenv[LINE_SIZE];
char* rdirfilename = NULL;
int rdir = NONE; 

const char* getusername()
{
    return getenv("USER");
}
const char* Gethostname()
{
    return getenv("HOSTNAME");
}
void getpwd()
{
    getcwd(pwd,sizeof(pwd));
}
void check_rdir(char* cmd)
{
    char* pos = cmd;
    while(*pos!='\0')
    {
        if(*pos == '>')
        {
            if(*(pos + 1) == '>')
            {
                *pos++ = '\0';
                *pos++ = '\0';
                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';  //ls -a -l < file.txt
            pos++;
            while(isspace(*pos)) pos++;
            rdirfilename = pos;
            rdir = IN_RDIR;
            break;
        }
        else 
        {
           // do nothing
        }
        pos++;
    }
}
void Interact(char* cline,int size)
{
    getpwd();
    printf(LEFT"%s@%s %s"RIGHT""LABLE" ",getusername(),Gethostname(),pwd);
    char* s = fgets(cline,size,stdin);
    (void)s;
    assert(s);
    commandline[strlen(cline) - 1] = '\0';
    check_rdir(cline);
}
int splitstring(char cline[],char* _argv[])
{
    if(strcmp(cline,"") == 0) return 0;
    int i = 0;
    _argv[i++] = strtok(cline,DELIM);
    while(_argv[i++] = strtok(NULL,DELIM));
    return i - 1;
}

void NormalExcute(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);
        }
        else if(rdir == OUT_RDIR)
        {
            fd = open(rdirfilename,O_WRONLY|O_CREAT|O_TRUNC,0666);
            dup2(fd,1);
        }
        else if(rdir == APPEND_RDIR) 
        {
            fd = open(rdirfilename,O_WRONLY|O_CREAT|O_APPEND,0666);
            dup2(fd,1);
        }
        //子进程执行命令
        execvpe(_argv[0],_argv,environ);
        exit(EXIT_CODE);
    }
    else
    {
        int status = 0;
        pid_t rid = waitpid(id,&status,0);
        if(rid == id)
        {
           last_code = WEXITSTATUS(status);
        }
    }
}

int BuildCommand(char* _argv[],int _argc)
{
    if(_argc == 2 && strcmp(_argv[0],"cd") == 0)
    {
        chdir(_argv[1]);
        getpwd();
        sprintf(getenv("PWD"),"%s",pwd);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"export") == 0)
    {
        strcpy(myenv,_argv[1]);
        putenv(myenv);
        return 1;
    }
    else if(_argc == 2 && strcmp(_argv[0],"echo") == 0)
    {
        if(strcmp(_argv[1],"$?") == 0)
        {
            printf("%d\n",last_code);
            last_code = 0;
        }
        else if(*_argv[1] == '$')
        {
            char* val = getenv(_argv[1] + 1);
            if(val) printf("%s\n",val);
        }
        else 
        {
            printf("%s\n",_argv[1]);
        }
        return 1;
    }


    if(_argc > 0 && strcmp(_argv[0],"ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    return 0;
}
int main()
{
    while(!quit)
    {
        rdirfilename = NULL;
        rdir = NONE;
        //2.交互问题,解决命令行
        Interact(commandline,sizeof(commandline));
        //3.子串分割问题,解析命令行
        int argc = splitstring(commandline,argv);
        if(argc == 0) continue;

        //4.指令的判断(内建命令和普通命令)
        int n = BuildCommand(argv,argc);
        //5.普通命令的执行
        if(!n) NormalExcute(argv);
    }
    return 0;
}

三、1号文件和2号文件的区别

当我们使用如下代码的时候

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>

#define filename "log.txt"

int main()
{
    fprintf(stdout,"hello normal message\n");
    fprintf(stdout,"hello normal message\n");
    fprintf(stdout,"hello normal message\n");
    fprintf(stdout,"hello normal message\n");
    fprintf(stdout,"hello normal message\n");
 
    fprintf(stderr,"hello error message\n");
    fprintf(stderr,"hello error message\n");
    fprintf(stderr,"hello error message\n");
    fprintf(stderr,"hello error message\n");
    fprintf(stderr,"hello error message\n");
    return 0;
}


运行结果为,我们可以看到,这两个文件都是往显示器上打印的,似乎没有什么区别

image-20231128175412685

但是如果我们这样做

image-20231128175558749

我们会发现一部分重定向了,一部分没有重定向

在一开始的时候是这样的

image-20231128180433678

随后发生了重定向,此时这个重定向仅仅是对于1号的文件的重定向

image-20231128180524995

这样的话,凡是原来往1里面写的,就会写进这个normal.log文件了

往2里面写的就往显示屏上打印了

如果我们在命令行中的是这样的

image-20231128180848142

其实上面是一个简写

下面是完整的,代表1重定向到normal.log,2重定向到err.log

./mytest 1>normal.log 2>err.log

这样的话就可以分开了

如果我们就想要重定向到一个文件中,那么可以这样做

./mytest 1>all.log 2>&1

image-20231128181522122

同理,前面的这个1也是可以省略的

image-20231128181631739

这个命令中

2>&1的意思是把1号文件描述符里面的内容写到2号文件描述符中。

这个的前提是已经把前半部分指令的操作做完了。而1已经指向这个这个all了。

所以最终1和2都指向这个文件了。所以就全部写入到一个文件中了

四、如何理解“一切皆文件?”

我们知道系统中有很多设备,如下图所示

这些外设,几乎都要去提供他们的读写方法,只不过对于键盘而言,写方法为空,对于显示器而言,读方法为空而已。但是他们都有读写方法

image-20231128222532291

他们都可以用同一种结构体来表示。然后用类似的读写接口

image-20231128223058653

对于这些硬件,我们都可以以文件的方式用open打开。然后创建对应的struct file内核数据结构。

image-20231128223741964

然后由于我们要对这些设备进行读写

所以linux内核提供了另外一个方法表的数据结构

struct operation_func()
{
	 int (*wirtep)();
	 int (*readp)();
}

当我们想要访问某个设备的时候。会创建这个方法表的数据结构,然后提供一个指针指向这个方法表,方法表中的函数指针就指向对应的接口

image-20231128224429863

如此一来,task_struct就会通过文件描述符表中的数组从而去调用对应的方法数据结构,最终达到调用read函数

这样就可以实现上层都是调用一样的函数了,而根据下层的不同动态的调用不同的方法了

image-20231128225646625

所以所谓的一切皆文件,就是相当于在文件这一层封装了一个文件对象,让文件对象中的指针指向不同设备的函数方法,然后通过上层的一个方法数据结构,来对这些进行汇总。使得我们压根就不需要关心下层是如何的。只需要知道,以后要读取这个文件就调用这个方法即可

image-20231128230206588

所以从文件对象这一层,往上就有了一切皆文件,这一层我们也叫做,VFS(虚拟文件系统)

image-20231128230332248

而我们似乎就可以发现,这不就是C++中的多态吗?

上层都是基类,下层就是派生类。

所以面向对象就是历史的必然!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

青色_忘川

你的鼓励是我创作的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值