Linux进程信号的处理

举报
卖寂寞的小男孩 发表于 2022/10/19 08:15:08 2022/10/19
【摘要】 本文主要介绍Linux下进程信号的处理。

@[toc]

零.前言

信号的发送与进程间通信是不同的,信号只能够由操作系统来进行发送,而进程的作用是请求操作系统来发送信号。我们使用control+C可以终止一个进程的本质上其实就是向该进程发送一个2号信号。

一、信号的引入

1.在生活中有很多信号的场景的存在,比如红绿灯,闹钟,老师的脸色等,当我们获得了这些信号之后,我们立刻就知道下一步要去做什么了。注意,信号与进程之间通信的信号量之间是没有任何关系的。
2.同时,只有当绿灯量起来的时候我们才知道应该在绿灯的时候行走吗?显然不是的,进程也是这样,不管是否接收到了信号,进程都知道如果收到这些信号应该做什么。进程收到信号之后应该做什么是由操作系统工程师已经处理好的。
3.当我们收到某一种信号时,不一定立刻去处理该信号,因为可能有重要的事情需要去做。此时就需要将信号存储。存储的位置就是进程的PCB中,信号的本身也是数据,因此在向进程中传递信号的本质就是向PCB中写入数据。
4.信号的发出者只有操作系统,无论我们如何发送信号都是请求操作系统来进行发送的。

通过以上分析,我们可以将信号的发送分为三大部分:分别是信号产生,信号保存和信号处理。在Linux系统中,我们可以使用

kill -l

来查看所有信号:
在这里插入图片描述
注意观察,是没有32和33号信号的,我们只研究前31个信号。

二、信号的产生

1.通过键盘产生

当一个进程是一个死循环的进程时,我们可以使用键盘进程control+C来终止掉进程。control+C的本质就是向该进程传递2号信号从而使该进程终止。注意,键盘只能向前端进程传递信号。
下面来验证一下以上内容:

(1)发送2号信号

要验证这一问题,我们需要认识一个函数:
在这里插入图片描述
在signal这一函数中,它的第一个参数代表的是信号编号。它的第二个参数是一个函数指针,它指向的是一个返回值为void,参数为int的函数,该函数的参数就是signum,该参数会被signal函数自动传入到该函数中。
注意该函数执行的前提是收到了signum号信号,否则不会执行该函数。
当向该进程发送signum信号,则执行handler所指向的函数。我们可以根据该函数这一功能来间接判断发送的是哪一个信号。

#include<stdio.h>    
#include<signal.h>    
#include<unistd.h>    
void handler(int signo)    
{    
  printf("get a signo:%d\n",signo);    
}    
int main()    
{    
  signal(2,handler); //当收到2号信号时执行handler函数   
  while(1)    
  {    
    sleep(1);                                                                                                                                          
    printf("assistant is stupid!\n");    
  }    
}    

此时在进程运行的过程中,我们使用control+C就不会使进程退出了,而是直接执行handler函数:
在这里插入图片描述
我们可以使用control+\,即三号信号来终止进程。

(2)只能向前端进程传递信号

如果我们让进程在后台中运行,那么键盘将无法向进程中输入信号:

./mytest &

在这里插入图片描述
此时我们发现,当在键盘输入control+C的时候,并没有向进程传递信号,此时进程只能通过系统向进程传递9号信号来关闭。

2.程序异常收到信号

(1)程序异常发送信号的现象

当我们对一个空指针进行解引用的时候,程序会发生崩溃退出的,而程序退出的本质就是收到了某种信号,导致了程序的退出,我们可以通过以下代码来找到令空指针解引用程序退出的信号。

#include<stdio.h>    
#include<signal.h>    
#include<unistd.h>    
#include<stdlib.h>    
void handler(int signo)    
{    
  printf("get the signal:%d\n",signo);    
  exit(1);    
}    
int main()    
{    
  int sig=1;    
  for(;sig<=31;sig++)    
  {    
     signal(sig,handler);//接收所有信号    
  }    
  int* p=NULL;    
  *p=100;//Segmentation fault段错误
}            

