Linux进程间通信之管道和内存映射

一、进程间通信相关概念

1、什么是进程间通信

Linux环境下,每个进程的虚拟地址空间是相互独立的,每个进程都各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都访问不到,所以导致进程与进程之间不能直接相互访问。如果要交换数据必须要通过内核,在内核开辟一块缓冲区,进程1先把数据从用户空间拷贝到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制就叫做进程间通信(IPC,InterProcessCommunication)。

图示:

在这里插入图片描述

2、进程间通信的方式

在进程间完成数据传递需要借助操作系统提供的一些特殊方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等 。随着计算机的蓬勃发展,一些方法由于自身设计的缺陷被淘汰或者弃用。现如今常见的通信方式有:

  • 管道(使用最简单)
  • 信号(开销最小)
  • 共享映射区(无血缘关系)
  • 本地套接字(最稳定)

二、管道 pipe

1、管道的概念

管道是一种最基本的IPC通信机制,也可称为匿名管道,应用于有血缘关系的进程之间,完成数据传递。调用 pipe 函数即可创建一个管道。

在这里插入图片描述

  • 管道的特点:
    • 管道的本质其实是一块内核缓冲区。
    • 由两个文件描述符引用,一个表示读端,一个表示写端。
    • 规定数据从管道的写端流入管道,从管道的读端流出管道。
    • 当两个进程都终止时,管道也自动消失。
    • 管道的写端和读端都是默认阻塞的。

2、管道的原理

  • 管道的实质是内核缓冲区,内部使用环形队列来实现。
  • 默认缓冲区大小为 4k ,可以使用 ulimit -a 命令来查看大小。
  • 实际操作过程中缓冲区大小会根据数据压力做适当调整。

3、管道的局限性

  • 数据一旦被读走,就从管道中消失了,不能进行反复读取。
  • 数据在一个管道内只能在一个方向上流动,如果要实现双向流动,必须使用两个管道。
  • 只能在有血缘关系的进程间使用管道。

4、创建管道 pipe 函数

  • 函数描述:创建一个管道
  • 函数原型:int pipe(int fd[2]);
  • 函数参数:如果函数调用成功,f[0]存放的是管道的读端文件描述符,fd[1] 存放的是管道的写端文件描述符
  • 函数返回值:
    • 成功:返回 0
    • 失败:返回 -1,并设置 errno值

注意:

函数调用成功后返回读端和写端的文件描述符,其中 fd[0] 是读端,fd[1] 是写端,向管道里读写数据是使用这两个文件描述符来进行的,读写管道的实质是操作内核缓冲区。

5、父子进程使用管道进行通信

  • 管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端。那么如何实现父子进程间通信呢?

  • 一个进程在由 pipe() 创建管道后,一般再 fork() 出一个子进程,然后通过管道实现父子进程间的通信。因此可以看出,只要两个进程间存在血缘关系,这里的血缘关系是具有相同的祖先,都可以采用管道的方式进行通信。父子进程间具有相同的文件描述符,并且指向同一个管道 pipe,其他没有关系的进程不能获得 pipe() 产生的两个文件描述符,也就不能利用同一个管道进行通信。

  • 父子进程间使用管道通信的步骤:

    • 第一步:父进程创建管道:

      在这里插入图片描述

    • 第二步:父进程 fork 出子进程:

      在这里插入图片描述

    • 第三步:父进程关闭 fd[0](读端),子进程关闭 fd[1] (写端):

      在这里插入图片描述

创建步骤总结:

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

2、父进程调用 fork() 创建子进程,那么子进程也就有两个文件描述符并且指向同一个管道。

