Linux系统编程学习笔记_005_进程间通信

目录

管道

pipe函数

命名管道FIFO

存储映射I/O

mmap函数

信号

信号相关的概念

kill函数

alarm函数

setitimer函数

信号集操作函数

信号捕捉

SIGCHLD信号

中断系统调用(慢速)


在进程间进行数据传递需要借助操作系统提供的特殊方法,常用的进程间通信方式有:

1. 管道:使用简单

2. 信号:开销小

3. mmap映射:非血缘关系进程间

4. socket(本地套接字):稳定

管道

最基本的IPC机制,作用于有血缘关系的进程之间完成数据传递,调用pipe系函数可创建管道。

实现原理: 内核借助环形队列机制,使用内核缓冲区(4K)实现。

特质:

1. 伪文件

2. 管道中的数据只能一次读取。

3. 数据在管道中,只能单向流动。

局限性:

1. 数据不能进程自己写自己读。

2. 数据不可以反复读。

3. 半双工通信。

4. 只有血缘关系进程间可用。

pipe函数

原型: int pipe(int pipefd[2]);

创建并打开管道

参数:fd[0] 读端 fd[1] 写端

返回值:成功返回0 失败返回-1 errno

管道通信代码示例,父进程往管道写,子进程从管道读并打印读取内容:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>

void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	int ret;
	int fd[2];
	pid_t pid;
	
	char *str = "hello pipe\n";
	char buf[1024];

	ret = pipe(fd);
	if(ret == -1) sys_err("pipe error");
	
	pid = fork();
	if(pid>0){
		close(fd[0]); //父进程关闭读段
		write(fd[1], str, strlen(str));
		close(fd[1]);
		sleep(1);
	} else if (pid == 0){
		close(fd[1]); //子进程关闭写段
		ret = read(fd[0], buf, sizeof(buf));
		write(STDOUT_FILENO, buf, ret); 
		close(fd[0]);
	}
	
	return 0;
}

管道的读写行为:

读管道:

1. 管道有数据,read返回实际读到的字节数

2. 管道无数据: 无写端——read返回0(类似读到文件尾);有写端——read阻塞等待

写管道:

1. 管道无读端:异常终止(SIGPIPE信号导致)

2. 有读端:管道已满——阻塞等待;管道未满——返回写出字节个数

使用管道实现父子进程读写完成ls|wc -l效果:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>

void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	int fd[2];
	int ret;
	pid_t pid; 
	ret = pipe(fd);
	if(ret==-1){
		sys_err("pipe error");
	}

	pid = fork();
	if(pid == -1){
		sys_err("fork error");
	}else if (pid > 0){
		close(fd[1]);
		dup2(fd[0], STDIN_FILENO);
		execlp("wc","wc","-l", NULL);
	}else if (pid == 0){
		close(fd[0]);
		dup2(fd[1], STDOUT_FILENO);
		execlp("ls","ls",NULL);
	}
	
	return 0;
}

管道实现兄弟进程间通信,使用循环创建兄弟进程,用循环因子标示,兄:ls 弟: wc -l

注意:父进程在fork之后,依然把持着管道的读端和写端,因此需要先close掉父进程的读写来保证管道信息的单向流动。

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

void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	int fd[2];
	int ret, i;
	pid_t pid; 
	ret = pipe(fd);
	if(ret==-1){
		sys_err("pipe error");
	}
	
	for(i=0;i<2;i++){
		pid = fork();
		if(pid==-1) sys_err("fork error");
		if(pid==0) break;
		
	}
	
	if(i==2){
		close(fd[0]);
		close(fd[1]);
		wait(NULL);
		wait(NULL);
	} else if(i == 0){ //brother	
		close(fd[0]);
		dup2(fd[1], STDOUT_FILENO);
		execlp("ls","ls",NULL);
	} else if(i == 1){	
		close(fd[1]);
		dup2(fd[0], STDIN_FILENO);
		execlp("wc","wc","-l", NULL);
	}
	
	return 0;
}

命名管道FIFO

mkfifo函数

原型: int mkfifo(const cahr *pathname, mode_t mode);

可用于无血缘关系进程间的通信,操作方式和文件类似

创建FIFO管道:

#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>

