欢迎光临
我们一直在努力

linux(10)进程间通信

前言:

依旧月更博主这一块,今天早上八点多爬起来打算要写来着,配一个Ubuntu环境花了一个小时,不过现在至少可以在vscode上面写代码了,不用再看那个雷霆vim了,写的时候真的能气死我,废话少说,依旧前言大纲环节

今天我们来学一下进程间通信:

首先我们先来聊一下进程间通信这个东西,(它诞生的目的?)

之后我们来看一看,早期我们使用的进程间通信方法(匿名管道)

在了解了匿名管道之后,我们来聊一聊命名管道

之后呢,我们再来学习一下system V标准(共享内存,消息队列,信号量)

其中,我们主要来聊一聊共享内存,因为以上三个东西,都是一个标准,所以使用起来大同小异,所以学会一个另外两个也就会了

最后我们来对进程间通信做一个总结,内核是如何组织ipc资源的?(ipc就是进程间通信)

正文:

进程间通信:

目的:

①. 数据传输:⼀个进程需要将它的数据发送给另⼀个进程

②. 资源共享:多个进程之间共享同样的资源。

③. 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进程终⽌时要通知⽗进程)。

④.  进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变

发展历程:

①  管道

②  system V 进程间通信

③  POSIX 进程间通信

匿名管道:

管道是什么?

我们把一个进程连接到另一个进程的数据流称为一个管道,这是unix中最古老的进程间通信的方式(linux不就是在unix之上延伸过来的吗)

匿名管道如何使用?

我们使用的是pipe函数:

函数原型:

int pipe(int fd[2]);

  • fd[0]:管道读端,只能 read
  • fd[1]:管道写端,只能 write

我们可以把0,看成一个张开的嘴,那不就是用嘴读么

:

同理我们可以把1看成一支笔,那不就是写么;

示例代码:

#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include <sys/wait.h>
int main()
{
int fd[2];
pipe(fd);
//fd[0]是读端,fd[1]是写端
//我们今天让子进程读,父进程写
pid_t pid=fork();
//子进程拿到的返回值是0,父进程拿到的是子进程的pid
if(pid==0)
{
//子进程
close(fd[1]);
char buf[100];
int n=read(fd[0],buf,sizeof(buf)-1);
//为什么减一?因为我们从管道里读数据,管道里的数据可没有\\0的规定,所以我们要预留一个位置,给它手动加上\\0
buf[n]='\\0';
printf("子进程读到了:%s",buf);
exit(0);
}
//父进程
close(fd[0]);
char msg[]="hello kg";
write(fd[1],msg,strlen(msg));
wait(NULL);
return 0;
}

我们运行以后的结果是:

wuchun@VM-0-10-ubuntu:~/10_6$ gcc -o guandao guandao.c
wuchun@VM-0-10-ubuntu:~/10_6$ ./guandao
子进程读到了:hello kgwuchun@VM-0-10-ubuntu:~/10_6$

所以这样我们就实现了父进程写,子进程读的过程;那么具体是怎么实现的?我们来看看底层原理。

不过在此之前,我们先来简单说一下

vscode的使用:

我们在编写完代码以后,按ctrl+s,那么代码才会保存。

那么下面那个窗口哪里来的呢?我们按ctrl+~就出现了下面的界面,我们可以直接在下面进行命令行操作,就相当于我们的xshell了。

匿名管道底层原理:

真难画……

可以见得,父进程创建子进程后,子进程的fd表,也是拷贝的父进程的表。父进程使用pipe函数,从而父进程的表中,fd[0]指向了读的file,fd[1]指向了写的file,而子进程拷贝了父进程的表后,指向和父进程一样。

//这里的管道文件,file指向同一个inode,从而管理着同一个内核缓冲区,但是这个匿名管道,它虽然有自己的inode,但是它不会写到磁盘里

那么这时候,我把父进程的读端关了,那么它就只能写了,我把子进程的写端关了,那么它就只能读了,从而通过这样,我们就可以通过让父子进程看到同一个内核缓冲区,从而在内核缓冲区中,进行通信。

