【Linux】进程信号

在这里插入图片描述

前不久刚介绍过信号量,本文将来介绍进程信号,虽然都有信号,但是二者毫无关系。

📘生活中的信号

信号在我们的生活中并不陌生;闹钟,红绿灯,电话等等,都是生活中常见的信号。
而我们在看见红绿灯时,知道红灯停绿灯行,听见闹钟知道定闹钟是为了提醒我们处理事务…也就是说我们人类能够识别信号。
而相对的,进程也能够识别信号,如果把人看作进程的话,那么操作系统就是人所在的社会,操作系统发出信号,进程识别后会进行处理。

📒Linux中的信号

我们可以通过kill -l的命令来查看Linux中的信号。
在这里插入图片描述
其中1 ~ 31号信号是普通信号,34 ~ 64号信号为实时信号,实时信号执行的优先级更高。

🧱信号的产生

1.通过键盘按键产生

我们之前会通过键盘输入ctrl + c来终止程序,而实际上这就是一个信号,并且产生的是2号信号SIGINT。具体来说是键盘输入产生了一个硬件中断,被操作系统OS获取解释成信号,并发送给目标前台进程,前台进程收到信号,执行该信号的默认处理操作,即推出进程。

[lyl@VM-4-15-centos Signal]$ cat signal.cc 
#include <iostream>
#include <sys/types.h>
#include <unistd.h>

int main()
{
  while(true)
  {
    std:: cout << "I am proc:" << getpid() << std::endl;
    sleep(1);
  }
}
[lyl@VM-4-15-centos Signal]$ ./mysignal 
I am proc:9005
I am proc:9005
I am proc:9005
I am proc:9005
^C

2.通过系统命令(系统函数调用)产生

其实,除了键盘输入产生信号外,还可以通过kill命令发送信号给进程。
在这里插入图片描述
【注意】这里需要注意的是ctrl+c只能够终止前台进程,而如果要终止后台进程,需要通过kill命令。在这里,进程可以在执行的任何时候收到信号而终止,也就是说信号相对于进程的控制流程是异步的。
实际上,系统命令就是通过调用系统函数来产生的信号,比如kill命令就是调用的kill函数产生信号,常见的系统函数有:

#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);
这两个函数都是成功返回0,错误返回-1

另外,abort函数使当前进程接收到信号而异常终止:

#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值

3.由硬件条件(进程触发错误)产生

我们知道如果我们在程序中进行除0操作,或者有野指针的时候,程序在运行后是会崩溃的。其实,这里也是进程收到了信号后终止的结果,我们试着通过捕获信号来看看对应的信号都是几号信号,这里用到的是信号的自定义处理操作,后续介绍信号处理时会提到。

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

void handler(int sig)//自定义捕捉信号
{
  std::cout << "catch a sig: " << sig << std::endl;
  exit(1);
}

int main()
{
  while(true)
  {
    for(int i = 1; i < 32; i++)
    {
      signal(i, handler);
    }
    std:: cout << "I am proc:" << getpid() << std::endl;
    int a = 0;
    int b = 2;
    std::cout << b / a << std::endl;
    sleep(5);
  }
}

我们实现一个除0的操作,运行结果如下:
在这里插入图片描述
可以看到进程捕捉到了一个8号信号,这是SIGFPE信号,即除零异常信号,最终结束进程。而如果我们在进程中出现野指针或者越界访问的话,进程就会捕捉到11号SIGSEGV信号,即段错误信号。
这样我们就能对与经常遇到的程序崩溃有更深层次的理解了,从宏观来看,程序出现错误后崩溃了,但站在操作系统上看,其实是操作系统捕获到异常并且发送信号给进程让进程终止。
至于为什么说是由硬件条件产生的信号,这是因为程序出现异常的实质是硬件出现了异常,例如当前进程执行了除
以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

💡core dump文件

