操作系统实践(基于Linux的应用)进程间通信

进程间通信

1、概述

进程间通信(Inter Process Communication,IPC)包括以下几种方式:

(1)管道(Pipeline)

​ 管道是Linux最初支持的IPC方式,可分为无名管道,命名管道。在管道通信中,发送进程以字符流形式将大量数据送入管道,接收进程可从管道接收数据,从而实现通信。

(2)信号量(Semaphore)

​ 信号量是一种被保护的变量,只能通过初始化和两个标准的原子操作即P、V操作(也称为wait、signal操作)来访问。信号量用来实现进程(线程)间的同步。

(3)信号(Signal)

​ 信号是UNIX系统最早的IPC方式之一。操作系统通过信号通知某一进程发生了某种预定义的事件:接收到信号的进程可以选择不同的方式处理该信号,一是可以采用默认处理机制——进程中断或退出,二是忽略该信号,也可以自定义该信号的处理函数。

​ 内核为进程生产信号以响应不同的事件,这些事件就是信号源。信号源可以是异常、其他进程、终端的中断、CPU超时、文件过大、内核通知等。

(4)消息队列(Message Queue)

​ 消息队列就是消息的一个链表,它允许一个或者多个进程向它写消息,一个或多个进程从中读消息。Linux维护了一个消息队列向量表——msgqueue,来表示系统中所有的消息队列。消息队列通信克服了信号传递信息少的弱点。

(5)共享内存(Shared Memory)

​ 共享内存是一个可被多个进程访问的内存区域,由一个进程所创建,其他进程可以挂载到该共享内存中,从而实现进程间通信。

​ 共享内存是最快的IPC机制,但由于Linux本身不能实现对其同步控制,需要用户程序进行并发访问控制(如信号量机制)。

(6)套接字(socket)

​ 套接字可以实现不同主机间的进程通信。一个套接字是进程间通信的端点(Endpoint),其他进程可以访问、连接和进行数据通信。

​ 在上述通信方式中,信号量和信号属于低级通信,适用于少量数据交换。共享内存设计内存管理,进程同步知识。下面主要讲述管道和套接字通信。

2、管道通信

在Linux中,管道是一种使用非常频繁的通信机制。从本质上说,管道也是一种文件,但它又和一般的文件有所不同。Linux中使用的管道分为两大类,即无名管道和命名管道。

2.1 管道概述

管道是Linux进程间通信的主要手段之一。从本质上看,一个管道是一个只存在于内存中的文件,但是这个文件于一般文件的属性不同,它不能以读写方式打开,对这个文件的操作要通过两个分别以只读和只写方式打开的文件进行,它们分别代表管道的两端,即读端、写端。通过写端和读端,管道实现了两个进程间进行单向通信的机制。因为管道传递数据的单向性,管道通信又被称为半双工通信。根据适用范围的不同,管道可以分为无名管道和命名管道。
无名管道主要用于父、子进程或兄弟进程等相关进程之间的通信。在Linux系统中可以通过系统调用建立起一个单向的通信管道,这种关系一般都是由父进程建立。当需要双向通信时需要建立两个管道,各自实现一个方向上的通信。管道两端的进程均将该管道看做一个文件,一个进程负责往管道中写数据,而另一个从管道中读取数据。
命名管道是为了解决无名管道的应用范围限制而设计的。因为“无名”,所以不相关进程难以找到该管道,为此引人了命名管道,不相关的进程可以通过管道的名称来查找该管道。为了实现命名管道,引人了一种新的文件类型—FIFO(First In First Out)文件。实现一个命名管道实际上就是实现一个FIFO文件,该文件建立在实际的文件系统上,拥有自己的文件名称,任何进程可以在任何时间通过文件名或路径名与该文件建立联系。命名管道一旦建立,之后它的读、写以及关闭操作都与无名管道完全相同。虽然与命名管道对应的FIFO文件inode结点是建立在文件系统中,但是仅是一个结点而已,文件的数据还是存在于内存缓冲页面中,这一点和无名管道相同。
管道技术在Linux中应用非常广泛。读者此前已经学习了Linux的一些常用命令,如Is和grep,这两个命令使用|进行组合实际上就构成了一个完整的管道,具体如下:

$ ls -l|grep rwx

​ 从管道的实现角度来看,ls -l进程构成了管道的写端,而grep rwx进程则构成了管道的读端。ls -l命令执行的结果被写人到管道中,而grep rwx进程从管道中读出ls -l命令的执行结果,并对其进行过滤查找。另外,Linux命令中经常使用的重定向操作符>实际上也是一种管道的实现方式,具体如下:

$ ls -l>test.txt

​ 其中ls -l进程构成了管道的写端,经过重定向后其输出被写入到了文件test.txt中,而不是输出到标准输出设备上。文件test.txt实际上构成了管道的读端。

​ 上述命令中的|和>实际上是Linux对管道技术的一种封装实现。下面看一下无名管道的使用。

2.2 无名管道

2.2.1 无名管道的使用方法1

​ 无名管道的第一种使用方法是使用标准函数库提供的popen和pclose函数,函数原型如下:

# include<stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream)

​ 其中popen函数通过创建一个管道、创建子进程、启动并调用Shell等步骤实现一个子进程的执行。popen函数的第一个参数command表示生成的子进程启动Shell后要指明的命令。popen的第二个参数type指明文件的属性,type只有两种选择,要么是读要么是写,这是因为管道是单向的,不能读写兼具。popen函数的返回值是一个FILE类型的文件流指针。由于Linux把一切都视为文件 ,也就是说,可以使用stdio I/O库中的文件处理函数来对其进行操作。如果type是r,主调用程序就可以使用被调用程序的输出,方法是通过函数返回的FILE指针,利用标准库函数,如fread来读取程序的输出。如果type是w,主调用程序就可以向被调用程序发送数据,方法是通过函数返回的FILE指针,利用标准库函数,如fwrite 向被调用程序写数据,而被调用程序就可以在自己的标准输入中读取这些数据。
​ pclose函数用于关闭由popen创建的关联文件流。pclose 只在popen启动的进程结束后才返回,如果调用pclose时被调用进程仍在运行,pclose调用将等待该进程结束。它返回关闭的文件流所在进程的退出码。
下面看一个具体的例子(省略了头文件包含):

/ * popen_ test.c* /
int main() {
    FILE *fpr = NULL;
    FILE *fpw = NULL;
    char buf[BUFSIZ + 1]	//BUFSIZ 8192
    int len = 0;
    memset(buf, '\0', sizeof(buf));	//初始化缓冲区
    								//打开ls和grep进程
    fpr = popen("ls -1","r");
    fpw = popen("grep cwx", "w");
    if(fpr && fpw){
        							//读取数据
        len = fread(buf, sizeof(char), BUFSIZ, fpr);
        while(len > 0){				//还有数据可读,循环读取数据,直到读完所有数据
            buf[len] = '\0';
            //把数据写入grep进程
            fwrite(buf, sizeof(char), len, fpw);
            len = fread(buf, sizeof(char), BUFSIZ, fpr);
        }
        //关闭文件流
        pclose(fpr);
        pclose(fpw);
        exit(0);					//0  EXIT_SUCCESS
    }
    exit(1);						//1	 EXIT_FAILURE
}

​ 上述代码中BUFSIZ是标准库函数定义的宏,指定缓冲区的大小,默认为8192字节。程序首先利用popen函数以只读方式创建一个管道,返回文件流描述符fpr,并启动子进程执行ls命令,然后利用popen函数以只写方式创建一个管道,返回文件流描述符fpw,并启动子进程执行grep命令。然后,程序利用返回的文件流描述符进行操作。使用fread从fpr中读数据,并缓存到buf中,也就是把ls命令的结果从管道中读出,然后写人到buf中;使用fwrite把buf中的数据写人到fpw中,fpw与grep进程相关联,也就是把buf中的数据交给grep去解析。程序执行结果如下,可以看到程序执行后把Is -I结果中唯一具有rwr表示的文件popen_ test 过滤并显示出来了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JXih5ECV-1651222390983)(C:\Users\unrealstar\AppData\Roaming\Typora\typora-user-images\image-20220429151056108.png)]