那么我们总结一下匿名管道:

小总结:

匿名管道的数据存在内核内存里,不会写入磁盘;管道(inode + 缓冲区 + 一对 struct file)是 pipe () 系统调用,由内核创建。

谁来创建?

调用 pipe(fd) 这个系统调用 → 内核来创建: 内核一次性生成三样东西:

① 1 个管道 inode(内存 inode,记录管道属性、引用计数)

② 1 块内核环形缓冲区(存放管道字节数据)

③ 2 个 struct file(读端、写端,绑定这个 inode)

匿名管道一个父进程创建多个管道:

那么,如果我一个父进程,我要同时和好几个子进程通信,我不就要创建好几个管道么,就比如说,我们要创建一个内存池:(父进程写,所有子进程都是读)

那么我们理想的情况是,父进程把所有写端的fd保存起来,然后在要写的时候,选择fd,然后向对应的子进程发送信息,那么假如我这样写:

for(int i=0;i<4;i++)
{
fork();
//……
}

最后在等待的时候是会出问题的!!!!

为什么呢?

我们此时发现,父进程关闭了3,保留了4(写),同时子进程1关闭了4,保留了3(读)

那么我们如果再创建一个子进程呢?我们还记得,fd表的规律是,选择最小的未使用的fd序号,所以对父进程而言,新的读还是3(因为先前的被关了),而新的写是5,而子进程2又会继承父进程的fd表,所以这个子进程2,就有了子进程1的写端(4)!!!!

那么由此得出,我们继续创建子进程,每一个子进程,都会有先前所有子进程的写端!!!

从而结论是,所有的子进程读端都是3,写端则看它是第几个子进程咯

