我们在执行像如下的死循环打印的程序时,如果我们想要终止这个进程,我们可以在键盘中按Ctrl + C,然后就可以终止当前前台进程了,那么为什么可以通过这种方式来终止进程呢?
其实当我们在键盘中按Ctrl + C时,此时键盘输入会产生一个硬件中断,这个中断会被操作系统获取,并且解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。Ctrl + C表示的就是2信号。
下面我们来验证Ctrl + C就是给进程发送的2信号。我们看到当进程运行时,我们通过kill命令给该进程发送2信号,该进程也终止了。
注意:
Linux下的信号:
那么除了2信号,Linux中还有什么信号呢?我们可以使用kill -l命令来查看Linux中的信号。我们通常将[1,31]信号称为普通信号,而[34,64]信号称为实时信号。
//查看信号列表
kill -l
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define SIGINT 2
信号名定义路径:/usr/include/bits/signum.h
下面是[1,31]信号表示的意思。
信号编号 | 信号名 | 信号含义 |
---|---|---|
1 | SIGHUP | 如果终端接口检测到一个连接断开,则会将此信号发送给与该终端相关的控制进程,该信号的默认处理动作是终止进程。 |
– | – | – |
– | – | – |
– | – | – |
在学习产生信号之前,我们需要先了解当信号发送给进程后,进程是怎样保存信号信息的。实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,在Linux中进程控制块是内核数据结构task_struct。而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。其中比特位的位置代表信号的编号,而比特位的内容就代表是否收到对应信号,比如第6个比特位是1就表明收到了6号信号。
当一个进程收到信号,本质就是该进程的内核数据结构task_struct中的信号位图被修改了,而内核数据结构只能通过操作系统来进行修改,因为操作系统是进程的管理者。所以信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。
信号处理常见方式:
在Linux中,我们可以通过man手册查看各个信号默认的处理动作。
//查看信号的信息
man 7 signal
当我们执行死循环程序时,我们可以通过Ctrl + C来终止进程,其实我们还可以使用Ctrl + \来终止进程。按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。
下面我们来使用signal函数验证Ctrl + C向进程发送2信号,Ctrl + \向进程发送3信号。
我们先来学习signal系统调用的使用。我们上面讲了信号处理常见方式有默认、忽略、自定义,而signal函数就是我们自定义一个进程收到某个信号后的处理动作的,signal函数的第一个参数就是要自定义处理动作的信号,第二个函数就是设置对应信号的处理方法。可以看到sighandler_t类型是一个函数指针,这个指针指向一个参数为int没有返回值的函数。所以在signal函数中采用回调函数的方式来将某一个信号的处理方法进行重写。并且在signal函数中调用handler函数时会将signum作为handler的参数传进去。
下面我们自定义信号2的处理函数。下面的代码中我们使用signal函数自定义了2信号的处理动作为catchSig函数,此时当这个进程收到2信号时,就不会执行默认的处理动作了,而是执行我们修改后的处理动作,即调用catchSig函数。signal函数修改进程对特定信号的后序处理动作一般都写在程序的开头,表示事先声明下面的程序中当遇到某个信号时,不会再执行默认的处理动作,而是执行修改后的处理动作。
我们看到此时当按Ctrl + C时向进程发送2信号就不会终止进程了,因为我们修改了该进程对2信号的处理动作为调用catchSig函数。此时我们想要终止进程可以按Ctrl + \。
下面我们将进程收到3信号的处理动作也修改为调用catchSig函数。然后当进程运行后,我们按Ctrl + C时,可以看到进程收到了2信号,我们按Ctrl + \时可以看到进程收到了3信号。此时我们可以使用kill -9 pid命名来杀掉该进程。
那么2信号和3信号都是终止进程,它们之间有什么不同呢?
我们看到2信号SIGINT的默认处理动作为Term,3信号SIGQUIT的默认处理动作为Core。SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core DumpT即erm和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储(Core Dump)。
那么什么是核心转储?
核心转储(Core Dump)就是当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。
所以在云服务器上,一般核心转储的功能都是被默认关闭的,我们可以通过ulimit -a 命令来查看当前资源限制的设定。
ulimit -a
我们看到当前服务器中允许产生的core文件的大小为0,我们可以使用 ulimit -c 1024命令改变Shell进程的Resource Limit,允许core文件最大为1024K。这个命令设置了本次登录中允许core文件最大为1024K,当下一次登录时允许core文件的最大值还是为0。
ulimit -c 1024
当我们设置好了核心转储后,此时执行下面的死循环进程,然后我们向这个进程发送3信号终止该进程,可以看到生成了一个core文件。而当我们向这个进程发送2信号终止该进程时,没有生成core文件。
注意:
ulimit命令改变的是Shell进程的Resource Limit,但test02进程的PCB是从父进程Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。
那么生成的core文件有什么用呢?
我们知道进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,而事后我们可以用调试器检查core文件以查清错误原因,这种调试方式叫做Post-mortem Debug(事后调试)。下面我们来演示怎么使用core文件来进行调试。
下面我们写一段代码,这段代码中有一个除0错误,而除0错误操作系统会向进程发送8信号,表示出现浮点错误,并且8信号的默认处理方式为Core类型,也会生成core文件。
我们编译时生成debug版本的程序,然后我们使用gdb进行调试,我们输入core-file core.26499命令,可以看到gdb中直接跳到了出现错误的地方。所以核心转储可以在进程出现某种异常的时候,将当前进程在内存中的相关核心数据转存到磁盘中,这样在调试时就可以使用生成的core文件来进行调试了。
core-file core.26499
我们在前面学习进程等待时,当时我们了解了wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。status按照比特位的方式,将32个比特位进行划分,用来保存不同的信息。我们当时学习的status的低16位中,知道了8-15位是保存子进程退出码的,而第7位core dump我们当时没有讲解,其实status的第7位就是记录这个等待的进程在退出时是否进行了核心转储。
下面我们验证进程等待中的status的core dump标记位。
我们看到当创建的子进程生成core文件后,父进程中接收到的status的core dump位变为1。
我们将子进程打印自己的pid,然后睡眠100秒,这期间我们使用kill 命令向子进程中发送2信号,因为2信号不会生成core文件,所以我们看到父进程中的status的core dump位为0。
下面我们将生成的core文件的大小改为0,即代表关闭了核心转储功能,此时当进程收到8信号时,就不会生成core文件了,并且父进程中的status的core dump位也为0。
为什么服务器中默认关闭生成core文件的功能呢?
这是因为core文件中可能包含用户密码等敏感信息,不安全。并且当一个进程异常退出时就会生成core文件,而如果程序一直挂掉又启动的话,那么就会在磁盘中生成大量的core文件。
用户除了可以使用键盘上的Ctrl + C等按键向前台进程发送信号外,还可以在代码中调用系统调用函数来向进程发送信号。下面我们来看kill系统调用函数。
kill
kill系统调用函数的第一个参数为进程pid,即要向哪个进程发送信号,就填哪个进程的pid。
第二个参数为sig,即要发送的信号。
其实我们前面在命令行中输入kill -2 pid命令时,底层调用的就是kill系统调用。
下面我们来模拟实现一个kill程序。
我们看到使用test04程序成功向test01程序发送了3信号终止了test01程序。
raise
我们还可以在程序中调用raise函数来让操作系统给当前进程发送一个信号。
arise函数只有一个参数sig,这个参数就是该进程想让操作系统发送给自己的信号。如果信号发送成功,arise返回0,否则返回一个非零值。
下面我们让一个进程在睡眠2秒后调用raise函数让操作系统向自己发送一个3信号来终止自己。
abort
abort函数是一个无参数无返回值的函数。我们看到abort函数默认向当前进程发送一个SIGABRT信号,即6信号。
下面我们改变当前进程收到6信号后的默认处理方式,将当前进程收到6信号后调用handler函数。但是我们看到与之前不同的是,test06进程还是退出了。这是因为abort函数的作用是异常终止进程,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的,所以我们就是修改了SIGABRT信号的默认处理方式,该进程还是会终止。那么我们就知道了调用abort函数一定可以将进程终止。
SIGPIPE信号
SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。
下面代码当中,创建匿名管道进行父子进程之间的通信,其中父进程是读端进程,子进程是写端进程,但是一开始通信父进程就将读端关闭了,那么此时子进程在向管道写入数据时就会收到SIGPIPE信号,进而被终止。
SIGALRM信号
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动
作是终止当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
下面我们使用alarm定时器来验证1秒内CPU会进行多少次count++。
我们先调用alarm设置一个1秒的闹钟,当1秒后alarm会向当前进程发送SIGALRM信号,而SIGALRM信号的默认处理方式就是终止进程。我们看到测试的结果为CPU每秒才进行了不到两万次++运算,这肯定是不准的,这是因为这个测试方法每一次++都进行了打印,而打印就需要IO操作,并且因为是云服务器,所以还会有网络的影响。
下面的代码中我们修改了SIGALRM信号默认的处理方式,当进程收到SIGALRM信号时会打印count的值,这样我们就准确的算出了1秒内CPU计算++的次数。并且每当我们向该进程发送一个14信号时,就会调用一次catchSig函数打印count的值。
当我们需要间隔一定的时间执行一件事时,我们就可以在闹钟触发后再定一个新的闹钟。下面我们写一个间隔一定时间就打印日志的一个程序来体会alarm函数的应用。
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
typedef function<void ()> func;
vector<func> callbacks;
uint64_t count = 0;
void catchSig(int signum)
{
for(auto &f : callbacks)
{
f();
}
alarm(2);
}
//打印日志函数
void showCount()
{
cout<<"final count: "<<count<<endl;
}
void showLog()
{
cout<<"这个是日志功能"<<endl;
}
void logUser()
{
if(fork()==0)
{
execl("/usr/bin/who","who",nullptr);
exit(1);
}
wait(nullptr);
}
int main()
{
signal(SIGALRM,catchSig);
alarm(2);
callbacks.push_back(showCount);
callbacks.push_back(showLog);
callbacks.push_back(logUser);
while(true)
{
count++;
cout<<"进程还在运行"<<endl;
sleep(1);
}
return 0;
}
我们看到每隔2秒程序就会打印以下的信息,并且不会影响程序的正常运行,程序还是正常执行。这就是一种定时任务。
当通过软件条件给进程发送信号时,操作系统都做了什么呢?
操作系统先识别到某种软件条件触发是否满足,如果满足操作系统就构建信号,然后将信号发送给指定的进程。
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。
下面我们写一段代码产生了除0的异常,我们发现程序会一直循环打印。但是上面我们使用abort函数时,虽然改变了6信号的默认处理方式,但是程序还是会因为异常而退出,而当除0出现异常时,为什么程序不会因为异常而退出呢?
我们知道,CPU当中有一堆的寄存器,当我们需要对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等。而操作系统是软硬件资源的管理者,在程序运行过程中,若操作系统发现CPU内的某个状态标志位被置位,而这次置位就是因为出现了某种除0错误而导致的,那么此时操作系统就会马上识别到当前是哪个进程导致的该错误,并将所识别到的硬件错误包装成信号发送给目标进程,本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。但是因为寄存器中的异常一直没有被解决,并且也没有办法解决。所以操作系统会一直检测到这个异常,然后给进程发8信号,这样进程就会一直收到8信号而一直执行handler函数。
那对于下面的野指针问题,或者越界访问的问题时,操作系统又是如何识别到的呢?
首先我们需要知道的是,当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。
其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。
当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。
而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。
下面我们来验证当出现野指针或越界访问时,操作系统发送的信号为SIGSEGV信号,即11信号。
**总结: **
通过上面的分析,我们可以总结出所有的信号都有它的来源,但是最终全部都会被操作系统给识别到,然后操作系统对这些信号进行解释,并发送给对应的进程,即修改对应进程的task_struct内核结构体中的数据。
在每个进程的task_struct中都记录了这样的两个位图结构和一个函数指针数组,pending位图结构里面的每一位就记录了对应信号是否为未决状态。block位图结构记录了对应信号是否屏蔽。handler为一个函数指针数组,里面记录了对应信号的处理动作。前面我们使用signal函数修改信号的默认处理动作的过程其实就是将自己写的sighandler函数的地址放入到handler函数指针数组中。
我们前面说了信号的处理有三种,分别为忽略、默认、自定义。操作系统向一个进程发送一个信号就是修改这个进程的pending位图里的内容,当进程处理信号时会先去pending位图中查找哪些信号需要被处理,当发现比特位为1的信号后,然后去block位图中查看当前信号是否被阻塞,block中为0表示没有被阻塞,此时才会来到handler函数指针数据中执行对应信号的处理动作。在得到信号编号signum后,并不会直接执行handler数组里面的对应函数,而是先将函数进行强转,如果结果为0,表示执行这个信号的默认动作;如果结果为1,表示忽略这个信号;只有结果不为0或1时,才会执行这个信号的自定义动作。
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
我们知道在每个语言中都会给我们提供.h、.hpp和语言自定义的类型,以便我们使用该语言进行一些操作。那么操作系统也给我们提供了一些.h头文件和操作系统自定义的类型,而sigset_t就是操作系统提供的一种自定义类型。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include
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);
sigpending
我们可以使用sigpending系统调用来获得当前进程的pending信号集。sigpending系统调用读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。返回值:若成功则为0,若出错则为-1.
如果set是空指针,oldset是非空指针,则读取进程的当前信号屏蔽字通过oldset参数传出。如果set是非空指针,则更改进程的信号屏蔽字为set,参数how指示如何更改。如果oldset和set都是非空指针,则先将原来的信号屏蔽字备份到oldset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
选项 | 操作 |
---|---|
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask | set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask=set |
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
下面我们来使用上面的函数验证一些问题,首先我们思考一下,如果我们对所有的信号都进行了自定义捕捉,那么我们是不是就写了一个不会被异常或者用户杀掉的进程?
我们写如下的程序,将1-31的普通信号都自定义捕捉。
我们向当前进程发送信号时,虽然我们对9号信号进行了自定义捕捉,但是发现发送9号信号时还是杀掉了当前进程。这是因为操作系统规定了SIGKILL信号不能被捕捉。
下面我们演示将2号信号block,并且不断的获取并打印当前进程的pending信号集,然后我们突然发送一个信号,我们就能看到pending信号集中,2号信号对应的比特位由0变为1。
#include
#include
#include
#include
using namespace std;
void catchSig(int signum)
{
std::cout<<"获得了一个信号:"<<signum<<endl;
}
static void showPending(sigset_t &pending)
{
for(int sig = 1; sig<=31; sig++)
{
//调用sigismember函数检查pending信号集中是否有指定信号。
if(sigismember(&pending,sig))
{
cout<<"1";
}
else
{
cout<<"0";
}
}
cout<<endl;
}
int main()
{
//1. 定义信号集对象
sigset_t bset,obset;
sigset_t pending;
//2. 初始化信号集对象
//将上面的信号集对象的比特位都置为0
sigemptyset(&bset);
sigemptyset(&obset);
sigemptyset(&pending);
//3. 添加要进行屏蔽的信号
//此时bset信号集的2号信号的比特位为1
sigaddset(&bset,2); //也可以传入2信号的宏定义SIGINT
//4. 设置set到内核中对应的进程内部(默认清空进程不会对任何信号进行block)
//使用sigprocmask系统调用接口来设置当前进程的信号屏蔽字为bset。
int n = sigprocmask(SIG_BLOCK, &bset, &obset);
assert(n == 0);
(void)n;
cout<<"block 2 信号成功。"<<endl;
//5. 重复打印当前进程的pending信号集
while(1)
{
// 5.1 获取当前进程的pending信号集
sigpending(&pending);
// 5.2 调用showPending函数显示当前进程的pending信号集中没有被递达的信号
showPending(pending);
sleep(1);
}
return 0;
}
当向当前进程发送一个2号信号时,我们看到了当前进程的pending信号集中2号信号对应的比特位变为了1,但是因为当前进程将2号信号屏蔽了,所以2号信号永远处于未决状态,而永远不会被递达。
下面的程序中,在20秒后就解除对2号信号的屏蔽。然后我们在这之前向当前进程发送一个2号信号,我们看到当前进程的pending信号集的2号信号的比特位变为1了,但是当解除了2号信号的屏蔽后,当前进程马上被终止了。这是因为默认情况下,恢复对于2号信号的block的时候,2号信号会进行递达,但是2号信号的默认处理动作是终止进程,所以当前进程才被终止了。
下面我们对2号信号进行自定义捕捉,然后当我们向当前进程发送2号信号时,我们看到当前进程的pending信号集中2号信号对应的比特位变为1,而当解除对2号信号的block后,2号信号马上被递达,执行了自定义处理动作,然后当前进程的pending信号集中的2号信号对应的比特位变为0。
我们通过上面的测试发现可以通过操作系统提供的sigpending接口来获取当前进程的pending信号集,但是操作系统好像没有提供一个接口来改变当前进程的pending信号集。这是因为前面我们学习的所有的信号发送方式都是修改当前进程pending信号集的方式。
下面我们封装了一个blockSig接口用来将传入的信号进行屏蔽。然后我们将1-31号信号都进行屏蔽。
然后我们写一个shell脚本,用来向当前进程发送信号。
我们看到当向进程发送9号信号时,当前进程还是被杀掉了,这说明9号信号不能被屏蔽。
然后我们将shell脚本中向进程发送信号时,跳过9号信号。
然后我们看到当shell脚本向进程发送19号信号时,当前进程被暂停了,这说明19号信号也无法被屏蔽。
然后我们将shell脚本中向进程发送信号时,跳过9号信号和19号信号。
我们看到这次进程没有被终止或暂停了,但是我们看到当前进程的pending信号集中18和19的位置都为0了,这是因为18信号和19信号类似。
通过上面的测试我们知道了:
有两种信号不能被屏蔽:SIGKILL(9)、SIGSTOP(19)。
有两种信号不能被捕捉:SIGKILL(9)、SIGSTOP(19)。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
我们知道每一个进程都有自己的进程地址空间,这个进程的地址空间由内核空间和用户空间组成,进程的地址空间中的用户空间采用用户级页表映射到物理内存中,内核空间采用内核级页表映射到物理内存中。
内核级页表是一个全局的页表,只有一份,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。所以在物理内存的不同区域有每个不同进程的代码和数据,但是只有一份操作系统的代码和数据,所有进程共享这一份操作系统的代码和数据。
那么为什么要这样设计呢?这是因为出于安全考虑。
在计算机系统中将指令分为特权指令和非特权指令。
在具体实现上,将CPU的运行模式分为用户态(目态)和核心态(管态、内核态)。可以理解为CPU内部有一个小开关,当小开关为0时,CPU处于内核态,此时CPU可以执行特权指令,切换到用户态的指令也是特权指令。当小开关为1时,CPU处于用户态,此时CPU只能执行非特权指令。而这个小开关就是CR3寄存器。应用程序运行在用户态,操作系统内核程序运行在内核态。应用程序向操作系统请求服务时通过使用访管指令,从而产生一个中断事件将操作系统转换为内核态。
所以当一个进程想要切换到另一个进程时,此时需要执行操作系统内核程序,那么当前进程就会通过产生一个中断来进入内核态执行操作系统内核程序。即在当前进程的进程地址空间中的内核空间中找到操作系统内核程序,然后执行转换进程的程序,将当前进程的代码和数据从CPU中撤下来并且将当前进程的寄存器数据都保存(进程上下文),然后换上另一个进程的代码和数据。
我们上面讲的进程收到信号后并不是立即处理信号,而是在合适的时候,其实就是在从内核态切换为用户态的时候。
从用户态切换为内核态通常发生在下面的几种情况中:
(1). 需要进行系统调用时。
(2). 当前进程的时间片到了,进行进程切换。
(3). 产生中断(外中断)和异常(内中断)时。
从内核态切换为用户态有如下的几种情况:
(1). 系统调用返回时。
(2). 进程切换完毕。
(3). 异常和中断处理完毕。
其中,CPU由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时候,本质上是因为我们需要执行操作系统的内核程序,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。
内核如何实现信号的捕捉
当我们在执行主控制流程的时候,可能因为某些情况而让CPU陷入内核,当CPU在内核态执行完内核程序后准备切换为用户态时,此时会检查当前进程的pending信号集,即会在这个时候进行当前进程的信号处理。因为此时CPU还没有退出内核态,所以有权利查看当前进程的pending信号集。
在查看当前进程的pending信号集时,当发现有处于未决状态的信号,并且这个信号没有被阻塞,那么此时就需要对该信号进行处理。
如果处于未决状态的信号的处理动作是默认或者忽略,则执行该信号的处理动作后将pending信号集中对应的标志位置为0。如果没有新的信号要递达后,那么CPU就会返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。
如果处于未决状态的信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么执行该信号的处理动作时需要先切换为用户态执行用户提供的自定义处理动作,执行完后再通过特殊的系统调用sigreturn切换为内核态,然后修改当前进程的task_struct内核数据结构中的pending信号集,将处理完的信号的标志位置为0。如果没有其它信号要进行递达,那么就再次切换为用户态,并且返回到主控制流程中上次被中断的地方继续向下执行。
下面我们通过一个例子来体会这个过程。如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
我们可以使用下面的图片来总结当待处理信号是自定义捕捉时的情况。
这个图中的绿色点就表示在内核态时检查未决信号的过程。而4个蓝色的点就表示状态的切换,箭头方向就代表着此次状态切换的方向。
那么当识别到信号的处理动作是用户自定义的时,能不能直接在内核态执行用户自定义函数呢?
理论上是可以的,但是因为内核态中执行的都是内核程序,如果允许在内核态执行用户代码的话,那么可能用户代码会有一些非法操作破坏系统,所以出于安全考虑在内核态时不能执行用户代码。
我们前面学习了调用signal系统调用接口来自定义捕捉信号,操作系统还提供了sigaction系统调用接口来进行自定义捕捉信号。sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oldact传出该信号原来的处理动作。act和oldact指向sigaction结构体。在这里sigaction接口和sigaction结构体的名字相同。
下面我们使用sigaction接口来自定义捕捉2号信号。
我们在调用sigaction接口时一定需要创建一个sigaction结构体对象当作第二个参数,并且这个结构体对象需要进行一系列初始化,至于第三个参数我们如果不想要接收当前信号旧的处理动作,就可以设置为nullptr。
因为上面的代码中用到了强转,会出现精度问题,所以在编译时使用 -fpermissive 选项忽略精度问题。
我们看到使用sigaction接口成功自定义捕捉了2号信号,并且我们看到2号信号原来的处理动作是默认。
下面我们将2号信号的处理动作在代码开始就设置为忽略,然后我们看到sigaction接口返回的oact中2号信号的旧的处理动作就为忽略了。
我们知道自定义捕捉信号会执行用户自定义的处理动作,那么当在执行自定义动作的时候,此时又来了同样的信号,操作系统该如何处理呢?
答:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前信号处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
下面我们来验证一下上面的结论。
当我们自定义捕捉到2号信号后,会执行handler函数,在handler函数中我们sleep(10),在这期间如果操作系统再次收到2号信号,那么此时2号信号是被屏蔽的,所以不会进行递达。只有当执行完handler函数后操作系统才会递达下一个信号。
我们看到当向进程发送2号信号后,再发送2号信号时,此时2号信号已经被屏蔽了。
下面我们来通过显示当前进程的pending信号集来看到2号信号被阻塞了。
我们看到当执行当前的2号信号时,此时如果再次发送2号信号给这个进程,这个进程的pending信号集是会记录当前信号的,但是并不会马上执行对应的处理动作,而是等执行完当前信号的处理动作后再执行新的信号的处理动作。
设置sa_mask,在处理2号信号的期间也屏蔽其它信号
我们看到在执行2号信号的处理动作期间,向进程发送3、4、5号信号,都被操作系统屏蔽了。但是当2号信号的处理动作执行完后,当前进程马上被终止了,这是因为3号信号的默认处理动作是终止进程,所以当执行完2号信号的处理动作后,执行3号信号的处理动作终止进程了。
前面我们学习数据结构时知道了单链表头插结点,分为两步操作,第一步将新结点的next指针头结点,第二步将head指针指向新的头结点。
下图的情况中main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
答:这是因为两次调用函数都建立了栈帧,表明上看是访问的同一个局部变量,实际是各种的函数访问各自栈帧中的局部变量。
如果一个函数符合以下条件之一则是不可重入的:
下面我们使用一个例子来体会volatile关键字的用处。
我们写一个下面的程序,程序执行时flag为0,此时程序会一直死循环。然后我们向进程发送一个2号信号,进程执行2号信号的自定义处理函数changeFlag,在changeFlag函数内将flag改为1,那么main主控制流程中就不满足while循环条件了,然后会执行下面的语句进行打印。我们看到该程序的执行结果如下,和我们分析的一致。
然后我们在makefile文件中,使用g++编译器编译mysignal.cc文件时,我们使用g++编译器的优化选项,O1、O2、O3即为g++编译器的优化选项,O3的优化级别最高。
然后我们重新编译mysignal.cc文件,再次执行这个程序时,我们向进程发送2号信号,但是此时进程的结果却变了。这就是因为g++编译器做的优化出了问题,导致我们程序的结果和预期的不一致。
下面我们来分析出现这种现象的原因。
当我们不使用g++的优化选项来编译代码时,生成的可执行程序中当需要用到flag变量时会去内存中读取flag变量的值,所以当在changeFlag函数中改变了flag变量的值为1后,此时内存中flag的值就变为1了,然后程序执行到while循环中时用到了flag变量,所以会去内存中取flag的值,取回来为1。经过判断后发现不满足while循环条件,然后执行下一条语句。
当我们使用g++的优化选项来编译代码时,在编译阶段g++编译器看到程序下面的代码中都没有改变flag变量的值,所以就采用优化,将flag变量的值存到edx寄存器中,然后编译生成的可执行程序中想要使用flag变量时,就去edx寄存器中取值,这样访问flag变量的值就不需要访问内存了,就提高了程序的效率。但是当我们执行程序时,向进程发送一个2号信号,然后changeFlag函数中内存中的flag变量的值改为1,但是edx寄存器中的flag的值并没有改变,这就存在了数据二异性的问题。所以在while循环判断时用到flag变量的值时,不会去内存中取flag变量的值,而是去edx寄存器中取到flag变量的值为0,所以还满足while循环的条件,然后就会一直进行while循环。
所以当我们使用g++编译器的优化选项进行编译时,可能会出现使CPU无法看到内存的情况。如果我们不想让flag变量被编译器经过上面的优化,我们就可以使用volatile关键字修饰flag变量,这样就保持flag变量的内存可见性了。
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
前面学习进程时我们知道用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。其实子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,即向父进程发送SIGCHLD信号,当父进程收到SIGCHLD信号就知道子进程终止了,然后父进程在信号处理函数中调用wait清理子进程即可。
下面我们验证子进程退出时会向父进程发送SIGCHLD信号。
我们将父进程中自定义捕捉SIGCHLD信号,然后使用fork接口创建一个子进程,然后让子进程退出,这样父进程就会收到SIGCHLD信号而执行handler函数。我们看到程序运行的结果和我们预期的是一样的。