【Linux】进程创建|进程终止|进程等待|进程程序替换

1.进程创建

fork函数初识

在linux中fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
在这里插入图片描述
返回值:给子进程返回0,给父进程返回子进程id,出错返回-1

当一个进程调用fork之后,就有两个二进制代码相同的进程,相当于从一个执行流变成两个执行流 了。但每个进程都将可以开始它们自己的旅程,看如下程序。

😊我们先来看这样的一个程序:

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

int global_value = 100;

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        printf("fork error\n");
        return 1;
    }
    else if(id == 0)
    {
        int cnt = 0;
        while(1)
        {
            printf("我是子进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value);
            sleep(1);
            cnt++;
            if(cnt == 10)
            {
                global_value = 300;
                printf("子进程已经更改了全局的变量啦..........\n");
            }
        }
    }
    else
    {
        while(1)
        {
            printf("我是父进程, pid: %d, ppid: %d | global_value: %d, &global_value: %p\n", getpid(), getppid(), global_value, &global_value);
            sleep(2);
        }
    }
    sleep(1);
}

执行结果:

在这里插入图片描述

fork之前父进程单独执行,fork之后,父子两个执行流分别执行。注意:fork之后,谁先执行完全由调度器决定。
对于上面的执行结果:
在子进程没有改变全局变量global_value的时候,可以看到父子进程之前的global_value以及global_value的地址都是一样的;
在子进程改变全局变量global_value之后,可以看到父子进程的值有了区别,子进程的值是300,父进程是100,因为我们知道进程具有独立性,再往后看奇怪的事情发生了,为什么父子进程的这个全局变量global_value的地址是一样的呢?
那么下面就介绍一下写时拷贝。

写时拷贝

一般情况下父子代码共享,当父子再不写入时,数据也是共享的。当任意一方试图对共享的数据进行修改,那么便以写时拷贝的方式给这个进程复制一份副本。具体见下图蓝色区域:
在这里插入图片描述
所以子进程修改global_value的内容之后,父子进程表面上global_value的虚拟地址是一样的,但是实际映射到物理内存上是不一样的。

fork返回值的三个问题

1️⃣1.如何理解fork函数有两个返回值问题?
因为没有调用fork的时候,只有父进程,调用fork之后,子进程也就被创建出来了(创建的过程特别复杂),父子进程两个执行流,两套代码,两个return,那么就意味着有两个返回值。

3️⃣2.如何理解fork返回之后,给父进程返回子进程pid,给子进程返回0?
生活中正常来讲父亲:孩子是1:n的关系(n>=1),对于父进程来说,父亲找孩子不具有唯一性。而对于子进程来说,孩子找父亲是具唯一性的。所以才会给父进程返回子进程pid,给子进程返回0。

3️⃣3.对于上面的例子代码,如何理解同一个id值,怎么可能会保存两个不同的值,让if和else if同时执行?
两个返回值那么就意味着同一个id要被父子进程的返回值赋值(写入)两次,所以先对id进行赋值(写入)的进程就正常赋值(写入),后对id进行赋值(写入)的进程因为进程具有独立性,那么就会发生写时拷贝,典型的同一个id,虚拟地址一样,但是内容却不一样。

  • fork常规用法
    1.一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,需要生成子进程来处理请求。
    2.一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
  • fork调用失败的原因
    1️⃣系统中有太多的进程2️⃣实际用户的进程数超过了限制。

2.进程终止

进程退出场景

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确
  • 代码异常终止

进程常见退出方法

进程退出码:

写代码是为了完成某件事情,如何得知我的代码运行的如何呢?——进程退出码
进程退出的时候,都有对应的退出码,作用是标定进程执行的结果是否正确。一般而言,退出码,都必须有对应的退出码的文字描述,1. 可以自定义 2. 可以使用系统的映射关系(不太频繁)

如何设定main函数返回值呢?如果不关心进程退出码,return 0就行。如果未来我们是要关心进程退出码的时候,要返回特定的数据表明特定的错误。
0:success, !0:标识失败, !0具体是几,标识不同的错误
在C语言中有strerror和perror函数可以自动获取错误码,然后打印对应的错误信息。


  • 正常终止:
    1.从main返回退出码(return 0这个0就是退出码)
    2.调用exit(exit(23)这个23就是退出码)
    3._exit(_exit(45)这个45就是退出码)

