Linux进程间通信(IPC):System V消息队列(《Unix高级编程》《Unix网络编程-卷2》学习总结)

目录

一、进程间通信(InterProcess Communication, IPC)

1.1 Unix进程间共享信息的三种方式

1.2 IPC对象的持续性

1.2.1 定义

1.2.2 IPC持续性类型

1.2.3 各种IPC的持续性分类

二、XSI IPC

三、XSI IPC的共同特征:标识符和键

3.1 标识符

3.2 键(Key)

3.3 struct ipc_perm结构体

四、消息队列理论基础

4.1 消息队列的4个功能函数

1) msgget函数:创建一个新的消息队列或引用一个现有消息队列

2) msgsnd函数:将消息加入到消息队列尾部。

3) msgrcv函数:从消息队列中取出(并从队列中删除)消息。

4)msgctl函数:实现对消息队列的各种操作控制

五、消息队列代码实例

5.1 程序代码

5.2 程序运行截图


一、进程间通信(InterProcess Communication, IPC)

经典的IPC可分为五种,分别是:无名管道(管道)有名管道(FIFO)消息队列信号量共享内存(共享存储)。另外还有一种网络进程间通信(network IPC):套接字(Socket)。

对于消息队列信号量共享内存这三种IPC,根据Posix(Portable Operating System Interface, 可移植操作系统接口)和System V两种不同的标准支持,又可以分为两个大类:一种是Posix IPC,包括:Posix消息队列、Posix信号量、Posix共享内存;另一种是 Systme V IPC又称XSI IPC),包括:System V消息队列、System V信号量、System V共享内存

另外这些IPC又可以根据其功能特点,分为:用于消息传递的IPC用于同步的IPC。用于消息传递的IPC用于在进程之间传递消息,包括:无名管道、有名管道、消息队列(包括Posix消息队列和System V消息队列)、套接字;用于同步的IPC用于对共享信息的进程实现同步控制,包括:信号量(包括Posix信号量和System V信号量)、共享内存(Posix共享内存和System V共享内存),另外还有互斥锁、条件变量、读写锁、记录上锁。

几种IPC的分类也可以参考:https://www.cnblogs.com/Jimmy1988/p/7553069.html

(本文只介绍System V的消息队列)

1.1 Unix进程间共享信息的三种方式

传统的Unix编程模型中,一个系统上运行多个进程,每个进程都有各自的地址空间,进程间信息共享的方式可分为以下三类:

其中,最左边的两个进程共享文件系统中某个文件上的某些信息。为访问这些信息,进程必须穿越内核(read、write、lseek等函数)。这种方式下,档文件更新时,对共享的文件进行某种形式同步显然时必要的,以防止多个写入者相互串扰,也防止多个读者对写入者的干扰。

中间的两个进程共享内核中的某些信息。比如无名管道、有名管道、System V消息队列、System V信号量。它们每次访问共享信息的操作,都会对内核进行一次系统调用。

最右边两个进程,有一个双方都能访问的共享内存,每个进程一旦设置好共享内存区,进行对该共享内存区的访问就不会涉及到内核。当然,为了保护该内存区的临界资源,访问该共享内存区的所有进程都需要某种形式的同步。

1.2 IPC对象的持续性

1.2.1 定义

任意类型IPC的持续性(persistence)定义为:该类型IPC的一个对象持续存在的时间。(个人认为可理解为IPC对象的生命周期)

1.2.2 IPC持续性类型

根据这个定义,IPC按照持续性不同可分为以下三类:

(1)随进程持续的(process-persistent):IPC对象一直存在,直到打开着该对象的最后一个进程关闭该对象为止。比如有名管道和无名管道。

(2)随内核持续的(kernel-persistent):IPC对象一直存在,直到内核重新自举或显示删除该对象为止。比如System V消息队列、信号量、共享内存。

(3)随文件系统持续的(filesystem-persistent):IPC对象一直存在,直到显示删除该对象为止。由于它存在于文件系统,所以即使内核重新自举,对象还是保持其值。Posix的消息队列、信号量、共享内存必须至少是随内核持续的,但是如果是使用映射文件实现的,那他们就是随文件系统持续的。

