应用程序设计之(4)linux进程间通信

先说一下三大类通信方式吧

tips:耐心读完希望对学习进程间通信的小白有个不错的收获

传统通信方式:无名管道、有名管道、信号

IPC通信:共享内存、消息队列、信号灯

BSD通信: 套接字通信(socket)

传统通信方式

由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。

无名管道

1、是"半双工"工作方式
"半双工"同时只能从一端写入,另一端读取.
2、无名管道不属于文件系统. 数据交互在"内核内存"完成.
3、无名管道只能用于亲缘进程间的通信.
函数接口: pipe
{
int pipe(int pipefd[2]);
用法: int fd[2];
pipe(fd); fd[0] 表示读端 fd[1] 表示写端
}
ret = read()在读取管道时(与读取文件返回0有区别).
如果写端关闭 且当中没有内容可读则会返回0. ret == 0
如果写端没有关闭 且当中没有内容可读,read会阻塞.
思考:
读端如果关闭,写端会出现什么状况
{
读端如果关闭,写端的存在是没有意义的,所以内核会发送一个管道破裂信号(SIGPIPE),该信号会
默认结束 进程
}
写端如果关闭,读端会出现什么状况
{
当写端关闭,如果管道中还有内容则读端会继续读取,直到读取完成返回0.
}

Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「无名管道」和「有名管道」。

无名管道顾名思义,它没有名字标识,无名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是无名管道,通信的数据是无格式的流并且大小受限,通信的方式是单向的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来无名管道是只能用于存在父子关系的进程间通信,无名管道的生命周期随着进程创建而建立,随着进程终止而消失。

有名管道

1、是"半双工"工作方式
"半双工"同时只能从一端写入,另一端读取.
2、有名管道属于文件系统. 数据交互在"内核内存"完成.
3、有名管道可以用于非亲缘进程间的通信.
指令mkfifo 可以直接创建管道文件.
接口函数: mkfifo()
{
创建管道文件.
int mkfifo(const char *pathname, mode_t mode);
}
读端如果关闭,写端会出现什么状况
{
读端如果关闭,写端的存在是没有意义的,所以内核会发送一个管道破裂信号(SIGPIPE),该信号会
默认结束 进程
}
写端如果关闭,读端会出现什么状况
{
当写端关闭,如果管道中还有内容则读端会继续读取,直到读取完成返回0.
}

有名管道突破了无名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

信号

概念:在软件层面对中断的一种模拟方式.(软中断),内核进程与应用进程也可以使用信号来通 信.

查看信号种类命令:kill -l 一共62个信号,通常进程使用前31个

进程处理信号的方式:

默认处理:几乎都是终止程序

忽略处理:不能忽略的信号: SIGKILL, SIGSTOP

自定义处理:使用函数来处理信号.

相关函数接口

 信号发送函数

kill()
raise()
alarm()

信号接收函数

signal()
{
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
{
signum: 信号值
handler: 处理函数. SIG_IGN: 忽略 SIG_DFL: 默认处理
错误返回: SIG_ERR
}
}
pause()
{
int pause(void);
阻塞进程,等待任意信号来临.
返回值:
错误 -1
}

IPC通信

共享内存

共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,它直接分配一个共享空间,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。

概念:内核会寻找一片内存空间,然后将空间进行映射操作,能够把内存映射到用户空间。大大提 升了通信效率. 操作流程: 1. 创建共享内存空间 (内核里)

2. 映射内核空间 到 用户空间

3. 读/写该共享内存

4. 取消映射、删除共享内存 相关指令: ipcs -m :查看共享内存 ipcrm -m id: 删除共享内存 例如: ipcrm -m 1234