3、父进程关闭管道的读端,子进程关闭管道的写端。这样就得到了一个由父进程在管道的写端写入数据,子进程在管道的读端读出数据,进而实现父子进程间的通信。

  • 使用管道完成父子进程间通信

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <sys/wait.h>
    
    int main()
    {
    	//创建管道
    	//int pipe(int pipefd[2]);
    	int fd[2];
    	int ret = pipe(fd);
    	if(ret<0)
    	{
    		perror("pipe error");
    		return -1;
    	}
    
    	//创建子进程
    	pid_t pid = fork();
    	if(pid<0) 
    	{
    		perror("fork error");
    		return -1;
    	}
    	else if(pid>0) //父进程
    	{
    		//关闭读端
    		close(fd[0]);
    		sleep(5);
    		write(fd[1], "hello world", strlen("hello world"));	
    
    		wait(NULL);
    	}
    	else //子进程
    	{
    		//关闭写端
    		close(fd[1]);
    		
    		char buf[64];
    		memset(buf, 0x00, sizeof(buf));
    		int n = read(fd[0], buf, sizeof(buf));
    		printf("read over, n==[%d], buf==[%s]\n", n, buf);
    	
    	}
    
    	return 0;
    }
    
    
  • 父子进程间通信,实现ps aux | grep bash

    使用 execlp 函数和 dup2 函数

    //使用pipe完成ps aux | grep bash操作
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <sys/wait.h>
    
    int main()
    {
    	//创建管道
    	//int pipe(int pipefd[2]);
    	int fd[2];
    	int ret = pipe(fd);
    	if(ret<0)
    	{
    		perror("pipe error");
    		return -1;
    	}
    
    	//创建子进程
    	pid_t pid = fork();
    	if(pid<0) 
    	{
    		perror("fork error");
    		return -1;
    	}
    	else if(pid>0)
    	{
    		//关闭读端
    		close(fd[0]);
    
    		//将标准输出重定向到管道的写端
    		dup2(fd[1], STDOUT_FILENO);
    		
    		execlp("ps", "ps", "aux", NULL);
    
    		perror("execlp error");
    	}
    	else 
    	{
    		//关闭写端
    		close(fd[1]);
    	
    		//将标准输入重定向到管道的读端
    		dup2(fd[0], STDIN_FILENO);
    
    		execlp("grep", "grep", "--color=auto", "bash", NULL);
    
    		perror("execlp error");
    	}
    
    	return 0;
    }
    
    

    原理图

    在这里插入图片描述

6、管道的读写行为

  • 读操作

    • 有数据
      • read 正常读,返回读出的字节数
    • 无数据
      • 写端全部关闭
        • read 解除阻塞,返回0,相当于读文件读到了尾部
      • 写端没有全部关闭
        • read 阻塞
  • 写操作

    • 读端全部关闭

      • 管道破裂,进程终止,内核给当前进程发 SIGPIPE 信号
    • 读端没有全部关闭

      • 缓冲区写满了

        write 阻塞

      • 缓冲区没有写满

        继续 write

7、设置管道为非阻塞

管道在默认情况下,读端和写端都是阻塞的,如果要设置读端或写端为非阻塞,则可参考以下三个步骤进行:

  1. int flags = fcntl(fd[0], F_GETFL, 0);

  2. flags |= O_NONBLOCK;

  3. fcntl(fd[0], F_SETFL, flags);

    如果是读端设置为非阻塞:

    • 写端没有关闭,管道中没有数据可读,则read 返回 -1
    • 写端没有关闭,管道中有数据可读,则read 返回实际读到的字节数
    • 写端已经关闭,管道中有数据可读,则read 返回实际读到的字节数
    • 写端已经关闭,管道中没有数据可读,则read 返回0

8、查看管道中缓冲区的大小

  • 命令:

    ulimit -a

  • 函数

    long fpathconf(int fd, int name);

    printf(“pipe size == [%ld]\n”,fpathconf(fd[0],_PC_PIPE_BUF));

    printf(“pipe size == [%ld]\n”,fpathconf(fd[1],_PC_PIPE_BUF));

练习:将管道的读端设置为非阻塞,并且查看管道缓冲区的大小

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

int main()
{
	//创建管道
	//int pipe(int pipefd[2]);
	int fd[2];
	int ret = pipe(fd);
	if(ret<0)
	{
		perror("pipe error");
		return -1;
	}
	printf("pipe size==[%ld]\n", fpathconf(fd[0], _PC_PIPE_BUF));
	printf("pipe size==[%ld]\n", fpathconf(fd[1], _PC_PIPE_BUF));

	//close(fd[0]);
	//write(fd[1], "hello world", strlen("hello world"));	

	//关闭写端
	close(fd[1]);

	//设置管道的读端为非阻塞
	int flag = fcntl(fd[0], F_GETFL);
	flag |= O_NONBLOCK;
	fcntl(fd[0], F_SETFL, flag);

	char buf[64];
	memset(buf, 0x00, sizeof(buf));
	int n = read(fd[0], buf, sizeof(buf));
	printf("read over, n==[%d], buf==[%s]\n", n, buf);

	return 0;
}

三、FIFO

1、FIFO的概念

