linux(信号)

进程在没有收到信号的的时候,其实早就已经能够直到一个信号该怎么被处理,也就是进程能够认识并且处理一个信号,这是因为程序员设计进程的时候早就设计了对信号的识别能力。因为信号随时可能产生,所以在信号产生之前,我可能正在做优先级高的事情,我可能不能立马处理该信号,我们要在后续合适的时候进行处理,所有在信号产生和信号处理之间存在时间窗口。进程在收到信号的时候,如果没有立马处理这个信号,需要进程具有记录信号的能力。

信号的产生对于进程来说是异步的,也就是操作系统发信号和进程执行自己代码这两个过程是互不干扰的。信号分为普通信号和实时信号,其中1到31是普通信号,34到64是实时信号。 task_struct内部必定要有一个位图结构,用int表如uint32 signals:00000100···。所谓发送信号本质其实是写入信号,直接修改特定进程的信号位图中的特定比特位0->1,比特位的位置对应信号的编号,比特位的内容对应是否收到该信号。task_Struct是内核数据结构,只能由os进行修改,无论后面有多少种信号产生方式,最终都必须让os完成最后的发送过程也就是对信号进行写入。

很多信号的默认动作虽然都是终止进程,但是不同的信号表示了不同的进程退出原因,退出对于我们本身并不重要,重要的是了解退出的原因。

处理信号的方式:1、默认动作;2、自定义动作;3、忽略

信号的产生  

ctrl+c:键盘输入一个硬件中断,被os获取,解释称信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。

前台进程:linux只允许一个继承处于前台,默认情况下是bash进程,所以./xxxx会让前台进程从bash变成xxxx,如果xxxx是一个死循环,此时我们输入指令是没有任何反应的,因为xxxx程序并不认识这些指令。./xxxx &带了一个取地址的符号,这叫做后台进程。此时输入指令是由反应的。