在这里插入图片描述
此时我们可以发现,传入的是第11号信号。
我们还可以测试一下,如果进行除0操作而造成崩溃使程序退出的信号:
在这里插入图片描述
我们发现,传入的是8号信号。
因此我们可以得出结论:在win或者Linux系统下,进程崩溃的本质是进程收到了与崩溃对应的信号,然后进程执行信号的默认动作,即杀死进程。

(2)程序异常发送信号的原因

那么为什么会收到信号呢?
在这里插入图片描述
首先我们需要明确,计算机的一切计算操作都是在CPU中完成的,当CPU进行除0操作的时候会出现异常,而操作系统是硬件的管理者,当操作系统得知CPU运算出现异常的时候就会向产生异常进程发信号,使其终止。
因此我们可以得到这样一个结论,程序的异常最终其实都会体现在其他的软件或者硬件上。

(3)如何定位程序异常

当程序异常崩溃时,我们最想知道的就是程序崩溃的原因,在哪里崩溃的。
在Linux系统中,当一个进程退出时,它的退出码和退出信号都会被设置(正常情况),当一个进程异常的时候,进程的终止信号会被设置,表明进程退出的原因。如果必要,OS还会设置退出信息中的标志位core dump(它在status的第8位),并将进程在内存中的数据转移到磁盘中,方便后期调试。
在默认情况下,这种基于core dump的调试方式是被关掉的。当需要进行coredump调试,coredump位被设为1
我们可以通过ulimit指令来进行查看:

ulimit -a //查看系统资源
ullimit -c 10240 //允许进行coredump,设置大小为10240

在这里插入图片描述
此时当我们再运行问题代码时,会带一个core dump的提示,并且在当前目录下,还会找到生成的一个文件,这个就是我们的调试文件:
在这里插入图片描述
此时我们使用gdb来进行调试(注意如果要使用gdb的话需要在生成可执行文件的时候使用-g选项):
此时当我们使用r选项令代码运行起来时,gdb就会自动查看core.2206这个文件,从而给出问题出现的位置:
在这里插入图片描述
这里表示异常发生在代码的28行。
注意,并不是所有的异常(信号)可以进行上述coredump调试。

3.系统调用产生的信号

说人话就是用代码输入信号。
我们不仅可以在终端使用kill -x输入信号,系统也提供了一个名为kill的函数可以用于输入信号:
在这里插入图片描述
它的第一个参数为进程的pid,它的第二个参数为信号序号。通过这个函数我们可以向pid这个进程发送sig号信号。
我们可以使用它以及命令行传参,来模拟实现kill进程:
我们令argv第二个参数为信号,第三个参数为进程的pid,与kill进行对应。

static void Usage(const char* proc)//说明函数    
{    
  printf("Usage:\n\t %s signo who\n",proc);    
}    
int main(int argc,char* argv[])    
{    
//./mytest signo who
if(argc!=3)
{
  Usage(argv[0]);
  return 1;
}
int signo=atoi(argv[1]);
int who=atoi(argv[2]);
printf("signo:%d,who:%d\n",signo,who);
kill(who,signo);
}

在这里插入图片描述
此时就成功地模拟实现了kill进程。
kill函数可以给任何pid的进程输入信号,同时还有一个专门给自己输入信号的函数:raise。

raise(8)代表给自己输入8号信号

还可以使用abort()来给自己的进程输入abort信号(6号信号)。

4.软件条件产生信号

通过某种软件触发信号的发送,系统层面设置定时器,或者某种操作而导致条件不就绪的场景下,触发的信号发送。
比如在进程通信的管道通信中,当读端关闭,写端仍然在继续写,操作系统就会给写端发送sigpipe信号。就是一种典型的软件条件触发的信号发送。
我们再来介绍一个软件条件产生的信号,信号发出的软件时OS。
在这里插入图片描述
它表示的是一个闹钟,意思是seconds秒之后发送14号信号。它的返回值是上一个闹钟剩余的时间。当之前已经设定闹钟且该闹钟没有结束时,再次调用alarm只会取消闹钟,而不是设定新的闹钟。

  int ret=alarm(30);    
  printf("assistant is stupid!\n");    
  while(1)    
  {    
  sleep(1);    
  int res=alarm(0);                                                                                                                                    
  printf("ret:%d,res:%d\n",ret,res);    
  }  

