Linux——进程控制

目录

进程创建

fork函数

fork用法

fork失败原因

进程终止

进程常见退出方法

进程等待

进程等待的必要性

wait()

waitpid()

status

进程替换

替换函数

execl

execv

execlp

execvp

execle

execvpe

execve系统调用

命名的理解

简易shell


进程创建

fork函数

        在上一篇中我们已经知道了他是什么,也知道了他怎么用,这里就不过多赘述了,但是我们还是要看一下,fork创建子进程操作系统都做了什么。

  • 分配新的内存块和内核数据结构给子进程。
  • 将父进程部分数据结构内容拷贝至子进程。
  • 添加子进程到系统进程列表当中。
  • fork返回,开始调度器调度。

        我们一步一步看,创建了一个子进程,系统中也就多了一个进程,再次强调:进程 = 内核数据结构 + 代码和数据。为子进程分配和初始化他的数据结构,子进程的数据结构都是来自父进程,进行拷贝操作,再把他添加到运行队列中。

        但是子进程没有从硬盘加载到内存这个操作,所以他没有自己的代码和数据,只能共享父进程的,代码通常也是只读的,共享没有问题,但是数据是可能会修改的,所以数据必须要分离。分离就给你拷贝一份,但是子进程拷贝根本就用不到的数据,那么这个进程创建出来也只是多了个进程,并且和父进程一模一样,要是再不退出那就一直占着空间,那这就是浪费空间

        所以创建子进程不需要将不会被访问或只读的数据进行拷贝,只用拷贝将来会写入的数据,但是将来发生什么是是不知道的啊,所以操作系统选择了写时拷贝来进行父子进程数据的分离,从而保证了进程的独立性。

fork用法

  1. 一个父进程希望复制自己,与子进程同时执行不同的代码。
  2. 一个进程要执行不同的程序,例如子进程fork返回后调用exec函数,这后面再说。

fork失败原因

  1. 系统中有太多进程。
  2. 实际用户的进程数超过了限制。

进程终止

进程终止时操作系统要释放进程申请的相关内核数据结构和代码。

进程退出的场景:

  • 代码跑完,结果正确
  • 代码跑完,结果不正确
  • 代码没有跑完,程序崩溃

这些退出场景是什么意思,接下来就来看一下。

        通常我们写的main函数的返回值不都是0吗,这个0是什么,是啥意思呢?

        其实这里并不一定要是0,它表示的是这个进程的退出码。0代表代码跑完了,结果是正确的。在命令行中,我们想要获取最近一次进程的退出码使用的是:echo $?。

        通常情况下,0表示success结果正确,而非0表示结果不正确,非0值有无数个,不同的值表示不同的错误,从而给程序运行结束之后定位错误原因。

#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main()
{
    for (int i = 0; i < 135; i++)
    {
        printf("[%d]: %s\n", i, strerror(i));
    }
    return 0;
}

 这里为什么写i < 135是因为strerror打印的信息一共就135条。

这只是strerror提供的错误信息,如果不想用它的也可以自己定义退出码。

 

ls也是一个程序,如果使用错误的选项或者不存在的文件会怎么样呢?

错误码是2,也可以对应上面的图找找,2也就是No such file or directory。


这里演示一下程序崩溃。

int main()
{
    printf("hello 1\n");
    printf("hello 1\n");
    printf("hello 1\n");
    int* p = NULL;
    *p = 1; // 野指针错误
    printf("hello 2\n");
    printf("hello 2\n");
    printf("hello 2\n");
    return 0;
}

这为啥是139啊?所以程序崩溃的时候,退出码无意义。

进程常见退出方法

正常的终止方法:

  1. 只有main函数内的return语句就是终止进程的。
  2. 调用exit();他在任何地方调用都表示终止进程。
  3. _exit();

他们两个就什么区别呢?

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    printf("hello");
    sleep(3);
    exit(1);
}

如果我们不写"\n"代表数据还在缓冲区内,exit会帮我们刷新缓冲区。

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main()
{
    printf("hello");
    sleep(3);
    _exit(1);
}

_exit是一个系统调用,它直接帮我们终止程序。

 

        那我们就要再来说一下系统调用和库函数的关系了,库函数是用户为了更好的使用而封装了系统调用接口,exit也是把_exit封装了一下,\n会自动刷新缓冲区,exit也会刷新,所以这个缓冲区一定不在操作系统中,如果在的话,_exit也会刷新缓冲区,所以这个缓冲区是C标准库维护的,关于这些细节以后会说的。