key_t ftok(const char *pathname, int proj_id);
{
获取key值:标识共享内存,以及用于区分共享内存用于亲缘 还是 可以用于非亲缘.
key_t ftok(const char *pathname, int proj_id)
pathname: 文件名
proj_id: 组合成key 需要用到的id
返回值:错误返回-1 正确返回 获取到的key值.
key 值是通过两个参数(文件的节点号 与 proj_id的值)组合形成的
}
int shmget(key_t key, size_t size, int shmflg);
{
创建共享内存.
key: IPC_PRIVATE 或 ftok的返回值 IPC_PRIVATE只能用于亲缘进程
size: 共享内存区大小
shmflg: 相当于open函数的权限位,也可以用8进制表示法
shmflg如包含IPC_CREAT,表明如果指定的共享内存不存在,则新建一个对象
返回值: 正确返回 共享内存段标识符(共享内存id) 错误 -1
}
void *shmat(int shmid, const void *shmaddr, int shmflg);
{
共享内存映射.
shmid:要映射的共享内存区标识符
shmaddr: 将共享内存映射到指定地址(若为NULL,则表示由系统自动完成映射)
shmflg: SHM_RDONLY:共享内存只读 默认0:共享内存可读
成功:映射后的地址 错误 -1
}
读/写 就是对内存的读写 比如fgets(),printf()...
int shmdt(const void *shmaddr);
{
取消映射
shmaddr: 共享内存映射后的地址
成功 0 错误 -1
}
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
{
共享内存控制函数.
shmid:要操作的共享内存标识符
cmd : IPC_STAT (获取对象属性)
IPC_SET (设置对象属性)
IPC_RMID (删除对象)
buf : 指定IPC_STAT/IPC_SET时用以保存/设置属性 当参数二为 IPC_RMID时 为
NULL
成功 0 错误 -1
}

实际例子

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <strings.h>
#include <sys/types.h>
#include <sys/shm.h>

int main(int argc, char *argv[])
{
	/*ftok获取key,可作为生成shmid的键值,
	该key值由1.txt的节点号与'b'值组成*/
	key_t key = ftok("1.txt", 'b'); 
	if(key == -1)
	{
		perror("ftok");
		return -1;
	}

	printf("key=%#x\n", key);
	/*IPC_CREAT没有则创建共享内存,0777内存访问权限*/
	int shmid = shmget(key, 1024, 0777 | IPC_CREAT);
	if(shmid == -1)
	{
		perror("shmget");
		return-1;
	}
	
	/*该指令可以查看共享内存是否创建成功,
	显示出来的是内核共享内存的信息,不是映射区域*/
	system("ipcs -m");

	char *p = NULL;
	/*参数NULL表示用户空间的映射地址随机分配
	返回值为分配成功的空间首地址,也就是说p指针就是指向那个首地址
	使用p就是使用共享内存*/
	if((p = shmat(shmid, NULL, 0)) == (void*)-1)
	{
		perror("shmat");
		return -1;
	}
	
	/*本次获取共享内存信息与上一次一样没有任何变化.*/
	system("ipcs -m");
	
	if(-1 == shmdt(p))
	{
		perror("shmdt");
		return -1;
	}
	
	/*本次获取共享内存信息与上一次一样没有任何变化.
	但是内存映射已经被取消,所以p不能再当共享内存使用
	ipcs -m 查看的信息没有变化,因为内核的共享内存还在*/
	system("ipcs -m");
	if(-1 == shmctl(shmid, IPC_RMID, NULL))
	{
		perror("shmctl");
		return -1;
	}

	/*内核共享内存消失,因为已经彻底被删除
	注意:如果没有取消映射直接删除,本次内存信息查看会发现并没有删除成功
	因为映射内存还在,所以内核不敢直接删除内核共享,但是程序运行完后ipcs -m将
	不会看到内核共享内存了,因为程序结束后用户空间都将释放包括用户共享内存
	
	所以今天看到的程序没有关闭共享内存还在,结束就不在的原因是因为没有取消映射*/
	system("ipcs -m");

	return 0;

}

信号灯集

多进程竞争同个共享资源会造成数据的错乱那么,就需要信号量来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。

信号灯集虽然和信号只有一字之差但是这两个通信方式却天差地别

信号灯
{
查看系统信号灯指令: ipcs -s 删除 ipcrm -s + semid
System V的信号灯是一个或者多个信号灯的一个集合。
其中的每一个都是单独的计数信号灯。而Posix信号灯指的是单个计数信号灯(线程用到的信号量)
int semget(key_t key, int nsems, int semflg)
{
创建信号灯集函数
key: 和信号灯集关联的key值
nsems: 信号灯集中包含的信号灯数目
semflg: 信号灯集的访问权限,通常为IPC_CREAT | 0666
返回值: 正确:信号灯集ID 错误: -1
}
int semctl (int semid, int semnum, int cmd…/*union semun arg*/)
{
信号灯集控制函数
semid: 信号灯集ID
semnum: 要修改的信号灯编号
cmd: GETVAL:获取信号灯的值
SETVAL:设置信号灯的值
IPC_RMID:从系统中删除信号灯集合
union semun: semctl是可变参数函数:cmd为GETVAL或SETVAL时,
需要传递第四个参数。参数类型为union semun
返回值: 正确 0 错误 -1
}
int semop(int semid, struct sembuf *opsptr, size_t nops)
{
信号灯集资源控制函数(p v操作)
semid:信号灯集ID
opsptr: struct sembuf {
short sem_num; // 要操作的信号灯的编号
short sem_op; // 0 : 等待,直到信号灯的值变成0
// 1 : 释放资源,V操作
// -1 : 分配资源,P操作
short sem_flg; // 0, IPC_NOWAIT, SEM_UNDO
};
nops: 要操作的信号灯的个数
返回值: 正确 0 错误 -1
}
}

