2020 6.s081——Lab1:Xv6 and Unix utilities梦开始的地方

一任宫长骁瘦

台高冰泪难流

锦书送罢蓦回首

无余岁可偷

——知否知否

完整代码见:6.s081/kernel at util · SnowLegend-star/6.s081 (github.com)

Lecture 01知识点总结

首先透彻理解Lecture01的知识很是重要

pid=wait((int *) 0);

“wait(int *status)”函数用于等待子进程的终止,它的参数是一个指向整数的指针(通常是 int * 类型),并且通常用来存储子进程的终止状态。这里“0”意思是将指针赋值为NULL,表示不关心子进程的退出状态信息,这意味着不打算获取子进程的终止状态。

如果传递一个非NULL的指针给wait(int *status)函数,它将用来存储子进程的退出状态信息,以便可以检查子进程的退出状态,例如子进程是正常终止还是出错等。在这种情况下,应该确保指针指向一个有效的内存位置,以便存储子进程的状态信息。而父进程可以利用status这个指针来获取子进程的退出状态。

exec(char *file, char *argv[])详解

当调用exec(char *file, char *argv[])函数时,xv6 将会卸载当前进程的代码和数据,并加载并执行指定路径的新程序。新程序会接管当前进程的上下文,并开始执行。这意味着 exec 调用后,当前进程的地址空间、堆栈、文件描述符等状态都会被新程序取代。

这对于实现进程的动态加载和替换,以及执行不同的程序非常重要。例如,当您在命令行中运行一个可执行程序时,实际上是通过 exec 系统调用来执行它,从而替换了当前的 shell 进程。

文件描述符

文件描述符fd其实就是代表了open操作对应的那个文件。例如

fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);

int write(int fd, char *buf, int n)

这里的fd就是指“example.txt”这文件;此外,write函数里面的fd不能替换为函数名。因为 write 函数需要一个文件描述符作为其第一个参数,而不是文件名称。文件描述符是一个整数,用于标识已打开文件或其他 I/O 资源。

每个进程都维护一个独立的文件描述符表,其中包含了该进程打开的所有文件和I/O资源的引用。

 

 

运行可执行文件的问题

对于一般的可执行文件a,要运行它的命名形式为“./a”,而不是直接在命令行输入“a”来运行。只有当一个可执行文件位于系统的 PATH 路径中时,可以直接输入其文件名来运行它,而不需要指定完整的文件路径。系统会在 PATH 中的各个目录中查找这个可执行文件,如果找到了匹配的文件,就会执行它。

管道

return 0与exit(0)

int main()函数其实不用return 0,如果main函数没有显式的return语句,编译器将会隐式地在函数末尾插入一个return 0语句,表示程序正常退出并返回0。但是,在xv6只用return 0则会出问题。

在 xv6 中,要正常终止一个进程,应该使用 exit(0) 系统调用而不是在 main 函数中使用 return 0。这是因为 xv6 通过系统调用来通知内核进程的结束,同时执行一些清理工作,以确保资源的正确释放。exit(0) 系统调用会触发这个行为,而简单的 return 0 并不会。

在你提到的错误信息中,"usertrap(): unexpected scause 0x000000000000000d" 是一个异常信息,表明出现了意外的异常类型。当你在 main 函数中使用 return 0 时,进程没有经过适当的清理,导致 xv6 报告了这个异常。

总结起来,为了正常终止 xv6 进程并避免异常错误,应该使用 exit(0) 而不是 return 0。这确保了进程的正确退出并执行必要的清理工作。

Boot xv6 (easy)

我奶奶都能过的lab。注意一点就是得在“xv6-labs-2020”这个文件夹底下运行“make qemu”,而不是在“xv6-labs-2020/user”这个文件夹底下运行它。

sleep

真正意义上梦开始的地方

自己编写的文件得放在“user”文件夹里面,然后在 “Makefile”的“UPROGS”里面添加这个文件名,最后打开qemu进行运行。当然,我们发现不进入qemu也能运行“sleep”这条命令,但是这里的“sleep”命令和我们自己编写的“sleep”函数有出入。那是因为“sleep”是系统的内置函数,只有打开qemu才能运行我们自己编写的“sleep”函数。

