
🔥海棠蚀omo:个人主页
❄️个人专栏:《初识数据结构》,《C++:从入门到实践》,《Linux:从零基础到实践》
✨追光的人,终会光芒万丈
博主简介:
目录
[2.3.2系统调用号 + 虚拟地址空间 + 软中断](#2.3.2系统调用号 + 虚拟地址空间 + 软中断)
[2.5 Sigaction函数](#2.5 Sigaction函数)
前言:
今天我们就要进入到信号的最后一部分知识:信号捕捉,这一块涉及到很多的周边知识,如:硬件中断,时钟中断等相关知识,这些知识都是下面我们要了解的,只有将这些周边知识都了解了,才能更好地去理解信号捕捉中的问题。
既然如此,那我们废话不多说,下面就开启今天的内容!!!
一.信号捕捉的流程
在讲解下面的知识前,想必大家心中有疑惑:为什么同样都是信号处理,怎么不讲默认动作和忽略信号呢?
因为OS处理默认动作和忽略信号是很容易的事情,反而自定义捕捉是最麻烦的,后面我们就知道它麻烦在哪儿了。
我们早在讲解信号产生的章节中说到:进程收到信号,不一定会立即处理,而是在合适的时候处理,那么到底是什么时候呢?又是如何处理的呢?
那么今天我先把结论点出来:是在进程从内核态切换回用户态的时候处理的,此时OS会检测当前进程的三张表,再决定要不要处理信号!!!

通过上面这张图我们可以直观地看到整个信号捕捉的流程,既然是从内核态切换回用户态的时候处理的,那么换句话说就是我们的进程曾经从用户态切换到了内核态。
而上图就揭示了我们的进程会因为中断,异常或者系统调用而进入到内核态,在处理完异常等工作后,会在切换回用户态的时候通过do_signal函数检查进程的三张表,判断是否需要处理信号。
而默认处理动作和忽略信号在这一步OS就会去执行了,而自定义捕捉还早着呢,所以上面说默认处理动作和忽略信号对OS而言比较简单,而自定义捕捉比较麻烦,因为后面还有两步操作呢。
而OS系统检测到需要执行自定义捕捉,接着就会切到用户态去执行handler函数,而执行完后又会通过sigreturn函数再次切到内核,最后又在内核中通过sys_sigreturn函数再次切回用户态。
通过上面的所有步骤才算完成了信号捕捉的过程,而我们将上面的图再进行简化一下:

简化之后我们对于信号捕捉的过程只要记忆上面这张简化图即可,中间的那一条横线一定要画在上面,使其有四个交点,这四个交点就是内核态和用户态切换的四个阶段!!!
相信大家对于上面信号捕捉的过程还有疑惑,其中最大的想必就是:到底什么是用户态,什么又是内核态呢?

我们曾经在初识进程的时候见过这张图,在这张图中我们可以看到分别由用户空间和内核空间两大部分,这里我先输出结论:
内核态:是操作系统运行时的状态,主要用来执行内核代码,叫做内核态
用户态:用cpu执行用户自己的代码所处的状态,叫做用户态
在上图中的位于用户空间中的就是用户自己的代码所处的空间,而操作系统的内核代码就在上面的内核空间中。
上面我们只是浅谈了一下内核态和用户态的相关概念,有了一个初步的认识,但是距离理解它们还远远不够,所以我们要通过下面的各种周边知识来更进一步的来理解。
二.周边知识
2.1硬件中断
首先我们要讲的就是:硬件中断,那么何为硬件中断呢?
用一个例子来带大家理解,我们想必都有过在进程正执行的时候通过ctrl + c来终止掉进程的经历吧,至于进程为什么会被终止掉我们都知道是因为OS向当前进程发送了2号信号,那么我的问题是:操作系统是怎么知道你按下了ctrl + c的?
这个问题的答案就是我们要讲的硬件中断,我们来看:

上面就是触发硬件中断的完整历程,下面我来阐述这整个过程:
我们都知道显示器,键盘等在计算机中叫做外设,而我们按下的ctrl + c本质就是发起中断,在外设于cpu中间有一个设备叫做中断控制器,而中断控制器会针对外设形成一个中断号来表明是哪个外设发起的中断。
这个中断号其实就可以认为是外部设备的编号,因为我们的外部设备不止一个,所以计算机会对它们进行编号,这点也不奇怪,之后中断控制器就会去通知cpu来处理中断。
cpu在得知中断后,就会获取中断控制器中的中断号,并停下此时手中的工作,进而通过某些寄存器来保护现场,也就是将一些寄存器当中的上下文数据给保存起来。
至此涉及硬件的部分我们就讲完了,后面就是软件层面的过程,我们接着看:
在操作系统中有一个模块,叫做中断向量表,这是一个函数指针数组,里面存的就是各种中断处理方法,在上图中我们可以看到这些处理方法中有处理键盘的,有处理显示的,也有处理网卡的等等很多。
而当cpu拿到中断号后,就会去这个中断向量表中查找相应的中断处理方法,此时的中断号就相当于数组下标,通过中断号cpu就能精准找到针对某个外设的中断处理方法,进而去执行中断处理方法,也就是向当前进程发送信号!!!
至此我们就讲完了硬件中断的完整过程,而在这个过程中我们可以发现:外部中断的产生和cpu执行自己的代码,是异步的!!!
在这个过程中我们可以得出一些结论:
结论一:中断向量表是由OS提供的 --- 换句话说,中断向量表就是OS的一部分!!!
结论二:cpu执行中断向量表中的方法,其实就是在执行OS的代码!!!
所以我们就把由外部设备触发的,中断系统运行流程的就叫做硬件中断!!!
可能有人问了:那中断向量表在哪儿呢,我怎么没讲过?那么下面我们就来简单看看:
cpp
void trap_init(void)
{
int i;
set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱
⻔。
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。
outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
set_trap_gate(39,¶llel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
上面就是我从早期的0.11内核版本的Linux源码中截取的部分代码,上面就清晰地展示了从0到16的中断号所对应的中断处理方法,并且不止有这么些,后面还有个循环设置了中断号从17到48的中断处理方法,只是它们都默认为reserved函数,后面在初始化时会重新设置。
2.2时钟中断
我们用一个问题来引出时钟中断的相关内容:进程可以在操作系统的指挥下被调度,被执行,那么操作系统自己又是被谁指挥的呢?
答案就是时钟中断,那么时钟中断又是如何指挥操作系统干活的呢?我们来看:

我们来看这张图,有一个外部设备叫做:时钟源,它会以固定的频率向中断控制器发起中断,而发起中断后要做的事就和上面的硬件中断是一样的了。
不过我们对于时钟源所对应的中断处理方法:do_timer,就有的说了,这个中断处理方法很关键,cpu所执行的这部分代码中就有关于进程调度相关的代码!!!
而cpu执行关于进程调度相关的代码的重要性想必不用我多说,就相当于把整个操作系统给运行起来了。
所以,我们就可以输出一个结论:OS的运行,是在时钟源的中断下,一直触发中断处理,进而执行操作系统的代码!!!
但是让时钟源来指挥操作系统工作,换句话说就是操作系统要时刻等待着时钟源发起中断,进而指挥操作系统工作,那操作系统如何做到时刻等待着呢?
答案就是死循环,没错,操作系统就是一个死循环,它时刻等待着中断的到来,来指挥它进行工作,那么怎么证明呢?我们来看:

这是我从Linux源码中截取的代码,这个代码就在Linux的main函数中,它是一个循环,并且是一个死循环,不停的在执行pause函数,而pause函数的作用就是让cpu在空闲时等待中断,正是印证了我们上面所说的操作系统在时刻等待着中断的到来!!!
所以我们可以在输出一个结论了:操作系统就是一个躺在中断上的软件集合!!!
有了对上面知识的了解后,下面我们再来补充几个细节:
细节一:当代的cpu计算机,已经没有外部时钟源了,因为将时钟源设置在外部,效率太低了,所以将时钟源集成到cpu内部了。
细节二:如何理解时间片?
我们上面说了,时钟源是以固定频率向cpu发送信号的,那么我们如果知道了中断触发的时间间隔,再与触发的中断次数相乘,不就得到了时间吗?
也就是中断触发的时间间隔 * 触发的中断次数 = 时间,而我们只要将这个时间固定为一个值,不就形成了进程调度中的时间片吗?
细节三:所以我们该如何理解时间片到了?
有了上面的讲解,这个时间片不就是一个计时器嘛,所以在进程运行时,操作系统就会检查当前进程的时间片是否结束了,没有结束就不会执行进程切换的工作,如果结束了就会进行进程切换。
细节四:每隔一段时间,就触发一次时钟中断,那么cpu什么时候执行我们自己的代码呢?
既然是隔段时间才发起中断,那么在两次中断的间隔时间中,cpu就会去执行我们自己进程的代码。
2.3软中断
上面我们讲的都是由外部硬件所发起的中断,那么有没有可能因为软件原因而触发和上面一样的中断逻辑呢?
有的兄弟,有的,这种方式就叫做软中断!!!
那么该如何做到呢?
其实为了支持这种方式,在cpu中也设计了对应的汇编指令,例如:int或者syscall,这是规定好的一种二进制指令,这个int可不要认为是我们日常中所使用的int整型,这两个虽然名字相同,但不是一个东西。
这个int汇编指令所对应的中断号就是0x80,和上面是一样的,当cpu执行对应的汇编指令后,比如:int,就会根据这个0x80中断号来执行相应的中断处理方法。
而软中断的应用场景就是我们经常提到的系统调用,下面我们一起来看看。
2.3.1从内核角度见一见系统调用
cpp
// sys.h
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
extern int sys_setup (); // 系统启动初始化设置函数。 (kernel/blk_drv/hd.c,71)
extern int sys_exit (); // 程序退出。 (kernel/exit.c, 137)
extern int sys_fork (); // 创建进程。 (kernel/system_call.s, 208)
extern int sys_read (); // 读⽂件。 (fs/read_write.c, 55)
extern int sys_write (); // 写⽂件。 (fs/read_write.c, 83)
extern int sys_open (); // 打开⽂件。 (fs/open.c, 138)
extern int sys_close (); // 关闭⽂件。 (fs/open.c, 192)
extern int sys_waitpid (); // 等待进程终⽌。 (kernel/exit.c, 142)
extern int sys_creat (); // 创建⽂件。 (fs/open.c, 187)
extern int sys_link (); // 创建⼀个⽂件的硬连接。 (fs/namei.c, 721)
extern int sys_unlink (); // 删除⼀个⽂件名(或删除⽂件)。 (fs/namei.c, 663)
extern int sys_execve (); // 执⾏程序。 (kernel/system_call.s, 200)
extern int sys_chdir (); // 更改当前⽬录。 (fs/open.c, 75)
上面就是我从Linux源码中截取的一部分系统调用的代码,从上面我们可以看到里面就有比较熟悉的一些系统调用,如:exit,fork,read,write等等,这些系统调用函数都是我们经常使用的,而它们在内核中的名字需要在前面加上sys_。
cpp
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};
而在内核中还有一个数组名为:sys_call_table,这个数组是一个函数指针数组,里面的内容就是各个系统调用函数的函数名,所以这是一个全局的系统调用表,我们调用系统调用函数本质就是通过这张表的数组下标来查找到要调用的系统调用函数。
所以我们给每一个系统调用都定义了一个系统调用号,其本质就是该系统调用在全局系统调用表中的下标。

所以这个过程就是如上图所示的这样。
2.3.2系统调用号 + 虚拟地址空间 + 软中断
首先我们先来见一见这些系统调用号是怎么个事:

这是我从Linux内核中截取的部分系统调用所对应的系统调用号,和我们说的一样,均是int类型的数据,也就是数组下标。
有了上面对上面知识的了解后,我们来思考一个问题:用户要访问OS,只有一种途径,那就是系统调用,那么我们的进程该如何看到位于内核中的系统调用的呢?
答案就是虚拟地址空间,所以下面我们就要借助虚拟地址空间来谈一谈:

我们知道,通过虚拟地址空间将地址映射到物理内存中需要页表,或者叫做用户级页表,那么操作系统的代码要不要也加载到内存中呢?
答案当然是要的,既然操作系统的代码也加载到了内存中,那么就要建立相应的映射关系,我们来看:

所以不只有一张用户级页表,还有一张内核级页表,这张页表就是构建起虚拟地址空间中的内核区和加载到内存中的内核代码之间的映射关系,所以我们现在就知道了虚拟地址空间中的内核区中是什么内容了。
而每个进程的这张内核级页表中的映射关系都是相同的,因为内核区在虚拟地址空间中的位置是固定且一致的,所以它看到的内核虚拟地址布局也是一样的。
注意:其实只有一张页表,只不过是为了便于理解才将其分为用户级页表和内核级页表,那一张页表中就包括了用户和内核!!!
所以此时我们就可以输出一个结论了:无论进程怎么切换,怎么调度,每一个进程都可以找到同一个内核,也随时能找到内核!!!
所以所有的系统调用未来全都可以理解成为在我自己的虚拟地址空间中完成调用的,但是我们在使用时只知道系统调用的名字啊,并不知道系统调用具体在哪儿,所以底层是如何调用的呢?
要进行系统调用就要完成下面两个工作:
1.系统调用名 - > 系统调用号,也就是要将系统调用名转化为系统调用号
2.主动触发一次系统调用号,也就是软中断,让cpu去执行int或者syscell指令,进而让cpu去进行中断处理,帮我进行系统调用查表,最后执行相应的系统调用函数!!!
而我们在使用时并没有做上面的两个工作,只是简单的调用了如:exit,fork,read等系统调用函数,所以上面的工作由谁来完成呢?
答案就是上面列举的" 系统调用函数 ",什么意思呢?
意思就是我们所认为的系统调用函数并不是真正OS所提供的系统调用函数,OS所提供的系统调用函数是需要系统调用号,约定寄存器,int 0x80等技术才能够调用的,不是C风格的。
而我们所常用的系统调用函数其实是用C风格封装过后形成的,这些函数内部就会完成上面的两步工作,进而调用真正的系统调用。
为什么要对这些系统调用进行封装呢?
无它,因为太难用了,所以采用C语言对其进行封装,不然我们要调用一个系统调用函数就要手动完成上面的两步操作。
但是我们来思考一个问题:上面我们说了用户想要访问OS,只能通过系统调用,那操作系统是如何做到只能通过系统调用这一种方式来访问OS系统的呢?
这就与cpu中的cs寄存器相关,我们来看:

在cs寄存器的最低两位称为CPL,表示当前权限级别,00的时候表示此时处于内核态,可以访问内核,11的时候表示此时处于用户态,就不能访问内核。
所以我们要访问内核,就必须修改cpl,将其改为00,而如何进行修改呢?
答案就是通过执行int 0x80或者systell指令,而执行int 0x80或者systell指令又与软中断深度绑定,所以操作系统就通过这种方式限制了用户只能通过系统调用来访问内核。
并且在执行完系统调用后,返回用户自己的代码时,同样会再次执行上面的操作,将cpl改为11,也就是用户态。
所以内核态和用户态之间相互转化的过程其实就是cpl在00和11之间不断变换的过程!!!
2.4写时拷贝??缺页中断??
所以有了对上面知识的理解后,我们该如何理解曾经见过的写时拷贝和缺页中断呢?
其实它们归根结底都是因为出现了某种问题而导致cpu报错,而这些报错为什么会有相对应的处理措施呢?就比如:出现写时拷贝时,就去开辟一块空间
答案就是写时拷贝,缺页中断等这些问题,最终全部会被转换成为cpu内部的软中断,进而去执行相应的中断处理方法。
并且:
CPU内部的软中断,⽐如int 0x80或者syscall,我们叫做陷阱。
CPU内部的软中断,⽐如除零/野指针等,我们叫做异常。
这两种方式的根本区别就是 是主动的还是被动的,int 0x80为什么叫做陷阱就是因为我们使用系统调用是 主动陷入内核的,而下面的除零等问题都是 被动触发中断,从而进入内核的。
有了上面知识的铺垫后,我们此时再看信号捕捉的这张图,相信大家对里面的中断,异常,系统调用,乃至于内核态和用户态都有了更深刻的理解。
此时可能有人会问: 我要是代码没有问题,也没有调用系统调用,会从用户态进入到内核态吗?
答案是当然的,别忘了还有时钟中断呢,这玩意儿会以固定的频率向cpu发起中断,即使是在我们进程被调度的过程中也会如此,所以cpu在调度你的进程的时候,其实一直都在进行着用户态和内核态之间的转化。
2.5 Sigaction函数
既然本章的主题是讲解信号捕捉,那么SIgaction这个函数就不得不讲了,我们来看:

Sigaction可以读取和修改与执行信号相关联的处理动作,说人话就是它是专门为信号捕捉而设计的一种函数 ,虽然它没有signal函数更简单好用。
为什么说它是专门为信号捕捉而设计的呢?
这就与它的参数有关,signum就是执行信号的编号,这没什么可说的,主要就是后面两种参数,我们可以看到后面两个参数均是结构体类型的参数,这两个参数和我们上一篇讲的sigprocmask函数后面的两个参数很相似, act就表示该信号新的处理动作,而后面的oldact就表示该信号原来的处理动作,所以它是一个输出型参数。
那这个结构体是什么呢?我们来看看:

从上图我们就可以看到这个结构体中都有哪些变量,这里面我们只看用红色方框圈起来的三个变量,剩下的两个与实时信号有关,实时信号我们没有讲解过,所以这里我们就不必看剩下的两个变量了。
下面我们就简单介绍一下这三个变量都是干什么的:
第一个变量handler想必大家都不陌生,没错,既然该函数是针对信号捕捉而设计的,自然少不了信号捕捉函数,该变量指的就是信号捕捉函数。
第三个变量 sa_flags的核心作用是提供细粒度的信号行为控制,这里我们直接将其设为0即可。
而对于最后的sa_mask变量,在讲解它之前我们还要了解一部分知识:
当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字 ,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时, 如果这种信号再次产⽣,那么它会被阻塞到当前处理结束为⽌,目的是为了防止任意信号进行递归处理 。
下面我们通过一个简单的例子来证明上面的结论:
cpp
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
void handler(int signo)
{
cout << "获取到一个信号: " << signo << endl;
while (true)
{
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
for (int i = 31; i > 0; i--)
{
if (sigismember(&pending, i))
cout << "1";
else
cout << "0";
}
cout << endl;
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
act.sa_flags = 0;
act.sa_handler = handler;
sigaction(2, &act, &oact);
while (true)
{
cout << "我是一个进程 " << endl;
sleep(1);
}
return 0;
}

可以看到当我们按下ctrl + c向当前进程发送2号信号后,开始执行handler信号捕捉函数,紧接着当我们再次向进程发送2号信号后,内核pending表中2号信号的位置由0变为了1,说明此时2号信号已经被阻塞了,和我们上面说的是一样的。
而上面我们只是阻塞了一个信号,那要如何在调用信号处理函数时,除了当前信号被自动屏蔽外,如何屏蔽其他的信号呢?
这就要用到我们上面未讲的sigset_t mask变量了,该变量就是用来设置需要额外屏蔽的信号的,下面我们来看看该如何使用:




从上面我们就可以看到在信号处理函数执行期间,不只是2号信号被阻塞了,3,4,5,6号信号也均被阻塞了。
以上就是Linux信号捕捉全解析:深入原理与实战,掌控进程的生命节拍的全部内容。
