Linux系统编程3——进程间通信


前言

本文用于记录进程间通信学习。

一、什么是进程间通信

Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。
在这里插入图片描述

二、进程间通信方式

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

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

三、管道

管道是一种最基本的IPC机制,也称匿名管道,应用于有血缘关系的进程之间,完成数据传递。调用pipe函数即可创建一个管道。
在这里插入图片描述
管道的特点

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

1、管道的原理

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

2、管道的局限性

  1. 数据一旦被读走,便不在管道中存在,不可反复读取
  2. 数据只能在一个方向上流动,若要实现双向流动,必须使用两个管道(半双工)
  3. 只能在有血缘关系的进程间使用管道(父子进程等)

3、创建管道函数——pipe

/*
函数作用:创建管道
函数原型:int pipe(int fd[2]);
函数参数:若函数调用成功,fd[0]存放管道的读端,fd[1]存放管道的写端
函数返回值:
	成功返回0
	失败返回-1,并设置error值
*/

函数调用成功返回读端和写端的文件描述符,其中fd[0]是读端, fd[1]是写端,向管道读写数据是通过使用这两个文件描述符进行的,读写管道的实质是操作内核缓冲区。
管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端。如何实现父子进程间通信呢?

4、父子进程使用管道通信

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

  1. 父进程调用pipe()函数,得到两个文件描述符fd[0]和fd[1],分别指向管道的读端和写断
  2. 父进程调用fork创建子进程,子进程也有两个文件描述符指向同一管道
  3. 父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道中的数据读出,这样就实现了父子进程间通信。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <string.h>

int main()
{
    int fd[2];
    // int pipe(int pipe(fd[2))
    int ret = pipe(fd);
    if(ret < 0)
    {
        perror("pipe error");
        return -1;
    }
    // 创建子进程
    pid_t pid = fork();
    if(pid > 0)
    {
        // 父进程关闭读端
        close(fd[0]);
        write(fd[1],"hello world", strlen("hello world"));
        wait(NULL);
    }
    else if(pid == 0)
    {
        // 子进程关闭写端
        close(fd[1]);
        char buf[64];
        memset(buf, 0x00, sizeof(buf));
        int n = read(fd[0], buf, sizeof(buf));
        printf("n==[%d], buf==%s\n", n, buf);
    }
n==[11], buf==hello world
使用pipe完成ps aux | grep bash操作

在这里插入图片描述

//使用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;
}

5、管道的读写行为

读操作
  • 有数据
    read读正常,返回读出的字节数
  • 无数据
    写端全部关闭:read解除阻塞,返回0,相当于文件读到了尾部
    写端没有全部关闭:read阻塞
写操作
  • 读端全部关闭
    管道破裂,进程终止,内核给当前进程发SIGPIPE信号
  • 读端没全部关闭
    缓冲区写满了:write阻塞
    缓冲区没有满:继续write

6、如何设置管道为非阻塞

默认情况下,管道的读写两端都是阻塞的,若要设置读或者写端为非阻塞,则可参考下列三个步骤进行:

  1. 第1步: int flags = fcntl(fd[0], F_GETFL, 0);
  2. 第2步: flag |= O_NONBLOCK;
  3. 第3步: fcntl(fd[0], F_SETFL, flags);

若是读端设置为非阻塞:

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

7、查看管道缓冲区大小

命令

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;
}
pipe size==[4096]
pipe size==[4096]
read over, n==[-1], buf==[]

四、FIFO

FIFO常被称为命名管道,以区分管道(pipe)。管道(pipe)只能用于“有血缘关系”的进程间通信。但通过FIFO,不相关的进程也能交换数据
FIFO是Linux基础文件类型中的一种(文件类型为p,可通过ls -l查看文件类型)。但FIFO文件在磁盘上没有数据块,文件大小为0,仅仅用来标识内核中一条通道。进程可以打开这个文件进行read/write,实际上是在读写内核缓冲区,这样就实现了进程间通信。

1、创建管道

  • 方式1 —— 命令

    mkfifo 管道名
    
  • 方式2 —— 函数

    int mkfifo(const char* pathname, mode_t mode);
    

