第八章:Linux信号

系列文章目录


文章目录


前言

linux信号是OS的重要功能。


linux中的信号

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

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

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

C++ 复制代码
[admin1@VM-4-17-centos linux_code]$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX
//查看系统定义的信号列表

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

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

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

进程对信号的处理

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

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

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

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

信号的释义

PowerShell 复制代码
man 7 signal查看信号详细信息的命令

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

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

信号的捕捉

信号的捕捉signal()

cpp 复制代码
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设置为忽略处理
cpp 复制代码
#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暂停进程。

信号的捕捉sigaction()

cpp 复制代码
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。
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
void hancler(int signo)
{
    std::cout<<"进程捕捉到信号"<<signo<<std::endl;
    sleep(10);
}
int main()
{
    //signal(2,hancler);//外部需要对该进程发送信号

    struct sigaction act, oact; //创捷结构体对象
    sigemptyset(&act.sa_mask);
    act.sa_handler = hancler;
    act.sa_flags = 0;
    sigaction(2, &act, &oact);

    while(1)
    {
    }
    return 0;
}

^C进程捕捉到信号2
^C^C^C^C^C^C^C^C进程捕捉到信号2
^C进程捕捉到信号2

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

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

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

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

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

信号的产生

通过终端按键产生信号

计算机是如何知道键盘输入了数据呢?键盘是通过硬件中断(由硬件完成)的方式,通知系统键盘输入了数据。

前台进程与后台进程

Linux只允许一个进程在前台,默认进程是bash,所以bash能够执行指令,可以用键盘按键来终止。

后台可以有多个进程运行,只能用 kill -9 来终止。

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

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

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

cpp 复制代码
//my_kill.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<< "Usage:" << proc << " pid " << "Signno\n" << std::endl; 
}

int main(int argc, char* argv[])//传参
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    pid_t pid = atoi(argv[1]);//获取第一个参数
    int signno = atoi(argv[2]);//获取第二个参数
    int n = kill(pid, signno);//需要发送信号的进程/发送几号信号
    if(n == -1)
    {
        perror("kill");
    }
    while(1)
    {
        std::cout << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

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

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

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

cpp 复制代码
//当计数器运行到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;
}

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

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

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

硬件异常产生信号

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

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

cpp 复制代码
void hancler(int signno)
{
    std::cout << "进程捕捉到信号" << signno << std::endl;
}

int main()
{
    signal(8, hancler);

    int a = 1/0;

    while(1)
    {
        std::cout << "正在运行进程:" << getpid() << std::endl;

        sleep(1);
    }

    return 0;


}
进程捕捉到信号8
进程捕捉到信号8
进程捕捉到信号8
进程捕捉到信号8
进程捕捉到信号8^C

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

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

CPU中的硬件MMU通过页表拿到对应的物理地址,如果发现物理地址越界访问,操作系统就会向该进程发送11号信号。

软件条件产生异常

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

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

十四号信号SIGALRM(定时器)

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

cpp 复制代码
ALARM(2)  
#include <unistd.h>
unsigned int alarm(unsigned int seconds);//seconds延时几秒
返回值为定时器剩余的秒数(可能会被提前唤醒)
alarm(0)表示取消之前设定的闹钟
cpp 复制代码
int cnt = 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++;
        sleep(1);
    }
    return 0;
}

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

总结

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

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

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

  4. 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?都是借助OS向目标进程发送信号,即向目标进程pcb写信号位图。

  5. 信号产生方式:键盘/系统调用/指令/软件条件/硬件异常

进程退出时的核心转储

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

核心转储的定义

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

linux系统级别提供了一种能力,可以将一个进程在异常的时候,将内存中进程的相关数据,全部dump到磁盘中,一般会在当前进程的运行目录下,形成core.pid这样的二进制文件。

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

核心转储的意义

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

信号的保存(位图结构)

相关概念铺垫

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

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

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

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

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

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

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

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

