Linux下的信号

信号

信号

红绿灯,下课铃,闹钟等都是信号,我们是认识这些信号的,在没有收到这些信号之前,我们也知道如果收到了这些信号我们该怎么做

进程也是,在没有收到信号的之前,就知道如何处理这些信号

如何处理:1.有执行默认动作SIG_DFL。2.执行自定义动作。3.忽略SIG_IGN

可能进程在运行的时候突然就收到了一个信号,所以进程要有记录信号的能力,要将信号记录下来

进程可能一次要处理多个信号,所以对于信号,进程需要组织管理起来,就是先描述,在组织

kill -l查询所有信号
在这里插入图片描述
我们可以看到没有0信号,31信号之后直接就是34信号,1-31是普通信号,34-64是实时信号,括号里的数字就是信号,后面大写的字母就是信号名称,是宏

信号的产生对于进程来讲是异步的

进程对于信号的保存是只保存有无产生,不是保存收到的这个信号的数量
有31个普通信号,保存有无产生可以用0和1来保存,所以用位图结构就特别合适,信号的位图结构保存在进程的pcb中

所以保存信号,就是修改进程信号位图的特定比特位
比特位的位置是信号的编号,比特位的内容0或1表示是否收到信号
要修改进程pcb中的信号位图结构,进程的代码是没有权限的,要用系统调用接口来修改

信号产生

键盘输入信号

mysignal.cc

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

using namespace std;

int main()
{
    while(1)
    {
        cout << "我是一个进程,我的PID:" << getpid() << endl;
        sleep(1);
    }
    return 0;
}

makefile

mysingal:mysingal.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f mysingal

在这里插入图片描述
通过指令向进程发信号,进程执行了9号信号的默认动作,杀死进程

在这里插入图片描述

我们按了ctrl c的之后,键盘就会产生硬件中断,被操作系统将其解释成2号信号,发送给前台进程,前台进程收到信号,退出

  • 前台进程:操作系统只允许一个进程处于前台,默认为bash,当你执行了这样一个死循环的代码之后,前台进程就不是bash了,是你的死循环进程,所以你输入的指令不起作用了,只有ctrl c终止进程在这里插入图片描述
  • 后台进程./可执行文件 &即可变成后台进程,之后我们执行指令是不受影响的,这个时候不能ctrl c终止,只能发信号终止
    在这里插入图片描述
捕捉信号singal函数

signal函数是一个用于处理信号的函数

sighandler_t signal(int signum, sighandler_t handler)
  • signum:要修改执行方法的信号编号。
  • handler:指向信号新的执行方法的指针。
  • 返回值:是先前为该信号设置的处理函数的指针,如果该信号之前没有设置过处理函数,则返回 SIG_DFLSIG_IGN

要注意sighandler_t是一个函数指针类型,参数是int,返回值是void的函数指针

signal函数用于指定在接收到指定信号时调用的处理函数。它允许程序捕获和处理不同类型的信号,例如程序终止信号、键盘中断信号等。

在这里插入图片描述

mysignal.cc:

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

using namespace std;

void handler(int signo)
{
    cout << "收到的信号:" << signo << endl; 
}

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

    while(1)
    {
        cout << "我是一个进程,我的PID:" << getpid() << endl;
        sleep(1);
    }
    return 0;
}

ctrl c会被操作系统解释成2号信号,现在确实没有终止进程,而去执行了我们自定义的方法
在这里插入图片描述
所以,如果我们将所有信号都设置为执行我们的自定义方法,那么进程是不是就无法被杀死了?我们试一下
在这里插入图片描述
在这里插入图片描述
此时除了9号信号,其他信号都被修改为执行自定义方法,所以想创造一个无法被杀死的进程,这样是不可能的

系统调用输入信号

kill

给任意进程发任意信号

int kill(pid_t pid, int sig);

man 2 kill2号手册中可以查询到
在这里插入图片描述

  • pid 参数是指定要发送信号的进程的进程ID(PID)。
  • sig 参数是要发送的信号的编号。