当创建了一个FIFO,就可以使用open函数打开它,常见的文件I/O函数都可用于FIFO。如:close、read、write、unlink等。
FIFO严格遵循先进先出(first in first out),对FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。

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

在这里插入图片描述
思路:
进程A

  1. 创建一个fifo文件:myfifo
  2. 调用open函数打开myfifo文件
  3. 调用write函数写入一个字符串入:“hello world"(其实是将数据写入到了内核缓冲区)
  4. 调用close函数关闭myfifo文件
//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

  1. 调用open函数打开myfifo文件
  2. 调用read函数读取文件内容(其实就是从内核中读取数据)
  3. 打印显示读取的内容
  4. 调用close函数关闭myfifo文件
//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);
		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;
}

五、内存映射区

1、内存映射区介绍

存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。从缓冲区中取数据,就相当于读文件中的相应字节;将数据写入缓冲区,则会将数据写入文件。这样,就可在不使用read和write函数的情况下,使用地址(指针)完成I/O操作。
使用存储映射这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实现。
在这里插入图片描述

2、mmap函数


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

3、munmap函数

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

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

  • 第五个参数:打开的文件对应的文件描述符

  • 第六个参数:4k的整数倍

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;
}

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;
}

6、匿名映射

使用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;
}

六、信号

1、信号的概念

信号是信息的载体,Linux/UNIX 环境下,古老、经典的通信方式, 现下依然是主要的通信手段。信号在我们的生活中随处可见,例如:古代战争中摔杯为号; 现代战争中的信号弹;体育比赛中使用的信号枪…
信号的特点

  • 简单
  • 不能携带大量信息
  • 满足某个特点条件才会产生

2、信号的机制

进程A给进程B发送信号,进程B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,处理完毕后再继续执行。与硬件中断类似——异步模式。但信号是软件层面上实现的中断,早期常被称为“软中断”。
每个进程收到的所有信号,都是由内核负责发送的。
进程A给进程B发送信号示意图
在这里插入图片描述

2.1、信号的状态

信号有三种状态:产生、未决和递达
产生

  • 按键产生,如:Ctrl+c、Ctrl+z、Ctrl+\
  • 系统调用,如:kill、raise、abort
  • 软件条件产生,如:定时器alarm
  • 硬件异常产生,如:非法访问内存(段错误)、除0(浮点数例外)、内存对齐出错(总线错误)
  • 命令产生,如:kill

未决

  • 产生和递达之间的状态,主要由于阻塞(屏蔽)导致该状态

递达

  • 递送并且到达进程

2.2、信号的处理方式

  • 执行默认动作
  • 忽略信号(丢弃不处理)
  • 捕捉信号(调用用户的自定义处理函数)

2.3、信号的特质

信号的实现手段导致信号有很强的延时性,但对于用户来说,时间非常短,不易察觉。
Linux内核的进程控制块PCB是一个结构体,task_struct, 除了包含进程id,状态,工作目录,用户id,组id,文件描述符表,还包含了信号相关的信息,主要指阻塞信号集和未决信号集。

2.4、阻塞信号集和未决信号集

Linux内核的进程控制块PCB是一个结构体,这个结构体里面包含了信号相关的信息,主要有阻塞信号集和未决信号集。

  • 阻塞信号集中保存的都是被当前进程阻塞的信号。若当前进程收到的是阻塞信号集中的某些信号,这些信号需要暂时被阻塞,不予处理。
  • 信号产生后由于某些原因(主要是阻塞)不能抵达,这类信号的集合称之为未决信号集。在屏蔽解除前,信号一直处于未决状态;若是信号从阻塞信号集中解除阻塞,则该信号会被处理,并从未决信号集中去除。

在这里插入图片描述
在这里插入图片描述

2.5、信号的四要素

通过man 7 signal可以查看信号相关信息

  1. 信号的编号

    使用kill -l命令可以查看当前系统有哪些信号,不存在编号为0的信号。其中1-31号信号称之为常规信号(也叫普通信号或标准信号),34-64称之为实时信号,驱动编程与硬件相关。

  2. 信号的名称

  3. 产生信号的事件

  4. 信号的默认处理动作

    Term:终止进程
    Ign:忽略信号 (默认即时对该种信号忽略操作)
    Core:终止进程,生成Core文件。(查验死亡原因,用于gdb调试)
    Stop:停止(暂停)进程
    Cont:继续运行进程