要注意的是:虽然无名管道的数据是在内核中维护的,但是它是随进程持续的,而不是随内核持续的,因为最后一个将某个无名管道打开着用于读的进程关闭该管道后,内核将丢弃所有的数据,并删除该管道。类似的,有名管道在文件系统中有名字,但也只是随进程持续的

1.2.3 各种IPC的持续性分类

各种IPC对象的持续性总结:

(1)随进程持续的:无名管道、有名管道、套接字(包括TCP套接字、UDP套接字、Unix域套接字)

(2)随内核持续的:System V消息队列、System V信号量、System V共享内存;(不使用映射文件实现的)Posix消息队列、Posix信号量、Posix共享内存。

(3)随文件系统持续的:(使用映射文件实现的)Posix消息队列、Posix信号量、Posix共享内存

除了这些传统意义的IPC,还有如Posix互斥锁、Posix条件变量、Posix读写锁、fcntl记录上锁、Posix基于内存的信号量,这些都是随进程持续的。


二、XSI IPC

基于System V的消息队列、信号量、共享内存有很多相似之处,被称作XSI IPC。这三种类型的XSI IPC源于1970年一种被称为“Columbus UNIX”的AT&T内部版,后来被添加到System V上。所以有时也被称为System V进程间通信方式。

XSI IPC和非XSI IPC对比,XSI IPC存在三个基本问题:(1)它们是在系统范围内起作用的,没有引用计数。(2)它们在文件系统中没有名字。(3)它们不使用文件描述符,所以不能对它们使用多路转接IO函数(select poll)。

对于问题(1):比如,一个进程在创建消息队列后,加入了几个消息,然后终止进程。消息队列及其内容不会被删除,它们会一直留在系统中直至发生某个进程调用msgrcv读消息,或调用msgctl删除消息队列。而管道这种非XSI IPC不会这样,当最后一个引用管道的进程终止时,管道就被完全删除了。

对于问题(2):比如,无名管道和有名管道可以通过命名对其进行访问和修改(int fd[2]; pipe(fd); write(fd[1],”hello world”,12);),而XSI IPC没有名字,所以为了对XSI IPC提供支持,内核中增加了十几个全新的系统调用(msgget, semop, shmat等)。另一方面,我们不能用ls、rm、chmod对它们进行操作,所以增加了ipcs(1),ipcrm(1)两个新命令。

对于问题(3):它们很难使用一个以上XSI IPC结构,或者在文件、设备IO中使用XSI IPC结构。比如,如果没有某种形式的忙等待,就不使一个服务器进程,等待将要放在两个消息队列中任意一个中的消息。(暂时没懂)


三、XSI IPC的共同特征:标识符和键

3.1 标识符

每个XSI IPC结构都用一个标识符(identifier)加以引用,标识符为非负整数。标识符是IPC对象的内部名,所以不能使合作进程通过标识符进行联系。

3.2 键(Key)

每个XSI IPC结构都与一个键相关联,键作为IPC对象的外部名,可以使合作进程在其对象上进行汇聚。键的数据类型为key_t,通常在<sys/type.h>中定义为长整形。在通过XSI IPC创建函数(msgget、semget、sheget)创建IPC结构时,都应该制定一个键。