在我们之前介绍进程等待的时候,曾经介绍过父进程会等待子进程并且获取子进程的运行结束状态。
在这里插入图片描述
而在当时,有一个参数我们完全没有提及,这就是core dump标志位。
那么什么是core dump呢?当一个进程要异常终止时,可以选择把用户空间的内存数据全部保存到磁盘上,文件名通常是core,这个过程就叫做core dump。进程异常终止通常是因为有bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许
产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,
因为core文件中可能包含用户密码等敏感信息,不安全,其次,core文件一般很大,占用的资源较多。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: ulimit -c 1024
在这里插入图片描述
可以看到一开始core文件的大小为0,我们通过ulimit -c 1024将其大小指定为1024k。那么在core文件允许产生的情况下我们运行除0的程序就会出现如下情况:在这里插入图片描述
可以看到此时在我们该路径下产生了一个core.10825文件其后缀就是我们异常终止进程的进程pid。而我们就可以通过这个core文件进行debug:
在这里插入图片描述
通过调试我们知道进程是因为收到了8号信号即除0异常信号而终止的,说明在我们的代码中出现了除零操作。

4.由软件条件产生

与硬件条件产生信号相对应的就是由软件条件产生的信号。其实,我们之前介绍进程间通信的时候就曾接触过软件条件产生的信号,在介绍管道的时候,我们曾说过如果读端不读,而写端一直在写,那么OS会给进程发送一个SIGPIPE信号来终止进程,这其实就是由软件条件所产生的。
此外,alarm函数也可以产生信号,其产生的是SIGALRM信号:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
调用alarm函数会在seconds秒后给进程发送一个信号

该函数返回值是0或者闹钟所剩余的时间(这是因为在闹钟执行的过程中有可能出现让闹钟天结束的事务)。举个例子,我们要小憩30分钟,但是中途可能被叫醒,此时如果还像继续多睡一会,那么这时候闹钟显示的就是余下的时间。

🎈小结一下

  • 由于信号不是被立即处理的,也就是说信号在被处理之前需要被保存起来,那么会被保存在哪里呢?进程的PCB,即进程控制块中。而信号有1-31号与34-64号,那么就可以通过位图结构来保存,其中比特位表示几号信号,0和1代表信号的产生与否。
  • 而发送信号的本质,就是去写进程控制块中的位图结构,那么这就是由操作系统OS来发送的。需要注意的是,OS具备识别信号的能力,这是因为OS是软硬件的管理者,既能够识别进程正常运行的情况,也能够在进程出现异常时做出响应。
  • 一个进程在没有收到信号的时候,是能知道自己应该对合法信号作何处理的,一般是有默认处理,忽略和自定义处理三种处理方式。
  • 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?实际上OS通过向目标进程的PCB中写数据,将目标进程位图的对应比特位置1,从而向目标进程发送信号。

🧱信号的识别

在上面我们认识了信号的产生,理解了信号产生的本质,信号发送的过程以及一些相关概念,信号在产生后由操作系统解释并送达给进程,而进程在识别到信号后并不是立即处理的,而是在"合适"的时候进行处理。
为什么进程识别到信号后不会立即处理呢?这是因为信号可能在进程进行的任何时候产生,而在识别到信号时,进程可能正在处理着更为重要的事务,不能够及时处理信号。这也进一步印证了信号的产生与进程的运行是异步的。
接下来我们将继续了解一下信号在产生后到进程处理时的这个期间,即信号识别中。

📓信号的阻塞

首先我们认识一下信号的阻塞,在此之前先介绍与信号有关的一些常见概念。

1.信号其他常见的相关概念
  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2.信号在内核中的表示

在这里插入图片描述

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
    1)其中,block位图中比特位的位置表示信号的编号,比特位的内容(0或者1)表示是否阻塞该信号。
    2)pending位图比特位的位置表示信号编号,比特位的内容(0或者1)表示是否收到信号。而OS发送信号的本质就是修改目标进程PCB中的pending位图。
    3)handler数组时由信号的编号作为数组的索引,由此找到该信号对应的处理方式(即指向对应的处理方法)。
  • 如果进程没有收到对应的信号,照样可以阻塞特定的信号,实际上,阻塞可以理解为一种“状态”。
  • 而检测信号是否会被递达,是否被阻塞,这些都是OS的任务;信号的学习都离不开这三个表。
  • 在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本文不讨论实时信号。
3.信号阻塞的操作
sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此在Linux中有一个数据类型就是用于表示这样的未决和阻塞标志,即sigset_t。sigset_t称为信号集,这个类型有“有效”和“无效”两种状态。其中,在未决信号集中看来,“有效”表示信号未决,而“无效”表示信号递达;在阻塞信号集中,“有效”表示信号被阻塞,“无效”表示信号未被阻塞。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