匿名管道读端写端的四种情况:

  • 读正常 && 写满 缓冲区写满,write 阻塞,等读端读走一部分数据腾出空间,写才继续。
  • 写正常 && 读空 缓冲区没有数据,read 阻塞,等待写端写入数据。
  • 写关闭 && 读正常 所有写端 fd 全部 close。read读到数据正常返回;读完剩余数据后,再次 read 返回0(代表读到 EOF)。
  • 读关闭 && 写正常 所有读端 fd 全部 close。进程调用 write,会收到 SIGPIPE 信号,进程默认直接被杀掉;write 返回 – 1,errno=EPIPE。
  • 测试1:

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

    int main()
    {
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
    close(fd[0]);
    char buf[5000];
    printf("子进程开始写大量数据\\n");
    write(fd[1],buf,5000);
    printf("写完成!\\n");
    close(fd[1]);
    exit(0);
    }
    else
    {
    sleep(3); //等子写,缓冲区写满,子write阻塞
    printf("父进程准备读\\n");
    char recv[5000];
    read(fd[0],recv,5000);
    printf("父读取成功\\n");
    wait(NULL);
    }
    return 0;
    }

    wuchun@VM-0-10-ubuntu:~/10_6$ ./ceshi1
    子进程开始写大量数据
    写完成!
    父进程准备读
    父读取成功

    测试2:

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

    int main()
    {
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
    close(fd[1]);
    char buf[100];
    printf("子进程准备读,缓冲区为空,read阻塞\\n");
    read(fd[0],buf,100);
    printf("子读到数据:%s\\n",buf);
    close(fd[0]);
    exit(0);
    }
    else
    {
    sleep(3);
    printf("父开始写入\\n");
    write(fd[1],"hello",5);
    close(fd[1]);
    wait(NULL);
    }
    return 0;
    }

    wuchun@VM-0-10-ubuntu:~/10_6$ ./ceshi2
    子进程准备读,缓冲区为空,read阻塞
    父开始写入
    子读到数据:hello

    测试3:

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

    int main()
    {
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
    close(fd[0]);
    write(fd[1],"abc123",6);
    printf("子写完,关闭写端\\n");
    close(fd[1]);
    exit(0);
    }
    else
    {
    sleep(1);
    char buf[100];
    int n = read(fd[0],buf,100);
    printf("读到%d字节:%s\\n",n,buf);
    n = read(fd[0],buf,100);
    printf("再次read返回:%d\\n",n); //返回0 EOF
    wait(NULL);
    }
    return 0;
    }

    测试4:

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

    int main()
    {
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
    close(fd[1]);
    close(fd[0]); //子直接关闭唯一读端
    sleep(2);
    exit(0);
    }
    else
    {
    sleep(1);
    printf("父准备写,读端已经全部关闭\\n");
    write(fd[1],"test",4); //触发SIGPIPE,进程终止
    printf("write不会执行到这里\\n");
    wait(NULL);
    }
    return 0;
    }

    同步与互斥:

    我们先引入一组概念:

    共享资源:多个进程都能访问的资源。

    临界资源:共享资源里,一次只允许一个进程访问的那一类

    而我们的匿名管道,很明显就是临界资源:

    而访问临界资源的时候,我们要遵守的原则是,同步与互斥:

    互斥:

    多个进程不能同时访问同一个临界资源,同一时间只允许一个进程进行操作;

    同步:

    进程之间按照约定顺序运行,一个等待另一个,协调先后次序

    例如:

    ①缓冲区空 → 读进程阻塞,等着写进程写入数据;

    ②缓冲区满 → 写进程阻塞,等着读进程拿走数据。

    特点:

    在匿名管道中,通信的双方必须是有血缘关系的(父子,兄弟之类)

    命名管道:

    而如果我们想要不依赖血缘关系就达成使用管道通信呢?

    我们就要用命名管道了:

    那么怎么用?

    用法:

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

    • 功能:在磁盘创建 FIFO 特殊文件,生成对应的 inode。只创建文件,不打开
    • 参数:
      • pathname:管道文件路径名
      • mode:文件权限,如 0664
    • 返回值:成功返回 0;失败返回 – 1,设置 errno

    来个例子:

    我们今天来实现从client向server传消息

    client.c

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <string.h>
    int main()
    {
    int wfd=open("mypipe",O_WRONLY);
    char writebuf[1000];
    while(1)
    {
    printf("please enter:\\n");
    int s=read(0,writebuf,sizeof(writebuf)-1);
    writebuf[s]=0;
    write(wfd,writebuf,strlen(writebuf));
    }
    return 0;
    }

    server.c

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <unistd.h>
    #include <stdlib.h>
    int main()
    {
    //server是读
    if(mkfifo("mypipe",0664)==-1)
    {
    perror("mkfifo");
    exit(1);
    }
    int rfd=open("mypipe",O_RDONLY);
    char readbuf[1000];
    while(1)
    {
    printf("等待client输入\\n");
    int n=read(rfd,readbuf,sizeof(readbuf)-1);
    if(n>0)
    {
    readbuf[n]=0;
    printf("client said:%s",readbuf);
    }
    }
    return 0;
    }

    makefile:

    .PHONY:all
    all:server client
    server:server.c
    gcc -o $@ $^
    client:client.c
    gcc -o $@ $^
    .PHONY:clean
    clean:
    rm server client mypipe

    这里因为要同时make两个的话,实际上只能make出第一个,所以我们直接在第一行来一个all,让all的产生依赖server和client,那么all,server,client入栈,从而全部生成;

    那么我们一个进程输入:

    一个进程输出:

    这样就实现了我们的命名管道,我们可以发现,我们的client和server,什么关系都没有,但是可以通过命名管道,从而实现进程间通信;

    那么我们可以发现,命名管道和匿名管道的共性是什么?

    共性:

    先让一个进程创建管道,再让要进行通信的进程获取;

    表格对比:

    对比项匿名管道 pipe命名管道 mkfifo(FIFO)
    创建方式 pipe()系统调用 mkfifo()函数 / mkfifo命令
    存在形式 没有实体文件,只有内核缓冲区 存在管道文件(在磁盘有 inode,但不存数据)
    通信进程关系 只能用于有亲缘关系进程(父子 / 兄弟) 无亲缘关系进程也能通信
    访问方式 依靠继承文件描述符 像普通文件一样open打开,拿到 fd
    生命周期 进程全部退出,管道自动销毁 管道文件本身永久存在,所有打开进程关闭后内核缓冲区释放
    数据存储 数据存在内核缓冲区,不在磁盘 数据同样只存在内核缓冲区,磁盘只留文件入口
    通信方向 半双工,单向数据流 半双工,单向数据流
    同步互斥 内核自带同步、互斥机制 内核自带同步、互斥机制
    读写规则 数据读取后缓冲区清除,不可重复读 数据读取后缓冲区清除,不可重复读

    画图理解:

    共享内存:

    以上是我们对管道的理解,那么,我们想象一下:

    如果说:

    我们都知道,一个进程的task_struct里面存着vm_area_struct的链表头,也存着指向页表的指针,那么只要我们在物理内存里面,把同一块物理内存映射到不同的两个进程里,那么我们是不是就可以实现进程间通信,而且一旦达成,就不再需要使用什么系统调用,write,read,这不是很快么

    这种想法当然可以,所以我们直接来共享内存:

    来看看共享内存数据结构长啥样:

    struct shmid_ds {
    struct ipc_perm shm_perm; // IPC权限:uid/gid、读写权限、key
    int shm_segsz; // 共享内存段大小(字节)
    __kernel_time_t shm_atime; // 最后一次shmat挂载时间
    __kernel_time_t shm_dtime; // 最后一次shmdt卸载时间
    __kernel_time_t shm_ctime; // 最后一次修改shmid_ds属性的时间
    __kernel_ipc_pid_t shm_cpid; // 创建该共享内存段的进程pid
    __kernel_ipc_pid_t shm_lpid; // 最后一次执行shmat/shmdt的进程pid
    unsigned short shm_nattch; // 当前挂载的进程数量(重点!引用计数)
    unsigned short shm_unused; // 兼容保留字段
    void *shm_unused2;
    void *shm_unused3;
    };

    这里的第一个成员:

    struct ipc_perm shm_perm很重要,我们把它长啥样也扒出来:

    struct ipc_perm {
    key_t __key; // shmget传入的key值
    uid_t uid; // 所有者用户ID
    gid_t gid; // 所有者组ID
    uid_t cuid; // 创建者用户ID
    gid_t cgid; // 创建者组ID
    unsigned short mode; // 权限位(类似文件rwx权限)
    unsigned short __seq; // 序列号,IPC对象的序号
    };

    如何使用呢?

    1. shmget 创建 / 获取共享内存段

    int shmget(key_t key, size_t size, int shmflg);

    • key:IPC 键,当做共享内存的名字
    • size:共享内存字节大小,必须是页的整数倍
    • shmflg:IPC_CREAT 创建;IPC_CREAT|IPC_EXCL 不存在才创建;加上权限如0666
    • 返回:成功返回 shmid;失败-1
    2. shmat 挂载(关联到进程虚拟地址空间)

    void *shmat(int shmid, const void *shmaddr, int shmflg);

    • shmid:shmget 返回的共享内存 id
    • shmaddr:指定虚拟地址,填 NULL 让内核自动分配(最常用)
    • shmflg:SHM_RDONLY 只读挂载;0 可读可写
    • 返回:成功返回共享内存起始虚拟地址;失败(void *)-1
    3. shmdt 卸载(脱离进程地址空间)

    int shmdt(const void *shmaddr);

    • shmaddr:shmat 返回的指针
    • 返回:成功 0;失败 – 1
    4. shmctl 控制共享内存(获取信息 / 修改属性 / 删除)

    int shmctl(int shmid, int cmd, struct shmid_ds *buf);

    • shmid:共享内存 id
    • cmd:
      • IPC_STAT:读取共享内存信息,存入 buf(shmid_ds)
      • IPC_SET:修改权限、uid 等属性
      • IPC_RMID:标记删除共享内存段
    • buf:struct shmid_ds 结构体指针,IPC_RMID 时传 NULL 即可
    • 返回:成功 0;失败 – 1

    来个例子:

    client.c

    #include <stdio.h>
    #include <unistd.h>
    #include <sys/shm.h>
    #include <sys/ipc.h>
    #include <sys/types.h>
    #include <string.h>
    int main()
    {
    key_t key=ftok("shm_key",'x');
    int shmid=shmget(key,4096,0666);
    char* shmaddr=shmat(shmid,NULL,0);
    strcpy(shmaddr, "hello shared memory!");
    printf("client写入完成\\n");
    shmdt(shmaddr);
    return 0;
    }

    server.c

    #include <stdio.h>
    #include <unistd.h>
    #include <sys/shm.h>
    #include <sys/ipc.h>
    #include <sys/types.h>
    #include <string.h>
    int main()
    {
    key_t key=ftok("./shm_key",'x');
    int shmid=shmget(key,4096,IPC_CREAT | 0666);
    char* shmaddr=shmat(shmid,NULL,0);
    printf("server等待client写入数据:\\n");
    while(1)
    {
    printf("server读到了:%s\\n",shmaddr);
    sleep(1);
    }
    shmdt(shmaddr);
    shmctl(shmid,IPC_RMID,NULL);
    return 0;
    }

    结果如下:

    但是我们发现:

    共享内存没有进⾏同步与互斥! 共享内存缺乏访问控制!会带来并发问题

    关于共享内存,消息队列,信号量:

    三者都依靠 key_t,用 ftok() 获取 key;都可以用 ipcs / ipcrm 查看、删除内核对象

    1. 共享内存 shm

    // 创建/获取共享内存
    int shmget(key_t key, size_t size, int shmflg);
    // 挂载到进程虚拟地址
    void *shmat(int shmid, const void *shmaddr, int shmflg);
    // 卸载映射
    int shmdt(const void *shmaddr);
    // 控制(获取属性/删除)
    int shmctl(int shmid, int cmd, struct shmid_ds *buf);

    2. 消息队列 msg

    // 创建/获取消息队列
    int msgget(key_t key, int msgflg);
    // 发送消息
    int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
    // 接收消息(无消息默认阻塞)
    int msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
    // 控制(获取属性/删除)
    int msgctl(int msqid, int cmd, struct msqid_ds *buf);

    3. 信号量 sem

    // 创建/获取信号量集合
    int semget(key_t key, int nsems, int semflg);
    // P/V操作
    int semop(int semid, struct sembuf *sops, size_t nsops);
    // 控制(初始化值/删除)
    int semctl(int semid, int semnum, int cmd, …);

    所以三者其实使用起来其实使用方法差不多,我们学会了共享内存,其它几个自然也是信手拈来

    那么我们接下来要看的是,内核如何管理ipc资源?

    内核如何管理ipc资源:

    三者的数据结构:

    共享内存:

    struct shmid_ds {
    struct ipc_perm shm_perm; // IPC权限
    size_t shm_segsz; // 共享内存段字节大小
    time_t shm_atime; // 最后挂载shmat时间
    time_t shm_dtime; // 最后卸载shmdt时间
    time_t shm_ctime; // 最后修改属性时间
    pid_t shm_cpid; // 创建者pid
    pid_t shm_lpid; // 最后shmat/shmdt的进程pid
    shmatt_t shm_nattch; // 当前挂载的进程数量(shm_nattch就是shm_nattch,对应shm_nattch,就是之前shm_nattch,也就是shm_nattch,shm_nattch,shm_nattch,shm_nattch)
    };

    消息队列:

    struct msqid_ds {
    struct ipc_perm msg_perm; // IPC权限
    struct msg *msg_first; // 队列第一条消息(内核指针)
    struct msg *msg_last; // 队列最后一条消息
    time_t msg_stime; // 最后msgsnd发送时间
    time_t msg_rtime; // 最后msgrcv接收时间
    time_t msg_ctime; // 最后修改属性时间
    unsigned long msg_cbytes; // 队列当前全部消息字节数
    msgqnum_t msg_qnum; // 当前消息个数
    msglen_t msg_qbytes; // 队列最大允许总字节
    pid_t msg_lspid; // 最后msgsnd的pid
    pid_t msg_lrpid; // 最后msgrcv的pid
    };

    信号量集合:

    struct semid_ds {
    struct ipc_perm sem_perm; // IPC权限
    struct sem *sem_base; // 信号量数组起始地址
    time_t sem_otime; // 最后一次semop操作时间
    time_t sem_ctime; // 最后修改属性时间
    unsigned short sem_nsems; // 信号量集合里面信号量的个数
    };

    我们发现,三者都有一个

    struct  ipc_perm类型的东西:

    struct ipc_perm {
    key_t __key; // ftok生成的key
    uid_t uid; // 所有者用户ID
    gid_t gid; // 所有者组ID
    uid_t cuid; // 创建者用户ID
    gid_t cgid; // 创建者组ID
    unsigned short mode; // 权限rwx
    unsigned short __seq; // IPC序列号
    };

    那么,其实以上我们给出的共享内存,消息队列,信号量,它们都是用户态数据结构,但是它们在内核里,其实和我们看到的结构是不一样的,因为内核里很多属性,我们用户在使用的时候,并不需要考虑,所以它就只把一些我需要考虑的拷贝出来

    而真正内核级数据结构(消息队列):

    struct msg_queue {
    struct kern_ipc_perm q_perm;
    int q_id;
    time_t q_stime; /* last msgsnd time */
    time_t q_rtime; /* last msgrcv time */
    time_t q_ctime; /* last change time */
    unsigned long q_cbytes; /* current number of bytes on queue */
    unsigned long q_qnum; /* number of messages in queue */
    unsigned long q_qbytes; /* max number of bytes on queue */
    pid_t q_lspid; /* pid of last msgsnd */
    pid_t q_lrpid; /* last receive pid */

    struct list_head q_messages;
    struct list_head q_receivers;
    struct list_head q_senders;
    };

    我们发现,我们看到的ipc_perm,它其实在内核里叫kern_ipc_perm。

    那么,我们内核里,总不可能只有一个ipc资源吧?肯定不是,一定是有很多ipc资源的,那么我们就要对它们进行管理,怎么管理?先描述再组织;

    所以我们是怎么组织的呢?

    我们内核里有一个数据结构是:

    struct ipc_id_ary {
    int size;
    struct kern_ipc_perm *p[0];
    };

    下面的是一个柔性数组,它里面存放的是kern_ipc_perm指针,而kern_ipc_perm正好是我们ipc资源的第一个成员,它的地址在数值上等于我们ipc结构体的地址,所以我们通过这个kern_ipc_perm结构体,把一个个共享内存,消息队列,信号量组织起来,我们需要的时候,再进行强转,从而可以实现对ipc资源的管理(多态!)

    可是,我们怎么知道到时候具体强转成共享内存,消息队列,还是信号量呢?

    所以啊:

    struct ipc_ids {
    int in_use;
    int max_id;
    unsigned short seq;
    unsigned short seq_max;
    struct mutex mutex;
    struct ipc_id_ary nullentry;
    struct ipc_id_ary* entries;
    };

    这个数据结构它的最后一个成员,正是指向ipc_id_ary,而这个数据结构,它会创建三个对象:

    shm_ids,msg_ids,sem_ids;

    这不就可以实现知道强转成哪一个数据结构了么,所以我们最后来画一张图:

    尾:

    从早上九点开始忙到下午五点,恍若隔世啊……下次更新就是写信号了,不知道什么时候能写出来,不过可以期待一下?

    感谢观看,祝国庆快乐

    赞(0)
    未经允许不得转载:171主机测评 » linux(10)进程间通信
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址