《Linux内核艺术》——第6章 用户进程与内存管理

现代操作系统的重要特征就是支持实时多任务——同时运行多个程序。运行中的程序被称为进程。在类UNIX操作系统的设计者看来,操作系统的核心就是进程。
所谓的操作系统就是若干个正在运行、操作的进程构成的系统。按照这个思路,进程的创建只可能由进程承担,也就是父子进程创建机制。在任何情况下,至少得有
一个进程留守,这就是进程0。与计算机使用者交互也是由专门的进程(即shell)负责。总之,一切皆为进程。

在一台计算机只有一个CPU、一个CPU只有一个核的时代,多个进程同时运行的本质是分时轮流运行。确保多进程同时正确运行,就必须解决两个关键问题:一
个是如何防止多进程同时运行时,一个进程的代码、数据不会被其他进程直接访问、覆盖;另一个是如何做到多进程有序轮流执行。

第一个问题涉及进程保护,第二个问题涉及进程调度。

1. 进程保护

1.1 线性地址

cpu为32位寻址,所以线性地址为 0-4GB
打开cpu的PG前为实模式,线性地址恒等于物理地址,
打开PG后为保护模式,线性地址需要 MMU映射 为物理地址。

为了实现进程隔离,Linux0.11在线性地址层面进行了保护,
将 0-4GB 的线性地址空间,分为 64份,每份 64MB,
task[64]每个任务对应一份,所以Linux0.11的应用程序的虚拟空间为64MB.
注意,内核虽然也运行在保护模式,但是内核的线性地址和物理地址恒等,
回顾Linux0.11对物理内存的划分可知,内核分配了一段固定的物理空间,
用户进程共享一段用于映射的物理空间,缓冲块又是另一段固定的物理空间。

所以线性地址隔离的核心是 线性地址的基地址,每个进程的每个页的基地址是通过LDT记录的,
64个进程,每个进程占用GDT两项,一项是TSS,一项是LDT,所有进程的LDT的设计是完全一样的,
每个LDT都有3项,第一项为空,第二项是进程的代码段,第三项是进程的数据段。

关于线性地址保护还有硬件实现部分,大致是硬件会检查跳转指令的线性地址,若过长就视为跨界访问,就不记录了。

1.2 进程执行时分页

1.2.1 使用mem_map避免页面资源冲突

内核使用mem_map来管理页面资源,对1MB(物理内存)以上的内存空间进行分页管理(0-1MB是内核的空间,使用直接映射)。主内存中每个页面的引用计数都被初始化为0,即默认为空闲页面

申请空闲页面时,遍历 mem_map[],找出为0的页面,并将其设置为1

若分配失败,终止进程



1.2.2 明确申请页面的时机

通过第1章的介绍我们得知,每个页目录项和页表项的最后3位,标志着其所管理的页面的属性(一个页表本身也占用一个页面),它们分别是U/S、R/W和P。判断是否该申请页面,是在解析线性地址时确定的,关键要看P这个标志位。

一个页目录项或一个页表项,如果和一个页面建立了映射关系,P标志就设置为1;如果没建立映射关系,该标志就是0。进程执行时,线性地址值都会被MMU解
析。如果解析出某个表项的P位为0,说明该表项没有对应页面,就会产生缺页中断。前面我们所说的缺页中断,就是这样产生的。如果P位为1,就说明该表项对
应着具体的页面,直接根据表项中记录的地址值找到具体的页面。所以,这一位非常重要,设计者在设计内核时,始终都要保证这一位的信息明确,绝对不能出现垃
圾值。因为垃圾值就等于错误。

为了避免申请的新页面有垃圾值(当申请的页面做页表使用时,垃圾值会导致页表项的p值不为0),每次申请页面都需要对页面清零。


复制页表时,就得建立映射关系,关系建立后,将P位设置位1

页表和页面的关系解除后,页表项就要清零。页目录项和页表解除关系后,页目录项也要清零,这样就等于把对应的页表项、页目录项的P清零了。代码如下:

1.2.3 页面共享

Linux 

