Linux(九) 信号

目录

一、什么是信号

二、信号的种类

三、信号的产生

3.1 通过终端按键产生信号

Core Dump 核心转储

3.2 调用系统函数向进程发信号

3.3 由软件条件产生信号

3.4 硬件异常产生信号

四、信号的注册

五、信号的注销

六、信号的三种处理方式

七、信号的递达阻塞未决

八、信号集

sigprocmask

sigpending

九、信号的捕获

sigaction

十、可重入函数 

十一、volatile

十二、SIGCHLD


一、什么是信号

  1. 输入命令,在Shell下启动一个前台进程。
  2. 用户按下Ctrl+C,键盘输入产生一个硬件中断。
  3. 如果CPU当前正在执行这个进程的代码,则该进程的用户空间代码暂停执行, CPU从用户态切换到内核态处理硬件中断。
  4. 终端驱动程序将Ctrl+C解释成一个SIGINT信号,操作系统OS将其记在该进程的PCB中(也可以说发送了一个SIGINT信号给该进程)。
  5. 当某个时刻要从内核返回到该进程的用户空间代码继续执行之前,首先处理PCB中记录的信号,发现有一个SIGINT信号待处理,而这个信号的默认处理动作是终止进程,所以直接终止进程而不再返回它的用户空间代码执行。

Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。

Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步
(Asynchronous)的。

二、信号的种类

使用命令查看:kill -l

非可靠信号:1~31号信号,信号可能会丢失
可靠信号:34~64号信号,信号不可能丢失

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2

编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

SIGHUP:1号信号,Hangup detected on controlling terminal or death of controlling process(在控制终端上挂起信号,或让进程结束),ation:term

SIGINT:2号信号,Interrupt from keyboard(键盘输入中断,ctrl + c ),action:term

SIGQUIT:3号信号,Quit from keyboard(键盘输入退出,ctrl+ | ),action:core,产生core dump文件

SIGABRT:6号信号,Abort signal from abort(3)(非正常终止,double free),action:core

SIGKILL:9号信号,Kill signal(杀死进程信号),action:term,该信号不能被阻塞、忽略、自定义处理

SIGSEGV:11号信号,Invalid memory reference(无效的内存引用,解引用空指针、内存越界访问),action:core

SIGPIPE:13号信号,Broken pipe: write to pipe with no readers(管道中止: 写入无人读取的管道,会导致管道破裂),action:term

SIGCHLD:17号信号,Child stopped or terminated(子进程发送给父进程的信号,但该信号为忽略处理的)

SIGSTOP:19号信号,Stop process(停止进程),action:stop

SIGTSTP:20号信号,Stop typed at terminal(终端上发出的停止信号,ctrl + z),action:stop

三、信号的产生

3.1 通过终端按键产生信号

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。

Core Dump 核心转储

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024

 验证进程等待中的core dump位:

    pid_t id = fork();
    if(id == 0)
    {
        sleep(100);
        int a = 10;
        //a /= 0;
    }

    int status = 0;
    waitpid(id,&status,0);
    cout << "父进程pid:" << getpid() << " 子进程pid:" << id << " exit code:" <<\
     (status & 0x7F) << " core dump:" << ((status >> 7) & 1) << std::endl;

为什么生产环境中一般都关闭core dump? 

因为现实中服务器可能会挂掉重启,如果每次挂掉都写core.xxx文件就会导致磁盘挤满,OS会出问题

然后写一个死循环程序:

前台运行这个程序,然后在终端键入Ctrl-C( 貌似不行)或Ctrl-\(介个可以):
 ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了。 使用core文件:

gdb调试,自动帮我们定位哪一行代码出问题了 。

3.2 调用系统函数向进程发信号

首先在后台执行死循环程序,然后用kill命令给它发SIGSEGV信号。
 

    cout << "我是一个进程,pid:" << getpid() << endl;
    while(1);

 

29228是test进程的id。之所以要再次回车才显示 Segmentation fault ,是因为在29228进程终止掉 之前已经回到了Shell提示符等待用户输入下一条命令,Shell不希望Segmentation fault信息和用 户的输入交错在一起,所以等用户输入命令之后才显示。
指定发送某种信号的kill命令可以有多种写法,上面的命令还可以写成 kill -SIGSEGV 29228或 kill -11 29228, 11是信号SIGSEGV的编号。以往遇 到的段错误都是由非法内存访问产生的,而这个程序本身没错,给它发SIGSEGV也能产生段错误。

kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己给自己发信号)。

#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函数总是会成功的,所以没有返回值。

6号信号,SIGABRT,通常用来终止进程

abort() = raise(6) = kill(getpid(),6)

如何系统调用接口?
用户调用系统接口->执行OS对应的系统调用接口->OS提取参数,或者设置特定的数值->OS向目标进程写入信号->修改对应进程的信号标记位->进程后续会处理信号->执行对应的处理动作 

