【Linux】信号+再谈进程地址空间


目录

一、Linux中的信号

1、Linux中的信号

2、进程对信号的处理

3、信号的释义

二、信号的捕捉

1、信号的捕捉signal()

2、信号的捕捉sigaction()

三、信号如何产生?

1、kill()用户调用kill向操作系统发送信号

通过命令行参数模仿写一个kill命令

2、raise()进程自己给自己发任意信号(实际上是操作系统->进程)

3、abort()进程自己给自己发6号信号

4、硬件异常产生信号

4.1八号信号SIGFPE(除零错误可引发)

4.2十一号信号SIGSEGV(段错误可引发)

5、软件条件产生异常

5.1十三号信号SIGPIPE(匿名管道读端关闭,写端收到该信号)

5.2十四号信号SIGALRM(定时器)

6、信号相关问答

四、进程退出时的核心转储

1、核心转储的定义

2、核心转储的意义

五、信号的保存(位图结构)

1、相关概念铺垫

2、信号在内核中的表示

六、信号的处理

1、再谈进程地址空间

1.1用户态->内核态

1.2进程如何从用户态切换至内核态并执行内核代码

2、信号的捕捉流程

3、sigset_t信号集(调库,用于处理block和pending位图中的01)

4、sigprocmask(调用该函数可读取或更改阻塞信号集)

5、sigpending(获取当前进程的pending信号集)

6、屏蔽信号并实时打印pending位图(运用上方三个接口)

七、可重入函数

八、volatile关键字

九、SIGCHLD信号


一、Linux中的信号

1、Linux中的信号

使用kill -l查看所有信号。使用信号时,可使用信号编号或它的宏。

1、Linux中信号共有61个,没有0、32、33号信号。

2、【1,31】号信号称为普通信号,【34,64】号信号称为实时信号。

以普通信号为例,进程task_struct结构体中存在unsigned int signal变量用以存放普通信号。(32个比特位中使用0/1存储、区分31个信号——位图结构)

那么发送信号就是修改进程task_struct结构体中的信号位图。当然,有权限改动进程PCB的,也只有操作系统了。

2、进程对信号的处理

1、进程本身是程序员编写的属性和逻辑的集合;

2、信号可以随时产生(异步)。但是进程当前可能正在处理更为重要的事情,当信号到来时,进程不一定会马上处理这个信号;

3、所以进程自身必须要有对信号的保存能力;

4、进程在处理信号时(信号被捕捉),一般有三种动作:默认、自定义、忽略。

3、信号的释义

man 7 signal查看信号详细信息的命令

Trem:正常结束;Core:异常退出,可以使用核心转储功能定位错误,见本文第四节;Ign:内核级忽略。

2)SIGINT  终止信号,即键盘输入ctrl+c
3)SIGQUIT 终止信号,即键盘输入ctrl+\
6)SIGABRT 终止信号  调用abort即可收到该信号
8)SIGFPE  终止信号  除0错误即可收到该信号
11)SIGSEGV 终止信号 段错误即可收到该信号
13)SIGPIPE 终止信号 匿名管道读端关闭,写端即可收到该信号
14)SIGALRM 终止信号 alarm()函数(定时器)
17)SIGCHLD 内核级忽略信号 子进程退出时会向父进程发送该信号
18)SIGURG 继续进程(进程切换至后台运行,通过9号信号杀掉)
19)SIGSTOP 暂停进程

可以发现,有挺多信号的功能都是一样的。这是因为不同的信号,可以代表发生了不同的事件,但处理结果可以一致。

二、信号的捕捉

1、信号的捕捉signal()

SIGNAL(2) 
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);//signum:被捕捉的信号编号;handler:对指定的信号设置自定义动作
handler设置为SIG_DFL表示信号默认处理方式,SIG_ING设置为忽略处理
#include <iostream>
#include <unistd.h>
#include <signal.h>
void hancler(int signo)
{
	//这里写自定义内容,捕获到signo信号后即可执行自定义代码
    std::cout<<"进程捕捉到信号"<<signo<<std::endl;
}
int main()
{
    signal(2,hancler);//外部需要对该进程发送信号
    while(1)
    {
        std::cout<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

外部需要对该进程发送信号,才能被signal接口捕捉。上面例子中,外部发送kill -2 PID或者键盘ctrl+c都行。

当捕捉到指定信号后,将会执行自定义函数。可用于信号功能的替换。

9号和19号信号无法被捕捉kill -9乱杀进程,kill -19暂停进程。

2、信号的捕捉sigaction()

SIGACTION(2)    
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
signum:信号;act:结构体对象;oldact:输出型参数,记录原来的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);//用于支持旧版本的sigaction函数的信号处理函数地址,一般不使用。
};
Sigaction()在成功时返回0; 在错误时返回 -1,并设置 errno。