0.11把分页的基础建立在分段的基础上。每个进程的线性地址空间只要被限定住,彼此不干扰,那么分页时就不会出现混乱。前面已经介绍过进程线性地址空间
的总体格局,将IA-32体系下的4 GB线性地址空间分为64等份,每个进程一份,彼此不干扰,页目录表的设计完全遵照这个格局。Linux
0.11的页目录表只有一个,一个页目录表可以掌控1024个页表,一个页表掌控1024个页面,一个页面4
KB,这样一个页目录表就可以掌控1024×1024×4 KB=4
GB大小的内存空间。把一个页目录表64等分,每个进程可以占用16个页目录项,掌控16个页表,即占用64
MB的物理页面。这使得为每个进程分的页面,都可以映射到不同的页表项上,页表也映射到不同的页目录项上,MMU解析任何进程的线性地址时,最终都可以映
射到不同的物理地址。当然,出于共享页面的需要,不同的线性地址是允许映射到相同的页面的,但那只不过是实际应用的需求,是一种策略。页目录表、页表、页
面这套映射模式足以支持唯一的页面映射到唯一进程的线性地址空间。

如父子进程页面共享

此时最好的选择就是,子进程创建完毕后,先沿用着父进程的代码,父进程有多少页面,子进程就共享多少,将来子进程加载了自己的程序,再重新映射。这就引出
了一个问题:多进程操作同一个页面,有读有写,这相当于给进程的封闭环境开了口子。为此,需要另想办法把这个口子堵住。

Linux 0.11用页表中的U/S、R/W两个位将这个口子堵住了。

先介绍U/S位。如果U/S位设置为0,表示段特权级为3的程序不可以访问该页面,其他特权级都可以;如果被设置为1,表示包括段特权级为3在内的所有程
序都可以访问该页面。它的作用就是看死用户进程,阻止内核才能访问的页面被用户进程使用。当然,Linux 0.11中的保护,更偏重于使用“段”。

在怠速前为内核分页的时候,U/S位被设置为1,代码如下:

代码中7的二进制形式是111,说明U/S位被设置为1。

创建进程的时候,子进程页目录项和页表项中的U/S位都被设置为1,代码如下:

进程执行时,为进程新申请页面,并把页面映射到进程的线性地址空间,这会将页面对应的页表项的U/S位设置为1。如果是新申请的页表,也会将对应的页目录项U/S位设置为1。代码如下:

上面把进程会访问的页面的页面项都设置为1.进程就可以访问那些页面。

下面介绍R/W位。如果它被设置为0,说明页面只能读不能写;如果设置为1,说明可读可写。

进程是可以共享页面的,这样会带来问题;如果多个进程往一个页面里面写数据,那么这个页面就会出现混乱。所以需要保护,R/W位就提供了这种保护。

创建进程的时候,父子进程共享页面。这些共享的页面就不能写入数据了,R/W位设置为0,代码如下:

再有,没有父子关系的两个进程也可以共享页面,不用另行加载,这时候也要把R/W位设置为0,禁止写入数据。代码如下:



通过上述介绍不难发现,进程共享的页面,只可能有两种操作,要么读,要么写。读不会引起数据混乱,可以随便读。如果是写,就可能引起混乱,需要禁止。而对
于写入的需求,Linux
0.11采取了一套写时复制的策略来解决,即把要写入数据的页面再复制一份给进程,两个进程各有一份,各写各的页面,这样就不会产生混乱。我们将在本章最
后一节,用一个操作实例来讲解写时复制机制。

Linux 0.11中确有像管道这样的需求,两个进程在同一页面内又读又写。我们将在第8章中详细介绍如何保证进程操作管道时不产生数据混乱。

1.2.4 内核分页

进入保护模式后,内核先给自己分页。分页是建立在线性地址空间基础上的。前面一节我们介绍过,内核的段基址是0,代码段和数据段的段限长都是16

MB。每个页面大小为4 KB,每个页表可以管理1024个页面,每个页目录表可以管理1024个页表。既然确定了段限长是16
MB,这样就需要4个页目录项下辖4个页表,来管理这16 MB的内存,设置的代码如下:

从图6-3中不难发现,内核的线性地址等于物理地址。这样做的目的是,内核可以对内存中的所有进程的内存区域任意访问。

