第四章  Linux的与进程有关的存储管理和进程创建

4.1 i386体系结构与kernel 2.2.x存储管理

    到进程管理就必须首先了解基本的i386体系结构和存储管理。这是因为体系结构决定了存储管理的实现,而进程管理又与存储管理密不可分。在现代操作系统中,存储管理一般都采用虚拟存储的方式,也就是系统使用的地址空间与实际物理地址空间不同,是“虚拟”的地址。处理器提供一定的机制将“虚拟”的地址转换为实际的地址。操作系统的存储管理就是基于处理器提供的地址转换机制来实现的。

    基本的存储管理有两种方式,即段式和页式。段式管理就是将内存划分为不同的段(segment),通过段指针来访问各个段的方法。在比较早的系统中,如pdp-11等就是采用这种方法。这种方法的缺点是在设计程序时必须考虑段的划分,这是很不方便的。页式管理就是将内存划分为固定大小的若干个页面(page),以页面为单位分配使用。通过页映射表完成地址转换。

    i386的存储管理采用两级虚拟的段页式管理,就是说它先分段,再分页。具体地说,它通过gdt(Global Descriptor Table)和ldt(Local Descriptor Table)进行分段,把虚拟地址转换为线性地址。然后采用两级页表结构进行分页,把线性地址转换为物理地址。下图表示了i386中虚拟地址到物理地址的转:

 

虚拟地址

 
 

 

 

 

 


4-1. i386虚拟地址转换

           

 

 

 

 

 

 

 

 

         虚存                        内存                       辅存

 

 

 

 

 

 

 

 

 

 

 


4-2 存储介质的映射关系

 

Linux中,操作系统处于处理器的0特权级,通过设置gdt,将它的代码和数据放在独立的段中,以区别于供用户使用的用户数据段和代码段。所有的用户程序都使用相同的数据段和代码段,也就是说所有的用户程序都处于同一个地址空间中。程序之间的保护是通过建立不同的页表映射来完成的。Linux 2.2.x gdt表中的段描述符如下图所示:

 

APM BIOS数据段

 

APM BIOS16位代码段

 

APM BIOS代码段

 

APM BIOS保留

 

保留

 

保留

 

用户数据段

 

用户代码段

 

内核数据段

 

内核代码段

 

空描述符

 

 
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


3-2. 全局描述符表gdt

 

    在实际的使用中,用户程序还可以通过调用设置ldt的系统调用来拥有自己的地址空间。

 

4.2 Linux以Kernel 2.2.x为内核的进程管理

    进程是一个程序的一次执行的过程,是一个动态的概念。在i386体系结构中,任务和进程是等价的概念。进程管理涉及了系统初始化、进程创建/消亡、进程调度以及进程间通讯等等问题。在Linux的内核中,进程实际上是一组数据结构,包括进程的上下文、调度数据、信号处理、进程队列指针、进程标识、时间数据、信号量数据等。这组数据都包括在进程控制块PCB(Process Control Block)中。PCB处于进程核心堆栈的底部,不需要额外分配空间。

    Linux进程管理与前面的i386体系结构关系十分密切。前面已经讨论了i386所采用的段页式内存管理的基本内容,实际上,i386中的段还有很多用处。例如,在进程管理中要用到一种特殊的段,就是任务状态段tss(Task Status Segment)。每一个进程都必须拥有自己的tss,这是通过设置正确的tr寄存器来完成的。根据i386体系结构的定义,tr寄存器中存放的是tss的选择符(selector),该选择符必须由gdt中的项(即描述符descriptor)来映射。同样的,进程的ldt也有这种限制,即ldtr对应的选择符也必须由gdt中的项来映射。

    在kernel 2.2.x中,为了满足i386体系结构的这种要求,采用了预先分配进程所需要的gdt项目的方法。也就是为每一个进程保留2项gdt条目。进程PCB中的trldtr值就是它在gdt中的选择符。进程与它的gdt条目的对应关系可以由task数组来表示。

 

    下面是详细的分析。

    (1).系统初始化

    在Linux 2.2.x中,一些与进程管理相关的数据结构是在系统初始化的时候被初始化的。其中最重要的是gdt和进程表task。

    Gdt的初始化主要是确定需要为多少个进程保留空间,也就是需要多大的gdt。这是由一个宏定义NR_TASKS决定的。NR_TASKS的值就是系统的最大进程数,它是在编译的时候被确定的。由图2可以知道,gdt的大小是10+2+NR_TASKS*2。

    进程表task实际上是一个PCB指针数组,其定义如下:

    Struct task_struct *task[NR_TASKS] = {&init_task,};

    其中,init_task是系统的第0号进程,也是所有其它进程的父进程。系统在初始化的时候,必须手工设置这个进程,把它加到进程指针表中去,才能启动进程管理机制。可以看出,这里task的大小同样依赖于NR_TASKS的定义。

 

    (2).进程创建

    在Linux 2.2.x中,进程是通过系统调用fork创建的,新的进程是原来进程的子进程。需要说明的是,在2.2.x中,不存在真正意义上的线程(Thread)。Linux中常用的线程pthread实际上是通过进程来模拟的。也就是说linux中的线程也是通过fork创建的,是“轻”进程。fork系统调用的流程如下:

 

 

 

 

 

流程图:文档: 4.选择下一个可运行进程
文本框: yes
流程图:文档: 5.是否需要切换
 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


4-3.fork系统调用流程

 

    图中比较关键的步骤是:

    创建进程PCB。实际上是创建进程堆栈,共两个页面即8k。进程PCB在堆栈的底部。

    将新进程的PCB加入到进程表中。从前面的讨论可以知道,进程表task的大小是预先定义的,所以首先要从task中找到一个空的表项:nr = find_empty_process()。如果不能找到空的表项,说明系统已经达到了最大进程数的上限,不能创建新进程。返回的nr是空闲表项的下标。

    将父进程的地址空间复制到子进程中。在这一步中,还要复制父进程的ldt到子进程,然后在gdt中建立子进程的ldt描述符(descriptor),并将这个选择符保存在PCB中。

    为子进程设置tss。同时在gdt中建立子进程的tss描述符,并将这个选择符保存在PCB中。

 

    (3).进程调度

 

    这里我们不讨论进程调度的算法,只是看一看进程切换时应该做的工作。在Linux 2.2.x中,进程切换是在switch_to函数中完成的。Switch_to基本操作如下:

    设置ltr,加载新进程的tss

    保存原进程的fsgs寄存器到它的PCB中。

    如果需要,加载新进程的ldt

    加载新进程的页表

    加载新进程的fsgs

以上的tssldt的选择符都是从进程的PCB中取出的。