FIFO常被称为命名管道,以便于区分管道(pipe),管道(pipe)只能用于有“血缘关系”的进程间通信。但是通过FIFO,不相关的进程也能进行数据间交互。

FIFO是Linux基础文件类型中的一种(文件类型为p,可以通过ls -l 查看文件类型)。但是FIFO文件在磁盘上没有数据块,文件大小为0,仅仅用来标识内核中的一条通道。进程可以打开这个文件进行read/write,实际上是在读写内核缓冲区,这样就实现了进程间通信。

2、创建管道

  • 方式1 使用命令 mkfifo

    • 命令格式:mkfifo 管道名

      例如:mkfifo myfifo

  • 方式2 使用函数 mkfifo()

    • 函数原型:int mkfifo(const char *pathname, mode_t mode);

    当创建了一个FIFO,就可以使用 open 函数来打开他,常见的 IO 函数都可以用于 FIFO。如:close()、read()、write()、unlink()等。

    FIFO 严格遵循先进先出(first in first out) ,对FIFO的读总是从开始处返回数据,对它们的写则是把数据添加到末尾。他们不支持像lseek() 等文件定位操作。因为它相当于是一个容器队列,只能先进先出,不能插入等操作。

3、使用FIFO完成两个进程间通信

示意图

在这里插入图片描述

  • 思路:

    • 进程A:
      1. 创建一个 FIFO 文件:myfifo
      2. 调用 open 函数打开 myfifo 文件
      3. 调用 write 函数写入一个字符串如:“hello world"(其实将数据写入到了内核缓冲区,myfifo 文件大小为0)
      4. 调用 close 函数关闭 myfifo 文件
    • 进程B:
      1. 调用 open 函数打开 myfifo 文件
      2. 调用 read 函数读取文件内容(其实就是从内核缓冲区读取数据)
      3. 打印读取到的内容
      4. 调用 close 函数关闭 myfifo 文件
  • 实现:

    -进程A

    //fifo完成两个进程间通信的测试
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    
    int main()
    {
    	//创建fifo文件
    	//int mkfifo(const char *pathname, mode_t mode);
    	int ret = access("./myfifo", F_OK);
    	if(ret!=0)
    	{		
    		ret = mkfifo("./myfifo", 0777);
    		if(ret<0)
    		{
    			perror("mkfifo error");
    			return -1;
    		}
    	}
    
    	//打开文件
    	int fd = open("./myfifo", O_RDWR);
    	if(fd<0)
    	{
    		perror("open error");
    		return -1;
    	}
    
    	//写fifo文件
    	int i = 0;
    	char buf[64];
    	while(1)
    	{
    		memset(buf, 0x00, sizeof(buf));
    		sprintf(buf, "%d:%s", i, "hello world");
    		write(fd, buf, strlen(buf));
    		sleep(1);
    
    		i++;
    	}
    
    	//关闭文件
    	close(fd);
    
    	//getchar();
    
    	return 0;
    }
    
    

    -进程B

    //fifo完成两个进程间通信的测试
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    
    int main()
    {
    	//创建fifo文件
    	//int mkfifo(const char *pathname, mode_t mode);
    	//判断myfofo文件是否存在,若不存在则创建
    	int ret = access("./myfifo", F_OK);
    	if(ret!=0)
    	{
    		ret = mkfifo("./myfifo", 0777);//第一位0代表八进制
    		if(ret<0)
    		{
    			perror("mkfifo error");
    			return -1;
    		}
    	}
    
    	//打开文件
    	int fd = open("./myfifo", O_RDWR);
    	if(fd<0)
    	{
    		perror("open error");
    		return -1;
    	}
    
    	//读fifo文件
    	int n;
    	char buf[64];
    	while(1)
    	{
    		memset(buf, 0x00, sizeof(buf));
    		n = read(fd, buf, sizeof(buf));
    		printf("n==[%d], buf==[%s]\n", n, buf);
    	}
    
    	//关闭文件
    	close(fd);
    
    	//getchar();
    
    	return 0;
    }
    
    

    注意:

    myfifo 文件如果是在进程A中创建的,如果先启动进程B则会报错

    原因是因为如果先调用进程B的话,不会创建出 myfifo 文件,如果直接读的话就会找不到该文件。

    解决:

    在进程A和进程B中先判断是否有 myfifo 文件,如果没有就先创建,再执行 open 操作。

    //判断myfofo文件是否存在,若不存在则创建
    int ret = access(“./myfifo”, F_OK);
    if(ret!=0)
    {
    ret = mkfifo(“./myfifo”, 0777);//第一位0代表八进制
    if(ret<0)
    {
    perror(“mkfifo error”);
    return -1;
    }
    }