此时我们就可以捕捉到alarm的返回值,即上一个alarm剩余的时钟时间。
我们还可以利用这个alarm函数来记录一下5s内程序运行的速度:

  int count=0;    
  alarm(5);    
  printf("hello world!\n");                                                                                                                            
  while(1)    
  {    
     count++;    
     printf("%d\n",count);    
  }    

在这里插入图片描述
可以看到5s内count被加到了446706。

5.如何理解OS向进程发送信号

OS发送的信号时直接发送给进程的PCB的。在进程的PCB中存在一个32位整数,我们将该整数看成一个位图结构,它的每一位都代表一个信号,当值为1时,表示收到了该信号,当值为0时,表示没有收到该信号。
信号的传递本质是信号的写入,即OS向进程的PCB中的位图中写入比特位,就完成了信号的发送。

三、信号产生中

1.基本概念

1.信号递达:实际执行的信号处理动作叫做信号递达。递达其实就是信号的执行过程。
2.信号未决:信号从产生到递达之间的状态,称为信号未决。
3.信号阻塞:进程可以选择阻塞某个信号。

当进程阻塞某信号时,该信号产生时将处于未决状态,直到进程取消阻塞,才执行递达的动作。只要被阻塞就不会被递达。这其实就是信号保存的过程。
阻塞与忽略的区别在于,忽略已经开始进行处理信号了,只不过处理信号的方式是不处理。

2.三张表

操作系统根据三张表来确定是否处理信号,以及如何处理信号:
这三张表分别是:block,pending,handler
在这里插入图片描述
其中block也是一个32位整数,我们也将其看成一个位图,比特位的位置代表信号的编号,比特位的内容代表是否被阻塞。block也称为屏蔽字。
pending表示的是是否收到该信号。即32位位图。
handler的内容是函数指针,它是一个函数指针数组,如果信号成功递达,则执行该函数指针所指向的函数。其中SIG_DEF表示的是默认,SIG_ICM表示的是忽略。如果我们使用signal改变信号的执行内容,则该函数的地址会被传递给对应信号的handler中。
第一行表示的是,没有收到信号1,信号1没有被阻塞,如果信号1被递达执行默认操作。
第二行表示的是,收到了信号2,信号2被阻塞了,如果信号2递达执行忽略策略。
第三行表示的是,没有收到信号3,信号3被阻塞了,如果收到信号3,执行默认的操作。
等等。。。
注意,如果某个信号被屏蔽了,操作系统就不会关心该信号是否被接收。

3.sigset_t

(1)系统调用类型

系统调用除了可以体现在函数上之外,还体现在操作系统提供的数据类型上。这些数据类型的最终目的也是配合系统调用函数来使用的。
比如在以上的三张表中,block表和pending表的本质其实就是一个32位整数,这个32位整数的类型就是sigset_t,由于它们并不是在用户层出现而是在系层出现的。因此需要操作系统的接口来实现对表的更改。
因此要修改sigset_t类型的数据,不能让用户直接进行修改,而是需要使用系统调用接口来进行修改。

sigset_t s;//定义一个sigset_t的变量
s=10;//错误,用户不能直接修改该变量的值

(2)信号集处理函数

修改sigset_t类型的变量,操作系统提供了如下函数:

int sigemptyset(sigset_t set);//初始化set所指向的信号集,将其中所有信号对应的bit清零。
int sigfillset(sigset_t set);//初始化set所指向的信号集,将所有信号对应的bit置为1。
int sigaddset(sigset_t
set,int signo);//在信号集中添加signo号信号。
int sigdelset(sigset_t
set,int signo);//在信号集中删除signo号信号。
int sigismember(const sigset_t* set,int signo);//判断是否含有signo号信号。

4.sigprocmask与sigpending

(1)sigprocmask

使用man手册查询结果如下:
在这里插入图片描述
该函数是与屏蔽字有关的函数(block):
第二个参数表示传入一个新的sigset_t类型的变量set,是一个输入型参数。
第三个参数表示返回之前的屏蔽字,是一个输出型参数。
第一个参数表示的是对屏蔽字进行的操作,可以传入如下几个变量:

SIG_BLOCK:包含了我们希望添加到屏蔽字的信号,相当于mask=mask|set。
SIG_UNBLOCK:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set。
SIG_SETMASK:设置当前信号屏蔽字为set所指向的值,相当于maks=set。

(2)sigpending

在这里插入图片描述
它的用法很简单:
读取当前进程的pending表,通过set传出。

5.举例

下面用一个例子来具体地使用这些函数:

(1)屏蔽2号信号

#include<stdio.h>    
#include<signal.h>    
#include<stdlib.h>    
#include<unistd.h>    
int main()    
{    
  sigset_t iset,oset;    
  sigemptyset(&iset);    
  sigemptyset(&oset);    
  sigaddset(&iset,2);    
  sigprocmask(SIG_SETMASK,&iset,&oset);    
  while(1)    
  {    
    sleep(1);    
    printf("hello world!\n");                                                                                                                          
  }    
}    

(2)打印信号位图

如果进程首先屏蔽掉2号信号,此时传入2号信号,并不断获取pending位图,并打印显示。由于2号信号不会被递达,因此它一直在pending位图中,我们就可以进行观察了。

void showpending(sigset_t* set)    
{    
  int i=1;    
  for(i=1;i<=31;i++)    
  {    
    if(sigismember(set,i))    
    {    
      printf("1");    
    }    
    else{    
      printf("0");    
    }    
  }    
  printf("\n");    
}    
int main()    
{    
  sigset_t iset,oset;    
  sigemptyset(&iset);    
  sigemptyset(&oset);    
  sigaddset(&iset,2);    
  sigprocmask(SIG_SETMASK,&iset,&oset);    
  sigset_t pending;    
  while(1)    
  {    
    sigemptyset(&pending);    
    sigpending(&pending);    
    showpending(&pending);
    sleep(1);
  }
}        

此时当我们发送2号信号时,就可以看到pending表的变化:
在这里插入图片描述

(3)将2号信号递达

当将信号屏蔽之后,再解除屏蔽我们就可以看到pending表中2号信号由1变成0的过程。
但是由于2号信号递达之后进程会立刻退出,因此我们需要修改2号信号的执行方式来方便进行观察。
我们令20s之后2号信号递达:

  #include<stdio.h>    
  #include<signal.h>    
  #include<stdlib.h>    
  #include<unistd.h>    
  void showpending(sigset_t* set)    
  {    
    int i=1;                                                                                                                                           
    for(i=1;i<=31;i++)    
    {    
      if(sigismember(set,i))    
      {    
        printf("1");    
      }    
      else{    
        printf("0");    
      }    
    }    
    printf("\n");    
  }    
void handler(int signo)    
  {    
    printf("2号信号被递达了!\n");    
  }    
  int main()    
  {    
    signal(2,handler);    
    sigset_t iset,oset;    
    sigemptyset(&iset);    
    sigemptyset(&oset);    
    sigaddset(&iset,2);    
    sigprocmask(SIG_SETMASK,&iset,&oset);    
    sigset_t pending;    
    int count=0;
        while(1)
    {
      count++;
      sleep(1);
      if(count==20)
      {
        sigprocmask(SIG_SETMASK,&oset,NULL);
        printf("恢复2号信号\n");
      }
      sigemptyset(&pending);
      sigpending(&pending);
      showpending(&pending);
    }
  }                                                

在这里插入图片描述

四、信号的处理

1.信号的处理方式

一般而言,信号的处理有三种情况:

1.默认动作:即系统默认处理信号的方式,通常是暂停或者终止。
2.忽略动作:是一种信号处理方式,只不过处理方式是什么也不干。
3.自定义动作:使用signal函数就是在修改信号的处理方式,也称为信号的捕捉。注意,9号信号不能被自定义处理。

2.信号检测和处理

在信号产生中的模块中,我们了解到OS会对进程中信号的三张表进行检测,从而判断是否要执行某一个信号。这里提到了两个关键词,分别是检测和执行,那么这两个动作发生在哪里呢?
要了解这一部分,我们需要了解进程处理过程中的两个状态:用户态和内核态

(1)用户态和内核态

用户态:用户代码和数据被访问或者执行得我时候所处的状态,我们自己的代码都是在用户态被执行的。
内核态:执行OS代码和数据的时候,计算机所处的状态,叫做内核态。OS代码全部在内核态执行。