特别需要注意的是:The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.
几个常用到的信号:

SIGINT: 程序终止(interrupt)信号,在用户键入INTR字符(通常是Ctrl-C)时发出,用于通知前台进程组终止进程。
SIGQUIT:和SIGINT类似, 但由QUIT字符(通常是Ctrl-)来控制. 进程在因收到SIGQUIT退出时会产生core文件, 在这个意义上类似于一个程序错误信号
SIGKILL:这是两个不能被捕捉或者忽略信号中的一个(另一个为SIGSTOP),它向管理员提供了一个可以杀死任意一个进程的可靠方法。
SIGTERM:程序结束(terminate)信号, 与SIGKILL不同的是该信号可以被阻塞和处理。通常用来要求程序自己正常退出,shell命令kill缺省产生这个信号。如果进程终止不了,我们才会尝试SIGKILL
SIGSTOP:停止(stopped)进程的执行. 注意它和terminate以及interrupt的区别:该进程还未结束, 只是暂停执行. 本信号不能被阻塞, 处理或忽略
SIGSEGV:当一个进程执行了一个无效的内存引用,或发生段错误时发送给它的信号
SIGUSR1:用户自定义信号 默认处理:进程终止
SIGUSR2:用户自定义信号 默认处理:进程终止
SIGPIPE:如果一个 socket 在接收到了 RST packet 之后,程序仍然向这个 socket 写入数据,那么就会产生SIGPIPE信号
SIGALRM:在定时器终止时发送给进程的信号
SIGCHLD:在一个进程终止或者停止时,将SIGCHLD信号发送给其父进程,按系统默认将忽略此信号,如果父进程希望被告知其子系统的这种状态,则应捕捉此信号
SIGCONT:该信号可以让暂停的进程恢复执行。本信号不能被阻断

3、信号相关函数

3.1、signal函数

函数作用:注册信号捕捉函数

函数原型:
	typedef void (*sighandler_t)(int);
	sighandler_t signal(int signum, sighander_t handler);
函数参数:
	signum: 信号编号
	handler: 信号处理函数
//signal函数测试---注册信号处理函数
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

//信号处理函数
void sighandler(int signo)
{
	printf("signo==[%d]\n", signo);
}

int main()
{
	//注册信号处理函数
	signal(SIGINT, sighandler);

	//while(1)
	{
		sleep(10);
	}

	return 0;
}

//注意:在控制台输入Ctrl-c并不能将程序直接杀死!
^Csigno==[2]

^Csigno==[2]
^Csigno==[2]
^Csigno==[2]
^Csigno==[2]
^Csigno==[2]
^Csigno==[2]
^Csigno==[2]
已杀死
3.2、kill函数/命令

函数作用:给指定进程发送指定信号

kill命令:kill -SIGKILL 进程PID
kill函数原型: int kill(pid_t pid, int sig);
函数返回值:
	成功:0
	失败:-1,设置errno
函数参数:
	pid参数:
		pid > 0: 发送信号给指定的进程。
		pid = 0: 发送信号给与调用kill函数进程属于同一进程组的所有进程。
		pid < -1:|pid|发给对应进程组。
		pid = -1:发送给进程有权限发送的系统中所有进程
	sig信号参数:
		不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但是名称一致
PS:进程组:每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组ID与进程组长ID相同。
//signal函数测试---注册信号处理函数
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

//信号处理函数
void sighandler(int signo)
{
	printf("signo==[%d]\n", signo);
}

int main()
{
	//注册信号处理函数
	signal(SIGINT, sighandler);


	while(1)
	{
		sleep(1);
		kill(getpid(), SIGINT);
	}

	return 0;
}

^Csigno==[2]
signo==[2]
^Csigno==[2]
signo==[2]
signo==[2]
signo==[2]
signo==[2]
signo==[2]
已杀死
3.3、abort函数

函数作用:给自己发送异常终止信号SIGABRT,并产生core文件

函数原型:void abort(void);
函数扩展:abort() == kill(getpid(), SIGABRT);
3.4、raise函数

