Linux信号相关笔记

3 篇文章 0 订阅
1 篇文章 0 订阅

最近又温习了一遍Linux中的信号知识,发现有很多东西以前没有注意到,就通过这篇博客记录一下,巩固一下知识点。


一,信号基础:

        信号是什么?为了回答这个问题,首先要从异常说起,这里的异常不是指c++/java中的Exception,而是指控制流的一种突变。

        控制流指一个程序的指令序列,它在最简单的情况是平滑的,意味着上一条指令地址和下一条指令地址在存储器中是相邻的,但是程序也可以通过跳转,函数调用和函数返回来造成控制流产生突变,这是一种必要的机制,使得程序能够对由程序变量表示的内部程序中的变化做出反应。

        但是一个进程也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行相关,比如IO设备完成交给它的工作时产生的硬件中断,内核通知父进程它的一个子进程退出等。

        异常分为四类:中断,陷阱,故障和终止,信号属于中断这一类,叫软件中断。

        异常发生在计算机系统的各个层次,比如硬件层有硬件中断,操作系统层内核通过上下文转换将控制从一个用户进程切换到另一个用户进程,而在应用层,一个进程可以发送信号给另一个进程从而接收进程突然转移控制到信号处理程序。

        所以信号就是更高层的软件形式的异常,它允许进程中断其他进程。

        其实抛去上面这么多概念,信号本质上就是一条小消息,它通知进程系统发生了一个某种类型的事件。



二,信号API:

        信号发生时,有三种方式处理它:
        1.忽略此信号,有两种信号不能被忽略:SIGKILL和SIGSTOP,原因是它们向超级用户提供了使进程终止或者停止的可靠方法
        2.执行系统默认动作,针对大多数信号,系统的默认动作是终止进程,注意这跟忽略不同,忽略一个信号必须明确告诉内核我要忽略此信号。
        3.捕捉信号,需要注册一个函数到内核中,告诉内核:当这个信号发生时调用这个函数,这是通过如下函数来实现的:
         void (*signal(int signo,void(*func)(int)))(int);

        这里复习一下c语言的知识,分析一下这个声明:
        首先找到优先级最高的括号,即第一个括号,找到标识符signal,它的左边是星号,右边是一个括号,括号运算优先级高,因此表明signal是一个函数,这个函数有两个参数即为int和void(*)int,返回值是什么呢?signal左边是个星号,表明返回值是指针类型,再看右边末尾的括号,说明这是个函数指针,参数为int,返回值为void。

        Linux man文档用一种更直观的声明表示signal():

        typedef void (*sighandler_t)(int);

        sighandler_t signal(int signum, sighandler_t handler);


        不调用signal函数和调用signal函数而使用SIG_DFL效果是相同的,信号的处理方式为系统默认,对大部分信号而言都是终止进程。
        调用signal函数使用SIG_IGN,则忽略signum信号,什么都不做,SIGKILL和SIGSTOP不能被忽略。

        调用signal函数,传递一个合法的函数指针,则信号到来时执行该函数做信号处理函数,信号处理函数返回时再继续执行原来的指令,注意,信号可能在程序运行的任意时间点到来,因此可能产生一些难以察觉的问题和竞争条件。


