目录
信号的产生
生活角度的信号
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动
作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
进程角度的信号
信号的产生并不是立马就能被处理的,信号的产生不是被立即处理的,他需要经过:
产生 -> 保存信号 -> 信号处理
,而我们接下来要做的就是讨论这三个过程
1、信号产生前需要做的事
2、信号产生时和信号被处理过程中需要做的事
3、信号处理时需要做的事
那么我们接下来先讲如何产生信号,他分为三种方式
1、通过终端案件产生信号。
2、通过系统调用产生信号。
3、通过软件条件产生信号
1、通过终端按键产生信号
实验1:
当我们执行一个已经死循环的程序之后,在往终端上输入指令,bash并不会为我们解释命令翻译给OS内核去执行,而是做了一个忽略的处理方式
实验2:
而这种情况只是针对的是前台进程,如果我们将这个进程挂在后台的话,bash可以为我们解释该命名并翻译给OS内核执行。
但是引起更奇怪的现象是这个进程以运行起来,我们就不能通过按键盘上的ctrl + c 从而来终止这个可执行程序,
终止的方法可以通过敲入fg指令将正在执行的进程放入前台最后再ctrl + c
。
解释以上两种情况
1、如果默认以
. /xxx
的方式运行起来的进程是属于前台进程
2、如果以
. /xxx &
的方式运行起来的进程是属于后台进程
3、补充:一个终端前台进程只允许拥有一个,如果是1的情况,那么bash就会被放入后台,那么就算先终端输入命令,bash也不会去翻译,所以当我们实际敲入命令的时候,啥事都不发生,还是一直死循环。 如果是2的情况,OS会将. /xxx &方式运行起来的进程放到后台,那么前台就只有一个bash进程,我们向终端敲入的命令当然会被bash翻译给OS去执行。
为什么当myfile进程放入到后台之后,即会执行bash翻译后交给OS执行完并返回的结果?(ls执行后向屏幕输出),并且还会像屏幕输出myfile进程所打印的信息?
当myfile这个进程被放入后台后,bash进程是会在前台的,那么bash就能接受到命令并翻译给OS去执行,其实显示器也是我们的临界资源,当两个进程都可以同时访问显示器的时候,这个临界资源是没有被保护起来的,所以就会混在一起输出。
为什么向终端发生ctrl + c这个后台进程还是不会被杀掉呢?
因为键盘是属于硬件,那么OS就需要对他管理起来,在键盘上按下ctrl + c 这个动作本质是将前台进程发送一个信号,这个信号是2号信号所以如果当我们的进程在前台执行的时候ctrl + z是可以直接终止这个前台进程的,而如过这个进程是在后台执行的话,ctrl + z 即使是发送了信号,也不能终止这个后台进程,所以想要终止这个信号就得将该进程切换到前台再按下ctrl + z发送2号信号最后杀掉该进程,
如果证明ctrl + z发送的就是2号信号,当我们执行myfile进程后,他会不断的向终端输出信息,但是可以发现再右侧新起一个进程之后,使用kill -2 xxxxx就可以杀掉这个进程,其实9号信号也可以杀掉这个进程,所以这种证明的方式貌似有点勉强。。。。那么请看下面的例子
证明ctrl + z 会发送2号信号。
signal函数
//函数声明#include<signal.h>typedefvoid(*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum:信号名称
handler:这是一个函数指针
功能:等待获取指定的signum信号,如果收到该信号就会调用对应的handler函数
(sigaction函数稍后详细介绍),可选的处理动作有以下三种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
测试程序:
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<signal.h>voidhandler(int sign){printf("catch signal is %d\n", sign);//捕捉信号}intmain(){signal(2, handler);//当signal收到2号信号时就会去调用handler这个函数指针所指向的函数while(1){printf("hello word\n");sleep(1);}return0;}
程序运行结果
结论:
键盘按下ctrl + z会产生2号信号,而2号信号会被signal函数捕捉并调用该信号所对应的处理函数,所以会打印捕捉到的2号信号,
但是在linux中有一种信号不能被捕捉他就是9号信号。
9号信号不能被保留的特殊原因是:
如果当进程利用的signal函数捕捉到9号信号,再利用9号信号对应的处理函数将linux中的所有信号全部屏蔽掉了之后,那么这个进程就不能被OS杀掉了,如果将来你的服务器被别人植入了病毒后,这个进程就干不掉了,万一别人在你的服务器上一直while(1) {sleep(1); auto ptr = new T[10]; },那么就会导致内存不断泄漏并且你还干不死他
Core Dump
SIGINT(
2号信号,按下ctrl + z发送
)的默认处理动作是终止进程,SIGQUIT(
4号信号,按下ctrl + \ 发送
)的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。
一般一个进程在运行期间,出现了段错误导致程序崩溃,OS会向进程发送信号,进程会被OS终止,在进程被终止前OS会将进程的重要信息保存一份在磁盘上,而Core Dump是一个核心转储,进程异常终止时会提前将信息存放进Core Dump文件中,Core Dump的详细解释请看下面
首先解释什么是Core Dump。什么是coredump
Coredump叫做核心转储,该文件也是二进制文件,可以使用gdb、elfdump、objdump或者windows下的windebug、solaris下的mdb进行打开分析里面的具体内容。
当一个进程要异常终止时,它是进程运行时在突然崩溃的那一刻的一个内存快照。操作系统在程序发生异常而异常在进程内部又没有被捕获的情况下,会把进程此刻内存、寄存器状态、运行堆栈等信息转储保存在一个文件里,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因
,这叫做Post-mortem Debug(事后调试)。
一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全
注:core是在半导体作为内存材料前的线圈,当时用线圈当做内存材料,线圈叫做core。用线圈做的内存叫做core memory
ulimit -a 用来显示当前的各种用户进程限制
Linux对于每个用户,系统限制其最大进程数,为提高性能,可以根据设备资源情况,设置个Linux用户的最大进程数,一些需要设置为无限制:由于博主使用的是云服务器会core file size的默认大小是0,需要使用ulimit -c设置core文件的大小,这样就能观察到错误信息
我们先来看一组程序发生段错误后,core文件会发生什么变化吧。
core文件变化,core文件只是拥有者具有读写权限,core文件也是一个普通文件,并且core文件后面的数字表示的是形成core文件对应的进程编号
前面我们知道一个进程异常终止前会将错误信息存放进程core文件中,而core文件里会保存错误信息,这里可以通过gdb查看到错误原因,他的错误信息会报错进程收到的异常终止信号和对应出错的行数
总结:
core dump是一个保存异常退出信息的文件,在进程异常退出前会记录下进程退出时收到的信号,和运行时出现错误的位置,所以当我们程序运行出错后也可以通过这种调试方法,这种方法叫做事后调试。
2、调用系统函数向进程发信号
kill函数
函数原型int kill(pid_t pid, int signo);头文件#include <signal.h>pid_t pid指定的进程标识idint signo发送的信号
注意:
- pid大于零时,pid是信号欲送往的进程的标识。
- pid等于零时,信号将送往所有与调用kill()的那个进程属同一个使用组的进程。
- pid等于-1时,信号将送往所有调用进程有权给其发送信号的进程,除了进程1(init)。
- pid小于-1时,信号将送往以-pid为组标识的进程。
signo:准备发送的信号代码,假如其值为零则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在执行。
使用kill向进程发送信号
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<signal.h>intmain(int argc,char*argv[]){if(argc ==3){kill(atoi(argv[1]),atoi(argv[2]));//argv[1]:进程标识id//argv[2]:信号}return0;}
当输入完
sleep 10000 &
命令后会将这个进程挂在后台运行,这个进程需要执行10000秒后才会终止,但是在这个过程中,运行myfile进程后回向sleep进程发送9号终止信号(权限最高),sleep这个进程就被终止了。这就是通过系统调用发送信号,
raise函数
函数原型int raise(int sig);头文件#include <signal.h>功能自己给自己发送信号sig信号
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<signal.h>intmain(int argc,char*argv[]){raise(9);//给进程发送9号信号while(1){printf("hello word\n");}return0;}
程序运行结果:
注意:
raise函数和kill函数的区别是:kill函数可以给一个指定的进程发送指定的信号, kill命令是调用kill函数实现的,而raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
abort函数
函数原型void abort(void);头文件#include <stdlib.h>功能自己给自己发送指定的信号SIGABRT也就是6号信号,使当前进程接收到信号而异常终止
3、由软件条件产生信号
软件条件是什么意思呢?当一个子进程往一个已经关闭的匿名管道中写入数据时,写入条件不满足就会被OS发送特定的信号( SIGPIPE)终止该进程, 而写入条件不满足就会收到OS发送的信号,也就是当软件条件产生时OS会发送信号终止该进程。
函数原型unsigned int alarm(unsigned int seconds);头文件#include <unistd.h>功能调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。unsigned int seconds设定的闹钟时间
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<signal.h>int count =0;voidhandler(int sign){printf("cath sign is %d\n", sign);exit(1);}intmain(int argc,char*argv[]){alarm(1);// 设定闹钟,等待1秒后向进程发送14号信号,14号信号的默认处理动作是终止该进程 //signal(14, handler); while(1){printf("%d\n", count++);}return0;}
进程运行1秒后收到alarm(1)函数发送的14号信号就会被终止,count一共计数了35174次
alarm向进程发送信号被signal函数捕捉时需要在handler函数中调用exit退出进程
程序云行结果:
如果handler函数中不调用exit终止,进程会一直执行。
4、硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程
模拟野指针异常
运行结果:
解释野指针问题:
由于这个程序会有指针操作,而指针存放的只是一个虚拟地址,虚拟地址要访问数据需要在页表中找到对应的物理地址,在映射出物理内存,而野指针并不会存放在页表当中,也就没办法通过虚拟地址与物理地址建立映射关系从而映射出物理内存,所以虚拟地址在通过页表映射物理内存的时候OS就已经检查到这种错误了,OS会对这种行为采取一定的策略,该策略就是发送信号将进程终止,发送的信号是11信号SIGSEGV。
当前进程执行了除以0操作导致的异常
程序运行结果:
当进程执行后,OS会将该进程PCB放到运行时队列中,随后执行进程PCB所关联的代码,而在执行代码的过程中会发现运行到达除0的操作,我们都知道CPU才是参与 + - * /运算的硬件设备,其实CPU的内部是有数据寄存器和状态寄存器的, 而数据寄存器只是负责存储变量的数据,但是状态寄存器会记录该进程的标志位和溢出状态,如果CPU在计算的过程中发现了溢出,OS会检查到,检查到错误之后OS会发送信号并终止这个进程,这里进程没有直接退出的原因是signal函数捕捉错误后,调用的handler函数并没有直接退出,这里我们可以选择在终端按下ctrl + z 发送2号信号终止这个进程,进程被终止了也就意味着进程不在会被调度,进程的上下文也就清空了,OS会回收进程资源。
总结:
由于OS属于进程的管理者,我们发送的信号需要交给进程去捕捉和执行,处理该信号,而处理信号默认是将该进程杀掉,或者让进程执行别的任务,OS需要对进程管理起来,那么就需要OS给进程发送信号,而信号产生时并不是立即处理的,中间会有一个时间段,这个过程会把信号记录下来,那么进程是如何保存已经产生的信号的呢?进程是如何发送信号的呢?
进程PCB中会存放一张位图用于保存收到的信号,他的每一个比特位代表着一个信号,当没有收到信号时,位图中的每一个比特位全部都会置零,而收到信号后会将对应的比特位由0置为1,OS是如何发送信号的呢? os会找到进程所关联的进程PCB,将进程PCB中的位图由0置1,如果是要发送7号信号,就会将第7个比特位由0置1,OS就完成了发送信号,而进程收到信号默认的处理方式是终止该进程。
总结思考一下
- 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?
OS是进程的管理者
- 信号的处理是否是立即处理的?
在合适的时候
- 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
在信号被处理之前会被保存在进程PCB中的位图上,会有一个比特位标识着,
- 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
知道,一个进程即使没有收到信号也会记录处理该信号的实现细节
- 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
可以,
信号从产生 -> 发送 -> 保存 -> 处理会经过四个阶段
1、信号的产生通过四种方式:1、终端按键 + 2、系统调用 + 3、软件条件 + 4、硬件异常
2、信号的发送,信号是由OS给进程发送的,OS发送信号给进程会将位图中表示该信号的比特位由0置1。
3、保存信号:位图可以保存信号,而进程是由一个结构体task_struct所描述的,task_struct中是包含一张位图,当进程收到信号,会将位图的比特位由0置1,表示该进程已经接受并保存了信号。
4、处理信号,
阻塞信号
信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
.注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
信号在内核中的表示示意图
一个进程中会包含三张表:block、pending、handler。
pending会记录进程收到的信号
block记录信号被屏蔽 / 阻塞的信息
handler他是一个函数指针数组,存放函数的地址,也就是对应信号的处理函数,数组的下标是信号的编号
handler表其实是一个函数指针数组,这个函数指针数组存放的每一个指针都是一个函数指针,还记得我们当时调用signal函数时,需要捕捉一个信号,那么就需要自己手写一个handler函数,当我们自定义了一个对应信号得处理函数,那么handler表中就会记录该函数的地址,也就是说我们每次用signal函数注册一次,对每个信号都提供一个处理的函数,那么handler这个数组中就会多出一项函数地址
那么我们再来整体理解一下这三张表组合在一起后表示的含义吧,其实这个三张表表示的是信号和进程的关系。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,
未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。
sigset_t : 信号集及信号集操作函数:信号集被定义为一种数据类型:
typedefstruct{unsignedlong sig[_NSIG_WORDS];
} sigset_t
信号集操作函数
信号集用来描述信号的集合,linux所支持的所有信号可以全部或部分的出现在信号集中,主要与信号阻塞相关函数配合使用。下面是为信号集操作定义的相关函数:
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的
#include<signal.h>intsigemptyset(sigset_t *set)初始化由set指定的信号集,信号集里面的所有信号被清空;
intsigfillset(sigset_t *set)调用该函数后,set指向的信号集中将包含linux支持的64种信号;
intsigaddset(sigset_t *set,int signum)在set指向的信号集中加入signum信号;
intsigdelset(sigset_t *set,int signum)在set指向的信号集中删除signum信号;
intsigismember(const sigset_t *set,int signum)判定信号signum是否在set指向的信号集中。
- 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
- 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集), 对block表(信号屏蔽字)进行增删查改。
#include<signal.h>intsigprocmask(int how,const sigset_t *set, sigset_t *oset);
how:可以被设置为3个值 其中包括 1、SIG_BLOCKSIG_BLOCK,2、SIG_UNBLOCK,3、SIG_SETMASK
oset:这是一个输出型参数,他会保存上一次没有被修改的屏蔽字(block集),如果无意修改,方便往后的恢复。
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值,
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
#include<signal.h>intsigpending(sigset_t *set);
读取当前进程的未决信号集(pending),通过set参数传出。调用成功则返回0,出错则返回-1。
接口使用
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<signal.h>voidshow_pending(sigset_t *sig){int i =1;for(; i <=31; i++){if(sigismember(sig, i)){//判断sig集中是否存放i这个信号printf("1");}else{printf("0");}}printf("\n");}intmain(){//定义block集,和需要被恢复的block集
sigset_t block, oblock;//清空两张block集sigemptyset(&block);sigemptyset(&oblock);//将2号信号添加进程block屏蔽字集中sigaddset(&block,2);//注意这里添加并没有将2号信号添加到进程task_struct的屏蔽字位图中,//因为这只是一个栈上的局部变量,并没有设置到进程中//正确的处理方式是将已经在栈上设置好了的block位图的全部信息添加进进程屏蔽字位图中。sigprocmask(SIG_SETMASK,&block,&oblock);//定义未决集
sigset_t pending;while(1){//清空pending未决集sigemptyset(&pending);//读取进程中的pending未决集,存放进pendingsigpending(&pending);//显示从进程中读取的未决集show_pending(&pending);sleep(1);}return0;}
程序运行结果:当收到2号信号后,进程屏蔽字位图当中的第二个比特位就会由一开始的0置为1,但是程序不会终止他会一直死循环,因为进程虽然收到了这个信号,但是进程将该信号阻塞了,会进入未决状态。
对程序稍作修改,让他阻塞15秒钟后恢复进程的屏蔽字,那么进程就会由原来的未决状态转换为递达,进程处理完信号也就终止了。
程序运行结果:
调用signal函数观察程序现象
#include<stdio.h>#include<unistd.h>#include<stdlib.h>#include<signal.h>voidshow_pending(sigset_t *sig){int i =1;for(; i <=31; i++){if(sigismember(sig, i)){//判断sig集中是否存放i这个信号printf("1");}else{printf("0");}}printf("\n");}voidhandler(int sig){printf("%d \n", sig);}intmain(){//定义block集,和需要被恢复的block集
sigset_t block, oblock;//清空两张block集sigemptyset(&block);sigemptyset(&oblock);//将2号信号添加进程block屏蔽字集中sigaddset(&block,2);//注意这里添加并没有将2号信号添加到进程task_struct的屏蔽字位图中,//因为这只是一个栈上的局部变量,并没有设置到进程中//正确的处理方式是将已经在栈上设置好了的block位图的全部信息添加进进程屏蔽字位图中。sigprocmask(SIG_SETMASK,&block,&oblock);//定义未决集
sigset_t pending;int count =0;signal(2, handler);//捕捉2号信号,调用对应的处理函数while(1){//清空pending未决集sigemptyset(&pending);//读取进程中的pending未决集,存放进pendingsigpending(&pending);//显示从进程中读取的未决集show_pending(&pending);sleep(1);
count++;if(count ==15){//解除进程对2号信号的屏蔽sigprocmask(SIG_SETMASK,&oblock ,NULL);//当解除完进程对应2号信号的屏蔽,那么信号就会立马进入递达。进程也就默认被终止了。}}return0;}
程序运行结果:
捕捉信号
内核态和用户态
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:
- 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系
用户态和内核态是如果转换的呢?
cpu中会有一个寄存器cr,寄存器会包含比特位,当你进行系统调用的话,OS会将cpu中寄存器的比特位修改,OS会将cpu的执行模式由用户态改为内核态,那么进程就会由用户态转变为内核态,也就会去执行内核页表所映射的代码 + 数据。
进程为什么要保存一份内核空间和用户空间呢?
其实当进程在执行的时候如果调用系统调用接口就会有两种状态,这两种状态并不能同时存在,但是可以相互之间转换,如果调用了系统调用就会由用户态转换为内核态,这也是进程地址空间会存放内核级页表的原因,接下来观察进程地址空间中的存储模型:
内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。
信号是怎么被处理的?
当内核态返回时用户态时会对信号进行处理。
- 什么情况下进程会从用户态转变为内核态?
1、中断
2、异常
3、系统调用
内核如何实现信号的捕捉
1、如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于
信号处理函数的代码是在用户空间的
,处理过程比较复杂,举例如下:
前提条件:用户程序注册了SIGQUIT信号的处理函数sighandler。
1、当前正在执行main函数,这时发生中断或异常由用户态切换到内核态。
2、在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
3、信号处理函数执行特殊的系统调用再次陷入内核
4、内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,
sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。
sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了
总结:
当程序出现异常后会由用户态切换到内核态,当内核将异常处理完后,需要检查是否由信号需要被抵达,如果有,那么就调用自定义函数处理信号,由于signal属于系统调用所以会再次涉及一次系统调用,内核返回用户态后,并不会立马就恢复到上次异常的位置继续处理下面的代码,因为main和signal函数属于不同的执行流,signal还需要检查是否有抵达的信号,如果没有那么就回到main函数异常的位置继续往下执行。
记忆信号被捕捉的过程,画图辅助
四个端点标记着信号产生到处理的过程。
sighandler函数补充:
信号处理函数sighandler函数为什么要在用户态执行?如果sighandler是在内核态执行的话,那么当信号被捕捉后有人在自定义函数中写法非法的代码就会导致直接由内核去执行了,而交由系统去执行的代码,我们人为是不能干涉的(
内核态的权限是最高的
),所以sighandler这个自定义处理函数是得交给用户态处理的。
sigaction函数
intsigaction(int signum,conststruct sigaction *act,struct sigaction *oldact);
函数原型int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);头文件#include <signal.h>signo:signo是指定信号的编号act若act指针非空,则根据act修改该信号的处理动作, 指向struct sigaction结构体。oact若oact指针非 空,则通过oact传出该信号原来的处理动作,指向struct sigaction结构体。返回值:调用成功则返回0,出错则返回- 1功能sigaction函数可以读取和修改与指定信号相关联的处理动作
sigaction解释.
struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *,void*);
sigset_t sa_mask;int sa_flags;void(*sa_restorer)(void);}
sa_handler此参数和signal()的参数handler相同,代表新的信号处理函数
sa_mask 用来设置在处理该信号时暂时将sa_mask 指定的信号集搁置
sa_flags 用来设置信号处理的其他相关操作,下列的数值可用。
SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号
sa_handler
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号。
- 赋值为常数SIG_DFL表示执行系统默认动作。
- 赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
场景:当捕捉到信号时并且这个信号需要被递达,是要调用信号对应的自定义处理函数,如果该函数在处理信号时,又发生了一个信号,且函数体的内部还有系统调用,那么就需要再一次陷入内核态再处理该信号,那么这个过程就会一直陷入内核态和用户态之间的切换,如果程序是恶意程序,不断地向进程发送2号信号不断系统调用,会让这个handler函数无法返回,来看看内核态的做法,并不会陷入这种不断再用户态和内核态之间来回切换动作
1、当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,
2、当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
3、 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
4、sa_flags字段包含一些选项,本章的代码都 把sa_flags设为0,sa_sigaction是实时信号的处理函数。
#include<stdio.h>#include<signal.h>#include<unistd.h>voidhandler(int sig){printf("cath sign is: %d\n",sig);}intmain(){struct sigaction act, oact;
act.sa_handler = handler;//设置自定义处理函数
act.sa_flags =0;sigemptyset(&act.sa_mask);//清空act中sa_mask这个信号集 sigaction(2,&act,&oact);//捕捉到2号信号调用对应的处理函数while(1);return0;}
可重入函数
1、main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,
2、再次回用户态之前检查到有信号待处理,于是切换 到sighandler函 数,sighandler也调用insert函数向同一个链表head中插入节点node2,
3、插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。
4、结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了,那么node2就被丢失了
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
#include<stdio.h>#include<signal.h>#include<unistd.h>int quit =0;voidhandler(int sig){
quit =1;printf("quit is already set to 1\n");}intmain(){signal(2, handler);while(!quit);printf("end process\n");return0;}
程序运行结果:捕捉到2号信号调用自定义处理函数,程序终止
其实编译器是可以对quit 变量进行优化的,可以将该变量优化后放到寄存器中,那么再内存中修改quit将不受任何的影响。那么往后while循环在读取quit的时候也不需要从内存中读取到CPU中进行检查,而是直接在寄存器中对他进行访问,
使用gcc对quit 变量进行优化
使用gcc编译器对quit变量优化成寄存器变量后,再次在终端按下ctrl + z时进程并不会终止了。
原因是因为:我们的信号处理函数是在内存中修改quit的值的,但是quit已经是一个寄存器变量了,即使在函数体内修改了quit了值也并不会影响quit的值,所以就会导致内存中的quit值和寄存器中的quit值不一致的问题,寄存器中quit的 值还是0, while循环在做检查的时候是在CPU寄存器检查的,所以就会一直死循环了,解决方式是给quit变量添加volatile关键字
添加volatile关键字后,终端按下ctrl + c进程直接运行终止,因为现在内存中直接修改quit的值后也有效了
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作
SIGCHLD信号 - 选学了解
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程
#include<stdlib.h>#include<stdio.h>#include<signal.h>#include<unistd.h>voidhandler(int sig){printf("father pid is %d, get a sig :%d\n",getpid(),sig);}intmain(){signal(SIGCHLD, handler);if(fork()==0){printf("child is runing pid is :%d, father pid is %d\n",getpid(),getppid());sleep(5);printf("child quit\n");exit(1);}while(1);return0;}
版权归原作者 IT莫扎特 所有, 如有侵权,请联系我们删除。