函数作用:给当前进程发送指定信号(自己给自己发)

函数原型:int raise(int sig);
返回值:
	成功:0
	失败:非0值
函数扩展:raise(signo)==kill(getpid(),signo);
//raise和abort函数测-
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

//信号处理函数
void sighandler(int signo)
{
	printf("signo==[%d]\n", signo);
}

int main()
{
	//注册信号处理函数
	signal(SIGINT, sighandler);

	//给当前进程发送SIGINT信号
	raise(SIGINT);

	//给当前进程发送SIGABRT
	abort();

	while(1)
	{
		sleep(10);
	}

	return 0;
}

3.5、alarm函数

函数作用:设置定时器(闹钟),在指定seconds后,内核会给当前进程发送SIGALRM信号。进程收到该信号,默认动作终止。每个进程都有且只有唯一的一个定时器。

函数原型:unsigned int alarm(unsigned int seconds);
返回值:返回0或剩余秒数,无失败。
常用操作:取消定时器alarm(0),返回旧闹钟余下秒数
alarm使用的是自然定时法,与进程状态无关,就绪、运行、挂起(阻塞、暂停)、终止、僵尸...无论进程处于何种状态,alarm都计时。

在这里插入图片描述

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

void sighandler(int signo)
{
    printf("signo==[%d]\n",signo);
}
int main()
{
    signal(SIGINT, sighandler);
    signal(SIGALRM, sighandler);
    int n = alarm(5);
    printf("n==[%d]\n", n);
    sleep(2);
    n = alarm(5);
    printf("n==[%d]\n", n);

    while(1){
        sleep(3);
    }
    return 0;

}
n==[0]
n==[3]
signo==[14]
已杀死
//测试1秒钟可以数多少数字
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

int main()
{
	alarm(1);
	int i = 0;
	while(1)
	{
		printf("[%d]", i++);
	}

	return 0;
}

3.6、setitimer函数

函数作用:设置定时器(闹钟),可替代alarm函数,精度微妙us,可以实现周期定时

函数原型:int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
函数返回值:
	成功:0
	失败:-1,设置error值
函数参数:
	which:指定定时方式
		自然定时:ITIMER_REAL → 14)SIGALRM计算自然时间
		虚拟空间计时(用户空间):ITIMER_VIRTUAL → 26)SIGVTALRM  只计算进程占用cpu的时间
		运行时计时(用户+内核):ITIMER_PROF → 27)SIGPROF计算占用cpu及执行系统调用的时间
	new_value: struct itimerval,负责设定timeout时间
		itimerval.it_value: 设定第一次执行function所延迟的秒数 					      
		itimerval.it_interval: 设定以后每几秒执行function
		struct itimerval { 
		    struct timerval it_interval; // 闹钟触发周期
		    struct timerval it_value; // 闹钟触发时间
	   }; 
		struct timerval { 
		    long tv_sec; 			// 秒
		    long tv_usec; 			// 微秒
	   }          
	old_value:存放旧的timeout值,一般指定为NULL

// 每隔1s钟输出一次
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <signal.h>
#include <string.h>
void sighandler(int signo)
{
    printf("signo = [%d]\n", signo);
}

int main()
{
    signal(SIGALRM, sighandler);
    struct itimerval it;
    // 周期性时间赋值
    it.it_interval.tv_sec = 1;
    it.it_interval.tv_usec = 0;
    // 第一次触发的时间
    it.it_value.tv_sec = 3;
    it.it_value.tv_usec = 0;
    setitimer(ITIMER_REAL, &it, NULL);

    while(1)
    {
        sleep(1);
    }
    return 0;
}

4、信号集

4.1、未决信号集和阻塞信号集的关系