可见恒等映射模式并不是唯一的模式,内核选择线性地址到物理地址的恒等映射,是因为对内核来讲最方便。比如,内核为进程申请了页面,这个页面总是要映射到
页表项中,这需要往页表项中写入该页面的物理地址。如果是恒等映射模式,调用get_free_page()函数后,获取的线性地址值直接就可以当物理地
址来用,所以更为方便。

内核不仅掌控了所有内存页面的访问权,而且有权设置每个页面的读写、使用等属性,并把这些信息全部记录在页目录表和页表的表项中,代码如下:

另外值得注意的是,虽然内核的线性地址空间和用户进程不一样,内核是不能通过跨越线性地址空间直接访问进程的,但由于早就占有了所有的页面,而且特权级是
0,所以内核执行时,可以对所有页面的内容进行改动,“等价于”可以操作所有进程所在的页面。但这与内核直接通过线性地址“段”来访问进程是两码事儿。一
个典型的例子就是,某个进程要读盘,最终总要把缓冲区中的数据写到用户空间内,这件事要由内核来完成。代码如下:

从以上代码可以看出,内核肯定是能直接访问进程的LDT对应的内存地址所在的页面,但不等于内核跨越了线性地址的段,访问了进程的线性地址空间,段的基础保护并没有因为分页而被打破。

可见内核可以访问所有进程的物理内存,首先内核获得进程的LDT以获得线性地址的基地址,再加上偏移地址(程序的链接地址),可以得到完整的线性地址,然后内核可以访问进程对应的mem_map[i],通过进程的段表,页表,访问进程的物理地址。

1.2.5 用户进程从创建到退出的完整过程

根据前面讲解的原理,我们通过一个实例,理论联系实际,详细讲解一个用户进程从创建到退出的完整过程。

程序如下

1.2.5.1 分配pid,LDT,TSS

经解析得知,现在要执行str1这个进程,于是shell调用fork函数开始创建进程,产生int 

0x80软中断,最终映射到sys_fork()这个函数中,调用find_empty_process()函数,为str1进程申请一个可用的进程号、
在task[64]中为该进程申请一个空闲位置。我们这里假设str1这个进程是操作系统怠速以后第一个申请的用户进程。通过前面章节的介绍可知,申请到
的进程号是5,在task[64]中找到的空闲位置是第5项。

后面将根据task[64]中的项号,确定str1进程处于哪一个64 MB线性地址空间内,它的LDT和TSS将与GDT的哪两项挂接。我们来看后面的执行情况。

1.2.5.2 分配task_struct,内核栈·

copy_process()函数的第一件事就是为str1进程申请一个页面,这个页面用来承载进程的task_struct和内核栈。通过前面的介绍我
们得知,为了实现对进程的保护,系统为每个进程的管理专门设计了一个结构,这就是task_struct。每个进程都有这样一本账,以此保证互不干扰。进
程转入内核后,执行用的代码都是内核代码,但执行路径未必相同,导致数据压栈的顺序和内容不同。这些栈又不能存储在每个进程的用户空间内,这样很容易被覆
盖或改动,这就需要为每个进程专门准备一套内核栈。

从get_free_page()函数申请页面的策略来看,操作系统要让进程使用的页面向高地址方向密排,以此提高内存的使用效率。进程执行时,尤其是多
进程执行时,页面的释放是随机的。这就导致内存中经常会散布着被释放的空闲页面,而get_free_page()函数始终都是从高地址端向低地址端遍历
所有页面的,只要发现空闲页面,就申请,直到申请不到空闲页面为止。这可以保证所有申请到的页面在内存中都紧密排列,使得在4
GB的线性地址空间中分散的进程内存集约到有限的物理内存中运行。

当然,如果确实没有申请到空闲页面,说明此时内存中已经没有页面供进程使用了,所以就要直接返回错误信息,进程创建就此结束。系统怠速后内存中有大量的空
闲页面,str1进程又是怠速后刚刚创建的进程,所以此时可以申请到空闲页面。根据前面确定的task[64]中的项号,把该进程的
task_struct挂接到task[64]中,task[64]的项数和线性地址空间的64等分布局正好相符。每创建一个进程,都要把
task_struct的指针载入,这样系统如果查找进程,只要查task[64],就可以顺着指针找到唯一的task_struct,不会出现混乱。代
码如下:

给str1复制了task_struct后,str1进程便继承了shell的全部管理信息。但由于每个进程的task_struct结构中数据信息是不
一样的,所以还要对该结构进行个性化设置。先要将进程设置为不可中断等待状态。内核执行过程中,Linux
0.11不允许进程间切换,所以这里不进行状态设置也无所谓。但如果允许进程在内核执行时切换,这里就有必要设置为不可中断等待状态了。这是因为进程
task_struct结构已经挂接在task[64]结构中了,如果在个性化设置过程中发生时钟中断,就会轮转到该进程,而此时其个性化设置尚未完成,
一旦切换到该进程去执行,就会引起进程执行的混乱。代码如下:

1.2.5.3 复制页表,并设置目录项

str1进程的时间片数值是从shell进程继承过来的。此时shell进程可能已经执行过一段时间了,时间片可能已经减少。但不能因为这些,就让
str1进程的时间片也天然地减少,所以这里不能继承shell的时间片,而是用shell的优先级来确定时间片。如果优先级没有被用户指定设置过,它的
数值和时间片的原始数值是一样的,即15。代码如下:

接下来是对信号的个性化设置。task_struct结构中,关于信号的字段有三个,即signal、sigaction[32]和blocked。它们
分别表示信号位图、信号处理函数挂接点和信号屏蔽码。现在创建str1进程,只把signal进行了清零,其他的都没有设置。这样做是有原因的,如果不清
零,那么就等于默认沿用了父进程的信号位信息,将来str1执行时,如果执行到内核,则返回之前就要信号检测,本来str1没有接收到信号,就因为误用信
号位信息,导致没必要的信号处理。为此需要改变用户栈信息、绑定进程的信号处理函数等一系列配合才能处理信号,而str1进程根本没准备这些,带着这些未
知因素从内核返回进程,执行就彻底混乱了。

对会话和时间设置

下面介绍TSS字段。它是为进程间切换而设计的。进程切换是建立在对进程保护的基础上的。采取什么样的保护设计,就会有什么样的进程切换模式与之相适应。
进程执行时,会用到各种寄存器。这导致进程切换不会是一个简单的跳转,而是一整套寄存器的值随之切换。这就要保证进程切换走时与切换回来时不发生混乱,这
样才能保证进程执行的正确性。为此,Linux
0.11设计者在每个进程的task_struct中记笔账,这笔账就是TSS。进程切换就要与此相适应。每当进程切换,就用TSS来保存现场,即保存当
前各个寄存器的状态,等切换回这个进程后,再用TSS中的数据恢复寄存器中的数据。代码如下:

另外值得注意的是,用这些数值来设置寄存器,是CPU自动完成的,所以我们在内核中找不到给寄存器赋值的代码。那么CPU中的硬件怎么知道哪个数值是用来
为哪个寄存器赋值的呢?只有一种可能,就是CPU硬件的事先约定,按照目前TSS中各个字段的顺序取值。这意味着,如果顺序排列错误,进程执行就混乱了。

对进程的保护不仅体现在内核对进程的管理以及进程切换时的控制,在进程执行的过程中,也要有一整套措施时刻看着进程的边界,具体表现为分段和分页。下面我们继续介绍str1分段和分页的情况。

确定线性地址的关键在于确定段基址和段限长

值得注意的是,从以上代码中我们看到了根据task[64]中的项号nr来确定str1进程段基址,并参考段基址设置了str1进程的LDT,但始终没有
看到设置它的段限长。通过前面的介绍我们得知,进程的段限长存储在LDT中,复制task_struct结构时,把LDT也复制过来了,没有改变。这说明
str1沿用了其父进程shell的LDT。这样做的理由是,str1进程开始执行后总要执行代码,但它还没有加载属于自己的程序(或许以后也不加载
了),这样就只能和父进程共用代码。此时沿用父进程的段限长,就是为了能够共享到父进程的全部代码和数据。

分段问题处理完毕后,下面开始分页。分页是建立在分段基础上的,具体表现为,分段时用段基址和段限长分别为分页确定了从哪里开始复制页面表项信息、复制到哪里去和复制多少这三件事,代码如下:

前面分段时我们介绍到,str1创建后,还没有自己的程序,还要和父进程shell共享程序。这一点在分页时表现为与shell进程共享页面,即为str1进程另起一套页目录项和页表项,使之和shell指向共同的页面。代码如下:


值得注意的是,为新进程创建页表时,还要调用get_free_page()函数申请空闲页面。从需求上来看,这里申请的页面,将用来承载新进程的页表
项。这些页表项是用来管理str1所占用的页面的,是不让进程使用的,所以这里只申请了页面,并没有映射到str1进程的线性地址空间内。与前面为进程的
task_struct和内核栈申请页面类似,这里申请页面时,程序是在内核中执行的,处于内核的线性地址空间内,此时申请的页面早已映射到内核的线性地
址空间内,内核因此具备访问它的能力。同理,现在为装载页表而申请的页面,都是内核用来管理进程的,内核能访问到,进程访问不到。进程访问不到的根本原因
是,内核没有把这些页面映射到进程的线性地址空间内。

分段、分页完成后,还有文件继承的问题要处理。shell进程打开的文件,它的子进程一并继承,具体表现为,将文件的引用计数和i节点的引用计数累加,将
来子进程需要使用这些文件时,就可以直接操作,不需要再重新打开。比如说,shell进程怠速前打开了tty文件,还复制了文件句柄,str1进程就可以
直接用,不需要重新读取了。引用计数累加的执行代码如下:

文件继承问题解决后,将str1的进程TSS和LDT挂接在GDT的指定位置处。代码如下:


创建str1进程的过程到此结束了,将它的状态设置为“就绪态”。这意味着str1进程可以参与轮转了。


1.2.5.4 为str1进程加载的准备工作

为用户进程str1的加载做准备与为shell程序的加载做准备的方式是大体相同的,包括对参数和环境变量等外围环境的检测、对str1可执行文件的检测、对str1进程task_struct的针对性调整以及最终设置EIP、ESP这几部分。

进入do_execve函数后,先要做外围准备工作,即为管理str1进程参数和环境变量所占用的页面做准备,还需要把str1所在的文件i节点读出来,
通过i节点信息,检测文件自身是否有问题,再通过i节点找到文件头,对文件进行检测,其中包括检查记录的可执行文件代码长度、数据长度,能不能容纳在64
MB的线性地址空间内。代码如下:

创建str1进程时我们介绍到,它共享了一些shell进程打开的文件,继承了一些shell进程的信号字段内容。现在它要加载自己的程序了,所以有些关系要解除,有些要清零。代码如下:

前面已经介绍str1进程现在与shell进程正在共享着相同的页面,现在str1要加载自己的程序了,需要解除共享关系,通过调用free_page_tables()函数来实现。执行代码如下:

值得注意的是,原来str1进程与shell进程是共享页面的,即对于这两个进程,共享的页面都是只读的。现在解除的是str1和共享页面的关系,但这些
页面对于shell进程来讲仍然是只读的,那么这样会不会影响shell进程将来的执行?这一点我们在后面讲解写时复制时会详细介绍。

重新设置代码段和数据段

str1进程要加载自己的程序,需要根据程序的长度来重新设置LDT,代码如下:

根据程序头信息,设置task_struct

最后再调整EIP和ESP,使软中断返回后,直接从str1程序开始位置执行。前面我们已经介绍过,str1进程与shell进程解除了共享页面的关系,
控制页面的页表也已经释放,断绝了与str1进程的页目录项的映射关系。这意味着页目录项的内容为0,包括P位也为0。str1程序一开始执行,MMU解
析线性地址值时就会发现对应的页目录项P位为0,因此产生缺页中断。

1.2.5.5 str1程序的运行,加载

产生缺页中断

进入do_no_page()函数后,在加载str1程序之前,先要做如下两方面的检测。

第一,str1进程是否已经把程序加载进来了,或者产生缺页中断的线性地址值是否已经超出了程序代码的末端。显然两种情况都不成立,str1的代码内容将从硬盘上加载。执行代码如下:

第二,str1是否有可能与某个现有的进程共享代码,比如某个其他进程已经把str1程序加载了的情况。显然现在也不可能。代码如下:

现在的情况和加载shell程序时的情况一样,都需要从硬盘上把程序加载进来。下面就要在主内存中申请空闲页面,然后加载str1程序。

在主内存中申请一个空闲页面,准备将str1最起始部分的程序载入,执行代码如下:


从前面的讲解可知,所有分配给进程的页面天然存在两套映射关系,一套是与内核线性地址空间的映射,另一套是与进程线性地址空间的映射。内核与页面的映射关
系从来没被解除过。试想如果把页面映射给进程后就解除了内核与页面的映射关系,那就意味着内核无法访问到这个页面了。

将str1程序从硬盘加载到这个新分配的页面中,一次加载4 KB的内容。执行代码如下:

str1程序载入后,将这页内存映射到str1进程的线性地址空间内。对应的代码如下:


这个str1程序是大于一个页面的,所以,在执行过程中,如果需要新的代码,就会不断地产生缺页中断,以此来不断地加载需要执行的程序。

到这里为止,str1的加载过程就介绍完了。下面开始介绍str1运行起来的情况。

str1程序运行压栈时产生缺页中断

随着str1不断压栈,会不断分配新的物理页,映射到线性地址

程序执行完毕,foo函数到达递归的终止点返回(if(n==0)return 

0)。这时由于函数返回导致进程清栈,ESP向上(高地址)收缩,用户进程实际使用的栈空间就变小了。既然栈已经变小,那么之前映射到栈的线性地址空间的
物理页面就应该被释放掉。但我们从代码分析和测试中都发现,Linux
0.11内核并没有释放该物理页面。理由是这样的:进程执行的时候内核不在执行状态,进程执行过程中废弃的页面,内核无法时时检测。而CPU中没有专门的
功能电路来管理此事,没有判断废弃页面的硬件触发机制,内核即使设计了清理废弃页面的功能,也无用武之地。所以清栈后的页面没有被释放。

1.2.5.6 str1 退出

进程退出包括两方面:第一,释放str1进程代码与数据所占用的物理内存并解除与str1这个可执行文件的关系,这一点由str1进程自己负责;第二,释
放str1进程的管理结构task_struct所占用的物理内存并解除与task[64]的关系,这一点由父进程shell负责。

进入do_exit()函数后,调用free_page_tables()函数将str1程序占用的页面释放掉,包括前面提到的已清栈但尚未释放的内存页
面,并将管理这些页面的页表以及页目录项释放掉。这些页面中仍然保存着str1进程的垃圾数据,但解除了映射关系,str1进程将无法找到这些页面。执行
代码如下:

解除该进程与str1程序对应的可执行文件的关系,具体表现为先将与父进程共享的文件释放掉,然后内核将str1进程设置为僵死状态,并给它的父进程shell发送“子进程退出”信号(信号处理的问题将在第8章中详细介绍)。对应代码如下:


到这里为止,str1进程对退出所做的善后工作已经完毕,str1进程将切换到其他进程去执行。由于现在只创建了一个用户进程,所以现在系统中有进程0、进程1、updata进程、shell进程和str1这个用户进程。执行代码如下:

shell进程接收到str1发送的信号而被唤醒,即设置为就绪态,之后切换到shell进程去执行。shell进程执行进入内核后,内核将释放掉
str1进程task_struct所占用的页面,并解除str1进程与task[64]的关系,这样str1就彻底从系统中退出了。这个空出来的
task[64]位置,可以被其他进程占用。占用了这个位置的进程,将具有和str1相同的线性地址空间和页目录项。

1.3 多进程同时运行

1.3.1 进程调度

我们假设系统中尚没有任何用户进程存在,外设上有三个可执行文件,分别是str1、str2、str3,且文件中的程序都与前面介绍的str1进程的代码一致。在此前提下,我们依次创建三个用户进程:str1、str2、str3。

现在last_pid已经累加为4了,所以这三个进程的进程号应该依次是5、6、7。现在task[64]中的前四项已经被占用了,所以这三个进程只能依
次从第五项开始与task[64]建立关系,它们在task[64]中的项号依次为4、5、6。由此进一步可以得出,它们在线性地址空间的位置应该依次是
4×64~5×64MB、5×64~6×64MB、6×64~7×64MB。

