Linux进程信号——中断机制下信号处理

发布于:2025-08-09 ⋅ 阅读:(24) ⋅ 点赞:(0)

中断机制下信号处理

本篇文章,我们将重点学习Linux系统下,进程的信号是如何处理的(处理时机、方式)。
同时,必须理解到系统的一些相关机制,才能对信号的处理、以及整个Linux系统有更深理解。

宏观认识信号处理流程

在信号产生、保存的时候了解到:

1.信号递达的方式有三种:默认动作、自定义动作、忽略
2.信号并不是一接收到就要处理的,而是等到一个合适的时机来处理!

那么具体来说,什么是合适的时机?这个是我们在前面的信号学习中没有提及到的。

在这里插入图片描述
要明白什么是合适的时机,我们需要学习信号处理的相关知识。接下来我们将一起探究。


下面直接看一张图,通过这张图引出几个结论来讲解:
在这里插入图片描述

结论:
a. Linux系统是被分为 用户态(user mode)内核态(kernel mode) 的。

b. 当用户态执行系统调用、或因中断机制,此时系统会进入内核态。

在处理完异常、系统调用后,即准备返回用户态的时候,会进行信号的检查! 这就是所谓合适的时机!
c. 对于信号的处理:

  • c.1. 如果内核态在检查到该信号处理方式是默认动作、忽略,那么内核态在检查完信号的时候,就直接处理了。
  • c.2. 但是如果内核态下检测到信号处理是自定义动作,这需要内核态切换到用户态,执行自定义动作(自定义动作是用户定义的),然后切换回内核态,再由内核态切换回用户态。

至此,我们就大致明白了信号处理的大致流程,也明白了信号处理的时机!


下面来解决几个问题:

1.信号的捕捉过程,是需要进行身份切换的吗?
答案是对的,从流程图可看出,处理信号是内核做的事情,需要从uer mode -> kernel mode。

2.所谓的检查信号,是在做什么事情呢?
其实就是在检查当前进程的信号保存情况,即信号的三张表,block、pending、handler。

3.为什么能做到身份态的切换?也就是说,凭什么由一方跳转到另一方执行方法?
这个需要用到函数栈帧的知识,汇编指令下,调用函数的指令是call fun_addr。此时,会把要调用的函数入栈!
在这里插入图片描述
上图是正常的调用函数逻辑,要做到由内核态和用户态之间的切换,是很简单的!只需要编译代码的时候,强行把函数的返回地址写成用户态的自定义处理函数,就可以完成从内核态 -> 用户态的切换。用户态 -> 内核态也是一样的!

4.执行自定义动作的时候,是内核切换到用户态下执行,还是直接切换成用户态执行呢?
这个必须是用户态执行! 因为自定义动作是用户层面写的,如果用户代码中做了一些恶意操作,以内核态执行,这就会导致内核被攻击。这是不被允许的。


总结:
最后,我们已经能够从宏观上理解信号的整个处理流程以及信号的处理时机,为了方便记忆,可以用一张图来进行记忆:
在这里插入图片描述
这张图类似于一个正无穷,我们可以发现,如果是处理自定义动作,是需要进行4次身份的切换的!(轨迹和分界线有4个交点)。

而信号的处理时机,就是第2次身份切换前进行检查信号集表,然后进行处理!

中断机制——理解操作系统运行本质

通过对信号处理的宏观认识,我们是大概知道了信号处理流程。
但是,我们肯定会有疑问:

1.既然进入内核的条件是(系统调用、处理异常、中断),那么代码中只有while(1){}也会进入吗?
2.我们的进程,凭什么能够进入内核呢?

第一个问题是可以来解决的,即使我们的代码中,没有系统调用,没有异常,也是可以切换到内核的!因为系统是基于时间片的分时操作系统!每个进程单次运行的时间是有一定的时间片的!当时间片结束,操作系统需要切换进程来调度。这就会进入到内核中处理信号了。

第二个问题,我们目前无法解答,这涉及到很多相关的知识(中断机制、操作系统运行、陷阱、地址空间),这需要我们通过学习并组织相关知识才能理解。

硬件中断

先来理解第一个概念,即硬件中断。我们需要通过硬件中断来了解操作系统的运行本质。

在这里插入图片描述
上述就是硬件中断的流程图,我们来对每个部分进行讲解。


我们就从最常见的键盘外设入手,当我们调用一些键盘读取函数的时候,我们会知道,如果我们不输入内容按下回车,那么当前进程就会变成阻塞状态!(运行队列 -> 阻塞队列)。但是,只要我们按下回车输入内容了,系统就会读取输入的内容,然后处理相关数据。 这里会有一个很直白的问题:即系统怎么知道我们是输入了内容的?


这个问题还可以延伸到其它的硬件外设,操作系统是怎么直到这些设备是有反应的?操作系统不是在做进程调度等其他工作嘛?难道是操作系统定时进行检查?

上述的所有答案,都藏在了系统的硬件中断机制下:
首先,CPU是由很多针脚来连接硬件设备的!其中,有一部分特定的针脚,是和这些硬件外设进行直接连接的!