进程等待

进程等待的必要性

  1. 如果子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,这样就会造成内存泄漏。
  2. 子进程一旦变成僵尸进程,就算是kill -9 命令也无法将其杀死,因为无法杀死一个已经死去的进程。
  3. 对于一个子进程,父进程必须要知道自己派给子进程的任务完成有没有完成。
  4. 父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

还是这段代码,还是这个现象。

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

int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        exit(1); // 进程运行完毕,结果不正确
    }
    else if (id == 0)
    {
        // 子进程
        int cnt = 3;
        while (cnt--)
        {
            printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid());
            sleep(1);
        }
        exit(0);
    }
    else
    {
        // 父进程
        while (1)
        {
            printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    return 0;
}

        三秒后子进程退出,父进程还在运行,父进程没有读取子进程的退出信息,所以子进程进入了僵尸状态,不想让他这样那就让父进程接受它的退出信息就可以了。

wait()

这里又是一个系统调用接口。

作用:等待任意子进程

参数:是输出型参数,获取子进程的退出状态,不需要知道退出状态可设置为NULL。

返回值:等待成功返回等待进程的pid,失败返回-1。

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

int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        exit(1); // 进程运行完毕,结果不正确
    }
    else if (id == 0)
    {
        // 子进程
        int cnt = 3;
        while (cnt--)
        {
            printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid());
            sleep(1);
        }
        exit(0);
    }
    else
    {
        // 父进程
        printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());
        pid_t ret = wait(NULL); // 阻塞式的等待
        if (ret > 0)
        {
            printf("等待子进程成功,ret: %d\n", ret);
        }

        while (1)
        {
            printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
        }
    }
    return 0;
}

        子进程先运行三秒,使用wait的时候是阻塞式的等待,只有等待成功才往后执行,父进程也回收了处于僵尸状态的子进程。

waitpid()

还有一个就是waitpid,pid_t waitpid(pid_t pid, int* status, int options);

作用:等待指定子进程或任意子进程。

参数:

1.如果pid=-1,表示等待任意一个子进程。

2.如果pid>0,等待进程id与pid相等的子进程。

3.status如果想要结果需要传入status的地址,用来获取子进程的退出结果。

4.options默认为0,表示阻塞等待。

返回值:

1.正常返回的是子进程的pid。

2.如果选型中是WNOHANG,而子进程没有退出,返回的就是0.

3.调用出错返回-1

waitpid(pid, NULL, 0) == wait(NULL)

status

下面就来看看status是怎么用的。

int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        exit(1); // 进程运行完毕,结果不正确
    }
    else if (id == 0)
    {
        // 子进程
        int cnt = 3;
        while (cnt--)
        {
            printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid());
            sleep(1);
        }
        exit(5);
    }
    else
    {
        // 父进程
        printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());
        int status = 0;
        pid_t ret = waitpid(id, &status, 0); // 阻塞式的等待
        if (ret > 0)
        {
            printf("等待子进程成功,ret: %d, status: %d\n", ret, status);
        } 
    }
    return 0;
}

        还是子进程运行3秒,然后退出,此时父进程等待,等待成功拿到了退出信息,status应该帮我们拿到exit(5)的值,但是这里为什么不是5呢?

        这就要说一下,status不是按照整型来使用的,而是按照bit位的方式,将32个bit位进行划分,现在就先看低16位,这16位的前8位就存放着退出信息,如何拿到这8位,先向右移8位再按位与上0xFF,这样只有后八位是1,其余都是0就可以拿到这八位。

// ...
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞式的等待
if (ret > 0)
{
    printf("等待子进程成功,ret: %d, status8位退出码: %d\n", ret, status >> 8 & 0xFF); // 0xFF -> 0000...0000 1111 1111
} 
// ...

这样就拿到了5。

        还要再说的一点就是,平常写代码的时候遇到程序崩溃,准确的来说应该是进程崩溃,这是由操作系统发送信号才让他崩溃的,那么这种信号被存放在status的低7位

//...
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞式的等待
if (ret > 0)
{
    printf("等待子进程成功,ret: %d, status7位信号: %d, status8位退出码: %d\n", ret, status & 0x7F, status >> 8 & 0xFF);
    // 0x7F -> 0000...0000 0111 1111 ; 0xFF -> 0000...0000 1111 1111
}
//...

信号为0就代表正常退出。使用kill -l来查看所有的信号。