信号的处理(捕捉信号)

信号可以立即被处理吗?如果一个信号之前被block,当他解除block的时候,对应的信号会被立即递达。

为什么呢?信号的产生是异步的,当前进程可能正在做更重要的事情!什么时候是最合适的时间呢?当进程从内核态切换到用户态的时候,进程会在OS的指导下,进行信号的检测与处理!

再谈进程地址空间

用户态->内核态

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

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

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

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

信号的捕捉流程

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

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

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

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

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

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

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

cpp 复制代码
#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。

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

cpp 复制代码
#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:如何屏蔽信号集。

how 效果
SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK 设置当前信号屏蔽字为set所指向的值,相当于mask=set

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

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

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

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>

#define MAX_SIGNUM 31

static std::vector<int> signnoarr = {2};

static void show_pending(const sigset_t &pending)
{
    for (int signno = 1; signno < MAX_SIGNUM; ++signno)
    {
        if (sigismember(&pending, signno))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }

    std::cout << std::endl;
}

static void myhandler(int signno)
{
    std::cout << signno << "信号已被递达" << std::endl;
}

int main()
{
    for(const auto& signno : signnoarr) signal(signno, myhandler);
    //自定义递达

    //1.屏蔽指定信号
    sigset_t block, oblock, pending;

    //1.1 初始化
    sigemptyset(&block);
    sigemptyset(&oblock);
    sigemptyset(&pending);

    //1.2 添加屏蔽信号
    for(const auto& signno : signnoarr) sigaddset(&block, signno);

    //1.3 开始屏蔽,设置进进程
    sigprocmask(SIG_SETMASK, &block, &oblock);


    //2.遍历打印pending信号集
    int cnt = 10;
    while(true)
    {
        //2.1 初始化
        sigemptyset(&pending);

        //2.2 获取进程信号集
        sigpending(&pending);

        //2.3 打印
        show_pending(pending);

        sleep(1);
        if(cnt-- == 0)
        {
            sigprocmask(SIG_SETMASK, &oblock, &block);//回复进程屏蔽字,OS至少递达一个信号
            std::cout << "恢复对信号的屏蔽,不屏蔽任何信号" << std::endl;
        }
    }

}
000000000000000000000000000000
000000000000000000000000000000
000000000000000000000000000000
^C010000000000000000000000000000
010000000000000000000000000000
010000000000000000000000000000
010000000000000000000000000000
010000000000000000000000000000
010000000000000000000000000000
010000000000000000000000000000
010000000000000000000000000000
2信号已被递达
恢复对信号的屏蔽,不屏蔽任何信号
000000000000000000000000000000
000000000000000000000000000000
000000000000000000000000000000

可重入函数

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的内存可见性。

cpp 复制代码
#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;
}

SIGCHLD信号

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

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

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

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

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

相关推荐
海岛日记16 分钟前
centos一键卸载docker脚本
linux·docker·centos
AttackingLin1 小时前
2024强网杯--babyheap house of apple2解法
linux·开发语言·python
学Linux的语莫2 小时前
Ansible使用简介和基础使用
linux·运维·服务器·nginx·云计算·ansible
踏雪Vernon2 小时前
[OpenHarmony5.0][Docker][环境]OpenHarmony5.0 Docker编译环境镜像下载以及使用方式
linux·docker·容器·harmonyos
学Linux的语莫3 小时前
搭建服务器VPN,Linux客户端连接WireGuard,Windows客户端连接WireGuard
linux·运维·服务器
legend_jz3 小时前
【Linux】线程控制
linux·服务器·开发语言·c++·笔记·学习·学习方法
Komorebi.py3 小时前
【Linux】-学习笔记04
linux·笔记·学习
黑牛先生3 小时前
【Linux】进程-PCB
linux·运维·服务器
友友马3 小时前
『 Linux 』网络层 - IP协议(一)
linux·网络·tcp/ip