今天在linux环境下想直接测试讲义上的“fork”函数,结果总是报错。问了GPT才发现原来书上给的一些函数调用是只有xv6才具有的,在普通的linux系统上并不具备这些函数调用。

sleep()实现如下


#include "kernel/types.h"
#include "user/user.h"

int main(int argc, char const *argv[]){
  if(argc!=2){
    printf("Error!The function should obtain two argument.\n");
    exit(1);
  }
  sleep(atoi(argv[1]));
  exit(0);
}

pingpong

这个实验真正意义上把我搞麻了,初次pipe简直就是恶心至极。开始我一直有一个困扰,就是在父进程进行了pipe(p)之后进行fork(),此时父进程和子进程就都可以访问这个管道p了。但是我看到好多题解里面明明在一个进程里面关闭了管道的一端比如“close(p[0])”,但是又在另一个进程里面用到了“read(po[0],buf,sizeof(buf))”。我的想法是既然子进程和父进程都可以对管道进行有效操作,那上述的两个操作不就相悖了吗?这个疑惑困扰了我两天,那个时候四处寻找合适的解释结果都不能很好地解答我的疑惑。然后我又在讲义里看到了另一句话“在一个程序中创建子进程后,子进程将会继承与父进程相同的文件描述符表”,这句话又让我丈二和尚摸不着头脑。后来去群里一问才得知这句话的本质和建立软连接差不多。

       就拿在父进程里面创建的管道来说,子进程自己复制了一份管道的引用。然后close()操作只是作用于这份引用,也就是说子进程自己不能再使用某个端口的引用了,但是这并不影响父进程对两个端口的操作。领悟到这一层后,我又去测试了一番,果真是这样。这个困扰我几天的问题一解决,那pingpong的实现就变得有头绪了。

       对于实现过程,我最开始想不知道怎么保证父子进程之间的同步关系,在两个进程里面都是先write()然后接着read()。由于进程执行的异步性必然会导致两个进程的输出交杂在一起,就像这样“43:r:erceecieviev epo pnigng”。

后来想到的可能的解决办法是用wait()来保证子进程和父进程之间的同步关系。但是这个技巧在这题行不通,因为实验要求我们先打印子进程收到了数据,这就要求父进程自己得先写入,那把wait()放在父进程部分的开头就会导致类似死锁一样的结果。参考率其他人的结果,他们都是通过控制子进程的write()部分在read()部分后面来保证输出同步。用wait()一样可以实现,把wait()放在父进程的wirte()后但是read()之前即可解决。

pingpong()实现如下
 


#include"kernel/types.h"
#include"user/user.h"
#include "kernel/stat.h"

int main(){
    int pipe_ptc[2];    //父进程用来给子进程写入信息的管道
    int pipe_ctp[2];    //子进程用来给父进程写入东西的管道
    char* ptc_msg="ping",*ctp_msg="pong",ptc[256],ctp[256];
    if(pipe(pipe_ctp)==-1){
        printf("There is something wrong with pipe()!");
        exit(1);
    }

    if(pipe(pipe_ptc)==-1){
        printf("There is something wrong with pipe()!");
        exit(1);
    }

    if(fork()!=0){//子进程的fork()返回0

        write(pipe_ptc[1],ptc_msg,sizeof(ptc_msg));
        close(pipe_ptc[1]);                                 //关闭父进程管道的写入端
        int parent_pid=getpid();
        wait((int *)0);
        if(read(pipe_ctp[0],ctp,256)!=-1){
            printf("%d: received pong\n",parent_pid);
        }
        close(pipe_ctp[0]);                                 //关闭子进程管道的读入端
        exit(0);
    }
    else{
        int child_pid=getpid();
        
        write(pipe_ctp[1],ctp_msg,sizeof(ctp_msg));
        close(pipe_ctp[1]);                                 //关闭子进程管道的写入端
        
        if(read(pipe_ptc[0],ptc,256)!=-1){
            printf("%d: received ping\n",child_pid);
        }
        close(pipe_ptc[0]);                                 //关闭父进程的读入端

        exit(0);                                            //很重要,要不然子进程不会退出
    }
    exit(0);
}

primes