四、内存映射区

1、存储映射区的概念

存储映射I/O(Memory-mapped I/O)使一个磁盘文件与存储空间中的一个缓冲区相映射。从缓冲区中读数据就相当于读文件中的相应字节;将数据写入缓冲区,则会将数据写入文件。这样,就可以不使用 read/write 函数的情况下,使用(地址)指针完成 IO 操作。

使用存储映射这种方法,首先应该通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过 mmap 函数来实现。

原理图

在这里插入图片描述

2、mmap函数

  • 函数作用:
    • 建立存储映射区
  • 函数原型:
    • void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
  • 函数参数:
    • addr:指定映射的起始地址,通常设置为NULL,由系统指定
    • length:映射到内存的文件长度
    • prot:映射区的保护方式,最常用的:
      • 读:PROT_READ
      • 写:PROT_WRITE
      • 读写:PROT_READ | PROT_WRITE
    • flags:映射区的特性,可以是:
      • MAP_SHARED:写入映射区的数据会写回文件,并且允许其他映射该文件的进程共享。
      • MAP_PRIVATE:对映射区的写入操作会产生一个映射区的复制(copy-on-write),对此区域所做的修改不会写回原文件
    • fd:由 open 返回的文件描述符,代表要映射的文件
    • offset:以文件开始处的偏移量,必须是 4k 的整数倍,通常为 0,代表从文件头开始映射。
  • 函数返回值:
    • 成功:返回创建的映射区首地址
    • 失败:MAP_FAILED 宏

3、munmap 函数

  • 函数作用:
    • 释放由 mmap 函数建立的存储映射区
  • 函数原型:
    • int munmap(void *addr, size_t length);
  • 函数参数:
    • addr:调用 mmap 函数成功返回的映射区首地址
    • length:映射区的大小,mmap 函数第二个参数
  • 函数返回值:
    • 成功:返回 0
    • 失败:返回 -1,设置 errno 值

4、mmap 函数使用注意事项

  • 创建映射区的过程中,隐含着一次对映射文件的读操作,将文件内容读取到映射区
  • 当 MAP_SHARED时,必须要求:映射区的权限 <= 文件打开的权限(出于对映射区的保护)。而 MAP_PRIVATE 则无所谓,因为mmap 中的权限是对内存的限制。
  • 映射区的释放与文件关闭无关,只要映射建立成功,文件就可以立即关闭。
  • 特别注意:当映射文件大小为0时,不能创建映射区。所以,用于映射的文件必须要有实际大小;mmap使用时会经常出现总线错误,通常是由于共享文件存储空间大小引起的。
  • munmap 传入的地址必须是 mmap返回的地址。并且不能进行指针++操作。
  • 文件偏移量必须为 0 或者4k 的整数倍。
  • mmap 创建映射区出错率非常高,一定要检查返回值,确保映射区建立成功再进行后续的操作。

5、mmap函数使用总结

  • 第一个参数写为NULL,由系统分配
  • 第二个参数要映射的文件大小 > 0
  • 第三个参数:PROT_READ、PROT_WRITE
  • 第四个参数:MAP_SHARED 或者 MAP_PRIVATE
  • 第五个参数:打开的文件对应的文件描述符
  • 第六个参数:0或者4k 的整数倍

6、mmap 函数相关思考题

  • 可以 open 的时候O_CREAT 一个新文件来创建映射区吗?

    答:不可以,映射的文件大小必须大于0,除非在创建之后使用write函数写入数据再创建映射区

  • 如果 open 时O_RDONLY,mmap时 PROT 参数指定 PROT_READ | PROT_WRITE会怎么样?

    答:此时映射区的权限大于打开文件的权限,会报错权限不够

  • mmap 映射完成之后,文件描述符关闭,对 mmap 映射有没有影响?

    答:没有影响,一旦映射区建立完成,就可以关闭文件描述符了。

  • 如果文件偏移量为 1000 会怎样?

    答:1000 不是4k 的整数倍,是一个无效的参数

  • 对 mem 越界操作会怎样?

    答:报错,访问了非法内存

  • 如果 mem++,munmap 可否成功?

    答:不会成功,mem++就不是映射区的地址,使用munmap地址必须是映射区地址。

  • mmap 什么情况下会调用失败?

    答:映射文件=0、映射区权限大于打开文件权限,文件偏移量不是0或者4k的整数倍

  • 如果不检测 mmap 的返回值会怎样?

    答:建立映射区可能会失败,会返回MAP_FAILED ,那么后面的操作也就会出错。只要返回值是指针,都要进行检查。