三,信号标准化:

        Unix早期版本的信号机制并不可靠,信号可能丢失,而且在执行临界区代码时,进程很难关闭所选择的信号,POSIX.1对可靠信号进行了标准化,为了解决不同版本的Unix信号行为的可移植性问题,应该使用sigaction函数来代替signal,博客第八条贴上一个用sigaction来实现的可移植性signal函数,这是从Unix环境高级编程一书摘录过来的。
        在这里只关注Linux下的信号实现的行为,发现要注意的事项还是蛮多的:
        1.Linux内核的signal()系统调用(注意,是内核的函数)遵从System V行为:信号处理函数被调用以后,信号的处理方式会被复位为SIG_DFL,而且在信号处理期间系统不会阻塞同种类型的信号。这是个糟糕的做法,有两点原因:

        (1).信号处理函数被调用时,它的处理方式被复位为SIG_DFL,为了使下一次仍然可以正常处理该信号,必须再一次调用signal注册,但是如果在调用signal之前一个同样类型的信号到来了,那么程序就会以默认方式终止了。

        (2).一个信号A到达,进程正在执行它的信号处理函数,这时又到达了一个信号A,会导致信号处理函数被中断,产生了无尽的递归。

        2.默认情况下在glibc2和以后的版本,signal()包装函数(有别于上面说的内核函数)并没有调用内核的系统调用,它调用了sigaction()从而提供了可靠的信号机制:当一个信号处理函数被调用时,该信号的处理方式不会被复位为SIG_DFL,而且同种类型的信号到来时会被阻塞住。

        3.当程序阻塞在一个系统调用或者库函数的时候,收到了一个信号并且执行了该信号的处理函数,那么该阻塞就可能被中断不再继续执行,并且返回出错EINTR,这样处理的理由是,因为一个信号发生了,进程捕捉到了它,这意味着已经发生了某种事情,所以是个应当唤醒阻塞的系统调用的好机会。 

        这种行为在Linux是这样定义的:

        当使用以下阻塞式的调用,而且使用了sigaction函数注册了信号处理函数,而且设置了SA_RESTART标志的时候,那么被阻塞的调用会在信号处理函数返回以后被重启,否则就出错返回,errno被置为EINTR:

        (1)在"低速设备"上调用read,readv,write,writev,ioctl,低速设备是指可能会让这些调用永远阻塞的设备,比如终端,管道,FIFO,socket等,如果这些设备没有数据可读或者缓冲区已满,那么read和write可能会永远阻塞,但是磁盘并不属于低速设备,除非发生硬件错误,磁盘IO总会很快返回,并使调用者不再处于阻塞状态。要注意的是,如果这些函数在被信号处理函数中断时已经传送了一些数据,那么它们会成功返回,表示已经传输了一些数据。

        (2) open在fifo上阻塞:如果一个fifo被一个进程以读来打开,但是没有一个进程为写来打开它,那么open就会阻塞,除非在open的时候设置了O_NONBLOCK标志。

        (3)wait系列函数,包括wait3,wait4,waitid,waitpid

        (4)socket接口函数:accept,connect,recv,recvfrom,recvmsg,send,sendto,sendmsg

        (5)文件锁相关函数:flock,fcntl

        (6)POSIX消息队列相关函数:mq_receive,mq_timedreveive,mq_send,mq_timedsend

        还有以下函数从来不会重启,就算设置了SA_RESTART标志也没有用,它们被中断时总是设置errno为EINTR:

        (1)通过setsockopt设置了超时属性的socket函数:accept(),recv(),recvfrom(),recvmsg()

        (2)pause(),sigsuspend(),sigtimedwait(),sigwaitinfo()

        (3)IO多路转接相关:epoll_wait(),epoll_pwait(),poll(),ppoll(),select(),pselect()

        (4)System V 进程通信接口:msgrcv(),msgsnd(),semop(),semtimedop()

        (5)睡眠有关函数:clock_nanosleep(),nanosleep(),usleep()

        (6)从inotify()文件描述符上读。(inotify提供了监听文件系统事件的作用,android里面的FileObserver就是用它来实现的)

        (7)io_getevents()

        还有最后一个sleep()函数,它被中断以后会以成功值返回,表示还没有睡够的秒数。

        由此可见,要注意的事项还是蛮多的,平时在编写具有信号处理函数的程序中一定要注意。



