[ Linux ] 进程信号递达,阻塞,捕捉

目录

1.core dump字段

1.1 Core dump是什么?

1.2 用代码看看Core Dump

1.3 core dump的作用

core dump一般会被关掉

2.阻塞信号

2.1 信号其他相关常见概念

2.2在内核中的表示

3.信号产生中

3.1 sigset_t

3.2信号集操作函数

3.2.1 sigprocmask

3.2.2 sigpending

3.3 使用程序查看pending表

3.3.1手动设置pending表

3.3.2 恢复信号

4.信号捕捉

4.1用户级页表和内核级页表

4.2进程的信号什么时候处理

4.3 内核如何实现信号的捕捉

快速记忆

4.4 sigaction

mask


1.core dump字段

core dump在进程控制中进程等待部分,我们遗留了一个core dump字段,在waitpid中有一个status参数,该参数是一个输出型参数,其中status不能简单的当做整形来看待,我们说要当做位图来看待。

我们关于status只需要关心该整数的低16个比特位。这16个比特位会分为3个部分。次低8位(8-15)存放这子进程的退出码;低7位的作用,我们刚刚说到,代码跑完结果正确,代码跑完,结果不正确,那么代码异常呢?因此低7位的作用就是处理异常。一个进程如果异常退出,是因为这个信号收到了特定的信号!!还有一个位就是第7位也就是core dump字段。

1.1 Core dump是什么?

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

1.2 用代码看看Core Dump

我们使用C++语言写一个最简单的异常错误,查看core dump

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

using namespace std;

int main()
{
    //  fork 创建 child process
    pid_t id = fork();
    if (id == 0)
    {
        // child
        int *p = nullptr;
        *p = 1000;
        //访问野指针

        exit(1); //退出码设置为1
    }

    //父进程阻塞等待
    int status = 0;
    waitpid(id,&status,0);
    printf("exit code:%d, signo : %d , core dump code : %d\n",
        (status>>8) & 0xFF, status & 0x7F, (status >> 7)&0x1);

    return 0;
}

注意:如果你是用云服务器输出的的结果中core dump code为0时,请查看ulimit -a中core file size字段的大小是否为0,如果为0时使用 ulimit -c 1024 即可。再次运行时,core dump code 就会变成1。

当我们运行结束后,再查看当前工作目录下,会发现生成了一个core文件,文件后面的数字是引起core文件的进程是谁。-- 核心转储

核心转储:会把进程在运行中,对应的异常上下文数据,core dump到磁盘上,方便调试。并且如果core dump了会将status的core标志位置1。

1.3 core dump的作用

当程序发生异常终止或者程序运行崩溃时,对应的异常上下文数据会被core dump到磁盘上,方便调试,那么我们如果来查看呢?如何准确的定位呢?这里我们将进行演示,首先我们更改makefile,给程序加上-g调试选项信息。

myproc:myproc.cc
	g++ -o $@ $^ -g -std=c++11

.PHONY:clean
clean:
	rm -f myproc

然后,我们运行该程序(注意,一定要打开core file size 信息)

我们发现已经成功的生成了core 文件,现在我们进入gdb调试阶段之后,输入core-file 【生成的core文件】

按下回车我们发现,程序直接定位到了错误的行数,这样就方便我们定位错误,方便调试。

如果就是core dump的意义和使用方式。

core dump一般会被关掉

为什么core dump一般会被关掉呢?

core dump一般要配合gdb使用,而线上发布的产品一般是release版本,由于不是debug版本,即使我们获得了core文件,也gdb不了。

2.阻塞信号

2.1 信号其他相关常见概念

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

2.2在内核中的表示

我们会在写程序的时候,会无数次的直接或者间接的访问系统级软硬件资源(管理者是OS),本质上你并没有自己去操作这些软硬件资源,而是必须通过操作系统无数次的陷入内核调用内核的代码完成访问的动作,然后把结果返回给用户,用户得到结果。