3.3 由软件条件产生信号

SIGPIPE是一种由软件条件产生的信号,管道读端关闭,OS会给写端发送SIGPIPE信号,自动终止写端进程,读端关闭是软件条件不满足。

alarm:unsigned int alarm(unsigned int seconds);,收到14号信号,告诉内核在seconds秒后给进程发送SIGALRM信号,该信号默认处理动作为终止当前进程。

3.4 硬件异常产生信号

如何理解除0呢?
进行计算的是CPU,是个硬件,CPU内部是有寄存器的,状态寄存器(位图),有对应的状态标记位,溢出标记位,OS会自动进行计算完毕之后的检则!如果溢出标记位是1(CPU设置的),OS里面识别到有溢出问题,立即只要找到当前谁在运行提取PID,OS完成信号发送的过程,进程会在合适的时候,进行处理,一旦出现硬件异常,进程一定会退出吗?不一定!一般默认是退出,但是我们即便不退出,我们也做不了什么。
为什么会死循环?寄存器中的异常一直没有被解决!

 如何理解野指针或者越界问题?
1.都必须通过地址,找到目标位置
⒉我们语言上面的地址,全部都是虚拟地址

3.将虚拟地址转成物理地址

4.页表+MMU (memory manage unit ,硬件),页表是一部分硬件一部分软件

5.野指针->越界 -> 非法地址 ->MMU转化的时候一定会报错

四、信号的注册

在pcb中有一个未决(pending)信号集合(未决(pending)的意思是信号产生了但还没有决定怎么做),信号的注册就是指在这个pending集合中标记对应信号数值的二进制位为1

上面的话有些难以理解,我们先来看看在linux内核源码里一个进程的信号是如何保存的

在linux内核源码sched.h中的task_struct结构体里有这样一段关于信号的内容:

/* signal handlers */
        struct signal_struct *signal;
        struct sighand_struct *sighand;
​
        sigset_t blocked, real_blocked;
        sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */
        struct sigpending pending;

上面最后一行的sigpending结构体定义在signal.h中:

struct sigpending {
        struct list_head list;
        sigset_t signal;
};

这里的signal就是用来做信号标记的,给一个进程发送一个信号说白了就是在signal里标记一下这个信号曾经来过

那么signal是如何进行标记的呢?还得继续了解一下sigset_t这个结构体

在bits/sigset.h中进行了以下定义:

/* A `sigset_t' has a bit for each signal.  */
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
  {
    unsigned long int __val[_SIGSET_NWORDS];
  } __sigset_t;

注:这里的__sigset_t其实就是sigset_t,只是一个类型名的重定义

在这个结构体中只有一个数组成员,这个数组里存放着一些数作为位图,位图的每一个二进制位就代表了一种信号,0表示未曾收到这个信号,1表示已经收到这个信号

这里需要注意的是,真正存放信号的是数组中某个数的某个二进制位,数组的存在只是因为单独一个数的二进制位存不下这么多种类的信号

现在我们就可以理解,当使用上述方式对某一个进程发送一个信号时,操作系统就会将该进程对应的pending集合中表示相应信号的位图的二进制位由0改为1

但是非可靠信号和可靠信号的注册还有一点区别

为了理解这种区别我们还应该了解一下list_head链表和signal.h中的sigqueue结构体

list_head是linux内核提供的一个用来创建双向循环链表的结构,由于这个结构是没有数据域的所以较为复杂,在这里不做深究。

我们需要知道的是,内核通过一个以list为表头的链表将所有产生的信号都串在了一起,链表中的每个节点的结构是一个sigqueue:

/*
 * Real Time signals may be queued.
 */
struct sigqueue {
        struct list_head list;
        int flags;
        siginfo_t info;
        struct user_struct *user;
};

这个结构体保存信号所携带的信息

现在我们就可以对非可靠信号和可靠信号的区别有一定的了解了

  • 1~31非可靠信号的注册:
    当试图对一个进程发送一个非可靠信号时,若发现位图上对应的位为0,则置为1,并在list_head链表里加入一个sigqueue节点;若发现位图上对应的位已经为1,则直接返回。简单地说就是若信号还未注册,则注册一下,若已经注册,则什么都不做
  • 34~64可靠信号的注册:
    当试图对一个进程发送一个可靠信号时,若发现位图上对应的位为0,则置为1,并在list_head链表里加入一个sigqueue节点;若发现位图上对应的位已经为1,对该位不进行操作但依旧在链表里加入一个节点。也就是说,每次对进程发送一个可靠信号时,不管该进程之前是否收到过相同的信号,总是会在list_head链表里加入sigqueue节点

对于信号来说,位图只是用来标记有没有待处理信号的,而节点才是信号真正注册的信息

