目录
一. 用户态和内核态
1.1 用户态和内核态的概念
1.2 用户态和内核态之间的切换
二. 信号的捕捉和处理
2.1 捕捉信号的时机
2.2 多次向进程发送同一信号
2.3 sigaction 函数
三. 可重入函数和不可重入函数
四. volatile 关键字
五. SIGCHLD信号
5.1 SIGCHLD信号的意义
5.2 通过SIGCHLD信号等待子进程
5.3 对于SIGCHLD信号的SIG_DFL和SIG_IGN处理方法
六. 总结
用户态:
内核态:
区分内核态和用户态的目的:对操作系统的内资源进行保护,由于用户只能在用户态下运行属于其自身的代码,这样就防止了恶意程序修改和盗取操作系统内部的资源,保证安全性。
由于中断、系统调用、异常等原因,进程需要去访问OS的内部资源,这样就会发生由用户态到内核态之间的转换。执行完OS的内核代码后,就会从内核态再变回用户态。
用户态到内核态之间的切换,其底层的实现可以通过 进程地址空间 + 页表 来理解,如图1.1所示,每一个进程都有属于其自身的、独立的4G的进程地址空间,其中0~3G为用户空间,3~4G为内核空间,它们分别映射到用户自身使用的物理内存和操作系统内核级资源使用的物理内存。
每个进程地址空间会使用2张页表,一张为用户级页表,用于用户空间映射属于用户的资源,一张为内核级页表,用于内核空间内存OS资源,每个进程都有一张独立的用户级页表,而一张内核级页表可以由多个进程共享,由用户态到内核态,就是执行用户代码向执行内核代码的转变,即:在进程地址空间中,从访问用户空间到访问内核空间的转变,用户态到内核态转换完成后,进程就可以通过内核级地址空间,通过内核级页表,映射访问到OS的内部资源。等到
结论:无论是进程是在用户态执行属于用户自身的代码,还是在内核态执行OS的内核级代码,都是在进程地址空间的上下文中运行的。
所有的计算,都是在CPU中进行的,如果想要执行OS的内核代码,就必须获取到物理内存中的OS区域的数据,并将其拿到CPU的寄存器中,那么,如何知道是否有权限访问OS资源,即如何确定当前状态是否为内核态?
由用户态切换到内核态的第一步,是通过汇编指令INT 80,来进行状态的切换,INT 80执行后,进程就具备了访问OS内核资源的权利。同时,CPU中的寄存器,也会记录当前所处的状态是用户态还是内核态。
CPU中有两套寄存器,一套为可见寄存器,一套为不可见寄存器:
CPU中的CR3寄存器(不可见寄存器)就是用来表示当前CPU的执行权限的,CR3中记录不同的值,用于表示用户态或者是内核态。
总结,由内核态,到用户态再到内核态的转变过程中,执行的操作流程为:
捕捉信号,就要读取进程PCB中的block表和pending表的信息,来确定某个信号是否产生,以及是否被阻塞。由于进程PCB属于内核级数据,因此只有处于内核态的时候,才有去读进程PCB数据的权限,才能对信号进行捕捉。
结论1:对信号的捕捉一定是在内核态下进行的。
如果某个信号产生了,又处于阻塞状态,那么就需要对信号进行响应,由于用户可以自定义针对特定信号的处理方法,而用户自定义的处理信号的函数一定要在用户态下运行,因此,如果用户自定义了信号处理函数,从捕捉到信号到响应信号的过程中,会涉及到由内核态到用户态的转变。
结论2:信号是在内核态向用户态转变的时候进行处理的。
但是,如果相应信号的方式是默认或忽略,那么就不会涉及到内核态到用户态的转变,这是因为,通过默认和忽略方式响应信号的代码,属于OS的内核级代码,要在内核态下执行,一般默认的信号响应方式是终止进程或暂停进程。
对于终止进程的默认响应方式,OS直接将进程杀死,也就不会存在回到用户态,继续执行主指向流中的代码的情况。对于暂停进程的默认响应方法,进程PCB会被添加到阻塞(等待)队列中去,直到进程接收到可以继续运行的信号,这种状态下也不会回到用户态继续执行主执行流中的代码。如果采用忽略的策略响应信号,那么就是在内核态下执行完忽略响应方法,然后再回到用户态继续运行进程。
在用户自定义了信号响应函数的情况下,执行完用户的相应代码,是继续留在用户态回到主执行流上次终止的位置继续运行吗?答案是否定的。
执行完用户自定义的signal_handler函数后,会执行特殊的系统调用sigreturn再次进入到内核态,在内核态下,会进行对信号响应的一些收尾工作,如:处理改写 block、pending 表等,这些工作完成后,才会真正回到用户态,从主执行流上次中断的位置继续运行。
图2.1为进行信号处理的全流程图,其中包含了每一步所处的状态是用户态还是内核态。
结论1:如果进程正在执行编号为sigId的信号的处理方法,那么在执行处理方法的过程中,这个信号会被设置为阻塞状态。
结论2:在执行信号处理方法signal_handler的过程中,无论向进程发送多少次被阻塞的信号,在signal_handler执行完成后,这个信号也只能被相应一次,
在代码2.1中,对SIGINT信号的处理方法进行重新定义为signal_handler,在signal_handler中执行sleep(10),以便在执行signal_handler时可以多次接受到SIGINT信号,运行代码,先使用Ctrl+C发生SIGINT信号,触发对signal_handler的执行,在执行signal_handler期间多次通过Ctrl + C发送二号信号,观察运行结果,发现在第一次运行signal_handler函数期间接受的SIGINT信号,将来只会被响应一次。
代码2.1:处理信号期间多次发送信号
#include
#include
#include
#include
void signal_handler(int sig)
{
std::cout << "recieve a signal, sigId:" << sig << std::endl;
sleep(10);
}
int main()
{
signal(SIGINT, signal_handler);
while(true)
{
std::cout << "This is a process, pid:" << \
getpid() << ", ppid:" << getppid() << std::endl;
sleep(1);
}
return 0;
}
函数原型:int sigaction(int sig, const struct sigaction* act, struct sigaction* oact)
头文件:#include
函数功能:让用户自定义处理某个信号的函数,并且可以设置执行对这个信号的处理方法期间对指定信号进行阻塞。
返回值:执行成功返回0,失败返回-1。
这个函数有三个参数,其中sig为信号编号,act 和 oact 为 struct sigaction 结构体类型的指针,Linux操作系统是由C语言编写的,C/C++允许函数名和自定义类型的名称相同,struct sigaction的定义如下所示,用于表示处理信号的方法函数以及信号屏蔽字等。act为输入性参数,用它的值设置信号处理方法和屏蔽信息,oact为输出型参数,用于接受原来的struct sigaction数据。
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
在struct sigaction中,成员sa_sigaction、sa_flags和sa_restorer为与实时信号相关的参数,本文只对普通信号做处理,暂时不关心这三个参数。sa_handler为指向信号处理函数的函数指针,sa_mask为阻塞信号集,用于设置在运行sa_handler所指向的函数期间要阻塞的信号。
代码2.2演示了对sigaction函数的使用,重新设置2号SIGINT信号的处理方法,并设置对1~8号信号的阻塞,通过Ctrl+C发生SIGINT信号,在执行signal_handler期间,多次发生1~8号信号,并不断输出未决信号集,可见,在执行signal_handler期间发送的这些信号在signal_handler函数运行期间都处于未决状态,等到signal_handler运行完毕,才会对这些信号进行响应。
代码2.2:使用sigaction设置信号处理函数和信号屏蔽字
#include
#include
#include
#include
void showpending(const sigset_t& pending)
{
for(int sig = 1; sig <= 31; ++sig)
{
if(sigismember(&pending, sig)) std::cout << 1;
else std::cout << 0;
}
std::cout << std::endl;
}
void signal_handler(int sig)
{
std::cout << "recieve a signal, sigId:" << sig << std::endl;
int count = 20;
sigset_t pending;
while (count--)
{
sigpending(&pending); // 获取未决信号
showpending(pending); // 输出未决信号集状态
sleep(1);
}
}
int main()
{
std::cout << "This is a process, pid:" << getpid() << std::endl;
// act用于设置信号处理方法和阻塞集
// oact用于接受原来的sigaction信息
struct sigaction act, oact;
// 重新设置对信号的处理函数
act.sa_handler = signal_handler;
// 设置对1-8号信号的信号屏蔽字
sigemptyset(&act.sa_mask);
for (int sig = 1; sig <= 8; ++sig)
{
sigaddset(&act.sa_mask, sig);
}
// 调用sigaction对信号响应方法和屏蔽状态重新设置
sigaction(SIGINT, &act, &oact);
while (true)
{ }
return 0;
}
设想这样的场景:某个函数func正在被执行的时候(还没完成执行),突然进程接受到了一个信号,在处理这个信号的函数signal_handler中,func再次被调用,这样就相当于,在某一时刻,func被多个执行流调用执行,func发生和重入。
以链表头插为例,探究函数不可重入的场景。如图3.1所示,insertFront函数执行操作的流程为:将新节点node插入到原链表头结点的位置,然后更改head执行新插入的节点。那么假设,第一次调用insertFront函数的执行流执行到node1连接到node之前,但还没有更新head的时候,突然执行流被打断,insertFront重入执行node2头插,insertFront(node2)执行完成后退回到原inserFront的执行流中从上次被中断的位置继续运行,head被更改为node1,这样就造成了node2的丢失,这是由于insertFront函数重入引发的问题,insertFront函数就是不可重入函数。
实际的项目开发中,绝大部分函数都是不可重入的,函数可否重入,只代表函数的一种特性,而不用于评价函数的好坏。
volatile 关键字的功能是杜绝编译器的优化,本文借助信号处理,来解析volatile的功能。
编写代码4.1,定义一个全局变量flag并初始化为0,在主函数中通过while循环判断!flag是否为真,如果假,就终止while循环,然后进程退出。我们重定义对2号信号的处理函数signal_handler,进程在相应2号SIGINT信号时,应当执行的操作为将flag由0置1,那么当执行完一次signal_handler函数后,while循环理论上应当终止,使用g++编译代码,如果不人为指定优化级别,那么运行结果确实如此。但是,如果在用g++编译代码时,指定为最高的-O3优化级别,那么运行结果会如图4.1所示,指向完一次signal_handler函数后,flag由0置1,while理论上应当退出,但是并没有,这是为什么呢?
代码4.1:验证volatile关键字及编译器的优化情况
#include
#include
//volatile int flag = 0; -- 声明不允许编译器优化
int flag = 0;
void signal_handler(int sig)
{
std::cout << "flag change: " << flag;
flag = 1;
std::cout << "->" << flag << std::endl;
}
int main()
{
signal(SIGINT, signal_handler);
while(!flag)
{}
return 0;
}
这里就涉及到编译器的优化了,在最高O3级优化下,编译器看到在主函数中,flag没有被更改,因此就自作聪明的将第一次读到的flag载入到CPU的寄存器中,而不是在每次while循环判断成立条件时从内存中读取flag的值进行判断,因此即使内存中的flag已经被更改了,while循环依旧不会终止,因为这里直接从CPU寄存器中读取值用于while的条件判断,而CPU中的值是在第一次while条件判断的时候载入的,并没有被更改,这是由于编译器自作聪明的优化而造成的错误。
如果在声明和初始化全局变量flag时,使用volatile关键字进行声明,就可以杜绝上面的问题,语法为:volatile int flag = 0;
子进程终止或暂停的时候,会向父进程发生SIGCHLD信号,SIGCHLD信号不会触发父进程进行任何操作,如果不回收子进程,子进程就会处于僵尸状态。代码5.1通过重定义对SIGCHLD的处理函数,来验证子进程终止向父进程发送SIGCHLD信号。
代码5.1:验证子进程终止向父进程发生SIGCHLD信号
#include
#include
#include
#include
#include
void signal_handler(int sig)
{
std::cout << "recieve a signal:" << sig << std::endl;
if(wait(nullptr) > 0)
{
std::cout << "child process exit successful" << std::endl;
}
exit(0);
}
int main()
{
signal(SIGCHLD, signal_handler);
if(fork() == 0)
{
int count = 3;
while(count--)
{
std::cout << "child process, pid:" << getpid() \
<<", ppid:" << getppid() << std::endl;
sleep(1);
}
exit(0);
}
while(true) {}
return 0;
}
如代码5.2所示,重定义SIGCHLD信号的处理函数为waitChlid,在父进程中一次创建多个子进程,由于父进程在收到SIGCHLD的那一时刻,可能有多个子进程都退出来,但父进程只能收到有个SIGCHLD信号,因此,在waitChlid函数中不能只执行一次wait/waitpid等待子进程退出,而是应当使用非阻塞等待的方式,通过while进程轮询检查,来实现对每个子进程的等待,避免子进程僵尸。
设有n个子进程被创建,那么通过SIGCHLD信号保证子进程全部被回收的方法有两种:
代码5.2:使用信号的方法等待子进程退出
#include
#include
#include
#include
#include
#include
// 1. 通过waitpid,给定第一个参数-1和非阻塞,使用SIGCHLD信号非阻塞等待
void waitChild(int sig)
{
std::cout << "recieve a signal, sig:" << sig << std::endl;
pid_t id = 0;
int status = 0;
// 轮询检测,非阻塞等待子进程
while((id = waitpid(-1, &status, WNOHANG)) > 0)
{
std::cout << "There is a child process exit, pid:" << id \
<< ", exit code:" << WEXITSTATUS(status) << std::endl;
}
}
int main()
{
signal(SIGCHLD, waitChild);
// 创建5个子进程
for(int i = 0; i < 5; ++i)
{
if(fork() == 0)
{
std::cout << "This is a child process, pid:" << getpid() << std::endl;
exit(i);
}
}
while(true) { };
return 0;
}
// 2. 使用vector记录每个子进程id,wait非阻塞等待的方法,实现利用SIGCHLD信号等待子进程
std::vector childId;
void waitChild(int sig)
{
std::cout << "recieve a signal, sig:" << sig << std::endl;
pid_t id = 0;
int status = 0;
// 轮询检测,非阻塞等待子进程
for (size_t i = 0; i < childId.size(); ++i)
{
if ((id = waitpid(childId[i], &status, WNOHANG)) > 0)
{
std::cout << "There is a child process exit, pid:" << id
<< ", exit code:" << WEXITSTATUS(status) << std::endl;
}
}
}
int main()
{
signal(SIGCHLD, waitChild);
// 创建5个子进程
for (int i = 0; i < 5; ++i)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "This is a child process, pid:" << getpid() << std::endl;
exit(0);
}
childId.push_back(id); // 子进程的pid存入vector
}
while (true)
{ }
return 0;
}
通过man 7 signal,查阅SIGCHLD信号在系统中默认的处理方法为忽略,那么,采用SIG_DFL默认处理和SIG_IGN忽略处理,是不是效果完全一样呢?答案显然是否定的。
可以这么理解,虽然对于SIGCHLD信号的默认处理方式为忽略,但是SIG_IGN处理SIGCHLD的忽略等级更高,就连子进程的退出状态信息也忽略了,不会引发僵尸进程问题。