阻塞信号集是当前进程要阻塞的信号的集合,未决信号集是当前进程中还处于未决状态的信号的集合,这两个集合存储在内核的PCB中。
下面以SIGINT为例说明信号未决信号集和阻塞信号集的关系:
当进程收到一个SIGINT信号(信号编号为2),首先这个信号会保存在未决信号集合中,此时对应的2号编号的这个位置上置为1,表示处于未决状态;在这个信号需要被处理之前首先要在阻塞信号集中的编号为2的位置上去检查该值是否为1:

  • 如果为1,表示SIGNIT信号被当前进程阻塞了,这个信号暂时不被处理,所以未决信号集上该位置上的值保持为1,表示该信号处于未决状态;
  • 如果为0,表示SIGINT信号没有被当前进程阻塞,这个信号需要被处理,内核会对SIGINT信号进行处理(执行默认动作,忽略或者执行用户自定义的信号处理函数),并将未决信号集中编号为2的位置上将1变为0,表示该信号已经处理了,这个时间非常短暂,用户感知不到。

当SIGINT信号从阻塞信号集中解除阻塞之后,该信号就会被处理。
在这里插入图片描述

4.2、信号集相关函数

由于信号集属于内核的一块区域,用户不能直接操作内核空间,为此,内核提供了一些信号集相关的接口函数,使用这些函数用户就可以完成对信号集的相关操作。
信号集是一个能表示多个信号的数据类型,sigset_t set,set即一个信号集。既然是一个集合,就需要对集进行添加、删除等操作。
sigset_t类型的定义在signal.h文件中的第49行处:
typedef __sigset_t sigset_t;
__sigset_t的定义在sigset.h文件中的26,27行处:

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))

  typedef struct
  {
    unsigned long int __val[_SIGSET_NWORDS];
  } __sigset_t;

上述变量类型的定义的查找有个小窍门: 可以执行gcc的预处理命令:
gcc -E test.c -o test.i 这样头文件就会展开,可以直接到test.i文件中看到相关变量类型的定义。

4.2.1、sigemptyset

函数作用:将某个信号集清0

函数原型:int sigemptyset(sigset_t *set);
函数返回值: 
	成功: 0
	失败: -1 设置error
4.2.2、sigfillset

函数作用:将某个信号集置1

函数原型:int sigfillset(sigset_t *set);
函数返回值: 
	成功: 0
	失败: -1 设置error
4.2.3、sigaddset

函数作用:将某个信号加入信号集合中

函数原型:int sigaddset(sigset_t *set, int signum);
函数返回值: 
	成功: 0
	失败: -1 设置error
4.2.4、sigdelset

函数作用:将某信号清出信号集

函数原型:int sigdelset(sigset_t *set, int signum);
函数返回值: 
	成功: 0
	失败: -1 设置error
4.2.5、sigismember

函数作用:判断某个信号是否在信号集中

函数原型:int sigismember(const sigset_t *set, int signum);
函数返回值:
	在:1
	不在:0
	出错:-1 设置errno
4.2.6、sigprocmask

函数作用:用来屏蔽信号、解除屏蔽也使用该函数。其本质,读
取或修改进程控制块中的信号屏蔽字(阻塞信号集)。注意:屏蔽信号只是将信号处理延后执行(延至解除屏蔽);而忽略表示将信号丢弃处理。

函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
函数返回值:
	成功:0
	失败:-1 设置errno
函数参数:
	how参数取值:假设当前的信号屏蔽字为mask
		SIG_BLOCK: 当how设置为此值,set表示需要屏蔽的信号。相当于 mask 	
			= mask|set
		SIG_UNBLOCK: 当how设置为此,set表示需要解除屏蔽的信号。相当于 	
			mask = mask & ~set
		SIG_SETMASK: 当how设置为此,set表示用于替代原始屏蔽及的新屏蔽
			集。相当于mask = set若,调用sigprocmask解除了对当前若干个
			信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
	set:传入参数,是一个自定义信号集合。由参数how来指示如何修改当前信号屏
		蔽字。
	oldset:传出参数,保存旧的信号屏蔽字。

4.2.7、sigpending函数

函数作用:读取当前进程的未决信号集

函数原型:int sigpending(sigset_t *set);
函数参返回值:
	成功:0
	失败:-1 设置errno
函数参数:set传出参数
//信号集相关函数测试
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

//信号处理函数
void sighandler(int signo)
{
	printf("signo==[%d]\n", signo);
}

