进程间的通信

本文详细介绍了进程间通信(IPC)的五种主要方式:无名管道、命名管道(FIFO)、消息队列、共享内存和信号量。每种方式的特点、使用方法和示例代码都有清晰的阐述,包括创建、读写、控制等关键操作,展示了如何在不同进程间有效地交换数据。
摘要由CSDN通过智能技术生成

 

 

目录

一.管道

二.命名管道(FIFO)

三.消息队列

四.共享内存

五.信号

六.信号量

 


 

进程间的通信介绍:

IPC(interprocess  communication),指的是在不同进程之间进行数据交互。IPC的方式有无名管道,命名管道,消息队列,信号量,共享内存,socket套接字。其中socket支持不同主机之间两个进程IPC。

一.管道

管道,通常指无名管道,是UNIX系统IPC最古老的形式。

特点:

1.它是半双工的(即数据只能在一个方向上流动),具有固定的读端和写端。

2.它只能用于具有亲缘关系的进程之间的通信(也是父子进程或者兄弟进程之间)。

3.它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read,write等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。

4.管道中的数据被读走就没了。

 

原型:

int pipe(int pipefd[2]);

当一个管道建立时,它会创建两个文件描述符:fd[0]为读而打开,fd[1]为写而打开,如下图:

要关闭管道只需将这两个文件描述符关闭即可。

例子:

单个进程中的管道几乎没有任何用处。通常调用pipe的进程接着调用fork,这样就创建了父进程与子进程之间的IPC通道。

    

   图1是从父进程到子进程的管道,图2是从子进程到父进程的管道。

若要数据流从父进程流向子进程,则关闭父进程的读端(fd[0])与子进程的写端(fd[1]);反之,则可以使数据流从子进程流向父进程。

int main ()
{
    int fd[2];
    int pid;
    char buffer[1024] = {0};


    if(pipe(fd) == -1)//创建管道
    {
        printf("we failed to create pipe!\n");
        exit(-1);

    }

    pid = fork();//创建子进程


    if(pid<0)
    {
        printf("we failed to create child process!\n");
        exit(-1);

    }
    else if(pid > 0)//父进程
    {
          close(fd[0]);
          write(fd[1],"hello world!",strlen("hello world!"));

    }
    else if(pid == 0)//子进程
    {
          close(fd[1]);
          read(fd[0],buffer,1024);//如果管道里面没有数据,会一直阻塞直到管道里面有数据

          printf("%s",buffer);

    }

    /*关闭管道关闭这两个文件描述符即可*/
    close(fd[0]);
    close(fd[1]);



    return 0;
}

二.命名管道(FIFO)

FIFO(first in first out),它是一种文件类型。

特点:

1.FIFO可以在无关的进程之间进行数据的交互,这点与无名管道不同。

2.FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。

 原型:

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

参数说明:

其中mode参数于open函数中的mode相同。一旦创建了一个FIFO,就可以用一般的文件I\O函数操控它。

当open一个FIFO时,是否设置非阻塞标志(O_NONBLOCK)的区别:

  1.若没有指定O_NONBLOCK(默认不设置),只读open要阻塞到某个其他进程为写而打开此FIFO,类似的,只写open要阻塞到某个其他进程为读而打开它。

  2.若指定了O_NONBLOCK,则只读open立即返回。而只写open将出错返回-1.

 

例子:

FIFO的通信方式类似于在进程中使用文件来传输数据 ,只不过FIFO类型文件同时具有管道的特性。在数据读出时,FIFO管道中同时清除数据,并且“先进先出”。

往管道写入数据的进程代码如下:

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

int main ()
{
     int fd;
     char* pstr = "hello world";

     fd = open("./file",O_WRONLY);//打开fifo管道

     write(fd,pstr,strlen(pstr));//往管道里面写数据


}

从管道里面读取数据的进程代码如下:

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


int main ()
{
    int fd;
    char buffer[1024] = {0};
    int n_read;

    if( mkfifo("./file",0600) == -1 && errno!=EEXIST)//创建管道
     {
             printf("we failed to make fifo\n");
     }

    fd = open("./file",O_RDONLY);//打开fifo管道     


    n_read = read(fd,buffer,1024);//读取管道中的数据    

    printf("read %d bytes,context is %s\n",n_read,buffer);


    close(fd);//关闭fifo管道

    return 0;
}

三.消息队列

消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。

特点:

1.消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。

2.消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。

3.消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。

消息队列API原型:

1.  int msgget(key_t key, int msgflg);//创建或者打开消息队列

