进程信号
1、信号预备
1.1、信号概念
我们现实生活中存在哪些信号呢?
比如:信号弹、上下课的铃声、求偶、红绿灯、狼烟、旗语、发令枪、冲锋号、闹钟等都是信号。
a、你如何认识这些信号呢?有人教我的,我记住了。所以你能识别信号,并且知道这些信号的处理方法。
b、那么即便是现在没有信号产生,我也知道信号产生之后我该做什么。
c、信号产生了,我们可能立即处理信号,也可能不会立即处理信号,可能要等到合适的时候才会处理信号,因为我们在做更重要的事情。所以信号产生后,到信号被开始处理时存在一个时间窗口,在这个时间窗口内,你必须记住信号到来。
那么进程也是如此:
1、进程必须识别信号,并且能够处理信号。对于进程来说,哪怕信号没有产生,也要具备处理信号的能力——属于进程内置功能的一部分。
2、当进程收到了一个具体的信号,进程可能不会立即处理这个信号。而是等待合适的时候才会处理。
3、进程当信号产生到信号开始被处理,存在一个时间窗口,所以进程需要具备保存哪些信号已经发生的能力。
信号的处理方式有三种:1、默认动作。2、忽略。3、自定义动作。
自定义动作我们又称为信号的捕捉。
1.2、硬件中断
下面写一个简单的程序:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while (true)
{
printf("I am process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

运行程序,当我们在键盘输入ctrl + c之后,我们发现进程就终止了。这是因为进程收到了信号,被信号所杀,这个信号是2号信号。
当我们以后台进程的方式运行:

我们发现ctrl c不再起作用了,但是我们可以输入命令,如上图输入命令ls,bash会执行命令并把结果回显。
在Linux中,一次登录中有一个终端,一个终端一般配上一个bash。每一个登录只允许有一个进程是前台进程,但允许多个进程是后台进程。
当我们以前台进程的方式运行myprocess,我们发现无法执行指令,这是因为此时bash变成后台进程了,我们输入的指令都给了myprocess。当我们以后台进程的方式运行myprocess,前台进程是bash,此时我们输入的指令给了bash,所以可以执行指令。那么此时要杀掉myprocess进程就需要使用kill -9给进程发送9号信号。
也就是说前台进程或后台进程决定了谁来获取键盘的输入。如果是前台进程就能获取键盘的输入。
那为什么当bash是前台进程的时候ctrl c杀不掉呢?这是因为bash内部肯定会对ctrl c进行处理的。而普通进程ctrl c就会收到2号信号,然后进程终止。
使用kill -l可以查看所有信号:

信号本质上就是一个数字,被定义成宏。如:#define SIGINT 2。
其中1-31号信号为普通信号,34-64号信号为实时信号,我们只考虑普通信号。
下面进行验证ctrl c是让进程收到了2号信号:

系统调用signal函数用于修改特定进程对于信号的处理动作,也就是信号的捕捉。第一个参数signum表示几号信号。handler是一个函数指针,指向返回值为void参数为int类型的函数,表示收到了signum信号后的自定义处理动作。
在下面的代码中,我们使用signal函数实现进程收到2号信号自定义动作:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
printf("get a signal: %d\n", signo);
}
int main()
{
signal(2, myhandler);
while (true)
{
printf("I am process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

myhandler的参数是信号的值,当我们输入ctrl c,进程就会执行myhandler函数,然后打印信息。所以验证了ctrl c之后进程会受到2号信号。
1、signal只需要设置一次,往后都有效。
2、信号的产生和我们自己代码的运行是异步的。
那么键盘数据是如何输入给内核的?ctrl c又是如何变成信号的?
首先键盘被摁下,肯定是操作系统先知道,那么OS如何知道键盘上有数据了?

外设不止有键盘,操作系统不会一个一个过去询问是否有数据,这样对于操作系统来说负担太大了。CPU上是有针脚的,当键盘有数据了,键盘就会通过中断单元向CPU发送硬件中断。但是还有其他外设,比如显示器、网卡等等,所以就有中断号的概念。比如我们规定键盘的中断号就是10,那么键盘上有数据了向CPU发送硬件中断,然后CPU寄存器上就存储了中断号10。操作系统得知CPU上的中断号后,就到中断向量表中寻找对应的读取键盘的方法,然后调用这个方法,将数据拷贝到操作系统内核的文件缓冲区,然后用户就可以通过struct file获取。
那么当我们起了后台进程myprocess,它会不断向显示器文件上打印,然后我们输入ls指令,键盘就会向CPU发送硬件中断,操作系统得知后调用读取键盘的方法将数据拷贝到文件的页缓冲区,同时给显示器文件缓冲区也拷贝一份,所以我们输入ls显示器上也能看到,因此显示器上看起来是混乱的,因为多进程访问同一个显示器文件,但是对应键盘文件里面的数据是只有ls的,所以bash可以获取然后执行,所以这是不乱的。那么当我们在输入的密码的时候我们发现是不会回显的,这时候就是不给显示器文件拷贝。
我们学习的信号,就是用软件的方式,对进程模拟硬件中断。

2、信号产生
2.1、键盘组合键
1、ctrl c可以产生2号信号,前面我们已经做过验证了。
2、ctrl \可以产生3号信号,下面我们用程序来验证:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
printf("get a signal: %d\n", signo);
}
int main()
{
signal(3, myhandler);
while (true)
{
printf("I am process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

我们发现2、3号信号都被可以捕捉,那么1-31号所有信号都可以被捕捉吗?我们写个程序来验证一下:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void myhandler(int signo)
{
printf("get a signal: %d\n", signo);
}
int main()
{
// signal(3, myhandler);
for (int i = 1; i <= 31; i++)
signal(i, myhandler);
while (true)
{
// printf("I am process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}



经过我们的测试,我们发现9号信号和19号信号是无法被捕捉的。9号信号是用来杀掉进程的,19号信号是用来暂停进程的。
不是所有的信号都可以被signal捕捉,比如9和19号信号。
2.2、kill命令
使用kill命令可以给进程发送信号,这个我们已经有使用过了,所以不再赘述。
2.3、系统调用

系统调用kill可以给进程发送信号,第一个参数就是要发送的进程pid,第二个参数表示信号。
下面我们基于kill系统调用实现kill指令:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;
void Usage(const std::string& str)
{
cout << "Usage:\n\t";
cout << str << " signo pid" << endl << endl;
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signo = stoi(argv[1]);
int pid = stoi(argv[2]);
int n = kill(pid, signo);
if (n < 0)
{
perror("kill");
exit(2);
}
return 0;
}


raise函数用来给当前进程发送信号,本质上就是kill(getpid(),sig),下面演示使用raise函数给进程发送2号信号,同时将2号信号进行捕捉。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
using namespace std;
void myhandler(int signo)
{
printf("get a signo: %d\n", signo);
}
int main()
{
signal(2, myhandler);
int cnt = 0;
while (1)
{
printf("I am a process, pid: %d\n", getpid());
if (cnt % 2 == 0)
{
raise(2);
}
cnt++;
sleep(1);
}
return 0;
}


abort函数用来终止进程,我们通过kill -l查看所有信号,发现对应的是6号信号,下面我们将6号信号捕捉,再次进行测试:
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/types.h>
using namespace std;
void myhandler(int signo)
{
printf("get a signo: %d\n", signo);
}
int main()
{
signal(6, myhandler);
int cnt = 0;
while (1)
{
printf("I am a process, pid: %d\n", getpid());
if (cnt % 2 == 0)
{
abort();
}
cnt++;
sleep(1);
}
return 0;
}

运行程序我们发现捕捉了6号信号,也执行了我们自定义的动作,但是进程还是终止了。
无论信号如何产生,最终一定是OS发送给进程的,因为OS是进程的管理者。
2.4、异常
我们知道除0或野指针进程会发生异常,而异常的本质就是进程收到了信号。除0错误是进程收到了8号信号,野指针是进程收到了11号信号。
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
cout << "div before:" << endl;
int a = 10;
a /= 0;
cout << "div after:" << endl;
return 0;
}

#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
cout << "div before:" << endl;
//int a = 10;
//a /= 0;
int* p = nullptr;
*p = 10;
cout << "div after:" << endl;
return 0;
}

可以看到,进程收到8号或11号信号后就终止了。
下面我们分别对8号和11号信号进行捕捉,先来看对8号信号进行捕捉的场景:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
signal(SIGFPE, handler);
cout << "div before:" << endl;
int a = 10;
a /= 0;
// int* p = nullptr;
// *p = 10;
cout << "div after:" << endl;
return 0;
}

我们发现捕捉后,确实执行了我们的自定义动作打印信息,但是一直死循环打印这条信息,这是为什么呢?
接下来对11号信号进行捕捉,然后在程序中发生野指针访问:

我们发现一样是陷入了死循环。
首先除0或野指针为什么会让程序崩溃呢?这个很好理解,因为除0或野指针被操作系统识别到了,然后发送对应的信号给进程,而进程对于8号或11号信号的默认处理动作就是终止进程。
那么操作系统是如何识别到除0或野指针错误的呢?

首先来看除0错误。CPU在调度进程的时候如何得知进程要执行的代码?是通过CPU内的寄存器eip/pc,该寄存器保存了该进程正在执行代码的下一行代码的地址。另外CPU内也还有其他寄存器,比如eax、ebx、ecx等用来进行临时计算或保存函数的返回值。那么当执行a/=0这行代码的时候,在CPU内部就会溢出,然后CPU内有个状态寄存器32位/64位,每一位代表着不同的状态,其中有一位是溢出标志位,当除零后溢出就会将该标志位置为1。然后操作系统就会识别到CPU内的状态寄存器溢出标志位为1,操作系统就会给该进程发送8号信号。因为操作系统是软硬件资源的管理者,所以对于CPU的状态它也要知道。
CPU内的寄存器只有一套,而进程是多个的,虽然我们修改的是CPU内部的状态寄存器,但是只会影响当前进程。因为我们之前说过,当进程时间片到了之后,就会将CPU内寄存器的数据保存带走,下次调度的时候再将自己的数据恢复。所以进程切换的时候要有:保存上下文、恢复上下文。所以并不会影响下一个进程。

再来看野指针的问题,当我们拿着地址经过页表去查找映射的时候,因为查找页表的效率还是太慢,所以会有MMU内存管理单元,一般会内置在CPU中,帮助进行虚拟地址向物理地址的转换。实际上,野指针会找不到映射关系,或者说该映射关系是只读的,但是你要进行写入。那么这时候就会转换失败,转换失败会将虚拟地址保存在CPU内的某个寄存器中。那么操作系统通过这个寄存器就能知道,然后给进程发送11号信号。
再来看我们之前的现象,当我们对8号信号进行捕捉之后,就会执行我们的自定义动作,所以会打印信息。但是当前进程的上下文的状态寄存器中溢出标志位为1,所以每次CPU调度的时候,操作系统都会识别到进程的溢出标志位为1,所以就会给进程发送8号信号,然后再把进程切走。但是进程是不断在调度的,因为我们捕获8号信号后并不会像原来默认动作那样终止进程,所以进程不断调度,操作系统就会一直给进程发送信号然后再切走,所以就会死循环打印。
2.5、软件条件
异常只会由硬件产生吗?
在之前进程间通信的时候,使用管道进行进程间通信,当读端关闭,写进程就会收到13号信号:SIGPIPE,然后终止。所以异常也可以由软件条件产生。
还有闹钟,使用alarm函数可以定一个闹钟,当时间到了会给进程发送信号。


参数为秒数,表示你定的闹钟几秒后响。返回值为你上一个闹钟还剩余多少秒触发,因为在现实生活中,我们定了闹钟也可能提前醒来。
使用alarm定一个3秒后的闹钟:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
alarm(3);
while (1)
{
printf("I am a process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

我们发现闹钟3秒后响了,然后进程终止了。这是因为闹钟响了会给进程发送14号信号:SIGALRM,默认动作就是终止进程。
下面我们对14号信号进行捕捉,验证进程是收到了14号信号:

我们发现确实进程收到了14号信号,并且该闹钟只响了一次,后面就不会再触发了。
下面我们演示定个50秒的闹钟,然后通过kill命令向进程发送14号信号,然后进程就会执行我们自定义动作,我们在handler中再定一个50秒的闹钟并获取它的返回值,将返回值打印出来,看看距离上一个闹钟触发还有几秒。
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
int n = alarm(50);
cout << "剩余时间:" << n << endl;
}
int main()
{
signal(14, handler);
alarm(50);
while (1)
{
printf("I am a process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

我们在右侧向进程发送14号信号,进程就会执行handler函数,然后再定一个闹钟,同时获取上一个闹钟剩余时间。
下面我们定一个每两秒就响一次的闹钟:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
int n = alarm(2);
cout << "剩余时间:" << n << endl;
}
int main()
{
signal(14, handler);
alarm(2);
while (1)
{
printf("I am a process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

2.6、core dump

在进程控制章节的进程等待中,使用waitpid可以获取子进程的退出状态,其中低7位标识进程的终止信号,第8位标识core dump标志,次低8位标识进程的退出码。
我们使用man 7 signal可以查看上图左侧的信号信息。Term表示的就是终止。Core也是终止,不过还会进行core dump。
我们发现2号信号SIGINT是Term,8号信号SIGFPE是Core。
下面通过代码来验证:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
pid_t id = fork();
if (id == 0)
{
while (1)
{
printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
cout << "create child process success" << endl;
int status = 0;
int ret = waitpid(id, &status, 0);
printf("father wait success, ret: %d, exit code: %d, exit signo: %d, core dump: %d\n",
ret, (status>>8)&0xff, status&0x7f, (status>>7)&1);
return 0;
}


我们给子进程分别发送2号和8号信号,发现两次的core dump标志位都是0,这是因为云服务器默认core dump是关闭的。
我们可以使用ulimit -a查看配置信息:

我们发现core file size是0,说明core dump功能是被关闭的,我们可以通过ulimit -c设置文件大小,就可以将该功能打开。

然后再次运行程序分别给子进程发送2号信号和8号信号,查看程序的输出结果:


可以看到当发送8号信号,这时候core dump标志位为1,并且查看当前目录,当前目录下多了一个core文件。
打开系统的core dump功能,一旦进程出现异常,操作系统会将进程在内存中的运行信息,给我dump(转储)到进程的当前目录,形成core文件。核心转储:core dump。
那么当程序运行出错时,我们是不是应该关心哪一行代码出问题了。所以core dump可以直接定位到出错行,下面我们演示:

我们在程序中发生了除0错误,进程收到8号信号,并且后面跟了core dumped。然后查看当前目录,发现多了一个core文件。
接着我们gdb mysignal进行调试,然后直接输入core-file core
我们发现可以直接定位到哪一行出错,在我们的程序中是第14行。
3、信号保存
3.1、什么是信号的发送
信号的发送本质就是给进程的PCB发送,因此进程PCB内部必定要有结构来保存信号。为什么要保存信号?因为进程收到信号之后可能不会立即处理这个信号。那么信号从发送给进程到开始被处理就会存在一个时间窗口,所以需要保存信号。
也就是说task_struct里面会有保存信号的结构,实际上这个结构是个位图,如下图:

通过位图来管理信号,上图的第一个位置为1,表示的就是进程收到了1号信号。
1、比特位的内容是0还是1,表明进程是否收到了信号。
2、比特位的位置(第几个),表示信号的编号。
3、所谓发送信号,本质就是操作系统去修改task_struct的信号位图对应的比特位——写信号。
因为操作系统是进程的管理者,所以只有它才有资格修改task_struct的内部属性。
3.2、阻塞信号

进程收到信号有三种处理方式:1、默认动作。2、忽略。3、用户自定义动作。在系统中会有一个函数指针数组用来存储对应信号的处理方法。当我们调用signal函数就是把我们自己提供的函数地址覆盖到这个数组对应的元素,所以当进程收到信号就会执行用户自定义动作。
我们使用signal函数的时候,第二个参数可以传我们自定义的函数指针,还可以传两个宏:SIG_DFL和SIG_IGN。其中SIG_DFL表示信号的默认处理动作,SIG_IGN表示忽略该信号。
下面演示忽略2号信号:
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
signal(2, SIG_IGN);
while (1)
{
printf("I am process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

再来看一下这两个宏是什么:

SIG_DFL实际上是对0这个地址强转成函数指针,SIG_IGN实际上是对1这个地址强转成函数指针。
1、实际执行信号的处理动作称为信号递达(Delivery)。
2、信号从产生到递达之间的状态称为信号未决(Pending)。
3、进程可以选择阻塞(Block)某个信号。
4、被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
进程中不仅有handler数组,还有pending和block两张位图,它们是存储在task_strut里面的:

block位图用来表示信号是否被阻塞,pending位图用来保存信号,handler指针数组表示信号的处理方式。
上图中进程的1号信号没有被阻塞,且没有收到1号信号,收到1号信号的处理方式为默认动作。
2号信号被阻塞了,进程当前收到了2号信号,对于2号信号的处理方式为忽略。
3号信号也被阻塞了,进程当前没有收到3号信号,3号信号的处理方式是用户自定义函数。
举个例子:如果进程1号信号没有被阻塞,那么进程收到1号信号pending表中的比特位就会置1,然后先清0然后再去执行handler表中的方法。进程的2号信号被阻塞了,那么进程收到2号信号pending表中的比特位会置1,不会信号递达,直到用户把block表中的信号比特位置0,此时信号不再阻塞,就会将pending表中的比特位清0然后执行handler表方法。
那么如果在阻塞的时候,我收到了多次的同一个信号呢?最后也只会被递达一次。如果是实时信号就得将次数统计下来,不过我们不考虑实时信号。
3.3、信号集操作函数
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。
sigset_t可以配合下面的函数来修改block表和获取pending表,但是不支持直接对sigset_t结构操作。

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
sigismember用来判断位图结构某个信号的标志位是否为1。

sigprocmask用来读取或更改进程的信号屏蔽字,成功返回0,失败返回-1错误码被设置。
how有三个可选值:
1、SIG_BLOCK:set里面包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask|set。
2、SIG_UNBLOCK:set包含了我们期望从当前信号屏蔽字解除阻塞的信号,相当于mask = mask&~set。
3、SIG_SETMASK:设置当前信号屏蔽字为set。相当于mask = set。直接覆盖。
第二个参数set就是你要设置的位图结构。第三个参数oset表示上一次信号屏蔽字的结构,如果你修改信号屏蔽字之前想获取信号屏蔽字就可以传参,如果不需要直接设置为nullptr即可。

sigpending函数用来获取pending表,set是作为输出型参数,将pending表带出。
综上:
1、使用sigprocmask可以设置信号屏蔽字。
2、使用sigpending可以获取pending表。
3、使用signal可以修改handler表。
下面代码演示:通过sigprocmask设置2号信号阻塞,然后通过sigpending获取pending信息打印,在进程未收到2号信号时pending表为全0,当进程收到2号信号可以看到pending表对应的比特位为1。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(const sigset_t& pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo)){
cout << 1;
}
else{
cout << 0;
}
}
cout << endl << endl;
}
int main()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset); // 添加到block表中
sigset_t pending;
while (1)
{
sigpending(&pending);
PrintPending(pending); // 打印pending表
sleep(1);
}
return 0;
}

继续修改代码,当循环五次后就恢复,2号信号不再阻塞,我们要看到进程收到2号信号并执行默认动作进程终止。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void PrintPending(const sigset_t& pending)
{
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo)){
cout << 1;
}
else{
cout << 0;
}
}
cout << endl << endl;
}
int main()
{
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset); // 添加到block表中
sigset_t pending;
int cnt = 0;
while (1)
{
sigpending(&pending);
PrintPending(pending); // 打印pending表
sleep(1);
cnt++;
if (cnt >= 5)
{
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
}
return 0;
}

那么如果我将所有信号都屏蔽了呢,这样进程是不是就杀不掉了?
在signal函数那边我们测试过将1-31号信号全都捕捉,我们发现9号和19号信号是无法捕捉的。
在这里也是一样的,9号和19号信号无法被屏蔽。大家可以自行验证。
4、信号处理
4.1、进程地址空间第三讲
信号是什么时候被处理的?
先给出结论:当我们的进程从内核态返回用户态的时候,进行信号的检测和处理。
内核态:允许你访问操作系统的代码和数据。
用户态:只能访问用户自己的代码和数据。
用户态->内核态:通过int 80这条汇编实现
我们调用系统调用的时候就会进入内核态。操作系统是会自动做身份切换的,用户身份变成内核身份或者反过来。

之前我们谈地址空间都是谈的用户空间,也就是0-3GB,而3-4GB表示内核空间。操作系统本质也是一个进程,在操作系统起来的时候,要将操作系统的代码和数据加载到物理内存中。同时每个进程的3-4GB空间都会通过内核级页表映射到物理内存中。当然其实可以不这么做,直接让其减去一个地址就可以,但是有些数据还是需要通过内核级页表映射的。
内核级页表只有一份,每个进程映射同一份即可。用户级页表有多份,有几个进程就有几份用户级页表,因为进程间具有独立性。
每个进程看到的3-4GB的东西都是一样的。整个系统中,进程再怎么切换,3-4GB空间的内容都是不变的。
现在执行系统调用函数直接从正文代码跳转到内核空间对应的位置去执行,执行完返回正文代码继续向后执行。跟之前调用库函数跳转到动态库去执行是一样的。
从进程视角:我们调用系统中的方法,就是在我自己的地址空间中进行执行的。
从操作系统视角:任何一个时刻,都有进程执行。我们想访问操作系统的代码,随时都可以执行。
操作系统的本质是:基于时钟中断的一个死循环。
计算机硬件中,有一个时钟芯片,每隔很短的时间会向计算机发送时钟中断。
操作系统起来后先进行一系列前置工作,然后就死循环:for (;;)pause(); 那么是谁推着操作系统走呢?就是时钟,时钟每隔一段时间通过CPU针脚发送硬件中断,CPU根据中断号去查中断向量表里面的方法,找到对应的方法然后操作系统就会去检查当前进程时间片是否到了,没到就返回继续死循环,到了就切走换下一个进程然后返回。
进程执行系统调用可以直接跳转到内核空间去,但是系统调用不是你想执行就能执行的,需要进行身份的转换即:用户态转换为内核态。在访问内核空间的代码前需要先变成内核态,才有资格访问。在CPU中有一个ecs寄存器,它的低两位组合有四种情况:00、01、10、11,其中11->3表示用户态,00->0表示内核态。所以跳转到内核空间执行前需要通过一条汇编语句:int 80——陷入内核。
4.2、信号何时被处理

当我们的进程从内核态返回用户态的时候,进行信号的检测和处理。
当我们执行代码时出现中断、异常或系统调用就会通过int 80将3->0身份转换为内核态,然后处理完操作系统中重要的事情后准备返回用户态之前进行信号的检测和处理,遍历pending表,如果为1查一下block表是否阻塞,阻塞了就不管继续往后找,找到发现没有阻塞且用户自定义了处理动作,这时候返回用户态执行用户自定义动作,执行完通过sigreturn再次进入内核,然后从内核中返回到上次停止的地方继续往后执行。
那么为什么不直接使用内核态去执行用户的自定义动作呢?从理论上来说是可以的,但是如果用户在自定义函数里面做一些非法的操作就会出问题,所以需要返回用户态再执行。
那么我们平时写的代码也没有系统调用啊?什么时候会进入内核态再从内核态返回呢?不要忘了当进行进程间切换的时候,必定是内核态将你进程的上下文数据恢复,然后再恢复成用户态继续执行代码。所以调度的时候就会存在内核态向用户态的转换,就可以进行信号的检测处理。
对于上图,有一个记忆的方法:

如图,一个无穷的符号。四个青色小圈表示身份的转换。首先先从用户态转换为内核态,然后再它们相交的地方做信号检测,然后返回用户态执行代码,执行完陷入内核态,然后再返回。
4.3、信号捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
上面我们已经介绍过signal函数的使用了,所以这里不再赘述,我们再看一个新的系统调用:


第一个参数signum表示信号编号,第二个参数的类型我们发现和函数名是一样的,这是一个输入型参数,sigaction里面我们只看sa_handler和sa_mask这两个成员,其他我们不考虑。sa_handler就是捕捉函数,sa_mask我们后面再谈。第三个参数oldact是输出型参数,表示将原来处理动作信息带出来,如果不需要可以设置为nullptr。
下面代码演示通过sigaction捕捉2号信号:
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
}
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
sigaction(2, &act, &oact);
while (1)
{
printf("I am a process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

问题:pending位图什么时候将比特位由1置0?
我们可以在信号捕捉函数中获取pending表并打印,当我们给进程发送信号,进程会执行handler函数,如果打印对应信号的比特位为1,说明进程是先递达再将pending表改为0。如果打印对应的比特位为0,说明进程是先修改pending表再进行信号递达。
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
using namespace std;
void PrintPending()
{
sigset_t pending;
sigpending(&pending);
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
cout << 1;
else
cout << 0;
}
cout << "\n\n";
}
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
while (1)
{
PrintPending();
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
memset(&act, 0, sizeof(act));
memset(&oact, 0, sizeof(oact));
act.sa_handler = handler;
sigaction(2, &act, &oact);
while (1)
{
printf("I am a process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}

运行程序我们发现会先将pending表置0,然后再进行信号递达。
我们在handler中进行了死循环打印,然后我们再次给进程发送2号信号,我们发现进程pending表对应的比特位又变成1了。
信号在被处理之前,会将对应信号添加到信号屏蔽字中,这样在进行信号处理的时候如果再收到该信号,就不会再执行该信号的处理动作,防止信号捕捉嵌套调用。直到该信号处理动作完成,才会将该信号从信号屏蔽字中移除。
因为在执行信号捕捉函数的时候,很有可能再次进入内核状态,比如进程切换,然后从内核返回用户态再次检测,发现又收到当前信号,那么就会继续调用,造成信号捕捉嵌套调用。所以阻塞后,如果在递达期间收到同个信号的多次,那么解除阻塞后也只会被递达一次。
那么如果我想在处理该信号的时候,不仅仅屏蔽该信号,还想屏蔽其他信号呢?
上面说的sigaction结构里面的sa_mask字段就是用来设置屏蔽其他信号的,这是一个sigset_t对象,我们可以调用信号集操作函数将想屏蔽的其他信号添加到sa_mask即可。
下面演示在进程收到2号信号执行自定义动作时,不仅屏蔽了2号信号,也屏蔽了我们自己添加的1、3、4号信号:
在main函数中加入:
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);

可以看到,不仅仅当前递达信号被屏蔽了,我们添加的1、3、4号也被屏蔽了。
5、可重入函数
先来回忆一下链表的头插,我们以不带头的链表为例,只需要两个步骤。假设链表头节点为head,要插入的新节点为p。那么头插就是:p->next = head;head = p;
下面看这种情况:

在main函数中我们调用insert函数进行链表的头插,起初状态为0,然后进入insert函数中执行完p->next=head之后状态为1,这时候进程收到了信号,而我们对信号进行了捕捉,执行sighandler函数,在sighandler函数内部我们再次调用了insert函数进行链表的头插,先执行p->next=head变成状态2,然后再修改head=p变成状态3,成功完成链表头插,返回sighandler函数继续往下执行,当sighandler执行完返回insert函数执行head=p变成状态5。
insert函数被main和handler执行流重复进入,我们称为insert函数被重入了。
在上面图中节点node2丢失,造成了内存泄漏。
如果一个函数被重复进入的情况下出错了,或者可能出错,我们称之为不可重入函数。反之我们称为可重入函数。
目前我们学习到的大部分函数都是不可重入的。
如果一个函数符合以下条件之一则是不可重入的:
1、调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2、调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
6、volatile关键字
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
using namespace std;
int flag = 0;
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
flag = 1;
}
int main()
{
signal(2, handler);
while (!flag);
cout << "process quit normal" << endl;
return 0;
}

这段代码我们定义了一个全局变量flag = 0,然后在main函数中我们对2号信号进行了捕捉,打印进程获取信号的信息并把flag改为1。接着main函数中是while死循环,判断条件为flag逻辑取反,最后打印进程退出信息。我们使用g++编译后运行程序,然后通过ctrl c给进程发送2号信号,打印了信息然后进程退出。这很正常,也符合我们的预期。
1、我们在main函数中只有在while循环判断内访问了flag变量,并且在后续并没有对flag进行修改。
2、while循环内判断flag逻辑取反,这个工作是在CPU上进行的,CPU进行算数运算和逻辑运算。
基于以上两点,并且在优化条件下,flag变量可能直接被优化到CPU内的寄存器中。
也就是flag变量会被保存到CPU的寄存器中,不会再到内存中去取。
但是我们上面运行结果并没有优化到寄存器中,所以我们可以给gcc加上-O优化选项:

下面我们添加上-O选项优化后再次运行程序:

优化后我们给进程发送2号信号,进程修改了flag为1,但是还是进行死循环,程序不会退出。这是因为flag被优化到CPU寄存器中,CPU寄存器的值为0,而我们修改的flag是内存上的值,内存上的值修改为1。但是这时候CPU不会到内存中去取了,所以死循环不退出。
因为优化,CPU寄存器和内存就产生了一道屏障,导致内存不可见。
我们可以给flag添加volatile关键字,防止编译器过度优化,保持内存的可见性。
volatile int flag = 0;

7、SIGCHLD信号
在进程控制我们讲过,父进程必须通过wait/waitpid等待子进程,解决子进程的僵尸问题。可以通过阻塞和非阻塞轮询的方式等待子进程,还可以获取子进程的退出信息(可选的)。
实际上,子进程退出的时候并不是悄无声息的,子进程退出的时候会给父进程发送17号信号:SIGCHLD。

下面进行证明:父进程创建子进程,子进程循环打印三次语句后退出,我们对父进程的17号信号进行捕捉,在捕捉函数中调用waitpid对子进程进行回收。
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void handler(int signo)
{
cout << "father get a signo: " << signo << endl;
pid_t rid = waitpid(-1, nullptr, 0);
if (rid > 0)
{
printf("father wait child success, pid: %d\n", rid);
}
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
int cnt = 3;
while (cnt--)
{
printf("I am child process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
while (1)
{
printf("I am father process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}

子进程在进行等待的时候,我们可以采用基于信号的异步的方式进行等待。
1、等待的好处:获取子进程的退出状态,释放子进程的僵尸。
2、虽然不知道fork之后父子进程谁先运行,但是一定是父进程最后退出。
采用基于信号的方式进行等待还是要调用wait/waitpid这样的接口,且父进程必须保持自己是一直在运行的。
那么如果我们有多个子进程呢?多个子进程同时退出,或者多个子进程退出的时候是不确定的,那么该如何处理呢?
因为多个子进程退出的时候,如果当前子进程退出父进程收到信号,进入handler函数调用waitpid回收子进程,会把17号信号阻塞了,那么回收的过程中其他进程如果退出会发送17号信号,如果只有一个进程退出还好,如果多个进程退出但是信号阻塞了当解除阻塞后只会递达一次,那么就会导致有的进程僵尸了无法被回收。
解决办法如下:在handler函数中while循环等待子进程,注意设置为WNOHANG避免阻塞等待。

#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
using namespace std;
void handler(int signo)
{
sleep(2);
pid_t rid;
while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
{
printf("father get a signo: %d, wait child success: %d\n", signo, rid);
}
}
int main()
{
srand(time(nullptr));
signal(SIGCHLD, handler);
for (int i = 0; i < 5; i++)
{
sleep(rand() % 3);
pid_t id = fork();
if (id == 0)
{
int cnt = 3;
while (cnt--)
{
printf("I am child process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
exit(0);
}
}
while (1)
{
printf("I am father process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}

还有一种办法回收子进程:
由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法,父进程调用signal或sigaction将17号信号SIGCHLD的处理动作设置为SIG_IGN,也就是忽略。这样子进程在终止时就会由操作系统自动清理掉不会产生僵尸进程。此方法对于Linux可用,对于其他UNIX系统不一定可用。
下面编写代码进行测试:
#include <iostream>
#include <cstring>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
using namespace std;
int main()
{
signal(SIGCHLD, SIG_IGN);
pid_t id = fork();
if (id == 0)
{
int cnt = 2;
while (cnt--)
{
printf("I am child process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
cout << "child process quit!!!" << endl;
exit(0);
}
while (1)
{
printf("I am father process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}

使用man 7 signal查看所有信号的信息:

我们发现SIGCHLD信号的默认处理动作为Ign忽略。
这里注意区分:
SIGCHLD的默认处理动作是忽略,这里是隐式忽略,不会自动回收子进程,子进程会变成僵尸状态,需要调用wait/waitpid对子进程进行回收。
设置SIGCHLD信号的处理动作为SIG_IGN:主动忽略,系统会自动回收子进程,子进程不会僵尸,父进程无需调用wait/waitpid来回收子进程。
1815

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