我们可以自己写一个程序来向其他进程发信号

mysignal.cc

#include <iostream>
#include <cstdlib>
#include <cassert>
#include <unistd.h>
#include <cstring>
#include <sys/types.h>
#include <signal.h>
#include <string>

using namespace std;

void usage(string proc)
{
    cout << "usage: \n";
    cout << proc << " 信号编号 目标进程\n" << endl;
}


int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        usage(argv[0]);
        exit(1);
    }
    int signo = stoi(argv[1]);
    int id = stoi(argv[2]);
    int n = kill(id, signo);
    if(n != 0)
    {
        cout << errno << " :" << strerror(errno) << endl;
        exit(2);
    }

    return 0;
}

在这里插入图片描述

raise
int raise(int sig);

给进程自己发任意信号,与kill不同,kill是可以向任意进程发任意信号,raise就只能给自己发任意信号
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

abort
void abort(void);

这是一个c语言库函数,给进程自己发送指定的6号终止信号
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
abort函数有点不同,它是c语言库函数,就算我们将6号信号改为执行我们自定义的方法,我们自定义的方法并没有退出,abort函数还是会终止进程
在这里插入图片描述

在这里插入图片描述

软件条件产生信号

SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。

alarm
unsigned int alarm(unsigned int seconds);

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
alarm函数在设置的时间结束后发送了SIGALRM,14号信号
在这里插入图片描述

在这里插入图片描述
返回值为之前设置的闹钟的剩余时间

硬件异常产生信号

硬件异常是指在计算机系统的硬件层面上发生的异常情况
例如访问无效的内存地址、野指针、除以0、非法指令等。
当硬件异常发生时,操作系统会通过生成相应的信号来通知进程。

我们写一个除0的代码
在这里插入图片描述
程序可以正常被编译,只不过会有警告,运行之后程序崩溃了,浮点数异常
在这里插入图片描述
CPU中有状态寄存器,状态寄存器中包含了0,进位,溢出,符号,奇偶标志位
溢出标志位可以记录本次计算是否有溢出问题
除0就是一个溢出问题,如果发送了除0错误,溢出标志位会被置1

操作系统来检查发现CPU的溢出标志位为1,就是硬件异常,就会向进程发送SIGFPE,8号信号

我们修改一下代码,让8号信号去执行我们的自定义方法
在这里插入图片描述
在这里插入图片描述

我们可以看到,我们没有退出进程,会一直执行我们写的自定义方法
因为我们的进程没有退出,执行了一次8号信号的自定义方法之后,进程会继续向下执行代码
执行之前操作系统会检查是否有硬件异常,而CPU的溢出标志位仍然为1,所以操作系统又识别到硬件异常,所以又向进程发送SIGFPE,8号信号,一直这样循环

我们再来看看野指针错误发生的硬件异常
在这里插入图片描述
在这里插入图片描述
野指针问题引发的段错误
在这里插入图片描述
在这里插入图片描述
CPU中有个硬件MMU,操作系统是通过MMU这个硬件来查询页表的映射关系的,MMU报错,操作系统发现硬件异常,会向进程发送SIGSEGV,11号信号

MMU(Memory Management Unit)是计算机系统中的一个硬件组件,用于管理内存的访问和映射。它负责将虚拟地址(在程序中使用的地址)转换为物理地址(内存中的实际地址)

我们的代码*a = 10;这句代码运行的时候,首先第一步是通过虚拟地址找到物理地址
如果操作系统通过MMU没有找到虚拟地址到物理地址的这个映射关系,MMU硬件就会报错
如果找到了映射关系,然后就查页表中的这个映射关系的权限,如果没有写的权限,那么MMU硬件报错,有权限就写入成功

在这里插入图片描述

信号保存