2.  int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);//添加消息

3.ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg);//读取消息

4.int msgctl(int msqid, int cmd, struct msqid_ds *buf);//控制消息队列

在以下两种情况下,msgget将创建一个新的消息队列:

1.如果没有与键值key相对应的消息队列,并且flag中包含了IPC_CREAT标志位。

2.key参数为IPC_PRIVATE。

 

 

键值:

ftok函数(系统IPC键值的格式转换函数):系统建立IPC通讯(消息队列,信号量和共享内存)时必须指定一个ID值。通常情况下,该id值通过ftok函数得到。

函数原型:

key_t ftok(const char *pathname, int proj_id);

参数说明:

pathname是你指定的文件名,一般使用当前目录,如:

key_t  key;

key = ftok(".",1);这样就将pathname设为当前目录。

id是子序号。虽然是int类型,但是只使用8bits(1-255).

在一般的unix实现中,是将文件的索引节点号取出,前面加上子序号得到key_t的返回值。

如指定文件的索引节点号为65538,换算成16进制为0x010002,而你指定的ID值为38,换算成16进制为0x26,则最后的

key_t返回值为0x26010002.

查询文件索引节点号的方法是:ls   -i

键值获取代码如下:

#include <sys/types.h>
#include <sys/ipc.h>
#include <stdio.h>

int main ()
{
    int key;

    key = ftok(".",1);


    printf("key = %x\n",key);//以16进制的形式打印


    return 0;
}

读取消息队列里面的数据代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>



/*消息队列节点*/
struct msgbuf {

        long mtype; //消息类型      
        char mtext[1024]; //消息内容 
};


int main ()
{
   int key;
   int msgId;
   key = ftok(".",1);
   struct  msgbuf readbuffer ={ .mtext ={0}};

   /*消息队列的创建*/
   msgId = msgget(key,IPC_CREAT|0777);

   if(msgId == -1)
   {
        printf("创建消息队列失败!\n");
        exit(-1);

   }

   /*获取消息队列里面的数据*/

   msgrcv(msgId,&readbuffer,sizeof(readbuffer.mtext),999,0);//999代表消息类型,0代表用默认的方式接受消息

   /*打印读取到的消息*/

         printf("mtext:%s\n",readbuffer.mtext);

   /*移除消息队列*/

   msgctl(msgId,IPC_RMID,NULL);

   return 0;
}

往消息队列加入数据代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include <string.h>


/*消息队列节点*/
struct msgbuf {

        long mtype; //消息类型      
        char mtext[1024]; //消息内容 
};


int main ()
{
   struct msgbuf sendbuffer ={999,"this is message from queue"};
   int key;
   int msgId;
   key = ftok(".",1);

   /*消息队列的创建*/
   msgId = msgget(key,IPC_CREAT|0777);

   if(msgId == -1)
   {
        printf("创建消息队列失败!\n");
        exit(-1);

   }

   /*把数据加入消息队列*/

   msgsnd(msgId,&sendbuffer,strlen(sendbuffer.mtext),0);//0代表默认


   /*移除消息队列*/

   msgctl(msgId,IPC_RMID,NULL);

   return 0;
}

四.共享内存

共享内存就是使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。

函数原型:

1.int shmget(key_t key, size_t size, int shmflg);//创建或者获取一个共享内存

2.void *shmat(int shmid, const void *shmaddr, int shmflg);//挂载共享内存

3.int shmdt(const void *shmaddr);//卸载共享内存

4.int shmctl(int shmid, int cmd, struct shmid_ds *buf);//控制共享内存

 

说明:

当用shmget函数创建一段共享内存时,必须指定其size,而如果引用一个已存在的共享内存,则将size指定为0.

当一段共享内存被创建以后,它并不能被任何进程访问。必须使用shmat函数连接该共享内存到当前进程的地址空间,连接成功后把共享内存区对象映射到调用进程的地址空间,,随后可像本地空间一样访问。

shmdt函数是用来断开shmat建立的连接的。注意,这并不是从系统中删除该共享内存,只是当前进程不能再访问该共享内存而已。

shmctl函数可以对共享内存执行多种操作,根据参数cmd执行相应的操作。常用的是IPC_RMID(从系统中删除该共享内存)。

 

往共享内存写入数据代码如下:

#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>


