Linux信号的保存与信号的处理

目录

前言

一、信号保存

1、重谈信号的概念

2、信号在内核中的表示

3、sigset_t

4、信号集操作函数、

• sigset_t相关的接口

• sigpromask

• sigpending

二、信号处理

1、再谈地址空间

2、用户态与内核态

• 内核态和用户态的切换

• 用户态切换为内核态的几种情况

3、信号的捕捉时机

4、sigaction

三、扩展知识

1、可重入函数

2、volatile

3、SIGCHLD

4、浅谈键盘输入数据的过程

5、浅理解OS是如何正常运行的

• 如何理解系统调用

• OS 是如何运行的


前言

上一期就介绍了,信号产生后可能并不会立即的执行,而是等到合适的时候去执行!这样就意味着,在被执行之前需要将信号给保存起来!我们上一期只是很粗糙的说他是用一张位图保存的,本期我们将详细的介绍保存数据信号的数据结构!

一、信号保存

上一期我么已经对信号的产生做了介绍,并了解了信号的生命周期有了整体的认识:信号的产生 -> 信号的保存 -> 信号的处理!我们下面在此基础上,先来对一些概念进行校正!

1、重谈信号的概念

• 实际执行信号的处理动作称为信号的递达(Deliver)

• 信号从产生到递达之间的状态称为信号的未决(Pending)

• 使信号处于"停滞"状态,无论是否有信号产生,都无法进行递达的状态称为信号阻塞        Block)

• 进程是可以选择阻塞某个信号的!

通俗的解释就是,递达就是实际执行信号对应的handler方法;未决就是信号产生了但是还没有处理的那种状态;阻塞就是不挂你有没有这个信号,我先把这个信号给拉黑,不让你递达!

下面用一个例子理解:

午饭时间到了,你妈给你打电话通知你要吃饭了(信号产生),你收到通知去吃饭的路上这个过程就是信号未决;但是现在不巧,你妈刚刚打电话之前,你开了一把CF,你收到你妈的通知后,你并没有立刻去吃饭,而是先打完CF再去吃饭,此时你将你妈的通知"停滞"往后了,此时你妈的通知就是信号的阻塞!你怕你妈又打天花催你,于是你就把你妈给拉黑了,这属于还没有收到信号,就把信号给阻塞了!

注意:

信号的阻塞可以发生在递达前的任意时候

• 被阻塞的信号产生时将保持在未决的状态,直到进程解除此信号的阻塞,才可以执行递达

• 阻塞和忽略是不一样的;阻塞是可能收到了信号但是干不了即不能递达,而忽略是收到信号的一   种处理方式,只不过这种处理方式是啥都不干

2、信号在内核中的表示

对于一个信号来说,无非需要存储三种状态:

1、信号是否阻塞 

2、信号是否未决

3、信号递达时的执行动作

所以在内核中,每个进程都维护了三张表:block 表、pending 表、handler

如图,block ,表示信号是否被阻塞 pending ,表示是否未决 handler ,表示是否递达;

其中,block  pending 表,其实是两张位图!31个普通信号正好可以用一个int来表示!其中,位图的每一位表示对应的信号,里面的值 0/1 表示是否阻塞/未决

而,handler 表,其实是一个函数指针数组!其中数组的下标表示的是对应的信号,数组中的元素表示的是该信号的递达方法

OK,介绍到这里我们也就可以明白,我们上一期使用signal(int signum, handler);就是通过signum找到handler表,将自定义的处理函数的地址放进去,然后在执行的时候我们就可以使用自己的递达方法了!

3、sigset_t

根据上面的介绍,每个信号只有一个bit的未决标志,不记录该信号产生了多少次,其中阻塞也是一样的!因此,未决和阻塞的表示可以用相同的数据类型sigset_t来存储,sigset_t称为信号集