​ popen函数包括了至少3个步骤:创建一个管道、创建子进程、启动并调用Shell执行参数command指定的命令。这样就带来了一个优点和一个缺点。 优点是在启动command命令程序之前,可以先启动Shell来分析命令字符串,也就可以使各种Shell扩展(如通配符)在程序启动之前就全部完成,这样就可以通过popen启动非常复杂的Shell命令。而相对应的缺点就是,对于每个popen调用不仅要启动一个被请求的程序,还要启动一个Shell。即每一个popen调用事实上将启动两个进程,从效率和资源的角度看,popen函数显然要差一些。

2.2.2 无名管道的使用方法2

​ 从上一小节可以看出调用popen函数可以创建一个管道,但同时还会创建子进程、启动Shell,可以说popen是对管道的一种封装调用。与此相对,pipe则是一个底层调用,函数原型如下:

#include <unistd.h>
int pipe(int pipefd[2]);

​ pipe 函数的功能就是创建一个用于进程间通信的管道。数组pipefd返回所创建管道的两个文件描述符,其中pipefd[0]表示管道的读端,而pipefd[1]表示管道的写端。从写端写入到管道中的数据由内核进行缓存,直到有进程从读端将数据读出。
​ 从函数的原型可以看出,pipe函数跟popen函数的一个重大区别是, popen函数是基于文件流(FILE)工作的,而pipe函数是基于文件描述符工作的,所以在使用pipe创建的管道要使用read和write调用来读取和发送数据。
​ 管道可以看做是一种特殊的文件。 其特殊性表现在,首先它只存在于内存中,其次管道中的数据从读端读取后就被移出管道,也就是从管道使用的内核缓冲区中移除。管道的容量是有限制的。在Linux内核2.6.11版本之前,管道的容量,或者说一个管道使用的内核缓冲区大小,与系统的页面大小相同,在i386架构下就是4096字节,这与POSIX标准一致。在Linux内核3.13.0版本中管道容量可以根据用户的需求动态增长,最大为1048576 字节,root用户可以通过设置/proc/sys/fs/pipe-max-size参数来改变该值。
​ 另外,管道的读、写行为和一般文件也不相同。当一个进程使用read读取一个空管道时,read将会阻塞,直到管道中有数据被写人;当一个进程试图向一个满的管道写人数据时,write将会被阻塞,直到足够多的数据被从管道中读取,write可以将数据全部写人管道中。根据POSIX.1-2001标准。当向管道中写人的数据量小于管道容量时,写入过程是原子性的,即写人到管道中的数据是一个连续的流。 当向管道中写入的数据量大于管道容量时,写人过程就不一定是原子性的,即该进程写入到管道中的数据可能会与其他进程写入到管道中的数据交织在一起。
下面看一个管道的实际例子。

/* pipe_test01.c */

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <string.h>
#define BUFFER_SIZE 25
#define READ_END	0
#define WRITE_END	1

int main(void)
{
	char write_msg[BUFFER_SIZE] = "Greetings";
	char read_msg[BUFFER_SIZE];
	pid_t pid;
	int fd[2];
	if (pipe(fd) == -1) { 	/* 创建管道 */
		fprintf(stderr,"Pipe failed");
		return 1;
	}
	pid = fork();  /*创建子进程*/
	if (pid < 0) {
		fprintf(stderr, "Fork failed");
		return 1;
	}
	if (pid > 0) {  /* 父进程*/
		close(fd[READ_END]); /* 关闭不使用的读端*/
		/*向管道中写入数据 */
		write(fd[WRITE_END], write_msg, strlen(write_msg)+1); 
		close(fd[WRITE_END]); /* 关闭管道的写端*/
		wait(NULL);
	}
	else { /* 子进程 */
		close(fd[WRITE_END]); /* 关闭不使用的写端*/
		/*从管道中读取数据 */
		read(fd[READ_END], read_msg, BUFFER_SIZE);
		printf("child read %s\n",read_msg);
		close(fd[READ_END]); /* 关闭管道的读端*/
	}
	return 0;
}