五、信号的注销

看上文中信号的生命周期会发现,在处理信号之前,会先销毁信号的信息

信号注销存在的目的就是为了抹除信号存在的痕迹,防止对同一个信号进行多次处理

删除要处理的信号sigqueue节点:

  • 若信号是非可靠信号,则直接将位图置0(非可靠信号在没有处理之前只会注册一次)
  • 若信号是可靠信号,则删除后需要判断是否还有相同节点,没有的话才会重置位图为0

六、信号的三种处理方式

1. 忽略此信号。
2. 执行该信号的默认处理动作。
3. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

七、信号的递达阻塞未决

信号其他相关常见概念

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

  1. 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  2. SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
  3. 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本文不讨论实时信号。
  1. 信号的阻塞,并不会干扰信号的注册。信号能注册,但不能被立即处理,
  2. 将block位图中对应的信号比特位置为1,表示阻塞该信号
  3. 进程收到该信号,还是一如既往的注册
  4. 当进程进入到内核空间,准备返回用户空间的时候,调用do_signal函数,就不会立即去处理该信号了
  5. 当该信号不被阻塞后,就可以进行处理了

八、信号集

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来表示,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
  • 这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask

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

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

  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。
  • 如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

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

sigpending

#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 下面用刚学的几个函数做个实验。程序如下:

#include <iostream>
#include <signal.h>
#include <assert.h>
#include <unistd.h>
using namespace std;

void showpening(sigset_t &pending)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        if (sigismember(&pending, sig))
        {
            cout << "1";
        }
        else
        {
            cout << "0";
        }
    }
    cout << endl;
}
void handler(int signum)
{
    cout << "捕捉信号:" << signum << endl;
}
void blocksig(int sig)
{
    sigset_t bset;
    sigemptyset(&bset);
    sigaddset(&bset,sig);
    int res = sigprocmask(SIG_BLOCK,&bset,nullptr);
    assert(res == 0);
    (void)res;
}

int main()
{
    for(int sig = 1;sig <= 31;sig++)
    {
        blocksig(sig);
    }
    sigset_t pending;
    while(true)
    {
        sigpending(&pending);
        showpening(pending);
        sleep(1);
    }


    return 0;
}

程序运行时,每秒钟把各信号的未决状态打印一遍,由于我们阻塞了所有信号,那岂不是任何信号都无法杀死进程了,OS考虑了这个问题,9号信号SIGKILL和19号信号SIGSTOP不会被阻塞

九、信号的捕获

内核态处理默认和忽略信号是很容易的,直接就做了,这里的是捕捉信号,所以重新进入用户态,其实OS可以直接执行sighandler,但是OS为了自身安全考虑,不会执行,还是会返回到用户态来执行。内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。

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

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段.

十、可重入函数 

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构

我们用的90%的函数都是不可重入的。

十一、volatile

int flag = 0;
void handler(int sig)
{
printf("chage flag 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("process quit normal\n");
return 0;
}

 运行此代码发现当我们给进程发送2号信号的时候,进程并没有被终止,这是为什么呢?

正常是edx读取flag,进行修改或者判断。 但是编译器优化的时侯,编译器发现在main函数里没有任何一个语句是改flag的,所以编译器认为每次检测flag的时候都要edx检测一下内存太慢了,所以编译器就在第一次检测的时候把flag放入edx,之后每次检测的时候就自己检测edx不再访问内存了,所以我们在编写代码的时候必须显性的告诉编译器哪些代码不能被优化,就需要使用volatile修饰。

此时需要使用的编译代码是

g++ -o $@ $^ -std=c++11 -03 #03代表优化等级

十二、SIGCHLD

 进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
 

int main()
{
    signal(SIGCHLD,SIG_IGN); // 虽然SIGCHLD默认忽略,但默认忽略不会回收子进程,要显式的使用才会自动回收
    if(fork() == 0)
    {
        cout << "child:" << getpid() << endl;
        sleep(5);
        exit(0);
    }
    while(true)
    {
        cout << "parent:" << getpid() << "执行我自己的任务" << endl;
        sleep(1);
    }


    return 0;
}
void handler(int signum)
{
    cout << "子进程退出:" << signum << " parent pid:" << getpid() << endl;
    wait();
    //10 子进程都退出
    // while(wait())
    // 10 5个子进程退出?
    // 如果五个子进程退出,由于pending只有一位,所以父进程不知道有几个退出,所以父进程还会等待
}


// 证明子进程退出,会给父进程发信号
int main()
{
    signal(SIGCHLD,handler);
    if(fork() == 0)
    {
        cout << "child pid:" << getpid() << endl;
        sleep(3);
        exit(0);
    }
    while(true) sleep(1);


    return 0;
}

  • 30
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值