信号——【信号的产生+信号的保存+信号的处理】

信号

互斥等4个概念

都能看见的资源:公共资源

  1. 互斥:如何一个时刻,都只允许一个执行流在进行共享资源的访问—加锁
  2. 任何一个时刻都只允许一个执行流在进行访问的共享资源,叫做临界资源
  3. 临界资源是要通过代码访问的,凡是访问临界资源的代码,叫做临界区
  4. 原子性:要么不做,要么做完。只有两种确定状态的属性。
    在这里插入图片描述

信号量

例如:看电影买票,买了票才能进去看电影,且确保电影票不会超过座位资源数量而导致冲突,如果放映厅是顶级VIP放映厅,只有一个座位,就形成互斥。

信号量/信号灯本质就是一个计数器int count=? ? ,描述资源数量的计数器

如果这个计数器是1,则是二元信号量,互斥功能:将临界资源独立使用!

因为不同进程可以看到同一个计数器count(资源),所以信号量被归类到了进程间通信

任何一个执行流,想访问临界资源中的一个子资源的时候,不能直接访问,进程要通过执行代码来申请,所有的进程得先看见信号量!

P操作: 先申请信号量资源,if(count>0)count- -;else 挂起阻塞 只要申请成功,就一定能拿到一个子资源。

然后进入自己的临界区,访问对应的临界资源。
V操作: 释放信号量资源,count++ 计数器增加,则表示对应的资源进行了归还

对于计数器count的加减操作一定是原子的!
在这里插入图片描述

接口

查看信号量ipcs -s 删除信号量ipcrm -s
在这里插入图片描述
在这里插入图片描述
公共拥有的结构体ipc_perm
在这里插入图片描述

理解IPC

每个ds结构中都有一个ipc_perm结构体,通过ipc_perm指针数组形成多态
在这里插入图片描述

什么是信号

1、程序员设计进程的时候,早就已经设计了对信号的识别能力,进程在没有收到信号的时候就能辨别一个信号如何被处理了。

2、当一个信号产生时,进程可能在做优先级更高的事情,无法立即处理这个信号,需要在合适的时候处理信号

3、所以一个进程收到信号时,如果没有立即处理这个信号,需要进程具有记录信号的能力:信号产生———>**时间窗口(保存信号)**———>信号处理

4、发送信号本质就是写入信号,直接修改特定进程的信号位图中的特定比特位0->1:
task_struct数据内核结构必定存在一个位图结构来管理信号,且只能由OS进行修改,无论后面有多少种信号产生方式,最终都必须让OS来完成最后的发送过程
1~31个信号是只有保存无产生的信号,用int32位二进制表示;其余都是实时信号

5、信号的产生对于程序来讲是异步

处理信号的方式:1、默认动作 2、忽略信号 3、用户自定义捕捉(handler)

信号的产生

信号的产生:键盘、系统调用、指令、软件条件、硬件异常

在输入的时候,键盘被按下,键盘通过硬件中断的方式,通知OS键盘被按下
在这里插入图片描述

系统调用产生信号

1.signal—— 对指定的信号设定自定义处理动作
signal(int sig, void (*func)(int))
参数:
sig:要发送的信号
func:自定义操作的函数

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
void handler(int signo)//自定义操作
{
  printf("catch a signal : %d\n", signo);
}
int main()
{
    signal(2, handler);
    while(1)
    {
        cout<<"我的PID是:"<<getpid()<<endl;
        sleep(2);
    }
    return 0;
}

在这里插入图片描述
Ctrl+C执行的是2号信号,我们自定义2号信号为handler自定义操作,当我们键盘产生信号时就会执行自定义操作,但是没有给Ctrl+Z自定义操作,所以可以退出。

  1. raise——给进程自己发送信号
    int raise(int sig);
    参数:
    sig:要发送的信号
    返回值:
    成功返回0,失败返回-1
    和kill比较:
    raise函数相当于kill(getpid(), sig)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
void handler(int signo)
{
  printf("catch a signal : %d\n", signo);
}

int main()
{
  signal(2, handler);
  while(1){
     raise(2);
     sleep(1);
  }
  return 0;
}

在这里插入图片描述
3.abort——给自己发送指定的信号(发送6号信号)
void abort(void);

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
int main()
{
  while(1){
     abort();
  }
  return 0;
}


3.通过软件条件产生