如果这时候子进程来一个除0错误会怎么样呢?

        8号信号就可以知道是SIGFPE,这是浮点数错误。这就代表程序崩溃,收到操作系统发来的信号,此时的退出码就没有意义了。

        如果子进程是个死循环,父进程使用kill -9 信号处理。

所以程序异常不止是内部的问题,也有可能是外力操作。

        既然我们已经知道了怎么拿到这几位信息,但这样是不是太麻烦了,所以系统为我们提供了两个宏:

  • WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。(Wait IF EXIT EnD,方便记忆)
  • WEXITSTATUS(status):用于获取进程的退出码(Wait EXIT STATUS)。
int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        exit(1); // 进程运行完毕,结果不正确
    }
    else if (id == 0)
    {
        // 子进程
        int cnt = 3;
        while (cnt--)
        {
            printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid());
            sleep(1);
        }
        exit(5);
    }
    else
    {
        // 父进程
        printf("I am parent, pid: %d, ppid: %d\n", getpid(), getppid());
        int status = 0;
        pid_t result = waitpid(id, &status, 0); // 阻塞式的等待

        if (result > 0)
        {
            if (WIFEXITED(status))
            {
                // 子进程正常退出
                printf("子进程执行完毕,子进程的退出码: %d\n", WEXITSTATUS(status));
            }
            else
            {
                printf("子进程异常退出: %d\n", WIFEXITED(status)); 
            }
        }
    }
    return 0;
}

        waitpid的第三个参数option,默认0的情况下就是阻塞等待,库中也帮我们定义了一个宏define WNOHANG 1;,它的值其实就是1,宏就是为了帮助我们见名知意,Wait NO HANG,hang这个单词也有挂起吊死的意思,有的时候一个进程怎么都不动,CPU可能很忙,没有调度这个进程,所以他要么在阻塞队列中要么在等待被调度,所以NO HANG就是非阻塞等待的意思。

        那么waitpid是怎么做到阻塞或者非阻塞的呢,在waitpid这个函数中,操作系统要先检测子进程是否退出,在task_struct中存放着这些信息。

        如果子进程退出了,如果有status就把status的信息填充好,然后返回它的pid。

        如果子进程没退出,还要再检测你的option是几,是0就是阻塞等待,在CPU中的寄存器中保存它的上下文,然后把父进程挂起,放到等待队列中,所以进程阻塞是阻塞在函数内部的这一行,后面的代码就不执行了,只有满足条件的时候才被唤醒,从寄存器中拿到这一行的位置,继续向后执行;如果是1就代表非阻塞,函数直接就return,继续执行其他代码。

int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        exit(1); // 进程运行完毕,结果不正确
    }
    else if (id == 0)
    {
        // 子进程
        int cnt = 3;
        while (cnt--)
        {
            printf("cnt = %d, I am child, pid: %d, ppid: %d\n", cnt, getpid(), getppid());
            sleep(1);
        }
        exit(5);
    }
    else
    {
        // 父进程
        int quit = 0;
        while (!quit)
        {
            int status = 0;
            pid_t res = waitpid(id, &status, WNOHANG); // 非阻塞式的等待
            if (res > 0)
            {
                // 等待成功,子进程退出
                printf("等待子进程退出成功,退出码: %d\n", WEXITSTATUS(status));
                sleep(2);
                quit = 1;
            }
            else if (res == 0)
            {
                printf("子进程还在运行,父进程可以继续处理其他事\n");
                sleep(1);
            }
            else 
            {
                // 等待失败
            }
        }
    }
    return 0;
}

        我们前面老是提到,进程具有独立性,但是进程的退出码也是子进程的数据,父进程为什么能拿到?

        子进程退出了,变成了僵尸进程,但也保留着进程PCB的信息,在PCB中也会有退出码和退出信号等信息,这就要说到wait和waitpid了,他们两个是系统调用接口,说白了就是系统帮我们用这两个函数从子进程的PCB中拿到了status的值


进程替换

        在进程创建的部分说到了fork创建子进程,之后父子各自执行父进程代码的一部分,如果子进程不想执行父进程的代码,换言之就是让子进程执行一个新的代码,这就需要进程替换来完成,进程替换是通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中。

        子进程要调用一种exec函数,从而执行另一个程序。当进程调用这种exec函数时,该进程的用户空间代码和数据完全被新程序替换页表的映射关系重新建立,从新程序的第一行开始执行。调用exec并不会创建新的进程,它的本质就是加载程序的函数,所以调用exec前后该进程的pid并未改变