但是,冯诺依曼体系规定,外设是不能直接和CPU相连的:
在这里插入图片描述
冯诺依曼体系其实是规定:外设的数据是不能直接交付给CPU的,而是要先经过内存。这是红色那条线——数据信号的流向。之前,我们并没有讲黑色的数据控制信号流向。我们会发现,控制信号是可以直接在外设和CPU之间流动的!


具体的表现是,计算机中的所有外设,都会先和一个叫做中断控制器的设备连接起来,然后中断控制器再连接到CPU的特定针脚。

这里还需要再铺垫一个重要的概念——即外设其实也有控制器,也有寄存器
比如系统要对磁盘进行操作,那磁盘中的寄存器肯定是要存储一些相关数据的:
操作指令、操作的数据、操作的位置。然后通过控制器进行转化操作。

所以,所有的外设都连接在中断控制器下,然后CPU特定针脚连接该控制器。然后,当外设准备就绪后(比如按下键盘回车键),就会向中断控制器发送一个中断信号也称发起中断

每个设备的中断信号各不相同,一旦激活,就会在中断控制器中的寄存器中,存储一个中断号!并且,向CPU发送通知,此时CPU就能够知道,某个外设准备好了,就会从中断控制器中获取中断号!因为操作系统是管理软硬件资源的,所以系统就能知道硬件准备好了!

这就是硬件中断!

操作系统运行——时钟中断

那么,操作系统究竟在做什么?它是如何把所有的资源管理、进程调度、硬件状态等给统一管理起来的?
这里我们需要提前说明:操作系统的真实运行情况和我们之前所认为的是有很大的差别的!


先给出结论:操作系统在没有中断的情况下,什么也不做!

void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参见'schedule()'),因为任
* 务0 在任何空闲时间里都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这里,⼀直循环执⾏'pause()'。
*/
	for (;;)	
	pause();
} // end main

操作系统本质就是一个死循环!只有接收到了中断信号才会进行对应的操作!那么,操作系统怎么进行进程的调度呢?

答案:在计算机中是有一个时钟源的硬件设备的!这个时钟源就是记录时间戳的!即使我们电脑关机了,它也会继续计数。 这个时钟源是有计数频率的,没计数一次,就会像中断控制器发送一个中断,这也被叫做时钟中断。操作系统就是接收到这个时钟中断信号来进行进程的调度的。所有的操作都是如此,都是基于中断信号!

不过,当代的计算机认为将时钟源接到中断控制器上效率较低,所以直接在CPU内集成了一个时钟源,CPU不用通过中断控制器就能接收到时钟中断信号!

中断向量表(Interrupt Descriptor Table)

操作系统本质是一个死循环,只有接收到中断的信号才会进行相关操作。那么,操作系统是如何根据中断来进行相关操作的呢?

早在操作系统真正运行之前,就需要把相关内容初始化。其中,有一个部分是非常重要的,即中断向量表(Interrupt Descriptor Table,IDT)。


当硬件外设发送中断,并且被CPU接收到后,此时,操作系统就知道了CPU已经接收到了相关的中断信号了(中断号,每个外设不一样)。

然后,CPU就会保存现场,也就是当前寄存器的内容(进程的上下文,这个部分不是重点,就不过多介绍)。然后来处理这些中断信号。操作系统得知CPU内的中断号后,就会根据这个中断号在IDT内进行索引!
我们可以把中断向量表近似认为是一个函数指针数组,中断号,就是这个数组的下标。所以,当系统成功拿到CPU中存储的中断号后,就会在IDT内进行索引,拿到处理该中断的方法。

比如键盘发送的中断号是x,那么CPU拿到后,操作系统就会在IDT的x位置进行所以,拿到方法并且执行该方法。对于键盘而言,就是读取键盘输入的数据!

那么,时钟源也会发送中断,也有对应的中断号!系统也是拿到了中断号,在IDT中找到处理时钟中断的方法,而处理时钟中断的方法,就是进程的调度。

进程调度解析

我们需要更加清晰地了解进程调度的方式,从而对Linux操作系统有着更深的理解。

我们已经知道,操作系统本质是死循环,只有接收到中断信号才会执行对应的方法。而对于时钟的中断,处理方法是进程调度。


所以,操作系统就是一个基于中断工作的一个软件!

计算机CPU中是有主频的:
在这里插入图片描述
比如这个处理器,主频是GHz,也就是时钟源1s内发送的中断的次数。所以,在这种情况下,发送一次中断的级别是ns级别的,这里为了方便,就默认为1ns。

而每个进程单次运行都是有时间片的!这个时间片,本质就是一个计数器:

struct task_struct{
	int count;//时间片计数器
	//...
};

也就是说,假设count = 10,也就是当每个进程单词运行时间只能是10ns。即10次时钟中断。

通过这个计数器,系统就可以在调度进程的时候,不段的将时间片计数器进行自减操作,一旦时间片耗尽,就重新启动进程的调度(保存当前进程上下文,切换进程)。

当然,如果系统是刚开机不要紧。因为系统一启动就需要先联网,联网就可以从网络上获取时间,计算出时间戳,再计算出历史的总频率(从时间戳0到现在,按照当前CPU频率应该发送了多少次时钟中断)。