管道如果读端不读了,存储系统会发生SIGPIPE 信号给写端进程,终止进程。这个信号就是由一种软件条件产生的,这里再介绍一种由软件条件产生的信号SIGALRM(时钟信号)

alarm——设定一个闹钟,操作系统会在闹钟到了时送SIGALRM信号给进程,默认处理动作是终止进程
#include <unistd.h>
unsigned alarm(unsigned seconds);
参数:
second:设置时间,单位是s
返回值:
0或者此前设定的闹钟时间还余下的秒数

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
int main()
{
  alarm(1);
  int cnt=0;
  while(1)
  {
    cnt++;
    cout << cnt << endl;
  }
  
  return 0;
}

在这里插入图片描述
“闹钟“就是一个软件实现的,任意一个进程都可以通过alarm系统调用在内核中设置闹钟,OS内可能存在很多的闹钟,操作系统需要管理这些闹钟,即先描述,再组织
在这里插入图片描述

4.通过硬件产生的异常(CPU异常和MMU异常)

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int main()
{
  // 由软件条件产生信号  alarm函数和SIGPIPE
  // CPU运算单元产生异常,内核将这个异常处理为SIGFPE信号发送给进程
  int a = 10;
  int b = 0;
  printf("%d", a/b); 
  return 0;
}

在这里插入图片描述
CPU产生异常:发生除零错误,CPU运行单元会产生异常,内核将这个异常解释为信号,最后OS发送SIGFPE信号给进程:8) SIGFPE
在这里插入图片描述

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main()
{
  // MMU硬件产生异常,内核将这个异常处理为SIGSEGV信号发送给进程
  int* p = NULL;
  printf("%d\n", *p);
  return 0;
}

在这里插入图片描述

MMU产生异常:当进程访问非法地址时,mmu想通过页表映射来将虚拟转换为物理地址,此时发现页表中不存在该虚拟地址,就会产生异常,OS将异常解释为信号,然后发送给进程:
11) SIGSEGV
在这里插入图片描述

Tips:所有信号的产生都是借助OS向目标进程发送信号,即向目标进程PCB写入信号位图

核心转储文件 core.pid

在这里插入图片描述

Term:就是直接终止,没有多余动作
Core:终止前会先进行核心转储,然后再终止进程

在这里插入图片描述
使用ulimit -a查看所有资源限定设置,可以看到,核心转储文件默认是关闭的。

核心转储文件:OS可以将该进程在异常的时候,核心代码部分进行核心转储,将内存中进程的相关数据,全部dump到磁盘中方便异常后进行调试一般在云服务器上看不到,默认是关闭的。打开指令:ulimit -c 1024

gdb时,输入核心转储文件即可自动定位问题代码:**(gdb) core-file core.pid**

为什么核心转储文件一直是被关闭的呢?

就算一个程序就几行代码,它的转储文件大小都有几百KB,如果是一个更大的程序,挂掉后又被重新挂起,就会反复生成巨大的转储文件,导致把硬盘空间挤爆。
关闭指令:ulimit -c 0

core dump标志位

int status = 0;
waitpid(id, &status, 0);
cout << "exit code: " << ((status>>8) & 0xFF) << endl;//退出码
cout << "exit signal: " << (status & 0x7F) << endl;   //退出信号
cout << "core dump flag: " << ((status>>7 & 0x1)) << endl;

当开启时,报错后core dump为1,反之为0

信号的保存

概念

  • 实际执行信号的处理动作称为信号递达
  • 信号递达的三种方式:默认、忽略和自定义捕捉
  • 信号从产生到递达之间的状态(保存起来),称为信号未决(Pending)。
  • 进程可以选择阻塞(Block)某个信号。
  • 阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作

注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
在这里插入图片描述

信号在内核图中的表示

OS发生信号给一个进程,此信号不是立即被处理的,那么这个时间窗口中,信号就需要被记录保存下来,那么信号是如何在内核中保存和表示的呢?
在这里插入图片描述
pending表:如果该位为1,代表收到该信号,处于未决状态为0代表还没收到该信号或者收到信号已经被递达了;它是一个32位无符号整数。
uint32_t pending=0;pending |=(1<<(signo-1))

block表:每个信号对应1位,如果该位为1,那么代表该信号被阻塞,为0代表不被阻塞

handler表:代表对该信号递达动作,默认(SIG_DFL)、忽略(SIG_IGN)、自定义捕捉,其中自定义捕捉就是用户自定义的函数。handler表本质其实是函数指针数组,存放的是用户自定义函数的指针,该数组的下标,表示信号编号