7、练习

练习1:使用 mmap 完成没有血缘关系的进程间通信。

思路:

  • 两个进程都打开相同的文件,然后调用mmap函数建立存储映射区,这样两个进程共享一个存储映射区。

write

//使用mmap函数完成两个不相干进程间通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mman.h>

int main()
{
	//使用mmap函数建立共享映射区
	//void *mmap(void *addr, size_t length, int prot, int flags,
    //              int fd, off_t offset);
	int fd = open("./test.log", O_RDWR);
	if(fd<0)
	{
		perror("open error");
		return -1;
	}

	int len = lseek(fd, 0, SEEK_END);

	//建立共享映射区
	void * addr = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
	if(addr==MAP_FAILED)
	{
		perror("mmap error");
		return -1;
	}
	
	memcpy(addr, "0123456789", 10);

	return 0;
}

read

//使用mmap函数完成两个不相干进程间通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mman.h>

int main()
{
	//使用mmap函数建立共享映射区
	//void *mmap(void *addr, size_t length, int prot, int flags,
    //              int fd, off_t offset);
	int fd = open("./test.log", O_RDWR);
	if(fd<0)
	{
		perror("open error");
		return -1;
	}

	int len = lseek(fd, 0, SEEK_END);

	//建立共享映射区
	void * addr = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
	if(addr==MAP_FAILED)
	{
		perror("mmap error");
		return -1;
	}

	char buf[64];
	memset(buf, 0x00, sizeof(buf));
	memcpy(buf, addr, 10);
	printf("buf=[%s]\n", buf);

	return 0;
}

练习2:使用 mmap 完成父子间进程通信

原理图:

在这里插入图片描述

思路:

  • 调用mmap函数创建存储映射区,返回映射区首地址 ptr
  • 调用 fork 函数创建子进程,子进程也拥有了映射区首地址
  • 父子进程可以通过映射区首地址ptr 完成通信
  • 调用 munmap 函数释放存储映射区
//使用mmap函数完成父子进程间通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mman.h>

int main()
{
	//使用mmap函数建立共享映射区
	//void *mmap(void *addr, size_t length, int prot, int flags,
    //              int fd, off_t offset);
	int fd = open("./test.log", O_RDWR);
	if(fd<0)
	{
		perror("open error");
		return -1;
	}

	int len = lseek(fd, 0, SEEK_END);

	void * addr = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
	//void * addr = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);
	if(addr==MAP_FAILED)
	{
		perror("mmap error");
		return -1;
	}
	close(fd);

	//创建子进程
	pid_t pid = fork();
	if(pid<0) 
	{
		perror("fork error");
		return -1;
	}
	else if(pid>0)
	{
		memcpy(addr, "hello world", strlen("hello world"));	
		wait(NULL);
	}
	else if(pid==0)
	{
		sleep(1);
		char *p = (char *)addr;
		printf("[%s]", p);
	}

	return 0;
}

8、匿名映射

匿名映射,就是不会使用文件,与文件没有关系,匿名映射区的初始化大小为0。只能用于有血缘关系的进程间通信。

使用mmap函数创建匿名映射:

mmap(NULL,4096,PROT_READ|PROT_WRITE,MAP_SHARED|MAP_ANONYMOUS,-1,0);

练习:使用匿名映射完成父子进程间通信

//使用mmap匿名映射完成父子进程间通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/mman.h>

int main()
{
	//使用mmap函数建立共享映射区
	//void *mmap(void *addr, size_t length, int prot, int flags,
    //              int fd, off_t offset);
	void * addr = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
	if(addr==MAP_FAILED)
	{
		perror("mmap error");
		return -1;
	}

	//创建子进程
	pid_t pid = fork();
	if(pid<0) 
	{
		perror("fork error");
		return -1;
	}
	else if(pid>0)
	{
		memcpy(addr, "hello world", strlen("hello world"));	
		wait(NULL);
	}
	else if(pid==0)
	{
		sleep(1);
		char *p = (char *)addr;
		printf("[%s]", p);
	}

	return 0;
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值