替换函数

返回值:只有替换失败了才有返回值为-1,替换成功会把自己也替换掉,所以成功没有返回值。

替换函数有六种以exec开头的函数,它们统称为exec函数,先来看第一个:

execl

int execl(const char *path, const char *arg, ...);

        第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。

        例如ls -l -a,ls就是/usr/bin下的一个可执行程序,所以就用代码调用一下它。

int main()
{
    printf("begin---------------------\n");

    execl("/usr/bin/ls", "ls", "-l", NULL);

    printf("end-----------------------\n");
    return 0;
}

这样就在代码中使用了命令,奇怪的一点是,我的end代码怎么没有打印出来?

        execl是程序替换,调用函数成功,会将当前进程的代码和数据都进行替换,只要替换成功,后面的代码就不会执行了。

        在进程替换之前,父子进程的代码是共享的数据写时拷贝。进程替换后,子进程要写入新的程序并重新建立映射关系,所以父子进程的代码也要分离代码也要进行写时拷贝,这样父子进程的代码和数据都分离了。

execv

        再来看下一个函数:

int execv(const char *path, char *const argv[]);

        从命名上execl最后一个是“l”,可以理解为list也就是列表,最后一个参数也是可变参数列表,把参数一个一个写出来。而execv最后一个是“v”,这就是vector,像一个数组一样,最后一个参数也是指针数组。

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