信号集

typedef struct
{
	unsigned long int __val[_SIGSET_NWORDS];
}__sigset_t

sigset_t: 未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,也被定义

一种数据类型。这个类型可以表示每个信号状态处于何种状态(是否被阻塞,是否处于未决状态)

阻塞信号集也叫做当前进程的信号屏蔽字这里的“屏蔽”应该理解为阻塞而不是忽略

例如:修改127位置的状态,首先127/(sizeof(unsigned long int)*8) 定位下标,这里的unsigned long int4字节为标准,得到下标为3,然后对val进行增删查改操作:
XXX->__val[3] & (1<<(127%(sizeof(unsigned long int)*8))) 如需删除,&与上取反即可

信号集操作函数的原型

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
  • sigemptyset: 初始化set指向的信号集,将所有比特位置0
  • sigfillset: 初始化set指向的信号集,将所有比特位置1
  • sigaddset: 把set指向的信号集中signum信号对应的比特位置1
  • sigdelset: 把set指向的信号集中signum信号对应的比特位置0
  • sigismember: 判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)

Tips:在实现这些函数之前,需要使用sigemptysetsigfillset对信号集进行初始化。前四个函数的返回值是成功返回0,失败返回-1。最后一个函数的返回值是真返回0,假返回-1

1.sigprocmask——阻塞信号集操作函数
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

功能:
读取或更改进程的信号屏蔽字

参数:
how:三个选项:
SIG_BLOCK:把set中的信号屏蔽字添加到进程的信号屏蔽字中,mask = mask|set
SIG_UNBLOCK:把set中的信号屏蔽字在进程信号屏蔽字的那些去掉,mask = mask&~set
SIG_SETMASK:设置当前进程的信号屏蔽字为set,mask = set
set:如果为非空指针,则根据how参数更改进程的信号屏蔽字
oset:如果为非空指针,将进程原来的信号屏蔽字备份留在oset中
返回值:
成功返回0,失败返回-1

2.sigpending——未决信号集操作函数
int sigpending(sigset_t *set);

功能:
读取进程的未决信号集
参数:
set:读取当前进程的信号屏蔽字到set指向的信号屏蔽中
返回值:
成功返回0,失败返回-1

实例演示:把进程中信号屏蔽字2号信号进行阻塞,然后隔1s对未决信号集进行打印

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <iostream>
using namespace std;
static void show_pending(const sigset_t& pending)
{
  for(int signo=31;signo>=1;--signo)
  {
    //判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)
    if(sigismember(&pending,signo))
    {
      cout << "1";
    }
    else
    {
      cout << "0";
    }
  }
  cout << endl;
}
int main()
{
  // 1. 先尝试屏蔽指定的信号
  sigset_t set,oset;
  sigset_t pending;
  //1.1使用系统函数对信号集进行初始化
  sigemptyset(&set);
  sigemptyset(&oset);
  sigemptyset(&pending);
  //阻塞2号信号
  //1.2添加要屏蔽的信号
  sigaddset(&set,2);
  //1.3开始屏蔽,设置进内核, 前面的代码没有影响当前进程,从这段开始影响
  //oset保存原来的信号
  sigprocmask(SIG_BLOCK,&set,&oset);
  //2.遍历打印pending信号集
  while(1)
  {
    //2.1初始化
    sigemptyset(&pending);
    //2.2获取它
    sigpending(&pending);
    //2.3打印
    show_pending(pending);
    sleep(1);
  }
}

在这里插入图片描述
进程收到2号信号时,且该信号被阻塞,处于未决状态,没有被递达,未决信号集中2号信号对应的比特位由0置1,所以代码一直运行