//signo:特定信号被发送给当前进程的时候,执行handler方法的时候,要自动填充对应的信号给handler方法。但是9号信号不可以被自定义,只能执行默认动作,所以又叫做管理员信号。
//我们甚至可以给所有的信号设置同一个处理函数
void handler(int signo)
{
    cout << "get a singal " << signo << endl;
}
int main()
{
    
    //1.2号信号,对于进程的默认处理动作是终止进程
    //2.signal函数可以对指定的信号设定自定义处理动作
    //3.signal(2,handler)调用完这个函数的时候,handler方法调用了吗?没有被调用,只是更改了2号信号的处理动作,可以理解为一种映射关系,并没有调用handler方法,可以理解成为仅仅传递了一个函数指针参数而已。
    //那么handler方法什么时候调用?当2号信号产生的时候。
    //signal(2,handler)可以理解成为我们执行用户动作的自定义捕捉
    signal(2,handler);//还有一种写法就是signal(SIGINT,handler),ctrl+c就是向进程发送2号信号,相当于操作系统内部执行了handler(2)
    signal(3,handler);//还有一种写法就是signal(SIGQUIT,handler),ctrl+\就是向进程发送3号信号,相当于操作系统内部执行了handler(3)
    while(true)
    {
        std::cout << "我是一个进程,我正在运行 ..., pid: " << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

硬件中断产生信号

我们平时在输入的时候,计算机怎么知道我从键盘输入了数据呢?键盘是通过硬件中断的方式,通知系统,我们键盘已经按下了。

中断向量表是一个函数指针数组,其中数组下标是中断号,当硬件外部数据就绪的时候,那么硬件首先会像cpu的特定针角触发中断(cpu每个针脚对应不同的硬件),发送中断信息,cpu寄存器会读取该下标,(以键盘为例,这一块只是检测键盘被摁下)拿着这个中断号运行对应的中断向量表上的对应函数,运行该方法就是从该硬件上读取外部数据(此时关注的是键盘摁下的位置)。

 系统调用产生信号

int  kill(pid_t pid,  int  sig):要和kill命令区分开来。

void Usage(std::string proc)
{
    cout<<"Usage:\n\t";
    cout<<proc<<" 信号编号 目标进程\n"<<endl;
}
int main(int argc,char* argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signo= atoi(argv[1]);//取出发送给进程的信号的编号
    int target_id=atoi(argv[2]);//取出目标进程oid
    int n=kill(target_id,signo);//给target_id进程发送signo号信号
    assert(n==0);
    return 0;
}

  int raise(int sig);

void myhandler(int signo)
{
    cout<<"get a signo:"<<signo<<endl;
}
int main(int argc,char* argv[])
{
    signal(SIGINT,myhandler);//接收到2号信号之后进行信号捕捉。也证明raise函数的功能。
    sleep(1);
    raise(2);//给自己发送2号信号
    return 0;
}

  void abort(void)

int main()
{
    cout<<"begin"<<endl;
    sleep(1);
    abort();//给自己发送6号信号SIGABRT
    cout<<"end"<<endl;
}

上述代码可以看出,abort函数确实给进程发送了6号信号,但是自定义捕捉之后就没有继续执行了呢?这是语义规定的,abort信号执行完了,必须退出,即便是做了信号捕捉的动作也会退出。

 软件条件产生信号

  unsigned int alarm(unsigned int seconds)。闹钟可以存在很多个,但是每一个进程只允许有一个,单个进程设置闹钟会对以前的闹钟进行覆盖处理。多个进程可以设置多个闹钟。

int count = 0;
void myhandler(int signo)
{
    cout << "get a signo:" << signo << "count:"<<count<<endl;
    exit(0);
}
int main()
{
    signal(SIGALRM,myhandler);
    alarm(1);//1秒钟之后给进程发送SIGALRM信号。alarm(0)是取消闹钟。
    while(true) count++;
}

上面可以从某个维度来说对比一下算力

void myhandler(int signo)
{
    cout << "get a signo:" << signo << endl;
    alarm(1);//每隔个一秒钟自己给自己设定一个闹钟
}
int main()
{
    signal(SIGALRM,myhandler);
    alarm(1);//1秒钟之后给进程发送SIGALRM信号。这里闹钟是一种软件,我们设置的1秒钟是条件。
    while(true)
    {
        sleep(1);
    }
}

 硬件异常产生信号

int main()
{
    int a =10;
    a /= 0;
    cout<<"div zero here"<<endl;
    return 0;
}


当我们运算除以0之后,就会导致cpu内部状态寄存器内部的溢出标志位从0置为1,进而cpu硬件发生异常,我们的os会识别到该硬件异常,然后向导致该硬件异常的进程发送8号信号,进程在处理8号信号SIGPFE的时候会将进程终止(8号信号的默认动作)。除以0的本质就是出发cpu硬件异常。目前我们的cpu除0之后,溢出标志位会被置为1,如果进程并没有退出,也没有修复标志位(比如说采用signal信号捕捉操作,收到8好信号就不会执行默认的终止进程动作而是自定义动作),此时操作系统只要调度该进程就会向进程发送8号信号,现象就是一直不断地向该进程发送8号信号。

int main()
{
    int * p = nullptr;
    *p = 100;//第一步不是写入,而是首先要找到该变量,也就是先进行从虚拟到物理地址的转换。没有映射或者有映射但是没有权限都会导致MMU直接报错。更上面的溢出一样,也属于硬件报错的一种,这次报错的硬件是MMU。
    return 0;
}

核心转储功能 

Linux系统提供了一种能力,可以将一个进程在异常的时候,os可以将该进程在异常的时候,其核心代码部分进行核心转储,将内存种进程相关的数据,全部dump到磁盘中,一般会在当前进程的运行目录下形成core.pid这样的二进制文件,叫做核心转储文件。

打开核心转储的功能:

term终止就是终止来看,没有多余的动作。core:会先进行核心转储,然后再终止进程。核心转储的作用: 方便异常之后进行调试(能调试的前提是代码编译成为可执行程序的时候采用的是debug模式)。程序调试的过程当中,我们最想知道的是我们代码的问题出现的位置。core.pid文件就是用来自动定位时候调试的。

对于term类型的动作,core dump标志位一定是0。对于core类型的动作要看系统是否允许形成core类型文件,允许的话core dump标志位就会是1,否则是0。

信号的保存 

信号从产生到递达之间的状态称为信号未决。进程会维护三张表。

pending表:位图结构,比特位的位置表示哪一个信号,比特位的内容代表该信号收到但没有递达也就是处于未决状态。操作系统向进程发信号就是操作系统向当前位图结构进行数据写入的操作。

block表:也是位图结构,比特位的位置表示哪一个信号,比特位的内容代表对应信号是否被阻塞。

handler表:函数指针数组,指针类型是 void (*sighandler_t)(int)类型。数组的下标表示哪一个信号。数组的特定下标的内容表示该信号的递达动作。递达的动作有三类,default、ignore以及自定义,其中前两种是采用函数指针对0或者1做强转,内部判断的时候先判断地址是否是0或1,除此之外就跳转到函数指针指向的位置运行对应函数。

signal(2,SIG_DFL):该函数表示对2号信号执行默认动作,不写该函数也是执行的默认动作。SIG_DFL的由来就是typedef ((_sighandler_t) 0);typedef void(*_sighandler_t)(int)。signal(2,SIG_IGN)对二号信号执行忽略,也就是当进程运行的时候,按下ctrl+c对进程发送二号信号没有任何的反应。

int sigprocmask( int how, const sigset_t *restrict set, sigset_t *restrict oset )how代表的是你想如何设置block位图(在这里称为mask屏蔽字),用来检查以及更改mask屏蔽字,前两个参数输入型参数,第三个是输入型参数返回老的block表方便复原。

void showBlock(sigset_t *oset)//将信号打印出来
{
    int signo = 1;
    for(; signo <=31; signo++)
    {
        if(sigismember(oset, signo)) cout << "1";//判断signo是否在oset信号及里面
        else cout << "0";
    }
    cout << endl;
}
int main()
{
    // 只是在用户层面上进行设置,只是在栈上进行设置,并没有涉及到具体的进程。
    sigset_t set, oset; // 系统提供的信号集类型sigset_t
    sigemptyset(&set);//先对set进行数据清空
    sigemptyset(&oset);
    sigaddset(&set, 2); //向set集合里面添加2号信号

    // 接下来就是设置进入进程,谁调用,设置谁
    sigprocmask(SIG_SETMASK, &set, &oset); //让mask直接等于set,set上面添加的是2号信号所以未来2号信号会阻塞,接下来2号信号没有反应也就是crtl+c没有作用,而且我们看到老的信号屏蔽字block位图是全零。
    int cnt = 0;
    while(true)
    {
        showBlock(&oset);
        sleep(1);
        cnt++;

        if(cnt == 10)
        {
            cout << "recover block" << endl;
            sigprocmask(SIG_SETMASK, &oset, &set);//将老的信号屏蔽字设置进来,也就是解除对2号信号的屏蔽。
            showBlock(&set); 
        }
    }
    return 0
}

int sigpending(sigset_t *set):检测pending信号集,用改接口获取调用进程的pending位图,不提供设置功能。  

static void PrintPending(const sigset_t &pending)//static表示该接口只在本文件内有效
{
    cout << "当前进程的pending位图: ";
    for(int signo = 1; signo <= 31; signo++)
    {
        if(sigismember(&pending, signo)) cout << "1";//判断信号signo是否在集合pending当中
        else cout << "0";
    }
    cout << "\n";
}
int main()
{

    //1. 屏蔽2号信号
    sigset_t set, oset;
    // 1.1 初始化
    sigemptyset(&set);
    sigemptyset(&oset);
    // 1.2 将2号信号添加到set中
    sigaddset(&set, SIGINT/*2*/);//采用2或者SIGINT都可以
    //1.3将新的信号屏蔽字设置到进程当中
    sigprocmask(SIG_BLOCK,&set,&oset);
    //2.while获取进程的pendign信号集和,打印01
    while(true)
    {
        //2.1先获取pending信号集
        sigset_t pending;
        sigemptyset(&pending);//对pendign信号集进行初始化操作
        int n=sigpending(&pending);//获取pending信号集
        assert(n==0);
        (void)n;//防止n只被定义但是没有使用
    }
    //2.2打印,方便我们查看
    PrintPending(pending);
} 

 

int main()
{

    // 1. 屏蔽2号信号
    sigset_t set, oset;
    // 1.1 初始化
    sigemptyset(&set);
    sigemptyset(&oset);
    // 1.2 将2号信号添加到set中
    sigaddset(&set, SIGINT /*2*/); // 采用2或者SIGINT都可以
    // 1.3将新的信号屏蔽字设置到进程当中
    sigprocmask(SIG_BLOCK, &set, &oset);//追加屏蔽字
    // 2.while获取进程的pendign信号集和,打印01
    int cnt = 0;
    while (true)
    {
        // 2.1先获取pending信号集
        sigset_t pending;
        sigemptyset(&pending);        // 对pendign信号集进行初始化操作
        int n = sigpending(&pending); // 获取pending信号集
        assert(n == 0);
        (void)n; // 防止n只被定义但是没有使用
        // 2.2打印,方便我们查看
        PrintPending(pending);
        //2.3休眠一下
        sleep(1);
        //2.4 10s之后解除屏蔽信号
        if(cnt++ == 10)
        {
            cout<<"解除对2号信号的屏蔽"<<endl;
            sigprocmask(SIG_UNBLOCK,&set,&oset); //SIG_UBBLOCK就是从当前的信号屏蔽字中解除阻塞的信号
        }
    }
    return 0;
}

信号的处理

用户态:执行你写的代码的时候,进程所处的状态;内核态:执行os的代码的时候,进程所处的状态。那为什么在执行我的进程的时候会执行os的代码呢?进程时间片到了,需要切换一下,就要执行进程切换逻辑,而这是由os调用的。或者代码当中直接调用系统调用接口或间接调用系统调用接口(cout这种封装了了系统调用的接口)。

所有的进程0到3GB是不同的,不同进程的堆栈数据位置是不一样的,所以每一个进程都要有自己级页表。但是所有的进程3到4GB是一样的,每一个进程都可以看到同一张内核级页表,所有进程都可以通过同一个窗口,看到同一个OS。OS运行的本质其实都是在进程的地址空间内运行,无论进程如何切换,3到4GB不变,看到的OS的内容与进程切换无关。所以所谓系统调用的本质就如同调用库函数的方法 ,在自己的地址空间进行函数跳转。现在问题来了,这就导致我们可以任意的访问os的数据和代码?为了解决这个问题,设置了用户态和内核态。 cpu存在一个寄存器叫做CR3寄存器,寄存器有对应的比特位,比特位为3表征正在运行的进程执行级别是用户态,比特位为0表征当前正在运行的进程是内核态。那由谁来更改进程的内核态和用户态呢?用户是我发直接更改的,所以操作系统提供的所有的系统调用,内部在正式执行调用逻辑的时候,会去修改执行级别。所以我们只能通过系统调用访问该区间的代码,因为系统调用才包含了更改用户态和内核态的函数。 总结:用户态和内核态表征的是cpu当前的执行级别,在软件层面的影响就是你是不同的状态就可以访问地址空间不同位置的代码和数据。

进程是如何被调度的? os是一个软件,而且是一个死循环,这也就能保证一直在运行。os时钟硬件,每个很短的一段时间向os发送时间中断,os要执行对应的中断处理方法。这样os就可以在硬件的催促下定期的执行os任务。所谓的进程被调度时间片到了,然后就进程对应的上下文等进行保存并切换,再选择合适的进程执行,这一套就是一个schedule函数来完成的。os时钟硬件的中断处理方法就是检测当前进程的时间片,时间到了os就会让操作系统os自己调用schedule函数,完成一次进程的切换。

信号处理可以不是立即处理的,而是合适的时候在做处理。信号当然也可以被立即处理,如果一个信号之前被block,当它解除block的时候,对应的信号会被立即递到。为什么信号不能立即处理?因为信号的产生是异步的,当前进程可能正在做更重要的事情。什么时候是合适的时候呢?当前进程从内核态切换到用户态的时候,进程会在os的指导下,进行信号的检测与处理。

信号捕捉流程:用户态做正常的执行代码,突然时间片到了进程要调度或者系统调用要陷入内核,再由内核返回到用户层的时候先做信号检测,发现信号没有被block且已经pending此时再向上交付到应用层执行handler方法,之后再重新陷入到内核执行sys_sigreturn系统调用返回用户态继续向下执行代码。每一次从内核返回到用户层都要进行信号检测。只有在内核态中才有权力检测信号。

int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact):检查或更改一个信号的处理动作。当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来 的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。

//采用sigaction进行信号捕捉
static void handler(int signo)
{
    cout << "对特定信号:" << signo << "执行捕捉动作" << endl;
    sleep(10);//捕捉一个信号的处理过程需要花费10秒钟
}
int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigaction(2, &act, &oldact);
    while(true)
    {
        sleep(1);
    }
    return 0;
}