信号灯集实际运用

#include <sys/types.h>
#include <unistd.h>

#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>

#include <string.h>
#include <sys/wait.h>
#include <sys/sem.h>
union semun {
	int              val;    /* Value for SETVAL */
    struct semid_ds *buf;    /* Buffer for IPC_STAT, IPC_SET */
	unsigned short  *array;  /* Array for GETALL, SETALL */
	struct seminfo  *__buf;  /* Buffer for IPC_INFO(Linux-specific) */
};
int main(int argc, char *argv[])
{
	//1、创建信号灯集
	int semid = semget(IPC_PRIVATE, 2, IPC_CREAT | 0644);
	if (semid < 0){
		perror("semget");
		return -1;
	}
	system("ipcs -s");
	//2、初始化信号灯
	union semun value1 = {0}; //初值为 0
	semctl(semid, 0, SETVAL, value1);  //设置编号为 0的信号灯初值为 0
	
	union semun value2 = {1};
	semctl(semid, 1, SETVAL, value2);  //设置编号为 1的信号灯初值为 1


	int shmid = shmget(IPC_PRIVATE, 32, IPC_CREAT | 0644);
	if (shmid < 0){
		perror("shmget");
		return -1;
	}
	system("ipcs -m");
	
	char *p = shmat(shmid, NULL, 0);
	if ( p == (char *)-1){
		perror("shmat");
		return -1;
	}

	pid_t pid = fork();
	if (pid < 0){
		perror("fork");
		return -1;
	}else if(pid == 0){
		while(1){
			struct sembuf buf1 = {	
				.sem_num = 1,			//待操作的信号灯编号
				.sem_op  = -1,			//p操作
				.sem_flg = 0			//阻塞等待
			};
			semop(semid, &buf1, 1);     //p(1)

			fgets(p, 32, stdin);

			struct sembuf buf2 = {
				.sem_num = 0,			//待操作的信号灯编号
				.sem_op	 = 1,			//v操作
				.sem_flg = 0			//阻塞等待
			};
			semop(semid, &buf2, 1);     //V(0)

			//if( strncmp(p, "quit", 4) == 0){  //strstr :字符串中查找子串
			if(strstr(p, "quit") != NULL){
				break;
			}
		}
	} else{
		waitpid(pid, NULL, WNOHANG);
		while(1){
			struct sembuf buf1 = {0, -1, 0};
			semop(semid, &buf1, 1);     //p(0)

			printf("recv: %s\n", p);

			struct sembuf buf2 = {1, 1, 0};
			semop(semid, &buf2, 1);     //V(1)
		
			if(strncmp(p, "quit", 4) == 0){
				break;
			}
		}
		if(shmdt(p) < 0){
			perror("shmdt");
			return -1;
		}
		char buf[32] = {0};
		sprintf(buf, "ipcrm -m %d -s %d", shmid, semid);
		system(buf);

		system("ipcs -m -s");
	}
    return 0;
}
/*******************************end of file*****************************/

消息队列

消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
    相关接口函数:
        1、打开或者创建消息队列

 int msgget(key_t key, int flag);
            头文件:
                #include <sys/types.h>
                #include <sys/ipc.h>
                #include <sys/msg.h>
            参数:
                key:IPC_PRIVATE 或者是  ftok函数返回值
                flag:消息队列访问权限(IPC_CREAT|0644)
            返回值:
                成功:消息队列ID号
                失败:-1


                 2、发送消息(添加消息)
         

   int msgsnd(int msgid, const void *msgbuf, size_t size, int flag);
            参数:
                msgid:消息队列ID号
                msgbuf:消息的缓冲区首地址,消息类型必须如下所示:
                    struct msgbuf{
                        long type;        //消息类型
                        char buf[32];    //消息正文
                    };
                size: 消息正文的长度
                flag:
                    0:阻塞等待发送消息完成
                    IPC_NOWAIT:非阻塞
            返回值:
                成功:0
                失败:-1


        3、接收消息(读取消息)
          

  int msgrcvd(int msgid, void *msgbuf, size_t size, long msgtype, int flag);
            参数:
                msgid:        消息队列ID号
                msgbuf:    存储消息的缓冲区
                size:         消息正文的长度
                msgtype:    要读取的消息的类型
                flag:
                    0:若无数据,一直阻塞下去
                    IPC_NOWAIT:非阻塞
            返回值:
                成功:实际读取的字节数
                失败:-1


        4、消息队列的控制
            