int main(){
	int ret = mkfifo("testfifo", 0664);
	return 0;
}

FIFO实现非血缘关系进程间通信:

写端:

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

void sys_err(char *str){
	perror(str);
	exit(-1);
}

int main(int argc, char *argv[]){
	int fd, i;
	char buf[4096];
	
	if(argc < 2){
		printf("Enter the fifoname\n");
		return -1;
	}
	fd = open(argv[1], O_WRONLY);
	if(fd < 0) sys_err("open");
	i = 0;
	while(1){
		sprintf(buf, "hello itcast %d\n", i++);
		write(fd, buf, strlen(buf));
		sleep(1);
	}
	close(fd);
	return 0; 
}

 读端:

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

void sys_err(char *str){
	perror(str);
	exit(-1);
}

int main(int argc, char *argv[]){
	int fd, i;
	char buf[4096];
	
	if(argc < 2){
		printf("Enter the fifoname\n");
		return -1;
	}
	fd = open(argv[1], O_RDONLY);
	if(fd < 0) sys_err("open");
	while(1){
		int len = read(fd, buf, sizeof(buf));
		write(STDOUT_FILENO, buf, len);
		sleep(1);
	}
	close(fd);
	return 0; 

}

开启后通信如下

 

存储映射I/O

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

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

fd:用于创建共享内存映射区的文件的文件描述符

offset:偏移位置,默认0,表示映射文件全部,4K整数倍

返回值:

成功:内存映射区的首地址

失败:MAP_FAILED,设置errno

munmap函数

原型:int munmap(void *addr, size_t length);

释放映射区,参数addr传入mmap返回值,length为大小

示例,使用mmap创建一个映射区(共享内存),并往映射区里写入内容:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/mman.h>
#include <fcntl.h>

void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	char *p = NULL;
	int fd;

	fd = open("testmap", O_RDWR|O_CREAT|O_TRUNC, 0644);
	if(fd==-1){
		sys_err("open error");
	}
	ftruncate(fd, 20);
	int len = lseek(fd,0,SEEK_END);
	p = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
	if(p == MAP_FAILED){
		sys_err("mmap error");
	}
	strcpy(p, "hello mmap");
	printf("---%s\n", p);

	int ret = munmap(p,len);
	if(ret == -1){
		sys_err("munmap error");
	}

	return 0;
}

mmap注意事项:

1. 用于创建映射区的文件大小为 0,实际指定非0大小创建映射区,出 “总线错误”。

2. 用于创建映射区的文件大小为 0,实际制定0大小创建映射区, 出 “无效参数”。

3. 用于创建映射区的文件读写属性为,只读。映射区属性为 读、写。 出 “无效参数”。

4. 创建映射区,需要read权限。当访问权限指定为 “共享”MAP_SHARED时, mmap的读写权限,应该 <=文件的open权限。 只写不行。

5. offset需要是4096的整数倍。

6. 对申请的映射区内存不能越界访问。

7. 文件描述符fd在mmap创建映射区完成即可关闭(后续访问文件使用地址)。

8. munmap用于释放的 地址,必须是mmap申请返回的地址。

9. 映射区访问权限为 “私有”MAP_PRIVATE, 对内存所做的所有修改,只在内存有效,不会反应到物理磁盘上。

10.  映射区访问权限为 “私有”MAP_PRIVATE, 只需要open文件时,有读权限,用于创建映射区即可。

mmap函数保险调用方法:

1.open("文件名",O_RDWR)

2.mmap(NULL,有效文件大小,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0)

父子进程mmap通信:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/mman.h>
#include <fcntl.h>


void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	int *p = 0;
    int var = 0; 
	int fd;
	pid_t pid;

	fd = open("tmp", O_RDWR|O_CREAT|O_TRUNC, 0644);
	if(fd==-1){
		sys_err("open error");
		exit(1);	
	}
	ftruncate(fd, 4);
	//p = (int *)mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);
	p = (int *)mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
	if(p == MAP_FAILED){
		sys_err("mmap error");
		exit(1);
	}
	close(fd);
	
	pid = fork();
	if(pid == 0){
		*p = 7000;
		var = 1000;
		printf("child, *p = %d, var = %d\n", *p, var);
	} else{
		sleep(1);
		printf("parent, *p=%d, var = %d\n", *p, var);
		wait(NULL);
		int ret = munmap(p, 4);
		if(ret == -1){
			sys_err("munmap error");
			exit(1);
		}
	}
	
	return 0;
}