​ 上述程序在父、子进程之间实现了一个无名管道,父进程向管道写入了一串字符,子进程从管道中将字符读出。请读者注意,在fork生成子进程后,父、子进程共享fd[2]数组,数组中存储的是读端、写端的文件描述符。父进程通过写端写入数据,子进程则通过读端读出数据。其执行结果如图所示

​ 与pipe函数经常一起使用的还有dup、dup2函数,其原型如下:

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);

​ 两个函数的主要功能都是实现对oldfd文件描述符的复制。其中dup函数使用所有文件描述中未使用的最小的编号作为新的描述符,而dup2则使用传入的newfd作为oldfd的副本。

​ 把上述pipe_test01.c中的子进程代码替换如下:

/* pipe_test02.c */
/* child process*/
close(0);	// 关闭标准输入
dup(fd[READ_END]);  //以标准输入作为管道的读端
close(fd[WRITE_END]);
close(fd[READ_END]);
//启动新进程od  
execlp("/usr/bin/od", "od", "-c", NULL);   //启动新进程od
return 0;

​ 上述代码中首先调用close函数关闭标准输出,这样当调用dup函数时,能够使用的最小的文件描述符就是0,因此标准输人就被作为管道读端的一个副本,然后关闭写端和读端,执行程序od -c。此时父进程向管道写人的数据成为了od -c的输人,od -c命令把输入的数据以ASCII码的形式显示在屏幕上,其执行结果如图所示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uUPsFvAr-1651222390985)(file:///D:\QQ\QQ消息记录等\3518925535\Image\C2C\8B0A61137546B42B330DBABC9D5307FC.jpg)]

​ 另外利用read函数的特性,可以实现在父、子进程之间的多次通信。假设子进程分几次向管道写入数据,父进程循环等待读取管道,父进程的一个典型实现代码如下:

/* pipe_test03.c */

for(;;)
    {   
        nread = read(p[0], buf, MSGSIZE);//根据读取返回确定后续操作
        switch(nread)
        {
        	case -1:  
                if(errno==EAGAIN) //非阻塞模式下才会有的返回值
                { 
                    printf("pipe empty\n"); 
                    sleep(1); 
                    break; 
                 }
                 else{
                    error("read call");
                 } 
             case 0:  
             	 printf("End of conversation\n");
                 exit(0);			
             default: 
                 printf("MSG=%s\n",buf);
                 if (strcmp(buf, msg2) ==0)
                 {
                    printf("the parent %d will exit\n",getpid());				
                 }
        }
   }

在上述代码中父进程根据read返回的字节教判断后续动作。当read函数返回为-1时查看是否是EAGAIN错误,即管道空而返回,由于此时pipe管道是阻塞的,所以不会出现此种情况。可以看到在图7.2的执行结果中没有pipe emply的输出;当返回值为0时表示读取结束,父进程退出;当返回值为其他时,输出读取的信息。

2.3 命名管道

无名管道可以比较方便地在相关进程之间实现数据通信。但是因为无名,所以在不相关进程之间就不能使用无名管道了,而必须使用命名管道。命名管道也被称为FIFO文件,它是一种特殊类型的文件。 虽然创建方式不同,但命名管道和无名管道的IO行为都是相同的。由于Linux中所有的设备、对象等都可以看做是文件,所以命名管道的使用也就变得与文件操作一致,这也使它的使用变得非常方便。

2.3.1 命名管道相关函数

命名管道在使用之前需要先进行创建,创建函数如下,

# include < sys/types.h>
# include < sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

​ mkifo函数的功能是创建一个FIFO特殊文件,也就是命名管道,该文件的名称为第一个参数pathname,第二参数mode指明该文件的权限。
​ 与其他文件一样,在使用之前必须先打开文件。命名管道在使用之前,也必须打开。虽然命名管道打开使用的也是open函数,但是其参数与一般文件不同。使用open雨数打开命名管道有以下方式:

open(const char * pathname, O_RDONLY);
open(const char * pathname, O_RDONLY|O_NONBLOCK);
open(const char * pathname, O_WRONLY);
open(const char * pathname, O_WRONLY|O_NONBLOCK);

​ 与普通文件相比,命名管道打开时使用的属性较少。虽然Linux 3.13.0内核支持以O.RDWR模式打开一个命名管道,但是POSIX对此没有明确定义,而且从实践上来看以O.RDWR来使用一个命名管道很容易出现错误。所以建议读者以只读或只写的方式打开命名管道,实现单向数据传输。在open函数第二个参数中有一个特殊选项O_NONBLOCK,该选项表示非阻塞。加上这个选项后,表示该open调用及以后在该管道文件描述符上的操作都是非阻塞的。
​ 最后再次强调,虽然命名管道在使用之前需要创建和打开,但是打开后其IO行为与无名管道是相同的。

2.3.2 命名管道用于无关进程间通信

​ 命名管道可以实现无关进程间的高效数据传输。下面看一个例子。 该例子程序中,服务器读取管道中的数据并将其保存到硬盘的一个文件上:客户端则读取一个文件的数据将其写人到管道中。服务器与客户端程序使用的头文件和宏定义都放在comml.h中。

/* comm1.h */
#include < unistd.h>
#include < stdlib.h>
#include < stdio.h>
#include < fcntl.h>
#include < sys/types.h>
#include < sys/stat.h>
#include < limits.h>
#include < string.h>
#define NAMEDPIPE "my_fifo"
#define DESTTXT "dest.txt"
#define SOURTXT "data.txt"

​ 该头文件约定了服务器和客户端使用的命名管道的名称为NAMEDPIPE,服务器向硬盘上存储数据的文件名称为DESTTXT,客户端读取数据的文件为SOURTXT
​ 服务器关键代码如下:

/* fifo server01.c */
# include "comm1.h"
int main() 
    int pipe_fd, dest_fd;
    int count = 0, bytes_read = 0, bytes_write;
    char buffer[PIPE_BUF + 1];
    memset(buffer, '\0', sizeof(buffer));  //清空缓冲数组
    pipe_fd = open(NAMEDPIPE, O_RDONLY);   //以只读、阻塞方式打开管道文件
    if(pipe_fd == -1)
		exit(EXIT_FAILURE) ;
    printf('Process %d opening FIFO O_RDONLY\n", getpid());
    //以只写方式创建保存数据的文件
    dest_fd = open(DESTTXT, O_WRONLY|O_CREAT, 0644);
    if(dest_fd == -1)
    	exit(EXIT_ FAILURE) ;
    printf("Process %d result %d\n", getpid(), pipe_fd);
    do{
        count = read(pipe_fd, buffer, PIPE_BUF);
        //把读取的FIFO中数据保存在文件DBSTTXT中
        bytes_write = write(dest_fd, buffer, count);
        bytes_read += count;
    }while(count>0);
    close(pipe_fd); close(dest_fd);
    printf("Process %d finished, %d bytes read\n", getpid(), bytes_read);
    exit(EXIT_SUCCESS);
}

客户端关键代码如下:

/* fifo_client01.c */
# include "comm1.h"
int main()
{
	int pipe_fd, sour_fd;
    int count = 0, bytes_sent = 0, bytes_read = 0;
    char buffer[PIPE_BUF + 1];
    if(access(NAMEDPIPE, F_OK) == - 1) {	//管道文件不存在,创建命名管道
    	mkfifo(NAMEDPIPE, 0777);
    }
    printf("Process %d opening FIFO O.NRONLY\n"getpid());
    //以只写阻塞方式打开FIFO文件,以只读方式打开数据文件
    pipe_fd = open(NAMEDPIPE, O_WRONLY);
    sour_fd = open(SOURTXT, O_RDONLY);
    printf("Process %d result %d\n", getpid(), pipe_fd);
    //从目标文件读取数据
    bytes_read = read(sour_fd, buffer, PIPE_BUF);
    buffer[bytes_read] = '\0';
    while( bytes_read > 0) {
        //向FIFO写数据
        count=write(pipe_fd, buffer, bytes_read);
        //累加写的字节数,并继续读取数据
        bytes_sent += count;
        bytes_read = read(sour_fd, buffer, PIPE_BUF);
        buffer[bytes_read] = '\0';
    }
    close(pipe_fd); close(sour_fd);
    printf("Process %d finished, %d bytes sent\n", getpid(), bytes_sent);
    exit(EXIT_ SUCCESS);
}

​ 在上述代码中,由客户端负责创建命名管道。服务器、客户端各自以只读、只写方式打开命名管道。然后客户端和服务器进程通过while 循环各自实现从文件data.txt中读数据并写人到管道中、从管道中读数据并写人到文件dest.txt中的操作,直到客户端读取的文件到达尾部、服务器端从管道读取数据返回为0。两个程序编译后执行结果如图7.3所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uNWHONXB-1651222390985)(file:///D:\QQ\QQ消息记录等\3518925535\Image\C2C\E071DA00A08FEFEFA6DE994F2DFE23F6.jpg)]

​ 在图7.3中,如果先执行fifo_server01程序会直接运行结束,原因是命名管道是由fifo_client01 建立的。所以在第一次运行时需要先运行fifo_ client01,再运行fifo.server01,可以看到两个进程在非常短的时间内完成了10485760 字节数据的传输。如果程序不是第一次运行,也就是my_ fifo管道已经存在,则无论先运行fifo_server01 还是fifo_client01都可以。创建的命名管道是随内核持续的,在系统重启后将被删除。另外在执行时,当前目录下需要有存储数据的文件data. txt。
​ 提示:为了验证管道的传输效率,这里使用的data.txt达到了10MB。可以使用dd命令创建指定大小的文件,具体命令如下:

dd if = /dev/zero of=data.txt bs = 1M count 10

​ 这样就可以生成一个10MB的data.txt 文件,文件内容为全0(因从/dev/zero为读取,/dev/zero为0源)

2.3.3 基于管道的双向通信

​ 由于管道是单向通信的,所以fifo_ server01 和fifo_client01 使用一个管道实现了从客户端到服务器的数据单向传输。如果要实现服务器与客户端的双向传输,显然就需要两个管道。一个管道用于从客户端向服务器传送数据,客户端使用管道的写端,服务器使用管道的读端;另一个管道实现从服务器到客户端的数据传送,服务器使用管道的写端,客户端使用管道的读端。使用无名管道实现两个相关进程双向通信的一个简化模型如下:

int child_to_parent[2],parent_to_child[2];
pid_t pid;
pipe(&child_to_parent);//创建父进程中用于读取数据的管道
pipe(&parent_to_child);//创建父进程中用于写人数据的管道
if((pid=fork())==0){//子进程
    close(child_to_parent[0]);//关闭管道child_to_parent 的读端
    close(parent_to_child[1]);//关闭管道parent_to_child 的写端
    write(child_to_parent[1], buf, len);//子进程向child_to_parent管道写人数据
    read(parent_to_child[0], buf, len);//子进程从parent_to_child 管道读取数据
    /*处理buf中读人的数据*/
    close(child_to_parent[1]);//关闭管道child_to_parent 的写端
    close(parent_to_child[0]);//关闭管道parent_to_child 的读端
} else {//父进程
    close(child_to_parent[1]);//关闭管道child_to_parent 的写端
    close(parent_to_child[0]);//关闭管道parent_to_child 的读端
    write(parent_to_child[1], buf, len);//父进程向parent_to_child 管道写人数据
    read(child_to_parent[0], buf, len);//父进程从childto_parent管道读取数据
    /*处理buf中读人的数据*/
    close(parent_to_child[1]);//关闭parent_to_child管道的写端
    close(child_to_parent[0]);//关闭child_to_parent管道的读端
    /* 使用wait系列丽数等待子进程退出并取得退出代码 */
}

​ 上述代码给出了使用两个管道实现父子进程双向通信的简单模型,没有包括错误检测、数据处理等步骤。其中child_to_parent表示从子进程向父进程传输数据的管道,parent_to_child表示从父进程向子进程传输数据的管道。请读者注意,上述代码中父、子进程中的write和read的顺序并不是固定不变的,可以根据实际应用进行调整。但是,父、子进程不能都是先调用read、再调用write,否则会出现死锁。原因是read调用是阻塞的,调用后如果管道的写端没有数据写人,则调用进程会一直阻 塞,而父、子进程都是先调用read后调用write,则会出现循环等待,进人死锁状态。

​ 使用命名管道实现无关进程双向通信的主要原理和上述模型基本一致,不再赘述。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值