这题主要就是理解文档给的那个“线程筛”图——即让父进程按从小到大的顺序把所有的数都传给子进程,然后子进程排除掉不能被第一个素数2整除的传递给孙进程,孙进程再排除掉不能被第二个素数3整除的传递给它的子进程,以此类推……很显然,每个进程传给它的子进程的那批数字里面第一个是最小的且是素数,递归得到的第i个进程可以得到第i-1个素数。

接下来详细描述下处理过程:

假设最初始的进程p0 对那些数不做任何处理,直接从小到大一股脑传给第一代子进程p1 。接下的一代代子孙进程对数据的处理就具有一般性了。首先,子进程pi-1 接收从它的父进程pi

传过来的数据,且pi 传过来的第一个数n1 为我们得到的第i-1个素数。pi-1 接收完数据后把那些不能被n1 整除的数据传递给它的子进程pi-2 ,而后进程pi-2

进行类似的处理。显然,我们如果要完成上述进程的两个通信过程,就势必得用到两个管道。同时,由于xv6的文件描述符有限,我们就要及时关闭用不到的管道端(即文件描述符)。这里提一句,务必养成及时关闭文件描述符的好习惯,不然最后总是会有各种输出卡住的问题。

还有些实现的细节需要注意。我遇到的问题是一开始习惯性地创建了个读缓冲区的数组buf[40],然后读取管道中的数据就是read(0,buf,sizeof(buf))。乍一看没问题,但是考虑到如果管道里面的数据没有40个,那这个进程就会被阻塞在这里,等着管道的写入端继续写入数据直到把40个数据读满。所以这题应当是一个整形一个整形地读入数据。

 

 primes()实现如下
 

#include"kernel/types.h"
#include"user/user.h"
#include "kernel/stat.h"

void child(int pipe_p2c[]){
    // int buf[40];                           //buf[0]里面的元素应该是最小的
    int elem,min;                          
    int pipe_s2g[2];                       //子进程给孙进程通信的管道
    close(pipe_p2c[1]);
    // for(i=0;i<40;i++)
    //     buf[i]=0;
    // if(read(pipe_p2c[0],buf,40)==0){   //如果没东西可以读了就可以开始退出了       不能一下子读入sizeof(buf),因为管道里面的元素没有这么多
    if(read(pipe_p2c[0],&min,sizeof(int))==0){
        close(pipe_p2c[0]);
        exit(0);
    }
    printf("prime %d\n",min);
    pipe(pipe_s2g);
    if(fork()!=0){//子进程准备给孙子进程写东西了
        close(pipe_s2g[0]);                        //把管道的读入端关掉再说
        // for(i=1;buf[i]!=0;i++){
        //     if(buf[i]%buf[0]!=0)                //不能相除的才传给下一辈
        //         write(pipe_s2g[1],&buf[i],1);   //一个字节一个字节地写入管道
        // }
        while(read(pipe_p2c[0],&elem,sizeof(int))!=0){
            if(elem%min!=0)
                write(pipe_s2g[1],&elem,sizeof(int));
        }
        close(pipe_p2c[0]);
        close(pipe_s2g[1]);
        wait(0);
        exit(0);
    }
    else{//孙子进程
        child(pipe_s2g);
    }
}

int main(){
    int i;
    int pipe_p2c[2];
    pipe(pipe_p2c);
    if(fork()!=0){
        close(pipe_p2c[0]);
        for(i=2;i<=35;i++){//把这些数字依次写入管道里面准备让子进程读
            write(pipe_p2c[1],&i,sizeof(int));
        }
        close(pipe_p2c[1]);//及时关闭文件描述符,不然输出会在这里卡住
        wait(0);
        exit(0);        
    }
    else{
        child(pipe_p2c);
    }
    exit(0);
}

find

这题主要是对题目的要求思考了很久,一开始没理解find命令在类unix机器上是怎么用的。查阅了下资料,发现这里的find命令大概格式如下“find path filename”,这里的path既可以是一个目录如“./a/b”,也可以是一个具体的文件路径“./a/c.txt”。

然后第二个困扰了我很久的点是题目的hint3:Don't recurse into "." and ".."我心想平时在windows系统或者是linux系统里面查看文件夹的内容时从来就没有看到过“.”和“..”这两个特殊的文件啊?后来问GPT说是用ls打开文件目录就可以看到这两个特殊的目录项,尝试了一番依然没有发现。直到后面进入qemu的时候再调用ls发现上来就把这两个特殊的目录项给列出来了。对于命令中要含有“.”和“..”我也不理解是个怎样的形式,问了GPT半天才发现形式可以如下“ls -l /path/to/some/directory/./../another/directory”。解决了这两个疑惑,就可以着手完成find了。

