Linux系统下的进程间通信(IPC)


进程间通信作用:

数据传输
资源共享
事件通知
进程控制

其中管道用来进行数据传输,信号用来进行事件控制, System -V 消息队列 用来进行数据传输+进程控制, System -V 信号量用来进行资源共享+进程控制。 System -V 共享内存用来进行数据传输。


通信方式:

一、早期unix系统的ipc

什么是管道
各位同学可以先想象管道的抽象概念,就像我们生活中的自来水管,正是因为有了这些水管组成的管道,自来水才能从自来水厂流到我们家中。在Linux系统中的管道也是起到数据传输的作用,把一个进程的数据通过这个管道传输到另一个进程。悄悄的告诉你,,你可以把linux里面的管道当成一个特殊的文件,一个进程对这个文件写操作,另一个进程对这个进程读操作就可以实现进程通信了。

管道分为匿名管道和命名管道。那匿名管道和命名管道有什么区别呢:
匿名管道只能用于父子间的进程通信,而命令管道则适用于毫不相关的进程进行通信,因为他有名字,别的进程可以找到它的管道名。而无名管道的话别的不相干的进程找不到,只有自己亲生的父进程能找到。


实用的小技巧:
如果要查看某个进程的pid,可以用ps -aux | grep + (执行程序),这里的|(与符号)就是表示管道的意思。例如,我运行了一个main执行程序,就通过ps -aux | grep main查看pid号,然后想要给这个程序发送中断信号的话,可以敲命令kill SIGINT pid号。

1.匿名管道(pipe)

匿名管道PIPE:
通过man命令查看pipe()函数原型:int pipe(int pipefd[2]);
其中形参数组pipefd[0] 指管道的读取端,pipefd[1]指向管道的写端 ,pipe()函数还会返回一个int类型的变量, 如果为0则表示创建匿名管道成功,如果为-1则表示创建失败。

匿名管道创建成功以后,创建该匿名管道的进程(父进程)同时掌握着管道的读取端和写入端, 但是想要父子进程间有数据交互,则需要以下操作:

父进程调用pipe()函数创建匿名管道,得到两个文件描述符pipefd[0]、pipefd[1] 分别指向管道的读取端和写入端。

父进程调用fork()函数启动(创建)一个子进程, 那么子进程将从父进程中继承这两个文件描述符pipefd[0]、pipefd[1] 它们指向同一匿名管道的读取端与写入端。

由于匿名管道是利用环形队列实现的,数据将从写入端流入管道,从读取端流出,这样子就实现了进程间通信, 但是这个匿名管道此时有两个读取端与两个写入端, 

如果想要从父进程将数据传递给子进程,则父进程需要关闭读取端,子进程关闭写入端。

如果想要从子进程将数据传递给父进程,则父进程需要关闭写入端,子进程关闭读取端。

当不需要管道的时候,就在进程中将未关闭的一端关闭即可。

直接看代码来进行分析:

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

 int main()
 {
     pid_t pid;
     int pipe_fd[2];                          
     char buf[256];
     const char data[] = "Pipe Test Program";
     int real_read, real_write;

     memset((void*)buf, 0, sizeof(buf));

     /* 创建管道 */
     if (pipe(pipe_fd) < 0)                 
     {
         printf("pipe create error\n");
         exit(1);
     }

     /* 创建一子进程 */
     if ((pid = fork()) == 0)               
     {
         /* 子进程关闭写描述符,并通过使子进程暂停 3s 等待父进程已关闭相应的读描述符 */
         close(pipe_fd[1]);
         sleep(3);

         /* 子进程读取管道内容 */          
         if ((real_read = read(pipe_fd[0], buf, MAX_DATA_LEN)) > 0)
         {
             printf("%d bytes read from the pipe is '%s'\n", real_read, buf);
         }
         /* 关闭子进程读描述符 */
         close(pipe_fd[0]);               
         exit(0);
     }

     else if (pid > 0)
     {
         /* 父进程关闭读描述符,并通过使父进程暂停 1s 等待子进程已关闭相应的写描述符 */
         close(pipe_fd[0]);                  
         sleep(1);

         if((real_write = write(pipe_fd[1], data, strlen(data))) != -1)  
         {
             printf("Parent write %d bytes : '%s'\n", real_write, data);
         }

         /*关闭父进程写描述符*/
         close(pipe_fd[1]);                 

         /*收集子进程退出信息*/
         waitpid(pid, NULL, 0);            

         exit(0);
     }
 }