当一个进程正常终止的时候,紧接着输入 echo $? 就可以把最近一次return或者exit和_exit的退出码打印出来。

  • 异常退出:
    ctrl + c,信号终止

  • _exit函数(是系统调用)
    头文件:#include <unistd.h>
    函数原型:void _exit(int status);
    参数:status 定义了进程的终止状态,父进程通过wait来获取该值(下面进程等待会有讲解)。
    说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行echo $?发现返回值是255。
  • exit函数(是C语言的库函数)
    头文件:#include <unistd.h>
    函数原型:void exit(int status);
    exit最后也会调用exit, 但在调用exit之前,还做了其他工作:
    1.执行用户通过 atexit或on_exit定义的清理函数。
    2.关闭所有打开的流,所有的缓存数据均被写入
    3.调用_exit

用代码对比exit()和_exit()

int main()
{
	printf("hello");
	exit(0);
}
运行结果:
[root@localhost linux] # ./a.out
hello[root@localhost linux]#

int main()
{
	printf("hello");
	_exit(0);
}
运行结果:
[root@localhost linux] # ./a.out
[root@localhost linux]#

很明显的区别就是退出的时候刷不刷新缓冲区的问题。

  • return退出
    return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数。

3.进程等待

进程等待必要性:
父子进程中,如果子进程退出,子进程会把对应的退出信息存到子进程的PCB里,需要父进程来处理,但如果父进程不管不顾,就可能造成子进程变为僵尸进程,进而造成内存泄漏。进程一旦变成僵尸状态,kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
另外,父进程派给子进程的任务完成的如何,例如,子进程是否运行完成?运行结果对还是不对?是否正常退出?这些我们都需要知道。
那么这里就要用到进程等待来解决僵尸进程以及获取子进程信息的问题了。
父进程通过进程等待的方式:回收子进程资源,获取子进程退出信息

进程等待的方法

wait方法

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);

返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,通过传入的status获取子进程退出状态,不关心则可以设置成为NULL。
如果终止了多个子进程,则wait()将获取任意的子进程并返回该子进程的进程ID。

waitpid方法

pid_ t waitpid(pid_t pid, int *status, int options);

检测值进程退出信息,将子进程退出信息通过status拿出来。

  • 返回值:
    当正常返回的时候waitpid返回收集到的子进程的进程ID;
    如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
    如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
  • 参数:
    pid:
    Pid=-1,等待任一个子进程。与wait等效。
    Pid>0.等待其进程ID与pid相等的子进程。
    status:
    可以直接用status位段的特性来判断进程的退出信息。(下面获取进程status有介绍)
    也可以用Linux提供的这两个宏通过status来判断状态
    WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
    WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
    options:
    0:让waitpid使用阻塞等待的方式运行
    WNOHANG: 让waitpid使用非阻塞等待的方式运行,若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

获取子进程status

wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特
位):
一般代码运行有下面这三种结果:
运行完(代码完,结果对或者代码完,结果不对)
异常(代码没跑完,出异常了)

通过status就可以体现上面的几种运行情况:
在这里插入图片描述
退出状态(8到15位):退出码(结果是否正确)
终止信号(0到6位):退出信息,一个进程如果出异常了。那么一定是该进程收到了对应的信号。包括除0或者野指针,其实是属于操作系统识别到了这个进程有问题,然后给这个进程发信号。换句话说我们是可以通过终止信号来得知这个进程是否正常退出。

  • 正常退出情况下,退出码被设置,coredump标志位为0,不会设置退出信号
  • 异常退出时,退出码不会被设置,coredump标志位为1,退出信号被设置。

通过kill -l 就可以看对应的退出信号,退出信号如果是0那么说明没有问题。如果退出信号非零。那么就根据下面的序号去寻找对应的错误即可。
在这里插入图片描述

进程的阻塞等待方式:

  • 如果子进程先父进程一步退出,父进程还没有运行到wait,那么子进程在这段时间就会变为僵尸状态,直到父进程运行到wait,然后父进程就会回收子进程的资源然后通过传入的status值获取子进程的退出信息。
  • 如果父进程已经运行到wait,子进程还在正常运行中,那么父进程这时就会停下来等待,直到子进程运行完毕,父进程通过wait回收了子进程的资源、获取了子进程的退出信息之后。父进程才会接着运行wait下面的代码。
  • 如果不存在该子进程,则立即出错返回。