当一个信号正在被递达执行期间,pending位图由1置0,同时该信号将被阻塞。

如果这时再接收到这个信号,发现该信号被阻塞,同时pending位图由0置1,保存这个信号。

若同一时间再接收到该信号,由于pending已存满,多余的该信号将被丢失。

当首个信号被捕捉完毕,操作系统会立即解除对该信号的屏蔽,因为pending位图对应的比特位是1,所以立即执行新的捕捉动作,同时pending位图该信号位由1清零。

这就是上图执行结果出现两次2号信号捕捉的原因。

三、信号如何产生?

1、kill()用户调用kill向操作系统发送信号

通过命令行参数模仿写一个kill命令

有一个系统调用kill,用户使用kill函数让操作系统向进程发送信号。

KILL(2) 
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);//pid:目标进程的pid。sig:几号信号
成功时(至少发送了一个信号) ,返回零。出现错误时,返回 -1设置errno

mysignal.cc

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <string>
void Usage(const std::string& proc)
{
    std::cout<<"Usige:"<<getpid()<< "Signno\n"<<std::endl;
}

int main(int argc,char* argv[])//运行main函数时,需要先进行传参
{
    if(argc!=3)//如果传入main函数的参数个数不为3
    {
        Usage(argv[0]);
        exit(1);
    }
    pid_t pid=atoi(argv[1]);//获取第一个命令行参数,作为pid
    int signo=atoi(argv[2]);//获取第二个命令行参数,作为signo
    int n=kill(pid,signo);//需要发送信号的进程/发送几号信号
    if(n==-1)//kill()失败返回-1
    {
        perror("kill");
    }
    while(1)
    {
        std::cout<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

2、test.cc

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
int main()
{
    while(1)
    {
        std::cout<<"这是一个正在运行的进程"<<getpid()<<std::endl;
        sleep(1);
    }
    return 0;
}

2、raise()进程自己给自己发任意信号(实际上是操作系统->进程)

RAISE(3)
#include <signal.h>
int raise(int sig);//sig:信号编号
raise()在成功时返回0,在失败时返回非0。

raise(signo)等于kill(getpid,signo);

//当计数器运行到5时,进程会因3号进程退出
int main(int argc,char* argv[])//运行main函数时,需要先进行传参
{
    int cnt=0;
    while(cnt<=10)
    {
        std::cout<<cnt++<<std::endl;
        sleep(1);
        if(cnt>=5)
        {
            raise(3);
        }
    }
    return 0;
}

3、abort()进程自己给自己发6号信号

ABORT(3)
#include <stdlib.h>
void abort(void);
函数 abort()永远不会返回

abort()等于kill(getpid,SIGABRT);

4、硬件异常产生信号

硬件异常指非人为调用系统接口等行为,因软件问题造成的硬件发生异常。操作系统通过获知对应硬件的状态,即可向对应进程发送指定信号。

4.1八号信号SIGFPE(除零错误可引发)

例如出现除0错误,操作系统将会发送8号信号SIGFPE。

此时使用signal()捕捉这个信号,就会发现8号信号一直在被捕捉。这是因为状态寄存器是由CPU进行维护的,当8号信号被捕捉,进程并没有退出,根据时间片轮转,当进程被切换/剥离至CPU时,会读取和保存当前寄存器的上下文信息,所以我们就看到了8号信号被死循环捕捉。

4.2十一号信号SIGSEGV(段错误可引发)

5、软件条件产生异常

5.1十三号信号SIGPIPE(匿名管道读端关闭,写端收到该信号)

例如匿名管道读端关闭,操作系统会向写端发送13号信号SIGPIPE终止写端。

5.2十四号信号SIGALRM(定时器)

设置alarm函数是在告诉操作系统,将在设定的时间到来时,向进程发送14号信号终止进程。

ALARM(2)  
#include <unistd.h>
unsigned int alarm(unsigned int seconds);//seconds延时几秒
返回值为定时器剩余的秒数(可能会被提前唤醒)
alarm(0)表示取消之前设定的闹钟
//设置一个cnt,用于测试代码在指定时间跑了多少
void hancler(int signo)
{
	//这里写自定义内容,捕获到signo信号后即可执行自定义代码
    std::cout<<"进程捕捉到信号"<<signo<<" "<<cnt<<std::endl;//检测到5秒后cnt为多少
    alarm(5);//循环捕捉闹钟
}
int main()
{
    signal(14,hancler);
    alarm(1);//定时1秒
    alarm(5);//定义新的闹钟,旧闹钟会失效哦
    while(1)
    {cnt++;}
    return 0;
}

闹钟是由软件实现的。任何一个进程,都可以通过alarm函数设定闹钟,所以操作系统需要通过先描述再组织的方式管理这些闹钟。

6、信号相关问答

所有信号产生,最终都要有操作系统来进行执行,因为操作系统是进程的管理者 。

信号的处理是否是立即处理的?见下文~

信号如果没有被立即处理,那么信号将被保存至pending位图中

一个进程在没有收到信号的时候,能否知道,自己应该对合法信号作何处理呢? 能,程序员写好了对应信号的处理方式(你没走人行道但你知道红灯停,绿灯行)

如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?操作系统直接修改进程pcb中的信号位图。

四、进程退出时的核心转储

信号旁边写着Core的信号,都可以使用核心转储功能。

1、核心转储的定义

核心转储:当进程出现异常时,将进程在对应时刻的有效数据由内存存储至磁盘。

云服务器默认关闭了核心转储。在终端输入ulimit -a显示操作系统各项资源上限;使用ulimit -c 1000允许操作系统最大设置1000个block大小的数据块。

2、核心转储的意义

将程序异常的原因转储至磁盘,支持后续调试。

五、信号的保存(位图结构)

1、相关概念铺垫

1、信号递达(Delivery) :实际执行信号的处理动作;

2、信号未决(Pending):信号从产生到递达之间的状态

3、进程可以选择阻塞 (Block )某个信号。

4、信号被阻塞时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

5、阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

2、信号在内核中的表示

例如signal捕获信号的流程就是通过signo编号修改handler[signo]中的函数指针指向用户自定义的信号处理方法。当收到信号时,将pending位图中对应的比特位修改为1,若block位图中没有阻塞该信号,该信号被递达时就会执行该信号的处理方法。

对于普通信号,pending位图同时间只能保存一次同个信号,若该信号处于未递达状态,后续再次收到该信号将无法被保存(丢失)。

六、信号的处理

1、再谈进程地址空间

博主首篇进程地址空间传送门:【Linux】进程地址空间

1.1用户态->内核态

1.2进程如何从用户态切换至内核态并执行内核代码

每个进程的虚拟地址空间中有一块1G大小的内核空间,通过内核级页表映射的方式找到物理内存中内核代码进行执行。

由于内核级页表中对应物理地址的映射关系是一样的,所以每个进程都可以使用相同的内核级页表,无论进程如何切换,均可使用同一张内核级页表进行映射调用。

在进行用户态->内核态的切换过程中,首先通过CR3寄存器将进程状态由用户态修改为内核态(陷入内核),在本进程的内核空间中找到物理内存中的内核代码进行执行,执行完毕后将结果返回给进程。

2、信号的捕捉流程

信号的自定义捕捉:信号在产生的时候,不会被立刻处理,而是从内核态返回用户态的时候,对信号进行处理。

进程首先因为中断、异常、系统调用陷入内核,以内核态的身份运行内核代码,通过进程控制块中的信号位图分析当前信号的处理方式。

 若为自定义处理,则需要进程回到用户态去执行用户设定的handler方法。为什么进程不能以内核态的身份直接执行handler方法?这是因为进程处于内核态,权限非常高,操作系统是没有能力识别代码的逻辑的,若handler被人为植入恶意代码,原先部分没有权限的代码因为执行身份的变化而被提权,所以操作系统必须让进程先回到用户态,降低进程的权限。

执行完handler方法后,进程需要重新回到内核态去执行一些系统调用,才能回退回用户态。

3、sigset_t信号集(调库,用于处理block和pending位图中的01)

每个信号只有一个bit的未决/阻塞标志,非0即1,不记录该信号产生了多少次。

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

#include <signal.h>
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigemptyset(sigset_t *set);
函数sigfifillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。 
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);

在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfifillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

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

4、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参数的可选值。

how:如何屏蔽信号集

SIG_BLOCK

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

SIG_UNBLOCK

set包含了我们希望从当前信号屏蔽字解除阻塞的信号,相当于mask=mask&~set

SIG_SETMASK

设置当前信号屏蔽字为set所指向的值,相当于mask=set

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。没有手动捕捉的话,一般信号都是终止的,所以递达了,进程大概率也就寄了。

5、sigpending(获取当前进程的pending信号集)

SIGPENDING(2)
#include <signal.h>
int sigpending(sigset_t *set);//set:输出型参数,输出当前进程pending位图
sigending()在成功时返回0,在错误时返回-1。在发生错误时,将 errno 设置。

6、屏蔽信号并实时打印pending位图(运用上方三个接口)

默认情况所有的信号是不被阻塞的,如果一个信号被屏蔽了,那么这个信号不会被递达。

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

// #define BLOCK_SIGNAL 2
#define MAX_SIGNUM 31

using namespace std;

// static vector<int> sigarr = {2,3};
static vector<int> sigarr = {2};

static void show_pending(const sigset_t &pending)
{
    for(int signo = MAX_SIGNUM; signo >= 1; signo--)
    {
        if(sigismember(&pending, signo))
        {
            cout << "1";
        }
        else cout << "0";
    }
    cout << "\n";
}

static void myhandler(int signo)
{
    cout << signo << " 号信号已经被递达!!" << endl;
}

int main()
{
    for(const auto &sig : sigarr) signal(sig, myhandler);

    // 1. 先尝试屏蔽指定的信号
    sigset_t block, oblock, pending;
    // 1.1 初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);
    // 1.2 添加要屏蔽的信号
    for(const auto &sig : sigarr) sigaddset(&block, sig);
    // 1.3 开始屏蔽,设置进内核(进程)
    sigprocmask(SIG_SETMASK, &block, &oblock);

    // 2. 遍历打印pengding信号集
    int cnt = 10;
    while(true)
    {
        // 2.1 初始化
        sigemptyset(&pending);
        // 2.2 获取它
        sigpending(&pending);
        // 2.3 打印它
        show_pending(pending);
        // 3. 慢一点
        sleep(1);
        if(cnt-- == 0)
        {
            sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
            cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
        }
    }
}

七、可重入函数

main函数调用insert函数向一个链表head中插入节点P1,插入操作分为两步,刚执行完第一句代码,此时硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作执行完毕后,sighandler返回内核态,再次回到用户态就从main函数继续执行刚才剩余的代码。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有P1真正插入链表中,P2这个节点谁都找不到了。发生内存泄漏

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

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

八、volatile关键字

优化后,通过信号自定义方法handler修改全局q,但是程序不会退出。

O3优化时:编译器认为q在main执行流中没有被修改,所以编译器对q做了优化,直接将q放在了寄存器中,这样后续执行时就不用再去内存中读取q了,提高了程序运行效率。虽然handler中修改了内存中的q,但是寄存器中的q值一直是1(寄存器中的q值是临时值,操作系统没有对其进行修改),所以会发生上图效果。

解决方法:给q加volatile关键字,让q通过内存读取而不是寄存器,保持变量q的内存可见性。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
volatile int q=1;//保持内存可见性
void handler(int signo)
{
    q=0;
}
int main()
{
    signal(2,handler);
    while(q!=0);
    return 0;
}

当程序结果与预期偏离时,可以尝试使用volatile关键字,万一就是编译器过度优化造成的程序逻辑异常呢?

九、SIGCHLD信号

1、子进程退出,会向父进程发送17号信号SIGCHLD;

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

//忽略子进程发出的17号信号
signal(SIGCHLD,SIG_IGN);
sigaction(SIGCHLD,act,oldact);//act中忽略17号信号

系统默认的忽略动作和用户用signal/sigaction函数自定义的忽略 通常是没有区别的,但这里是一个特例。

虽然信号SIGCHID的默认动作也是忽略,但这个忽略是实实在在的无视了这个信号;我们手动在handler方法中使用SIG_IGN,子进程退出时发送给父进程的信号将会被父进程忽略,但子进程会被操作系统回收,这就是区别所在。

猜你喜欢

转载自blog.csdn.net/gfdxx/article/details/129468765