2.命名管道(fifo)

命名管道FIFO:
函数原型如下:
int mkfifo(const char * pathname,mode_t mode);
mkfifo()会根据参数pathname建立特殊的FIFO文件,而参数mode为该文件的模式与权限。mkfifo()创建的FIFO文件其他进程都可以进行读写操作,可以使用读写一般文件的方式操作它, 如open,read,write,close等

使用FIFO的过程中,当一个进程对管道进行读操作时:

若该管道是阻塞类型,且当前 FIFO内没有数据,则对读进程而言将一直阻塞到有数据写入。

若该管道是非阻塞类型,则不论 FIFO内是否有数据,读进程都会立即执行读操作。 即如果FIFO内没有数据,读函数将立刻返回 0

使用FIFO的过程中,当一个进程对管道进行写操作时:

若该管道是阻塞类型,则写操作将一直阻塞到数据可以被写入。

若该管道是非阻塞类型而不能写入全部数据,则写操作进行部分写入或者调用失败

下面看代码并进行分析:

 #include <sys/wait.h>
 #include <sys/types.h>
 #include <sys/stat.h>
 #include <unistd.h>
 #include <errno.h>
 #include <fcntl.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <limits.h>
 #include <string.h>

 #define MYFIFO "myfifo"    /* 命名管道文件名*/



 void fifo_read(void)
 {
     char buff[256];
     int fd;
     int nread;

     printf("***************** read fifo ************************\n");
     /* 判断命名管道是否已存在,若尚未创建,则以相应的权限创建*/
     if (access(MYFIFO, F_OK) == -1)                 //(4)
     {
         if (mkfifo(MYFIFO, 0666) < 0)    //(5)
         {
             printf("Cannot create fifo file\n");
             exit(1);
         }
     }

     /* 以只读阻塞方式打开命名管道 */
     fd = open(MYFIFO, O_RDONLY);                //(6)
     if (fd == -1)
     {
         printf("Open fifo file error\n");
         exit(1);
     }

     memset(buff, 0, sizeof(buff));

     if ((nread = read(fd, buff, MAX_BUFFER_SIZE)) > 0)      // (7)
     {
         printf("Read '%s' from FIFO\n", buff);
     }

    printf("***************** close fifo ************************\n");

     close(fd);                              //(8)

     exit(0);
 }


 void fifo_write(void)
 {
     int fd;
     char buff[] = "this is a fifo test demo";
     int nwrite;

     sleep(2);   //等待子进程先运行              //(9)

     /* 以只写阻塞方式打开 FIFO 管道 */
     fd = open(MYFIFO, O_WRONLY | O_CREAT, 0644);        //(10)
     if (fd == -1)
     {
         printf("Open fifo file error\n");
         exit(1);
     }

     printf("Write '%s' to FIFO\n", buff);

     /*向管道中写入字符串*/
     nwrite = write(fd, buff, MAX_BUFFER_SIZE);          //(11)

     if(wait(NULL))  //等待子进程退出
     {
         close(fd);                          //(12)
         exit(0);
     }

 }


 int main()
 {
     pid_t result;
     /*调用 fork()函数*/
     result = fork();                //(1)

     /*通过 result 的值来判断 fork()函数的返回情况,首先进行出错处理*/
     if(result == -1)
     {
         printf("Fork error\n");
     }


     else if (result == 0) /*返回值为 0 代表子进程*/
     {
         fifo_read();            //(2)
     }

     else /*返回值大于 0 代表父进程*/
     {
         fifo_write();       //(3)
     }

     return result;
 }