全局变量(读时共享,写时复制特性),子进程对*p的修改反映到了父进程上:

将共享内存定义为private则结果如下:

使用mmap实现无血缘关系进程间通信:

写端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/mman.h>
#include <fcntl.h>

struct student{
	int id;
	char name[256];
	int age; 
};

void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	int fd;
	struct student stu={1,"jack",18};
	struct student *p;

	fd = open("tmp", O_RDWR|O_CREAT|O_TRUNC, 0664);
	if(fd==-1){
		sys_err("open error");
		exit(1);	
	}
	ftruncate(fd, sizeof(stu));
	p = mmap(NULL, sizeof(stu), PROT_WRITE, MAP_SHARED, fd, 0);
	
	if(p == MAP_FAILED){
		sys_err("mmap error");
		exit(1);
	}
	close(fd);
	while(1){
		memcpy(p,&stu,sizeof(stu));
		stu.id++;
		sleep(1);
	}
	munmap(p,sizeof(stu));
	
	return 0;
}

读端:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <sys/mman.h>
#include <fcntl.h>

struct student{
	int id;
	char name[256];
	int age; 
};

void sys_err(const char *str){
	perror(str);
	exit(1);
}

int main(int argc, char *argv[]){
	int fd;
	struct student stu={1,"jack",18};
	struct student *p;

	fd = open("tmp", O_RDONLY);
	if(fd==-1){
		sys_err("open error");
		exit(1);	
	}

	p = mmap(NULL, sizeof(stu), PROT_READ, MAP_SHARED, fd, 0);
	
	if(p == MAP_FAILED){
		sys_err("mmap error");
		exit(1);
	}
	close(fd);
	while(1){
		printf("id = %d, name = %s, age = %d\n", p->id, p->name, p->age);
		sleep(1);
	}
	
	
	return 0;
}

结果如下:

匿名映射:只能用于血缘关系进程间通信。

p = (int *)mmap(NULL, 40, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);

关于匿名映射作用可参照:匿名映射的作用_我是小x的博客-CSDN博客_匿名映射

信号

信号共性:

简单、不能携带大量信息、满足条件才发送。

信号的特质:

信号是软件层面上的“中断”。一旦信号产生,无论程序执行到什么位置,必须立即停止运行,处理信号,处理结束,再继续执行后续指令。

所有信号的产生及处理全部都是由【内核】完成的。

信号相关的概念

产生信号:

1. 按键产生: Ctrl+c, Ctrl+z, Ctrl+\

2. 系统调用产生: kill, raise, abort

3. 软件条件产生:alarm

4. 硬件异常产生:非法访问内存(segment fault),除0,内存对齐出错(总线错误)

5. 命令产生:kill命令

未决:产生与递达之间状态,主要由于阻塞导致。 

递达:产生并且送达到进程,直接被内核处理掉。

信号处理方式: 执行默认处理动作、忽略(丢弃)、捕捉(自定义用户处理函数)

阻塞信号集(信号屏蔽字): 本质:位图。用来记录信号的屏蔽状态。

屏蔽X信号再次收到时,在解除屏蔽前,该信号的处理将推后(一直处于未决态)。

未决信号集:本质:位图。用来记录信号的处理状态。

信号产生后,未决信号集中描述该信号的位翻转为1表示该信号处于未决态,被处理后翻转为0。该信号集中的信号表示已经产生,但尚未被处理。

信号四要素

1.编号 2. 名称 3.事件 4.默认处理方式

kill函数

原型:int kill(pid_t pid, int signum)

参数:

pid:

> 0:发送信号给指定进程

= 0:发送信号给跟调用kill函数的那个进程处于同一进程组的进程。

< -1: 取绝对值,发送信号给该绝对值所对应的进程组的所有组员。

= -1:发送信号给,有权限发送的所有进程。

signum:待发送的信号

返回值:成功: 0  失败: -1 设置errno

kill -9 -groupname  杀死一个进程组

示例,子进程发送信号kill父进程:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <signal.h>