两者的区别在于权限,它们之间的切换表现为系统调用。
当用户调用系统调用接口时,除了进入函数,身份也会发生变化,会由用户的身份变成内核的身份。
我们知道当执行某个进程的时候,用户的代码和数据会被加载到内存中,同理,内核的代码和数据也一定要加载到内存中。当我们开机的时候就是将内存的代码和数据加载到内存中的过程。
在这里插入图片描述
除了用户区的页表之外,还有内核区的页表,对于任意一个进程来说,它的内核区的内容都是一样的。因此内核区的页表只需要存在一份即可,被多个进程所使用。由于内核页表的存在,我们能够保证所有的进程都能找到同一个操作系统。所谓系统调用,其实就是将进程的身份转换为内核,然后根据内核页表找到系统函数并执行。
同时在CPU内部,有一个CR3的寄存器,它是用来判断当前进程执行的是用户态还是内核态,如果是内核态,它的值就会被赋值为0,如果是用户态它的值就会被赋值为3。

(2)信号检测与处理流程

在这里插入图片描述
当我们执行用户代码的时候,执行到一个系统调用,此时进入内核态,执行完系统调用的函数的代码后需要返回用户态。继续执行用户的下一条代码。在返回之前的需要进行信号的检测与处理操作。当没有信号,或者信号被阻塞,或者信号的处理方式不是自定义的,此时直接进入用户态执行用户的下一条代码。
但如果信号被递达了,且是自定义处理的。此时就需要进入用户态执行该自定义函数,然后再返回内核态的sigret,然后再返回用户态执行下一条代码:
整个处理流程类似数学中的无穷大:
在这里插入图片描述
在处理信号的过程中:
1.当handler为默认状态的时候,直接释放资源,进程结束。(因为在内核态)
2.当handler为忽略状态的时候,直接将pending的1置为0。
3.当handler为自定义的时候,进程由用户态->内核态->用户态->内核态->用户态,一共经历了四次转变。
但是为什么要切换到用户态去执行handler的代码呢?OS显然也有权限去执行,但是它不相信任何人,用户只能使用OS的接口去让OS执行一系列的操作。
结论:当内核态即将切换为用户态的时候,进行信号的检测和处理。

3.修改信号执行的两个函数

其中一个函数就是signal函数,这个在前面已经介绍了,这里不多赘述。
另一个函数shisigaction。我们可以通过man手册进行查询:
在这里插入图片描述
它的第二个参数是一个输入型参数,表示的是一个结构体(这个结构体和函数是同名的),这个结构体中的第一个参数是一个函数指针,指向自定义的信号处理方法:
在这里插入图片描述
它的第三个参数是一个和第二个参数相同类型的结构体,它的第一个元素指向未修改之前的信号处理方法。
下面使用这个函数来自定义一个信号处理的方法:

#include<stdio.h>    
#include<signal.h>    
#include<stdlib.h>    
#include<unistd.h>    
#include<string.h>    
void handler(int signo)    
{    
  printf("get a signo:%d\n",signo);                                                                                                                    
}    
int main()    
{    
  struct sigaction act;    
  memset(&act,0,sizeof(act));    
  act.sa_handler=handler;    
  sigaction(2,&act,NULL);    
  while(1)    
  {    
    printf("hello world!\n");    
    sleep(1);    
  }    
}    

此时就对2号信号完成了修改:
在这里插入图片描述
下面来介绍一下该结构体中的另一个变量:sg_mask:
在这里插入图片描述
当某个信号的处理函数在被调用的时候,内核自动将当前信号加入进程的屏蔽字。当函数结束后,屏蔽字恢复。这样保证了当处理一个信号时,如果该信号再次出现,则不会被处理。直到前一个信号已经被处理结束。
当处理一个信号时,除了该信号,我们还想屏蔽另一些信号,此时就需要sg_mask发挥作用了。
比如进程正在处理2号信号,此时再传入2号信号,信号不会被处理。我们希望不仅仅传入2号信号不会被处理,传入3号信号也不会被处理,就可以将sg_mask的3号信号置为1(sg_mask也是sigset_t类型)。

  sigemptyset(&act.sa_mask);    
  sigaddset(&act.sa_mask,3);          