这样子,在操作系统内,就可以根据这个历史总频率来记录时间了!


我们下面来看看,时钟中断的处理方法(就以内核0.11为例,了解远离即可):

// Linux 内核0.11
// main.c

//首先要初始化调度进程的相关内容:
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)

// 调度程序的初始化子程序。
void sched_init(void){
...
// 将时钟中断加入到IDT,下标0x20
	set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
	outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调用中断门。
	set_system_gate(0x80, &system_call);
...
}

// system_call.s 时钟中断的处理方法是用汇编语言写的
// 因为c语言先会被转化为汇编,所以c和汇编是可以混着写的
_timer_interrupt:
...

// do_timer(CPL)执⾏任务切换、计时等工作,在kernel/shched.c,305 ⾏实现。
call _do_timer; // 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl){
...
	if(--current->count > 0){// 继续执行当前进程}
// 反之重新进行调度
	schedule();
}

void schedule(void){
...
	switch_to(next); // 切换到任务号为next 的任务,并运行。
}

软中断

前面都是由硬件设备发送的中断,但是有没有一种可能,由软件执行直接产生中断呢?
答案是有的:而且分为两种。

软件引起的硬件异常

信号的产生的一种方式就是——硬件异常!
比如除0操作,如果结果溢出,就会在CPU中的EFLAGS寄存器中存储溢出状态。如果发生了段错误(野指针,指针重复释放),也是CPU寻物理地址的时候报错,由MMU,CR3寄存器来报错,这些都是软件导致的硬件异常。

而这种方式产生的硬件异常,都是在CPU内直接记录的。一旦发生异常,也会直接在CPU内触发中断,即发出中断信号!这种信号被规定为CPU内的中断。

操作系统——基于中断进行工作的软件。接收到了这种中断后,也是会在中断向量表中进行索引,找到处理中断的方法!


解决方法:
浮点数计算溢出 -> 直接报错杀掉进程
段错误 -> 寻址失败、重复释放空间 -> 报错杀掉进程

这里特别需要提到的一点——缺页中断
就是我们曾经在文件系统中讲过,系统操作文件,需要把文件加载到内存上。但是,内存可能空间不足,所以操作系统会把文件的虚拟地址先分配好,然后在物理内存上先放一部分。一旦发现当前虚拟地址指向的文件内容不在物理内存上,就会触发缺页中断机制!

其实所谓的缺页中断,其实也是软件产生的异常(这是操作系统层面的),一旦出现上述情况,CPU内就会发出缺页中断,然后系统就可以在IDT中找到处理缺页中断的方法,即把文件后序内容导入。如果文件过大,就会触发多次缺页中断!

我们可以从源码中查看到这些:

void trap_init(void){
	int i;
// 设置除操作出错的中断向量值。以下雷同
	set_trap_gate(0,&divide_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,&parallel_interrupt);// 设置并行的陷阱门。
}

上述展示的就是Linux系统下,一些硬件异常产生的中断。操作系统早在开始运行之前,就把这些内容给初始化好了。

陷阱——理解系统调用

但是,还有一种情况,不是因为硬件异常,而是用户层主动触发中断!

在CPU的指令集中,有这么一个指令,是可以主动触发中断的:

x86架构下:int 0x80
x86_64架构下:syscall 0x80
其中:0x80是IDT下标

这个指令,可以让当前系统直接触发一次中断,然后执行中断向量表中的方法。


那么,用户层为什么要主动触发中断呢?
这个其实和系统调用是有关的!我们肯定会有疑问,用户层凭什么直接执行系统调用呢?系统调用的方法是内核中的代码,凭什么能够执行呢?

其实,这是因为:系统调用的执行,就是通过软件层的主动中断来进行的!

软件曾主动触发的中断,中断号是0x80。而IDT表中,对应的处理方法是CallSystem()
也就是说,当我们使用系统调用的时候,如open、fork…,系统会主动的触发中断,然后此时就由操作系统来接管,来执行处理中断的方法。而0x80的处理方法,就是执行系统调用!

系统调用有很多,所以,在Linux内核中,会有一个系统调用表,其实就是一个数组。每个系统调用都对应着一个系统调用号:

// 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)
extern int sys_time (); // 取当前时间。 (kernel/sys.c, 102)
extern int sys_mknod (); // 建⽴块/字符特殊⽂件。 (fs/namei.c, 412)
extern int sys_chmod (); // 修改⽂件属性。 (fs/open.c, 105)
extern int sys_chown (); // 修改⽂件宿主和所属组。 (fs/open.c, 121)
extern int sys_break (); // (-kernel/sys.c, 21)
extern int sys_stat (); // 使⽤路径名取⽂件的状态信息。 (fs/stat.c, 36)
extern int sys_lseek (); // 重新定位读/写⽂件偏移。 (fs/read_write.c, 25)
extern int sys_getpid (); // 取进程id。 (kernel/sched.c, 348)
extern int sys_mount (); // 安装⽂件系统。 (fs/super.c, 200)
extern int sys_umount (); // 卸载⽂件系统。 (fs/super.c, 167)
extern int sys_setuid (); // 设置进程⽤⼾id。 (kernel/sys.c, 143)
extern int sys_getuid (); // 取进程⽤⼾id。 (kernel/sched.c, 358)
extern int sys_stime (); // 设置系统时间⽇期。 (-kernel/sys.c, 148)
extern int sys_ptrace (); // 程序调试。 (-kernel/sys.c, 26)
extern int sys_alarm (); // 设置报警。 (kernel/sched.c, 338)
extern int sys_fstat (); // 使⽤⽂件句柄取⽂件的状态信息。(fs/stat.c, 47)
extern int sys_pause (); // 暂停进程运⾏。 (kernel/sched.c, 144)
extern int sys_utime (); // 改变⽂件的访问和修改时间。 (fs/open.c, 24)
extern int sys_stty (); // 修改终端⾏设置。 (-kernel/sys.c, 31)
extern int sys_gtty (); // 取终端⾏设置信息。 (-kernel/sys.c, 36)
extern int sys_access (); // 检查⽤⼾对⼀个⽂件的访问权限。(fs/open.c, 47)
extern int sys_nice (); // 设置进程执⾏优先权。 (kernel/sched.c, 378)
extern int sys_ftime (); // 取⽇期和时间。 (-kernel/sys.c,16)
extern int sys_sync (); // 同步⾼速缓冲与设备中数据。 (fs/buffer.c, 44)
extern int sys_kill (); // 终⽌⼀个进程。 (kernel/exit.c, 60)
extern int sys_rename (); // 更改⽂件名。 (-kernel/sys.c, 41)
extern int sys_mkdir (); // 创建⽬录。 (fs/namei.c, 463)
extern int sys_rmdir (); // 删除⽬录。 (fs/namei.c, 587)
extern int sys_dup (); // 复制⽂件句柄。 (fs/fcntl.c, 42)
extern int sys_pipe (); // 创建管道。 (fs/pipe.c, 71)
extern int sys_times (); // 取运⾏时间。 (kernel/sys.c, 156)
extern int sys_prof (); // 程序执⾏时间区域。 (-kernel/sys.c, 46)
extern int sys_brk (); // 修改数据段⻓度。 (kernel/sys.c, 168)
extern int sys_setgid (); // 设置进程组id。 (kernel/sys.c, 72)
extern int sys_getgid (); // 取进程组id。 (kernel/sched.c, 368)
extern int sys_signal (); // 信号处理。 (kernel/signal.c, 48)
extern int sys_geteuid (); // 取进程有效⽤⼾id。 (kenrl/sched.c, 363)
extern int sys_getegid (); // 取进程有效组id。 (kenrl/sched.c, 373)
extern int sys_acct (); // 进程记帐。 (-kernel/sys.c, 77)
extern int sys_phys (); // (-kernel/sys.c, 82)
extern int sys_lock (); // (-kernel/sys.c, 87)
extern int sys_ioctl (); // 设备控制。 (fs/ioctl.c, 30)
extern int sys_fcntl (); // ⽂件句柄操作。 (fs/fcntl.c, 47)
extern int sys_mpx (); // (-kernel/sys.c, 92)
extern int sys_setpgid (); // 设置进程组id。 (kernel/sys.c, 181)
extern int sys_ulimit (); // (-kernel/sys.c, 97)
extern int sys_uname (); // 显⽰系统信息。 (kernel/sys.c, 216)
extern int sys_umask (); // 取默认⽂件创建属性码。 (kernel/sys.c, 230)
extern int sys_chroot (); // 改变根系统。 (fs/open.c, 90)
extern int sys_ustat (); // 取⽂件系统信息。 (fs/open.c, 19)
extern int sys_dup2 (); // 复制⽂件句柄。 (fs/fcntl.c, 36)
extern int sys_getppid (); // 取⽗进程id。 (kernel/sched.c, 353)
extern int sys_getpgrp (); // 取进程组id,等于getpgid(0)。(kernel/sys.c, 201)
extern int sys_setsid (); // 在新会话中运⾏程序。 (kernel/sys.c, 206)
extern int sys_sigaction (); // 改变信号处理过程。 (kernel/signal.c, 63)
extern int sys_sgetmask (); // 取信号屏蔽码。 (kernel/signal.c, 15)
extern int sys_ssetmask (); // 设置信号屏蔽码。 (kernel/signal.c, 20)
extern int sys_setreuid (); // 设置真实与/或有效⽤⼾id。 (kernel/sys.c,118)
extern int sys_setregid (); // 设置真实与/或有效组id。 (kernel/sys.c, 51)

// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(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对应的数组下标!


但是,这些系统调用前面都是带sys前缀的,这些都是内核中的代码,我们没有办法直接使用啊,那么,我们常用的系统调用(open, fork…)是如何调用的呢?

其实很简单,我们常说的2号手册下的系统调用,其实也是经过glibc库进行封装的!只不过,范畴是系统调用。其实这个封装也很简单,就分为两步:

// 就以open为例
int open(const char *pathname, int flags);{
...
	// 1.把系统调用号移动到CPU寄存器
	move eax 5
	// 2. 主动触发中断
	syscall 0x80
...
}
// 没有看错,这个系统调用的封装,是汇编语言!!!