信号在内核中的表示示意图

  1. 每个信号都有两个标志位分别表示阻塞(Block)和未决(Pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,知道信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  2. SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  3. SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。

因此通过pending表格和hanlder这两个表,进程就具备了识别和处理信号的能力。由于pending表是一个位图,下标表示几号信号,表中的内容为0表示该信号没有收到,为1表示收到了该信号。因此根据pending表就可以知道进程时候收到了某一信号。如果收到了某一信号,接下来再来看hanlder表格,hanlder是一个函数指针数组,表示的是收到某一个信号后的处理动作。因此这也就具备了处理信号的能力。

3.信号产生中

我们在刚刚了解了pending和hanlder,还有一个block,block是用来阻塞某个信号。block也是一个位图。block位图和pending位图是一模一样的,但是含义是存在不一样的。其中:block和pending位图中第几个比特位代表第几个信号,这是一样的;而不一样的是,位图中第几个比特位的内容表示是不一样的,我们刚说pending位图中比特位的内容表示是否收到信号,而block位图中比特位的内容表示是否阻塞(拦截)该信号。也就是说即使pending收到了该信号,但是被block之后还是不能递达,不能hanlder。

3.1 sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞表示也是这样表示的。因此,未决和阻塞表示可以用相同的数据类型sigset_t来存储。,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态。在阻塞信号中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集合中“有效”和“无效”的含义是该信号是否处于未决状态。

因此我们有了sigset_t在用户层上有了block信号集和pending信号集。而block信号集也叫做信号屏蔽字。

3.2信号集操作函数

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

#include <signal.h>
//对信号集做清空
int sigemptyset(sigset_t *set);
//对信号集全置1
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);

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

3.2.1 sigprocmask

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

#include <signal.h>
// set是新增,覆盖,删除  oset 通过该参数返回屏蔽字
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返回前,至少将其中一个信号递达。

3.2.2 sigpending

#include <signal.h> 
int sigpending(sigset_t *set);
//读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 

这个参数可以拿出来我们pending位图的内容。也可以修改pending表

3.3 使用程序查看pending表

3.3.1手动设置pending表

在一开始所有的信号都不会被block,所有pending都是0,所有hanlder都是default默认的。接下来我们想尝试获取一下当前进程的pending信号集。

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

using namespace std;