int main()
{

	//注册SIGINT和SIGQUIT的信号处理函数
	signal(SIGINT, sighandler);
	signal(SIGQUIT, sighandler);

	//定义sigset_t类型的变量
	sigset_t pending, mask, oldmask;

	//初始化
	sigemptyset(&pending);
	sigemptyset(&mask);
	sigemptyset(&oldmask);

	//将SIGINT和SIGQUIT加入到阻塞信号集中
	sigaddset(&mask, SIGINT);
	sigaddset(&mask, SIGQUIT);

	//将mask中的SIGINT和SIGQUIT信号加入到阻塞信号集中
	//sigprocmask(SIG_BLOCK, &mask, NULL);
	sigprocmask(SIG_BLOCK, &mask, &oldmask);

	int i = 1;
	int k = 1;
	while(1)
	{
		//获取未决信号集
		sigpending(&pending);	

		for(i=1; i<32; i++)
		{
			//判断某个信号是否在集合中
			if(sigismember(&pending, i)==1)	
			{
				printf("1");
			}
			else
			{
				printf("0");	
			}
		}
		printf("\n");

		if(k++%10==0)
		{
			//从阻塞信号集中解除对SIGINT和SIGQUIT的阻塞
			//sigprocmask(SIG_UNBLOCK, &mask, NULL);	
			sigprocmask(SIG_SETMASK, &oldmask, NULL);	
		}
		else
		{
			sigprocmask(SIG_BLOCK, &mask, NULL);	
		}

		sleep(1);
	}

	return 0;
}

5、信号捕捉函数

  • signal函数 (上面已介绍)
  • sigaction函数

5.1、sigaction函数

函数作用:注册一个信号处理函数

函数原型:int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
函数参数:
	signum:捕捉的信号
	act:传入参数,新的处理方式
	oldact:旧的处理方式
	struct sigaction {
       void  (*sa_handler)(int);	// 信号处理函数
       void  (*sa_sigaction)(int, siginfo_t *, void *); //信号处理函数
       sigset_t  sa_mask; //信号处理函数执行期间需要阻塞的信号
       int      sa_flags; //通常为0,表示使用默认标识
       void     (*sa_restorer)(void);
};

sa_handler:指定信号捕捉后的处理函数名(即注册函数)。也可赋值为 SIG_IGN表忽略 或 SIG_DFL表执行默认动作
sa_mask: 用来指定在信号处理函数执行期间需要被屏蔽的信号,特别是当某个信号被处理时,它自身会被自动放入进程的信号掩码,因此在信号处理函数执行期间这个信号不会再度发生。注意:仅在处理函数被调用期间屏蔽生效,是临时性设置。
sa_flags:通常设置为0,使用默认属性。
sa_restorer:已不再使用

//sigaction函数测试---注册信号处理函数
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

//信号处理函数
void sighandler(int signo)
{
	printf("signo==[%d]\n", signo);
	sleep(4);
}

int main()
{
	//注册信号处理函数
	struct sigaction act;
	act.sa_handler = sighandler;
	sigemptyset(&act.sa_mask);  //在信号处理函数执行期间, 不阻塞任何信号
	sigaddset(&act.sa_mask, SIGQUIT);
	act.sa_flags = 0;
	sigaction(SIGINT, &act, NULL);

	
	signal(SIGQUIT, sighandler);	

	while(1)
	{
		sleep(10);
	}

	return 0;
}

信号处理不支持排队:

  • 在XXX信号处理函数执行期间, XXX信号是被阻塞的, 如果该信号产生了多次, 在XXX信号处理函数结束之后, 该XXX信号只被处理一次.

  • 在XXX信号处理函数执行期间,如果阻塞了YYY信号, 若YYY信号产生了多次, 当XXX信号处理函数结束后, YYY信号只会被处理一次.

5.2、内核实现信号捕捉的过程

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:

  1. 用户程序注册了SIGQUIT信号的处理函数sighandler。
  2. 当前正在执行main函数,这时发生中断或异常切换到内核态。
  3. 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
  4. 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
  5. sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
  6. 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

在这里插入图片描述blog.csdnimg.cn/8251fc2c3985491f822f105dc45aafe2.png)信号一定会被立刻处理吗?信号不一定会被立即处理,操作系统不会为了处理一个信号而挂起当前正在运行的进程,这样产生的消耗太大(当然紧急信号除外)操作系统选择在内核态切换至用户态的时候去处理信号,不用单独进行进程切换而浪费时间。但是有时候一个正在睡眠的进程突然收到信号,操作系统肯定不愿意切换当前正在运行的进程,预示着就将该信号存在此进程的