man 7 signal可以查询信号更详细的信息
在这里插入图片描述
在这里可以查询到这样的列表,其中Action中有TermCore,都是终止,但是有些不同,Term只是终止了进程,而Core会终止进程并且生成核心转储文件

  • 核心转储(Core Dump):当一个程序发生崩溃或异常终止时,操作系统可以生成一个核心转储文件,记录程序在崩溃时的内存状态和执行堆栈信息。核心转储文件对于调试和分析程序崩溃非常有用,可以提供有关崩溃原因和上下文的重要信息。通过分析核心转储文件,开发人员可以了解程序在崩溃前的状态,并定位错误的源头。
  • 终止(Term):终止是指一个程序的正常或异常结束。当一个程序完成其任务或因某种原因无法继续运行时,它可能会被终止。终止可以是正常的,例如程序运行完毕并顺利退出,或者是异常的,例如发生严重错误导致程序崩溃。终止可以由程序自身触发,也可以由操作系统或其他外部因素触发

Core Dump核心转储

核心转储(Core Dump),也称为崩溃转储,是在计算机系统中发生严重错误或程序崩溃时生成的一种文件。它记录了程序在崩溃时的内存状态和执行堆栈信息,提供了诊断和调试错误的重要依据。

当一个程序发生崩溃或异常终止时,操作系统会捕获程序的当前内存状态,并将其保存为一个核心转储文件,在程序当前目录下形成一个core.pid这样的二进制文件。这个文件包含了程序运行时的内存映像,包括堆、栈、寄存器状态以及其他相关的调试信息

核心转储文件对于调试和分析程序崩溃非常有用,它可以提供以下信息:

  1. 内存状态:核心转储文件记录了程序崩溃时的内存状态,包括堆和栈中的数据。这些信息可以帮助开发人员了解程序在崩溃前的运行状态,进而定位错误的原因。
  2. 执行堆栈:核心转储文件中包含了程序崩溃时的执行堆栈信息。通过分析执行堆栈,可以确定程序崩溃的位置和调用链,从而指导调试和修复错误。
  3. 变量和数据:核心转储文件还可以包含程序崩溃时的变量和数据的值。这些数据可以帮助开发人员理解程序崩溃的上下文,并有助于定位错误的来源。

使用核心转储文件进行调试时,可以使用调试器工具(如GDB)加载核心转储文件,并检查内存状态、执行堆栈以及变量的值,以便定位和修复错误。

我用的是云服务器,云服务器是默认关闭Core Dump这个功能的,首先我们先打开这个功能

ulimit -a显示当前用户的资源限制信息
在这里插入图片描述

  • core file size:核心转储文件的最大大小(以字节为单位)。如果该值为0,则表示禁用核心转储。
  • data seg size:数据段的最大大小(以字节为单位)。它限制了程序可以使用的堆和全局数据的大小。
  • file size:单个文件的最大大小(以字节为单位)。
  • open files:用户可同时打开的最大文件数。它限制了进程可以打开的文件数目。
  • stack size:栈的最大大小(以字节为单位)。它限制了进程可以使用的栈空间大小。
  • cpu time:CPU 时间限制,表示进程在用户模式下可以使用的最大 CPU 时间。
  • max user processes:用户可创建的最大进程数。
  • max memory size:进程可使用的最大内存大小(以字节为单位)。
  • pipe size:管道缓冲区的最大大小(以字节为单位)。
  • max locked memory:进程可以锁定的最大内存大小(以字节为单位)。

ulimit -c size设置核心转储文件的大小在这里插入图片描述
我们试试生成核心转储文件,一个正常运行的进程,如果收到了对应信号,那么也会生成核心转储文件,不是非要发生了错误异常,等操作系统发送信号才会生成核心转储文件
在这里插入图片描述

在这里插入图片描述
可以看到我们发送2号信号,并没有生成核心转储文件,因为2号信号是Term,发送8号信号生成了核心转储文件,8号信号是Core