四,信号术语:


        理解信号发送到目的进程经过哪些步骤非常重要,有两个步骤:
        1.发送信号:内核检测到某个系统事件发生时,发送一个信号给目的进程,一个进程也可以发送信号给它自己。
        2.接收信号:信号被发送出去以后,并不一定马上被目的进程接收,一个进程可能会通过sigprocmask()函数将该信号阻塞住了,这种情况下只有解除阻塞,信号才可能被目地进程接收。
        一个只发出而没有被接收的信号叫待处理信号,在任何时刻,一种类型至多只会有一个待处理信号,如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,它们只是被简单地丢弃。

        下面有两个问题和上面的知识相关:
         1.K信号的处理函数正在进行,这时有一个相同的信号K也到来了怎么办?如果这个时候又来第三个信号K系统又会怎么办?
        2.K信号的处理函数正在进行,另外一个不同的信号M到来了,是等K信号的处理函数结束以后再执行M信号的处理函数,还是什么?



        第一个问题可以由上面的知识推断出来,Unix信号处理程序通常会阻塞当前处理程序正在处理的类型的待处理信号,这就是说如果K的信号处理函数正在执行,一个相同的信号到来了,那么K的信号屏蔽字会被设置为阻塞,只有当信号处理函数返回时,才解除阻塞,这样K的中断处理程序不会被自身中断,从而避免了无尽的递归,如果这时又来了第三个信号K,由于在任何时刻,一种类型至多只会有一个待处理信号,因此这第三个信号只是被简单的丢弃。

        第二个问题经我测试,答案是:K信号处理函数会被M的信号处理函数所中断,执行完M的再回过头来执行K的。


五,信号使用注意事项:


        信号看起来貌似简单,其实还是蛮复杂的,因为它属于并发的范畴,,这里简单的总结几点:
        1.由于任意类型至多只有一个待处理信号,因此存在一个待处理信号仅仅表明至少已经有一个信号到达了。举个例子,像shell或者web服务器这样的程序,基本结构是父进程创建一些子进程,这些子进程运行一会儿,然后终止,父进程必须回收子进程,以避免在系统中留下僵尸进程,我们想让父进程在子进程运行时可以自由地做其他工作,所以可以用SIGCHLD处理程序回收子进程,而不是显式地等待子进程终止,但是不可以在信号处理程序中只等待一个子进程终止,意思是说不是收到一个SIGCHLD信号就代表一个子进程终结,而是代表至少有一个子进程终结,所以应该使用while循环尽可能多的回收子进程,否则有些子进程就变成了僵尸进程。由此还得到一个重要教训是: 不可以用信号来对其他进程中发生的事件计数。

        2.有些函数是不可重入的,什么意思?意思是假设一个函数F()正在执行,这时一个信号到达了,执行了它的信号处理函数,它又执行了函数F(),如果会产生错误或者问题,就代表F不可重入。这是由于中断可以在程序运行的任意时间点到来。查看man 7 signal,它会告诉你哪些函数是可重入的,所谓的"Async-signal-safe functions"。这个规定极大的制约了信号处理程序的编写,注意,所有标准IO函数和pthread_XXX函数都不是可重入的。为了避免从信号处理程序中调用任何函数的方法之一是: 让处理程序仅仅设置一个全局标志,由某个线程检查该标志以确定何时接收到了一个消息。

        3.很多情况下发生了中断,原有的程序可能会被打断节奏,执行另外一套代码,比如可交互式程序执行过程中按ctrl+c,可能需要执行一段清除程序然后优雅的退出。那么默认返回到原有执行点的行为就不再满足要求了,这个时候可以采用setjmp和longjmp组合,执行长跳转,根据setjmp的返回值执行不同的代码,在信号处理函数的结尾跳转到清除程序的位置去。但是setjmp和longjmp又引发了另外一个问题。正如在上面"信号术语"中所述,当捕捉到一个信号时,进入信号捕捉函数,此时当前信号被自动地加到进程的信号屏蔽字中,这阻止了后来产生的这种信号中断该信号处理函数,当longjmp从信号处理程序中返回以后,在Linux系统中,信号屏蔽字仍然会被设置,也就意味着jmp_buf并不保存信号屏蔽字相关内容。这个时候需要使用信号替代函数sigsetjmp和siglongjmp来解决这个问题。

        4.如果希望对一个信号解除阻塞,然后使用pause等待以前被阻塞的信号发生,需要在一个原子操作先恢复信号屏蔽字然后使进程休眠,否则可能会使进程在pause处永远阻塞。这个原子操作是sigsuspend()函数。

        这里列了四点注意事项,事实上信号要注意的还很多,只能在项目中总结学习了。


 六,信号与线程

          基于进程的信号本身已经够复杂的,多线程环境下的信号则更加复杂,以下列举多线程环境的几点注意要点:

          1.进程中的信号是传递到单个线程的,如果信号与硬件故障或计时器超时相关,该信号就被发送到引起该事件的线程中去,而其他信号则被发送到任意一个线程。

          下边写个程序实验一下:

          

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <errno.h>
#include <string.h>

