目录
1.快速认识
信号跟信号量是没有关系的概念。
信号是linux系统提供的让用户(进程)给其他进程放松异步信息的一种方式。
系统要求进程要有随时响应外部信号的能力,随后做出反应
1.1生活方面
像是起床时用闹钟叫醒自己(提醒),朋友给自己打眼色(提醒)、大学翘课被导员约谈(危险)等等。
1、上面的例子中,在没有发生的时候,我们已经知道发生的时候,怎么处理了,比如被闹钟叫醒就起床;朋友给自己打眼色,就配合;被约谈就说明出大事了,认错,然后装鸵鸟等等。
2、我们能够认识这些信号,是因为提早就已经通过某种渠道被设置了识别特定信号的方式,比如红绿灯,从小就教你红灯停绿灯行,
上面2个可以归并为,识别一个信号并处理。
3、信号到来的时候,我们可能正在处理更重要的事情(比如在打游戏,女朋友发消息了),我们暂时不能处理到来的信号,必须暂时将信号进行临时保存,不能忘了(不然赢了游戏,输了人生)
4、信号可以不立即处理,在合适的时候处理(明天再说)。
5、信号的到来,我们无法准确预料(像是你打游戏的时候,就不清楚女朋友会不会发消息给你,你也不知道什么时候会被导员抓到翘课),所以信号是异步发送的(我忙我的,他忙他的,不管我忙不忙,他产生信号后都会发送给我,然后接着忙自己事情)。
异步:异步就是不需要等待,比如你上课的时候溜出去上厕所,老师是不会等你回来才继续上课,而是不管你,接着上课。
同步:同步就是,你跟队友组排,你要上厕所,队友都得等你上完厕所才能开启排位
把前面的例子中,我视为一个进程,发生信号的可能是另一个进程或者说用户。进程看待信号的方式,就是上面列举的。
1.2技术角度
我们运行一个自己写的c语言程序(前台进程),运行到一半,我们按下ctrl+c,这时候会产生一个硬件中断,被os获取,解释成信号,前台进程收到信号,进而引起进程退出。
ctrl+c的本质就是向前台进程发送SIGINT即2号信号。
信号处理,是自己处理。
1.3信号概念
信号是进程之间事件异步通知的一种方式,属于软中断。
1.3.1查看信号
每个信号都有一个编号和一个宏定义名称,可以在signal.h中找到。
没有0和32、33信号,一共32个信号
编号34以上都是实时信号(类比实时操作系统、实时进程),本文只讨论34以下的信号。这些信号在什么情况下产生,默认处理动作是什么,在signal(7)里都有详细说明:man 7 signal
我们使用的操作系统大都是分时操作系统,但其实像linux是既支持分时也支持实时,只是我们平时不怎么用实时的部分。像车载系统,既要有分时(比如同时听音乐、导航),也要有实时(比如自动预警,自动刹车之类的)。
其中,Core,Term都是终止进程的一种,具体区别之后会说,另外Ign是忽略,Stop就是暂停。
1.3.2信号处理
我们前面看到signal函数的第二个参数是处理方法,signal.h提前设置好了3个处理动作:
1、忽略信号---SIG_IGN,顾名思义,就是收到了信号,但什么都不做
2、执行默认处理动作---SIG_DFL,比如前台进程ctrl+c默认就是退出进程,大部分信号默认都是终止进程。
3、用户自定义的一个处理动作(捕捉信号)
上面的3种处理动作,可以切换,使用系统调用更改信号的处理方式
signum就是信号编号或者传信号名字也行(宏定义),比如前面的2号信号。handler就是表示更改信号的处理动作,收到对应信号后,回调执行handler函数。返回值是更改前的处理方法
注意,这个sighandler_t的函数指针,参数传的是信号编号。
要注意的是,signal函数仅仅是设置了特定信号的捕捉行为处理方式,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用!!
Ctrl-C产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像Ctrl-C
这种控制键产生的信号。w
前台进程在运行过程中用户随时可能按下Ctrl-C而产生一个信号,也就是说该进程的用
户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控
制流程来说是异步(Asynchronous)的。#define SIG_DFL ((__sighandler_t) 0) /*Defaultaction.*/ #define SIG_IGN ((__sighandler_t) 1) /* Ignore signal.*/ /* Type of a signal handler.*/ typedef void (*--sighandler_t) (int); 其实SIG_DFL和SIG_IGN就是把0,1强转为函数指针类型 因为其他函数地址其实都很大,所以0、1地址的函数就可以提前预设好是默认和忽略#include <iostream> #include <unistd.h> #include <signal.h> using namespace std; void handler(int signumber) { cout << "我是:" << getpid() << ",我获得了一个信号:" << signumber <<endl; } int main() { cout << "我是进程:" << getpid() <<endl; signal(SIGINT/*2*/, handler);//到时候捕捉到信号后内部会调用handler(SIGINT) while(true) { cout << "I am a process, I am waiting signal!" << endl; sleep(1); } }像前两者处理方式,就是signal的第二参数是SIG_IGN 和SIG_DFL。
注意,不调用signal,所有信号都是默认处理动作。
2.产生信号
信号其实是从纯软件角度,模拟硬件中断的行为
只不过硬件中断是发给CPU,而信号是发给进程
两者有相似性,但是层级不同,这点我们后面的感觉会更加明显
2.1通过终端按键产生信号
2.1.1基本操作
ctrl+c,即2号信号,终止前台进程
ctrl+\,即3号信号,是发送终止信号,并生成core dump文件(用于时候调试)
ctrl+z,20号信号,发送暂停信号,将当前前台进程挂起到后台等待。
2.1.2os如何得知键盘有数据
1、键盘按下
2、像CPU发送硬件中断
3、CPU识别自身针脚具有硬件中断信息(其实就是高电压)
4、CPU执行操作系统中处理键盘数据的代码
5、OS停下当前工作,将数据从外设读入内存,等待进一步处理。
键盘驱动不管输入的究竟只是字符还是组合键(比如命令ctrl+c),由os来负责解释这究竟是字符还是命令。
我们的电脑主板是早就已经规划好了,各种硬件设备连接的是cpu的哪个针脚,所以虽然USB口有很多,随便插,但实际上鼠标连的针脚是固定的。
而针脚也是有编号的,cpu内部也是有寄存器专门存这个编号的(可以理解为中断号)。cpu根据这个编号,去os内部维护的一个表(可以理解为中断向量表)里找对应下标的方法,比如键盘就是读取键盘的方法,cpu找到后会执行这个方法(将键盘输入的内容存到内存中键盘对应的缓冲区里),从而完成键盘读取的工作,这样的设计也不用让os时时刻刻的去检测键盘了,只要有中断就行。(详情可以看后面操作系统的硬件中断)
注意,存入struct file中的缓冲区前,os会先判断这些内容是字符还是组合键,普通字符就直接存入缓冲区,由进程处理。组合键就会被解释为信号,比如ctrl+c,解释成2号信号,然后os会发生2号信号给相应进程。
另外,我们read系统调用的时候,如果键盘缓冲区没有数据,是不是会阻塞等待,进入键盘的等待队列里。当os将数据写入缓冲区后,os就会将等待队列的第一个进程唤醒,然后read才能从缓冲区里读数据
2.2调用系统命令向进程发信号
shell命令行中输入:kill -信号编号或信号名字 进程pid
即可向对应进程发送信号。
小细节,如果是传11号信号(段错误信号),结果需要多按一次回车才会显示。
之所以要再次回车才显示 Segmentation fault ,是因为在进程终止掉之前已经回到了Shel提示符,并等待用户输入下一条命令,Shell不希望Segmentationfault 信息和用户的输入交错在一起,所以等用户输入命令之后才显示。
9号信号可以kill掉进程。
2.3函数发信号
2.3.1kill系统调用
kill命令是调用kill系统调用实现的。kill函数可以给一个指定的进程发送指定的信号。
pid顾名思义,要接受信号的进程的pid,sig即发送的信号编号(或者信号名字)。
使用:
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int main(int argc,char *argv[]) { if(argc!=3){ cout<<"Usage: "<<argv[0]<<"-Signumber Pid"<<endl; return 1; } int signumber=stoi(argv[1]+1); int pid=stoi(argv[2]); int n=kill(pid,signumber); if(n<0){ cerr<<"kill error"<<strerror(errno)<<endl; } }
2.3.2raise
raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
使用:
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; void handler(int signumber){ cout<<"enter handler,signumber: "<<signumber<<endl; } int main() { signal(2,handler); int cnt=0; while(true){ cout<<"cnt: "<<cnt++<<endl; sleep(1); if(cnt==5){ cout<<"send 2 signal to caller"<<endl; raise(2); } else if(cnt==10){ cout<<"send 9 signal to caller"<<endl; raise(9); } } }
2.3.3abort
abort函数使当前进程接收到信号而异常终止。
abort没有参数,就是给自己传递6号信号,SIGABRT
使用:
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int main() { int cnt=0; while(true){ cout<<"cnt: "<<cnt++<<endl; sleep(1); if(cnt==5){ cout<<"send 6 signal to caller"<<endl; abort(); } } }
2.4由软件条件产生信号
之前管道文章里有提及,当管道的读端已经关闭,那么os会判断该管道不具备读写的条件,所以会发送SIGPIPE信号(13号)给写端,让写端进程结束(默认动作是Term)。
上面的例子,就是管道作为软件的一种,其满足了某个条件,因而产生了信号。
接下来介绍alarm函数和SIGALRM信号(14号)。
调用alarm函数可以设定一个闹钟,告诉内核在seconds 秒后给当前进程发送SIGALRM信号,该信号默认动作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
闹钟只会响一次,不会重复响。关于返回值,可以看下面的例子
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int cnt=0; void handler(int sig){ cout<<"cnt: "<<cnt<<endl; int n=alarm(2); cout<<"cnt: "<<cnt<<"剩余时间: "<<n<<endl; if(cnt==20)exit(0); } int main(){ signal(SIGALRM,handler); alarm(50); while(true){ sleep(1); cout<<"cnt: "<<++cnt<<endl; if(cnt==13)raise(SIGALRM); } return 0; }
下面看取消闹钟的例子
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int main(){ int cnt=0; alarm(5); while(true){ sleep(1); cout<<"cnt: "<<++cnt<<endl; if(cnt==3){ int n=alarm(0);//取消闹钟 cout<<"剩余时间: "<<n<<endl; } if(cnt==13)exit(0); } return 0; }
2.4.1IO效率验证
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int main(){ alarm(1); int cnt=0; while(true){ cout<<"cnt:" <<++cnt<<endl; } return 0; }最后结果是229667,22万左右。再看下面的代码
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int cnt=0; void handler(int sig){ cout<<"cnt: "<<cnt<<endl; exit(0); } int main(){ signal(SIGALRM,handler); alarm(1); while(true){ cnt++; } return 0; }477469309,可以达到4亿多。
可以发现差距很大,代码的差距就是多了很多的IO操作,也就是cout(再加上我是云服务器,是通过xshell连接的,考虑到网络因素也就更慢了)。
同样是1秒,当不需要IO操作的时候,纯++,也就是内存极操作,比多了IO操作的代码,速度快了很多。
虽然很基础,但这确实IO操作低速度的表现之一。
2.4.2设置重复闹钟
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int cnt=0; void handler(int sig){ cout<<"cnt: "<<cnt<<endl; int n=alarm(2); if(cnt==10)exit(0); } int main(){ signal(SIGALRM,handler); alarm(4); while(true){ cnt++; sleep(1); } return 0; }
通过这样,就能实现重复闹钟。
2.4.3理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产生机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产生的SIGPIPE信号)等。当这些软件条件满足时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号产生。
2.4.4系统闹钟理解
系统闹钟,其实本质是OS必须自身具有定时功能(像是以前说的定期刷新缓冲区等等),并能让用户设置这种定时功能,才可能实现闹钟这样的技术。另外os内部可能同时存在很多闹钟。
现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:
struct timer_list { struct list_head entry; unsigned long expires; void (*function)(unsigned long); unsigned long data; struct tvec_t_base_s *base; };expires是超时时间(是时间戳,计算的时候只要把当前时间戳叫上设置的时间即可),function是个函数指针,这个函数是处理方法。OS为了管理定时器,采取的是时间轮的做法,简单理解就是堆结构(最小堆、最大堆)。
因此os只需要每次检查当前时间戳是否超过了堆顶的定时器对象的expires即可,超过了之后,默认的处理方法就是朝对应pid进程发送SIGALRM信号(这个结构内部肯定是有存pid的,只是这里被封装了)
2.5硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以O的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
我们在C/C++当中除零,内存越界等异常,在系统层面上,是被当成信号处理的。
实际上OS会检查应用程序的异常情况,其实在CPU中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位(当发生溢出错误时,比如除0导致的溢出,溢出标志位会置为1)。OS会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法(比如发送对应信号给对应进程)。
讲完除0,我们再谈谈野指针问题:
我们知道虚拟和物理地址,而指针里面存的是虚拟地址,另外虚拟和物理地址的转换,需要os+cpu(MMU元件),CR3寄存器里面存着页表(页表有虚拟和物理的映射关系),当我们要对某个地址做写入的时候,MMU会根据CR3和我们传入的虚拟地址(存在一个寄存M,比如eax)进行转换,如果转换失败(失败原因有很多,比如这里是野指针,也就是访问非法内存0,0地址基本都是只读区,甚至不会在页表里有映射关系,自然就失败了),就会把转换失败的虚拟地址存在CR2寄存器里(转换成功与否也是在状态寄存器里有标记位的),然后CPU会让OS来处理,OS发现CR2里有数据结且标记为失败,os会根据失败原因发送信号,就是发送11号信号SIGSEGV。也就是段错误
2.5.1除0
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; int main(){ int a=10; a/=0; while(1){sleep(1);} return 0; }
FPE就是信号里的SIGFPE
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; void handler(int sig){ cout<<"get signal: "<<sig<<endl; } int main(){ signal(SIGFPE,handler); int a=10; a/=0; while(1){sleep(1);} return 0; }
发现一直有8号信号产生被我们捕获,这是为什么呢?
除零异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等操作,所以CPU中还保留上下文数据以及寄存器内容,除零异常会一直存在,就有了我们看到的一直发出异常信号的现象。下面的访问非法内存其实也是如此。另外,面对异常,因为寄存器是cpu内部的,用户改不了,所以用户其实能做的,就只有终止进程(让上下文数据清空或者说标记为已删除)罢了,最多打印日志,找找哪里出异常。
2.5.2模拟野指针
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> using namespace std; void handler(int sig){ cout<<"get signal: "<<sig<<endl; exit(1);//以免一直发信号 } int main(){ signal(SIGSEGV,handler); int *a=nullptr; *a=10; while(1){sleep(1);} return 0; }
2.5.3子进程退出core dump
之前关于进程的文章中有说,进程退出,会返回一个信号和退出码。但其实这2个占了int类型的低16位中的15位。其中退出码是高8位,信号是低7位,从低到高第8位存的就是core dump标志。另外,为什么信号里没有0信号?因为0意味着没有信号。
下面代码可以验证
#include <iostream> #include <unistd.h> #include <signal.h> #include<sys/types.h> #include<cerrno> #include<cstring> #include<sys/wait.h> #include<stdlib.h> using namespace std; int main(){ if(fork()==0){ //child 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; }但实际上,一般都是只会输出0,为什么?看下面
2.5.4Core Dump
我们前面看man 7 signal的时候有发现,同样是终止进程,却有Term和Core两种。
SIGINT的默认处理动作是终止进程即Term,SIGFPE的默认处理动作是终止进程并且CoreDump即Core,现在我们来验证一下。
首先解释什么是CoreDump。当一个进程要异常终止时,可以选择把进程的在内存中的核心数据全部保存到磁盘上,文件名通常是core或core.pid,这叫做Core Dump。
进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。
一个进程允许产生多大的core文件取决于进程的ResourceLimit(这个信息保存在PCB
中)。默认是不允许产生 core 文件的(即core文件大小为0),因为core 文件中可能包含用户密码等敏感信息,不安全,而且云服务器中的程序经常都是24小时运行的,这就意味着如果进程挂掉了,为了先恢复功能,很可能是有自动重启的设置的,这样的话如果是centos的话,很可能一夜过去整个服务器磁盘都被塞满了(原因看下面生成core文件部分,反过来也能说明为什么Ubuntu和centos在这方面会有差异)。
在开发调试阶段可以用ulimit 命令改变这个限制,允许产生 core 文件。首先用ulimit 命令
改变 Shell 进程的 Resource Limit,如允许 core 文件最大为 1024K: ulimit -c 1024注意,因为进程pcb继承自shell进程pcb,所以只要改了shell的,那么之后创建的进程都可以产生core文件。
另外ulimit -a是查看
可以发现,core文件大小是0。
可以发现core文件大小已经被更改。这时候,我们上面的代码,结果如下:
我们可以发现,coredump被设置为了1,并且当前工作目录下生成了core文件(Ubuntu是单纯的core,centos下是core.进程pid),设置为1说明已经发生了核心转储(也就是core dump)
另外,为了能使用core,我们编译时记得编译器选项记得加-g。
可以发现,会直接告诉我们,是在第18行代码的a/=0处出错,程序终止是因为SIGFPE信号,属于架构异常。这样就不用我们一个个调试找错误了。
稍微总结下,coredump标志位必须在系统开启了coredump功能且进程收到了动作为core的信号才为1。
小补充
我们前面只说了怎么产生信号,信号是要发生给进程的。问题来了,进程会不会收到很多信号?会。这些信号不管是只有一个还是有多个,是不是要有个地方保存?要。那么保存在哪里呢?进程的pcb中。
那么pcb怎么保存呢,简单的形容,那就是有一个位图,就是一个整型变量pending。然后比特位的位置表示信号编号,比特位的值1/0,表示是/否收到指定信号。那么发送信号,其实就是把对应的比特位 置1。所以发送信号更确切的说是写入信号。
我们前面还讲了键盘的组合键会被os解释成为2号信号并发送,那么这个过程其实就是os找到了对应的进程pcb,并将位图的2号比特位 置为1。
我们还要注意,PCB是内核数据结构,只有os可以改,所以我们可以发现,不管信号的产生方式有多少种(命令封装了系统调用,系统调用是os提供,函数也是封装了系统调用,软件条件和硬件异常到最后也是由os执行发送信号),最后信号一定是由os来发送信号的!
3.保存信号
3.1相关概念
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)(像前面说的位图里置1的状态也是信号未决,直到信号递达之后才置0)。
进程可以选择阻塞(Block,也可以称为屏蔽)某个信号(这个阻塞,进程也有个位图block来存储,比特位的位置依旧是信号编号,比特位的内容,是否阻塞该信号)。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。对于进程来说,忽略是已读不回,阻塞就是不管消息有没有接受到,都看不到。
3.2内核中的表示
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler.现在再来理解,为什么信号在未产生前就已经可以被识别,也知道怎么处理,就是因为有了这3个东西(2位图加函数指针表),函数指针表表示信号的处理动作(默认就有动作,说明内核编写员早就已经内置了,这内置的就是默认处理动作),阻塞位图表示理不理该信号,未决位图表示是哪个信号。
因此,前面说过最后都是os发送信号,发送信号就是写入信号,写入信号,就是把pedding位图位置为信号编号的比特位置1,剩下的动作,进程根据pending和block以及handler来决定怎么处理这个信号。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。我们这里不讨论实时信号。另外signal函数其实就是把处理方法指针填入了handler的下标为该信号编号的位置处。
以下是内核结构(AI生成)
#include <signal.h> #include <sys/types.h> /* 信号处理函数指针类型 */ typedef void (*__sighandler_t)(int); /* 信号集数据结构 */ typedef struct { unsigned long sig[2]; // 64位信号位图 } sigset_t; /* 进程信号处理信息结构 */ struct signal_struct { sigset_t blocked; // 阻塞信号集 sigset_t pending; // 未决信号集 struct sigaction action[64]; // 信号处理函数数组 }; /* 信号处理动作结构 */ struct sigaction { __sighandler_t sa_handler; // 信号处理函数指针 sigset_t sa_mask; // 执行处理函数时的阻塞信号集 int sa_flags; // 标志位 }; /* 简化的 PCB 结构(信号处理相关部分) */ struct task_struct { pid_t pid; // 进程ID // ... 其他进程控制字段 ... /* 信号处理相关字段 */ struct signal_struct *signal; // 指向信号处理结构 sigset_t blocked; // 当前阻塞信号集 sigset_t real_blocked; // 临时阻塞信号集 struct sigpending pending; // 未决信号队列 // ... 其他字段 ... }; /* 未决信号队列结构 */ struct sigpending { struct list_head list; // 未决信号链表 sigset_t signal; // 未决信号位图 }; /* 信号处理函数示例(用户空间)*/ void sighandler(int signo) { // 用户自定义信号处理逻辑 switch(signo) { case SIGINT: printf("Received SIGINT signal\n"); break; case SIGQUIT: printf("Received SIGQUIT signal\n"); break; default: printf("Received signal %d\n", signo); } }
3.3sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
这个类型是os提供的用户级数据结构。
阻塞信号集也叫做当前进程的信号屏蔽字(SignalMask),这里的“屏蔽”应该理解为阻塞而不是忽略。
阻塞可以类比为文件权限的umask一样。
3.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 signo); int sigdelset(sigset_t *set, int signo); int sigismember(const sigset_t *set, int signo);函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。sigismember顾名思义,判断signo信号是否在ser信号集里。
注意,这里的操作只是对一个不涉及内核的数据结构进行管理,并没有真正的改变内核中进程的信号集。
这里的操作是为了下面的接口而服务的。
3.4.1sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
如果oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
3.4.2sigpending
作用很简单,就是把当前进程的pending信号集通过输出型参数set传出来。
毕竟我们不应该手动改pending。
3.4.3例子
不难发现,上面2个接口加上signal函数,正好对应了2个信号集和一个函数指针表。
下面用例子表示具体使用。
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <cerrno> #include <cstring> #include <sys/wait.h> #include <stdlib.h> using namespace std; void PrintSig(sigset_t &pending) { cout<<"Pending bitmap: "; // 因为我们不能对内部结构做访问,所以必须借助接口 for (int signo = 31; signo >= 1; signo--) { if (sigismember(&pending, signo)) { cout << "1"; } else { cout << "0"; } } cout << endl; } int main() { // 1.屏蔽2号信号 // 准备阻塞信号集对象--只是一个栈上的数据结构对象,并没有涉及内核 sigset_t block, oblock; sigemptyset(&block); sigemptyset(&oblock); // 这个可以不清空,因为后面也是拷贝进来的。 sigaddset(&block, 2); // SIGINT // 真正修改了该进程的信号屏蔽字 int n = sigprocmask(SIG_SETMASK, &block, &oblock); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); // 这一块可以用assert,只是如果没用上n的话,需要加个void(n) // 来骗过编译器自己用过n了。 } cout << "block 2 signal success" << endl; while (true) { // 2.获取进程的pending位图 sigset_t pending; sigemptyset(&pending); n = sigpending(&pending); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); } // 3.打印pending位图中收到的信号 PrintSig(pending); sleep(1); } return 0; }
当我发送了2号信号给进程后,本来pending位图里全是0的,现在从右往左第2位比特位变成了1,说明确实发送到了
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <cerrno> #include <cstring> #include <sys/wait.h> #include <stdlib.h> using namespace std; void PrintSig(sigset_t &pending) { cout << "Pending bitmap: "; // 因为我们不能对内部结构做访问,所以必须借助接口 for (int signo = 31; signo >= 1; signo--) { if (sigismember(&pending, signo)) { cout << "1"; } else { cout << "0"; } } cout << endl; } int main() { // 1.屏蔽2号信号 // 准备阻塞信号集对象--只是一个栈上的数据结构对象,并没有涉及内核 sigset_t block, oblock; sigemptyset(&block); sigemptyset(&oblock); // 这个可以不清空,因为后面也是拷贝进来的。 sigaddset(&block, 2); // SIGINT // 真正修改了该进程的信号屏蔽字 int n = sigprocmask(SIG_SETMASK, &block, &oblock); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); // 这一块可以用assert,只是如果没用上n的话,需要加个void(n) // 来骗过编译器自己用过n了。 } cout << "block 2 signal success" << endl; int cnt = 0; while (true) { cout << "pid: " << getpid() << endl; // 2.获取进程的pending位图 sigset_t pending; sigemptyset(&pending); n = sigpending(&pending); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); } // 3.打印pending位图中收到的信号 PrintSig(pending); // 4.解除对2号信号的屏蔽 cnt++; if (cnt == 20) { cout << "解除对2号信号的屏蔽" << endl; n = sigprocmask(SIG_UNBLOCK, &block, &oblock); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); } } sleep(1); } return 0; }
可以发现,当我们解除了屏蔽后,因为pending中有且只有2号信号,所以2号信号立刻被递达,再加上我们没有改2号信号的处理动作,所以执行默认动作----终止进程。
当然也可以改处理动作,让循坏继续,可以参考如下代码:
#include <iostream> #include <unistd.h> #include <signal.h> #include <sys/types.h> #include <cerrno> #include <cstring> #include <sys/wait.h> #include <stdlib.h> using namespace std; void PrintSig(sigset_t &pending) { cout << "Pending bitmap: "; // 因为我们不能对内部结构做访问,所以必须借助接口 for (int signo = 31; signo >= 1; signo--) { if (sigismember(&pending, signo)) { cout << "1"; } else { cout << "0"; } } cout << endl; } void handler(int sig){ cout<<sig<<"号信号被递达处理..."<<endl; } int main() { //0.改2号信号处理动作 signal(2,handler); // 1.屏蔽2号信号 // 准备阻塞信号集对象--只是一个栈上的数据结构对象,并没有涉及内核 sigset_t block, oblock; sigemptyset(&block); sigemptyset(&oblock); // 这个可以不清空,因为后面也是拷贝进来的。 sigaddset(&block, 2); // SIGINT // 真正修改了该进程的信号屏蔽字 int n = sigprocmask(SIG_SETMASK, &block, &oblock); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); // 这一块可以用assert,只是如果没用上n的话,需要加个void(n) // 来骗过编译器自己用过n了。 } cout << "block 2 signal success" << endl; int cnt = 0; while (true) { cout << "pid: " << getpid() << endl; // 2.获取进程的pending位图 sigset_t pending; sigemptyset(&pending); n = sigpending(&pending); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); } // 3.打印pending位图中收到的信号 PrintSig(pending); // 4.解除对2号信号的屏蔽 cnt++; if (cnt == 20) { cout << "解除对2号信号的屏蔽" << endl; n = sigprocmask(SIG_UNBLOCK, &block, &oblock); if (n != 0) { cerr << "set block failed,strerror: " << strerror(errno) << endl; exit(1); } } sleep(1); } return 0; }
3.4.4小细节
递达信号的时候,会把pending位图对应的比特位清0
那么问题来了,先清0还是先递达?答案是先清0------具体的验证可以在我上面代码的基础上,在handler函数中 ‘获取pending位图,再打印pending位图’ 来验证。
另外,我们也可以想到,如果我们把所有的信号屏蔽,这个进程会不会“无敌”?不会,因为os不允许,os不允许屏蔽9、19号信号(kill和stop),18号信号会被做特殊处理。
一个进程无法被kill杀死的可能有哪些?
这个进程阻塞了信号
用户有可能自定义了信号的处理方式
这个进程有可能是僵尸进程
这个进程当前状态是停止状态
4.捕捉信号
4.1信号捕捉的流程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了 SIGQUIT 信号的处理函数 sighandler。
当前正在执行main函数,这时发生中断或异常切换到内核态。
在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 处于未决且未阻塞。
内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 用户态中定义的sighandler 函数,sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
Sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。
如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
核心就是从内核态切换到用户态之前会检测信号并处理信号。
如果是SIG_IGN,则是pending位图清0返回用户态继续运行。
在信号处理的过程(捕捉)中,一共会有4次的状态切换(内核和用户态)
注意,自定义的处理动作,必须由用户态执行,因为自定义,意味着不可控,为了系统的安全,内核态不会直接调用用户空间定义的方法,以免被这个方法绕过了系统调用直接访问修改内核数据。
注意,我们会发现,处理方法是默认动作终止进程的信号,也要等待合适的时机,才能被递达,因为进程很可能正在做一些非常重要的且不能被中断或直接放弃的任务,如果强行中断或放弃很可能造成未知错误,所以需要等待合适的时机来递达。
因为现在大多系统都是分时操作系统,时间片用完之后都是要进行进程切换的,执行调度程序的就是os,也就是说也是要进入内核态的,再加上分时的切换很频繁,所以就算我们的程序没有调用系统调用,也是要经常进行用户态和内核态的切换的。
4.2sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。
signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
将sa_handler赋值为常数SIG_IGN,传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void handler(int signo){ cout<<"signal :"<<signo<<endl; } int main(){ struct sigaction act,oact; act.sa_handler=handler; act.sa_flags=0; sigemptyset(&act.sa_mask); sigaction(2,&act,&oact); while(true)sleep(1); return 0; }上面代码的效果,就是当你发送2号信号的时候,执行handler方法。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags字段包含一些选项,本文的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数(本文不介绍实时信号,有兴趣可以自行了解)。
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void Print(sigset_t &pending){ cout<<"current process pending: "; for(int signo=31;signo>=1;signo--){ if(sigismember(&pending,signo))cout<<"1"; else cout<<"0"; } cout<<endl; } void handler(int signo){ cout<<"signal :"<<signo<<endl; //测试当前信号在处理的时候,同样的信号不会被递归式递达而是被屏蔽,留在pending表里。 sigset_t pending; sigemptyset(&pending); int cnt=0; while(true){ sigpending(&pending); Print(pending); sleep(1); //防止死循环,自动kill if(++cnt==8)raise(9); } } int main(){ struct sigaction act,oact; act.sa_handler=handler; act.sa_flags=0; sigemptyset(&act.sa_mask); sigaction(2,&act,&oact); while(true)sleep(1); return 0; }
![]()
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; void Print(sigset_t &pending){ cout<<"current process pending: "; for(int signo=31;signo>=1;signo--){ if(sigismember(&pending,signo))cout<<"1"; else cout<<"0"; } cout<<endl; } void handler(int signo){ cout<<"signal :"<<signo<<endl; //测试当前信号在处理的时候,同样的信号不会被递归式递达而是被屏蔽,留在pending表里。 sigset_t pending; sigemptyset(&pending); int cnt=0; while(true){ sigpending(&pending); Print(pending); sleep(1); //防止死循环,自动kill cnt++; if(cnt==3){ cout<<"send 3 signal, "; raise(3); } if(cnt==5){ cout<<"send 4 signal, "; raise(4); } if(cnt==7){ cout<<"send 5 signal, "; raise(5); } if(cnt==10)raise(9); } } int main(){ struct sigaction act,oact; act.sa_handler=handler; act.sa_flags=0; sigemptyset(&act.sa_mask); sigaddset(&act.sa_mask,3); sigaddset(&act.sa_mask,4); sigaddset(&act.sa_mask,5); sigaction(2,&act,&oact); while(true)sleep(1); return 0; }
4.3操作系统的运行
4.3.1硬中断
网图:
中断向量表就是操作系统的一部分,启动就加载到内存中了
通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
由外部设备触发的,中断系统运行流程,叫做硬件中断关于os内关于中断的数据结构,比如trap_init和rs_init有兴趣可以了解下。Linux 0.11
void trap_init(void) { int i; set_trap_gate(0, ÷_error); // 设置除操作出错的中断向量值。以下雷同。 set_trap_gate(1, &debug); set_trap_gate(2, &nmi); set_system_gate(3, &int3); // int3-5 can be called from all set_system_gate(4, &overflow); set_system_gate(5, &bounds); set_trap_gate(6, &invalid_op); set_trap_gate(7, &device_not_available); set_trap_gate(8, &double_fault); set_trap_gate(9, &coprocessor_segment_overrun); set_trap_gate(10, &invalid_TSS); set_trap_gate(11, &segment_not_present); set_trap_gate(12, &stack_segment); set_trap_gate(13, &general_protection); set_trap_gate(14, &page_fault); set_trap_gate(15, &reserved); set_trap_gate(16, &coprocessor_error); // 下面将int17-48的陷阱门先均设置为reserved,以后每个硬件初始化时会重新设置自己的陷阱门。 for (i = 17; i < 48; i++) set_trap_gate(i, &reserved); set_trap_gate(45, &irq13); // 设置协处理器的陷阱门。 outb_p(inb_p(0x21) & 0xfb, 0x21); // 允许主8259A芯片的IRQ2中断请求。 outb(inb_p(0xA1) & 0xdf, 0xA1); // 允许从8259A芯片的IRQ13中断请求。 set_trap_gate(39, ¶llel_interrupt); // 设置并行口的陷阱门。 } void rs_init(void) { set_intr_gate(0x24, rs1_interrupt); // 设置串行口1的中断门向量(硬件IRQ4信号)。 set_intr_gate(0x23, rs2_interrupt); // 设置串行口2的中断门向量(硬件IRQ3信号)。 init(tty_table[1].read_q.data); // 初始化串行口1(.data是端口号)。 init(tty_table[2].read_q.data); // 初始化串行口2。 outb(inb_p(0x21) & 0xE7, 0x21); // 允许主8259A芯片的IRQ3,IRQ4中断信号请求。 }这是操作系统内核的中断初始化代码,涉及中断描述符表(IDT)的设置
trap_init()函数初始化各种CPU异常处理程序
rs_init()函数初始化串行口(RS232)中断处理
代码中使用了不同的门描述符设置函数:
set_trap_gate()- 设置陷阱门
set_system_gate()- 设置系统门
set_intr_gate()- 设置中断门
包含对8259A中断控制器的端口操作
4.3.2时钟中断
进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自已被谁指挥,被谁推动执行呢?
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?网图:
根据上面的流程,周期性的产生时钟中断,让os定期处理中断(检查时间片、内存管理等等),从而实现由硬件来让os运作起来了。Linux 0.11
// 调度程序初始化子程序 void sched_init(void) { // ... 其他初始化代码 // 设置时钟中断门(0x20对应IRQ0,时钟中断) set_intr_gate(0x20, &timer_interrupt); // 修改中断控制器屏蔽码,允许时钟中断 outb(inb_p(0x21) & ~0x01, 0x21); // 设置系统调用中断门(0x80对应系统调用中断) set_system_gate(0x80, &system_call); // ... 其他初始化代码 } // 汇编代码段 - 时钟中断处理程序 _timer_interrupt: // ... 保存现场等预处理代码 // 调用do_timer函数,CPL为参数 call _do_timer // 'do_timer(long CPL)'在kernel/sched.c第305行实现 // ... 恢复现场等后处理代码 // C代码 - 时钟中断处理核心函数 void do_timer(long CPL) { // ... 计时和其他处理工作 // 执行任务调度 schedule(); } // 调度函数 void schedule(void) { // ... 调度算法逻辑,选择下一个任务next // 切换到选定的任务 switch_to(next); // 切换到任务号为next的任务,并运行之 }sched_init()- 初始化调度系统(加载了任务0的tr,ldtr),设置时钟中断和系统调用中断
当时钟中断发生时,CPU跳转到_timer_interrupt汇编处理程序
汇编处理程序调用C函数do_timer()进行时间统计和处理
do_timer()调用schedule()执行任务调度算法
schedule()通过switch_to()实现实际的任务上下文切换
4.3.3死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加方法即可。
操作系统的本质:就是一个死循环!不断接受来自外部的其他硬件中断
Linux 0.11
void main(void) { for(;;) pause(); }特殊的任务0:这是系统启动后的第一个任务(空闲任务),具有特殊的行为逻辑
pause()函数的特殊含义:
对于普通任务:pause()意味着等待信号,进入阻塞状态
对于任务0:pause()只是检查是否有其他就绪任务可运行
调度机制:当没有其他任务运行时,调度器会重新激活任务0,使其不断循环执行pause()
无限循环的作用:任务0作为系统的"空闲任务",在CPU无事可做时持续运行,保持调度器的正常工作
4.3.4软中断
上述外部硬件中断,需要硬件设备触发。
有没有可能,因为软件原因,也触发上面的逻辑?有!
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内部触发中断逻辑。网图:
我们平时在用户层写的调用系统调用,里面做的事情是:
用户层通过寄存器(比如EAX)把系统调用号给操作系统
操作系统通过寄存器或者用户传入的缓冲区地址把返回值给用户
系统调用的过程,其实就是先int 0x80、syscal陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法上面的流程可以理解为对原始os提供的系统调用所做的一个封装。
// 调度程序初始化子程序 void sched_init(void) { // ... 其他初始化代码 // 设置时钟中断门(0x20对应IRQ0,时钟中断) set_intr_gate(0x20, &timer_interrupt); // 修改中断控制器屏蔽码,允许时钟中断 outb(inb_p(0x21) & ~0x01, 0x21); // 设置系统调用中断门(0x80对应系统调用中断) set_system_gate(0x80, &system_call); // ... 其他初始化代码 }
系统调用号的本质:数组下标!
extern int sys_setup(); // 系统启动初始化设置函数 (kernel/blk_drv/hd.c,71) extern int sys_exit(); // 程序退出 (kernel/exit.c,92) extern int sys_fork(); // 创建进程 (kernel/system_call.s,175) extern int sys_read(); // 读文件 (kernel/read_write.c,55) extern int sys_write(); // 写文件 (kernel/read_write.c,68) extern int sys_open(); // 打开文件 (kernel/open.c,56) extern int sys_close(); // 关闭文件 (kernel/open.c,71) // ... 其他类似的系统调用函数声明 // 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。 fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read, sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link, sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod, sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount, sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm, sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access, sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir, sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_geteuid, sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid, sys_setregid };平时我们用的系统调用见不到什么int 0x80或syscall。都是直接调用上层函数。
那是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了(像read系统调用对应底层os提供的就是sys_read)。
#define SYS_ify(syscall_name) __NR_##syscall_name :是一个宏定义,用于将系
统调用的名称转换为对应的系统调用号。比如:SYS_ify(open)会被展开为__NR_open
而系统调用号,不是glibc提供的,是内核提供的,内核提供系统调用入口函数 man 2
syscall,或者直接提供汇编级别软中断命令int or syscall,并提供对应的头文件或者开
发入口,让上层语言的设计者使用系统调用号,完成系统调用过程。
#define __NR_read 0 __SYSCALL(__NR_read, sys_read) #define __NR_write 1 __SYSCALL(__NR_write, sys_write) #define __NR_open 2 __SYSCALL(__NR_open, sys_open) #define __NR_close 3 __SYSCALL(__NR_close, sys_close) #define __NR_stat 4 __SYSCALL(__NR_stat, sys_newstat) #define __NR_fstat 5 __SYSCALL(__NR_fstat, sys_newfstat) #define __NR_lstat 6 __SYSCALL(__NR_lstat, sys_newlstat) #define __NR_poll 7 __SYSCALL(__NR_poll, sys_poll) #define __NR_lseek 8 __SYSCALL(__NR_lseek, sys_lseek) #define __NR_mmap 9 __SYSCALL(__NR_mmap, sys_mmap) #define __NR_mprotect 10 __SYSCALL(__NR_mprotect, sys_mprotect) #define __NR_munmap 11 __SYSCALL(__NR_munmap, sys_munmap) #define __NR_brk 12 __SYSCALL(__NR_brk, sys_brk) #define __NR_rt_sigaction 13 __SYSCALL(__NR_rt_sigaction, sys_rt_sigaction) #define __NR_rt_sigprocmask 14 __SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask) #define __NR_rt_sigreturn 15 __SYSCALL(__NR_rt_sigreturn, stub_rt_sigreturn)
4.3.5缺页中断、内存碎片处理、除零野指针错误
缺页中断、内存碎片处理、除零野指针错误,这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
操作系统就是躺在中断处理例程上的代码块!
CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱
CPU内部的软中断,比如除零/野指针等,我们叫做异常。
4.4内核态和用户态
关于特权级别,涉及到段,段描述符,段选择子,DPL,CPL,RPL等概念,而现在芯片为了保证兼容性,已经非常复杂了,进而导致OS也必须得照顾它的复杂性,这块我们不做深究了。
我们知道进程地址空间一般是4GB,而其中[0,3]GB都是用户空间,[3,4]GB都是内核空间
用户态就是执行用户[0,3]GB时所处的状态
内核态就是执行内核[3,4]GB时所处的状态写时拷贝也只拷贝用户空间的数据
另外页表也分内核级和用户级(其实是同一张,这里分开来说), 用户级页表就是之前提到过的页表,负责用户空间的虚拟地址与物理地址的映射,而内核级页表,负责的就是操作系统的各种数据结构、系统调用的映射。我们调用系统调用的时候,从用户空间的代码区携带系统调用号到内核空间,然后在表里索引找到对应系统调用,然后在内核空间执行系统调用,执行完再返回用户空间。
操作系统系统调用方法的执行,是在进程的地址空间中执行的,上面的跳转地址也一直是在进程地址空间内跳转!
对于系统调用,比较形象的理解,可以理解为动态库,也是共享一份源代码,只要有了存储系统调用表的地址,再加上系统调用号,就可以找到指定位置的系统调用。
不同的进程只要连接到同一份内核级页表,这样就能实现所有进程用同样的虚拟地址访问同一份操作系统代码,数据。操作系统无论怎么切换进程,都能找到同一个操作系统!
-----------------
状态的区分就是按照CPU内的CPL决定,CPL的全称是Current PrivilegeLevel,即当前特权级别(用户态和 内核态)。一般执行int 0x8θ或者syscall软中断,CPL会在校验之后自动变更
cpu内部会有寄存器来保存当前cpu的权限是用户态还是内核态(执行系统调用前设置为内核态,返回用户空间后再设置为用户态)。
5.可重入函数
网图:
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,这时因为某种原因(比如时钟中断,另外其实现在语言库里的函数大多直接或间接有调用系统调用,所以内核和用户的切换是很频繁的),硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程(main执行流和信号捕捉执行流)调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
6.volatile
这个关键字在c/c++都有。
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; int flag=0; void handler(int sig){ cout<<"change flag 0 to 1"<<endl; flag=1; } int main(){ signal(2,handler); while(!flag); cout<<"process quit normal"<<endl; return 0; }
标准情况下,键入CTRL-C,2号信号被捕捉,执行自定义动作,修改fLag=1,while 条件不满足,退出循环,进程退出
在编译时加入优化(因为flag在main中没有任何修改(while循坏里什么都没有也是一个原因),优化之后会把全局变量flag放入寄存器,这样就不用每次都从内存把值拷贝到寄存器里再进行判断)
优化情况下,键入CTRL-C,2号信号被捕捉,执行自定义动作,修改flag=1,但是while条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显,while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。
while检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要volatile
#include<iostream> #include<signal.h> #include<unistd.h> using namespace std; volatile int flag=0; void handler(int sig){ cout<<"change flag 0 to 1"<<endl; flag=1; } int main(){ signal(2,handler); while(!flag); cout<<"process quit normal"<<endl; return 0; }
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化(很多编译器都会自动优化,这就是防止这个变量被优化),对该变量的任何操作,都必须在真实的内存中进行操作
7.SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号(17号),该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。#include<iostream> #include<signal.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> using namespace std; void handler(int sig){ pid_t id; //注意,不能只处理一次,因为很可能同时有多个子进程都结束了,并发送了信号 //但递达只会递达一次,所以必须用while来一次性全处理掉 while(id=waitpid(-1,nullptr,WNOHANG)>0){ cout<<"wait child success: "<<id<<endl; } cout<<"child is quit! "<<getpid()<<endl; } int main(){ signal(SIGCHLD,handler); pid_t cid; if((cid=fork())==0){ //child cout<<"child : "<<getpid()<<endl; sleep(3); exit(1); } while(1){ cout<<"father proc is doing something!"<<endl; sleep(1); } return 0; }
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例(一个是系统的,一个用户语言级别,宏定义实际为1)。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
#include<iostream> #include<signal.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> using namespace std; void handler(int sig){ pid_t id; //注意,不能只处理一次,因为很可能同时有多个子进程都结束了,并发送了信号 //但递达只会递达一次,所以必须用while来一次性全处理掉 while(id=waitpid(-1,nullptr,WNOHANG)>0){ cout<<"wait child success: "<<id<<endl; } cout<<"child is quit! "<<getpid()<<endl; } int main(){ //signal(SIGCHLD,handler); signal(SIGCHLD,SIG_IGN); pid_t cid; if((cid=fork())==0){ //child cout<<"child : "<<getpid()<<endl; sleep(3); exit(1); } while(1){ cout<<"father proc is doing something!"<<endl; sleep(1); } return 0; }
![]()
别管grep进程。
我们可以发现,这次我们处理动作手动设置为SIG_IGN,子进程会自动退出。


signum就是信号编号或者传信号名字也行(宏定义),比如前面的2号信号。handler就是表示更改信号的处理动作,收到对应信号后,回调执行handler函数。返回值是更改前的处理方法

















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























7870

被折叠的 条评论
为什么被折叠?