在这里插入图片描述

有了上面的基础接下来看一下waitpid函数的应用。

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        //子进程
        int cnt = 5;
        while (cnt)
        {
            printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);
            sleep(1);
            int* p = NULL;//野指针错误
            *p = 100;
        }
        exit(12); //进程退出
    }
    // 父进程
    int status = 0; // 不是被整体使用的,有自己的位图结构
    pid_t ret = waitpid(id, &status, 0);
    if (id > 0)
    {
        printf("wait success: %d, sig number: %d, child exit code: %d\n", ret, (status & 0x7F), (status >> 8) & 0xFF);
    }

    sleep(5);
}    
  • 当把上面的野指针错误屏蔽的话,正常终止信号就是0,退出码就是我们自己设定的exit()里的数字12。
    在这里插入图片描述
  • 当进程有野指针错误,那么终止信号就会显示11,从kill -l我们可以知道11号是段错误(段错误就是指访问的内存超出了系统所给这个程序的内存空间),指的就是这里的野指针错误。
    只要有了错误那么退出码就无意义,退出码系统就不再写入了,退出码会显示0。
    在这里插入图片描述
  • 如果提前用kill命令杀死子进程,那么用kill -n 进程id杀掉子进程,那么退出信号就是n。退出码无意义还是0.
    在这里插入图片描述

进程的非阻塞等待方式:

把waitpid的option选项换成WNOHANG此时的waitpid就是非阻塞等待的方式了,检测id的进程状态如果没有就绪,直接返回,接着执行下面的代码,不会一直卡住去等待,可以用while循环来控制多次检测,每一次都是非阻塞等待。那么多次非阻塞等待就称为轮询。
相比于阻塞等待,非阻塞等待不会占用父进程的所有精力。可以在轮询期间去干一些别的事情。
示例代码:

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

#define NUM 10

typedef void (*func_t)(); //使用typedef把func_t定义成一个函数指针类型

func_t handlerTask[NUM];

//样例任务
void task1()
{
    printf("handler task1\n");
}
void task2()
{
    printf("handler task1\n");
}
void task3()
{
    printf("handler task1\n");
}

void loadTask()
{
    memset(handlerTask, 0, sizeof(handlerTask));
    handlerTask[0] = task1;
    handlerTask[1] = task1;
    handlerTask[2] = task1;
}


int main()
{
    pid_t id = fork();
    assert(id != -1);
    if (id == 0)
    {
        //child
        int cnt = 3;
        while (cnt)
        {
            printf("child running, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
            sleep(1);
           
        }

        exit(10);
    }

    loadTask();
    // parent
    int status = 0;
    while (1)
    {
        pid_t ret = waitpid(id, &status, WNOHANG); //WNOHANG: 非阻塞-> 子进程没有退出, 父进程检测时候,立即返回
        if (ret == 0)
        {
            // waitpid调用成功 && 子进程没退出
            //子进程没有退出,我的waitpid没有等待失败,仅仅是监测到了子进程没退出.
            printf("wait done, but child is running...., parent running other things\n");
            for (int i = 0; handlerTask[i] != NULL; i++)
            {
                handlerTask[i](); //采用回调的方式,执行我们想让父进程在空闲的时候做的事情
            }
        }
        else if (ret > 0)
        {
            // 1.waitpid调用成功 && 子进程退出了
            printf("wait success, exit code: %d, sig: %d\n", (status >> 8) & 0xFF, status & 0x7F);
            break;
        }
        else
        {
            // waitpid调用失败
            printf("waitpid call failed\n");
            //    break;
        }
        sleep(1);
    }
    return 0;
}

运行结果:
在这里插入图片描述

4.进程程序替换

是什么?

程序替换的本质就是将指定程序的代码和数据加载到指定的位置。覆盖自己原有的代码和数据。

为什么?

之前我们创建子进程只能执行父进程代码的一部分,现在我们创建出来子进程想让子进程执行一个全新的程序的话就需要用到进程程序替换。

怎么做?

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id从未改变。
其实有七种以exec开头的函数,统称exec函数:
先来看一下返回值:

RETURN VALUE
The exec() functions return only if an error has occurred.
The return value is - 1, and errno is set to indicate the error.
  • 如果调用出错则返回-1
  • 只要是一个函数,调用就有可能失败,就是没有替换成功(没有替换)。
  • 为什么没有成功返回值呢?因为成功了,就和接下来的代码无关了,判断毫无意义。只要返回了,那就一定是错误了。

execl(l:列表式的将参数一个一个的传入)

格式:

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

用法:

execl("/usr/bin/ls"/*要执行哪一个程序*/, 
       "ls", "--color=auto", "-a", "-l", NULL/*你想怎么执行*/);
        // 所有的exec函数都以null结尾

perror("ececl"); //打印错误原因

下面是调用成功的例子,可以看到我们设置的退出码123在程序被正常替换之后就没有被执行了。

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

int main(int argc, char* argv[])
{
    printf("process is running...\n");
    pid_t id = fork();
    assert(id != -1);

    if (id == 0)
    {
        // 这里的替换,会影响父进程吗?不会的因为进程具有独立性
        // 类比:命令行怎么写,这里就怎么传
        sleep(1);
        // ./exec ls -a -l -> "./exec" "ls" "-a" "-l"
        
        execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);
        
        exit(123); //能运行到这里就必然是替换失败了
    }

    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if (ret > 0) printf("wait success: exit code: %d, sig: %d\n", 
    (status >> 8) & 0xFF, status & 0x7F);

}