上述现象证明了当正在处理2好信号的时候,2号信号是被block的。

static void PrintPending(const sigset_t &pending) // static表示该接口只在本文件内有效
{
    cout << "当前进程的pending位图: ";
    for (int signo = 1; signo <= 31; signo++)
    {
        if (sigismember(&pending, signo))
            cout << "1"; // 判断信号signo是否在集合pending当中
        else
            cout << "0";
    }
    cout << "\n";
}
static void handler(int signo)
{
    cout << "对特定信号:" << signo << "执行捕捉动作" << endl;
    sleep(20);//有该函数可知信号处理动作的时间是40秒钟
    int cnt = 10;
    while (cnt)
    {
        cnt--;
        sigset_t pending;
        sigemptyset(&pending); // 不是必须的
        sigpending(&pending);
        PrintPending(pending);
        sleep(5);
    } 
}
int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));
    act.sa_handler = handler;
    act.sa_flags = 0;//默认设置为0
    sigemptyset(&act.sa_mask);//当我们处理2号信号的时候会对2号信号进行屏蔽工作,如果我们在屏蔽2号信号的时候想要顺手把3号信号也屏蔽了,就需要对sa_mask进行设置。这边虽然对sa_mask信号集进行了清零工作,但是正在被处理的信号这里是2号信号也会添加到block表里面。
    sigaddset(&act.sa_mask,3);//向block表中添加3号信号
    sigaddset(&act.sa_mask,4);//向block表中添加4号信号
    sigaddset(&act.sa_mask,5);//向block表中添加5号信号
    sigaction(2, &act, &oldact);//对2号信号进行处理
    while(true)
    {
        cout << getpid() << endl;
        sleep(1);
    }
    return 0;
}