int main ()
{
    int key;
    int shmId;
    char* shmaddr;

    key = ftok(".",1);


    /*共享内存的创建*/
    shmId = shmget(key,1024*4,IPC_CREAT|0666);//共享内存的大小是以兆对齐的

    if(shmId == -1)
    {
       printf("共享内存的创建\n");
       exit(-1);

    }
    /*挂载共享内存*/
    shmaddr = shmat(shmId,0,0);//0代表默认方式


    /*往共享内存中写入数据*/

    strcpy(shmaddr,"hello world");

    /*延迟5s卸载共享内存然后删除共享内存*/

    sleep(5);
    shmdt(shmaddr);
    shmctl(shmId,IPC_RMID,0);


    return 0;
}

从共享内存代码中读数据代码如下:

#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>


int  main ()
{
   int key;
   int shmId;
   char *shmaddr;

   key = ftok(".",1);

   /*获取共享内存*/
   shmId = shmget(key,0,0);

   if(shmId == -1)
   {
       printf("获取共享内存失败!\n");

   }

   /*挂载共享内存*/

   shmaddr = shmat(shmId,0,0);


   /*读取共享内存数据*/

   printf("data:%s\n",shmaddr);

   /*卸载共享内存*/

   shmdt(shmaddr);

    return 0;
}

五.信号

对于linux来说,实际信号是软中断,许多重要的程序都需要处理信号。信号,为linux提供了一种处理异步事件的方法。比如,终端用户输入了ctrl+c来中断程序,会通过信号机制停止一个程序。

信号概述

1.每个信号都有一个名字和编号,这些名字都以“SIG”开头.

2.信号定义在signal.h头文件中,信号名都定义为正整数。

具体的信号名称可以使用指令“kill   -l”来查看信号的名字以及序号,信号是从1开始编号的,不存在0号信号,kill对于信号0有特殊的应用。

3.信号的处理三种方法:忽略,捕捉和默认动作

忽略信号,大多数信号可以使用这个方式来处理,但是有两种信号不能被忽略(分别是SIGKILL和SIGSTOP).因为他们向内核和超级用户提供了进程终止和停止的可靠方法,如果忽略了,那么这个进程就变成了没人能管理的进程,显然是内核设计者不希望看到的场景

捕捉信号,需要告诉内核,用户希望如何处理某一种信号,说白了就是写一个信号处理函数,然后将这个函数告诉内核。当该信号产生时,由内核来调用用户自定义的函数,以此来实现某种信号的处理。

系统默认动作,对于每个信号来说,系统都对应有默认的处理动作,当发生了该信号,系统会自动执行。(可以用man 7 signal来查看)

信号时是如何来使用呢?

例如:在一个终端运行top指令,在另一个终端使用ps -aux|grep top查看top的PID号。在使用指令 “kill  9 PID”杀死该进程。

信号处理函数的注册:

1.入门版:函数signal

2.高级版:函数sigaction

信号处理发送函数:

1.入门版:kill

2.高级版:sigqueue

函数原型:

 typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
参数说明:signum是信号值,handler是个函数指针,指向的函数返回值为void型,参数类型为int型。

信号处理函数入门版代码如下:

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


/*信号处理函数*/
void handler(int signum)
{
    printf("get signal of %d\n",signum);


}

int main ()
{
   /*SIGINT代表ctrl+c发送的信号*/
   signal(SIGINT,handler);


   while(1);

   return 0;
}

信号处理发送函数入门版代码如下:

#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>



//int kill(pid_t pid, int sig);


int main (int argc,char **argv)
{
   
        
   /*命令行参数判断*/
   if(argc != 3)
   {
       printf("param error!\n");
       exit(-1);
   }


   int pid;
   int sig;

   pid = atoi(argv[1]);//命令行参数属于字符,通过atoi函数转换成int型
   sig = atoi(argv[2]);

   /*信号发送函数,往进程号为pid的进程发送sig信号*/
   kill(pid,sig);


   return 0;
}

kill和signal已经可以完成信号的发送和接受处理,但是不能在发送和接受处理的时候携带数据。这个时候需要用到高级版函数sigaction和sigqueue。

sigaction的函数原型

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

struct sigaction {
        void     (*sa_handler)(int);//信号处理函数,
        void     (*sa_sigaction)(int, siginfo_t *, void *);
        sigset_t   sa_mask;
        int        sa_flags;
        void     (*sa_restorer)(void);
};

参数说明:

第一个参数signum是注册信号编号,第二个参数act如果不为空说明需要对该信号有新的处理方式,第三个参数oldact如果不为空,可以对之前的信号处理方式进行备份,以方便之后进行恢复。