我们来试下用核心转储文件调试,我们编译生成的可执行文件默认是release模式的,我们要在编译的时候带上-g选项,生成调试模式
在这里插入图片描述
在这里插入图片描述
gdb 可执行程序进入调试模式,然后输入命令core-file 核心转储文件名,就会直接跳转到程序出错的那一行,并且会说明报错的信号,报错的文件
在这里插入图片描述
ulimit -c 0关闭生成核心转储文件

阻塞信号

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的被保存的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是信号递达的一种处理动作。

在这里插入图片描述

  • pending表:位图结构。比特位的位置表示哪种信号,比特位的内容表示是否收到该信号,也就是是否未决

  • block表:位图结构,比特位的位置表示哪种信号,比特位的内容表示该信号是否被阻塞

  • handler表:函数指针数组,数字下表表示信号编号,数组的内容表示该信号的递达动作

SIG_DFL,信号执行默认动作,实际上是把0强转成函数指针
SIG_IGN,信号被忽略,实际上是把1强转成函数指针
在这里插入图片描述

信号集sigset_t和信号集操作函数

sigset_t 是一个用于表示信号集的数据类型。它是一个用来存储一组信号的位图结构,可以用于管理和操作信号的状态,比如设置信号的阻塞或解除阻塞,以及检查信号是否在集合中。

  • sigemptyset(sigset_t *set):将信号集 set 清空,即将所有位都设置为0,表示没有任何信号。
  • sigfillset(sigset_t *set):将信号集 set 填满,即将所有位都设置为1,表示包含所有信号。
  • sigaddset(sigset_t *set, int signum):将信号 signum 添加到信号集 set 中,即将相应的位设置为1。
  • sigdelset(sigset_t *set, int signum):从信号集 set 中删除信号 signum,即将相应的位设置为0。
  • sigismember(const sigset_t *set, int signum):检查信号 signum 是否在信号集 set 中,如果在则返回非零值,否则返回0。
  • sigprocmask(int how, const sigset_t *set, sigset_t *oldset):用于管理进程的信号屏蔽字,可以阻塞或解除阻塞指定的信号集。how 参数指定操作类型,set 参数指定要设置的信号集,oldset 参数用于存储之前的信号集。
  • sigpending(sigset_t *set):获取当前被阻塞的待处理信号集,在调用 sigprocmask 函数设置了信号屏蔽字后,这些信号可能会被阻塞并排队等待处理

阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)

sigprocmask

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

用于设置或修改进程的信号屏蔽字

在这里插入图片描述

  • how 参数指定了信号屏蔽字的操作类型,可以是以下三个值之一:

    • SIG_BLOCK:将 set 中的信号添加到当前信号屏蔽字中,即将相应的位设置为1。
    • SIG_UNBLOCK:从当前信号屏蔽字中移除 set 中的信号,即将相应的位设置为0。
    • SIG_SETMASK:将当前信号屏蔽字替换为 set 中的值。
  • set 参数指定了要设置的信号屏蔽字,它是一个指向 sigset_t 结构的指针。
    根据 how 参数的不同,set 可以是一个新的信号屏蔽字,或者是一个包含要添加或移除的信号的信号集。

  • oldset 参数是一个指向 sigset_t 结构的指针,是输出形参数,用于存储之前的信号屏蔽字。
    如果 oldset 不是 NULL,则 sigprocmask 函数会将之前的信号屏蔽字存储在 oldset 所指向的结构中。

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

using namespace std;