可重入函数

volatile关键字 

int quit = 0; // 保证内存可见性
 void handler(int signo)
 {
     printf("change quit from 0 to 1\n");
     quit = 1;
     printf("quit : %d\n", quit);
 }
 int main()
 {
     signal(2, handler);
     while(!quit); //没有收到2号信号,代码会卡到这里一直死循环。注意这里我们故意没有携带while的代码块,故意让编译器认为在main中,quit只会被检测。由于main函数没有对quit变量进行修改,如果编译的时候采用优化,将quit变量的值从内存当中优化到寄存器,这样就不用每次判断的时候将quit的值从内存加载到cpu当中了,就会导致while循环检测的时候,不再从内存里面去拿quit的值了,只从cpu的寄存器里面去拿这个变量值,相当于while循环检测的时候让cpu寄存器的quie覆盖住了我们当前内存的quit,cpu会就近的去查寄存器的值而不去查内存的值,我们这就叫做内存位置不可见了。  
     printf("main quit 正常\n");
     return 0;
 }

  

//接下来我们需要修正,告诉编译器,保证每次检测都要尝试着从内存中进行数据读取,不要直接采用寄存器中的数据,让内存数据可见。
 volatile int quit = 0; //volatile关键字是用来杜绝对quit变量做内存寄存器级别的优化,保证内存可见性。
 void handler(int signo)
 {
     printf("change quit from 0 to 1\n");
     quit = 1;
     printf("quit : %d\n", quit);
 }
 int main()
 {
     signal(2, handler);
     while(!quit); //没有收到2号信号,代码会卡到这里一直死循环。注意这里我们故意没有携带while的代码块,故意让编译器认为在main中,quit只会被检测。如果编译的时候采用优化,就会导致while循环检测的时候,不再从内存里面去拿quit的值了,只从cpu的寄存器里面去拿这个变量值,相当于while循环检测的时候让cpu寄存器的quie覆盖住了我们当前内存的quit,cpu会就近的去查寄存器的值而不去查内存的值,我们这就叫做内存位置不可见了。  
     printf("main quit 正常\n");
     return 0;
 }