相关操作函数

Linux提供了一系列的接口函数来操作信号集sigset_t:

#include <signal.h>
int sigemptyset(sigset_t *set);//初始化set,使其所指向的信号集所有比特位清零,即使“无效”
int sigfillset(sigset_t *set);//初始化set,使其所指向的信号集所有比特位置位,即使“有效”
int sigaddset (sigset_t *set, int signo);//写set,添加signo信号
int sigdelset(sigset_t *set, int signo);//写set,删除signo信号
int sigismember(const sigset_t *set, int signo);//判断signo信号是否在set信号集中

注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
上面四个函数都是成功返回0,出错返回-1。
sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值:

how功能
SIG_BLOCKset包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask
SIG_UNBLOCKset包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK设置当前信号屏蔽字为set所指向的值,相当于mask=set

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递
达。

#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1

我们可以通过一个小实验来体会一下上面几个接口的效果,代码如下:

#include <iostream>
#include <signal.h>
#include <unistd.h>

int main()
{
  sigset_t set, pending;
  sigemptyset(&set);//初始化set,将该信号集中的位图比特位清零
  sigaddset(&set, SIGINT);//将SIGINT信号添加到set信号集中
  sigprocmask(SIG_BLOCK, &set, NULL);//将set信号集中的信号设置为阻塞,即SIGINT信号被阻塞了

  while(1)
  {

    sigpending(&pending);//将当前进程的未决信号集传给pending
    for(int i = 1; i < 32; i++)
    { 
      if(sigismember(&pending, i))//若i号信号未决则输出1,否则输出0
        std::cout << '1';
      else 
        std::cout << '0';
    }
    std::cout << std::endl;
    sleep(1);
  }
}

在这里插入图片描述

🧱信号的处理

进程在处理信号时,通常有三种处理方法:

  1. 默认处理方式,比如2,3号等信号的处理就是终止进程,当然也不是所有的信号默认处理方式都是终止进程,比如18(SIGCONT),19(SIGSTOP)号信号就是进程的继续与暂停。在这里插入图片描述

  2. 忽略信号,忽略信号也是一种信号的处理方式,即识别到信号后什么都不做。

  3. 自定义处理方式,即提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号 。

🥏自定义信号处理(信号捕捉)

首先介绍一个函数:

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

这个函数用来自定义信号的处理方法,其中signum为自定义处理的信号,而handler为一个函数指针,该函数就是我们自定义的函数方法,下面我们将1-31号信号都进行捕捉来试验一下:

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

void handler(int sig)
{
  std::cout << "catch a sig: " << sig << std::endl;
}

int main()
{
  while(true)
  {
    for(int i = 1; i < 32; i++)
    {
      signal(i, handler);
    }
    std:: cout << "I am proc:" << getpid() << std::endl;
    sleep(1);
  }
}

此时运行进程,通过kill命令来发送信号,我们会发现进程捕捉到了我们发送的信号,但是并没有终止,这是因为我们给这些信号自定义了处理方法,那么是不是说这个进程已经金刚不坏,杀不掉了呢?其实并不是,因为如果这样就杀不死进程,那么进程就会一直消耗资源,那么对于Linux来说这就是一个bug了,操作系统是不会允许任何浪费资源的行为,于是在Linux中规定,有的信号是不能被捕捉和忽略的,9号信号SIGKILL就是一个,也就是说进程无法捕捉9号信号,一旦识别就立即终止:
在这里插入图片描述

🥏信号捕捉的过程

我们知道32位系统的内存是4G,其他0 ~ 03G为用户空间,3 ~ 4G为内核空间。这就意味着进程运行过程中会有两种状态,执行用户空间代码时为用户态,执行内核空间中代码时为内核态;其中用户态只能执行用户空间代码,不能执行内核空间代码,而内核态权限更高,用户空间和内核空间代码均可执行。
信号捕捉是进程执行用户自定义的信号处理函数,这个函数的代码是存放在用户空间的,我们以处理SIGINT信号为例来分析信号捕捉的过程,自定义处理函数我sighandler:

  • 首先进程在用户空间执行main函数代码,当发生中断或异常或者是调用系统函数时需要由用户态切换到内核态
  • 当内核处理完事务后检测到SIGINT信号需要被处理,那么此时内核会决定返回用户态后选择不恢复main函数上下文数据继续执行,而是去执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
  • sighandler函数返回后再次进入内核态自动执行特殊的系统调用sigreturn。
  • 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