假设现在轮到str1进程执行。str1开始执行foo函数调用,就需要压栈,于是产生缺页中断。在缺页中断处理中,内核为str1进程申请了空闲物理页
面,并将其映射到str1进程的线性地址空间。之后,进程再对text数组进行设置,内容就被写在了刚分配的物理页面上了。

Linux 

0.11中,两种情况下会导致进程切换。一种是由时钟中断引发的进程切换,这与进程执行毫无关系。无论是哪个进程执行,也无论是在3特权级下执行还是在0
特权级下执行,时钟中断都会产生,只要满足切换条件,就切换。另一种是由进程执行引起的中断。进程执行到内核中时,如果执行了类似读硬盘数据的程序,数据
读出之前,进程无法继续执行,就应当将当前进程挂起,切换到其他进程去执行。但无论是哪种情况下的切换,都是TSS和LDT中的全套信息跟着进程走。下面
先来看由时钟中断引发的切换。

str1在执行过程中,每10毫秒就会产生一次时钟中断,这样就会削减它的时间片。我们在程序中调用了sleep()函数,以便起到延时的效果。这样当前
进程的时间片被削减为0时,程序还没有执行完,此时要么是0特权级,要么是3特权级。str1进程一直在执行用户程序,特权级为3,此时就会调用
schedule()函数,准备进程切换。代码如下:

于是切换至进程str2执行,进程str2也执行同样逻辑的程序。值得注意的是,当设置text数组时,屏幕打印的链接地址与当时进程str1的地址相同。但它们的线性地址不同,物理内存中进程str2也并没有与str1重叠。

str2执行一段时间后,时间片被削减为0后又切换到str3去执行。它也要压栈,str3开始运行,执行的代码与进程str2相同,也是压栈,并设置text。

我们不妨对str3的代码稍加改动,调用open()、read()和close()函数,从硬盘上读文件。这样就映射到sys_read()函数中执
行。下达读盘指令后,数据不会马上进入缓冲区。没有数据,str3就不能继续执行。这时候它会主动地将自己挂起,然后切换到其他进程去执行。代码如下:

str3执行一段时间后,时间片也用完了。这样三个用户进程虽然还需要继续执行,但时间片都用完了。当再发生时钟中断时,do_timer()函数调用schedule()函数进行进程切换,这时,内核会为它们重新分配时间片。

内核从task[]的末端开始重新给当前系统的所有进程(包括处于睡眠的进程,但进程0除外)分配时间片。时间片的大小为
counter/2+priority。priority是进程的优先级,所以进程的优先级越高(priority值越大),分配到的时间片就越多。然后
根据此时时间片的情况重新选择进程运行,如此反复进行。执行代码如下:

这里值得注意的是,重新分配时间片时,并不需要给进程0进行分配。这是因为,只要系统中所有进程都暂时不具备执行条件,就自动切换到进程0去执行。进程0
将一直执行下去,即便在此过程中它的时间片削减为0了,但由于还没有可以运行的进程,所以仍然需要进程0继续执行。这样一来,时间片对进程0来讲就没有意
义了。可见,进程0是一个特殊的进程,它的执行是由系统当前的留守需求决定的,时间片轮转这套机制并不适用于它。

接着它们都会继续不断地压栈,通过图6-28我们可以看出这一点。这三个用户进程在线性地址空间内压入它们各自的栈中的数据都是连续的,但在物理空间内压栈的数据却是完全“交错”分布的。

不难发现,任何时刻都只有一个进程在执行,根本不存在多个进程同时执行的情况(所谓多进程同时执行,只是人的主观感受)。数据不会彼此覆盖,而且无论由于
哪种情况产生进程切换,都通过调用schedule()函数来进行切换。这个函数进行进程切换,就会用TSS和LDT全套数据跟着进程走,以此实现对进程
的保护。

1.3.2 页写保护

假设现在系统有一个用户进程(进程A),它自己对应的程序代码已经载入内存中,此时该进程内存中所占用的页面引用计数都为“1”,接下来它开始执行,通过
调用fork函数创建一个新进程(进程B)。在新进程创建的过程中,系统将进程A的页表项全部复制给进程B并设置进程B的页目录项。此时这两个进程就共享
页面,被共享页面的引用计数累加为2,并将此共享页面全部设置为“只读”属性,即无论是进程A还是进程B,都只能对这些共享的页面进行读操作,而不能是写
操作。执行代码如下:

我们假设接下来轮到进程A执行,而且进程A接下来的动作是一个压栈动作,现在看看会发生什么。

现在进程A的程序对应的所有页面都是只读状态的。这就意味着,无论是代码所占用的页面,还是原先压栈的数据所对应的页面,都只能进行读操作,不能进行写操
作。然而,压栈动作无疑是一个写操作,压栈时对应的线性地址值经过解析后肯定会映射到只读页面中,就会产生一个“页写保护”中断,如图6-30所示。

“页写保护”中断对应的服务程序是un_wp_page()函数,函数执行时,先要在主内存中申请一个空闲页面(以后我们称之为新页面),以便备份刚才压
栈的位置所在页面(以后我们称之为原页面)的全部数据,然后将原页面的引用计数减1。这是因为,原页面中的数据即将要备份到新页面中了,进程A也将要到新
页面中操作数据,而不再需要与原页面维持关系了,所以原页面的引用计数减1。执行代码如下:

值得注意的是,这里只是将原页面的引用计数减1,而并没有彻底释放。这是因为在整个操作系统中,所有可能被多个进程所共用的资源,比如文件i节点、文件管
理表表项、内存页面等,都要通过引用计数来表示它们被使用的状况。当一个进程与它们解除关系后,其他进程未必都与它们解除了关系,简单的“释放”是不行
的。

新页面虽然申请到了,但此时进程A的页表中与原页面对应的页表项还是指向原页面,没有页表项与页面对应,最终还是无法找到物理地址的。所以,现在还需要让
进程A的页表中指向原页面的页表项改为指向新页面,并将其使用的属性从“只读”改变为“可读可写”,这样进程A才具备了在新页面中处理数据的能力。执行代
码如下:

一切准备就绪后,就可以将原页面中的内容复制到新页面中了。复制之后,进程A就可以在新页面中完成这个压栈动作了。执行代码如下:

进程A执行一段时间后,就该轮到它的子进程——进程B执行了。进程B仍然使用着原页面。假设也要在原页面中进行写操作,但是现在原页面的属性仍然是“只
读”的,这一点在进程A创建进程B时就是这样设置的,一直都没有改变过。所以在这种情况下,又需要进行页写保护处理,仍然是映射到
un_wp_page()函数中。由于原页面的引用计数已经被削减为1了,所以现在就要将原页面的属性设置为“可读可写”,执行代码如下:

进程A和进程B在压栈数据的处理方面可以各自操作不同的页面了。这些页面都是可读可写的,而且引用计数都为1,以后彼此都不会干扰对方,如图6-34所示。

现在进程B并没有自己的程序。如果将来它有了自己的程序,就会和原页面解除关系,原页面的引用计数将会继续减1,于是就变成0,系统将认定它为“空闲页面”。

我们重新假设,现在不是父进程——进程A先执行,而是子进程——进程B先执行,那么又会出现什么情况呢?

这种情况与前面所述的情况是对称的,即系统先为进程B申请一页面空间,然后让进程B的页表中与原页表相对应的页表项指向新页面,最后将原页面内容复制给新
页面,以便进程B操作。等轮到进程A执行时,原页面被设置为“可读可写”,使进程A仍然使用原页面执行数据操作。

进程B先执行压栈操作时的内存分布如图6-35所示。

值得注意的是,页写保护是一个由内核执行的动作;在整个页写保护动作发生的过程中,用户进程仍然正常执行,它并不知道自己在内存中被复制了,也不知道自己被复制到哪个页面了。

从前面的讲解、分析,可以看出,在Linux 

0.11(准确地说是UNIX体系)的设计者看来,所谓的操作系统就是若干正在操作的进程构成的系统,所谓内核只是进程的延续(所以才有用户态、内核态的
说法),这是他们的设计指导思想。这个指导思想可以很好地解释大部分问题。但为进程划分线性地址空间、分页、时钟中断触发的进程调度……这些用“延续说”
不太容易完全解释清楚。

posted on 2022-08-22 10:56  开心种树  阅读(172)  评论(0编辑  收藏  举报