然后我们在进行运行10s后,我们将信号屏蔽字中2号信号解除屏蔽

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <iostream>
using namespace std;
static void show_pending(const sigset_t& pending)
{
  for(int signo=31;signo>=1;--signo)
  {
    //判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)
    if(sigismember(&pending,signo))
    {
      cout << "1";
    }
    else
    {
      cout << "0";
    }
  }
  cout << endl;
}
int main()
{
  // 1. 先尝试屏蔽指定的信号
  sigset_t set,oset;
  sigset_t pending;
  //1.1使用系统函数对信号集进行初始化
  sigemptyset(&set);
  sigemptyset(&oset);
  sigemptyset(&pending);
  //阻塞2号信号
  //1.2添加要屏蔽的信号
  sigaddset(&set,2);
  //1.3开始屏蔽,设置进内核, 前面的代码没有影响当前进程,从这段开始影响
  //oset保存原来的信号
  sigprocmask(SIG_BLOCK,&set,&oset);
  //2.遍历打印pending信号集
  int cnt=10;
  while(1)
  {
    //2.1初始化
    sigemptyset(&pending);
    //2.2获取它
    sigpending(&pending);
    //2.3打印
    show_pending(pending);
    sleep(1);
	
		//解除屏蔽
    **cnt--;
    if(cnt==0)
    {
      cout << "屏蔽信号解除" << endl;
      sigprocmask(SIG_UNBLOCK,&set,&oset);
    }**
  }
}

2号信号解除阻塞后,信号被递达了,进程终止
在这里插入图片描述

信号的处理

信号处理可以不是立即处理的,而是在“合适”的时候,如果一个信号之前被阻塞(block),当它解除block的时候,对应的信号会被立即递达!

为什么信号是在”合适“的时候处理的呢?

信号的产生是异步的,当前进程可能正在做更重要的事情!当进程从内核态切换回用户态的时候,进程在OS的指导下,才进行信号的检测与处理:默认、忽略、自定义捕捉。

  • 用户态: 处于⽤户态的 CPU 只能受限的访问内存,用户的代码,并且不允许访问外围设备,权限比较低
  • 内核态: 处于内核态的 CPU 可以访问任意的数据,包括外围设备,⽐如⽹卡、硬盘等,权限比较高
    在这里插入图片描述

CR3寄存器

操作系统中有一个cr寄存器来记录当前进程处于何种状态
0 表示正在运行的进程执行级别是内核态
3 表示正在运行的基础执行级别是用户态

用户无法直接更改执行级别,OS提供的所有系统调用,内部在正式执行调用逻辑的时候,会去修改执行级别

内核级页表

进程空间分为用户空间和内核空间。之前的页表都是指用户级页表,其实还有内核级页表
进程的用户空间是通过用户级页表映射到物理内存上,内核空间是通过内核级页表映射到物理内存上
在这里插入图片描述
上面的图主要说明:进程处于用户态访问的是用户空间的代码和数据,进程处于内核态,访问的是内核空间的代码和数据。

1、所有进程的[0,2]GB是不同的,每一个进程都要有自己的用户级页表
2、所有进程的[3,4]GB是相同的,每一个进程都可以看到同一张内核级页表,所有进程都可以通过统一的窗口看到同一个OS
3、OS运行的本质:其实都是在进程的地址空间内运行的!
4、所谓的系统调用的本质,其实就如同调用.so中的方法,在自己的地址空间中进行函数跳转并返回即可!

进程有不同的用户空间,但是只有一个内核空间,不同进程的用户空间的代码和数据是不一样的,但是内核空间的代码和数据是一样的。

进程是如何调度的?

  • OS是软件,本质是一个死循环
  • OS时钟硬件,每隔很短的时间向OS发送时钟中断

时钟中断——OS要执行对应的中断处理方法:检测当前进程的时间片schedule() ,进程被调度,就是时间片到了,然后讲进程对应的上下文等进行保存并切换,选择其他合适的进程。

信号捕捉的过程

在这里插入图片描述
从上面的图可以看出,进程是在返回用户态之前对信号进行检测检测pending位图,根据信号处理动作,来对信号进行处理。这个处理动作是在内核态返回用户态后进行执行的

如果信号处理动作是用户自定义的函数,简写如下:
在这里插入图片描述
其中4个绿点是4次状态切换,4个红点对应上图的1、2、4、5执行步骤,3则是信号检测过程

sigaction—信号捕捉

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

功能:
可以读取和修改与指定信号相关联的处理动作
参数
signum: 要操作的信号
act:一个结构体
sa_handler:SIG_DFT、SIG_IGN和handler(用户自定义处理函数)
sa_sigaction:实时信号处理的函数,我们不关心
sa_mask:一个信号屏蔽字,里面有需要额外屏蔽的的信号
sa_flags:包含一下选项,这里我们给0
sa_restorer:我们这里不使用
act:如果不为空,根据act修改信号处理动作
oact: 如果不为空,备份原来的信号处理动作给oact
返回值:
成功返回0,失败返回-1