根据hint1先看一遍lc.c很容易搞得自己一头雾水,我看了两遍之后还有些不得要领,遂直接开始照着lc.c来实现find的功能,边写边理解。其实两个最后先出来大同小异,就是find传过去的参数不仅有“path”,还有“filename”。在lc.c原有的基础上适当加上对“filename”的匹配处理即可。

从ls.c中我们可以看到先是用open()打开传过去的“path”,看用户给出的“path”是否能够被访问。然后用fstat()把这个文件的信息存入stat结构体中。处理完这个“path”后,如果path是个目录,就开始对该目录底下的目录项进行遍历,目录项有文件和目录两种类型。对于目录项是目录的情况,我们又可以把这个子目录信息存入dirent这个结构体中进行递归遍历。

在完成实验要求的find功能之后,我心血来潮统计了下qemu当前存在的目录项。一个有趣的结果是“.”和“..”并没有被计入在目录项里面。这我就有一个猜测了,“.”和“..”本质就是软连接。又发现了个很奇怪的问题,把“dir_ItemNum”放在if(de.inum)前面就会导致输出结果总是为64,而且就算在xv6内部继续添加文件也还是如此,这是为什么呢?但是如果再在当前目录创建./a/b这个子目录,统计结果又正常了。这时在使用命令“find . a”会发现dir_ItemNum的值增加了3。难不成xv6的当前目录内置了文件上限是64且都已经被创建好了,只不过没有内容导致这些目录项的inum是0,但是遍历当前文件夹的时候还是会遍历之?

find()如下
 

#include"kernel/types.h"
#include"kernel/stat.h"
#include"user/user.h"
#include"kernel/fs.h"

char *fmtname(char *path){
    static char buf[DIRSIZ+1];
    char *p;

    //find first character after last slash
    for(p=path+strlen(path);p>path&&*p!='/';p--);

    p++;

    //return blank-padded name
    if(strlen(p)>=DIRSIZ)
        return p;
    memmove(buf,p,strlen(p));
    memset(buf+strlen(p),' ',DIRSIZ-strlen(p));
    return buf;
}

int flag=0;
int dir_ItemNum=0;    //看一下当前目录底下有多少条信息 “.”和“..”没被统计进去

void find(char *directory,char *filename){
    char buf[512],*p;       //p作为定位指针
    int fd;
    struct dirent de;
    struct stat st;
    if((fd=open(directory,0))<0){
        printf("find: cannot open %s\n",directory);
        exit(1);
    }

    if(fstat(fd,&st)<0){
        printf("find: cannot stat %s\n",directory);
        close(fd);
        exit(1);
    }

    struct stat stat_temp;  //这句话不能定义在case内部吗?    

    switch(st.type){
        case T_DEVICE:
        case T_FILE:
            if(strcmp(fmtname(directory),filename)==0){
                printf("%s\n",directory);
                flag=1;
            }
            exit(0);

        case T_DIR:
            if(strlen(directory)+1+DIRSIZ+1>sizeof(buf)){
                printf("find: path too long\n");
                break;
            }

            strcpy(buf,directory);              //buf用来存当前正在访问目录的路径
            p=buf+strlen(buf);
            *p++='/';                           //把p挪到buf的最后一个元素上
            while(read(fd,&de,sizeof(de))==sizeof(de)){
                // dir_ItemNum++;  //怎么把这句放在if(d.inum)前面只能输出64呢?                
                if(de.inum==0)                  //如果 inum 字段等于 0,通常表示该目录项无效或未使用
                    continue ;

                memmove(p,de.name,DIRSIZ);      //把文件名都存在buf刚添上的“/”后面
                p[DIRSIZ]=0;
                if(stat(buf,&stat_temp)<0){     //这不是多此一举吗  有用的,可以判断当前目录下打开的文件是什么类型
                    printf("find: cannot stat %s\n",buf);
                    continue;
                }
                if(stat_temp.type==T_FILE)      //如果是文件类型
                    if(strcmp(de.name,filename)==0){
                        printf("%s\n",buf);
                        flag=1;                        
                    }

                if(stat_temp.type==T_DIR){
                    if((strcmp(de.name,".")==0)||(strcmp(de.name,"..")==0))     //得排除掉“.”和“..”这两个目录防止无限递归
                        continue ;
                    find(buf,filename);         //递归访问这个目录 开始把buf写成de.name了,有点小丑                   
                }

            }
    }
    return ;
}