void sys_err(const char* str){
	perror(str);
	exit(1);
}

int main(){
	pid_t pid = fork();
	if(pid>0){
		printf("parent, pid=%d\n", getpid());
		while(1);
	} else if(pid==0){
		printf("child pid = %d, ppid= %d\n", getpid(), getppid());
		sleep(2);
		kill(getppid(), SIGKILL);
	}
	return 0;
}

结果如下:

alarm函数

原型:unsigned int alarm(unsigned int seconds);

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

参数:

seconds:定时秒数

返回值:上次定时的剩余时间(秒数) alarm(0)取消定时器

使用alarm(1)加while循环可以统计计算机一秒能打印多少个数字,例子略。

使用time命令查看程序执行时间

实际时间 = 用户时间 + 内核时间 + 等待时间  —— 程序优化的瓶颈在于IO

setitimer函数

设置定时器,可代替alarm函数,精度微秒,可以实现周期定时

原型int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);

参数:

which:

ITIMER_REAL: 采用自然计时。 ——> SIGALRM

ITIMER_VIRTUAL: 采用用户空间计时  ---> SIGVTALRM

ITIMER_PROF: 采用内核+用户空间计时 ---> SIGPROF

new_value:定时秒数

类型:struct itimerval {

                struct timeval {

                time_t      tv_sec;         /* seconds */

                suseconds_t tv_usec;        /* microseconds */

            }it_interval;---> 周期定时秒数,用来设定两次定时任务之间间隔的时间

                struct timeval {

                time_t      tv_sec;         

                suseconds_t tv_usec;       

            }it_value;  ---> 第一次定时秒数  

            };

old_value:传出参数,上次定时剩余时间。

示例,使用setitimer函数定时每五秒打印输出到屏幕:

#include <stdio.h>
#include <signal.h>
#include <sys/time.h>

void myfunc(int signo){
	printf("hello world\n"); 
}

int main(void){
	struct itimerval it, oldit;
	signal(SIGALRM,myfunc);
	it.it_value.tv_sec = 2;
	it.it_value.tv_usec = 0;
	it.it_interval.tv_sec = 5;
	it.it_interval.tv_usec = 0;
	if(setitimer(ITIMER_REAL, &it, &oldit) == -1){
		perror("setitimer error");
		return -1;
	}
	
	while(1)
	return 0;
}

信号集操作函数

1. 自定义信号集的设定

sigset_t set;  自定义信号集。

sigemptyset(sigset_t *set); 清空信号集

sigfillset(sigset_t *set); 全部置1

sigaddset(sigset_t *set, int signum); 将一个信号添加到集合中

sigdelset(sigset_t *set, int signum); 将一个信号从集合中移除

sigismember(const sigset_t *set,int signum); 判断一个信号是否在集合中,在返回1不在返回0

2. 设置信号屏蔽字和解除屏蔽

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

how: SIG_BLOCK: 设置阻塞

SIG_UNBLOCK: 取消阻塞

SIG_SETMASK: 用自定义set替换mask

set: 自定义set

oldset:旧有的 mask

3. 查看未决信号集

int sigpending(sigset_t *set);

set: 传出的未决信号集

信号列表:

示例,利用自定义集合设置指定信号的阻塞:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <signal.h>

void sys_err(const char* str){
	perror(str);
	exit(1);
}

void print_set(sigset_t *pedset){
	int i;
	for(i=1;i<32;i++){
		if(sigismember(pedset, i))
			putchar('1');
		else 
			putchar('0');
	}
	printf("\n");
}

int main(){
	int ret = 0;
	sigset_t set,oldset,pedset;
	sigemptyset(&set);
	sigaddset(&set, SIGINT);
	sigprocmask(SIG_BLOCK, &set, &oldset);
	
	while(1){
		ret = sigpending(&pedset);
		if(ret==-1)
			sys_err("sigpending error");
		print_set(&pedset);
	}

	return 0;
}

编译运行,在输入Ctrl+C之后,进程捕捉到信号,但由于设置阻塞,没有处理,未决信号集对应位置变为1

信号捕捉

signal函数

注册一个信号捕捉函数

原型:

typedef void(*sighandler_t)(int);

