我们和进程凭什么进入内核?
while(true){} 这种代码运行时也是一个进程,它也会进入内核吗?
答案是是的,因为这中代码运行起来也是一个进程,它也会被调度,Linux 中的进程持有 CPU 不是说把这个进程跑完才这个进程才结束,每一个进程都有它自己的时间片,时间片到了操作系统就会把当前进程剥离下来,这个进程就不会被调度了,当下次调度到你时你才重新上,看起来我们的 while 循环一直被打印,其实我们的 while 循环在一直消耗时间片,时间片消耗完了就把此进程从 CPU 上拿下来了。进程的时间片到了之后操作系统就强制性的不让此进程执行,强制不让此进程执行就是操作系统强制介入,最后强制进入内核态。
1.2 穿插话题 - 操作系统是怎么运行起来的
1.2.1 硬件中断
当我们按下硬件设备时,操作系统就会向 CPU 的针脚发送一个叫做硬件中断的东西。
我们之前讲的冯诺依曼体系结构中,输入设备必须将数据从外设先搬到内存中,然后 CPU 只去访问内存就可以,这叫做数据信号,但并不意味着 CPU 不能和外设相连接,我们的外设可以和 CPU 通过线路连接到一起,只不过不以拷贝数据为目的,主要是为了传递信息。
CPU 上面的针脚可以间接的和外部设备做信息沟通。其实外部设备它的这些中断信息并没有和 CPU 直接相连,在硬件上会有一个叫做中断控制器的东西。
寄存器不一定在 CPU 中才有,在外部设备中也有。
硬件设备是让 CPU 知道某个外部设备'准备好了',但是是写还是读 CPU 并不知道,所以我们又来引申出来一个概念叫做中断向量表(IDT),其实就是一个函数指针数组。这个数组的内容是提取中断的中断方法,数组的下标表示提取中断的中断号。IDT 是操作系统的一部分。
所以总的流程就是外部设备'准备好了',CPU 通过中断控制器来获取中断号,然后 CPU 执行内核代码访问中断向量表,拿着获取的中断号,找到对应的中断方法。
上述的外部硬件中断,需要硬件设备触发,也有可能是软件的原因也触发上面的逻辑。为了让操作系统支持系统调用,CPU 也涉及到了对应的汇编指令(int 或者 syscall),可以让 CPU 内部触发中断逻辑。
假设 cpu 的主频为 1ns,那么 cpu 经过 1ns 就触发一次中断,在这 1ns 里,我们的进程在执行自己的代码,如果今天我们的代码里面有一个除零错误,在 cpu 的内部有一个标志寄存器 EFLAGS,它会发现我们的计算结果发生了 cpu 硬件上的溢出。以前 cpu 出错了 cpu 就不知道该怎么办,后来我们的程序设计者规定成为一种由 cpu 内部触发的中断,一旦检测出 cpu 内部出现错误,cpu 自己就会生成出一个中断号。它自己生成的中断一旦产生了,cpu 就要停下来处理这个中断,中断号一来,我们就要带着中断号在中断向量表里查找对应的中断处理方法。比如说异常处理,给目标进程发送信号。
操作系统是怎么知道硬件出异常了呢?
答案是通过中断,操作系统会自动注册中断处理方法。
比如我们以前所说的虚拟地址和物理地址找不到对应的映射关系就会产生缺页中断 page_fault()。
我们把这种没有外部设备驱动的由 CPU 内部,由软件触发的错误我们称作为软中断
我们上面所说的除 0 操作,野指针操作都是软件问题导致 cpu 硬件出错而产生中断,有没有一种可能让 CPU 内部,直接通过软件让 CPU 主动产生中断呢?
答案是有的,CPU 在自己的"指令集"中引入了两个新指令
在 32 位下叫做 int,在 64 位下叫做 syscall
总结:从现在开始我们知道的信号捕捉的方法有两种,一种叫做 signal 一种是 sigaction,sigaction 对普通信号来讲为我们提供了能够捕捉信号时屏蔽其他信号的能力(默认也会屏蔽自己),当信号处理函数完成时它会解除屏蔽。因此就不存在递归式的信号处理了。当解除屏蔽的时候此信号会被立即抵达。
二、可重入函数
main 函数调用 insert 函数向一个链表 head 中插入节点 Node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换至内核,再次回到用户态之前发现有信号要处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回到内核,再次回到用户态就从 main 函数调用的 insert 函数中继续进行,先前做的第一步后被打断,现在做第二步。结果是 main 函数和 sighandler 都向链表中插入了两个节点,而最后只有一个节点插入到链表中了,Node2 节点丢失了,这就会造成内存泄漏。
像这样的 main 执行流和 sighandler 执行流 insert 方法被两个执行流重复进入了,函数被重复进入代码出了问题则称此函数为不可重入函数,函数被重复进入了代码没有出问题则称此函数为可重入函数。
#include<stdio.h>#include<signal.h>int flag = 0;
voidhandler(int sig) {
printf("chage flag 0 to 1\n");
flag = 1;
}
intmain() {
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return0;
}
上述代码的执行逻辑是按理来说代码会一直在 while 循环这里,但是当我们发送二号信号,将 flag 从 0 置为 1,当 while 再次判断时 !1 就为 0,while 执行结束,就打印 process quit normal
在我们的 main 函数的执行流里面,并不会对 flag 做修改,那么就有人会说 handler 修改了,编译器中没有执行流的概念。编译器发现在整个 main 执行流的范畴内,编译器没有对 flag 进行修改,编译器默认 main 只对 flag 做检查,编译器是识别不到 flag 被修改的。比如说编译器在优化级别较高的情况下,编译器就会把我们的 flag 变量直接优化到 register 寄存器当中。
我们的 cpu 一般有两种计算模式:逻辑计算和物理计算。我们的数据是保存在内存中的,当我们要对某个数进行计算,1 先要把这个数从内存导入到 cpu 当中,2 在 cpu 内部进行对应的计算,3 如果需要就写回到内存中,如果不需要就不写回。总之少不了把数从物理内存导到 cpu 中才能进行计算。
我们的 while(!flag) 起始就是不断地把数从物理内存导到 cpu 中进行判断,我们来了一个 handler 方法把 flag 有 0 -> 1 了,while 就循环结束了。可是我们的编译器有可能把我们的 1,2 两步进行优化了,因为 flag 只在 while 循环中做判读,并不被修改,所以编译器就会给 flag 加一个建议性的关键字 register,并且 flag 为 0,所以在将来编译运行时直接把 0 加载到 cpu 寄存器中,从此 while 循环只需要检查寄存器中的值是否为真就行。
标准情况下,编译器不会优化我们的代码,当我们编译时加入 -O 选项时编译器就会对我们的代码进行优化,-O1 比 -O 更高的优化,-O2 是比 -O1 更高的优化。
我们的 CPU 通常会处理两种逻辑,计算逻辑和条件逻辑,执行的过程是:
从内存中将数据加载到 CPU 中 —> 在 CPU 中计算 —> 若需要时则返回。
当我们将我们的上述代码编译时加入 -O 选项,编译器发现我们的 flag 在 while 循环中只是充当判断条件,而并没有做任何的修改,就会把我们的 flag 优化到寄存器中,这样当 CPU 加载 flag 时就会把 flag 从内存中加载到 CPU 这一步骤省略了,直接从寄存器中读取,这样读取到的 flag 就会一直为 0,while 判断就会为真,发送 2 号信号就不会打印了。
如果我们不想被编译器优化呢?很明显需要 volatile
#include<stdio.h>#include<signal.h>volatileint flag = 0;
voidhandler(int sig) {
printf("chage flag 0 to 1\n");
flag = 1;
}
intmain() {
signal(2, handler);
while (!flag);
printf("process quit normal\n");
return0;
}