int main(int argc,char *argv[]){
    if(argc!=3){
        printf("Usage: find <directory> <filename>\n");
        exit(1);
    }
    find(argv[1],argv[2]);
    printf("%d\n",dir_ItemNum);
    if(flag==0)
        printf("Fail to find the file '%s'!\n",argv[2]);
    exit(0);
}

xargs

这个这个函数的实验要求十分怪异,我看半天没理解到底要实现哪些功能,还以为得实现到“find xx xx | xargs echo xxx”这种程度。谁知它只需要实现和echo的组合功能即可,这就简单不少了。

下面来简单介绍下管道命令“|”和“xargs”在类unix系统里的用法。用

$ echo “hello” | grep root

管道命令的作用,是将左侧命令(cat “hello)的标准输出转换为标准输入,提供给右侧命令(grep root)作为参数。但是,大多数命令都不接受标准输入作为参数,只能直接在命令行输入参数,这导致无法用管道命令传递参数。举例来说,echo命令就不接受管道传参。而xargs命令的作用,是将标准输入转为命令行参数。所以一般情况下“|”和“xargs”是穿一条裤子的。

实验给出的hint是用fork和exec两个调用来实现这个功能。我们得注意一点,我们正在实现的函数是“xarg.c”!所以agrcargc[]两个参数是以命令行中的xagrs命令以及它后面跟着的参数为操作对象的,千万不要以为这两个参数读取的是整个命令行的全部命令和参数!我开始就因为弄混了又搞半天才发现问题所在。如果想要读取整个命令行的数据,我们可以利用“read(0,p,1)”来一个字符一个字符地读取所有内容,直到读取到“\0”为止。因为在许多操作系统中,标准输入(stdin)的文件描述符通常默认已经打开,因此可以直接调用 read(0, buf, sizeof(buf)) 来从标准输入读取数据。

这里又有个小坑,开始问GPT“假如char p=' ',那while(p)会直接跳出吗?”GPT回答说是,我开始信以为真,直到去dev c++上去测试了下并不然。只有p=‘\0’时while(p)才能直接退出。

踩完以上两个坑后,思路就明了了起来。当前进程是实现xargs,那就得再fork()一个子进程,子进程调用exec()来实现整个命令行的命令。需要注意的是题目中要求为每一行执行一个命令,所以用数组char** arguments来存储命令行的命令的时候是一行一行地读取,即一行读一个命令或者参数。这里用不用管道都可以实现父进程给子进程传递xargs跟着的相应参数。

xargs代码如下

#include"kernel/types.h"
#include"kernel/stat.h"
#include"user/user.h"
#include"kernel/fs.h"
#include"kernel/param.h"

int main(int argc,char *argv[]){
    int i;
    char buf[128]={0};
    int len;
    char* arguments[32];//用来读入命令行的输入
    if(argc==1){
        printf("Usage: xagrs [Command] [para1] ...[para n]");
        exit(1);
    }
    for(i=1;i<argc;i++){
        len=sizeof(argv[i])+1;
        arguments[i-1]=(char *)malloc(sizeof(char)*len);
        strcpy(arguments[i-1],argv[i]);    //不能简单地用“=”来赋值,两者的类型是不一样的
    }
    i--;    //因为这里i已经到了agrc那么大,但实际上arguments数组的下标才记到i-1
    char *p=buf;
    read(0,p,1);
    while(*p){          //苦也,GPT误我!
        if(*p=='\n'){ //如果读到一行末尾
            *p='\0';    //加上'\0'从而构成字符串
            len=sizeof(buf)+1;
            arguments[i]=(char*)malloc(sizeof(char)*len);
            strcpy(arguments[i],buf);
            i++;
            memset(buf,0,128);  //将buf置为初始状态
            p=buf;  //将p指针重新定位到buf开头
            read(0,p,1);
            continue;
        }
        p++;
        read(0,p,1);
    }
    arguments[i]=0;
    if(fork()==0){
        if(exec(argv[1],arguments)==-1){
            printf("xargs: exec failed.\n");
            exit(1);
        }
    }
    wait(0);    //回收子进程
    exit(0);
}