6、SIGCHLD信号

6.1、产生SIGCHLD信号的条件

  • 子进程结束的时候
  • 子进程收到SIGSTOP信号
  • 当子进程停止时,收到SIGCONT信号

6.2、SIGCHLD信号的作用

子进程退出后,内核会给它的父进程发送SIGCHLD信号,父进程收到这个信号后可以对子进程进行回收。使用SIGCHLD信号完成对子进程的回收可以避免父进程阻塞等待而不能执行其他操作,只有当父进程收到SIGCHLD信号之后才去调用信号捕捉函数完成对子进程的回收,未收到SIGCHLD信号之前可以处理其他操作。

//父进程使用SICCHLD信号完成对子进程的回收
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void waitchild(int signo)
{
	pid_t wpid;

	//回收子进程
	while(1)
	{
		wpid = waitpid(-1, NULL, WNOHANG);
		if(wpid>0)
		{
			printf("child is quit, wpid==[%d]\n", wpid);
		}
		else if(wpid==0)
		{
			printf("child is living, wpid==[%d]\n", wpid);
			break;
		}
		else if(wpid==-1)
		{
			printf("no child is living, wpid==[%d]\n", wpid);
			break;
		}
	}
}

int main()
{
	int i = 0;
	int n = 3;

	//将SIGCHLD信号阻塞
	sigset_t mask;
	sigemptyset(&mask);
	sigaddset(&mask, SIGCHLD);
	sigprocmask(SIG_BLOCK, &mask, NULL);

	for(i=0; i<n; i++)	
	{
		//fork子进程
		pid_t pid = fork();
		if(pid<0) //fork失败的情况
		{
			perror("fork error");
			return -1;
		}
		else if(pid>0) //父进程
		{
			printf("father: fpid==[%d], cpid==[%d]\n", getpid(), pid);
			sleep(1);
		}
		else if(pid==0) //子进程
		{
			printf("child: fpid==[%d], cpid==[%d]\n", getppid(), getpid());
			break;
		}
	}

	//父进程
	if(i==3)
	{
		printf("[%d]:father: fpid==[%d]\n", i, getpid());
		//signal(SIGCHLD, waitchild);
		//注册信号处理函数
		struct sigaction act;
		act.sa_handler = waitchild;
		sigemptyset(&act.sa_mask);
		act.sa_flags = 0;
		sleep(5);
		sigaction(SIGCHLD, &act, NULL);

		//解除对SIGCHLD信号的阻塞
		sigprocmask(SIG_UNBLOCK, &mask, NULL);

		while(1)
		{
			sleep(1);
		}
	}

	//第1个子进程
	if(i==0)
	{
		printf("[%d]:child: cpid==[%d]\n", i, getpid());
		//sleep(1);
	}

	//第2个子进程
	if(i==1)
	{
		printf("[%d]:child: cpid==[%d]\n", i, getpid());
		sleep(1);
	}

	//第3个子进程
	if(i==2)
	{
		printf("[%d]:child: cpid==[%d]\n", i, getpid());
		sleep(1);
	}

	return 0;
}

七、本地socket通信

通过man 7 unix可以查到unix本地域socket通信相关信息

1、socket函数

函数作用:创建本地域socket

int socket(int domain, int type, int protocol);
函数参数:
	domain: AF_UNIX or AF_LOCAL 
	type: SOCK_STREAM 或者 SOCK_DGRAM 
	protocol: 0 表示使用默认协议
函数返回值:
	成功:返回文件描述符
	失败:返回-1,并设置errno值

创建socket成功以后,会在内核创建缓冲区,下图是客户端和服务端内核缓冲区示意图
在这里插入图片描述

2、bind函数

函数作用:绑定套接字

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数参数:
	socket:由socket函数返回的文件描述符
	addr:本地地址
	addlen: 本地地址长度
函数返回值:
	成功:返回文件描述符
	失败:返回-1, 并设置errno值