键通常由内核变换成标识符使客户进程和服务器进程在同一XSI IPC结构上汇聚,即键的创建方法,一般有以下几种:

  • IPC_PRIVATE创建新的XSI IPC结构:如果不是父子或亲缘关系的进程,可以通过进程(通常是服务器进程)指定键IPC_PRIVATE创建一个新的XSI IPC结构,将返回的标识符存放在一个文件中,供其它进程(通常是客户进程)取用。这种技术的缺点是:文件系统操作需要服务器进程将标识符写到文件中,客户进程还要从文件中读取标识符。比较麻烦。当然,如果是父子进程,父进程直接指定IPC_PRIVATE创建一个IPC结构,返回标识符后,可供fork后的子进程使用,如果想延续到其它进程,还可以通过exec函数的一个参数,传给另一个新程序。
  • 可以在一个客户进程和服务器进程,公用的一个头文件中,定义键。然后服务器进程制定此键创建一个新的XSI IPC结构。这种方法的缺点是:该键可能已经与一个XSI IPC结构结合,要排除这种错误,可以设定get函数出错返回时,删除该键关联的IPC结构,然后重新尝试利用该键创建XSI IPC结构。
  • 服务器进程和客户进程认同一个路径名和项目ID(0~255),接着,调用ftok函数(key_t ftok(const char *path, int id);其中,path参数必须引用一个现有的文件),将两个值变为一个键。然后用方法(2)使用此键。

3.3 struct ipc_perm结构体

定义在<sys/msg.h>,规定了权限和所有者,定义如下:

struct ipc_perm {
    key_t          __key;       /* Key supplied to msgget(2) */
    uid_t          uid;         /* Effective UID of owner */
    gid_t          gid;         /* Effective GID of owner */
    uid_t          cuid;        /* Effective UID of creator */
    gid_t          cgid;        /* Effective GID of creator */
    unsigned short mode;        /* Permissions */
    unsigned short __seq;       /* Sequence number */
};

创建XSI IPC时,要对ipc_perm结构体结构体的所有字段赋初值,之后可通过调用msgctl、semctl、shmctl函数修改uid、gid、mode字段,但调用进程者必须是IPC结构的创建者或者超级用户!


四、消息队列理论基础

  1. 消息队列是消息的链接表,存储在内核中,由消息队列标识符(常简称为队列ID)标识。
  2. 每个队列都有一个msqid_ds结构体(定义在<sys/msg.h>)与其关联。由于开发者并不需要将队列中的消息作为一个链表来维护,所以Unix98不要求有msg_first、msg_last、msg_cbytes成员,然后普通的源自System V的实现中都可以找到这三个成员。
//  /usr/include/linux下查看
struct msqid_ds {
	struct ipc_perm msg_perm;
	struct msg *msg_first;		/* first message on queue,unused  */
	struct msg *msg_last;		/* last message in queue,unused */
	__kernel_time_t msg_stime;	/* last msgsnd time */
	__kernel_time_t msg_rtime;	/* last msgrcv time */
	__kernel_time_t msg_ctime;	/* last change time */
	unsigned long  msg_lcbytes;	/* Reuse junk fields for 32 bit */
	unsigned long  msg_lqbytes;	/* ditto */
	unsigned short msg_cbytes;	/*当前队列字节数*/
	unsigned short msg_qnum;	/*当前队列中的消息数*/
	unsigned short msg_qbytes;	/*队列允许的最大字节数*/
	__kernel_ipc_pid_t msg_lspid;	/* pid of last msgsnd */
	__kernel_ipc_pid_t msg_lrpid;	/* last receive pid */
};

4.1 消息队列的4个功能函数

msgget用于新建或打开消息队列;msgsnd用于向消息队列尾添加消息;msgctl用于对消息队列执行一些操作,比如读取/设置msqid_ds结构体中的数据,类似于ioctl函数;msgrcv用于从消息队列中取消息。函数介绍如下:

1) msgget函数:创建一个新的消息队列或引用一个现有消息队列

函数头文件:#include<sys/msg.h>

函数原型:int msgget(key_t key, int msgflg)

参数说明:key为键,msgflg为权限标志。

在创建新的消息队列(通常由服务器进程创建)时,如果key指定为IPC_PRIVATE或者key是自己指定的一个和当前已有的任一类型IPC结构无关的值,则需要指明msgflg的IPC_CREAT标志位。如:

msgid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
msgid = msgget((key_t)1234, 0666 | IPC_CREAT);

创建消息队列时,参数msgflg可以指定O_CREAT或O_CREAT|O_EXCL。区别是:

当参数key标识的消息队列已经存在时,调用msgget()函数如果参数msgflg没有指定O_EXCL,msgget()将不创建消息队列也不返回错误;如果参数msgflg指定了O_EXCL,errno设置为EEXIST,msgget()函数失败返回错误。(这类似于open的O_CREAT | O_EXCL组合的效果)

当然,当参数key标识的消息队列不存在时,无论参数msgflg指定的是O_CREAT还是O_CREAT|O_EXCL,都会正常创建新的消息队列。

msgid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT | IPC_EXCL);

在引用一个现有消息队列(通常由客户端进程创建)时,key必须等于该消息队列创建时指明的key值,并且IPC_CREAT必须不指明。即,IPC_PRIVATE作为键,不能指定其引用一个现有的队列,IPC_PRIVATE只能用于创建新队列。

返回值:成功返回消息队列标识符(非负整数),出错返回-1。

其它说明:如果是创建新队列,msgid-ds结构的下列成员将被初始化:(1)msg_qnum、msg_lspid、msg_lrpid、msg_stime、msg_rtime设置为0。(2)msg_ctime设置为当前时间。(3)msg_qbytes设置为系统限制值。

2) msgsnd函数:将消息加入到消息队列尾部。

函数头文件:#include<sys/msg.h>

函数原型:int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

参数说明:msqid指向消息要加入的队列;msgp是struct msgbuf(见“其它”说明)结构的指针,struct msgbuf结构中含有消息(mtext)及消息的类型(mtype)成员,让msgp指向一个struct msgbuf声明的地址中,该地址中的消息就是要加入消息队列的消息;msgsz为消息的长度,单位为字节,如果为0,则无消息数据。msgflag和阻塞处理有关,其值可以是0(阻塞),也可以是IPC_NOWAIT(非阻塞),具体见“其它”说明。

返回值:成功返回0;出错返回-1。

其它说明:

(1)msgp和msgsz与结构struct msgbuf相关。struct msgbuf结构体:

struct msgbuf {
    long mtype;       /* message type, must be > 0 消息类型*/
    char mtext[64];    /* message data 消息数据*/
};

msgp指向该结构,msgsz为msgbuf.mtext的长度,单位为字节。

(2)参数msgflg相关:

msgflg可以指定为IPC_NOWAIT,这类似于文件I/O的非阻塞I/O标志。若指定IPC_NOWAIT,在消息队列已满(或队列中的消息总数、字节总数等于系统限制值)时,msgsnd立即返回EAGAIN错误。

如果没有指定IPC_NOWAIT,即msgflg指定为0,则进程一直阻塞到有空间容纳消息,或者系统中删除了此队列(此时会返回EIDRM错误,表示队列标识符被删除),或者捕捉到一个信号并从信号处理程序返回(返回EINTR错误)。

(3)msgsnd返回成功时,消息队列msqid_ds结构会随之更新,主要是msg_stime、msg_qnum、msg_lspid。

3) msgrcv函数:从消息队列中取出(并从队列中删除)消息。

函数头文件:#include<sys/msg.h>

函数原型:ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

参数说明:msqid指向取用消息的队列;msgp是struct msgbuf结构的指针,指向用于暂存消息的缓存区;msgsz为缓存区的数据部分(msgbuf.mtext)的长度。msgtyp取用什么类型的消息。msgflg和msgsnd函数的msgflg类似,在这里指定了所请求类型的消息不在所指定的消息队列中该如何处理。

返回值:成功返回消息数据部分(msgbuf.mtext)的长度;出错返回-1。

其它说明:

(1)如果消息队列中,消息数据部分的长度大于msgsz,分两种情况:一是在flag中设置了MSG_NOERROR位,则该消息会被截断(被截断部分的消息数据直接丢失,不通知);如果没有设置MSG_NOERROR位,返回E2BIG错误。

(2)参数msgtyp:

msgtyp==0:返回队列中第一个消息。msgtyp不等于0时,用于非先进先出次序读取消息:

msgtyp>0:返回队列中消息类型为msgtyp的第一个消息;