static void showPending(sigset_t *pendings)
{
    // 一个信号一个信号判断 如果在就打印1 如果不在打印0
    for(int sig = 1; sig<= 31;sig++)
    {
        if(sigismember(pendings,sig))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout << endl;
}

int main()
{
    //1.尝试不断的获取当前进程的pending信号集
    sigset_t pendings;
    while(true)
    {
        // 1.1 清空信号集
        sigemptyset(&pendings);
        
        // 1.2 获取当前进程的pengding信号集
        if(sigpending(&pendings) == 0 )
        {
            // 1.3 打印一下当前进程的pending信号集
            showPending(&pendings);
        }
        sleep(1);
    }
    return 0;
}

当程序执行起来时,我们发现进程的pending表都是空的,因为我们有一个置零的操作,那么我们如果更形象的看到当前进程的收到某一信号时,pending表对应位置变为1呢?因此我们加入signal函数。并且被block5秒钟,因此一旦2号信号收到时,前5秒是无法被递达,并且保存在pending表中

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

using namespace std;


void hanlder(int signo)
{
    cout<<"我是一个进程,刚刚获取到了一个信号:"<<signo<<endl;
}

static void showPending(sigset_t *pendings)
{
    // 一个信号一个信号判断 如果在就打印1 如果不在打印0
    for(int sig = 1; sig<= 31;sig++)
    {
        if(sigismember(pendings,sig))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout << endl;
}

int main()
{
    //3.屏蔽2号信号
    sigset_t bsig,obsig;
    sigemptyset(&bsig);
    sigemptyset(&obsig);
    //3.1添加2号信号到信号屏蔽字中
    sigaddset(&bsig,2);
    //3.2设置用户级的信号屏蔽字到内核汇总 让当前进程屏蔽到2号信号
    sigprocmask(SIG_SETMASK,&bsig,&obsig);

    //2.signal
    signal(2,hanlder);
    //1.尝试不断的获取当前进程的pending信号集
    sigset_t pendings;
    while(true)
    {
        // 1.1 清空信号集
        sigemptyset(&pendings);
        
        // 1.2 获取当前进程的pengding信号集
        if(sigpending(&pendings) == 0 )
        {
            // 1.3 打印一下当前进程的pending信号集
            showPending(&pendings);
        }
        sleep(1);
    }
    return 0;
}

至此我们可以看到,当把2号信号block后,只能暂时保存在pending表中,因此当我们查看pending表时,2号位置被置为1。那么我们如果把31个信号全部都block呢?会发生什么?

我们发现9号信号是不会被block的,这个我们也了解。9号信号是管理员信号,不能被用户所修改操作。

3.3.2 恢复信号

如果我们想把刚才的信号恢复,一旦恢复就会立马被递达。那么pending信号集也会从1变成0

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

using namespace std;

int cnt = 0;

void hanlder(int signo)
{
    cout<<"我是一个进程,刚刚获取到了一个信号:"<<signo<<cnt<< endl;
}

static void showPending(sigset_t *pendings)
{
    // 一个信号一个信号判断 如果在就打印1 如果不在打印0
    for(int sig = 1; sig<= 31;sig++)
    {
        if(sigismember(pendings,sig))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout << endl;
}

int main()
{
    cout<<"进程pid:"<<getpid()<<endl;
    //3.屏蔽2号信号
    sigset_t bsig,obsig;
    sigemptyset(&bsig);
    sigemptyset(&obsig);
    //3.1添加2号信号到信号屏蔽字中
    for(int sig = 1;sig<=31;sig++)
    {
        sigaddset(&bsig,sig);
        //2.signal
        signal(sig,hanlder);
    }
    //3.2设置用户级的信号屏蔽字到内核汇总 让当前进程屏蔽到2号信号
    sigprocmask(SIG_SETMASK,&bsig,&obsig);

    //1.尝试不断的获取当前进程的pending信号集
    sigset_t pendings;
    while(true)
    {
        // 1.1 清空信号集
        sigemptyset(&pendings);
        
        // 1.2 获取当前进程的pengding信号集
        if(sigpending(&pendings) == 0 )
        {
            // 1.3 打印一下当前进程的pending信号集
            showPending(&pendings);
        }
        sleep(1);
        cnt++;
        if(cnt == 10)
        {
            //结束信号
            cout<<"解除对所有信号的block......" <<endl;
            sigprocmask(SIG_SETMASK,&obsig,nullptr);
        }
    }
    return 0;
}

这是我们一次性把所有的信号都恢复,如果我们指向恢复特定信号(这里用2号信号为例),什么意思?就是当进程运行10秒时,只解除对2号信号的block。看到的现象时只有2号恢复为0,其他唯一,我们可以使用SIG_UNBLOCK。具体代码如下:

这里只把if里面稍加更改即可

        if(cnt == 10)
        {
            //结束信号
            cout<<"解除对2号信号的block......" <<endl;
            sigset_t sigs;
            sigemptyset(&sigs);
            sigaddset(&sigs,2);
            sigprocmask(SIG_UNBLOCK,&sigs,nullptr);
            
            
            
            //cout<<"解除对所有信号的block......" <<endl;
            //sigprocmask(SIG_SETMASK,&obsig,nullptr);
        }

4.信号捕捉

4.1用户级页表和内核级页表

我们在之前学习进程地址空间的时候谈到,一个进程的进程地址空会被分为用户空间(0~3G)和内核空间(3~4G),其中进程地址空间通过页表与物理内存映射,而用户级页表是每一个进程都有一份自己的用户级页表。内核级页表是所有进程共享的,只有一份,前提是你有权利访问!因此无论进程怎么切换,我们都可以找到内核的代码和数据,前提是你只要能够有权利访问! 当前进程如何具备权利,访问这个内核页表,乃至访问内核数据呢?进程如果是用户态的--只能访问用户级页表;进程如果是内核态的--能够访问内核级和用户级页表。而我们怎么知道我是用户态还是内核态呢?CPU内部有对应的状态寄存器CR3,有比特位标识当前进程的状态。其中3表示用户态,0表示内核态。

4.2进程的信号什么时候处理

进程的信号在被合适的时候处理--从内核态返回用户态的时候--检测--处理

  1. 如何理解内核态和用户态
  2. 进程的生命周期中,会有很多次机会去陷入内核(中断,陷阱,系统调用,异常.....),一定会存在很多次的机会进入内核态返回用户态

4.3 内核如何实现信号的捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:用户程序注册了SIGQUIT信号的处理函数sighandler。当前正在执行main函数,这时发生中断或者异常切换到内核态。在中断处理完毕后要返回用户态的main函数之前检查到信号SIGQUIT递达。内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数。sighandler函数和main函数使用不同的堆栈空间,他们之间不存在调用和被调用的关系。两个独立的控制流程。sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。如果没有新的信号要递达。这次再返回用户态就是恢复main函数的上下文继续执行了。

快速记忆

类似于无穷大,其中与横线有多少个交点,就证明有多少个状态切换,方向决定了是从内核到用户还是用户到内核

4.4 sigaction

#include <signal.h> 
            // signo: 捕捉哪个信号     act:设置成什么动作    oact:之前的动作
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函数调用,而是被系统所调用。

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

using namespace std;

int cnt = 0;

void handler(int signo)
{
    cout<<"我是一个进程,刚刚获取到了一个信号:"<<signo<<cnt<< endl;
}

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

    sigaction(2,&act,&oact);

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

我们发现就可以捕捉2号信号。

mask

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

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

using namespace std;

int cnt = 0;

void handler(int signo)
{
    cout<<"我是一个进程,刚刚获取到了一个信号:"<<signo<<"	cnt: "<<cnt<< endl;
    sigset_t pending;
    //增加hanlder信号的时间,永远都会正在处理2号信号!
    while(true)
    {
        cout<<"."<<endl;
        sigpending(&pending);
        for(int i = 1;i<=31;++i)
        {
            if(sigismember(&pending,i))
                cout<<'1';
            else    
                cout<<'0';
        }
        cout<<endl;
        sleep(1);
    }
}

static void showPending(sigset_t *pendings)
{
    // 一个信号一个信号判断 如果在就打印1 如果不在打印0
    for(int sig = 1; sig<= 31;sig++)
    {
        if(sigismember(pendings,sig))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout << endl;
}


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

    sigaction(2,&act,&oact);

    while(true)
    {
        cout<<"main running"<<endl;
        sleep(1);
    }

    return 0;
}

(本篇完)

附录:

makefile

# .PHONY:all
# all:mykill mypro

# mykill:mykill.cc
# 	g++ -o $@ $^ -std=c++11
myproc:myproc.cc
	g++ -o $@ $^ -g -std=c++11

.PHONY:clean
clean:
	rm -f myproc

myproc.cc

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

using namespace std;

int cnt = 0;

void handler(int signo)
{
    cout<<"我是一个进程,刚刚获取到了一个信号:"<<signo<<"	cnt: "<<cnt<< endl;
    sigset_t pending;
    //增加hanlder信号的时间,永远都会正在处理2号信号!
    while(true)
    {
        cout<<"."<<endl;
        sigpending(&pending);
        for(int i = 1;i<=31;++i)
        {
            if(sigismember(&pending,i))
                cout<<'1';
            else    
                cout<<'0';
        }
        cout<<endl;
        sleep(1);
    }
}

static void showPending(sigset_t *pendings)
{
    // 一个信号一个信号判断 如果在就打印1 如果不在打印0
    for(int sig = 1; sig<= 31;sig++)
    {
        if(sigismember(pendings,sig))
        {
            cout<<"1";
        }
        else
        {
            cout<<"0";
        }
    }
    cout << endl;
}


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

    sigaction(2,&act,&oact);

    while(true)
    {
        cout<<"main running"<<endl;
        sleep(1);
    }

    return 0;
}


// int main()
// {
//     cout<<"进程pid:"<<getpid()<<endl;
//     //3.屏蔽2号信号
//     sigset_t bsig,obsig;
//     sigemptyset(&bsig);
//     sigemptyset(&obsig);
//     //3.1添加2号信号到信号屏蔽字中
//     for(int sig = 1;sig<=31;sig++)
//     {
//         sigaddset(&bsig,sig);
//         //2.signal
//         signal(sig,hanlder);
//     }
//     //3.2设置用户级的信号屏蔽字到内核汇总 让当前进程屏蔽到2号信号
//     sigprocmask(SIG_SETMASK,&bsig,&obsig);

//     //1.尝试不断的获取当前进程的pending信号集
//     sigset_t pendings;
//     while(true)
//     {
//         // 1.1 清空信号集
//         sigemptyset(&pendings);
        
//         // 1.2 获取当前进程的pengding信号集
//         if(sigpending(&pendings) == 0 )
//         {
//             // 1.3 打印一下当前进程的pending信号集
//             showPending(&pendings);
//         }
//         sleep(1);
//         cnt++;
//         if(cnt == 10)
//         {
//             //结束信号
//             cout<<"解除对2号信号的block......" <<endl;
//             sigset_t sigs;
//             sigemptyset(&sigs);
//             sigaddset(&sigs,2);
//             sigprocmask(SIG_UNBLOCK,&sigs,nullptr);
            
            
            
//             //cout<<"解除对所有信号的block......" <<endl;
//             //sigprocmask(SIG_SETMASK,&obsig,nullptr);
//         }
//     }
//     return 0;
// }

// int main()
// {
//     //  fork 创建 child process
//     pid_t id = fork();
//     if (id == 0)
//     {
//         // child
//         int *p = nullptr;
//         *p = 1000;
//         //访问野指针

//         exit(1); //退出码设置为1
//     }

//     //父进程阻塞等待
//     int status = 0;
//     waitpid(id,&status,0);
//     printf("exit code:%d, signo : %d , core dump code : %d\n",
//         (status>>8) & 0xFF, status & 0x7F, (status >> 7)&0x1);

//     return 0;
// }



猜你喜欢

转载自blog.csdn.net/qq_58325487/article/details/128266812