(1): 首先使用fork函数创建一个子进程。
(2): 返回值为 0 代表子进程,就运行fifo_read()函数。
(3): 返回值大于 0 代表父进程,就运行fifo_write()函数。
(4): 在子进程中先通过access()函数判断命名管道是否已存在,若尚未创建,则以相应的权限创建
(5): 调用mkfifo()函数创建一个命名管道。
(6): 使用open()函数以只读阻塞方式打开命名管道。
(7): 使用read()函数读取管道的内容,由于打开的管道是阻塞的,而此时管道中没有存在任何数据, 因此子进程会阻塞在这里,等待到管道中有数据时才恢复运行,并打印从管道中读取到的数据。
(8): 读取完毕,使用close()函数关闭管道。
(9): 父进程休眠2秒,等待子进程先运行,因为本例子是在子进程中创建管道的。
(10): 以只写阻塞方式打开 FIFO 管道。
(11): 向管道中写入字符串数据,当写入后管道中就存在数据了,此时处于阻塞的子进程将恢复运行, 并将字符串数据打印出来。
(12): 等待子进程退出,并且关闭管道。

命名管道有特别注意的几个点

1、write和read命名管道的时候,如果是写操作的时候,缓存区满了,不能写入,或者读操作的时候,当前FIFO没有数据,则进程一直在这个位置等待,直到有数据被读出来了。
2、write操作具有“原子性”,这里的意思是,支持多进程写入并且数据不会相互践踏,遵循先进先出的原则(First in First Out).FIFO会去判断缓存区的剩余空间大小能不能把当前的数据全部写入,如果空间足够大,则全部写入,如果空间不够的话,写操作是不会往有名管道里面写入数据的,也就是write会返回-1,从而到达数据完整性的目的。如果是匿名管道就是即使不够大,还是会写入。

3.信号

信号是什么?

信号又被称为软中断信号,是在软件层次上对中断机制的一种模拟。

怎样会产生信号?

硬件:执行非法指令、访问非法内存、等等 软件:
1控制台终端(ctrl+c中断信号、ctrl+|(与的符号,不是L)退出信号、ctrl+z退出信号),
2kill命令(比如发送强制杀死信号给某个进程就用kill -9 pid)。 3程序中调用kill()函数。

信号的处理方式:

忽略:不处理这个信号
捕获:进程会调用相应的处理函数去处理这个信号。
默认:使用操作系统默认的方式去处理这个信号

捕获一个信号可以用signal()函数来进行捕获某个信号。这里可以signal()函数用法可以通过下面代码来说明。如果还是不理解这个函数的话可以在终端通过man 命令去查看该函数的具体用法。请看代码:

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

/** 信号处理函数 */
void signal_handler(int sig)            //(3)
{
    printf("\nthis signal number is %d \n",sig);

    if (sig == SIGINT) {
        printf("I have get SIGINT!\n\n");
        printf("这个信号被重定义了\n");
        /** 恢复信号为默认情况 */
        signal(SIGINT, SIG_DFL);        //(4)
    }

}

int main(void)
{
    printf("\nthis is an singal test function\n\n");

    /** 设置信号处理的回调函数 */
    signal(SIGINT, signal_handler);       

    while (1) {
        printf("waiting for the SIGINT signal , please enter \"ctrl + c\"...\n");
        sleep(1);                  
    }

    exit(0);
}

程序分析:
(1) :使用signal()函数捕获SIGINT信号(这个信号可以通过按下 CTRL+C 产生), 并设置回调函数为signal_handler(),当产生信号的时候就调用该函数去处理这个信号。
(2) :在信号没有到来的时候就打印信息并且休眠。
(3) :signal_handler()是信号处理函数,它传入一个int类型的信号值, 在信号传递进来的时候就将对应的信号值打印出来,在此例中我们可以看到, 信号处理函数使用了一个单独的整数参数,它就是引起该函数被调用的信号值。 如果需要在同一个函数中处理多个信号,这个参数就很有用。
(4) :如果信号是SIGINT,则打印对应的信息,并且调用signal()函数将SIGINT信号的处理恢复默认的处理(SIG_DFL), 在下一次接收到SIGINT信号的时候就不会进入这个函数里了,而是去执行操作系统默认的信号处理方式,也就是中断进程。