If you fail a test, make sure you understand why your code fails the test. Insert print statements until you understand what is going on.

今天刚在群里看到说相比于gdb,直接用printf检查代码的执行情况会更为简单方便。经历了CSAPP的拷打之后,我对此深以为然。还记得proxy lab就是printf大法立功。今天第一次打开s081的Lab guidance,发现了上面那句话,看来printf的好用之处已经广为人知了。无知时诋毁printf,成长时理解printf,成熟时加入printf

最后为了先把项目上传到github的仓库里面,也是折腾了一晚上,索性终有所得。

 

### 回答1: :xv6是一个基于Unix的操作系统,它是一个教学用途的操作系统,旨在教授操作系统的基本概念和实现。它是在MIT的x86架构上开发的,包括了Unix的一些基本功能,如进程管理、文件系统、内存管理等。xv6的源代码是公开的,可以用于学习和研究。 Unix utilitiesUnix操作系统中的一些基本工具,如ls、cd、cp、mv、rm等。这些工具可以帮助用户管理文件和目录,执行各种操作。这些工具的实现是基于Unix的系统调用,可以通过编写C程序来调用这些系统调用实现相应的功能。这些工具是Unix操作系统的基础,也是其他操作系统的参考。 ### 回答2: lab: xv6 and unix utilities 实验是一项旨在帮助学生深入理解操作系统和 Unix 工具使用的实验。该实验分为两个部分,第一部分教授学生如何构建和运行 xv6 操作系统;第二部分则重点教授 Unix 工具的使用。 在 xv6 操作系统部分,学生将学习到操作系统内核的基本结构和实现原理。实验将引导学生理解内存管理、进程调度、系统调用等关键操作系统概念。此外,学生还将学习如何编写简单的 shell 以及如何通过修改 xv6 内核代码来实现新的系统调用和功能。 在 Unix 工具部分,学生将探索 Unix 系统中广泛使用的常见工具。这些工具包括 vi 编辑器、grep、awk、sed 等。实验将介绍这些工具的基本使用方法以及它们在处理文本和数据时的实际应用。这部分实验还将让学生深入了解 shell 和 shell 脚本的编写,帮助他们在 Unix 环境中轻松地编写脚本和自动化任务。 lab: xv6 and unix utilities 实验对计算机科学专业的学生具有重要意义。通过完成这个实验,学生将建立起对操作系统和 Unix 工具的深入理解,为他们成为一名优秀的软件工程师奠定坚实的基础。同时,这个实验还将为学生提供实践经验,让他们能够将所学知识应用到真实的软件开发和运维中。 ### 回答3: Lab: xv6 and Unix Utilities是一个计算机科学领域的实验,旨在让学生深入了解Unix操作系统以及操作系统本身的自我管理机制。在这个实验中,学生需要从零开始构建一个类似于Unix的操作系统,在这个操作系统中,学生需要设计一些基本命令,例如ls,cat,grep等等,并且将它们与系统的底层API结合起来,以实现各种功能。此外,学生还需要了解和探索xv6这个开发工具,它是一个轻量级基于Unix的操作系统实现,具有一定的可移植性和简洁性,因此,它可以作为一个基础框架来实现一个完整的Unix操作系统。 这个实验的目标是让学生了解Unix的基本命令结构和API,以及操作系统内部的一些基本机制,例如进程管理,文件系统交互以及进程通信等等。此外,通过实现这些命令,学生还可以学到一些基本的C语言编程技能,例如文件操作,字符串处理以及进程管理等等。还可以学习到如何使用Git等版本控制工具,以及如何进行调试和测试代码的技巧。 在整个实验过程中,学生需要有较强的自我管理能力和综合运用能力,因为在实现这些命令的同时,他们还需要和其他团队成员进行交流和合作,以及不断改进和完善他们的代码。总之,这个实验是一个非常有趣且富有挑战性的计算机科学课程,通过完成这个实验,学生可以更好地了解操作系统的构造和运作机制,以及如何设计和开发高效的系统级应用程序。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值