msgtyp<0:返回队列消息类型值小于等于msgtyp绝对值的消息,如果这种消息有若干个,返回类型值最小的消息。

(3)参数msgflg:和msgsnd函数的msgflg类似。若指定IPC_NOWAIT,在没有指定的消息类型可用时,msgrcv立即返回-1,error设置为ENOMSG错误。如果没有指定IPC_NOWAIT,则进程一直阻塞,直到有指定类型的消息可用,或者直到从系统中删除了此队列(msgrcv返回-1,errno设置为EINTR),或者直到捕捉到一个信号并从信号处理程序返回(msgrcv返回-1,errno设置为EINTR)。

(4)msgrcv返回成功时,消息队列msqid_ds结构会随之更新,主要是msg_rtime、msg_qnum、msg_lrpid。

4)msgctl函数:实现对消息队列的各种操作控制

函数头文件:#include<sys/msg.h>

函数原型:int msgctl(int msqid, int cmd, struct msqid_ds *buf)

参数说明:msqid指向要操作的队列,cmd为要执行的命令。

返回值:成功返回0,出错返回-1。

其它说明:

cmd命令解释:

(1)IPC_STAT:取此队列的msqid_ds结构,并将它存放在buf指向的结构中。

(2)IPC_SET:将字段msg_perm.uid,msg_perm.gid、msg_perm.mode、msg_qbytes从buf指向的结构复制到与这个队列相关的msqid_ds结构中。此命令只能由下列两种进程执行:1、有效用户ID等于msg_perm.cuid或msg_perm.uid;2、具有超级用户特权的进程。另外,只有超级用户才能增加msg_qbytes的值。

(3)IPC_RMID:从系统中删除该消息队列,及队列中的所有数据。删除后,如果有其他进程试图对此队列进行操作,则会得到EIDRM错误。此命令只能由下列两种进程执行:1、有效用户ID等于msg_perm.cuid或msg_perm.uid;2、具有超级用户特权的进程。C语言中,可以通过system("ipcs -q -p");语句查看当前系统内核中的消息队列。

五、消息队列代码实例

父进程从标准输入获得消息,将消息加入消息队列后,供子进程从消息队列中读取。要在当前工作目录中建一个ftok_msg2的文件(touch ftok_msg2),供ftok()函数生成消息队列标识符(见本文3.2节)。本例程在参考例程基础上更改得到。

参考例程:https://blog.csdn.net/qq_40425540/article/details/80105517

5.1 程序代码

/*************************************************************************
	> File Name: 1_msg.c
	> Author: hank
	> Mail: 34392195@qq.com 
	> Created Time: 2020年07月19日 星期日 20时45分26秒
 ************************************************************************/

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

#define SIZE_MYTEXT 64

#define DISPLAY_INFO 1

const long msgtype_1 = 0x01;/*消息队列数据类型*/

/*消息队列,消息结构体*/
typedef struct msg_node
{
	long msg_type;
	char msg_data[SIZE_MYTEXT];
}msg_node;


int show_msgattr(int qid)
{
	struct msqid_ds buf_msgattr;

	if(msgctl(qid, IPC_STAT, &buf_msgattr) == -1)
	{
		printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);//-->stdout
		perror("'msgctl()' is error");//-->stderr
		return -1;
	}

	printf("\n***information of message %d***\n", qid);
	printf("msg_stime:%s", ctime(&(buf_msgattr.msg_stime)));
	printf("msg_rtime:%s", ctime(&(buf_msgattr.msg_rtime)));
	printf("msg_ctime:%s", ctime(&(buf_msgattr.msg_ctime)));
	printf("msg_qnum:%lu\n", buf_msgattr.msg_qnum);
	printf("msg_perm.uid:%lu\n", (long unsigned int)buf_msgattr.msg_perm.uid);
	printf("***     information end     ***\n\n");

	return 0;
}