act结构体如下:

struct sigaction {
	void     (*sa_handler)(int);
    void     (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t   sa_mask;
    int        sa_flags;
    void     (*sa_restorer)(void);
};

实例演示:

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

void handler(int signo)
{
  printf("catch a signal: %d\n", signo);
}

int main()
{
  struct sigaction act, oact;

  act.sa_flags = 0;// 选项 设置为0
  sigfillset(&act.sa_mask);
  act.sa_handler = handler;
  // 对2号信号修改处理动作
  sigaction(2, &act, &oact);
  while (1){
    raise(2);
    sleep(1);
  }
  return 0;
}

在这里插入图片描述

可重入函数

在这里插入图片描述
main函数调用insert()head中插入节点node1,插入操作分为两步,刚做完第一步时,因为硬件中断使进程切换到内核,再次返回用户态之前检查到有信号待处理,于是切换到sighandler()sighandler()也调用insert()向同一个head中插入节点node2,插入操作的两步都做完之后从sighandler()返回内核态,再次回到用户态就从main函数调用的insert()中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler()先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了

像上例这样,insert()不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入insert()访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为
“不可重入函数”,反之,如果一个函数只访问自己的局部变量或参数,则称为“可重入(Reentrant) 函数”。

为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

在多线程中,每个线程虽然是资源共享,但是他们的栈却是独有的,所以说局部变量不会造成错乱

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile关键字

#include <stdio.h>
#include <signal.h>
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
using namespace std;
int quit=0;
void handler(int signo)
{
	cout << signo << "号信号,正在被捕捉!" << endl;
	cout << "quit:" << quit ;
	quit=1;
	cout << "->" << quit << endl ;
}
int main()
{
	signal(2,handler);
	while(!quit) ;
	cout << "注意,我是正常退出的" << endl;
	return 0;
}

正常编译都是使用g++ -o $@ $^ -std=c++11 得到的结果如下:输入ctrl+C
在这里插入图片描述
但如果编译的时候带上O3级别的优化呢?g++ -o $@ $^ -O3 再次运行如下:
在这里插入图片描述
改变了也不会退出,这是为什么呢?
quit存在内存中,优化过后quit在main中没有变化,编译器就把quit放入寄存器cache中,此后都是读取cache中的quit,再写入内存中的quit,但循环判断条件的quit是在cache中一直不变为0的
在这里插入图片描述

volatile:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

加上这个关键字后volatile int quit=0;,就能正常运行了

SIGCHILD信号

子进程在死亡的时候,会向父进程发送SIGCHILD信号,不过父进程默认是忽略的,使用man 7 signal查看
在这里插入图片描述
用以上知识检查看看是不是17号信号

#include <stdio.h>
#include <signal.h>
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
	printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo);
}
void Count(int cnt)
{
    while (cnt)
    {
        printf("cnt: %2d\r", cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    printf("\n");
}
int main()
{
	signal(17,handler);
	printf("我是父进程, %d, ppid: %d\n", getpid(), getppid());
	pid_t id=fork();
	if(id==0)
	{
		printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
		sleep(20);
        exit(1);
	}
	//保持父进程在运行
	while (1)
    sleep(1);
	return 0;
}

运行结果如下
在这里插入图片描述
这样的意义在于,以前父进程被动式的等待,例如阻塞等待子进程,或者主动去“问问子进程”,即非阻塞式等待,现在我们可以让子进程叫我们了!

所以我们可以把handler写成如下形式

void handler(int signo)
{
	// 1. 我有非常多的子进程,在同一个时刻退出了 【只需要循环处理】
	// 2. 我有非常多的子进程,在同一个时刻只有一部分退出了 【必须非阻塞式等待,因为操作系统不知道你有多少个子进程要退出
	//如果你没退出,在这里就会造成死循环】
	//waitpid第一个参数是pid,这里是多个子进程,所以设置-1,意思是会等待任意一个子进程
	  printf("捕捉到一个信号: %d, who: %d\n", signo, getpid());
    sleep(5);

    // 5个退出,5个没退
    while (1)
    {
        pid_t res = waitpid(-1, NULL, WNOHANG);
        if (res > 0)
        {
            printf("wait success, res: %d, id: %d\n", res, id);
        }
        else break; // 如果没有子进程了?
    }
}

由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程

signal(17,SIG_IGN);

这里的手动设置的IGN和之前默认的IGN是不一样的

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值