注意:bind函数会自动创建socket文件,若在调用bing函数之前socket文件已经存在,则调用bind会报错,可以使用unlink函数在bind之前先删除文件。

struct sockaddr_un{
	sa_family_t sun_family; /*AF_UNIX or AF_LOCAL*/
	char sun_path[108]; /*pathname*/
};

在这里插入图片描述
本地套接字服务器的流程:

  • 可以使用TCP的方式, 必须按照tcp的流程

  • 也可以使用UDP的方式, 必须按照udp的流程

tcp的本地套接字服务器流程:

  1. 创建套接字 socket(AF_UNIX,SOCK_STREAM,0)

  2. 绑定 struct sockaddr_un &强转

  3. 侦听 listen

  4. 获得新连接 accept

  5. 循环通信 read-write

  6. 关闭文件描述符 close

tcp本地套接字客户端流程:

  1. 调用socket创建套接字

  2. 调用bind函数将socket文件描述和socket文件进行绑定.

  3. 不是必须的, 若无显示绑定会进行隐式绑定,但服务器不知道谁连接了.

  4. 调用connect函数连接服务端

  5. 循环通信read-write

  6. 关闭文件描述符 close

//本地socket通信客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/un.h>


int main()
{
	//创建socket
	int cfd = socket(AF_UNIX, SOCK_STREAM, 0);
	if(cfd<0)
	{
		perror("socket error");
		return -1;
	}

	//删除socket文件,避免bind失败
	unlink("./client.sock");

	//绑定bind
	struct sockaddr_un client;
	bzero(&client, sizeof(client));
	client.sun_family = AF_UNIX;
	strcpy(client.sun_path, "./client.sock"); 
	int ret = bind(cfd, (struct sockaddr *)&client, sizeof(client));
	if(ret<0)
	{
		perror("bind error");
		return -1;
	}

	struct sockaddr_un serv;
	bzero(&serv, sizeof(serv));
	serv.sun_family = AF_UNIX;
	strcpy(serv.sun_path, "./server.sock");
	ret = connect(cfd, (struct sockaddr *)&serv, sizeof(serv));
	if(ret<0)
	{
		perror("connect error");	
		return -1;
	}

	int n;
	char buf[1024];

	while(1)
	{
		memset(buf, 0x00, sizeof(buf));
		n = read(STDIN_FILENO, buf, sizeof(buf));

		//发送数据
		write(cfd, buf, n);

		//读数据
		memset(buf, 0x00, sizeof(buf));		
		n = read(cfd, buf, sizeof(buf));
		if(n<=0)
		{
			printf("read error or client close, n==[%d]\n", n);
			break;
		}
		printf("n==[%d], buf==[%s]\n", n, buf);
	}

	close(cfd);

	return 0;
}


//本地socket通信服务端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/un.h>


int main()
{
	//创建socket
	int lfd = socket(AF_UNIX, SOCK_STREAM, 0);
	if(lfd<0)
	{
		perror("socket error");
		return -1;
	}

	//删除socket文件,避免bind失败
	unlink("./server.sock");

	//绑定bind
	struct sockaddr_un serv;
	bzero(&serv, sizeof(serv));
	serv.sun_family = AF_UNIX;
	strcpy(serv.sun_path, "./server.sock"); 
	int ret = bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
	if(ret<0)
	{
		perror("bind error");
		return -1;
	}

	//监听listen
	listen(lfd, 10);

	//接收新的连接-accept
	struct sockaddr_un client;
	bzero(&client, sizeof(client));
	int len = sizeof(client);
	int cfd = accept(lfd, (struct sockaddr *)&client, &len);
	if(cfd<0)
	{
		perror("accept error");	
		return -1;
	}
	printf("client->[%s]\n", client.sun_path);

	int n;
	char buf[1024];

	while(1)
	{
		//读数据
		memset(buf, 0x00, sizeof(buf));		
		n = read(cfd, buf, sizeof(buf));
		if(n<=0)
		{
			printf("read error or client close, n==[%d]\n", n);
			break;
		}
		printf("n==[%d], buf==[%s]\n", n, buf);

		//发送数据
		write(cfd, buf, n);
	}

	close(lfd);

	return 0;
}


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值