#define NUM 16

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程
        printf("子进程开始运行,pid: %d, ppid: %d\n", getpid(), getppid());
        sleep(1);
        char* const _argv[NUM] = {
            "ls",
            "-l",
            "-a",
            NULL
        };
        execv("/usr/bin/ls", _argv);
        
        exit(1);
    }
    else 
    {
        // 父进程
        printf("父进程开始运行,pid: %d, ppid: %d\n", getpid(), getppid());
        int status = 0;
        pid_t res = waitpid(id, &status, 0); // 阻塞等待,子进程先运行,父进程再运行
        if (res > 0)
        {
            printf("wait success, exit_code: %d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

和execl的使用没有太大区别。

execlp

再来看下一个函数:

int execlp(const char *file, const char *arg, ...);

        execlp中的“l”代表列表的形式传参,而“p”代表的是传入一个文件名,他自己会在环境变量中找。

execlp("ls", "ls", "-l", "-a", NULL);

execvp

那么execvp也就好理解了。

int execvp(const char *file, char *const argv[]);
char* const _argv[NUM] = {
    "ls",
    "-l",
    "-a",
    NULL
};
execvp("ls", _argv);

execle

下一个是execle。

int execle(const char *path, const char *arg, ..., char *const envp[]);

        函数名没有“p”,所以不会从环境变量中找,“l”是列表的形式,“e”就代表第三个参数你可以设置环境变量。

char* const _env[NUM] = {
    "MY_VAL=100",
    NULL
};
execle("...", "...", NULL, _env); 

假如我不行使用ls这种系统提供的程序,我想要替换自己写的程序,那就需要传入这个程序的路径、可变参数,最后一个就可以传入想给这个进程的环境变量。

execvpe

int execvpe(const char *file, char *const argv[], char *const envp[]);

        这个函数也就不太难理解了,传入的参数可以在环境变量中找到,以数组的形式传入,可以传入环境变量给替换的进程。

execve系统调用

上面所说的6个函数都是对这个系统调用的封装,为了满足不同的需求。

int execve(const char *path, char *const argv[], char *const envp[]);

命名的理解

  • l(list) : 表示参数用列表形式
  • v(vector) : 参数用数组形式
  • p(path) : 有p自动搜索环境变量PATH
  • e(env) : 表示自己维护环境变量


简易shell

        简易的shell就是一个命令行解释器,原理就是当有命令需要执行的时候,shell(父进程)创建子进程让它执行命令,而shell(父进程)只需要等待子进程退出。

通过这个函数拿到从键盘中输入的字符串。

通过这个函数分割字符串。

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

#define NUM 1024
#define SIZE 32
#define SEP " "

// 保存用户输入的字符串
char cmd_line[NUM];

// 保存分割后的字符数组
char* g_argv[SIZE];

int main()
{
    // 0. 命令行解释器:一定是一个不退出的进程
    while (1)
    {
        // 1. 打印提示信息
        printf("[root@localhost myshell]# "); // 没有\n是不会打印的
        fflush(stdout); // 刷新缓冲区
        memset(cmd_line, '\0', sizeof(cmd_line));
        
        // 2. 获取输入的指令和选项
        if (fgets(cmd_line, sizeof(cmd_line), stdin) == NULL) // 判断
        {
            continue;
        }
        cmd_line[strlen(cmd_line) - 1] ='\0' ; // 输入的应该是:ls -l -a\n,要把\n去掉
        
        // 3. 命令行字符串解析
        g_argv[0] = strtok(cmd_line, SEP); // 第一次要传入字符串
        int index = 1;
        while (g_argv[index++] = strtok(NULL, SEP)); // 第二次如果还要解析上一个字符串就要传入NULL
        
         
        // 创建子进程
        pid_t id = fork();
        if (id == 0)
        {
            // 子进程
            execvp(g_argv[0], g_argv);
            exit(1);
        }
        else 
        {
            // 父进程
            int status = 0;
            pid_t ret = waitpid(id, &status, 0); // 阻塞等待
            if (ret > 0) 
                printf("exit_code: %d\n", WEXITSTATUS(status));
        }
    }
}

这样一个简易的shell就完成了,但是这个shell还有一点缺点。

        当我们运行自己写shell,输入cd ..命令返回上一级目录的时候就会有问题。

使用了cd命令,但是没有执行,这时为什么呢?

        因为这行命令都交给了子进程,子进程执行cd只会影响它的当前路径,所以需要特殊处理,说白了它就是父进程中的函数调用。

        原来我们讲过export添加环境变量也是要在父进程中添加的从而影响全局。这也是父进程中的函数调用,要注意的是添加环境变量的时候定义了字符数组,并把要添加的环境变量拷贝到数组中,使用数组添加环境变量,

还可以继续优化一下,ls可以加上配色,输入ll的也可以处理一下。

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

#define NUM 1024
#define SIZE 32
#define SEP " "

// 保存用户输入的字符串
char cmd_line[NUM];

// 保存分割后的字符数组
char* g_argv[SIZE];

// 存放想要添加的环境变量
char g_val[32];

int main()
{
    // 0. 命令行解释器:一定是一个不退出的进程
    while (1)
    {
        // 1. 打印提示信息
        printf("[root@localhost myshell]# "); // 没有\n是不会打印的
        fflush(stdout); // 刷新缓冲区
        memset(cmd_line, '\0', sizeof(cmd_line));
        
        // 2. 获取输入的指令和选项
        if (fgets(cmd_line, sizeof(cmd_line), stdin) == NULL) // 判断
        {
            continue;
        }
        cmd_line[strlen(cmd_line) - 1] ='\0' ; // 输入的应该是:ls -l -a\n,要把\n去掉
        
        // 3. 命令行字符串解析
        g_argv[0] = strtok(cmd_line, SEP); // 第一次要传入字符串
        int index = 1;
        if (strcmp(g_argv[0], "ls") == 0)
        {
            g_argv[index++] = "--color=auto"; // 也可以为ls加上配色
        }
        if (strcmp(g_argv[0], "ll") == 0)
        {
            g_argv[0] = "ls";
            g_argv[index++] = "-l";
            g_argv[index++] = "--color=auto";
        }
        while (g_argv[index++] = strtok(NULL, SEP)); // 第二次如果还要解析上一个字符串就要传入NULL
        // export添加环境变量也是要在父进程添加的
        if (strcmp(g_argv[0], "export") == 0 && g_argv[1] != NULL)
        {
            strcpy(g_val, g_argv[1]); // 如果使用g_val[1]去添加环境变量会添加失败
            int ret = putenv(g_val);
            if (ret == 0) printf("export success\n");
            continue;
        }
        
        // 4. 处理内置命令,是让父进程(shell)执行的,是要影响父进程的
        if (strcmp(g_argv[0], "cd") == 0)
        {
            if (g_argv[1] != NULL)
                chdir(g_argv[1]);
            continue;
        }
        
        // 5. 创建子进程
        pid_t id = fork();
        if (id == 0)
        {
            // 子进程
            execvp(g_argv[0], g_argv);
            exit(1);
        }
        else 
        {
            // 父进程
            int status = 0;
            pid_t ret = waitpid(id, &status, 0); // 阻塞等待
            if (ret > 0) 
                printf("exit_code: %d\n", WEXITSTATUS(status));
        }
    }
}
  • 19
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

微yu

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值