int main(int argc, char* argv[])
{
	//1、通过frok()函数获得消息队列的键值
	key_t msg_key;
	if((msg_key = ftok("./ftok_msg2",1)) < 0)
	{
		printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);//-->stdout
		perror("'ftok()' is error");//-->stderr
		return -1;
	}
	printf("msg_key=%d\n", msg_key);
	

	//2、msgget()函数新建队列,队列标识符msg_id由键msg_key通过msgget()函数获得。
	//这里,同时指定了IPC_CREATE和IPC_EXCL,所以,如果键值存在,创建失败,那么将直接返回,且errno设置为EEXIST。
	int msg_id;

#if DISPLAY_INFO
	system("ipcs -q -p");//显示队列信息以及进程id
	puts("#Create msg now...");
#endif
	if((msg_id = msgget(msg_key, IPC_CREAT|IPC_EXCL|0666)) < 0)
	{
		printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);
		perror("'msgget()' is error");
		return -1;
	}
#if DISPLAY_INFO
	system("ipcs -q -p");//显示队列信息以及进程id
	show_msgattr(msg_id);//显示msg_id的队列信息
#endif


	//3、fork()开子进程,父进程从标准stdin输入数据,装入消息队列。子进程从消息队列中取出数据,输出到stdout。
	pid_t pid;
	if((pid = fork()) < 0)
	{
		printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);
		perror("'fork()' is error");
		return -1;
	}
	else if(pid > 0)//父进程
	{
		msg_node tempnode_msg_send;//发送消息的 消息队列节点。

		tempnode_msg_send.msg_type = msgtype_1;//1、消息队列节点的“消息类型”
		char temp_msg_data[SIZE_MYTEXT];

		while(1)
		{
			printf("father says:");
			//2、消息队列节点的“消息数据”
			fgets(temp_msg_data, SIZE_MYTEXT-1, stdin);//从stdin中最大读取SIZE_MYTEXT-1个字节,留一个字节由系统自动保存'\0'。
			strcpy(tempnode_msg_send.msg_data,temp_msg_data);
			
			if(msgsnd(msg_id, &tempnode_msg_send, sizeof(tempnode_msg_send.msg_data), 0) == -1)
			{
				printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);
				perror("'msgsnd()' is error");
				return -1;
			}
			//这里可以查看以下msgsnd后,更改的msqid_ds结构,比如msg_stime、msg_qnum、msg_lspid
			if(strncmp(tempnode_msg_send.msg_data,"end",3) == 0)
				break;  //跳出while循环
			usleep(500000);//500ms
		}//while

		waitpid(pid, NULL, 0);//等待子进程
	}
	else
	{//pid = 0。子进程
		msg_node tempnode_msg_recv;//要接收到消息

		while(1)
		{
   			if(msgrcv(msg_id, &tempnode_msg_recv, sizeof(tempnode_msg_recv.msg_data), msgtype_1, 0) == -1)
  			{
  
  				printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);
  				perror("'msgrcv()' is error");
  				return -1;
  			}
  			if(strncmp(tempnode_msg_recv.msg_data,"end",3) == 0)
  			{
  				printf("father will quit, i quit too\n");
  				break;
  			}
			printf("child recv:%s\n", tempnode_msg_recv.msg_data);
		}
		exit(EXIT_SUCCESS); //子进程退出
	}
#if DISPLAY_INFO
	show_msgattr(msg_id);//显示msg_id的队列信息
	system("ipcs -q -p");//显示队列信息以及进程id
	puts("Remove msg now...");
#endif
	if(msgctl(msg_id, IPC_RMID, NULL) < 0)//删除消息队列
	{
		printf("[%s : %s] line:%d\n",__FILE__,__func__,__LINE__);
		perror("'msgrtl()' is error");
		return -1;
	}
#if DISPLAY_INFO
	system("ipcs -q -p");//显示队列信息以及进程id
#endif

	printf("\ndone! \n");
	return 0;
}

5.2 程序运行截图

1、创建消息队列时:

2、父子进程交互时:

3、子进程和父进程相继退出时:

本文为自学记录文章,主要参考《Unix环境高级编程》和《Unix网络编程(卷2)》,如有错误,还望提点。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值