当然,实际上是更复杂,但是主要逻辑就是这两步。一旦主动触发了中断,系统就会解决中断。会把从寄存器拿来的系统调用号在系统调用表中索引,从而进行系统的调用。


处理0x80中断的过程:

// 调度程序的初始化子程序。
void sched_init(void){
...
// 设置系统调用中断门。
	set_system_gate(0x80, &system_call);
}

system_call:
	cmp eax,nr_system_calls-1 ;// 调⽤号如果超出范围的话就在eax 中置-1 并退出。
	ja bad_sys_call
	push ds ;// 保存原段寄存器值。
	push es
	push fs
	push edx ;// ebx,ecx,edx 中放着系统调⽤相应的C 语⾔函数的调⽤参数。
	push ecx ;// push %ebx,%ecx,%edx as parameters
	push ebx ;// to the system call
	mov edx,10h ;// set up ds,es to kernel space
	mov ds,dx ;// ds,es 指向内核数据段(全局描述符表中数据段描述符)。
	mov es,dx
	mov edx,17h ;// fs points to local data space
	mov fs,dx ;// fs 指向局部数据段(局部描述符表中数据段描述符)。
// 下⾯这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。参见列表后的说明。
;// 对应的C程序中的sys_call_table 在include/linux/sys.h 中,其中定义了⼀个包括72个
;// 系统调用C处理函数的地址数组表。
	call [_sys_call_table+eax*4]
	
	push eax ;// 把系统调用号⼊栈。
	mov eax,_current ;// 取当前任务(进程)数据结构地址??eax。
;// 下⾯97-100 ⾏查看当前任务的运⾏状态。如果不在就绪状态(state 不等于0)就去执⾏调度程序。
;// 如果该任务在就绪状态但counter[??]值等于0,则也去执⾏调度程序。
	cmp dword ptr [state+eax],0 ;// state
	jne reschedule
	cmp dword ptr [counter+eax],0 ;// counter
	je reschedule
;// 以下这段代码执⾏从系统调用C函数返回后,对信号量进⾏识别处理。
ret_from_sys_call:

对于提供给用户的系统调用,也是可以看一下:
在这里插入图片描述
这是早期内核系统调用的封装。后期为了考虑更多的兼容性,系统调用的封装至会更加复杂。

但是我们还是可以看到上述提及的两个主要逻辑:

至此,我们就明白了,为什么用户层能够使用到系统调用!

而由软件主动触发的中断(不是导致硬件异常),这种叫做陷入内核,也称为陷阱。

内核态和用户态

但是,还差一个问题没有解决,即如何切换内核态和用户态呢?因为执行系统调用、或者是处理中断的时候,都是要从用户态转化到内核态进行操作的!

前面我们忽略了这个转化的条件,理解到了操作系统的运行本质:
即一个基于中断信号工作的软件。本质就是一个死循环。只有接受到中断才根据对应的中断工作,处理不同的中断。但是我们并没有说,为什么用户态可以切换为内核态来进行操作

用户态、内核态切换的本质

在这里插入图片描述

其实,所谓的用户态和内核态,其实就是CPU内CS(Code Segment)段寄存器的一小部分字段而已,也就是最后两个位置。

如果最后两个位都是0,即整数0,代表内核
如果最后两个位都是1,即整数3,代表用户

一般来说,表示当前态的字段叫作CPL(Curernt Process Level)。

所以,所谓的态性切换,就是这个CPL字段在0和3之间进行切换!

态性切换的过程

这个部分,我们需要在了解以下具体的切换流程,以及操作系统是怎么样在内核态和用户态中来回切换并进行代码的执行的。

在这里插入图片描述

回看这张图,我们需要再次对进程地址空间进行理解。


首先,每个进程都会有一个虚拟的进程地址空间,且被分为两个大的部分(以32位,4GB为例)

[0, 3]GB是用户区,用户态可以直接使用的内容/代码。
[3, 4]GB是内核区,只有内核态才能使用的内容/代码!

那么,所谓的执行系统调用,其实也是在当前的进程地址空间上执行的!也就是说,其实进程是看的见内核的相关内容的!所以,不管是自定义函数、还是系统调用,本质上都是当前进程地址空间上跳转使用的!


通过上述得知,在[0, 4]GB下,用户态和内核态是共存的。那么,如果说,用户态中随便使用一个[3, 4]GB的地址,那不就能直接使用到内核中的数据和代码了吗?这保护不了内核啊。

这个不用担心,可以通过CPL字段来进行操作!
如果当前CPL段值为3(用户态),那么直接使用系统调用是会被驳回的!OS不允许任何的用户来直接访问内核。用户要访问内核,必须通过系统调用!

当用户使用系统调用的时候,会主动的触发中断,此时,CPL段就会修改为0,此时就完成了态性的转换!然后再通过处理该中断的方式进行接口的使用。使用完后,回到用户态,就需要再把CPL段切换为3。这样就能保护内核了!

代码执行流程

最后,我们来疏通一下代码的执行流程。