sigset_t类型可以表示每个信号对应的"有效"和"无效";其中阻塞信号集中,有效就是阻塞,无效就是非阻塞;未决信号集中,有效就是有信号,无效就是没有收到信号!

阻塞信号集也叫做当前进程的信号屏蔽字(signal mask),和以前的权限掩码(权限屏蔽字)一样!

这就是sigset_t的定义

#ifndef ____sigset_t_defined
#define ____sigset_t_defined

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
  unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;

#endif

4、信号集操作函数、

• sigset_t相关的接口

对信号集的操作其实就是对 block pending 两张表的 增删查改!

上面刚介绍了,他两本质就是位图,你想操作你可以直接用一个变量获取,然后自己使用各种位运算操作!理论上是没有问题的,但是我们不推荐这样,而且OS也不同意让你操作,因为block pending 是进程PCB中的字段,除了OS谁也操作不了!所以,OS就提供了一批操作他两的系统调用!

#include <signal.h>

int sigemptyset(sigset_t *set);	//初始化信号集set,全部置0
int sigfillset(sigset_t *set);	//将信号集set,全部置1
int sigaddset(sigset_t *set, int signum);	//将signum信号增加到set
int sigdelset(sigset_t *set, int signum);	//将signum信号从set中删除
int sigismember(const sigset_t *set, int signum);	// 判断signum信号是否在set中

这批函数的返回值都是,成功,返回0; 失败返回-1

小Tips:在创建信号集, sigset_t的变量后,需要使用sigemptyset 进行做初始化操作,保证信号集的合法性!

• sigpromask

作用:调用 sigpromask 可以读取或更改进程的信号屏蔽字/阻塞信号集(block)

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

返回值

成功,返回0;失败, 返回-1, errno被设置

参数

1、参数1:how,对屏蔽字的更改操作

       SIG_BLOCK :set包含了我们希望添加到屏蔽字的信号,相当于mask=mask|set

       SIG_UNBLOCK :set中包含了我们希望从当前的信号屏蔽字中删除的阻塞信号,相当

                                     mask=mask & ~set

       SIG_SETMASK : 设置当前的信号屏蔽字为set,相当于mask = set

2、参数2: set, 对屏蔽字的内容更改  

3、参数3: 获取原先没有被修改的屏蔽字内容,目的是为了恢复

• sigpending

作用:获取未决信号集

参数

输出型参数,获取到的pending 表的值

返回值

成功,返回0;失败, 返回-1, errno被设置

OK,我们可以阻塞2号信号,然后再给他发2号信号,看两点现象:1、当发送2号信号时,没有终止 2、当检测其pending表时,我们发现当我们发送完2号信号后对应的位机会从0 变 1

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

void PrintPending(sigset_t &pending)
{
    std::cout << "cur process[" << getpid() << "]pending! ";
    for (int i = 31; i > 0; i--)
    {
        if (sigismember(&pending, i))
        {
            std::cout << '1';
        }
        else
        {
            std::cout << '0';
        }
    }
    std::cout << std::endl;
}

int main()
{
    // 1、创还能sigset_t
    sigset_t block_set, old_set;
    // 2、初始化
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    // 3、将2号信号给设置到block_set
    sigaddset(&block_set, 2);
    // 4、将block_set的数据写到block表
    sigprocmask(SIG_BLOCK, &block_set, &old_set);

    while (true)
    {
        // 5、获取pending表
        sigset_t pending;
        sigpending(&pending);

        // 隔一秒打印一次pending
        PrintPending(pending);
        sleep(1);
    }

    return 0;
}

上面的结果我们看到,首先2好信号已经不起作用了,因为2号被阻塞了,一旦被阻塞,就不再执行对应的handler方法了,阻塞收到信号后会在pending表中记录,上面也看到了由0到1!

当解除阻塞后,该进程会立马去执行,信号对应的handler方法!如何解除?我们之前不是保存了原先的block表吗,可以将old_set覆盖当前修改过的block:

2号信号的默认就是终止进程,所以这里解除之后就直接终止了!

如果你想看到他变成再由1变0,你可以捕捉:

当然,这里还可以验证一下,我们在执行handler前对pending位图的对应位进行修改,但是在执行前修改,还是执行后修改呢?其实很好验证:

void handler(int sig)
{
    std::cout << sig << "号信号被递达了...." << std::endl;
    std::cout << "-------------------------------" << std::endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    std::cout << "-------------------------------" << std::endl;
}

因为这个handler方法执行完就是递达结束了!如果执行完没有变0就是执行完再修改的,反之如果在没执行完handler方法就变0了,就是在执行前修改的!

说明在执行handler方法前就修改了!


二、信号处理

前面介绍了,进程收到了信号后,可能不会立即处理,而是在合适的时间处理!现在的问题是到底什么时候才算是合适的时候呢?换句话说就是OS是在什么时候去处理pending中已经收到信号呢?

在正式回答这个问题前,我们先来介绍一个前置知识:用户态和内核态

1、再谈地址空间

Linux操作系统是一个多用户、多任务的操作系统,为了安全性和资源管理,它将系统划分为用户态内核态两种模式运行!(注意:用户态和内核态是针对CPU的

每一个进程在都会有自己独立的虚拟的地址空间,但是我们注意到虚拟地址空间分为两部分用户空间内核空间,用户空间是给普通进程用的,那内核空间呢?当然是给OS用的!

操作系统也是软件,他启动起来也是进程;是进程就有代码和数据!它的代码和数据也和普通进程一样,会通过内核级页表映射到每个进程虚拟地址空间中的内核空间!

• 注意:OS中有很多的进程,但是并不是每个进程启动时,都要为其从磁盘加载一分内核的那部分OS的代码和数据!而是在内存中只有一份,内核级的页表也只有一份多个进程只是在他们各自的内核空间中映射了同一份内核级的页表! 

由于每个进程有独立的地址空间,所以每个进程都可以看到并访问操作系统!而访问OS的本质就是,特定情况下CPU跳转到当前进程地址空间的内核区访问!所以,内核空间的意义在于,无论哪一个进程在调度,随时都可以找到OS!

注意:虽然你每一个进程都可以看到OS,但是并不意味着你可以随意的访问!


2、用户态与内核态

• 概念

根据上面的介绍,我们可以简单的理解用户态和内核态为:

注意:用户态和内核态是针对CPU的!

用户态:CPU执行用户空间代码和数据的状态;

内核态:CPU执行内核空间代码和数据的状态;

• 权限与特点

用户态的权限较低,只能访问受限制的系统资源,无法直接访问硬件等!

内核态的权限最高,拥有访问所有软硬件资源的权限!

用户态与内核态的区别主要如下:

特性用户态内核态
权限受限全权
资源访问有限所有
代码执行用户程序操作系统内核
硬件访问受限可直接访问
安全性较高较低

• 内核态和用户态的切换

我当前进程代代码中使用了系统调用,当该进程被CPU调度起来时,执行到系统调用,此时CPU会保存该进程的上下文,然后修改内部的寄存器例如 ecs/psw 等的标记位,从用户态切换为内核态, 此时CPU有最高的权限,就可以去当前进程地址空间中的内核区执行系统调用的代码了!执行完内核态的代码,此时CPU再一次修改内部寄存器 ecs/psw 的标记位,切换为用户态!然后将在切换到内核前保存的CPU上下文数据恢复,继续执行用户态的代码!

总结:用户切换到内核,前首先保存当前CPU的上下文,然后修改寄存器变成内核态,执行完内核代码,修改CPU内寄存器的标记位,恢复切换前的CPU上下文,继续执行用户态!

• 用户态切换为内核态的几种情况

1、系统调用(System Call)

        • 用户执行一些需要内核权限的操作,例如:读写文件、创建进程、访问网络等;

2、硬件中断 (Hardware Interrupt)

        • 当硬件设备(例如磁盘、网络接口、键盘等)发生中断时,会触发硬件中断,将控制权从用户态转移到内核态。内核会根据中断类型进行处理,并可能需要调用相应的驱动程序来处理硬件事件。

3、时钟中断 (Clock Interrupt)

内核会设置定时器,定期触发时钟中断,用于执行一些周期性任务,例如进程调度、内存管理等。

4、异常(Exception)

 异常是指CPU在执行运行在用户态下的程序时,发生了某些事先不可知的错误或异常情况,如缺页异常、算术异常(如整数除零)、非法指令等。

注意:不仅是上述的三种情况才会变成内核态,而是只要你需要操作系统提供的服务,都会切换进入到内核态!

其中:

• 将用户态切换为内核态称为 陷入内核

• 将内核态切换为用户态称为 返回用户态

陷入内核是一个非常频繁的操作,操作系统会不断地进行用户态和内核态之间的切换,以保证系统的正常运行。

3、信号的捕捉时机

上面哔哔了半天的内核态和用户态,他和信号有嘛关系呢??

其实:信号的捕捉时机就是发生在 内核态 切换为 用户态 之前!

它的执行图如下:

如果在切换回用户态前,检测发现是有信号的,并且信号处理的函数是默认/忽略,此时内核态会执行完默认的方法,然后直接切换回用户态!

上面的执行完默认处理函数很好理解,但是,这里

1、为什么使用户自定义的处理方式时,为什么要切回用户态?

其实原因很简单,不切换用户态在技术角度肯定可以做到!但是如果不切换成用户态,此时OS不知道你自定的处理方法干了啥,万一你是 rm -rf /* 呢?所以如果直接用内核态执行,可能会被用户利用内核态的高权限"为所欲为"!所以,要从内核态切换为用户态,你用户的代码用户执行!这样最起码保证OS的安全!

2、在信号检测时,在做啥?

在检测信号时,其实就是在检查PCB中的那三张表:blockpendinghandler

首先,检查 pending 的每一位,看是否是1;如果是1, 再看 block表,如果是1,就代表阻塞,如果是0,就去看handler 是默认还是自定义;自定义就去用户态执行,自定义就以内核态的身份执行完了,切换回用户态继续往下执行!

3、为什么执行完用户自定义的处理方法后,需要切换回内核在切换回用户继续执行呢?

因为handler是内核的系统调用,调的!和在用户态的主执行流没有直接的调用关系,所以执行完用户自定义的方法后是没有办法回到主执行流的!但是内核是知道的,所以当只想完用户的handler后,通过sigreturn 的特殊系统调用回到内核,然后在通过sys_sigreturn 返回用户,继续向下执行主控制流!

4、sigaction

捕捉信号除了signal 还可以使用sigaction对信号进行捕捉!

OS提供这个系统调用的目的是为了让我们可以在处理信号时,自定义哪些信号要被阻塞!

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数

第一个 signum 是指定哪个信号要被自定义处理。

第二和第三都是 struct 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);	//实时信号相关,不用管
};

这个结构体中,我们只需要关心,第一个参数,他是我们自己指定处理signum信号的处理方法!

sa_mask 是屏蔽哪些信号,用户自定义完成; 

sa_flags 一般设置为0, 其他都是和实时信号有关系的,这里不管!

所以,我们如果有需求,可以将sa_mask屏蔽掉一批信号,然后执行自己自定义的那一个,这样可以避免其他信号对signum的干扰:

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

void Print(sigset_t &pending)
{
    for (int sig = 31; sig > 0; sig--)
    {
        if (sigismember(&pending, sig))
        {
            std::cout << 1;
        }
        else
        {
            std::cout << 0;
        }
    }
    std::cout << std::endl;
}

void handler(int signum)
{
    std::cout << "get a sig: " << signum << std::endl;
    while (true)
    {
        sigset_t pending;
        sigpending(&pending);

        Print(pending);
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 3);
    act.sa_flags = 0;

    sigaction(2, &act, &oact);

    while (true)
    {
        std::cout << "I am a process, pid: " << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

此时由于2好信号没有结束,当再次收到其他信号时,会先屏蔽起来,所以发2号和3号信号都会被阻塞,上面也看到了!当该信号阻塞结束后,才会取消对其他信号的屏蔽!

注意:31个普通信号只有少部分可以被屏蔽,其他的不能屏蔽!

三、扩展知识

1、可重入函数

可重入函数可以简单的理解为,可以被重新进入的函数!

比如单链表的头插场景中,节点node1还未完成插入时,假设刚执行了一步,此时信号被捕捉了,而且处理方式还是自定义的,而且处理方法是将node2也进行头插,此时先执行的是node2的头插,当给node2执行完之后,会在回执行node1的头插,此时会导致node2内存泄漏

此时导致内存泄漏的本质是,node1和node2在操作时同时并发访问了同一个单链表,且对这个单链表没有做任何的保护!因此在并发时就出现可重入导致的内存泄漏,此时的单链表就是临界资源!

我们以前学过的90%的函数都不是可重入函数!

不可重入的条件:

  • 调用了内存管理相关函数
  • 调用了标准 I/O 库函数,因为其中很多实现都以不可重入的方式使用数据结构

2、volatile

volatile是C/C++的一个关键字,它的作用是避免编译器的优化,保证内存的可见性!

我我们举一个栗子:

第一步,我们先使用一个全局变量设计一个死循环的场景,在此之前对2号信号进行自定义捕捉,在自定义捕捉的函数体内,实现将flag赋值为1!

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

int flag = 0;   // 一开始为假

void handler(int signo)
{
    printf("%d号信号已经成功发出了\n", signo);
    flag = 1;
}

int main()
{
    signal(2, handler);

    while(!flag);   // 故意不写 while 的代码块 { }

    printf("进程已退出\n");

    return 0;
}

OK,没问题,符合预期!我们知道编译器会优化,g++一般有个优化等级:

man gcc
/O1

如果你不指定就是O0, 我们这里就优化的小一点O1:

我们发现哎不就是优化了一下吗,这咋就不行了呢?即使疯狂的发送2号信号也不结束了!

其实,这就是编译器把我们的代码给优化了!

我们一般的代码的数据,首先会加载到内存,然后CPU调度时将数据加载到寄存器,这样做是没问题的!但是现在我一优化,编译器一看主函数你就没有对flag做处理,且只有你用了falg!(不用handler十不调的),所以此时会将flag直接设置进寄存器里面:

等到后面信号处理时,即使修改了flag,也不会同步到寄存器了!所以此时主函数那个循环一直就是0你发2好信号他就收到一次,但是就是不结束!

如何解决这个问题呢?将Volatile将在全局的flag前,就可以避免了:

此时就OK了!

3、SIGCHLD

前面在介绍进程等待的时候,介绍过为了避免子进程僵尸,父进程是要以阻塞或者非阻塞轮询的方式等待子进程的退出的

现在的问题是:父进程如何知道子进程退出了呢?

其实,子进程再退出的时候,会给父进程发送 SIGCHLD 的信号!该信号的默认动作时 忽略!

所以,我们可以不再是阻塞是的等到他,而是可以把 SIGCHLD 信号,给捕捉了,让他在自定义的函数体内进行等待!

我们举个例子:让子进程三秒后直接退出,用信号等待:

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

void handler(int sig)
{
    std::cout << "get a signal: " << sig << " pid: " << getpid() << std::endl;
    pid_t rid = waitpid(-1, nullptr, 0); // 阻塞式等待
    if (rid > 0)
    {
        std::cout << "wait child success, rid: " << rid << std::endl;
    }
}

void DoOtherThing()
{
    std::cout << "DoOtherThing()~" << std::endl;
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if (id == 0)
    {
        // child
        std::cout << "I am child process, pid: " << getpid() << std::endl;
        sleep(3);
        exit(1);
    }

    // father
    while (true)
    {
        DoOtherThing();
        sleep(1);
    }

    return 0;
}

没问题!可以等待成功!

可是现在又有问题,你上面是等待了任意一个子进程,且退出的只有一个!那我10个子进程同时退出呢?

我们可以的自定义的方法里面,循环的等待:

那我如果,10个子进程,5个退出5个时钟不退呢?上面这样的代码不就是,一直阻塞了吗?

其实,了可以在处理信号的函数里将其设置为,不要hang住:

void handler(int sig)
{
    std::cout << "get a signal: " << sig << " pid: " << getpid() << std::endl;
    while (true)
    {
        pid_t rid = waitpid(-1, nullptr, WNOHANG); // 非阻塞式等待
        if (rid > 0)
        {
            std::cout << "wait child success, rid: " << rid << std::endl;
        }
        else if (rid < 0)
        {
            std::cout << "wait child success done " << std::endl;
            break;
        }
        else
        {
            std::cout << "wait child success done " << std::endl;
            break;
        }
    }
}

我现在就是不想产生僵尸,又不想等,你结束了就自己退回吧,咋办呢? 

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

4、浅谈键盘输入数据的过程

我们前面介绍过,键盘等外设的数据都是由操作系统调用他们各自的驱动程序读取的!这是我们以前介绍的!但是这样说太过于笼统了,我下面介绍一下它的大概流程!

我们前面一直说的数CPU和外设在数据层面上不打交道,可没说在控制上也不打交道!键盘等外设都有一个自己的中断号,当键盘输入数据时,会由 8259等类似的芯片通过总线(USB等)给CPU发送中断,CPU收到中断后,会读取键盘的中断号存在内部的寄存器里面, 并保存当前任务的上下文,OS在一开始加载的时候会在内存加载一张函数指针数组的表,也称中断向量表!他里面每个元素都是提前设计好的,例如:读磁盘、读网卡、读键盘、等!上述的外设的中断号可以简单的理解为该中断向量表的下标!CPU就会拿着这个中断号,在中断向量表中索引,当找到中断号对应的下标,就去调度OS执行对应的方法!当执行完后,会执行一条中断返回指令,继续执行原来任务!

介绍完这个东西,你可能会觉得这不就是 和我们的信号一样吗?是的!但是,是先有的中断,信号是模拟中断产生的!信号是纯软件,中断是软件+硬件!

5、浅理解OS是如何正常运行的

• 如何理解系统调用

系统调用的本质是一张函数指针数组!它里面就是所有系统调用的函数名!我们平时的调用系统调用时,本质底层是CPU拿着系统调用号,到OS的系统调用的函数指针数组执行相关的方法!

这个系统调用号,从哪里来?

其实当你执行系统调用时,它的内部一定会将相对应的系统调用号,move到相关的寄存器,然后通过0x80等发生硬件中断,让CPU保存上下文,然后切换为内核态,按照寄存器里面的值索引到相关的方法,然后执行!

当然真实的系统调用比这复杂的多,我们简单的这样理解一下即可!

• OS 是如何运行的

操作系统的本质就是一个死循环+时钟中断, 不停的调度系统的任务!

外部的硬件时钟,会隔一定的时间(很短)相CPU发送时钟中断,CPU收到时钟中断后,获取中断号,然后检查当前任务的时间片,如果任务的时间片没有结束,继续执行该任务,如果时间片结束了,切换为内核态,按照中断号找到中断向量表中的对应方法即调度其OS的其他任务!

当然这是最简单的理解,真实的比这个复杂的多!!!

OK,本期分享就到这里!好兄弟,我是cp我们下期再见!

结束语:愿写尽代码千行,头发依旧如当初模样!

  • 12
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值