void printpending(const sigset_t &pending)
{
    cout << "pending位图";
    for(int signo = 1; signo <= 31; signo++)
    {
        if(sigismember(&pending, signo))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

int main()
{
    cout << "pid:" << getpid() << endl;
    //1.屏蔽2号信号
    sigset_t set, oset;
    //1.1初始化
    sigemptyset(&set);
    sigemptyset(&oset);

    //1.2将号信号添加到set中
    sigaddset(&set, 2);
    //1.3将新的信号屏蔽字设置到进程
    sigprocmask(SIG_BLOCK, &set, &oset);

    //2.不断获取进程的pending信号集,一直打印
    while(1)
    {
        //2.1获取pending信号集
        sigset_t pending;
        sigemptyset(&pending);

        int n = sigpending(&pending);
        assert(n == 0);
        (void)n;//防止出现警告

        //2.2打印pending信号集
        printpending(pending);

        sleep(1);
    }

    return 0;
}

我们对2号信号阻塞,现在进程收到2号信号,确实存进了pending表中,并且2号信号没有被递达
在这里插入图片描述
我们修改一下代码,让2号信号执行我们自定义函数并且在阻塞2号信号10s后解除阻塞
在这里插入图片描述
在这里插入图片描述
可以看到,收到2号信号之后,pending位图对应2号信号的位置由0变1,到达10s后2号信号被解除屏蔽,解除屏蔽后信号会立马被递达,2号信号执行完我们自定义的方法之后,pending位图对应的位置由1变0,如果执行的是默认方法,进程会被终止

在这里插入图片描述

信号的处理

进程收到信号之后,可能并不会立即被处理,要到合适的时候处理

信号被立即处理的一种情况:如果信号之前被block阻塞了,当被解除阻塞的时候就会立即递达

信号的产生是异步的,当进程从内核态回到用户态的时候,进程会在OS的指挥下,进行信号的检测和处理

用户态:执行用户写的代码时,进程所处的状态
内核态:执行OS的代码的时候,进程所处的状态
用户态切换到内核态通常是系统调用,中断和异常处理等

每一个进程的地址空间中都分为用户空间和内核空间,内核空间存放着操作系统的代码和数据结构,内核空间在地址空间的高地址部分,只能由操作系统内核访问

进程的程序地址空间中有两张页表,一张用户级页表,让用户在物理内存中找到自己的代码,一张内核级页表,让操作系统找到自己的代码

操作系统的代码是不变的,用户的代码每个都是不同的,所以用户级页表每个进程都有一张,内核级页表只有一张,所有进程都是看到同一张内核页表

所以,所谓的系统调用的本质就是调用函数并返回

那操作系统的代码就在进程的地址空间中,进程就可以随意的访问操作系统的代码和数据吗?

不可以的,用户要访问操作系统的代码和数据就要由用户态切换到内核态,CPU中有一个CR3寄存器就存放着标志着用户态和内核态等的标志位,要切换到内核态就要修改CPU中CR3寄存器,而修改这个寄存器用户态是无法修改的,只有当调用了系统调用接口,这个时候操作系统就会先将修改CPU中CR3寄存器切换到内核态,然后再执行操作系统的代码

在这里插入图片描述

捕捉信号sigaction

前文我们一直都用的signal对信号进行捕捉
signal函数在处理信号时是一种较为底层的方式,更高级的信号处理方式可以使用sigaction函数
sigaction函数提供了更多的灵活性和可移植性,可以更好地处理信号

int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
  • signum:指定要设置处理方式的信号编号。
  • act:指向 struct sigaction 结构的指针,用于设置新的信号处理方式。
  • oldact:指向 struct sigaction 结构的指针,用于存储旧的信号处理方式。
struct sigaction {
    void     (*sa_handler)(int);  // 处理函数的指针
    void     (*sa_sigaction)(int, siginfo_t *, void *);  // 扩展处理函数的指针
    sigset_t   sa_mask;           // 阻塞信号的集合
    int        sa_flags;          // 信号标志
    void     (*sa_restorer)(void); // 已弃用,忽略即可
};
  • sa_handler:指定一个函数,用于处理信号。当信号到达时,操作系统会调用这个函数来处理信号。
  • sa_mask指定一个信号屏蔽集合,可以自定义阻塞其他信号的传递,直到当前信号处理完成,处理完成后会解除屏蔽。当前信号被处理时,当前信号会自动被屏蔽。
  • sa_flags:用于设置信号的标志,可以控制信号的行为,例如 SA_RESTART 可以使被信号中断的系统调用自动恢复执行。

使用sigaction方法对信号进行捕捉
在这里插入图片描述
在这里插入图片描述

volatile

我们来看看volatile的用法

我们写的代码是捕捉2号信号,程序收到2号信号之后更改全局变量quit的值,然后循环条件不满足退出死循环
在这里插入图片描述
在这里插入图片描述

编译器有0,1,2,3等的优化级别,我们试着让编译器优化我们的代码

在这里插入图片描述
编译选项我们加了-O2,提高了编译器优化级别,现在发现我们程序在收到2号信号的时候并没有退出死循环,难道全局变量quit并没有被修改吗?quit确实被修改了

我们的进程的变量都要被加载到内存里,所以quit是在内存里的,
们while语句!quit,是一种计算,是要在CPU里计算的,所以quit要从内存加载到CPU的寄存器里
然后进行CPU进行计算,判断while循环是否成立
所以每一次判断while循环都要将quit从内存加载到CPU的寄存器中,quit是高频被不断判断的

而编译器优化就是发现我们的quit是不会被修改的,又是高频使用的,所以编译器就修改了我们的代码,省去了quit不断的从内存加载到CPU的寄存器这一步
quit只会从内存加载到CPU的寄存器一次,之后就一直拿CPU的寄存器的quit值去判断,所以编译器优化之后,我们内存中的quit确实被修改了,但是while循环判断的quit值确一直拿修改前的quit值去判断,所以死循环不会退出

编译器优化实际上就是修改我们的代码

上述这种情况叫做内存位置不可见,而volatile的功能就是让编译器不要用在CPU寄存器中老的数据,要用新的,从内存中加载到CPU寄存器中的新的数据,volatile就是确保变量可见性,禁止编译器优化
在这里插入图片描述
在这里插入图片描述
我们加上volatile关键字,现在就是正常退出while循环了

SIGCHILD

SIGCHILD,17号信号

之前在进程等待时,父进程都是阻塞式和非阻塞式等待子进程,这都是父进程主动的检测,这是之前我们不知道子进程有没有退出,只能阻塞式一直等待或轮询式等待

子进程退出其实是会向父进程会发送SIGCHILD,17号信号的,只是这个信号默认动作就是忽略,什么都不做

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

using namespace std;

void handler(int signo)
{
    cout << "收到的信号:" << signo << "pid:"<< getpid() << endl;
}

int main()
{
    signal(17, handler);
    
    pid_t id = fork();
    if(id == 0)
    {
        int cnt = 5;
        while(cnt > 0)
        {
            printf("我是子进程, 我的pid: %d,ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }
    
	while(true)
    {
        sleep(1);
    }
    return 0;
}


在这里插入图片描述
对照pid可以看到,父进程确实收到了子进程在退出时发送的SIGCHILD,17号信号

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

using namespace std;

pid_t id;
void handler(int signo)
{
    cout << "收到的信号:" << signo << "pid:"<< getpid() << endl;
    sleep(5);//让子进程僵尸5秒
    pid_t res = waitpid(-1, NULL, 0);
    if(res > 0)
    {
        printf("等待成功, res: %d, id: %d\n", res, id);//id是子进程的pid,res也是子进程的pid
    }
}

int main()
{
    signal(17, handler);
    
    id = fork();
    if(id == 0)
    {
        int cnt = 5;
        while(cnt > 0)
        {
            printf("我是子进程, 我的pid: %d,ppid: %d\n", getpid(), getppid());
            sleep(1);
            cnt--;
        }
        exit(1);
    }

    while(true)
    {
        sleep(1);
    }
    return 0;
}

在这里插入图片描述
这样就可以让父进程运行自己的代码,然后子进程退出,发送信号了,父进程再去回收子进程

也可以不产生僵尸进程,直接让操作系统回收子进程,不用通知父进程,只需要
signal(SIGCHILD, SIG_IGN);SIG_IGN在这里是特例,并不是忽略,而是自动清理子进程,不产生僵尸进程,此方法只保证在Linux中有效

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值