在这里插入图片描述
对于上述代码如果把ls路径故意写错,必然函数调用失败。如果我们自己通过exit或者return设置退出码了,那么父进程wait之后获取的就是设置好的return或者exit的值,如果没有设置并且函数调用失败,那么就是-1,我们知道在计算机中数值是以补码的形式存储的,那么-1在计算机中就是全1,全1的退出码通过status的8位退出码信息打印出来就是255.
在这里插入图片描述

而且父子进程中,当子进程进行程序替换的时候,对父进程会造成影响。那么这时候就要发生程序的写时拷贝了,以此来保证进程间的独立性。

execlp(p:path)

p:path:带p字符的函数,不用告诉程序的路径,你只要告诉要执行谁,它就会自动在环境变量PATH,进行可执行程序的查找!

格式:

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

使用举例:

execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);

这里有两个ls,一个是告诉系统我要执行谁,一个是告诉系统怎么执行。

execv(v:vector)

v:vector可以将所有的执行参数,放入数组中,统一传递,而不用进行使用可变参数列表方案。

格式:

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

使用举例:

char *const argv_[] = {"ls","-a", "-l","--color=auto",NULL};
execv("/usr/bin/ls", argv_);

execvp(v:vector p:path)

对于v和p上面两个函数有所介绍,这里就不多赘述了。
格式:

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

使用举例:

char *const argv_[] = {"ls","-a", "-l","--color=auto",NULL};
execvp("ls", argv_);

execle(e:环境变量)

格式:

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

mybin.c:

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

int main()
{
    // 系统就有
    printf("PATH:%s\n", getenv("PATH"));
    printf("PWD:%s\n", getenv("PWD"));
    // 自定义
    printf("MYENV:%s\n", getenv("MYENV"));

    return 0;
}

myexec.c:

char *const envp_[] = {
            (char*)"MYENV=11112222233334444",
            NULL
};

extern char **environ;
//execle("./mybin", "mybin", NULL, envp_); //自定义环境变量指针

putenv((char*)"MYENV=4443332211"); 
//将指定环境变量导入到系统中 environ指向的环境变量表

execle("./mybin", "mybin", NULL, environ); //默认的环境变量指针
//实际上,默认环境变量你不传,子进程也能获取

最终看到的效果就是在myexec.c中通过execle函数调用mybin然后将系统的和自定义的环境变量都打印出来了

我们平时想要运行一个程序,就要先加载程序到内存中,那么加载程序它其实用到的是exec函数(加载器),所以exec函数先于main函数执行,而且默认的环境变量通过exec函数就已经传入到main程序里了,所以我们在执行main程序的时候可以直接使用默认的环境变量。
但是自定义的环境变量就需要使用putenv来将其导入到系统中。然后才能通过environ指针来获取。

execvpe(v:vector p:path e:环境变量)

和上面的几个exec函数功能有相似之处,这里不过多介绍。

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