int msgctl(int msqid, int cmd, struct msqid_ds *buf);
            参数:
                msqid:消息队列的 ID号
                cmd:    
                    IPC_STAT:读取消息队列的属性,并将其保存在buf指向的缓冲区中。
                    IPC_SET:设置消息队列的属性。这个值取自buf参数。
                    IPC_RMID:从系统中删除消息队列。
                buf:消息队列缓冲区
            返回值:
                成功:0
                失败:-1

消息队列我们举个例子吧

消息队列的实际使用,实现父子进程间通信

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
struct msgbuf
{
    long type;
    char buf[32];
    int data;
};
int main(int argc, char *argv[])
{ 
    //create msg
    int msgid=msgget(IPC_PRIVATE,IPC_CREAT|0644);
    if(msgid<0)
    {
        perror("msgget");
        return -1;
    }
    system("ipcs -q");

    pid_t pid=fork();
    if(pid<0)
    {
        perror("fork");
        return -1;
    }
    else if(pid==0)
    {
        while(1)
        {
            //       int msgsnd(int msqid,const void *msgbuf,size_t size,int flag);
            struct msgbuf msg;//定义结构体变量,用来存贮消息和消息类型
            msg.type=10;//指定该条消息的类型
            msg.data=1024;//一个消息正文赋值
            fgets(msg.buf,32,stdin);//从标准输入读取一行数据列到buf消息正文中
            //将消息发送到消息队列 ,添加完该函数才能返回
            msgsnd(msgid,&msg,sizeof(msg)-sizeof(long),0);
            if(strncmp(msg.buf,"quit",4)==0)
                break;
        }
    }
    else
    {
        waitpid(pid,NULL,WNOHANG);
        while(1)
        {
            //      int msgrcv(int msgid,void *mgsbuf, size_t size,long msgtype,int flag );
            struct msgbuf msg={0};
            i`nt ret =msgrcv(msgid,&msg,sizeof(msg)-sizeof(long),10,0);
            if(ret<0)
            {
                perror("msgrcv");
            }
            printf("ret: %d\n",ret);
            printf("data: %d\n",msg.data);
            printf("recv: %s\n",msg.buf);
            if(strncmp(msg.buf,"quit",4)==0)
                break;
        }
        if(msgctl(msgid,IPC_RMID,NULL)<0)
        {
            perror("msgctl");
        }
        system("ipcs -q");
    }

    return 0;
} 

BSD通信: 套接字通信(socket)

前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 Socket 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。

套接字通信(socket)

socket通信应该划分在网络编程里面了,但是它实际实现的其实也是一种进程间通信,与别的通信不同的是,它可以实现在不同的服务器之间

(1)位于服务器端

server

1.创建套接字 socket()

2.定义服务器网络信息结构体 sockaddr_in将套接字与服务器网络信息绑定 bind()

3.将套接字设置成为被动监听模式 listen()

4.阻塞等待客户端的连接请求 accept()

5.进行通信

#include <stdio.h> //printf
#include <arpa/inet.h> //inet_addr htons
#include <sys/types.h>
#include <sys/socket.h> //socket bind listen accept connect
#include <netinet/in.h> //sockaddr_in
#include <stdlib.h> //exit
#include <unistd.h> //close
#include <string.h>

#define N 128
#define errlog(errmsg) do{perror(errmsg);\
        printf("%s -- %s -- %d\n", __FILE__, __func__, __LINE__);\
        exit(1);\
       }while(0)

int main(int argc, const char *argv[])
{
 int sockfd, acceptfd;
 struct sockaddr_in serveraddr, clientaddr;
 socklen_t addrlen = sizeof(serveraddr);
 char buf[N] = {};
 ssize_t bytes;

 if(argc < 3)
 {
  fprintf(stderr, "Usage: %s <ip> <port>\n", argv[0]);
  exit(1);
 }

 //第一步:创建套接字
 if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
 {
  errlog("fail to socket");
 }

 //第二步:填充服务器网络信息结构体
 //inet_addr:将点分十进制ip地址转化为网络字节序的整型数据
 //htons:将主机字节序转化为网络字节序
 //atoi:将数字型字符串转化为整型数据
 serveraddr.sin_family = AF_INET;
 serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
 serveraddr.sin_port = htons(atoi(argv[2]));

 //第三步:将套接字与服务器网络信息结构体绑定
 if(bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) < 0)
 {
  errlog("fail to bind");
 }
 
 //第四步:将套件字设置为被动监听状态 
 if(listen(sockfd, 5) < 0)
 {
  errlog("fail to listen");
 }

 //第五步:阻塞等待客户端的连接请求
 if((acceptfd = accept(sockfd, (struct sockaddr *)&clientaddr, &addrlen)) < 0)
 {
  errlog("fail to accept");
 }

 //服务器知道客户端的信息
 printf("%s --> %d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));

 while(1)
 {
  if((bytes = recv(acceptfd, buf, N, 0)) < 0)
  {
   errlog("fail to recv");
  }
  else if(bytes == 0)
  {
   printf("NO DATA\n");
   exit(1);
  }
  else 
  {
   if(strncmp(buf, "quit", 4) == 0)
   {
    printf("The client is quited\n");
    break;
   }

   printf("client: %s\n", buf);

   strcat(buf, " *_*");
   
   if(send(acceptfd, buf, N, 0) < 0)
   {
    errlog("fail to send");
   }
  }
 }

 close(acceptfd);
 close(sockfd);
 
 return 0;
}

(2)客户端

client

1.创建套接字 socket()

2.填充服务器网络信息结构体  sockaddr_in

3.发送客户端的连接请求 connect()

4.进行通信

#include <stdio.h> //printf
#include <arpa/inet.h> //inet_addr htons
#include <sys/types.h>
#include <sys/socket.h> //socket bind listen accept connect
#include <netinet/in.h> //sockaddr_in
#include <stdlib.h> //exit
#include <unistd.h> //close
#include <string.h>

#define N 128
#define errlog(errmsg) do{perror(errmsg);\
        printf("%s -- %s -- %d\n", __FILE__, __func__, __LINE__);\
        exit(1);\
       }while(0)

int main(int argc, const char *argv[])
{
 int sockfd;
 struct sockaddr_in serveraddr;
 socklen_t addrlen = sizeof(serveraddr);
 char buf[N] = {};

 if(argc < 3)
 {
  fprintf(stderr, "Usage: %s <ip> <port>\n", argv[0]);
  exit(1);
 }

 //第一步:创建套接字
 if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
 {
  errlog("fail to socket");
 }

 //第二步:填充服务器网络信息结构体
 //inet_addr:将点分十进制ip地址转化为网络字节序的整型数据
 //htons:将主机字节序转化为网络字节序
 //atoi:将数字型字符串转化为整型数据
 serveraddr.sin_family = AF_INET;
 serveraddr.sin_addr.s_addr = inet_addr(argv[1]);
 serveraddr.sin_port = htons(atoi(argv[2]));

#if 0
 //客户端可以自己指定信息
 struct sockaddr_in clientaddr;
 clientaddr.sin_family = AF_INET;
 clientaddr.sin_addr.s_addr = inet_addr(argv[3]);
 clientaddr.sin_port = htons(atoi(argv[4]));

 if(bind(sockfd, (struct sockaddr *)&clientaddr, addrlen) < 0)
 {
  errlog("fail to bind");
 }
#endif
 //第三步:发送客户端的连接请求
 if(connect(sockfd, (struct sockaddr *)&serveraddr, addrlen) < 0)
 {
  errlog("fail to connect");
 }

 while(1)
 {
  fgets(buf, N, stdin);
  buf[strlen(buf) - 1] = '\0';

  if(send(sockfd, buf, N, 0) < 0)
  {
   errlog("fail to send");
  }

  if(strncmp(buf, "quit", 4) == 0)
  {
   printf("The client is quited\n");
   break;
  }

  if(recv(sockfd, buf, N, 0) < 0)
  {
   errlog("fail to recv");
  }

  printf("server: %s\n", buf);

 }

 close(sockfd);
 
 return 0;
}

好像码的太长了,后面再来写一下各个进程区别和适用的地方吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

飞赴

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

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

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

打赏作者

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

抵扣说明:

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

余额充值