void sig_int_handle(int sig);
void unix_error(const char *msg);

void sig_int_handle(int sig){
    if(sig == SIGINT)
        printf("%u catch int sig !\n" , (unsigned int)pthread_self());
}
void unix_error(const char *msg){
    fprintf(stderr," %s: %s\n", msg , strerror(errno));
    exit(1);
}

void *thr_fn1(void *arg){
    signal(SIGINT,sig_int_handle);
    sleep(10);
    printf("thread 1 func exit\n");
}
void *thr_fn2(void *arg){
    sleep(10);
    printf("thread 2 func exit\n");
}
int main(int argc, char ** argv){
    pthread_t tid1;
    pthread_t tid2;
    
    pthread_create(&tid1,NULL,thr_fn1,NULL);
    pthread_create(&tid2,NULL,thr_fn2,NULL);
    printf("maintid:%u , tid1:%u , tid2:%u\n",
        (unsigned int)pthread_self(),
        (unsigned int) tid1,(unsigned int)tid2);
        
    pthread_join(tid1,NULL);
    pthread_join(tid2,NULL);
    return 0;
}

          很简单的程序,加上主线程一共有三个线程,只有thread 1设置了信号处理函数,我们可以通过打印语句看一下信号处理函数在哪个线程中被执行,我的机器是ubuntu12.04 64位,运行结果如下:

         maintid:4035348224 , tid1:4027086592 , tid2:4018693888
         ^C4035348224 catch int sig !
         ^C4035348224 catch int sig !
         thread 2 func exit
         thread 1 func exit

        由此可见,虽然是thread1设置了信号处理函数,但SIGINT信号却是被发送至主线程,事实上经过多次测试发现在我的机器上,不论哪个线程设置了信号处理函数,都会被主线程接收。

        2.sigprocmask的行为在多线程的进程中并没有定义,线程必须使用pthread_sigmask,它和sigprocmask函数基本相同,除了pthread_sigmask工作在线程中,并且失败时返回错误码,而不像sigprocmask那样设置errno并返回-1。pthread_sigmask改变调用线程的信号掩码。

        3.线程可以通过调用sigwait等待一个或多个信号发生:

        int sigwait(const sigset_t* set , int *signop);

        这个函数的含义是等待set中的信号集,如果在等待期间有信号发生,signop指向的整数将作为返回值,表明接收到的信号值。

        乍一看,这个函数好像和sigsuspend类似,但是它有一个特殊的能力:它可以同步地等待一个异步事件,我们是在使用信号,但没有涉及到异步信号处理程序,这就意味着可以十分简化的处理信号,在应用程序中可以这样使用:安排一个专用线程调用sigwait做set集的信号处理,而其他线程set集信号的屏蔽字被设置,这样在信号发生时,只有该专用线程会被通知产生了信号,由于是同步等待信号,没有涉及信号处理函数,就不需要担心调用哪些函数是安全的问题。
        和sigsuspend使用类似,线程在调用sigwait之前必须阻塞那些它正在等待的信号,sigwait会自动取消信号集set参数的阻塞状态,直到有信号到达,在返回之前,sigwait将恢复线程的信号屏蔽字。一般在sigwait之后可以设置一个标志,表示信号发生了变化,然后可以通过条件变量通知其他线程:"我收到了某个信号,有需要进行处理的赶紧进行处理吧!"

         4.主线程的信号屏蔽字被创建出来的子进程所继承。

         sigwait在多线程环境中很有用,事实上,大多数讨论线程的书都推荐在多线程的环境中使用sigwait来处理所有信号,而绝不要使用异步信号处理程序。


