Linux进程信号
一、信号入门
1.生活角度的信号
- 你在网上买了很多件商品,再等待不同商品快递的到来,你也知道快递到临时,你该怎么处理快递。也就是你能“识别快递”
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”
- 在收到通知,再到你拿到快递期间,是有一个窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
- 但你时间合适,顺利拿到快递后,就要开始处理快递了。而处理快递一般方式有三种:
- 执行默认动作(幸福的打开快递,使用商品)
- 执行自定义动作(快递是零食,你要送给你的女朋友)
- 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
2.技术应用角度的信号
1.信号的演示
编写一段死循环的代码,并将其运行起来:
#include <stdio.h>
#include <unistd.h>
int main() {
while (1){
printf("hello linux!\n");
sleep(1);
}
return 0;
}
我们发现程序在运行起来后,会不断的打印数据,我们想要让这段程序停下来,可以按下ctrl+c的组合键;其原因是,按下ctrl+c时键盘输入产生一个硬件中断,被操作系统获取,然后操作系统解释成信号(2号信号)发送给进程,进程收到2号信号后退出。我们可以通过signal函数来获取进程是不是收到2号信号才终止的;
2.signal函数介绍
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
函数功能:设置某一信号的对应动作
参数说明:
- 第一个参数signum:指明了所要处理的信号类型,它可以取除了SIGKILL(9号信号)和SIGSTOP(19号信号)外的任何一种信号。
- 第三个参数handler:表示我们要对信号进行的处理方法,它可以取以下三种值:
关联动作 | 含义 |
---|---|
SIG_DFL | 执行该信号的默认处理动作 |
SIG_IGN | 忽略该信号 |
sighandler_t | 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕获(catch)一个信号 |
1.SIG_DEF:默认处理动作
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
int main() {
signal(2,SIG_DFL);//这里对2号信号进行默认动作
while(1) {
printf("hello linux!\n");
sleep(1);
}
return 0;
}
当执行程序时,陷入死循环,此时按下ctrl+c进程停止,因为我们对2号信号采取默认动作处理,系统默认2号信号终止进程。
2.SIG_IGN:忽略该信号
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
int main() {
signal(2,SIG_IGN);//这里对2号信号进行忽略动作
while(1) {
printf("hello linux!\n");
sleep(1);
}
return 0;
}
当执行程序时,陷入死循环,此时按下ctrl+c进程并不会停止,因为我们对ctrl+c产生的2号SIGINT信号采取了忽略处理,若要停止进程可用ctrl+\(SIGQUIT)
3.提供信号处理函数(验证刚刚的是不是2号信号)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo){ //自定义一个信号处理函数
printf("get a signal: %d\n", signo);
}
int main() {
signal(2, handler); //对2号信号提供一个处理函数,用来捕捉2号信号(当2号信号到来时才会去捕捉,即调用)
while(1){
printf("hello linux!\n");
sleep(1);
}
return 0;
}
当程序运行起来,同样是死循环,当按下ctrl+c时,并不会退出程序,而是调用了处理函数
注意:
- ctrl+c产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样shell不必等待进程结束就可以接受新的命令,启动新的进程。
- shell可以同时运行一个前台和任意多个后台进程,只有前台进程才能接到像ctrl+c这种控制键产生的信号。
- 前台进程在运行过程中用户随时可能按下ctrl+c而产生一个信号,也就是说该进程的用户空间代码执行到任何地址都有可能受到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
3.查看信号列表
信号是进程之间时间异步通知的一种方式,属于软中断。俗话说就是通知事件发生。
我们可以通过kill -l 的方式查看Linux下信号列表,其中包含普通信号(1 ~ 31)和实时信号(34 ~ 64)。
4.信号处理的常见方式
信号处理方式有3种:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
对于默认处理函数,每种信号有所不同,我们可以通过以下的命令查看:man 7 signal
二、产生信号
1.通过终端按键产生信号
1.Core Dump(核心转储)
#include <stdio.h>
#include <unistd.h>
int main() {
while (1){
printf("hello linux!\n");
sleep(1);
}
return 0;
}
上面的程序一旦运行起来,就是死循环,我们可以通过ctrl+c 或 ctrl +\ 来进行终止进程,实际上ctrl +c是向进程发送的 2 号信号(SIGINT),ctrl + \ 实际上是向进程发送的3号信号(SIGQUIT),那么SIGINT和SIGQUIT有什么区别呢?
从上图可以发现,3号信号(SIGQUIT)除了终止进程外,还有一个核心转储。所谓的核心转储就是当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中,这种行为就叫做Core Dump(中文有的翻译成“核心转储”)。
2.Core Dump的相关设置
如果没有进行Core Dump的相关设置,默认是不开启的。可以通过 ulimit -a 查看是否开启。如果输出为0,则没有开启。
ulimit 的相关选项如下:
ulimit命令用来限制系统用户对shell资源的访问。限制shell启动进程所占的资源,支持以下各种类型的限制:所创建的内核文件的大小、进程数据块的大小、Shell进程创建文件的大小、内存锁住的大小、常驻内存集的大小、打开文件描述符的数量、分配堆栈的最大大小、CPU时间、单个用户的最大线程数、Shell进程所能使用的最大虚拟内存。同时,它支持硬资源和软资源的限制。
// -a:显示目前资源限制的设定;
// -c <core文件上限>: 设定core文件的最大值,单位为区块;
// -d <数据节区大小>: 程序数据节区的最大值,单位为KB;
// -f <文件大小>: shell所能建立的最大文件,单位为区块;
// -m <内存大小>: 指定可使用内存的上限,单位为KB;
// -n <文件数目>: 指定同一时间最多可开启的文件数;
// -p <缓冲区大小>: 指定管道缓冲区的大小,单位512字节;
// -s <堆叠大小>: 指定堆叠的上限,单位为KB;
// -t <CPU时间>: 指定CPU使用时间的上限,单位为秒;
// -u <程序数目>: 用户最多可开启的程序数目;
// -v <虚拟内存大小>: 指定可使用的虚拟内存上限,单位为KB。
// -H:设定资源的硬性限制,也就是管理员所设下的限制;
// -S:设定资源的弹性限制;
我们可以通过 ulimit -c 1024 来设置core文件的大小
此时我们已经开启了Core Dump,让我们运行程序,分别给到2号和3号信号,看看有什么不同之处;
按下 ctrl+\ 就会发现终止进程后会显示 core dumped, 并且此时我们发现多了一个core文件,这个文件的后缀是进程的PID
3.Core文件的调试
刚刚我们说过,Core Dump(核心转储)是当前程序运行的过程中异常终止或崩溃,操作系统会将程序当前的内存状态记录下来,保存在一个文件中的,然后进行调试找到错误所在,这种调试方式叫做事后调试;接下来我们使用下面的代码来进行演示:
#include <stdio.h>
#include <unistd.h>
int main() {
while(1){
printf("hello linux!\n");
sleep(1);
int a = 1;
a /= 0;
}
return 0;
}
4.Core Dump标志位的获取
在之前的 Linux进程的控制 中我们应该记得下面这幅图,起初我们并没有对 core dump标志位进行介绍,接下来结合上文和之前所学内容来详细的介绍
在Linux中,当一个进程退出的时候,它的退出码和退出信号都会被设置;当一个进程异常退出时,进程的退出信号会被设置,表明当前进程的退出原因;如果有必要,操作系统会设置退出信息中的core dump标志位,方便我们后期进行调试;
进程退出时的退出码和退出信号,我们是利用waitpid函数中的第二个参数status来获取的,所以core dump标志位也同样可以获取:
pid_t waitpid(pid_t pid, int *status, int options);
获取退出码:(status >> 8 ) & 0xFF
获取退出信号:status & 0x7F
获取core dump标志位:(status >> 7) & 1
其实所谓的程序崩溃、数组越界、野指针等问题,我们就不能停留在语言层面上了,其本质程序在运行的过程中,对硬件的访问产生了错误,操作系统进而做出处理,给进程发送相应的信号。
接下来我们通过父进程创建子进程来获取子进程退出时的退出码、退出信号和core dump标志位:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
int main() {
if(fork() == 0){
while(1){
printf("I am child.....\n");
sleep(1);
int a = 1; a /= 0;
}
}
int status = 0;
waitpid(-1, &status, 0);
printf("exit code:%d\nexit signal: %d\ncode dump flag: %d\n",(status >> 8) & 0xFF, status & 0x7F, (status >> 7) & 1);
return 0;
}
通过运行结果可以看到,所获得的status的第7个比特位为1,即可说明子进程在被终止时进行了核心转储。因此,core dump标志位实际上就是用于表示程序崩溃的时候是否进行了核心转储。
2.调用系统函数向进程发信号
1.通过kill命令给进程发送信号
//通过kill命令向进程发送信号 kill -信号 进程pid
eg: kill -SIGSEGV 进程pid
//通过kill命令向进程发送信号 kill -信号编号 进程pid
kill -11 进程pid
2.通过kill函数给进程发送信号
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。
//所需头文件
#include <sys/types.h>
#include <signal.h>
//函数原型
int kill(pid_t pid, int sig);
函数功能:发送一个信号给进程
参数说明:
第一个参数pid:具体哪个进程的pid;
第二个参数sig:发送哪一个信号;
返回值:成功返回0,失败返回-1
我们可以用kill函数模拟实现一个kill指令,实现逻辑如下:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
void Usage(const char* proc) {
printf("Usage: \n\t %s signo who\n", proc);
}
int main(int argc, char* argv[]) {
if (argc != 3){
Usage(argv[0]);
return 1;
}
int who = atoi(argv[1]);
int signo = atoi(argv[2]);
kill(who, signo);
return 0;
}
3.通过raise函数给进程发送信号
raise函数可以给当前进程发送指定的信号(自己给自己发信号)
//所需头文件
#include <signal.h>
//函数原型
int raise(int sig);
发送成功,则返回0,否则返回一个非零值
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo){
printf("get a signo:%d\n", signo);
exit(1);
}
int main(){
signal(8, handler);
int count = 5;
while(1){
count--;
printf("I am process. I will exit!! %d\n", count);
sleep(1);
if(count <= 0){
raise(8);
}
}
return 0;
}
4.通过abort函数给进程发送信号
abort函数可以给当前进程发送SIGBRT信号,使得当前进程异常终止;
//所需头文件
#include <stdlib.h>
//函数原型
void abort(void);
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo){
printf("get a signo:%d\n", signo);
}
int main(){
signal(6, handler);
int count = 5;
while(1){
count--;
printf("I am process. I will exit!! %d\n", count);
sleep(1);
if(count <= 0){
abort();
}
}
return 0;
}
abort函数是向进程发送6号信号(SIGABRT),无论是忽略该信号还是捕获该信号,该函数总是会成功,一定能终止进程。
3.软件条件产生信号
通过某种软件(OS),来触发信号的发生,系统层面设置定时器,或者某种操作而导致条件不就绪等这样的场景下,触发的信号;SIGPIPE 和 SIGALRM 信号都是由软件条件产生的信号。SIGPIPE:当读端关闭,写端一直在写,最终写端会收到该信号,这就是一种典型的软件条件触发的信号发生;接下来以alarm函数 和SIGALRM 信号为例。
alarm函数:设置一个闹钟来传递信号
//所需头文件
#include <unistd.h>
//函数原型
unsigned int alarm(unsigned int seconds);
函数功能:
让操作系统在seconds秒之后给当前进程发送SIGALRM信号,SIGALRM信号的默认处理动作是终止进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。
举个例子:
某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被别人吵醒了,但还想多睡一会。于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。
参数及返回值说明:
如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数;
验证设置闹钟后是否会收到信号并终止进程,代码如下:
#include <stdio.h>
#include <unistd.h>
int main(){
alarm(3);
while(1){
printf("hello linux!\n");
sleep(1);
}
return 0;
}
如下图所示:我们定了一个3秒的闹钟,3秒后就会给进程发送14号(SIGALRM)终止进程
验证再次设置一个闹钟是否返回上一个闹钟剩余的秒数,代码如下:
#include <stdio.h>
#include <unistd.h>
int main(){
int ret = alarm(30);
while(1){
printf("hello linux! ret = %d\n", ret);
sleep(1);
int res = alarm(0);
printf("res: %d\n", res);
}
return 0;
}
我们发现第一个闹钟设置的是30秒,循环体中每隔1秒打印数据,其中再次设置闹钟为0,就会覆盖原来的闹钟,返回上个闹钟剩余的秒数(29秒)
利用alarm函数,测试自己的云服务器一秒时间内可以将变量累加到多大
#include <stdio.h>
#include <unistd.h>
int count = 0;
int main(){
alarm(1);
while(1){
printf("hello: %d\n", count++);
}
return 0;
}
运行代码后,可以发现我当前的云服务器在一秒内可以将一个变量累加到六万左右。我们是没有对14号信号进行捕捉的,如果我们捕捉14号信号,count会发生什么变化呢?
此时可以看到,count变量在一秒内被累加的次数变成了五亿多,足以看出IO效率(printf函数打印)很低。
4.硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
内存管理单元简称MMU,它负责虚拟地址到物理地址的映射,并提供硬件机制的内存访问检查。MMU使得每个用户进程拥有自己独立的地址空间,并通过内存访问权限的检查保护每个进程所用的内存不被其他进程破坏。
三、阻塞信号
1.信号其他相关的常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
2.在内核中的表示
信号在内核中的表示示意图如下:
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),他们都是位图结构;还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标注。在上图中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。
- SIGQUIT信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程接触对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在抵达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。
总结一下:
在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号。
handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认,忽略以及自定义。
block、pending和handler这三张表的每一个位置是一一对应的。
3.sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
4.信号集操作函数
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的;下面的一系列函数供我们来操作:
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
- sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
- sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
- sigdelset函数:在set所指向的信号集中删除某种有效信号。
- sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1。
- sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。
5.sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
- 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
- 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
- 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
选项 | 含义 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
返回值:若成功则为0,若出错则为-1
注意:如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。
6.sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1
#include <signal.h>
int sigpending(sigset_t *set);
做一个实验:
- 利用上述的函数将2号信号屏蔽
- 向进程发送2号信号
- 此时2号信号被屏蔽,处于pending状态
- 通过sigpending函数来读取pending信号集来查看验证
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void show_pending(sigset_t *pending){
printf("current procecc pending: ");
int i = 1;
for (i = 1; i <= 31; i++){
if (sigismember(pending, i)){//判断一个信号是否在这个集合中
printf("1");
}
else{ printf("0"); }
}
printf("\n");
}
int main(){
//创建两个阻塞信号集(block位图),并初始化为空
sigset_t inset, outset;
sigemptyset(&inset);
sigemptyset(&outset);
sigaddset(&inset, 2); //添加2号信号到inset信号集中
sigprocmask(SIG_SETMASK, &inset, &outset); //直接阻塞2号信号
sigset_t pending;//创建一个未决信号集(pending位图)
sigemptyset(&pending);//初始化为空
while (1){
sigpending(&pending); //获取pending位图
show_pending(&pending); //打印pending位图(1表示未决)
sleep(1);
}
return 0;
}
可以看到,程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是全0,而当我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。
我们刚刚是将2号信号屏蔽了,pending表由0变为了1,接下来我们想看到2号信号递达后pending表的变化(由1变成0),我们可以设置一段时间后,自动解除2号信号的阻塞状态,解除2号信号的阻塞状态后2号信号就会立即被递达。因为2号信号的默认处理动作是终止进程,所以为了看到2号信号递达后的pending表,我们可以将2号信号进行自定义捕捉;代码如下:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void show_pending(sigset_t *pending){
printf("current procecc pending: ");
int i = 1;
for (i = 1; i <= 31; i++){
if (sigismember(pending, i)){//判断一个信号是否在这个集合中
printf("1");
}
else{
printf("0");
}
}
printf("\n");
}
void handler(int signal){
printf("%d号信号被递达了,已经处理完成了\n", signal);
}
int main(){
signal(2, handler);
//创建两个阻塞信号集(block位图),并初始化为空
sigset_t inset, outset;
sigemptyset(&inset);
sigemptyset(&outset);
sigaddset(&inset, 2); //添加2号信号到inset信号集中
sigprocmask(SIG_SETMASK, &inset, &outset); //直接阻塞2号信号
sigset_t pending;//创建一个未决信号集(pending位图)
sigemptyset(&pending);//初始化为空
int count = 0;
while (1){
sigpending(&pending); //获取pending位图
show_pending(&pending); //打印pending位图(1表示未决)
sleep(1);
count++;
if(count == 10){
sigprocmask(SIG_SETMASK, &outset, NULL); //接触2号信号的阻塞,outset是空的位图,执行到这里就会将原来的1置0
printf("恢复对2号信号,可以被递达了\n");
}
}
return 0;
}
此时就可以看到,进程收到2号信号后,该信号在一段时间内处于未决状态,当解除2号信号的屏蔽后,2号信号就会立即递达,执行我们所给的自定义动作,而此时的pending表也变回了全0。
四、捕捉信号
1.用户空间与内核空间
每一个进程都有自己的进程地址空间(虚拟地址空间),并且由两部分组成:内核空间和用户空间:
- 用户所写的代码和数据均存放在用户空间,通过用户级页表与物理内存建立映射关系;
- 内核空间存储的是操作系统的代码和数据,通过内核级页表与物理内存建立映射关系;
内核级页表是一个全局的页表(意味着独一份),它是用来维护操作系统的代码与进程的代码之间的关系。每个进程看到的代码和数据是不一样的,因为他们是不同的页表,但是对于内核页表是他们共享的,如下图所示:
2.用户态和内核态
1.概念
内核态:
执行操作系统的代码和数据时,计算机所处的状态就叫做内核态,操作系统的代码的执行全部都是在内核态;
用户态:
就是用户代码和数据被访问或者执行的时候,所处的状态;我们自己写的代码全部都是在用户态执行;
主要区别在于权限;
2.切换过程
虽然每个进程都能够看到操作系统,但并不意味着每个进程都能够随时对其进行访问;
内核态与用户态的切换过程的理解:
以进程A为例,在CPU内部有一个寄存器CR3,当进程A在执行自己的代码时(处于用户态)或操作系统的代码(内核态),它会去检查CPU中CR3寄存器存储的内容,如果是3,就执行的是用户态的代码和数据;如果是0,就执行内核态的代码和数据(这里的0和3,只是假设,为了方便理解);
当进程A在执行用户态的代码和数据时,需要调用系统调用,此时CPU的CR3寄存器从用户态切换为内核态通常有如下几种情况:
- 进行系统调用时。
- 当前进程的时间片到了,导致进程切换。
- 产生异常、中断、陷阱等。
从内核态切换为用户态有如下几种情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等处理完毕。
其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。
3.内核如何实现信号捕捉
当我们在执行主控制流程的时候,可能因为某些原因而陷入内核,当内核处理完毕准备返回用户态时,需要进行信号的检查。在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么就需要对该信号进行处理。图1-1:
如果待处理信号的处理动作是默认或者是忽略,则执行该信号的处理动作后清除对应的pending标志位;例如下图中的2号和3号信号,block都为1,操作系统不做处理;一直向下检查,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
图1-2:
如果待处理信号是自定义捕捉的,那么处理该信号时就需要先切换到用户态执行对应的自定义处理动作,例如下图的3号信号,它是处于未决状态的,并且没有被block,操作系统就会处理它,调用用户所写的函数,进而完成信号处理,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。
对于上图的信号捕捉,为了方便好记可以画个简化的图:
内核态在执行对应的信号处理时,是切换到用户态去执行的,为什么?
理论上来说是可以的,因为内核态是一种权限非常高,但是绝对不能这样设计。如果允许在内核态直接执行用户态的代码,那么用户就可以在代码中设计一些非法操作,比如清空数据,虽然在用户态时没有足够的权限做到清空数据,但是如果是在内核态时执行了这种非法代码,那么数据就真的被清空了,就算不能全部清空,也会造成很大的破坏因为内核态的权限是很大的。也就是说,不能让操作系统直接去执行用户的代码,因为操作系统无法保证用户的代码的合法性,即操作系统不信任任何用户。
信号处理是在“合适”的时候处理,如果理解“合适”的时候?
从内核态切换到用户态的时候,进行信号的检测和信号的处理;
4.sigaction
捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。
参数说明:
-
signum:指定信号的编号
-
若act指针非空,则根据act修改该信号的处理动作
-
若oldact指针非空,则通过oldact传出该信号原来的处理动作。
-
act和oldact指向sigaction结构体
struct sigaction { void (*sa_handler)(int);//处理信号的方法 void (*sa_sigaction)(int, siginfo_t *, void *);//实时信号,暂时不考虑 sigset_t sa_mask;// int sa_flags;//一般设置为0 void (*sa_restorer)(void);//实时信号,暂时不考虑 };
sa_handler:
- 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
- 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
- 将sa_handler赋值为一个函数指针,表明用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
sa_mask:
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
注意:
该函数返回值是void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
使用方法:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
void handler(int signal){
while(1){
printf("get a signal: %d\n", signal);
sleep(1);
}
}
int main(){
struct sigaction act;
memset(&act, 0, sizeof(act));
act.sa_handler = handler;//自定义信号处理方法
sigemptyset(&act.sa_mask);
//sigaddset(&act.sa_mask, 3);除了屏蔽2号信号以外,还想屏蔽3号信号
sigaction(2, &act, NULL);
while(1){
printf("hello linux!\n");
sleep(1);
}
return 0;
}
当我们将3号信号也屏蔽之后:
五、可重入函数
先分析下面的代码:链表的头插操作
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一个步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数。
如果一个函数符号以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六、volatile关键字
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
1.内存可见性与不可见性
如下图所示,在内存中有一个flag变量(全局的),右下角有一个循环用来对flag进行判断,如果不对flag进行任何修改,将会死循环下去。在经过一段时间后,由于某种原因(收到了某种信号),对flag进行了修改,不出意外的话,这个死循环会停下。、
#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int signal){
flag = 1;
printf("change flag 0 to 1\n");
}
int main() {
signal(2, handler);
while(!flag);
printf("这个进程是正常退出的1\n");
return 0;
}
运行后发现,他首先是处于死循环的,当我们按下ctrl +c 时,由于对flag由0置1了,循环停下,正常退出了;这种就叫做内存的可见性;
既然有内存的可见性,当然也存在不可见性,我们知道编译器是有优化功能的,如果我们给这段代码进行优化,会发生什么情况呢?
在编译代码时携带-O3选项使得编译器的优化级别最高,此时在运行代码,就算向进程发送2号信号,该进程也不会终止。这种情况就叫做内存的不可见性;
2.解决方法
首先我们来解释一下,为什么有无优化会存在这么大的差异?
我们知道CPU在和内存之间进行交互时,速度上是存在很大差距,一般优化后编译器在编译的时候,会将一些变量直接设置进CPU内部的寄存器中,当需要的时候,CPU就不会去内存中找这个变量,因为寄存器中已经有了。但是当信号到来时,修改的flag值是内存中的flag,此时并没有告诉这个寄存器这个值已经改变了,所以才会出现这样的情况。
那么volatile关键字就起到了作用,它是用来告知编译器,对flag变量的任何操作都必须真实的在内存中进行,即保持了内存的可见性。
#include <stdio.h>
#include <signal.h>
volatile int flag = 0;
void handler(int signal) {
flag = 1;
printf("change flag 0 to 1\n");
}
int main() {
signal(2, handler);
while(!flag);
printf("这个进程是正常退出的1\n");
return 0;
}
此时我们再进行编译运行后,就能看到和之前没有优化的一样的效果了。
volatile作用:
保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
七、SIGHLD信号
我们用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
- 采用第一种方式,父进程阻塞了就不能处理自己的工作了;
- 采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
验证子进程退出时是否会给父进程发送SIGCHLD信号
void GetChild(int signal) {
printf("get a signal: %d, pid: %d\n", signal, getpid());
}
int main() {
signal(SIGCHLD, GetChild);
pid_t id = fork();
if(id == 0){
int ret = 5;
while(ret){
printf("我是子进程:%d\n", getpid());
sleep(1);
ret--;
}
exit(0);
}
while(1);
return 0;
}
刚刚子进程在退出时,是处于僵尸状态的,因为父进程并没有对子进程进行处理,我们除了在调用handler方法时直接waitpid处理子进程;还可以将这个信号处理动作设置为SIG_IGN;
void GetChild(int signal) {
//waitpid(-1, NULL, 0);方法1
printf("get a signal: %d, pid: %d\n", signal, getpid());
}
int main() {
signal(SIGCHLD, SIG_IGN);//方法2
pid_t id = fork();
if(id == 0){
int ret = 5;
while(ret){
printf("我是子进程:%d\n", getpid());
sleep(1);
ret--;
}
exit(0);
}
while(1);
return 0;
}
SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
使用waitpid函数时,需要设置WNOHANG选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里阻塞住。
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig) {
pid_t id;
while( (id = waitpid(-1, NULL, WNOHANG)) > 0){
printf("wait child success: %d\n", id);
}//使用waitpid函数清理多个子进程时需要使用while不断进行清理
printf("child is quit! %d\n", getpid());
}
int main() {
signal(SIGCHLD, handler);
pid_t cid;
if((cid = fork()) == 0){
printf("child : %d\n", getpid());
sleep(3);
exit(1);
}
while(1){
printf("father proc is doing some thing!\n");
sleep(1);
}
return 0;
}