我们知道了捕获信号的方法,那么怎么把这个信号发送给其他的进程呢?
这里就需要通过kill()函数。这里顺便提一些kill()函数和raise()函数的区别,kill()函数可以把信号发送给其他进程,而raise则只能把信号发送给当前自己的进程。

直接看函数原型:
int kill(pid_t pid, int sig); 形参pid用来指定想要把信号发送到哪个进程,形参sig表示想要发送哪种信号。
int raise(int sig);这里只有形参sig,说明只能把信号发送给当前进程。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid;
    
    /* 创建一子进程 */
    if ((pid = fork()) < 0) {               
        printf("Fork error\n");
        exit(1);
    }

    if (pid == 0) {                         
        /* 在子进程中使用 raise()函数发出 SIGSTOP 信号,使子进程暂停 */
        printf("Child(pid : %d) is waiting for any signal\n\n", getpid());

        /** 对子进程发送暂停信号,所以子进程会停在这里 */
        raise(SIGSTOP);                   
        exit(0);
    }

    else {                                
        /** 等待一下,等子进程先执行 */
        sleep(1);
            /** 发送SIGKILL信号杀死子进程 */
            if ((kill(pid, SIGKILL)) == 0) {
                printf("Parent kill %d\n\n",pid);      
            }

        /** 一直阻塞直到子进程退出(杀死) */
        wait(NULL);            

        exit(0);
    }
}

(1) :fork启动一个子进程,如果返回值小于0(值为-1),则表示启动失败。
(2) :如果返回值为0,则表示此时运行的是子进程,打印相关信息。
(3) :在子进程中使用 raise()函数发出SIGSTOP信号,使子进程暂停。
(4) :而如果运行的是父进程,则等待一下,让子进程先执行。
(5) :调用kill()函数向子进程发送终止信号,子进程收到这个信号后会被杀死。
这里注意这个pid,可能会有人有疑问,这里不是在父进程中吗,应该是杀死父进程啊。这里就需要了解fork()函数产生一个新进程,对于子进程它的返回值为0,对于父进程它的返回值则是子进程的pid。所以是杀死子进程而不是父进程。
(6) :使用waitpid()函数回收子进程资源,如果子进程未终止,父进程则会一直阻塞等待,直到子进程终止。

系统支持的信号有62种,可以通过中断输入kill
-l命令去查看。信号值1-31的信号是非实时信号,34-64的信号为实时信号。这里的非实时信号的意思是这类信号不支持排队,如果发送多次相同的信号,进程只会收到一次,也只会处理一次。实时信号的话是支持排队的,发送了多少个信号给进程,进程就会处理多少次。

如果我们想把某个非实时信号设置为实时信号的话,那么就需要通过sigprocmask()函数来进行设置
同样我们通过下面的代码来理解:

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

void singal_handler(int sig)
{
        sigset_t set;
        printf("this singal is %d\n",sig);
        if(sig == SIGINT)
        {
        		/*清空信号集合,全为0*/
                sigemptyset(&set);
                /*设置SIGINT信号置1*/
                sigaddset(&set,SIGINT);
                /*解除SIGINT信号的屏蔽,从而使得SIGINT信号为实时信号*/
                /*其中形参SIG_UNBLOCK表示打开某个信号,SIG_BLOCK表示屏蔽某个信号。NULL表示不保存*/
                sigprocmask(SIG_UNBLOCK,&set,NULL);

                printf("I get the singal\n");
                sleep(3);
                printf("the signal is rebuiled\n");
        }
}

int main()
{
        signal(SIGINT,singal_handler);
        while(1)
        {
                printf("please input ctrl + C to stop\n");
                sleep(3);
        }
        return 0;
}

通过上述的代码,就可以把SIGINT信号由非实时信号变为实时信号,这个进程就可以多次获取和处理SIGINT信号了。