七,信号使用场景列举:


        那么平时写程序的时候什么时候会用到信号呢?这里简单列举几个例子:

        1.alarm可以为一个操作设置超时时限,比如可以alarm(30)然后对一个管道进行read操作,过了30秒还没有读到数据,那么read操作就被会被产生的SIGALRM信号中断返回了。

        2.通过kill函数发送信号SIGKILL(值为9,就是传说中的kill -9 xxx)给其他进程从而结束进程,这里扯远点,扯一下android里面杀进程的方式,android里面杀进程调用ActivityManager的forceStopPackage(String)方法可以杀掉任意的应用进程,当然这个api是被隐藏的,只有在源码环境下编译才能使用(或者反射),当时一直想不通,ActivityManagerService运行于SystemServer进程,也就是属于uid为system的用户,这个system用户虽说权力略大,所在的附加组比较多(数了一下,19个),但是也不像root那样为所欲为,想杀哪个就可以杀哪个,因为kill()函数明确写了:
For  a  process to have permission to send a signal it must either be privileged (under Linux: have the CAP_KILL capability), or the real or effective user ID of the sending process must equal the real or saved set-user-ID of the target process
一个进程的有效用户id等于目标进程的有效用户id或者设置用户id才能杀呀,明显system用户不符合,但是我却忽略了上面括号里面的话,有CAP_KILL这个能力也可以!事实上SystemServer启动的时候被设置了--capabilities=130104352,130104352这么一句话,就是让它拥有CAP_KILL能力从而可以杀其他进程的!
        3.在交互式程序中,如果用户按了ctrl+c或者ctrl+\想退出程序,为了防止进程突然退出造成数据的一些不一致状态,这个时候可以写个信号处理程序,在数据正在执行的时候阻塞住信号,等数据处理完了再解锁,然后在信号处理函数中通过长跳转跳到终结代码中做善后动作。

        4.捕捉SIGCHLD信号可以不需要显式等待子进程终止,这个在上面已经说过了

        5.有的系统中使用alarm来实现sleep函数

        6.使用信号可以实现父子进程的同步(其实这算是一种wait和notify机制),这个可以贴个代码做示例


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

static void charatatime(char *);
static volatile sig_atomic_t sigflag;
static sigset_t newmask , oldmask, zeromask;
static void sig_usr(int sig);

void TELL_WAIT();
void TELL_CHILD(pid_t pid);
void WAIT_PARENT();

void unix_error(const char *msg){
    fprintf(stderr," %s: %s\n", msg , strerror(errno));
    exit(1);
}
/*
父进程fork一个子进程出来,双方都打印一段内容
采用信号做的同步,如果不用信号做同步,那么父子进程会交替运行,产生不正确的输出
可能运行了很多次才会出现,这种问题最难跟踪
WAIT_PARENT和TELL_CHILD保证了父进程先运行
如果要让子进程先运行,采用类似的做法就能搞定
*/
int main(int argc ,char ** argv){
    pid_t pid;
    TELL_WAIT();
    
    if((pid = fork()) < 0){
        unix_error("fork error");     
    }else if(pid == 0){ //child process
        WAIT_PARENT();
        charatatime("output from child\n");
    }else{
        charatatime("output from parent\n");
        TELL_CHILD(pid);
    }
    return 0;
}
void TELL_WAIT(){
    if(signal(SIGUSR1,sig_usr) == SIG_ERR){
        unix_error("signal usr1 error");
    }
    sigemptyset(&zeromask);
    sigemptyset(&newmask);
    sigaddset(&newmask , SIGUSR1);
    //fork出来的子进程继承了信号屏蔽及安排,因此子进程的USR1信号处于阻塞状态
    if(sigprocmask(SIG_BLOCK,&newmask , &oldmask)< 0){
        unix_error("SIG_BLOCK error");
    }
}
void TELL_CHILD(pid_t pid){
    kill(pid,SIGUSR1);
}
void WAIT_PARENT(){
    //这里使用循环等待的原因是有可能其他信号导致sigsuspend返回,只有sigflag = 1才表示发生了SIGUSR1信号
    //这里还有个注意点,只能在SIGUSR1被阻塞的时候测试sigflag变量,否则有可能会导致sigsuspend永远阻塞住
    while(sigflag == 0)
        sigsuspend(&zeromask);
    sigflag = 0;
    if(sigprocmask(SIG_SETMASK,&oldmask,NULL)< 0){
        unix_error("sigprocmask error");
    }

}
static void charatatime(char *str){
    char *ptr;
    int c;
    setbuf(stdout,NULL);
    for(ptr = str ; (c = *ptr++)!=0;){
        putc(c,stdout);
    }
}
static void sig_usr(int sig){
    //仅设置一个标志,不调用任何函数,也就不会引发函数重入的问题
    sigflag = 1;    
}

        这种类似于wait和notify的机制也被Posix消息队列所使用,程序可以要求在指定的消息队列为空并且有一个消息放置到了队列中时发起一个信号通知。


 八,可移植性信号处理函数

         下面列出一个可移植性的信号函数代码,其信号处理语义如下:        

         1.只有这个处理程序当前正在处理的那种类型的信号被阻塞        

         2.和所有信号实现一样,信号不会排队等待       

         3.只要可能,被中断的系统调用会自动重启        

         4.一旦设置了信号处理程序,它就会一直保持,直到Siganl带着handler参数为SIG_IGN或者SIG_DFL被调用


