🪐🪐🪐欢迎来到程序员餐厅💫💫💫
主厨的主页:Chef‘s blog
所属专栏:青果大战linux
信号的捕捉的时间
重谈进城地址空间
我们对4G大小的内存进行分析,有3G是属于用户区的,剩下1G属于内核区。对于用户区我们之前也学习了相关的虚拟地址和页表以及ELF结构等等
-
户级页表负责进行从虚拟内存到物理内存的转换,仅限于用户区地址进行转化
-
内核级页表负责进行从虚拟内存到物理内存的转换,仅限于内核区地址进行转化
差异点在于,每个进程都有一张自己的用户级页表,可是所有进程的内核级页表是相同的,即内存中有且仅有一张内核级页表。这就意味着所有进程都可以看到同一个操作系统
如果我们让不同的进程的内核区映射到不同的操作系统,那么我们的电脑是不是就可以同时跑两个OS,这就是虚拟机(内核级)
用户态与内核态
OS运行时分为两种运行状态,分别是用户态和内核态。
用户态(User Mode):
用户态是一种受限的处理器执行模式。在这种模式下,应用程序运行并只能访问自己的内存空间(用户空间)。这些应用程序不能直接访问系统的硬件资源和内核数据结构。例如,一个普通的网页浏览器在用户态运行,它可以读取和写入自己分配的内存区域,用于存储网页内容、用户设置等信息,但不能直接控制硬盘的磁头移动来读写数据。
运行在用户态的程序所执行的指令是非特权指令。非特权指令是指那些不会对系统的整体运行安全和稳定性产生关键影响的指令,如简单的算术运算、逻辑判断等。
内核态(Kernel Mode):
内核态是操作系统内核运行的模式。当操作系统需要执行一些关键任务,如管理硬件设备、进行内存管理、处理系统调用等时,处理器会进入内核态。在这个状态下,处理器可以访问系统的所有内存空间(包括用户空间和内核空间),并且可以执行特权指令。
特权指令是指那些对系统资源具有完全控制权的指令,如设置系统时钟、启动和停止设备等。例如,当操作系统需要从硬盘读取数据来响应一个应用程序的文件读取请求时,它会在内核态下执行指令,直接控制硬盘控制器来完成数据的读取操作。
实现OS从用户态到内核态的转变本质和CPL寄存器有关
CPL(Current Privilege Level):
CPL是指当前特权级,它存储在 CPU 的某些控制寄存器中。在 x86 架构中,CPL 是段寄存器(如 CS - 代码段寄存器)的最低两位。这个两位的二进制数可以表示 0 - 3 四种特权级别,其中 0 是最高特权级(内核态),3 是最低特权级(用户态)。
每次通过页表转物理地址,MMU都会看你的cpl,当CPL为3,MMU就不会让你对内核区的地址进行映射,是所以进入内核的关键在于修改CPL
为什么使用两个比特位而不是一个比特位:
-
向前兼容性:在计算机体系结构的早期发展阶段,设计可能是基于更复杂的特权级别考虑,所以设计了大于两种的特权级别
-
未来拓展性:未来可能设计更多的的特权级别,所以先预留一个比特位
CPL是何时被谁修改的
当出现软中断或者硬件中断时,由于中断的处理程序需要在内核态才能执行,CPU会先把当前的程序的执行状态(包括 CPL)保存到系统栈(如内核栈)中,然后修改CPL的值为0,当中断处理完后恢复之前保存的数据,OS重新进入用户态。
为什么我们更改用户区的数据,比如栈、堆、全局变量这些,不需要使用系统调用,因为我们的CPL有对应的权限,而使用内核区的数据需要系统接口调用,因为权限不够!CPL不允许你这么做
信号捕捉
上一章我们就强调:在合适的时候,操作系统会处理信号,那么问题来了,到底什么时候才是合适的时候?
这个合适的时候就是进程要从内核态切换为用户态之前,此时OS会检测进程的pending表和block表来决定是否处理信号,再根据handler决定如何处理。
紧接着我们回忆一下函数栈帧
想一下对于这个代码,执行func1会进入它的内部,然后进入func2的内部,之后从func2出来,执行下一条“int x=10”的代码,那么为什么从函数func2跳转出来后,会继续执行这句代码呢?
因为func2的函数栈后面压着的就是int x=10这句指令(当然还有对func2返回值的move之类的)
#include<iostream>
void func2(){
printf("HELLO\n");
}
void func1(){
func2();
int x=10;
}
int main(){
func1();
}
在此基础之上,我们来看这个,这就是内核态转换用户态发生信号处理的全过程
- 如果信号的处理是默认方式,则流程为
A->B->C->D->A
- 如果信号的处理是自定义方式,则流程为
A->B->C->E->F->A
为什么在使用自定义函数还要切回用户态,直接以内核态访问不行吗?
如果自定义函数中有破坏内核区的代码,你切回了用户态他就会因为权限不够无法使用,你不切换他反倒就能使用了。
信号的自定义处理过程就是一个巨大的倒8
OS是如何运行的
电脑开机,没有启动任何任务,于是OS就被pause住了,当出现了进程,通过时钟中断进行进程调度,进程多了还会发生进程切换。
除此之外、OS还会定期做扫描,刷新缓冲区,定期检查alarm的闹钟等等。但是定期刷新怎么实现的呢?因为OS在开机被启动时,可以先自己fork出一批子进程,他们就负责干那些定期要做的事情,这些进程叫做OS的内核固定例程。
Linux内核源码如下
信号的捕捉的细节
sigaction
函数名和类型名可以一样
struct sigaction
{
void (*sa_handler)(int);//这个是处理信号的方法
void (*sa_sigaction)(int, siginfo_t *, void *);//它用于处理实时信号这个参数不用管
sigset_t sa_mask;
int sa_flags;//这个设置为0即可
void (*sa_restorer)(void);//这个也不用管
};
sigaction
是一个系统调用,用于检查和改变信号处理动作。它可以用来设置信号处理函数、信号掩码等相关操作,提供了比旧的信号处理函数(如signal
)更可靠和灵活的信号处理机制。
sa_handler
:这是一个函数指针,用于指定信号处理函数。同signal函数sa_mask
:这是一个信号集,用于指定在处理该信号时需要屏蔽的其他信号。当进程进入信号处理函数时,sa_mask
中指定的信号将被自动屏蔽,以防止在信号处理过程中被相同的信号再次中断,导致复杂的嵌套或者混乱的情况。
第一个参数用于指定要操控的信号,第二个参数act
用于传入信号处理方式,oldact
用于接收老的信号处理方式。
处理信号时,又传入相同信号会被继续处理吗
首先,处理i号信号期间,也可能陷入内核(至少也有个时钟中断),那要是此时又来个i号信号呢,由于状态切换,所以这个信号也会被处理...........吗,看代码
#include <iostream>
#include<signal.h>
#include<unistd.h>
#include<sys/types.h>
void handler(int num){
static int n=0;
n++;
while(1)
{
sleep(1);std::cout<<n<<std::endl;
}
}
int main(){
struct sigaction ne,old;
ne.sa_handler=handler;
sigaction(2,&ne,&old);
while(true){
pause();
}
}
我们在函数中使用了static变量,加入每次传入信号都会被处理,那么打印的n的值就会不断增大,结果是n始终等于1,说明 处理i号信号期间,又传入i号信号不会被继续处理。。
这是因为现在的OS,当我们正在处理某个信号,OS会把信号直接block,当这个信号处理完了,就会把block解除,验证如下
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
void PrintBlock()
{
sigset_t s1,s2;
sigemptyset(&s1);
sigemptyset(&s2);
sigprocmask(SIG_BLOCK,&s1,&s2);
std::cout<<"bolck:";
for (int i = 31; i; i--)
std::cout << sigismember(&s2, i);
std::cout << std::endl;
}
void handler(int num)
{
while (1)
{
PrintBlock();
sleep(1);
}
}
int main()
{
PrintBlock();
struct sigaction ne, old;
ne.sa_handler = handler;
sigaddset(&ne.sa_mask,4);
sigaddset(&ne.sa_mask,5);
sigaction(2, &ne, &old);
while (true)
{
pause();
}
}
可以看出三号也被屏蔽这是OS自己的一些默认设置我们不用管,反正二号确实block了
为什么这么设计
防止信号处理函数的嵌套调用
当一个信号被触发并开始执行其对应的信号处理函数时,如果不阻塞该信号,在信号处理函数执行期间,同一信号可能会被再次触发。这就会导致信号处理函数的嵌套调用,有可能触发栈溢出。当处理第一个handler,又传入一个该信号,如果要继续处理后者的handler,第一个handler就会被暂停,就像递归了一样
事实上sigaction的参数的sa_mask就是用在这里的,他可以设置处理目标信号时,要block的信号
当然了9号是不会被屏蔽的
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
void PrintBlock()
{
sigset_t s1, s2;
sigemptyset(&s1);
sigemptyset(&s2);
sigprocmask(SIG_BLOCK, &s1, &s2);
std::cout << "bolck:";
for (int i = 31; i; i--)
std::cout << sigismember(&s2, i);
std::cout << std::endl;
}
void handler(int num)
{
PrintBlock();
sleep(1);
}
int main()
{
struct sigaction ne, old;
ne.sa_handler = handler;
sigaddset(&ne.sa_mask, 4);
sigaddset(&ne.sa_mask, 5);
sigaction(2, &ne, &old);
while (true)
{
PrintBlock();
pause();
}
}
信号处理结束之后,刚才被设置的block的信号会接触block,是这些防止栈溢出的block不是所有block
pend何时被清零
是在信号刚开始处理时,而不是信号处理完后(虽然有点反直觉)
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
void PrintBlock()
{
sigset_t s1, s2;
sigemptyset(&s1);
sigemptyset(&s2);
sigprocmask(SIG_BLOCK, &s1, &s2);
std::cout << "bolck:";
for (int i = 31; i; i--)
std::cout << sigismember(&s2, i);
std::cout << std::endl;
}
void PrintPend()
{
sigset_t s1;
sigemptyset(&s1);
sigpending(&s1);
std::cout << "pend:";
for (int i = 31; i; i--)
std::cout << sigismember(&s1, i);
std::cout << std::endl;
}
void handler(int num)
{
PrintPend();
sleep(1);
}
int main()
{
struct sigaction ne, old;
ne.sa_handler = handler;
sigaddset(&ne.sa_mask, 4);
sigaddset(&ne.sa_mask, 5);
sigaction(2, &ne, &old);
while (true)
{
pause();
}
}
可以看到我们进入信号的自定义处理方法时,pend已经置零了。
因为在你处理二号信号期间,可能又有二号信号进来,如果处理完后在把pend置零,那么你不就丢失了一次新信号吗
当然了,如果你在二号处理期间,发送了多个二号,也只有一个会被记录,其他的就丢了,这确实没办法
杂谈
可重入函数
我们有一个链表,现在再执行头插node1,突然信号来了,CPU开始执行信号处理动作
结果handler也是插入链表,头插node2,结果node1不就丢失了吗,这显然属于bug
这个问题的关键在于,insert函数被两个执行流同时进入,即该函数被重入了。
在被重入时出了bug,则这个函数是不可重入函数,否则该函数是可重入函数
如果我们这里插入的是不同的链表,那他就是可重入函数了,这个问题我们会在线程详细详解
如果函数使用的全局资源,那么他可能是不可重入函数,如果没有全局资源那就是可重入函数
库函数大多是都是不可重入,比如STL库等等。但是要注意可不可重入是特性而不是优缺点。
SIGCHILD
子进程暂停或者退出都会向父进程发送SIGCHLD,他的默认处理方式是忽略。
#include<unistd.h>
#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<functional>
#include<vector>
#include<stdio.h>
#include<stdlib.h>
#include<wait.h>
void handler(int num){
std::cout<<"子进程没了,悲"<<std::endl;
}
int main(){
signal(SIGCHLD,handler);
pid_t id=fork();
if(id==0){
sleep(5);
std::cout<<"子进程退出"<<std::endl;
exit(0);
}
while(true){
sleep(1);
}
return 0;
}
我们对SIGCHLD进行了自定义捕捉,显然子进程结束时确实发送了该信号。
我们可以基于SIGCHLD信号回收子进程
#include<unistd.h>
#include<iostream>
#include<sys/types.h>
#include<signal.h>
#include<functional>
#include<vector>
#include<stdio.h>
#include<stdlib.h>
#include<wait.h>
void handler(int num){
std::cout<<"子进程没了,悲"<<std::endl;
pid_t id=waitpid(-1,nullptr,0);
if(id>0)
std::cout<<"回收成功"<<std::endl;
}
int main(){
signal(SIGCHLD,handler);
pid_t id=fork();
if(id==0){
sleep(3);
std::cout<<"子进程退出"<<std::endl;
exit(0);
}
while(true){
sleep(1);
}
return 0;
}
这样做成功回收子进程
但是如果是同时退了多个子进程怎么办,信号会丢失,那就僵尸进程了,内存泄漏了,下面是我们的改进版本。
#include <unistd.h>
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>
void handler(int num)
{
std::cout << "子进程没了,悲" << std::endl;
while (true)
{
pid_t id = waitpid(-1, nullptr, 0);
if (id > 0)
std::cout << "回收成功" << std::endl;
else{
std::cout<<"回收结束"<<std::endl;
break;
}
}
}
int main()
{
signal(SIGCHLD, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
sleep(1);
std::cout << "子进程退出" << std::endl;
exit(0);
}
}
while(1)
sleep(1);
return 0;
}
这样写,所有进程都回收了,但如果,10个子进程中只有六个退出,那么waitpid就会卡住
所以记得要改为
pid_t id = waitpid(-1, nullptr, WNOHANG);
下面是最终版本
#include <unistd.h>
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <wait.h>
void handler(int num)
{
std::cout << "子进程没了,悲" << std::endl;
while (true)
{
pid_t id = waitpid(-1, nullptr, WNOHANG);
if (id > 0)
std::cout << "成功回收一个子进程" << std::endl;
else if(id==0){
std::cout<<"退出的子进程已完成回收"<<std::endl;
break;
}
else{
std::cout<<"回收发生错误"<<std::endl;
break;
}
}
}
int main()
{
signal(SIGCHLD, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
if(i==9)
sleep(2);
sleep(1);
std::cout << "子进程退出" << std::endl;
exit(0);
}
}
while(1)
sleep(1);
return 0;
}
SIGCHLD的默认处理是忽略,我们可以给他重新设置SIG_IGN,注意这俩忽略可不一样‘
-
我们手动设置的SIG_IGN,会使得子进程结束时,自动清理,不需要再手动设置waitpid
-
该信号默认处理的忽略,表示就是不对信号进行有效处理(比如handler里面是空语句)
我宣布,进程完结!下一座难关——线程,我们寒假再来学习,关关难过关关过。