execve

程序替换中execve才是真正的系统调用,其他的六个都是封装,为了让我们有很多的选择性。
在这里插入图片描述


⚠️值得注意的是:可以使用程序替换,调用任何后端语言对应的可执行程序
下面是在C语言下分别调用C++、python、shell等语言的代码片段。

execl("./mybin", "mybin", NULL);
execl("./mypy.py", "mypy.py", NULL);
execl("./myshell.sh", "myshell.sh", NULL);

总结:

exec函数族:execl,execlp,execle,execv,execvp,execve

  • 其中 l 和 v 的区别在于程序运行参数的赋予方式不同,l是通过函数参数逐个给与,最终以NULL结尾,而v是通过字符指针数组一次性给与。
  • 其中有没有 p 的区别在于程序是否需要带路径,也就是是否会默认到path环境变量指定的路径下寻找程序,没有p的需要指定路径,有p的会默认到path环境变量指定路径下寻找
  • 其中有没有 e 的区别在于程序是否需要带环境变量,没有e则默认使用父进程环境变量,有e则可以添加自定义的环境变量。

5.制作一个简易的shell

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

#define NUM 1024
#define OPT_NUM 64

char lineCommand[NUM];
char *myargv[OPT_NUM]; //指针数组
int  lastCode = 0;
int  lastSig = 0;

int main()
{
    while(1)
    {
        // 输出提示符
        printf("用户名@主机名 当前路径# ");
        fflush(stdout);//刷新缓冲区

        // 获取用户输入, 输入的时候,输入\n
        char *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);
        assert(s != NULL);
        (void)s;
        
        // 一般我们输入完毕回车之后自动带一个\n,要清除最后一个\n,否则不好看
        lineCommand[strlen(lineCommand)-1] = 0; // ?
        //printf("test : %s\n", lineCommand);
        
        // "ls -a -l -i" -> "ls" "-a" "-l" "-i" -> 1->n
        // 字符串切割
        myargv[0] = strtok(lineCommand, " ");
        
            //对于ls命令需要做的一点小修改
	        int i = 1;
	        if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0)
	        {
	            myargv[i++] = (char*)"--color=auto";
	        }

        // 进行连续分隔:如果没有子串了,strtok->NULL, myargv[end] = NULL
        while(myargv[i++] = strtok(NULL, " "));
		
		//对于cd命令需要做的一点小修改(chdir的使用)
        // 如果是cd命令,不需要创建子进程,让shell自己执行对应的命令,本质就是执行系统接口
        // 像这种不需要让我们的子进程来执行,而是让shell自己执行的命令 --- 内建/内置命令
        if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
        {
            if(myargv[1] != NULL) chdir(myargv[1]);
            continue;
        }
        
        //对于echo命令需要做的一点小修改,这里就能解释为什么
        if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0)
        {
            if(strcmp(myargv[1], "$?") == 0)
            {
                printf("%d, %d\n", lastCode, lastSig);
            }
            else
            {
                printf("%s\n", myargv[1]);
            }
            continue;
        }

        // 这里是一个测试:测试是否成功, 条件编译
#ifdef DEBUG
        for(int i = 0 ; myargv[i]; i++)
        {
            printf("myargv[%d]: %s\n", i, myargv[i]);
        }
#endif
        // 内建命令 --> echo

        // 执行命令
        pid_t id = fork();
        assert(id != -1);

        if(id == 0)
        {
            execvp(myargv[0], myargv);
            exit(1);
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        assert(ret > 0);
        (void)ret;
        lastCode = ((status>>8) & 0xFF);
        lastSig = (status & 0x7F);
    }
}

补充:

  • 什么是当前路径?
    当前路径就是cwd所标识的路径。也就是当前进程的工作目录。
    exe所指的是当前进程执行的是磁盘路径下的哪一个程序。
    在这里插入图片描述

当fork()之后,子进程执行的cd->子进程有自己的工作目录->更改的是子进程的目录->子进程执行完毕->继续用的是父进程,即shell.

  • 我们自己写shell的时候,怎么使用cd来使它的当前路径变化呢?
    可以看到需要使用chdir就可以解决这个问题。在上面代码的注释中有相应的解释。
    在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

有效的放假者

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

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

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

打赏作者

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

抵扣说明:

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

余额充值