二、Sytem -V ipc

System -V 消息队列 (数据传输+进程控制)

什么是消息队列:
消息队列提供了一种从一个进程向另一个进程发送一个数据块的方法。 每个数据块都被认为含有一个类型,接收进程可以独立地接收含有不同类型的数据结构(其实就是一个结构体)。 我们可以通过发送消息来避免命名管道的同步和阻塞问题。

消息队列的特点:
1、独立于进程。
2、没有文件名和文件描述符。
3、具有唯一的key和ID。

消息队列与信号的对比:
信号承载的信息量少,而消息队列可以承载大量自定义的数据。

消息队列与管道的对比:
消息队列跟命名管道有不少的相同之处,它与命名管道一样,消息队列进行通信的进程可以是不相关的进程, 同时它们都是通过发送和接收的方式来传递数据的。在命名管道中,发送数据用write(),接收数据用read(), 则在消息队列中,发送数据用msgsnd(),接收数据用msgrcv(),消息队列对每个数据都有一个最大长度的限制。消息队列也可以独立于发送和接收进程而存在,在进程终止时,消息队列及其内容并不会被删除。

消息队列的用法:
1、定义一个唯一的key值
2、构造消息对象(megget()函数)
3、发送消息(msgsed()函数)
4、接收消息(msgrcv()函数)
5、删除消息队列(msgctl()函数)

下面直接看代码:

发送进程:

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

struct message
{
    long msg_type;
    char msg_data[100];
};
int main()
{
    int qid;
    struct message msg;

    /*构造消息对象*/
    if ((qid = msgget((key_t)1234, IPC_CREAT|0666)) == -1)
    {
        perror("msgget\n");
        exit(1);
    }

    printf("Open queue %d\n",qid);

    while(1)
    {
        printf("Enter some message to the queue:");

        /*利用fget()函数获取键盘输入的值*/
        if ((fgets(msg.msg_data, 100, stdin)) == NULL)
        {
            printf("\nGet message end.\n");
            exit(1);
        }

        /*把发送进程的pid赋值给msg_type这个结构体成员*/
        msg.msg_type = getpid();

        /*添加消息到消息队列*/
        if ((msgsnd(qid, &msg, strlen(msg.msg_data), 0)) < 0)
        {
            perror("\nSend message error.\n");
            exit(1);
        }
        else
        {
            printf("Send message.\n");
        }

        if (strncmp(msg.msg_data, "quit", 4) == 0)
        {
            printf("\nQuit get message.\n");
            break;
        }
    }

    exit(0);
}

发送程序分析:

1、调用msgget()函数创建/获取了一个key值为1234的消息队列,该队列的属性“0666”表示任何人都可读写,创建/获取到的队列ID存储在变量qid中。
2、调用msgsnd()函数把进程号以及用户输入的字符串,通过msg结构体添加到前面得到的qid队列中。
3、最后若用户发送的消息为quit,那么退出循环结束进程。

接收进程:

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

struct message
{
    long msg_type;
    char msg_data[100];
};

int main()
{
    int qid;
    struct message msg;
    
    /*创建消息队列*/
    if ((qid = msgget((key_t)1234, IPC_CREAT|0666)) == -1)
    {
        perror("msgget");
        exit(1);
    }

    printf("Open queue %d\n", qid);

    /* 当发送进程没发送字符串"quit"时,一直接收*/
    do
    {
        /* 清空字符串数组msg_data,读取消息队列*/
        memset(msg.msg_data, 0, 100);

        if (msgrcv(qid, (void*)&msg, 100, 0, 0) < 0)
        {
            perror("msgrcv");
            exit(1);
        }

        printf("The message from process %ld : %s", msg.msg_type, msg.msg_data);

    } while(strncmp(msg.msg_data, "quit", 4));

    /*从系统内核中删除消息队列 */
    if ((msgctl(qid, IPC_RMID, NULL)) < 0)
    {
        perror("msgctl");
        exit(1);
    }
    else
    {
        printf("Delete msg qid: %d.\n", qid);
    }

    exit(0);

}