typedef void (*sighandler_t)(int);
sighandler_t *Signal(int signum,sighandler_t *handler){
    struct sigaction action , old_action;
    action.sa_handler = handler;
    sigemptyset(&action.sa_mask);
    action.sa_flags = SA_RESTART; //由此信号中断的系统调用会自动重启
    
    if(sigaction(signum,&action,&old_action) < 0)
        unix_error("Signal error");
    return old_action.sa_handler;//返回上一个信号处理函数地址
}


九,实时信号

        上面所说的内容针对的是"标准信号"而言的,POSIX.1b标准中引入了实时信号的概念,而Linux也肯定支持实时信号,这里就讨论一下实时信号。

        信号可以划分为两大组:

        1.其值[SIGRTMIN,SIGRTMAX]之间的实时信号,对于linux而言,SIGRTMIN的值为33,SIGRTMAX的值为64,编程的时候绝不要在代码中硬编码实时信号的值,而要使用SIGRTMIN + n的形式。

        2.所有其他信号,也叫标准信号,如:SIGALRM,SIGINT,SIGKILL等。

        为什么会有实时信号这种东西呢?事实上,实时信号主要用来满足应用程序自身需求的,因为信号可以实现异步机制,甚至进程间通信进制,它能做的事情还是挺多的。

        实时信号的行为和标准信号不同,这也就使它有了存在的意义,不然标准信号中的SIGUSR1和SIGUSR2应该可以满足应用程序的要求。

        它的行为如下:

        1.信号是排队的,比如说同一类型的信号产生了三次,它就被递交三次,不会丢失,而且这种顺序被保证为FIFO,但标准信号不是这样的行为。

        2.不同类型的实时信号进行排队时,递交顺序是优先级高的先递交,值越小的信号优先级越高,这就意味着SIGRTMIN+1比SIGRTMIN+2信号更为优先。

        3.标准信号传递的时候,信号处理函数所能接收的只是信号的值,而实时信号还可以多接收一个siginfo_t结构和void *数据,作用就更强大。这是通过在sigaction函数中设置SA_SIGINFO标志实现的。siginfo_t带有的信息可以更为详细的展示了信号发生时的情况。

        4.sigqueue函数可以用于代替kill函数向某个进程发送一个信号,该函数可以传递一个sigval联合到信号处理函数中,信号处理函数可以通过siginfo_t的si_value成员来获取这个值。

        下边列出一个用于实时信号的信号处理函数代码:

typedef void Sigfunc_rt(int , siginfo_t *, void *);

/*
实时信号函数
*/
Sigfunc_rt *signal_rt(int signo,Sigfunc_rt* func, sigset_t *mask){
    struct sigaction act , oact;
    act.sa_sigaction = func;
    act.sa_mask = *mask; //保证低优先级的信号不会中断当前的信号处理函数
    act.sa_flags = SA_SIGINFO;
    if(signo == SIGALRM){
#ifdef SA_INTERRUPT
        act.sa_flags |= SA_INTERRUPT;
#endif
    }else{
#ifdef SA_RESTART
        act.sa_flags |= SA_RESTART;
#endif
    }
    if(sigaction(signo,&act,&oact) < 0)
        return ((Sigfunc_rt *)SIG_ERR);
    return oact.sa_sigaction;

}





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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值