前言
进程信号(上)一文中已经介绍了进程信号的概念性内容,本文我们介绍信号如何保存,以及信号捕捉的具体过程(画图理解)。同时还有核心转储、可重入函数、关键字volatile以及SIGHLD信号等补充内容。
信号的相关概念
- 执行信号的处理动作被称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,叫做信号未决(Pending)。
- 进程可以选择阻塞某个信号,被阻塞的信号产生后将保持在未决的状态,直到进程解除对此信号的阻塞,才能执行递达的操作。
注意:阻塞和忽略不同,信号被阻塞就不能递达,而信号被忽略则是信号递达的一种处理动作。
一、信号的保存——位图
1.内核中的表示
在进程内部要保存信号的信号,有3种数据结果是与之强相关的。
- 首先是pending表。
pending表就是pending位图。
原因:进程可能在任意时间收到OS发给它的信号,该信号可能暂时不被处理,因此需要进行保存,进程保存信号是用位图来保存的,这个位图就是pending位图,对应的被保存在pending位图的信号处于未决状态。
OS向进程发送信号就是在目标进程的pending位图中修改对应信号的比特位,从0修改为1,意思是当前进程收到该信号。因此,发信号也可以算是写信号,PCB属于OS内核结构,只有OS有权利修改pending位图,所以发送信号的执行者只能是OS。 - 其次是block位图
它的比特位的位置代表信号的标号,而比特位的内容表示该信号是否被阻塞。 - 最后是函数指针数组
typedef void(*handler_t)(int signo);
handler_t hander[32] = {0};
在内核中也有指针指向该数组,这个数组存放着当前进程所匹配的信号递达的所有方法。数组的下标代表信号的编号,数组下标对应的内容表示对应信号的处理方法。(该数组是内核数组,因此OS可以使用对应的系统接口来任意访问该数组)
在内核中,信号的基本数据结构构成:
signo从1开始,信号递达的伪代码:
if((1 << (signo - 1) & pcb -> block) { //signo被阻塞,不能抵达 } else { if(1 << (signo - 1) & pcb -> pending) { //递达该信号 } }
之前了解的信号接口signal(signo, handler)的本质是拿到信号在函数指针数组的下标,然后将用户层设置的handler函数放入该数组下标所对应的位置。将来信号产生时,如果该信号没有被阻塞,则OS拿到信号,根据信号的位置得到信号的编号,进而访问数组得到处理方法。
注意:在信号没有产生时,并不妨碍它先被阻塞。
总之,进程可以识别信号并作出相应的处理,是因为程序员在设置体系时,在内核中为每个进程设置好了这三种数据结构可以用于识别信号和处理信号。
2.信号集——sigset_t
pending位图中每个信号只有一个bit的未决标志,非0即1,它不能记录信号产生了多少次,block位图也是如此。因此,未决和阻塞标志可以用相同的数据类型sigset_t
。
sigset_t
称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态:在阻塞信号集中“有效”或“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
3.信号集操作函数
sigset_t类型对于每一种信号用一个bit来表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,用户(使用者)角度不必关心,用户只能调用一下函数来操作sigset_t便利,而不用对它内部数据做任何解释。例如,使用printf打印sigset_t辩手是无意义的。
sigprocmask:读取或更改进程的信号屏蔽字(阻塞信号集)
返回值:成功返回0,失败(出错)返回-1。
sigpending:读取当前进程的未决信号集,用set参数传出。
返回值:调用成功返回0,失败(出错)返回-1.
二、信号的捕捉过程
信号产生的时候,进程可能不会立即处理,而是在合适的时间处理。合适的时间就是进程从内核态返回到用户态的时候进行处理,当然这说明进程之前先进入了内核态。(典型例子是系统调用和进程切换)
1.内核态和用户态
用户代码和内核代码
我们平时自己写的代码就是用户代码。
用户代码不可避免的会访问OS自身的资源(getpid、waitpid…)、硬件资源(printf、write、read…),用户代码想要访问资源必须直接或间接访问OS提供的接口,即必须通过系统调用才能完成对资源的访问。系统调用是OS提供的接口,而普通用户不能以用户态的身份执行系统调用,只能先将自己的身份变为用户态才能执行。因此,执行系统调用的是进程,但是身份实际上是内核。从用户态到内核态需要都很烦的切换,还要调用OS内部的代码,所以一般系统调用花费的时间比较长,我们应该尽量避免频繁调用系统调用。
如何分辨是用户态还是内核态
一个进程在执行的时候,需要将进程的上下文数据放到CPU中的寄存器中,CPU中有许多寄存器,这些寄存器可以分为可见寄存器(eax、ebx…)和不可见寄存器(状态寄存器…)。这些寄存器在进程中具有特定的作用,例如寄存器的内容可以指向进程PCB、保存当前用户级页表,指向页表起始地址。寄存器中的CR3寄存器中存储的内容表示当前进程的运行级别:0表示内核态,3表示用户态。OS是根据CR3寄存器的内容来辨别当前进程是用户态还是内核态。
一个进程如何跑到OS中执行方法
之前我们介绍了进程地址空间,我们知道0-3G是用户级地址空间,通过用户级页表映射到不同的物理空间。除了用户级页表外还有内核级页表,OS为了维护虚拟到物理之间的OS级的代码所构成的内核级映射表,开机时就将OS加载到内存中,OS在物理内存中只保存一份(OS只有一份),因此,OS的代码和数据在内存中只有一份。当前进程从3-4G映射是将内核的代码和数据映射到当前进程的3-4G,此时使用的是内核级页表。每个进程都可以在自己特定的区域内以内核级页表的方式访问OS代码和数据,所以内核级页表只有一份(不同进程共享一份内核级页表)。
3-4G是OS内部的映射,进程建立映射的时候不仅要把用户的代码和数据与进程产生关联,还要通过用户级页表与OS产生关联,每个进程都有自己的进程地址空间,其中用户空间是每个进程独立占有的,而内核空间是从OS映射到每个进程的3-4G空间(每个进程都可以通过内核级页表映射到OS,并且每个进程看到的OS都是一样的),所以进程要访问OS的接口,需要在自己的地址空间上进行跳转。
每个进程都有内核级空间(3-4G),它们共享一个内核级页表,即使进程发生切换,内核级空间的内容也不会更改。
用户怎么才能执行访问内核数据的接口呢?
首先OS读取当前进程在CPU中CR3寄存器的内容,读取运行状态,只有当内容是0内核态时才允许进行访问,所以系统调用接口的起始位置会帮我们把用户态变为内核态(即,从3改为0)。因此,系统调用的前半段是用户态在运行。
OS是如何通过系统调用把进程从用户态该外内核态的?
中断汇编指令
int 80
就是陷入内核。简单理解为把进程运行级别由用户态改为内核态,在调用结束时再切换回来。
无论是用户态还是内核态,进程一定是处于运行的状态,区别是当前的执行级别是用户态还是内核态、页表是用户级页表还是内核级页表,以及它们可以访问的资源。
2.信号捕捉的过程
先通过系统调用陷入内核,从用户态进入内核态,可以直接从内核态进入用户态,但是由于陷入内核比较费时间,因此进入内核态后OS会做一些其他的工作,因此OS会在进程的上下文中搜索,在task_struct中找到当前进程,查看3张表:
- 先看block表:如果比特位内容为0,说明没有被阻塞;
- 继续在pending表中查看该信号对应的比特位内容,pending内容:如果为0则继续看下一个未被阻塞的信号;如果为1,则进行处理;
- 查看函数指针数组,找到pending对应比特位为1的信号的处理方法,对该信号进行处理。
为了方便记忆,我们可以将上图简化:
三、核心转储
1.数组越界并不一定会导致程序崩溃
在学习C语言的过程中,我们有发现数组越界并不一定会导致程序崩溃。
程序的崩溃本质是因为进程访问了未申请的空间,导致程序异常,OS向进程发送了终止进程的信号,但是实际上数组编译器在编译代码时,在栈上开辟的空间的大小与编译器是强相关的(并不仅由程序决定开辟多大空间,但是至少和程序申请的一样大)。例如,数组大小是10个元素,而它在栈上分配的字节数可能会大于10个元素空间,那么此时数组越界也可能还在有效的栈区内,因此不会发生异常,OS识别不出异常,它也不会发送信号终止进程导致程序崩溃。
例子:
文件test2.c
1 #include<stdio.h> 2 int main() 3 { 4 int arr[10]; 5 arr[100] =10; 6 printf("arr[%d] = %d\n",100, arr[100]); 7 printf("arr[%d] = %d\n",10000, arr[10000]); 8 return 0; 9 }
运行:
可以发现当对arr[100]进行操作时,数组虽然越界访问,但是程序并没有崩溃;而对arr[10000]进行操作时,程序崩溃了。
2.信号的退出方式
man 7 signal
- Term是正常结束,OS不会做额外的工作;
- Core是异常结束,OS除了终止进程的工作外,还有其他工作。
3.核心转储
在云服务器上,默认如果进程是core退出的,我们直接是看不到任何现象的,但是可以打开ulimit -a
:查看系统给当前用户设置的各种资源上限:
core file size设置成了0,这是云服务器默认关闭了core file选项,如果想看到现象,我们需要设置:ulimit -c 数字
:
文件test2.c
1 #include<stdio.h> 2 int main() 3 { 4 int arr[10]; 5 arr[10000] = 10; 6 printf("arr[%d] = %d\n",10000, arr[10000]); 7 return 0; 8 }
这时我们重新运行./test2
:
输出报错多了core dumped
:core
表示核心,dumped
表示转储,即core dumped
表示核心转储。转储到当前目录下以core
命名,后面跟引起core
问题的进程的pid
。
核心转储:当进程出现异常时,我们将对应时刻进程在内存中的有效数据转储到磁盘中。
4.核心转储的意义
一旦进程出现崩溃的情况,我们会想知道为什么会崩溃、在哪里崩溃等问题,所以OS为了方便调试,会将进程崩溃的上下文数据全部dump到磁盘中,用来支持调试。
5.如何支持gdb(调试)
这种直接快速进行调试的方式叫做事后调试,在gdb中上下文直接core-file core.xxx。因为是核心转储,所以在进程终止的时候,只会检测到是以core的方式终止进程。
注意:以core方式退出的是可以被核心转储的,后续可以快速定位问题;以term退出的,一般都是正常情况下的终止进程(即,没有异常)。
四、可重入函数
1.概念
一般而言,我们认为main执行流和信号捕捉执行流是两个执行流。
在main中和handler中,某函数被重复进入,程序会出现问题,则该函数称为不可重入函数;
在main中和handler中,某函数被重复进入,程序不会出现问题,则该函数称为可重入函数。
我们目前使用的大部分接口都是不可重入的(重入和不重入是特性)。
2.举例
insert(经典的不可重入函数)
main函数调用insert:向链表head插入Nodel。
inset只做了第一步就被中断(由于信号原因,执行信号捕捉),此时进程会被挂起,然后唤醒再次回到用户态检查待处理的信号,切换到sighandler方法,如果此时的sighandler方法中也调用了insert函数(要将Node2头插到链表中:Node2节点的next指向下一个节点的位置,然后让head的next指向Node2,如此完成Node2的头插),信号捕捉完后就成功的将Node2头插到链表中。接下来回到main执行流,对Node1进行插入的第二步,让head的next指向Node1。程序的最后只有head1插入到链表中,而head2找不到了(发生内存泄漏),出现问题。
不可重入函数:
调用了malloc/free的函数。malloc也是用全局链表来进行管理的;
调用了标准I/O库的函数。标准I.O库的函数实现都是以不可重入的方式使用全局数据结构。
五、volatile关键字
编译器的优化使程序出错
通过自定义方法handler修改全局q,程序不会退出。
文件mysignal.c
1 #include<stdio.h> 2 #include<signal.h> 3 int quit = 0; 4 void handler(int signo) 5 { 6 printf("%d号信号,正在被捕获", signo); 7 printf("quit:%d", quit); 8 quit = 1; 9 printf("->%d\n", quit); 10 } 11 int main() 12 { 13 printf("my pid = %d\n", getpid()); 14 signal(2, handler); 15 while(!quit); 16 printf("我是正常退出的"); 17 return 0; 18 }
运行:
优化前:退出
优化后:不退出
原因
O3优化:
编译器认为quit在main执行流中只是被检测,没有被修改,编译器就对quit做了优化(将quit放入寄存器,这样后续就不用再去内存中读取quit,提高了程序运行效率)。
因此,虽然程序中修改了quit,但只是改变了内存中的quit,CPU的寄存器中保存的值不会一起改变,所以无论内存中的quit怎么改,寄存器中的quit一直不变一直为0。
而while循环因为代码的优化,导致检测quit时读取的是寄存器中的值,而不是内存中的值,因此一直循环,就导致了程序不退出的结果。
这就相当于寄存器中的quit值覆盖率物理内存中quit变量值。
举个生活中的例子:
一个不太会做饭的人,某天煮了一锅汤,他用勺子舀了一勺试味道,发现盐放少了,就往锅里加盐。然后,他又尝了一口勺子里的汤,发现盐还是少,就继续加盐,直到把一包盐加完,还是觉得汤里没盐,但是他家人舀了一勺喝了一口差点被齁死。最终发现他调味道的时候只试最开始的内勺汤,因为不想浪费太多汤来试味道,就一直没有换新的汤,就导致这一锅汤都不能喝的结果。
如何避免优化出错(volatile)
volatile可以保持可见性。
给quit加volatile关键字,则quit就会通过内存读取而不是寄存器,就能保持变量quit的内存可见性。
文件signal.c
1 #include<stdio.h> 2 #include<signal.h> 3 volatile int quit = 0; 4 void handler(int signo) 5 { 6 printf("%d号信号,正在被捕获", signo); 7 printf("quit:%d", quit); 8 quit = 1; 9 printf("->%d\n", quit); 10 } 11 int main() 12 { 13 printf("my pid = %d\n", getpid()); 14 signal(2, handler); 15 while(!quit); 16 printf("我是正常退出的\n"); 17 return 0; 18 }
六、SIGCHLD信号
子进程退出时,会向父进程发送17号信号SIGCHLD。
文件mysignal.c
1 #include<stdio.h> 2 #include<signal.h> 3 #include<stdlib.h> 4 void handler(int signo) 5 { 6 printf("pid:%d, %d号信号正在被捕捉!\n", getpid(), signo); 7 } 8 int main() 9 { 10 signal(SIGCHLD, handler); 11 printf("我是父进程:%d, ppid:%d\n", getpid(), getppid()); 12 pid_t id = fork(); 13 if(id == 0) 14 { 15 printf("我是子进程:%d, ppid:%d,我要退出了\n", getpid(), getppid()); 16 exit(1); 17 } 18 while(1) sleep(1); 19 return 0; 20 }
运行:
实际上,因为UNIX的历史原因,想要不产生僵尸进程还有一种方式:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork处理的子进程,在终止时会自动清理掉,不会通知父进程,也不会产生僵尸进程。
系统默认的忽略动作和用户用sigaction函数自定义的忽略,通常是没有区别的,但这是一个特例。
signal(SIGCHLD, SIG_IGN); sigaction(SIGCHLD, act, oldact);
注意:虽然SIGCHLD默认动作就是忽略,但是它与手动设置表现的不同。
默认是收到信号就忽略处理,但是该等还是要等;
手动设置的SIG_IGN,子进程退出时发送给父进程的信号会被父进程忽略,但是子进程会被OS回收。
这两者是有区别的,含义不一样。
总结
以上就是今天要讲的内容,本文我们介绍信号如何保存,以及信号捕捉的具体过程(画图理解)。同时还有核心转储、可重入函数、关键字volatile以及SIGHLD信号等补充内容。本文作者目前也是正在学习Linux相关的知识,如果文章中的内容有错误或者不严谨的部分,欢迎大家在评论区指出,也欢迎大家在评论区提问、交流。
最后,如果本篇文章对你有所启发的话,希望可以多多支持作者,谢谢大家!