sighandler signal(int signum, sighandler_t handler);

参数:signum - 待捕捉信号  handler - 捕捉信号后的操作函数

返回值:

示例,捕捉Ctrl+c信号:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <signal.h>

void sys_err(const char* str){
	perror(str);
	exit(1);
}

void sig_catch(int signo){
	printf("catch you%d\n", signo);
	return;
}
int main(){
	signal(SIGINT, sig_catch); 
	while(1);
	return 0;
}

结果:

sigaction函数

原型:int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数:待捕捉信号,结构体struct action

sa_handler:指定信号捕捉后的处理函数名(即注册函数),也可复制为SIG_IGN表忽略或SIG_DFL表执行默认动作

sa_mask:调用信号处理函数时所要屏蔽的信号集合(信号屏蔽字),仅在处理函数被调用期间屏蔽生效。

sa_flags:通常设置为0,表使用默认属性

返回值:

示例,使用sigaction函数捕捉两种信号:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <signal.h>

void sys_err(const char* str){
	perror(str);
	exit(1);
}

void sig_catch(int signo){
	if(signo == SIGINT){
		printf("catch you%d\n", signo);
	}else if(signo == SIGQUIT){
		printf("----catch you%d\n",signo);
	}
	return;
}
int main(int argc, char* argv[]){
	struct sigaction act, oldact; 
	act.sa_handler = sig_catch;  //set callback function name 
	sigemptyset(&(act.sa_mask));  //set mask when sig_catch working  
	act.sa_flags = 0;  //default 
	int ret = sigaction(SIGINT, &act, &oldact);
	if(ret == -1){
		sys_err("sigaction error");
	}
	ret = sigaction(SIGQUIT, &act, &oldact); 
	while(1);
	return 0;
}

 结果如下:

信号捕捉的特性:

内核实现信号捕捉过程:

SIGCHLD信号

产生条件:子进程状态发生变化时产生

1. 子进程终止时 2.子进程接收SIGSTOP停止时 3. 静止态收到SIGCONT后唤醒时

示例,使用信号捕捉回收子进程:

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

void sys_err(const char* str){
	perror(str);
	exit(1);
}

void catch_child(int signo){
	pid_t wpid;
	int status;
	//while((wpid = wait(NULL))!=-1)
	while((wpid = waitpid(-1, &status, 0))!= -1){ // clear zombie procedure
		if(WIFEXITED(status)){
			printf("catch child id %d, ret=%d\n", wpid, WEXITSTATUS(status));
	  	}
	}
	
	return; 
}

int main(){
	pid_t pid;
	// set block
	sigset_t set;
	sigemptyset(&set);
	sigaddset(&set, SIGCHLD);
	sigprocmask(SIG_BLOCK, &set, NULL); 
	
	int i;
	for(i=0; i<15; i++){
		if((pid=fork())==0)
			break;
	}

	if(15==i){
		struct sigaction act; 
		act.sa_handler = catch_child; //set callback function 
		sigemptyset(&act.sa_mask); //set mask when doing catch 
		act.sa_flags = 0; //set default 
		sigaction(SIGCHLD, &act, NULL);
		// unblock
		sigprocmask(SIG_UNBLOCK, &set, NULL); 
		printf("I'm parent,pid = %d\n", getpid());
		while(1); 
	} else{
		printf("I'm child pid = %d\n",getpid());
		return i; 
	}

	

	return 0;
}

通过循环wait是因为多个进程同时死亡,由于相同信号的不排队原则,父进程只会去处理累积信号中的一个,因此循环回收防止僵尸进程出现。

还有一种情况是父进程还没注册完捕捉函数,子进程就死亡了,解决的办法是在int i之前设置屏蔽,等父进程注册完捕捉函数再解除,这样即使子进程死亡,信号也因为被屏蔽而无法到达父进程,解除屏蔽过后,父进程就能处理累积起来的信号了。

中断系统调用(慢速)

系统调用可分为慢速系统调用和其他系统调用

慢速系统调用:可能会使进程永久阻塞的一类。如果在阻塞期间收到一个信号,该系统调用就被中断,不再继续执行(早期),也可以设定系统调用是否重启。如read, write, pause…

其他系统调用:如getpid,getppid,fork等

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值