如何理解编译器的优化?编译器的本质是在代码上做手脚,cpu其实是很笨的,其实用户为给他什么代码,它才执行上面代码。volatile这个关键字很少用,其作用是跟编译器做对抗的额。

SIG_CHLD信号

子进程退出了,父进程是如何得知的呢?以前父进程是采用阻塞或者非阻塞来得知子进程推出的消息,无论是哪种方式都需要父进程主动检测,那么难道子进程推出了父进程暂时是不知道的吗?其实子进程推出的时候会向父进程发送17号信号SIG_CHLD信号,父进程收到该信号之后会执行默认的动作SIG_DFL,而默认动作就是什么都不会做。

pid_t id;
void handler(int signo)
{
    printf("捕捉到了一个信号:%d,who:%d\n", signo, getpid());
    sleep(5);
    pid_t res = waitpid(-1, NULL, 0); // 第一个参数-1说明可以等待任意一个子进程,再执行改代码之前子进程已经推出了但是没有回收是处于僵尸状态。
    if (res > 0)
    {
        printf("wait success,子进程pid:%d,回收进程pid:%d", id, res);
    }
}
int main()
{
    signal(SIGCHLD, handler);
    int i = 1;
    id = fork();
    if (id == 0)
    {
        int cnt = 5;
        while (cnt)
        {
            printf("我是子进程, 我的pid: %d, ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }
    // 如果你的父进程没有事干,你还是用以前的方法
    // 如果你的父进程很忙,而且不退出,可以选择信号的方法
    while (1)
    {
        sleep(1);
    }

    return 0;
}

上述采用信号捕捉的方法回收子进程的方法不建议,当父进程创建了多个子进程的时候,但是由于信号不是立即处理的。第一个信号将pending位由0置为1了,此时第二和第三个进程也退出了,这时只能重置pending的比特位,将1再次改成1,也就是将上次的信号覆盖掉了,也就存在信号丢失了,比如说父进程创建了10个子进程,最后成功回收的可能只有两三个。

优化一下就可以将子进程的全部回收,改一下handler函数就可以了。

void handler(int signo)
{
    printf("捕捉到了一个信号:%d,who:%d\n", signo, getpid());
    sleep(5);
    while(1)//循环回收子进程
    {
       pid_t res = waitpid(-1, NULL, WNOHANG); // 第一个参数-1说明可以等待任意一个子进程,
       //在执行改代码之前子进程已经退出了但是没有回收是处于僵尸状态。
       //如果今天有10个进程,5个退出5个人在进行当中,那么由于waitpid是阻塞等待,前5次回收成功,
       //第6次还是会回收,但是由于没有进程退出,所以阻塞在这里。
       //导致handler无法返回,代码没办法向后运行,所以这边最好将第三个参数设置为
       //WNOHANG非阻塞等待这个代码就完美了。
        if (res > 0)
        {
            printf("wait success,子进程pid:%d,回收进程pid:%d", id, res);
        }
        else break;//有子进程退出就尽力回收,比如说先退5个就先回收5个。
        //第六个子进程暂时没有退出,我这边就break返回跳出循环继续执行main函数后续代码
        //避免因为有些进程没有退出而造成无效的阻塞
    }
}

还有一种更加优雅的回收子进程的方法,就是将父进程调用signal将SIGCHLD的处理动作设置为SIG_IGN,这样fork出来的子进程在终止的时候会自动清理不会产生僵尸进程也不会通知父进程。就是将上面的代码signal(SIGCHLD, handler)改成signal(SIGCHLD, SIG_IGN);

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值