文章目录
- 一、信号的捕捉处理
-
- [1.1 用户态 VS 内核态](#1.1 用户态 VS 内核态)
- [1.2 信号是什么时候被处理的?](#1.2 信号是什么时候被处理的?)
- [1.3 sigaction](#1.3 sigaction)
- [二、可重入函数 VS 不可重入函数](#二、可重入函数 VS 不可重入函数)
- 三、volatile
- [四、SIGCHLD 信号(子进程退出)](#四、SIGCHLD 信号(子进程退出))
- 五、结语
一、信号的捕捉处理
1.1 用户态 VS 内核态
一个进程要处理对应的信号,首先要收到该信号,进程怎么知道它收到了对应的信号呢?上面说过,操作系统给进程发送信号本质就是去修改 pending
位图,因此一个进程确定自己是否收到某个信号,一定是去检查 pending
位图。而,pending
位图属于内核数据结构,在用户层无法直接看到。所以对 pending
的检查不需要用户层写代码去实现,而是由内核去实现。
内核页表引入:
CPU 在执行我们写的可执行程序时,它不只是在执行我们自己写的代码,在我们的代码中可能会存在系统调用,所以 CPU 同时还会执行库操和作系统的代码。因为操作系统是不相信的用户的,所以在进行系统调用 的时候,会陷入内核(也就是把我们的身份从用户态切换到内核态),这个身份切换是由操作系统自动完成的。int 80h
就是 Inter X86 计算机中一个从用户态切换为内核态的中断。
每一个进程的进程地址空间中都有一个内核空间。该空间中的内容映射到物理内存中就是我们平时使用的系统调用的源代码,也就是操作系统的代码,每个进程看到的3~4G的东西都是一样的,整个系统中,进程再怎么切换,3~4G的空间内容是不变的,因此,所有的进程都共享同一个内核级页表 。站在进程视角,去调用系统调用接口,就是在我自己的地址空间中跳转到内核空间进行执行 。在操作系统角度,任何一个时候,都有进程执行,操作系统本身就是一个进程,所以我们想要执行操作系统的代码就可以随时执行。操作系统的本质是一个基于时钟中断的死循环,在计算机硬件中,有一个时钟芯片,每隔很短的时间(纳秒级别),就会向计算机发送时钟中断,计算机在接收到时钟中断后,就去中断向量表中执行相应的方法(进程调度之类的)
用户态和内核态的标准解释:
用户态和内核态是针对 CPU 来说的,这两种模式描述了 CPU 在运行程序时的两种不同状态。用户态和内核态是操作系统为了保护计算机资源而实施的一种运行模式,在操做系统中,代码可以根据其特权级别分为两种类型:用户态和内核态。这种分离有助于保护系统资源,防止恶意程序或错误代码损害系统稳定性和安全性。同时,这种设计也有助于提高操作系统的稳定性和性能,因为内核代码可以在受保护的环境中运行,而不受用户程序的干扰。
用户态 :用户态是普通程序的运行模式,具有较低的特权级别。在用户态下运行的代码不能直接访问硬件资源和其它受限资源,例如内存管理、设备驱动程序和文件系统等。用户态程序只能通过系统调用与内核态交互,以访问这些受限资源。
内核态:内核态是操作系统内核的运行模式,具有较高的特权级别。在内核态下运行的代码可以访问所有系统资源和设备,并可以执行任何指令。内核态负责管理系统资源、硬件设备和用户程序,以及处理系统中断和异常。
在现代操作系统中,一个进程根据其运行的代码所处的特权级别,可以在用户态和内核态之间切换。例如,当用户程序通过系统调用请求操作系统服务时,进程将从用户态切换到内核态,以允许内核代码执行相应的服务。当内核完成系统调用服务时,进程将切换回用户态,以便继续执行用户代码。
在 CPU 中有一个 ecs
寄存器,它的后两个 bit 位就标记了当前是处于用户态还是内核态,其中 0 表示处于内核态,3 表示处于用户态。int 80h
指令本质上就是将 3 修改成 0。
总结:用户态和内核态的产生,就是为了去判断,当前是否有权限去执行内核空间的代码,处于用户态是无法执行操作系统中的代码,只有处在内核态才能去执行操作系统中的代码。
1.2 信号是什么时候被处理的?
当我们的进程从内核态返回到用户态的时候,进行信号的检测和处理。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号 。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:用户程序注册了SIGQUIT 信号的处理函数 sighandler
。 当前正在执行 main
函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的 main
函数之前检查到有信号 SIGQUIT
递达 。 内核决定返回用户态后不是恢复 main
函数的上下文继续执行,而是执行 sighandler
函数,sighandler
和 main
函数使用不同的堆栈空间 ,它们之间不存在调用和被调用的关系,是两个独立的控制流程 。 sighandler
函数返回后自动执行特殊的系统调用 sigreturn
再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复 main
函数的上下文继续执行了。
CPU 进入内核态的机会是很多的:
一个进程在被调度切换的时候,就是处于内核态的,因为进程对的调度切换,完全是由操作系统中的代码来实现的。所以有的时候我们在代码中没有使用任何系统调用接口,但是该程序仍然可以收到并处理信号。CPU 在执行代码的过程中,陷入内核态的机会其实是非常多的。
不允许以内核态的身份去执行用户代码,因为如果用户态中如果有越权操作,那么当前如果是内核态,那么该操作就会被执行,造成意想不到的后果。
1.3 sigaction
除了上面一直使用的 signal
可以设置特定信号的捕捉方法外,sigaction
函数也可以设置特定信号的自定义捕捉方法。
signum
:指定信号的编号。act
:非空,根据act
去修改对应信号的处理动作。oldact
:若非空,通过其传递出该信号原来的处理动作。- 返回值:成功返回 0;失败返回 -1。
struct sigaction 结构体
其中只需要关注 sa_handler
和 sa_mask
sa_handler
:指向自定义的捕捉函数。sa_mask
:一个信号集,里面记录了在处理signum
时需要额外屏蔽掉的信号。
说明 :当某个信号的处理函数被调用时,在调用之前,内核自动将当前信号加入进程的信号屏蔽字 ,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止 。 如果在调用信号处函理数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags
字段包含一些选项,本章的代码都把 sa_flags
设为 0,sa_sigaction
是实时信号的处理函数。
sigaction 函数使用:
cpp
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;
void handler(int signum)
{
cout << "cat a single, signum: " << signum << endl;
return;
}
int main()
{
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
act.sa_handler = handler;
sigaction(2, &act, &oldact);
while(true)
{
cout << "process is running, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
验证一 :在调用对应信号的自定义捕捉方法之前 ,操作系统会把 pending
表中标记该信号的值,由 1 置 0。
cpp
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;
void PrintfPending()
{
sigset_t pending;
sigisemptyset(&pending);
sigpending(&pending);
for(int i = 31; i >= 1; i--)
{
cout << sigismember(&pending, i);
}
cout << endl;
}
void handler(int signum)
{
PrintfPending();
cout << "cat a single, signum: " << signum << endl;
return;
}
int main()
{
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
act.sa_handler = handler;
sigaction(2, &act, &oldact);
while(true)
{
cout << "process is running, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
验证二 :操作系统在去调用某个信号的自定义捕捉方法之前,还将该信号添加到 block
位图(信号屏蔽子)中了。
cpp
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;
void PrintfPending()
{
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
cout << "Pending: ";
for (int i = 31; i >= 1; i--)
{
cout << sigismember(&pending, i);
}
cout << endl;
}
void PrintfBlock()
{
sigset_t oset;
sigemptyset(&oset);
sigprocmask(SIG_BLOCK, nullptr, &oset);
cout << "Block: ";
for(int i = 31; i >= 1; i--)
{
cout << sigismember(&oset, i);
}
}
void handler(int signum)
{
cout << "cat a single, signum: " << signum << endl;
while (true)
{
PrintfBlock();
cout << ' ';
PrintfPending();
sleep(1);
}
}
int main()
{
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
act.sa_handler = handler;
sigaction(2, &act, &oldact);
while (true)
{
cout << "process is running, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
qewrdsz
二、可重入函数 VS 不可重入函数
main
函数和自定义捕捉方法,属于两个不同的执行流。
如果一个函数,被多执行流重复进入的情况下,出错了,或者可能出错,那么该函数叫做不可重入函数,否则,叫做可重入函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或new
,因为malloc
也是全局链表来管理堆的。 - 调用了标准 I/O 库函数。标准 I/O 库函数的很多实现都以不可重入的方式使用全局的数据结构。
三、volatile
cpp
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
using namespace std;
int flag = 0;
void handler(int signum)
{
cout << "cat a signal: " << signum << endl;
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
cout << "process quit normal" << endl;
return 0;
}
没有优化:结果符合预期
优化 :在优化条件下,flag
变量被编译器直接优化到 CPU
内的寄存器中,后来收到信号调用 handler
方法修改 flag
,是修改的内存中 flag
的值,并没有修改寄存中 flag
的值,CPU
寄存器中的 flag
从第一加载进去一直就是0。g++
编译器默认不进行优化,-O0
、-O1
、-O2
、-O3
四种优化等级。
为了避免编译器的这种优化,引入了 volatile
关键字,防止编译器过度优化,保持内存的可见行。
四、SIGCHLD 信号(子进程退出)
子进程在退出的时候,会主动的向父进程发送 SIGCHLD
信号(17号)。进程收到该信号的默认处理动作是忽略 。所以,父进程在进行等待的时候,可以采用基于信号的方式进行等待。若采用这种方式,父进程的主逻辑中可以不用调用 waitpid
函数,但是需要在自定义捕捉函数里面调用 waitpid
函数,并且父进程必须保证自己是一直在运行的,因为它不知道子进程什么时候会退出。根据 4.3 小节中验证的性质,如果有 10 个信号同时退出,这样做可以嘛?只有一半退出又该怎么办呢?可以采用 while
循环等待的方式,去应对多个子进程同时退出的场景,可以通过非阻塞去解决只有一半子进程退出的场景,因为已经设置成循环等待了,此时如果是阻塞等待,那么在一个进程退出后,进到 handler
方法里面,如果还有其他子进程没退出那么就会阻塞住。
cpp
#include <iostream>
#include <signal.h>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
using namespace std;
void handler(int signum)
{
pid_t ret;
cout << "cat a signal, signum is: " << signum << endl;
while ((ret = waitpid(-1, nullptr, WNOHANG)) > 0) // 非阻塞式等待,防止只有一半子进程退出,卡在这里
{
cout << "wait " << ret << " success" << endl;
}
}
int main()
{
signal(17, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt--)
{
cout << "I am child, pid: " << getpid() << endl;
sleep(1);
}
exit(0);
}
sleep(2);
}
// father
while (true)
{
cout << "I am father, pid: " << getpid() << endl;
sleep(1);
}
}
父进程调用 sigaction
函数,将 SIGCHLD
的处理动作设置为 SIG_IGN
,这样 fork
出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户 sigaction
函数自定义的忽略,通常是没有区别的,但这是一个特例。此方法只对 Linux
可用,不保证在其它 UNIX
系统上都可用。
五、结语
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,春人的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是春人前进的动力!