通过上面的学习,我们已经知道:
所谓的系统调用、或者用户态代码,其实都是在同一个进程地址空间下执行的。无非就是地址的跳转,从而通过页表,在物理内存中找到对应的代码和数据执行!

只不过是说,跳转到内核区的地址,是需要一定的权限的!即从用户态切换到内核态。但是为了保护内核,即使切换到内核态,也是通过内核中提供的安全的方法来进行访问。


而操作系统也是一个软件,那么它的所有代码和数据也是要加载到物理内存上!
所以,访问系统调用,其实也是通过内核区地址,索引页表,来找到访问的数据和代码的。

对于页表来说,一个进程地址空间是配套两个页表的!一个是用户页表,一个是内核页表。
用户页表是每个进程独有一份,因为每个进程的代码和数据是各不相同的。
但是,内核页表是所有进程共享一份。因为这是属于内核的数据,所有进程使用的都是一样的,使用方法也都一样。所以只需要一份即可,节省空间!

操作系统的运行总结

所以,通过上面一系列内容的学习,我们就已经很清楚的明白:

1.操作系统是基于中断信号工作的。
2.操作系统的本质就是一个躺在中断信号上的死循环代码块。

所以,操作系统就不断地检查是否有对应的中断信号。所有的进程的操作,最终被调度的时候都是通过中断信号来控制。不同的中断都有不同的处理方法!

具体到执行系统调用,需要通过主动陷入内核(软中断),同时让用户态切换为内核态,再来执行相应的系统调用。上层使用的,永远都是封装的。


可以发现,信号机制和中断机制是类似的:

发中断 -> 发信号
中断号 -> 信号编号
保存中断 -> 保存信号
处理中断 -> 信号递达

所以,信号机制其实是基于中断机制思想进行操作的。


最后来解决前面的问题:为什么while(1){}这样的代码,没有系统调用,也没有硬件异常,但是却还能进入到内核态检查信号呢?

就是因为时钟中断!时钟源不断地发送时钟中断,每发送一次,操作系统就要处理一次时钟中断。处理方法就是检查调度情况(内核态):
如果当前这个进程的时间片还没有结束,就不管,退出处理,直接检查信号并返回。
反之,重新调度进程,再来检查信号。

所以,所有的进程,无时无刻都会因为当前的时钟源发送中断而切换到内核态,从而进行信号的检查。这再一次证明了——操作系统就是一个基于中断工作的软件!

捕捉信号——sigaction

捕捉信号的方式其实我们已经学习过了,就是使用接口signal,但其实,捕捉信号的方式还有另外一种:sigaction

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);


