1.内核态和用户态
前一篇问到信号的处理是否是立即处理的?在合适的时候才处理。
合适的时候是什么时候?这首先就要先看看什么是内核态和用户态:
1.1 内核态和用户态概念

- 用户态:正在执行用户层的代码,此时CPU的状态是用户态。
- 内核态:正在通过系统调用访问内核或者硬件资源时,此时CPU的状态是内核态。
用户为了访问内核或者硬件资源,必须通过系统调用才能完成访问。虽然系统调用是在我们的代码中写的,也就是用户在使用,但是具体的执行者是内核,也就是操作系统。现在是知道了什么是用户态,什么是内核态,但是操作系统是怎么知道当前进程的身份状态的呢?

CPU中的寄存器虽然只有一套,但是有很多,有可见寄存器,如eax,ebx等等,还有很多的不可见寄存器,凡是和当前进程强相关的,都属于当前进程的上下文数据。
如上图,有专门用来存放当前进程PCB指针的寄存器。也有专门存放当前进程页表指针的寄存器。
CR3寄存器:专门用来表征当前进程的运行级别的。
0:表示内核态,此时访问的是内核资源或者硬件。
3:表示用户态,此时执行的是用户层的代码。
操作系统是一个进行软硬件资源管理的软件,它很容易就可以获取到CPU中CR3寄存器中是0还是3,从而知道当前是用户态还是内核态。
执行系统调用时,执行者是操作系统,而不是用户。那么又存在一个问题,一个进程是怎么跑到操作系统中执行代码的呢?
对进程地址空间进行一个补充介绍:

我们之前一直所说的页表都是用户级页表,每个进程都有一个。
进程地址空间的大小一共有4GB,我们之前谈论的只有0~3GB,这3GB的空间属于用户空间,用来存放用户的代码,数据等。为了保证进程的独立性,每个进程都有一个进程地址空间,都有一个用户级页表。
还有一份内核级页表,所有进程共用一份。
进程地址空间中的3~4GB空间,是不允许用户访问的,因为这1GB空间中的数据等,通过内核级页表和内存中的操作系统相映射,属于内核级别的。因为内存中只存在一份内核,所以所有进程的虚拟地址空间的这1GB空间都通过同一份内核级页表和内存中的内核相映射。
每一个进程地址空间中的3~4GB的内容都是一样的,因为它们都通过同一个内核级页表和内存中的内核相映射。
还记得动态链接吗?通过代码段的位置无关码跳转到共享区从内存中映射过来的动态库来执行相应的方法。系统调用和它的原理一样:
- 当执行到代码段中的系统调用时,会在跳转到当前进程虚拟地址空间中的内核空间中。
- 系统调用的具体实现都放在这1GB的内核空间中。
- 然后根据内核级页表和内存中内核的映射关系实现内核的访问。
1.2 内核态和用户态转化
此时又有一个问题,为什么我们的代码中不能访问这3~4GB的空间,而系统调用就跳转到这1GB的内核空间中进行访问了呢?我们都是用户的代码啊?
因为从代码段跳转到内核空间中后,CPU中的CR3寄存器从3变成了0。意味着进程运行级别从用户态变成了内核态,也就是执行者从用户变成了操作系统,所以可以对这1GB的内核空间进行访问。
系统调用接口的起始位置,会将CR3寄存器中的数据从3变成0,完成从用户态向内核态的转变。
所以说,系统调用前一部分是由用户在执行,其余部分由操作系统执行。
此时再来理解信号处理的时机:从内核态返回到用户态 ,这句话的含义:必然曾经进入到了内核态,而进入内核态的方式很多,比如进程切换,只有操作系统才有权力将进程从CPU上剥离下来换上另一个进程。还有系统调用,等等方式。
以我们最熟悉的系统调用为例:

以黑色长线为界,上面是用户态,下面是内核态。
- 当执行到用户代码段中的系统调用时,会跳转到虚拟地址空间中的内核空间去执行具体的方法,此时从用户态变成了内核态。
- 当系统调用被操作系统执行完毕以后,在返回之前,操作系统会检测task_struct中block位图,pending位图,然后再根据handler中的处理方式去处理相应的信号。
- 如果是自定义处理方式,操作系统会拿着handler表中的函数地址,通过特定的系统调用去执行用户自定义的处理方式,此时从内核态变成了用户态。
- 在执行完自定义处理方式以后,再次回到内核中取系统调用得到的数据,此时再次从用户态变成了内核态。
- 拿上要取的数据以后,通过特定的系统调用返回到用户代码中系统调用的位置,再次从内核态变成了用户态。
上面过程的伪代码形式:

- 涉及到的系统调用无需详细了解,只需要知道是通过系统调用实现的即可。
上面过程中存在一个问题,在执行自定义处理方式的时候,为什么必须从内核态切换成用户态去执行用户定义的处理方式呢?不能直接以内核态的身份去执行吗?
不可以。理论上是绝对可以实现的,因为内核态比用户态高,高级别去处理低级别肯定是可以的。但是操作系统不相信任何人 ,如果自定义处理方式中有用户的**恶意代码,而此时又以操作系统身份去执行,那么就会导致问题。**所以必须得切换到用户身份去执行自定义处理方式才能保证系统的安全。
两个独立的流程:
此时就存在了两个流程**,一个是main函数所在的执行流程,一个是自定义处理方式的执行流程:**
- 在执行完系统调用后不是恢复main函数的上下文进行执行,而是执行用户自定义的处理方式。
- 自定义处理方式函数和main函数使用不同的堆栈空间,并且不存在调用和被调用的关系,是两个独立的控制流程。

上面整个过程可以看成一个无穷大符号加一条线,线的上边是用户态,下边是内核态。每经过一次黑线就会发生一次身份状态的改变,一共改变了四次。
(这个图面试能画出来并能讲解的话,加大分)
上面这种自定义处理方式是最复杂的情况,如果是SIG_DFL(默认处理方式)和SIG_IGN(忽略方式),以内核态身份就可以处理,然后就可以直接返回到用户代码中系统调用的位置,少了两次身份的转变。因为默认方式和忽略方式是被写入到操作系统中的,被操作系统所信任的方式。
- 默认处理方式:所有信号的默认处理方式都是结束进程,只是不同信号表示不同的异常。
- 忽略处理方式:忽略和阻塞不一样,忽略也是一种处理方式,它仅仅是将task_struct中的pending位图中对应信号的比特位清空,然后就直接返回到用户态了。
2.处理信号
内核如何实现信号的捕捉?再看这张图:

2.1 信号捕捉
如果信号的处理动作是用户自定义函数, 在信号递达时就调用这个函数, **这称为捕捉信号。**由于信号处理函数的代码,是在用户空间的,处理过程比较复杂。
举例如下:用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行 main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
内核决定返回用户态后不是恢复main 函数的上下文继续执行, 而是执行 sighandler 函数, ighandler和main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系, 是两个独立的控制流程。
sighandler函数返回后自动执行特殊的系统调用sigreturn 再次进入内核态。 如果没有新的信号要递达, 这次再返回用户态就是恢复 main函数的上下文继续执行了。
捕捉信号和内核态和用户态关系很大
2.2 系统调用sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。man sigaction:

- int signum:信号编号。
- act:这是一个结构体变量:

- 这个结构体名字和函数名一样, 不建议这么用,但是这里这样用了)结构体中包括多个属性,sa_handler赋值自定义处理方式,暂时将sa_flags都设为0,其他暂时不用管。
- oldact:是一个输出型的结构体变量,将原本的捕捉方式放入这个结构体变量中。
- 返回值:成功返回0,失败返回-1。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,这里的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,这里不详细解释这两个字段。
这里创建个Linux_12在里面写代码捕捉一下2号信号:
Makefile:(-fpermissive是取消下面代码强转报错)
cpp
mysignal:mysignal.cc
g++ -o $@ $^ -std=c++11 -fpermissive
.PHONY:clean
clean:
rm -f mysignal
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
}
int main()
{
signal(2, SIG_IGN);
cout << "getpid: " << getpid() << endl;
// 内核数据类型,用户栈定义的
struct sigaction act, oact;
act.sa_flags = 0; // 把这个赋为0,其它不用管
sigemptyset(&act.sa_mask); // 清空
act.sa_handler = handler;
// 设置进当前调用进程的pcb中
sigaction(2, &act, &oact);
cout << "default action : " << (int)(oact.sa_handler) << endl;
while (true)
{
sleep(1);
}
return 0;
}

在handler里睡眠10秒:


在进程开始运行后,我们在10s内发送了很多次2号信号,但发现只能10秒捕捉一次。
当递达第一个2号信号的时候,同类型的信号无法被递达。
因为当前信号在被捕捉的时候,系统会自动将当前信号加入到进程的信号屏蔽字,也就是将block对应的比特位置位,然后将pending表对应比特位清空,再去进行递达。
但是第二个2号信号在第一个信号被捕捉的时候会将对应pending位图的比特位置位。所以当第一个2号信号处理完毕以后,解除对2号信号的屏蔽后,第二个2号信号就会被递达。除了这两个2号信号,其余的2号信号都被舍弃了。
进程处理信号的原则是串行的处理同类型的信号,不允许递归,所以同类型的多个信号同时产生,最多可以处理两个。上面内容,系统调用signal也可以实现,那么sigaction相对于signal有什么优势呢?刚刚代码中,由于在2号信号的自定义处理中没有结束进程,所以只能用其他信号来结束这个进程,如上面使用的是3号信号,如果想要在捕获2号信号以后,将3号信号也屏蔽了呢?此时就需要设置结构体变量act中的sa_mask成员。
再加一些代码演示一下:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void showPending(sigset_t *pending)
{
for(int sig = 1; sig <= 31; sig++)
{
if(sigismember(pending, sig)) cout << "1";
else cout << "0";
}
cout << endl;
}
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
sigset_t pending;
int c = 20;
while(true)
{
sigpending(&pending);
showPending(&pending);
c--;
if(c == 0)
{
break;
}
sleep(1);
}
}
int main()
{
signal(2, SIG_IGN);
cout << "getpid: " << getpid() << endl;
// 内核数据类型,用户栈定义的
struct sigaction act, oact;
act.sa_flags = 0; // 把这个赋为0,其它不用管
sigemptyset(&act.sa_mask); // 清空
act.sa_handler = handler;
sigaddset(&act.sa_mask, 3); // 处理2号信号期间随便把一些信号屏蔽
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);
sigaddset(&act.sa_mask, 8);
// 设置进当前调用进程的pcb中
sigaction(2, &act, &oact);
cout << "default action : " << (int)(oact.sa_handler) << endl;
while (true)
{
sleep(1);
}
return 0;
}


如果只用2号信号和3号信号演示:
当第一次2号信号被捕获后,第二个2号信号虽然被阻塞了,但是它还是让pending位图置位了。
当第一次2号信号被递达完成后,就会递达第二个2号信号。
当第二次2号信号被递达完成后,2号信号的pending位图的比特位是0,所以才递达3号信号。
虽然在捕获2号信号的同时会阻塞3号信号,但是3号的pending位图的比特位仍然被置位了。
在第一个2号信号被捕获的时候,同时阻塞了第二个2号信号和3号信号,此时pending位图的第二个和第三个比特位都是1,但是当第一个2号信号递达完成后,先处理的是第二个2号信号而不是3号信号。
- 一般一个信号被解除屏蔽的时候,会自动递达这个信号,如果该信号pending位图的比特位是1的话就会递达,是0的话就不做任何处理。
3.不可重入函数

如上图所示链表,在插入节点的时候捕获到了信号,并且该信号的自定义处理方式中也调用了插入节点的函数。
在main函数中,使用insert向链表中插入一个节点node1,在执行insert的时,刚让头节点指向node1以后(如上图序号1),捕获到了信号,进入到了该信号的自定义处理方式中。
在自定义处理方式中,同样调用了insert函数向链表中插入一个节点node2,此时完整的执行了insert函数,但是在头节点和最开始那个节点之间同时有了node1和node2(如上图序号2和3)。
当第二次调用insert中让头节点指向node2后(如上图序号3),流程返回到信号的自定义处理函数中,然后再返回到第一次调用insert处,头节点指向node1(如上图序号4)。
最后可以看到,该链表是丢了一个节点的。
- 重入:像insert函数这样,在main流程中调用还没有返回时就再次被handler流程调用再次进入该函数。
insert函数访问的是一个全局链表,有可能会因为重入和造成错乱,像insert这样的函数就称为不可重入函数。如果一个函数只访问自己的局部变量或参数,则不会造成错乱,此时这样的函数就称为可重入函数。
注意:可重入和不可重入是函数的特性,是中性的,并不是问题,所以也不需要被解决。我们目前使用的大部分结构都是不可以重入函数。符合以下条件之一的就是不可重入函数:
调用了malloc或者free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
4.volatile关键字
看段代码:
Makefile:
cpp
mysignal:mysignal.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f mysignal
定义全局变量quit,当quit是0的时候,一直进行while循环,当quit变成1的时候,结束循环,进程正常退出。信号2注册自定义处理方式,在函数中将全局变量改成1,让main函数控制的流程正常结束。
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int quit = 0;
void change_quit(int signum)
{
(void)signum;
cout <<"change quit: "<< quit;
quit = 1;
cout << "->" << quit << endl;
}
int main()
{
signal(2, change_quit);
while(!quit);
{
cout << "进程正常退出后:" << quit << endl;
}
return 0;
}