接收程序分析:

1、调用msgget()函数创建/获取队列qid。可以注意到,此处跟发送进程是完全一样的,无论哪个进程先运行, 若key值为1234的队列不存在则创建。
2、在循环中调用msgrcv()函数接收qid队列的msg结构体消息,此处使用阻塞方式接收, 若队列中没有消息,会停留在本行代码等待。
3、若前面接收到用户的消息为quit,会退出循环,在本行代码调用msgctl()删除消息队列并退出本进程。

System -V 信号量 (资源共享+进程控制)

System -V 信号量的本质是一个计数器,用于协调多进程对共享数据对象的读取,主要是用来保护共享资源,使得该资源在一个时刻只有一个进程独享。

信号量的工作原理:
信号量只能进行两种操作:P和V操作,锁行为就是P操作(信号量减1),解锁就是V操作(信号量加1),也可以理解为,P操作是申请资源,V操作是释放资源。

举个例子,就是两个进程共享信号量sem,sem可用信号量的数值为1(资源数为1),一旦其中一个进程执行了P操作,它将得到信号量, 并可以进入临界区,使sem减1。而第二个进程将被阻止进入临界区,因为当它试图执行P操作时,sem为0, 它会被挂起以等待第一个进程离开临界区域并执行V操作释放了信号量,这时第二个进程就可以恢复执行。

信号量的用法:
1、定义一个唯一的key
2、构造一个信号量(semget)
3、初始化集合中的信号量(semctl SETVA)
4、定义信号量的P/V操作(semop)
5、删除信号集(semctl RMID)

比如:在程序中定义了一个信号量sem,并对信号量进行初始化为0,然后fork一个进程,此时父进程进行P操作,而子进程延时10s后进行V操作,如果没有信号量的话,照道理是父进程先执行,但是这里因为有信号量,并且父进程要对信号量进行P操作,由于此时信号量为0,所以父进程堵塞,直到信号量大于0。而延时10s后,子进程对信号量的V操作,也就是加1了之后,sem值为1,父进程才能执行P操作。
最终结果是使父进程在子进程释放信号量后才运行,模拟了一个进程创建资源,一个进程等待资源的协调过程。

有关信号量的代码请参考野火发布在gitee的内容,链接如下:
https://gitee.com/uncommon_ljl/embed_linux_tutorial/tree/master/base_code/system_programing/systemV_sem

System -V 共享内存 (数据传输 这里的数据传输相对于其他的ipc读写效率高很多)

System -V 共享内存 的作用是实现高效率传输大量数据。
有个缺点就是共享内存无同步无互斥,需要借助信号量来进行进程间的同步工作。(这里的同步指的是多个不同的进程按照我们代码中所设置的顺序来访问同一内存)

System -V 共享内存的实现原理:
进程与进程之间的虚拟内存是相互独立,不能互相访问的,但是我们可以对实际物理内存的进行映射,把一块物理内存映射到两个不同进程的虚拟内存,这样就可以实现两个进程之间的内存共享。

共享内存的用法:
1、定义一个唯一的key
2、构造一个共享内存对象(shmget()函数)
3、共享内存映射(shmat()函数)
4、解除共享内存映射(shmdt()函数)
5、删除共享内存映射(shmctl ()函数)

有关共享内存的代码请参考野火发布在gitee的内容,链接如下:
read程序:
https://gitee.com/uncommon_ljl/embed_linux_tutorial/tree/master/base_code/system_programing/shm_read
write程序:
https://gitee.com/uncommon_ljl/embed_linux_tutorial/tree/master/base_code/system_programing/shm_write

PS:由于我学习进程相关的知识都是学习野火的教程,所以本文很多部分都是参考野火的教程,如果有同学对本文不理解或者想更加深入理解的话,可以去看野火原版教程,教程链接点我
特别说明:本文仅用来记录我的学习过程和理解,不做商用。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

我不是小白菜

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

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

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

打赏作者

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

抵扣说明:

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

余额充值