struct sigaction结构体中的sa_mask成员,设置信号集中的信号,会在捕捉函数调用前设置为 阻塞,并在捕捉函数返回时恢复默认原有设置。这样的目的是,在调用信号处理函数时,就可以阻塞信号了。在信号处理函数被调用时,操作系统会建立新的信号阻塞字,包括正在被递送的信号。因此,可以保证在处理一个给定信号时,如果这种信号再次发生,那么他会被阻塞到对之前一个信号的处理结束为止。

sigaction的时效性:当对某一个信号设置了指定的动作的时候,那么,直到再次显式调用sigaction并改变动作之前都会一直有效。

结构体中的sa_flags如果设置为SA_SIGINFO属性时,说明了信号处理程序带有附加信息,也就是会调用sa_sigaction这个函数指针所指向的信号处理函数。否则,系统会默认使用sa_handler所指向的信号处理函数。sa_sigaction和sa_handler使用的是同一块内存空间,相当于union,所以只能设置其中的一个,不能两个都同时设置。

关于 void     (*sa_sigaction)(int, siginfo_t *, void *);信号处理函数的参数说明如下:

void*是接收到信号所携带的额外数据,而结构体struct  siginfo_t这个结构体主要适用于记录接收信号的一些信号的一些相关信息。 

     The siginfo_t data type is a structure with the following fields:

           siginfo_t {
               int      si_signo;     /* Signal number */
               int      si_errno;     /* An errno value */
               int      si_code;      /* Signal code */
               int      si_trapno;    /* Trap number that caused
                                         hardware-generated signal
                                         (unused on most architectures) */
               pid_t    si_pid;       /* Sending process ID */
               uid_t    si_uid;       /* Real user ID of sending process */
               int      si_status;    /* Exit value or signal */
               clock_t  si_utime;     /* User time consumed */
               clock_t  si_stime;     /* System time consumed */
               sigval_t si_value;     /* Signal value */
               int      si_int;       /* POSIX.1b signal */
               void    *si_ptr;       /* POSIX.1b signal */
               int      si_overrun;   /* Timer overrun count;
                                         POSIX.1b timers */
               int      si_timerid;   /* Timer ID; POSIX.1b timers */
               void    *si_addr;      /* Memory location which caused fault */
               long     si_band;      /* Band event (was int in
                                         glibc 2.3.2 and earlier) */
               int      si_fd;        /* File descriptor */
               short    si_addr_lsb;  /* Least significant bit of address
                                         (since Linux 2.6.32) */
               void    *si_lower;     /* Lower bound when address violation
                                         occurred (since Linux 3.19) */
               void    *si_upper;     /* Upper bound when address violation
                                         occurred (since Linux 3.19) */
               int      si_pkey;      /* Protection key on PTE that caused
                                         fault (since Linux 4.6) */
               void    *si_call_addr; /* Address of system call instruction
                                         (since Linux 3.5) */
               int      si_syscall;   /* Number of attempted system call
                                         (since Linux 3.5) */
               unsigned int si_arch;  /* Architecture of attempted system call
                                         (since Linux 3.5) */
           }

其中成员很多,si_signo和si_code是必须实现的两个成员。可以通过这个结构体获取到信号的相关信息。关于发送过来的数据是存在两个地方的,sigval_t si_value这个成员中有保存了发送过来的信号;同时,在si_int或者si_ptr成员中也保存了对应的数据。

sigqueue的函数原型

int sigqueue(pid_t pid, int sig, const union sigval value);

union sigval {
        int   sival_int;//支持发送int型数据
        void *sival_ptr;//支持发送字符串类型数据
};

使用这个函数之前,必须要有几个操作需要完成

1.使用sigaction函数安装信号处理程序时,制定SA_SIGINFO的标志。

2.sigaction结构体中的sa_sigaction成员提供了信号捕捉函数。如果实现的是sa_handler成员,那么将无法获取额外携带的数据。

sigqueue函数只能把信号发送给单个进程,可以使用value参数向信号处理程序传递整数值或者指针值。

sigqueue函数不但可以发送额外的数据,还可以让信号进程排队(操作系统必须实现了POSIX.1的实时扩展),对于设置了阻塞的信号,使用sigqueue发送多个同一信号,在解除阻塞时,接收者会接收到发送的信号队列中的信号,而不是直接收到一次。

sigaction代码如下:

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


/*信号处理函数*/
void handler(int signum,siginfo_t *info,void *context)
{
    printf("获取的信号为%d\n",signum);

    /*判断是否有数据到来*/
    if( context != NULL)
    {
       printf("get data %d\n",info->si_int);//打印接受到的整形数据
       printf("发送消息的进程id是 %d\n",info->si_pid);
    }

}


