Linux进程信号

本节开始用Ubuntu环境来进行操作。

信号概念

信号就是用来通知信息事件的。

现实生活中,我们通过红绿灯判断是否前行,通过闹钟知晓几点该做什么事,通过上下课铃声知道要准备上课或下课。这些红绿灯、闹钟和上下课铃声,都是我们生活中常见的信号。

而我们能识别这些信号只是因为我们学习过。

进程中也存在很多信号,而进程可以识别信号,是因为进程和信号是程序员写的,在进程内部,程序员已经内置了对信号的识别和处理机制(信号特征+信号处理方法)。

所以在信号还没有产生之前,我们就已经知道信号处理方法。

进程中的信号:

通过kill -l命令查询。

一共有62种信号,其中前31种是普通信号,后31种为实时信号。今天我们主要学习的是普通信号。这些信号各自在什么条件下产生,默认动作是什么,都可以通过man 7 signal 命令了解。

action是每个信号会执行的动作,分类如下:

  • Core :进程接收信号后终止,同时生成核心转储文件,方便后续调试崩溃原因。
  • Term :进程接收信号后直接终止运行,不额外生成文件。
  • Ign :进程接收信号后无任何反应,直接忽略该信号。
  • Cont :专门用于恢复被暂停的进程,让其继续执行。
  • Stop :作用是暂停进程的执行,使其暂时停止运行。

信号运用实例

复制代码
#include<iostream>
#include<unistd.h>
int main()
{
        while(true)
        {   
                std::cout<<"i am a process"<<std::endl;
                sleep(1);
        }   
        return 0;
}

执行文件,启动一个前台进程。

按下ctrl+c,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给了目标前台进场。

前台进程因为收到信号,进而引发进程退出。

信号函数

发送信号函数:

NAME

signal - ANSI C signal handling

SYNOPSIS

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

参数说明:

signum:信号编号

handler:信号处理函数。函数指针,表示更改信号的处理动作,当收到对应的信号,就回调handler方法。

上面用ctrl+c函数来终止进程,但是ctrl+c本质就是向目标前台进程发送上面信号中的2信号( SIGINT),下面可以证明以下。

复制代码
#include<iostream>
#include<unistd.h>
#include<signal.h>
void handler(int signumber)
{
        std::cout<<"我是"<<getpid()<<",获得了"<<signumber<<"信号"<<std::endl;
}
int main()
{
        std::cout<<"我是进程:"<<getpid()<<std::endl;
        signal(2,handler);//signal(SIGINT,handler);
        while(true)
        {   
                std::cout<<"I am waiting signal!"<<std::endl;
                sleep(1);
        }   
        return 0;
}

ctrl+\本质是向进程发送3号信号,也可以用上面的方法来证明。上面这个代码中进程处理信号的方法就是自定义处理,所以不会按照之前默认的动作(退出进程)来处理信号。

注:前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生⼀个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

前台进程vs后台进程

前台进程

定义:在终端中运行,占据终端输入输出,用户需等待其执行完成才能进行后续操作。

简单来说,因为键盘只有一个,系统在任何时刻都只允许一个进程获取键盘输入的数据,所以这个进程就是前台进程。

后台进程

定义:在终端后台允许,不占据终端输入输出,用户可在终端继续执行其他命令的进程。

即后台进程无法从键盘中获取数据。

在进程中创建子进程,父进程退出,那么子进程变成了孤儿进程,变成了后台进程。

举例:在手机界面上,我们经常打开很多软件,但是有一些软件有时候打开完后,没有关闭,而是直接切到其他APP,那些软件不在屏幕上显示,却在后台悄悄运行(或暂停待命),等待再次切换回去,那些软件就是后台进程。前台进程就是你正在使用的、眼睛能看到、手指能操作的软件。

在Linux中如何能看到前台进程和后台进程?

通过ps -aux命令,如果状态后面有加号的就是前台进程,没有加号的就是后台进程。

也可以让前台进程变成后台进程,只要./cmd&。