在代码中加入这两段代码,即将sa_mask的信号3添加。此时当处理信号2的时候,信号3也就被阻塞了。

五、补充概念

1.可重入函数

有这样一种场景,当我们在进行链表的插入时,本质上分为两步:

p->next=head;
head=p;

我们假设这段代码在一个名为insert的函数中执行。
如果执行完第一步之后,接收到了信号,信号的处理方式还是这个insert函数,又向其中插入一个新的节点,这种情况我们称之为函数的重入,它可能带来不好的后果:
在这里插入图片描述
此时就会造成无法找到node2这个节点的后果。
注意,并不是只有遇到系统调用接口的时候进程才会进入到内核态,当CPU调度不同的进程,将其从运行队列前端移到后端的时候,是操作系统进行操作的,因此此时也会进入内核态从而完成对信号的处理。因此信号的处理是可能在任意的时间的。
此时insert在用户层调用一次,在信号捕捉时又调用了一次,我们称之为函数重入,但是由于函数重入造成了我们不想看到的结果,因此我们称insert函数为不可重入函数。与之对应的还有可重入函数。
大部分函数都是不可重入的。

2.volatile关键字

该关键字是C语言的比较冷门的关键字,我们站在信号的角度来认识一下它:

  int flag=0;    
  void handler(int signo)                                                                                                                              
  {    
    flag=1;    
    printf("change flag 0 to 1\n");    
  }    
  int main()    
  {    
    signal(2,handler);    
    while(!flag);    
    printf("进程正常退出\n");    
    return 0;    
  }    

这段代码表达的意思是,这是一个死循环的程序,当收到2号信号的时候,flag由0置为1,结束死循环,程序退出。执行结果是这样的:
在这里插入图片描述
如果我们编译过程中采用优化编译的方式:

gcc test.c -o mytest -O3

此时运行的结果,我们发现死循环不会被终止。
在这里插入图片描述
下面来分析一下原因:
在main执行流的过程中,gcc发现main函数中没有人对flag进行修改,就会进行优化:即将flag永久地存放在寄存器中,下一次CPU调度该进程的时候就不需要进行寻址访问了,而是直接使用寄存器中的值。
在这里插入图片描述
当CPU读取完flag之后,就不会再对其进行读取了。而信号到来,内存中的flag发生了变化在CPU中的flag是不知道的。而判断操作都是在CPU中进行的,因此会一直死循环。
为了避免这一情况,只需要将flag定义为:

volatile int flag=0

volatile的作用就是告诉编译器不要对这个变量做任何优化,要读取必须贯穿式地读取内存,不要读取缓冲区中的数据,保存内存的可见性。

3.SIGCHLD信号

该进程是在子进程退出后对父进程发出的信号。该信号的默认处理动作是忽略。

void Getchild(int signo)    
{    
  printf("get a signo:%d\n",signo);                                                                                                                    
}    
int main()    
{    
  signal(SIGCHLD,Getchild);    
  pid_t id=fork();    
  if(id==0)    
  {    
    int count=5;    
    while(count)    
    {    
      printf("I am a child:%d\n",getpid());    
      sleep(1);    
      count--;    
    }    
    exit(0);    
  }    
  while(1);    
  return 0;    
}  

当子进程退出时,就会捕捉到17号信号:
在这里插入图片描述
而SIGCHLD更重要的作用在于,当如果我们显示设置忽略17号信号的话,子进程退出后会直接被父进程回收,而不用等待父进程结束而变成僵尸进程:

signal(SIGCHLD,SIG_IGN)

在这里插入图片描述
这种做法目前只在Linux系统下有效。

六、总结

信号的内容有很多,总结起来可以分为三个大方向:
信号产生前,信号产生中,信号产生后。
信号的产生共有四种方式,分别是由键盘产生,进程崩溃产生,系统调用产生,和软件条件产生。
信号产生中要记住三张表以及它们代表的含义。以及处理三张表的各种接口。
信号产生后需要了解用户态和内核态,以及进程对信号的检测和处理的"合适时间"所指。以及两个可以修改信号的函数。
最后补充了三个概念,分别是可重入函数,volatile关键字以及SIGCHLD信号。

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。