在这里插入图片描述

  • 默认情况下,在内核处理一个信号的时候,这个信号会被短暂的阻塞block,直到当前信号被处理完毕。
  • 这是因为,在一个信号被处理时如果同样的信号再次到达,那么这个信号会被挂起不会处理;这时我们可能会疑问,如果由多个信号同时递达那么多的信号是否会丢失,对于常规信号即1 ~ 31号,这是会的。
  • 但是对于实时信号来说,会有一个处理队列来保存要处理的信号。

🥏信号捕捉相关函数

其实除了signal函数以外,sigaction函数也可以进行自定义函数的操作:

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo
是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体。

struct sigaction {
               void     (*sa_handler)(int);
               void     (*sa_sigaction)(int, siginfo_t *, void *);//实时信号处理函数
               sigset_t   sa_mask;//屏蔽字段,处理当前信号时额外屏蔽的信号集
               int        sa_flags;//这里我们设置为0
               void     (*sa_restorer)(void);
           };

将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

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

void handler(int signo)
{
  std::cout << "catch a signal:" << signo << std::endl;
  //exit(0);
  sleep(10);
}


int main()
{
  struct sigaction act, oact;
  act.sa_flags = 0;
  act.sa_handler = handler;
  sigemptyset(&act.sa_mask);//将sa_mask信号集清空
  sigaddset(&act.sa_mask, SIGQUIT);//添加3号信号到sa_mask中
  sigaddset(&act.sa_mask, SIGILL);//添加4号信号到sa_mask中

  sigaction(SIGINT, &act, &oact);

  while(true)
  {
    std::cout << "i am proc: " << getpid() << std::endl;
    sleep(1);
  }
  return 0;
}

在这里插入图片描述
我们用handler方法自定义信号SIGINT的处理,并且屏蔽字段在添加3号信号,可以看到在2号信号处理过程中,发送3号信号也不会处理,并且多次发送2号信号也只会最终处理一次。

📗补充概念

1.可重入函数

以前我们的程序都是单执行流的,在学习完信号后,我们知道,一个函数是有可能被多个执行流进入的,函数被多个执行流同时进入的情况叫做重入。
在这里插入图片描述

main函数调用insert函数向一个链表head中插入节点node1,
插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2
插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行
先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了,而node2就被丢弃成为野指针了。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
然而实际上,大多数函数都是不可重入的。如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

2.volatile

volatile是C语言中的关键字,其作用为声明修饰的变量保持内存的可见性。

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

int flag = 0;

void handler(int signo)
{
  //std::cout << "catch a signal:" << signo <<"修改flag为1" << std::endl;
  printf("catch a signal:%d, change flag to 1\n", signo);
  flag = 1;
}

int main()
{
  signal(SIGINT, handler);
  while(!flag);
  //std::cout << "process quit..." << std::endl;
  printf("process quit\n");
  return 0;
}

标准情况下,键入ctrl+c程序捕捉信号修改flag后退出循环。
在这里插入图片描述
这时我们修改gcc的选项,添加-O1进行优化.
在这里插入图片描述
优化情况下,键入 ctrl + c ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。这是因为在C语言中,如果一个变量在主控制流程中没有被修改,那么该变量会被优化为寄存器变量。那么这种情况就需要用volatile来修饰flag变量,告诉编译器要去内存找flag,不要优化该变量,必须在真实的内存中操作。

volatile int flag = 0;

在这里插入图片描述

3.SIGCHLD信号

之前讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父
进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
父进程自定义SIGCHLD信号的处理函数,在其中调用wait获得子进程:

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


void handler(int signo)
{
  std::cout << "catch a signal: " << signo << std::endl;
  pid_t id;
  while((id = waitpid(-1, nullptr, WNOHANG)) > 0)
  {
    std::cout << "wait child successfully... id:" << id << std::endl;
  }
  std::cout << "child quit... " << getpid() << std::endl;
}

int main()
{
  if(fork() == 0)
  {
    //child
    exit(2);
  }
  //father
  signal(SIGCHLD, handler);
  sleep(2);
  return 0;
}

事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽
略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证
在其它UNIX系统上都可用。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值