复制代码
#include<stdio.h>
#include<unistd.h>
int main()
{
         while(1)
         {   
                 printf("i am sleeping\n");
                 sleep(1);
         }   
        return 0;
}

信号处理的过程

分三个阶段:

信号产生

进程中信号的产生有两种方式:

1.通过键盘

常见的有:

ctrl+c键可以发送2信号(SIGINT)

ctrl+\键可以发送3信号(SIGQUIT),终止进程并生成core dump文件,用于事后调试。

ctrl+z键发送20信号(SIGTSTP),停止进程,将当前前台进程挂起到后台。

2.通过kill命令

比如kill -9+进程id,就是给进程发送9号终止进程的命令。

3.通过系统调用函数

kill函数

NAME

kill - send signal to a process

SYNOPSIS

#include <sys/types.h>

#include <signal.h>

int kill(pid_t pid, int sig);

RETURN VALUE

返回 0 代表信号发送成功,返回 -1 代表失败,失败原因藏在 errno 里。

参数说明:

pid:进程ID

sig:向进程发送的信号

实例:仿kill -number +进程id命令

复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<signal.h>
int main(int argc ,char*argv[])
{
    if(argc!=3)
    {
        std::cerr<<"Usage:"<<argv[0]<<"-signumber pid"<<std::endl;
        return 1;
    }
    int number=std::stoi(argv[1]+1);
    pid_t pid=std::stoi(argv[2]);
    int n=kill(pid,number);
    return n;
}

raise函数

raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。

NAME

raise - send a signal to the caller

SYNOPSIS

#include <signal.h>

int raise(int sig);

RETURN VALUE(返回值)

raise() returns 0 on success, and nonzero for failure.

实例:

复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<signal.h>
void handler(int signumber)
{
    std::cout<<"获取一个"<<signumber<<"信号"<<std::endl;
}
int main(int argc ,char*argv[])
{
    signal(2,handler);//给2信号自定义动作
    while(1)
    {
        sleep(1);
        raise(2);
    }
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ sudo g++ -o test_kill test_kill.cc
[sudo] slmx 的密码: 
slmx@ubuntu:~/d1$ ./test_kill
获取一个2信号
^C获取一个2信号
获取一个2信号
^C获取一个2信号
获取一个2信号
^C获取一个2信号
获取一个2信号
获取一个2信号
获取一个2信号
^\退出 (核心已转储)

abort函数

abort函数是当前进程接收到6号信号(SIGART)而异常终止。

NAME

abort - cause abnormal process termination

SYNOPSIS

#include <stdlib.h>

void abort(void);

RETURN VALUE

The abort() function never returns.向exit函数一样,执行都会成功,没有失败。

实例:

复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<signal.h>
void handler(int signumber)
{
    std::cout<<"获取一个"<<signumber<<"信号"<<std::endl;
}
int main(int argc ,char*argv[])
{
    signal(6,handler);//只是捕捉,还没有给进程发送
    while(1)
    {
        sleep(1);
        abort();//强制执行6号信号
    }
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ sudo g++ -o test_kill test_kill.cc
slmx@ubuntu:~/d1$ ./test_kill
获取一个6信号
已放弃 (核心已转储)

abort函数的流程:

调用abort()->触发SIGABRT->执行handler->内核强制终止进程。

4.通过软件条件

(1.坏掉的管道

13号(SIGPIPE)信号是一种由软件条件产生的信号,在学习管道的时候,当管道的读端关闭,但是写端一直写入时,就会导致管道破裂,os会给进程发送13号信号。

(2.闹钟

alarm函数

设定时间给进程发SIGALRM信号(14号),终止进程。

NAME

alarm - set an alarm clock for delivery of a signal

SYNOPSIS

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

RETURN VALUE

alarm () 函数的返回值表示距离上一个已设定闹钟的预计触发时间,剩余的秒数;若之前未设定过闹钟,则返回 0或者是以前设定的闹钟时间还余下的秒数。

如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

alarm调用,一次只运行一个进程,设置一个闹钟,以最新的为准。当第二次设置闹钟的新时间,会取消上一次的闹钟,返回上一次闹钟的剩余时间。

实例:重复5秒闹钟

复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<signal.h>
#include<stdlib.h>
void handler(int signumber)
{
    std::cout<<"重复闹钟启动"<<std::endl;
    alarm(5);
}
int main(int argc ,char*argv[])
{
    signal(14,handler);
    alarm(5);
    while(1)
    {
        sleep(1);
    }
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ ./test_kill
重复闹钟启动
重复闹钟启动
重复闹钟启动
重复闹钟启动
重复闹钟启动
^C

如何理解软件条件?

在操作系统中,信号的软件条件指的是由软件内部状态或者特定软件操作触发的信号产生机制。这些软件包括但不限于定时器超时(如alarm函数设定的闹钟时间到达)、软件异常等。当这些软件条件满足时,操作系统会向有关进程发送相应信号,以通知进程进行相应的处理。简而言之,软件条件是由操作系统内部或者外部操作而触发的信号产生。

5.硬件异常

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释位SIGFPE信号(8号)发送给进程,再比如当前进程访问了非法内存地址,MMU(内存的管理单元,主要负责虚拟地址和物理地址的转换,并提供内存保护和访问控制功能)会产生异常,内核将这个异常解释为SIGEGV信号(11号)发送。

注:软件异常信号产生的来源是os或运行时的程序;硬件异常信号产生的来源是计算机硬件组件(硬盘控制器、CPU、MMU等)

(1.除以0

复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
void handler(int signumber)
{
    std::cout<<"获取一个信号:"<<signumber<<std::endl;
    exit(1);
}
int main()
{
    //signal(8,handler);
    int a=10;
    a/=0;
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ sudo g++ test_fault.cc -o test -Wno-div-by-zero#禁用除0警告
slmx@ubuntu:~/d1$ ./test
获取一个信号:8
slmx@ubuntu:~/d1$ sudo g++ test_fault.cc -o test -Wno-div-by-zero
slmx@ubuntu:~/d1$ ./test
浮点数例外 (核心已转储)

(2.模拟野指针

复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
void handler(int signumber)
{
    std::cout<<"获取一个信号:"<<signumber<<std::endl;
    exit(1);
}
int main()
{
   //signal(11,handler);
    int *p=NULL;
    *p=100;
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ ./test_fault
获取一个信号:11
slmx@ubuntu:~/d1$ sudo g++ -o test_fault test_fault.cc
slmx@ubuntu:~/d1$ ./test_fault
段错误 (核心已转储)

如果把上面除0代码中自定义动作函数中不退出进程(即删除exit(1)),那么我们就会发现代码就会一直捕捉8号信号,这是为什么?

因为当CPU运算异常后,OS会检查应用程序的异常情况,而在CPU中有一些控制寄存器和状态寄存器,控制寄存器主要控制处理器的操作,决定 CPU 如何检测异常、是否允许异常被捕获,以及异常发生后如何通知系统内核,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应一些状态标记位、溢出标记位,标记当前存在未处理的错误信息。OS会检测是否存在异常状态,有异常就会调用对应的异常处理方法。

但是如果异常处理方法(即handler函数)里面没有清除CPU状态寄存器中的错误标记,CPU检查状态寄存器,发现"错误状态仍存在",会再次触发算术异常,导致内核再次给SIGFPE信号。如此循环,会就出现信号反复被捕捉的现象。

简单说,控制寄存器就像 "交通信号灯和路标":规定了哪些行为(如除零、越界访问)是 "违规" 的,违规后如何记录位置,以及是否允许系统(内核)对违规行为做出反应(发送信号)。

(3.子进程退出core dump(核心转储)即子进程非自愿退出。

Core Dump:当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存在磁盘上,文件名通常是core。

进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫Post-mortem Debug(事后调试)。

一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。

为什么子进程退出core dump会产生信号呢?产生什么信号?

core dump是进程因严重错误(如段错误、非法指令、断言失败等)而导致异常终止时,系统将其内容状态写入磁盘文件中的行为。而子进程是替父进程执行任务的,子进程终止是要通知父进程的。子进程终止后,会进入僵尸状态,内核会给父进程发送SIGCHLD信号,告知子进程的状态变化,父进程接收到信号后,可通过等待系统调用wait获取子进程的具体退出原因。这是系统保证进程间同步和资源回收的重要机制。

上面有些信号的执行动作是Core,那就意味着这种信号的动作时会让进程异常退出且把内容保存到磁盘文件中。

实例:

复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
    if(fork()==0)
    {
        sleep(1);
        int a=10;
        a/=0;
        exit(0);
    }
    int status=0;
    waitpid(-1,&status,0);
    printf("exit signal:%d,core dump:%d\n",status&0x7F,(status>>7)&1);
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ sudo g++ -o test_fault test_fault.cc -Wno-div-by-zero
slmx@ubuntu:~/d1$ ./test_fault
exit signal:8,core dump:1
//core dump为1为异常退出(说明子进程终止时请求了 core dump 文件),为0为正常退出

注:无论信号发送的方式有多少种,最终全部都是通过os向目标进程发送信号的。

信号保存

在信号到来的时候,当前进程可能正在做更重要的事,所以信号处理的时候,可能不是立刻就处理信号的。而是先把这个信号记录下来,在合适的时候在进行处理。

前提摘要

1.实际执行信号的处理动作为信号递达。

2.信号从产生到递达之前的状态,称为信号未决(Pending)。

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

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

注:阻塞和忽略时不同的,阻塞是进程可以选择处不处理该信号,是在递达前进程操作的,而忽略是内核直接丢弃已暂存的信号,不执行任何操作,是递达后进程处理的动作。

在内核中的表示

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有⼀个函数指针表示处理动

作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。

handler中的表示:SIG_IGN表示该信号的处理动作是忽略该信号;SIG-DFL表示该信号的处理动作是默认动作(即原定义的处理动作);sighander为自定义的函数表示该信号的处理动作为自定义动作(即新建的自定义动作)。

os把信号记录在目标进程的tast_struct信号位图(pending表)中,在32位系统中,位图的单位为unsigned long,有32个比特位。当进程接受到几号信号,就会在位图中从右到左第几号的比特位上更改0->1即可。

进程通过位图中对应位置上的数字是0或1,识别是否接受到该信号。

即比特位的位置:信号编号;比特位的内容:是否收到信号。

sigset_t

上面所说的位图其实就是sigset_t,称为信号集。Block表和Pending表可以用相同的数据类型sigset_t来存储,是因为每个信号只有一个bit的未决标志和阻塞标志。sigset_t本质上是一个位图结构,可以表示每个信号的"有效"和"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号

是否被阻塞, 而在未决信号集中"有 效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。

信号集操作函数

#include <signal.h>

int sigemptyset(sigset_t *set);

//初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。

int sigfillset(sigset_t *set);

//初始化set所指向的信号集,使其中所有信号的对应bit置1,表示该信号集的有效信号包括系统支持的所有信号。

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。

sigprocmask函数

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

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1

参数:

oset:用于保存修改前的信号屏蔽字。

如果oset 是非空指针,set为空指针,则查询进程的当前信号屏蔽字。如果 set 是非空指针,oset为空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果 oset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oset 里,然后根据 set 和how 参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了 how 参数的可选值:

|-------------|-----------------------------------------------------|
| how参数值 | 含义 |
| SIG_BLOCK | 新的信号屏蔽字 = 原屏蔽字 maskset(将 set 中的信号添加到屏蔽集) |
| SIG_UNBLOCK | 新的信号屏蔽字 = 原屏蔽字 mask ∩ ~set(从屏蔽集中移除 set 中的信号) |
| SIG_SETMASK | 新的信号屏蔽字 = set(直接用 set 替换原屏蔽字) |

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

个信号递达。

sigpending函数
#include <signal.h>

int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过set参数传出。

调用成功则返回0,出错则返回-1

实例:在15秒内打印未决信号集并阻塞2号信号(ctrl+c),15秒后解除对2号信号的屏蔽。

复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
void PrintPending(sigset_t &pending)
{
    std::cout<<"当前进程["<<getpid()<<"]的pending信号:";
    for(int i=31;i>=1;i--)
    {
        if(sigismember(&pending,i))
        {
            std::cout<<1;
        }
       else
        {
             std::cout<<0;
        }
    }
    std::cout<<"\n";
}
void handler(int signumber)
{
    std::cout<<signumber<<"号信号被递达"<<std::endl;
    std::cout<<"-----------------------"<<std::endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);
    std::cout<<"-----------------------"<<std::endl;
}
int main()
{
    //1.捕捉2号信号
   signal(2,handler);
    //2.屏蔽2号信号
    sigset_t block_set,old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set,2);//还没有修改当前进行的内核block表
    //进入block表
    sigprocmask(SIG_BLOCK,&block_set,&old_set);//修改当前进行的内核block表,完成对2号信号的屏蔽
    //old_set保存修改前的屏蔽集
    //block_set中保存对2号信号阻塞的屏蔽集
    int cnt=15;//15秒后解除对2号信号的屏蔽
    while(cnt)
    {
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);
        cnt--;
        if(cnt==0)
        {
            std::cout<<"解除对2号信号的屏蔽"<<std::endl;
            sigprocmask(SIG_SETMASK,&old_set,&block_set);
            //设立新的屏蔽字为old_set;
            //block_setz中保存修改前的屏蔽集
        }
        sleep(1);
    }
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ sudo g++ -o test_fault test_fault.cc
slmx@ubuntu:~/d1$ ./test_fault
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
当前进程[2873]的pending信号:0000000000000000000000000000000
^C当前进程[2873]的pending信号:0000000000000000000000000000010#产生2号信号
当前进程[2873]的pending信号:0000000000000000000000000000010
当前进程[2873]的pending信号:0000000000000000000000000000010
当前进程[2873]的pending信号:0000000000000000000000000000010
当前进程[2873]的pending信号:0000000000000000000000000000010
当前进程[2873]的pending信号:0000000000000000000000000000010
当前进程[2873]的pending信号:0000000000000000000000000000010
解除对2号信号的屏蔽
2号信号被递达
-----------------------
当前进程[2873]的pending信号:0000000000000000000000000000000
-----------------------
^C2号信号被递达
-----------------------
当前进程[2873]的pending信号:0000000000000000000000000000000
-----------------------

信号处理

进程处理信号的方式有三种:

1.默认动作。操作系统对每种信号都定义了默认动作(程序员内置的动作),无需进程额外设置。

2.忽略信号。进程明确设置"忽略该信号",内核会直接丢弃已暂存的信号,不执行任何操作。

3.自定义处理。不去执行原来操作系统种已存在的默认动作,而是在进程中注册一个自定义函数(新的信号处理函数),当信号被触发时,内核会暂停进程当前执行的代码,转而执行这个自定义函数,执行完成后再回到源代码继续执行。

注:9号(SIGKILL)和19号(SIGSTOP))信号不能被捕捉(即虽然可以注册新的信号处理函数,但是进程忽略),也不能被忽略。因为9号和19号信号是强制性信号,9号是强制终止进程,19号是强制暂停进程,如果允许它们被捕捉,那么有些恶意进程(或故障进程)就会一直运行,可能导致失控。

9号和19号信号是内核直接管控。

用户空间vs内核空间

注:在32位系统下的分配

用户空间:虚拟地址中[0,3]G为用户空间,是普通进程存放数据和代码的。每个进程都拥有自己独立的用户空间,每个进程分配的虚拟地址空间大小可能不同,分配方式为按需分配。

内核空间:虚拟地址中[3,4]G为内核空间,存放操作系统内核的代码和数据,只有运行在内核态中的进程才能访问,用户态程序无法直接读写,空间大小通常是固定的(32位系统下为1G),每个进程的内核空间的核心部分是相同的,但存在私有部分不同。

用户态vs内核态

两者的本质区别是CPU指令的执行权限。

内核态有最高权限,可执行所有CPU指令,可以直接访问内存、硬盘、CPU等所有硬件,在虚拟地址空间中可以访问[0,4G]空间的内容。

用户态拥有低权限,仅能执行有限指令,无法直接访问硬件,需要通过内核间接调用,在虚拟地址空间中只能访问[0,3G]空间的内容。

用户态与内核态切换

有三种情况会导致用户态到内核态的切换:

1.系统调用函数。用户态通过系统调用向操作系统申请资源完成工作。

2.异常。当CPU在执行用户态进程时,发生异常时,当前进程会切换到处理此异常的内核相关进程中,也就是切换到内核态。

3.中断。当CPU在执行用户态进程时,外围设备完成了用户请求的操作后,会向 CPU 发出相应

的中断信号,这时 CPU 会暂停执行下⼀条即将要执行的指令,转到与中断信号对应的处理程序去

执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中

执行后边的操作等。

信号处理流程

如果默认动作是"终止进程",内核直接回收进程资源,不会回到用户态的主控制流;如果默认动作为"忽略",内核直接切换回用户态,进程从被中断的位置继续执行主控制流程。

简易画法:

sigaction

sigaction函数可以读取和修改与指定信号相关联的处理动作,和signal函数类似,但是比signal函数更强大、更灵活,能够精细控制信号的处理行为(如是否自动重启被中断的系统调用、是否保留信号掩码等)。sigaction函数添加的阻塞的信号会设置到内核中,防止进行递归处理。

#include <signal.h>

int sigaction(int signo, const struct sigaction *act, struct sigaction*oact);

参数说明:

signo是指定信号的编号。

act和oact指向sigaction结构体。

若act指针非空,则根据act修改该信号的处理动作;若为空,则查询当前处理动作。

若oact指针非空, 则通过oact传出该信号原来的处理动作;若为空,则不保存修改前的处理动作。

调用成功则返回0,出错则返回- 1

复制代码
struct sigaction

{

        void (*sa_handler)(int); // 信号处理函数(类似 signal 的回调)

        sigset_t sa_mask; // 处理信号时的临时信号掩码(阻塞哪些信号)本质是位图

        int sa_flags; // 标志位,控制处理行为

        void (*sa_sigaction)(int, siginfo_t *, void *); // 高级处理函数(带更多信息)

};

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外⼀些信号,则需sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

简单来说,sa_mask内的比特位为1的编号的信号都是被阻塞的,当调用sigaction函数的时候,sigaction中第一个参数signo指向的信号也被屏蔽了,oact结构体中保存着act结构修改前的内容。当信号处理函数返回时会自动恢复到oact结构体。

实例:

复制代码
#include<iostream>
#include<signal.h>
#include<unistd.h>
void handler(int signumber)
{
    std::cout<<"捕捉到一个信号:"<<signumber<<std::endl;
}
int main()
{
    struct sigaction act;
    act.sa_handler=handler;//新的处理方式
    sigemptyset(&act.sa_mask);//清空掩饰码
    act.sa_flags=0;//无特殊标志
    if(sigaction(2,&act,NULL)==-1)
    {
        std::cerr<<"sigaction失败"<<std::endl;
        return 1;
    }
    std::cout<<"等待捕捉2信号"<<std::endl;
    while(1)
    {
        sleep(1);//阻塞等待
    }
    return 0;
}
运行结果:
slmx@ubuntu:~/d1$ ./test_fault
等待捕捉2信号
^C捕捉到一个信号:2
^C捕捉到一个信号:2
^C捕捉到一个信号:2
^C捕捉到一个信号:2
^\退出 (核心已转储)

可重入函数

可重入函数(Reentrant Function)是指可以被安全地中断并再次调用的函数 ------ 即使在函数执行过程中被信号、中断或多线程打断,再次进入该函数时仍能正确执行,不会导致数据混乱或逻辑错误。

下面来演示一下不重入函数的危害。

仔细观察上面这个代码,感觉没有问题。

但是当我们开始演练:

阶段0.开始时:只有没有名字的节点为原链表的表头,和head指向原链表。

阶段1.当我们在主函数中调用insert函数,执行到函数中的第一条代码的时候,出现硬件中断+信号处理,主函数执行被打断。

阶段2-3.内核调用信号处理sighandler函数,执行完函数中调用的insert函数中第一行代码时,是第一张图,执行第二行代码时,为第二张图。

阶段4:信号处理返回后,继续执行主函数被打断的insert函数的第二行代码(head=node1),最终head指向node1。

从最终的结果中,看到node2被"丢失"了。链表中只有node1被插入了,node2没有被保留,造成内存泄漏。

怎么区分可重入函数和不可重入函数呢?

如果一个函数符合下列条件之一就是不可重入函数:

1.调用malloc或free,因为malloc也是用全局链表来管理堆的。

2.调用标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

如果一个函数符合下列条件就是可重入函数:

1.不使用全局变量或者静态变量。

2.仅依赖参数和局部变量。

volatile

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

运行以下代码:

复制代码
#include <cstdio>
#include <signal.h>
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;
}

当我们运行的时候,按下ctrl+c键才会打印printf语句。

复制代码
slmx@ubuntu:~/d1$ sudo g++ -o test_fault test_fault.cc
slmx@ubuntu:~/d1$ ./test_fault
^Cchage flag 0 to 1
process quit normal

如果进行优化了呢?

注:在g++编译命令中,-O2是编译器优化级别选项,表示启用 "二级优化级别的优化",O后面的数字越大,表示优化的等级越高,-O0表示不优化。

从上面的优化结果中,我们发现优化后,按下ctrl+c按键后,没有打印出printf语句,这就意味着flag的值没有从0变成1,这是为什么呢?

这是因为在优化后,编译器会分析代码逻辑,如果发现循环中没有显式修改flag的代码(信号处理函数的修改属于异步操作,编译器默认不会感知),那么就会认为flag的值在循环中不会变化,从而将其缓存到CPU寄存器中(寄存器访问速度远快于内存),避免每次循环都从内存中读取,所以flag的值一直都没有变化。

异步操作是指操作的执行时机不受主函数控制,可以在任意时刻插入主函数的执行流程中。

那么为了不被优化,我们可以用volatile关键字来保持flag变量在内存中的可见性。

复制代码
#include <cstdio>
#include <signal.h>
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;
}
运行结果:
slmx@ubuntu:~/d1$ sudo g++ -o test_fault test_fault.cc -O2
slmx@ubuntu:~/d1$ ./test_fault
^Cchage flag 0 to 1
process quit normal
相关推荐
Elias不吃糖3 小时前
第四天学习总结:C++ 文件系统 × Linux 自动化 × Makefile 工程化
linux·c++·学习
REDcker3 小时前
Linux 进程资源占用分析指南
linux·运维·chrome
samroom3 小时前
Linux系统管理与常用命令详解
linux·运维·服务器
PKNLP3 小时前
07.docker介绍与常用命令
运维·docker·容器
Mxsoft6194 小时前
电力系统AR远程运维与数字孪生交互技术
运维·ar
一叶之秋14124 小时前
Linux基本指令
linux·运维·服务器
码割机4 小时前
Linux服务器安装jdk和maven详解
java·linux·maven
亚林瓜子4 小时前
在amazon linux 2023上面源码手动安装tesseract5.5.1
linux·运维·服务器·ocr·aws·ec2
爱学习的大牛1235 小时前
Ubuntu 24.04 安装 FreeSWITCH 完整教程
linux·freeswitch