int main ()
{
   /*打印pid的值*/
   printf("进程id是 %d\n",getpid());



   struct sigaction act = {

       .sa_sigaction = handler,//handler是信号处理函数
       .sa_flags = SA_SIGINFO//设置SA_SIGINFO说明信号处理函数带有附加信息


   };


    /*信号处理函数高级版*/
    sigaction(SIGUSR1,&act,NULL);

   while(1);


    return 0;
}

sigqueue代码如下:

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



int main (int argc,char **argv)
{
    int signum;
    int pid;

    /*命令行参数判断*/
    if(argc != 3)
    {
          printf("param error!\n");
          exit(-1);
    }

    signum = atoi(argv[1]);//因为命令行属于字符需要用atoi转换成int型
    pid = atoi(argv[2]);



      union sigval value;
      value.sival_int = 100;

    /*发送信号函数高级版*/
    sigqueue(pid,signum,value);


    /*打印pid的值*/
    printf("发送消息的进程id是 %d\n",getpid());


    return 0;
}

注意:

value.sival_ptr指向的地址在两个进程中是不同的。siginfo_t的si_value.sival_ptr确实可以存放一个指针,但是如果接收信号的进程无法访问发送进程的地址空间,这个指针毫无意义。siginfo_t 的si_value.sival_ptr提供了进程间通过信号传送任意数据的机制,但是要真正实现,需要两个进程能够有共享内存来存放这些数据。

六.信号量

信号量(semaphore)雨已经介绍过的IPC结构不同,它是一个计数器。信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。

特点:

1.信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。

2.信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作。

3.每次对信号量的PV操作不仅限于对信号量值加1或减1,而且可以加减任意正整数。

4.支持信号量组。

原型:

最简单的信号量是只能取0和1的变量,这也是信号量最常见的一种形式,叫做二值信号量(Binary Semaphore)。而可以取多个正整数的信号量称为通用信号量。

linux下的信号量函数都是在通用的信号量数组上进程操作,而不是在一个单一的二值信号量上进行操作。


/*创建或获取一个信号量组:若成功返回信号量集ID,失败返回-1*/
int semget(key_t key, int nsems, int semflg);

/*对信号量组进行操作,改变信号量的值:成功返回0,失败返回-1*/
int semop(int semid, struct sembuf *sops, size_t nsops);

/*控制信号量的相关信息*/
int semctl(int semid, int semnum, int cmd, ...);

当semget创建新的信号量集合时,必须指定集合中信号量的个数(即nsems),通常为1;如果是引用一个现有的集合,则将nsems指定为0.

 

信号量的使用demo:

#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>

//联合体,用于semctl初始化
 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) */
           };



/*放锁函数*/
/*释放资源并将信号量值+1*/
/*如果有进程正在挂起等待,则唤醒它们*/
void vreturnKey(int id)
{
      struct sembuf set;

      set.sem_num = 0;/*序号*/
      set.sem_op  = 1;/*v操作*/
      set.sem_flg = SEM_UNDO;

      semop(id,&set,1);//1代表只操作了一个

      printf("returnkey\n");

}
/*拿锁函数*/
/*若信号量值为1,获取资源并将信号量值-1*/
/*若信号量值为0,进程挂起等待*/
void pGetKey(int id)
{
      struct sembuf set;

      set.sem_num = 0;/*序号*/
      set.sem_op  = -1;/*v操作*/
      set.sem_flg = SEM_UNDO;

      semop(id,&set,1);//1代表只操作了一个

      printf("getkey\n");

}



int main ()
{
    int key;
    int semId;
    int pid;
    key = ftok(".",1);


    /*创建或者创建一个信号量组,参数1代表信号量集合中有一个信号量*/
    semId = semget(key,1,IPC_CREAT|0666);

    union semun initsem;
    initsem.val = 0;//锁被子进程拿走(初始值:初值设为0资源被占用)


    /*初始化信号量,参数0代表操作第0个信号量,SETVAL设置信号量的值,设置为initsem*/
    semctl(semId,0,SETVAL,initsem);


    /*创建子进程*/
     pid = fork();

    if(pid >0)
    {
        pGetKey(semId);/*拿锁*/

        printf("this is father process\n");

       vreturnKey(semId);/*锁放回去*/
       semctl(semId,0,IPC_RMID);//销毁锁

    }
    else if(pid == 0)
    {
        printf("this is child process\n");

        vreturnKey(semId);//放回锁
    }
    else
    {
        printf("no child process is created!\n");

    }



    return 0;
}
     

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值