struct sigaction {
	void     (*sa_handler)(int);
	void     (*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask;
	int        sa_flags;
	void     (*sa_restorer)(void);
};

//到这里就可以看出来,是通关结构体来设置信号的捕捉操作,而且,oact是把操作前的情况拷贝下来。

这个接口就是对signo信号进行捕捉,只不过相对于signal接口而言,
sigaction的功能会多一些。

这个接口需要把我们需要设置的功能放在一个结构体sigatcion里面,我们可以很惊奇地发现,这个结构体的名字和接口是一样的。

结构体中,我们只需要了解sigset_t sa_maskvoid (*sa_handler)(int)即可。其余的都是用来操作实时信号的,我们这里不管。


我们先只设置void (*sa_handler)(int),这样子用法就和signal一样:

#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>

void handler(int sig){
    cout << "进程" << getpid() << " 接收到信号" << sig << endl;
}

// 测试sigaction
int main(){
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigaction(SIGINT, &act, &oact);

    while(1){
        cout << "my pid is " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述


但是,这个接口是还有别的功能,这里我们需要了解一下信号处理的一些细节:

1.这个我们测试过:处理信号的时候,是先把pending表置0,然后再来处理信号的。
2.这个需要重点关注:即当某个信号正在处理的时候,该信号的会被自动阻塞,直到信号处理完成,信号才会解除屏蔽!

我们来测试一下第二点:

#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>

void PrintPending(sigset_t& pending){
    for(int i = 31; i >= 1; --i){
        int ret = sigismember(&pending, i);
        cout << ret;
    }
    cout << endl;
}

void handler(int sig){
    cout << "进程" << getpid() << " 接收到信号" << sig << endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);

    //不让这个函数退出
    while(1){
        // 在这里的时候,再次发送信号,看一下pending表
        // 如果pending表中位置变成 0 -> 1, 那么就说明被阻塞了
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }
    
}

// 测试sigaction
int main(){
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0; // 这个可能会影响结果,所以初始化一下
    sigaction(SIGINT, &act, &oact);

    while(1){
        cout << "my pid is " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述
到此,我们就成功地验证了这个结论!这么做的原因是,如果允许一直发送信号递达,那么就会无限递归调用处理函数。这种情况OS是不允许的!

所以,结构体sigaction中,sig_mask就是用来屏蔽一些信号的。即捕捉某个信号的同时,把指定信号给屏蔽,屏蔽的信号就是sig_mask中置1位置对应的信号。

#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>

void PrintPending(sigset_t& pending){
    for(int i = 31; i >= 1; --i){
        int ret = sigismember(&pending, i);
        cout << ret;
    }
    cout << endl;
}

void handler(int sig){
    cout << "进程" << getpid() << " 接收到信号" << sig << endl;
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);

    //不让这个函数退出
    while(1){
        // 在这里的时候,再次发送信号,看一下pending表
        // 如果pending表中位置变成 0 -> 1, 那么就说明被阻塞了
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }
    
}

// 测试sigaction
int main(){
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0; // 这个可能会影响结果,所以初始化一下

    //将3 ~ 9号屏蔽
    sigset_t mask;
    for(int i = 3; i < 10; ++i){
        sigaddset(&mask, i);
    }
    act.sa_mask = mask;

    sigaction(SIGINT, &act, &oact);

    while(1){
        cout << "my pid is " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述
我们发现,确实是如此,但是有两个信号我们始终要记住:9号和19号,这两个信号是不可以被捕捉和屏蔽的!

可重入函数

这里简单讲解一下可重入函数的概念:
在这里插入图片描述
如上图所示,如果一个main函数在执行insert插入单链表节点的时候,很不巧,很有可能在没有执行完整个过程(刚执行完p->next = head,还没有更改头指针指向),就受到了信号。那么系统立马去执行信号,但是处理信号的时候,又再次进行节点头插。

经过上面的图分析,最后会发现,回到main函数调用的insert函数时,又把头指针指向了node1。那么,node2就被孤立掉了,我们没办法去找到这个节点了。这就导致了内存泄露。

这种情况本质原因就是因为:
insert函数有两个执行流(main和sighandler),它们一前一后执行insert函数,也被称为:
insert函数被重入了!


这里只做了解即可,到时候在多线程部分还会继续学习的。目前只需要知道:
函数类型可以被分为:
在这里插入图片描述
这是函数的特点,而不是优缺点!这里做一下了解即可。

volatile

这其实是c语言中的32个关键字之一,这个关键字其实没怎么见到过。

#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>

// 测试 volatile

int flag = 0;

void handler(int sig){
    cout << "进程" << getpid() << " 接收到信号" << sig << endl;
    flag = 1;
}

int main(){
    signal(2, handler);
    while(!flag){
        cout << "falg : " << flag << " pid is " << getpid() << endl;
    }
    return 0;
}

在这里插入图片描述
这段代码不解释了,我们只说原理:
CPU是一个用来作运算的器件,也就是逻辑运算和算数运算。如上面的代码:

在这里插入图片描述
这是正常情况下CPU的操作。
所以,当信号处理函数把flag修改为1的时候,会运算后再把数据重新写入内存。


但是,系统是没有办法识别出来两个执行流的。上述代码中,是有两个执行流在运算flag,一个是main中的逻辑运算,一个是handler中的算术运算。

在一些编译器优化程度比较大的情况下,没办法识别出来有两个执行流在计算同一个内存中的数据。所以,编译器只看到main函数中,对flag操作始终就只有逻辑运算!
在这里插入图片描述
我们来试试看:

在gcc/g++编译器下,优化程度(选项):
-O0 无优化(默认情况)
-O1 一级优化
-O2 二级优化
-O3 三级优化

#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>


// 测试 volatile

int flag = 0;

void handler(int sig){
    cout << "进程" << getpid() << " 接收到信号" << sig << endl;
    flag = 1;
}



int main(){
    signal(2, handler);
    while(!flag){}
    cout << "正常退出进程" << endl;
    return 0;
}

在这里插入图片描述
我们会发现,优化结果是比较奇怪的。所以,为了防止这种情况,需要对修改的变量加上关键字volatile,表示对于这个变量要完整走那三个步骤!

加上volatile关键字
在这里插入图片描述
就是正常的情况了。这里了解一下即可,用的其实很少。
这里只不过是通过信号的知识点来验证这个关键字的使用!

SIGCHLD信号

最后,我们需要讲一个也是比较特殊的信号——17) SIGCHLD信号。
这个信号是子进程退出的时候发给父进程的:

SIGCHLD      P1990      Ign     Child stopped or terminated

只不过,父进程默认是不接收这个信号,但是子进程会变成僵尸状态(父进程不回收)。


SIGCHLD的使用样例

既然说,子进程结束要给父进程发信号,那么可以再在发信号的时候,父进程处理信号是时候来回收子进程:

同时,需要使用一个监控窗口来观察进程的情况:

 while :; do ps -ajx | head -1 && ps -ajx | grep Test.exe | grep -v grep ; 
 sleep 1; done
#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int sig){
    //回收任意一个子进程
    int rd = waitpid(-1, nullptr, 0);
    if(rd < 0){
        cout << "回收失败" << endl;
    }
    else{
        cout << "回收成功" << endl;
    }
}


int main(){
    signal(SIGCHLD, handler);


    for(int i = 0; i < 10; ++i){
        //一次性创建10个子进程
        pid_t id = fork();
        if(id < 0){
            cerr << "fork error" << endl;
            return id;
        }
        else if(id == 0){
            // 子进程
            sleep(3);
            cout << "process" << getpid() << " over!" << endl;
            exit(1);
        }
    }

    //父进程
    while(1){
        cout << "i am father, pid is " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

在这里插入图片描述
最后我们会发现,还是有进程属于僵尸状态,这是为什么?

因为信号可能重叠发送,当处理信号的时候,该信号被屏蔽了!所以,个别信号丢失了,没来的及处理,所以导致这种情况发生。


为了解决这个问题,需要在处理信号的时候不断地进行回收信号:

void handler(int sig){
    //回收任意一个子进程
    while(1){
        int rd = waitpid(-1, nullptr, 0);
        if(rd < 0){
            cout << "回收失败" << endl;
            break;
        }
        else{
            cout << "回收成功" << endl;
        }
    }
}

在这里插入图片描述
这一次,通过监控窗口可以发现,所有的子进程都被回收了,但是运行的终端显示器上还是打印了回收失败!其实这也很好理解:
因为还是有信号重叠的情况。但是因为有些信号被阻塞来不及处理。但是在handler函数中强行的把所有子进程回收了。所以,后序来不及处理的信号递达时,因为没有子进程了,所以就等待失败了!但是进程是全被回收了!


但是,这样子写是有问题的,因为采用的是阻塞等待,如果一旦有进程不退出,那么父进程接收信号的时候就一直卡在等待子进程的位置:

int main(){
    signal(SIGCHLD, handler);


    for(int i = 0; i < 10; ++i){
        //一次性创建10个子进程
        pid_t id = fork();
        if(id < 0){
            cerr << "fork error" << endl;
            return id;
        }
        else if(id == 0){
            // 子进程
            sleep(3);
            cout << "process" << getpid() << " over!" << endl;
            if(i >= 7){
                pause();
            }
            exit(1);
        }
    }

    //父进程
    while(1){
        cout << "i am father, pid is " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

比如上面,让第七个被创建的子进程开始的所有子进程全部阻塞:
在这里插入图片描述
确实是如此,卡在了等待子进程的位置。父进程也不会继续运行了。
对此,解决方案是:回收子进程的方式变成WHOHANG


#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int sig){
    //回收任意一个子进程
    while(1){
        int rd = waitpid(-1, nullptr, WNOHANG);
        if(rd < 0){
            cout << "回收失败" << endl;
            break;
        }
        else if(rd == 0){
            cout << "本轮回收结束" << endl;
            break;
        }
        else{
            cout << "回收成功" << endl;
        }
    }
}

int main(){
    signal(SIGCHLD, handler);


    for(int i = 0; i < 10; ++i){
        //一次性创建10个子进程
        pid_t id = fork();
        if(id < 0){
            cerr << "fork error" << endl;
            return id;
        }
        else if(id == 0){
            // 子进程
            sleep(3);
            cout << "process" << getpid() << " over!" << endl;
            if(i >= 7){
                pause();
            }
            exit(1);
        }
    }

    //父进程
    while(1){
        cout << "i am father, pid is " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

一旦轮询式回收返回值为0,就代表本轮回收已经结束了,就可与直接进行退出,执行父进程代码,这样子不会阻碍父进程的代码运行。

在这里插入图片描述

每当我们杀掉一个子进程,父进程就会进行一轮回收,这样子就解决了。
在这里插入图片描述

SIGCHLD的特殊性

其实上面只是为了演示一下这个信号的作用,其实不用这么麻烦的。

Linux系统下(其他类Unix系统不行),如果把SIGCHLD信号的处理动作设置为SIG_IGN,那么子进程就不需要等待父进程回收,直接就可以退出!(前提是不关心子进程的退出情况)。

#include <iostream>
using namespace std;
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int sig){
    //回收任意一个子进程
    while(1){
        int rd = waitpid(-1, nullptr, WNOHANG);
        if(rd < 0){
            cout << "回收失败" << endl;
            break;
        }
        else if(rd == 0){
            cout << "本轮回收结束" << endl;
            break;
        }
        else{
            cout << "回收成功" << endl;
        }
    }
}

int main(){
    //signal(SIGCHLD, handler);

    // 设置SIGCHLD信号的处理动作为SIG_IGN
    signal(SIGCHLD, SIG_IGN);

    for(int i = 0; i < 10; ++i){
        //一次性创建10个子进程
        pid_t id = fork();
        if(id < 0){
            cerr << "fork error" << endl;
            return id;
        }
        else if(id == 0){
            // 子进程
            sleep(3);
            cout << "process" << getpid() << " over!" << endl;
            exit(1);
        }
    }

    //父进程
    while(1){
        cout << "i am father, pid is " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

在这里插入图片描述
注意:只有Linux系统能这么使用!


这里解释一下:
虽然SIGCHLD默认行为是Ign,也就是忽略。但是,这个忽略和我们显式设置忽略是不一样的。

其实SIGCHLD默认动作时SIG_DFL,只不过是对于该信号,行为是Ignore,忽略这个信号的SIG_DFL动作,不代表默认动作是Ign。子进程需要等待父进程回收。

但是,显示设置的时候,就是把子进程发送的信号给忽略处理了,也就是子进程不需要被父进程回收,这是一个特殊的地方!记住即可!


网站公告

今日签到

点亮在社区的每一天
去签到