在接收到2号信号后,quit从0变成1,所以main流程也正常结束了,不再循环。
我们的编译器会进行很多的优化,比如debug版本和relase版本中的assert就会被优化。在使用g++编译器的时候,可以指定g++的优化级别。(-O3是最高的优化级别,是大写O字母)
Makefile:
cpp
mysignal:mysignal.cc
g++ -o $@ $^ -std=c++11 -O3
.PHONY:clean
clean:
rm -f mysignal


为什么发送2号信号没有退出?
此时可以肯定quit被改成了1,但是while(!quit)还是在循环,没有停下来。
上诉现象的原因是什么?肯定是和优化有关,因为我们加了-O3选项。
quit在物理内存中一定有一块空间,最开始是0。当CPU指向while(!quit);指令的时候,会通过虚拟地址和页表的映射将物理内存中的quit数据取到CPU的寄存器中。当quit被修改后,物理空间中的数据就会从0变成1。

在没有优化前,CPU每次都是从物理内存中拿到quit的数据,再去指向while循环,所以当quit从0变成1后,CPU中寄存器的数据也会及时从0变成1,所以while循环会停下来。
但是采用优化方案后:在main控制的执行流中,quit没有进行修改,也没有写入,只是被读取,所以在第一次将从物理空间读取到寄存器中便不再读取了,每次执行while时候都是使用的寄存器中的quit值,所以始终都是0。在handler执行流中,对quit进行了修改,所以物理内存中的quit从0变成了1。
导致上面现象的原因就是CPU执行while时的quit和物理内存中的quit不是一个值。
为了让CPU的寄存器每次都从物理内存中取数据,使用volatile关键字来修饰这个quit变量。
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。

编译运行:

可以看到,此时在handler的执行流中修改了quit值,并且CPU中该值也得到了及时更新,所以程序可以正常结束。
5. SIGCHLD信号(了解)
在学习进程控制的时候,使用wait和waitpid系统调用何以回收僵尸进程,父进程可以阻塞等待,也可以非阻塞等待,采用轮询的方式不停查询子进程是否退出。
- 采用阻塞式等待,父进程就被阻塞了,什么都干不了,只能等子进程退出。
- 采用非阻塞式等待,父进程在干自己事的同时还要时不时的轮询一下,程序实现比较复杂。
- 实际上,子进程的退出并不是悄无声息的,在子进程退出时,会发出SIGCHLD信号给父进程。
写段代码证明一下在子进程退出时,会发出SIGCHLD信号给父进程,mysignal.cc:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signum)
{
cout << "子进程退出: " << signum << " father pid: " << getpid() << endl;
}
int main()
{
signal(SIGCHLD, handler);
if(fork() == 0)
{
cout << "child pid: " << getpid() << endl;
sleep(7); // 7秒后子进程退出
exit(0);
}
while(true)
sleep(1);
return 0;
}

可以看到,子进程在退出时,发出了编号为17的SIGCHLD信号,被父进程捕捉到了。
(如果子进程睡眠很久,然后子进程发送19号暂停信号,子进程也会发出编号为17的SIGCHLD信号)还是证明一下:



以前学的:当子进程退出后,父进程什么都没有干,子进程就会变成僵尸状态。
如果有10个子进程都退出呢? -> while(wait())
如果有10个子进程有5个子进程退出呢? -> vector<int> pids;
如果我们不想等待子进程,并且还想让子进程退出之后,自动释放僵尸子进程呢?:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 如果我们不想等待子进程,并且还想让子进程退出之后,自动释放僵尸子进程
int main()
{
signal(SIGCHLD, SIG_IGN); // 手动设置对子进程进行忽略
// 和OS默认是忽略的的不一样 -> 该僵尸就僵尸
if(fork() == 0)
{
cout << "child: " << getpid() << endl;
sleep(7);
cout << "子进程已经退出" << endl;
exit(0);
}
while(true) // 父进程,不关心子进程
{
cout << "father: " << getpid() << " 执行自己的任务" << endl;
sleep(1);
}
return 0;
}
man 7 signal下滑:(看到17号SIGCHLD信号,Ign就是ignore忽略的意思)

当显式SIGCHLD信号使用忽略方式(SIG_IGN)时,退出的子进程就会被自动回收。
- 虽然SIGCHLD默认的处理方式就是忽略,但是默认的忽略不会回收子进程,只有显式注册为SIG_IGN(忽略)方式才会自动回收退出的子进程。
