[原]UNIX内核(12):进程的结构——上下文 - 操作系统 - raof01-Hast...

来源:百度文库 编辑:神马文学网 时间:2024/04/28 15:59:46
对于UNXI这样一个多用户多任务的系统来说,进程无疑是支撑其目标的强力支柱。而为了描述一个进程,就需要确定其状态及占用的资源,这些都属于进程的结构。
每一个进程都有一个固定的生命期:创建——>运行——>消亡,这也如人的生老病死一样(其实进程与人很类似,不是吗?生老病死,吃喝拉撒睡,占用社会资源,为社会做贡献,当然也有危害社会的,这就像系统的病毒……扯远了,呵呵)。当然,进程的状态远不止这三项,如下图所示:

是不是与操作系统的教科书里不一样呢?其实大体一样,只是多了一些状态而以。关于这些状态之间如何迁移,后续文章会讨论。本文的重点是进程的结构以及进程控制的一些基础。不过这里还是要对一些问题进行一些说明的。
图中的虚线表示这两种状态是等价的,只不过为了强调“被抢占(preempt)”是从内核态返回用户态时调度器做了调度使该进程放在就绪进程列表中。UNIX内核与最近的Linux内核不同的是,Linux允许任何时候进行抢占(这也增加了内核编程的风险),而UNIX内核只有在从内核态返回到用户态时才允许抢占。另一个问题就是,进城进入内核态的唯一方法是调用系统调用或中断(称为“陷入(trap)内核”——系统调用也是通过中断来实现)。还有,进程在调用exit()退出后,其状态为zombie,这是因为其父进程(fork()调用者或者init进程)需要收集一些状态信息,并释放该进程的资源——通常,一个退出的进程不会再执行,当然也不会释放由内核为它分配的资源(人死了怎么可能火化自己?)。
用于描述进程的数据结构是进程表项和u area,也就是说,通过进程表项和u area可以惟一确定一个进程。其中进程表项对于内核来说,任何时候都可访问,而内核只能访问运行进程的u area,这是因为内核在编译时访问的u area地址固定。
进程表项包括以下字段:
1、状态,用于标识进程状态;
2、指向上下文切换、换入换出内存所需信息的指针;
3、用户标识UID,用来决定进程的各种特权;
4、进程标识PID,用于标识一个进程以及确定进程间的关系;
5、事件描述符,用于睡眠状态的进程;
6、调度参数;
7、软中断信号字段;
8、各种计时字段,用于进程的account和优先级计算;
u area的各个字段则进一步刻画了进程状态的特性:
1、指向对应于该u area的进程表项的指针;
2、真正的用户标识和有效用户标识,决定了进程的各种特权;
3、计时器,记录在用户态和内核态的运行时间;
4、软中断信号的handler数组;
5、控制终端,标识注册于进程的终端;
6、记录系统调用出错的错误字段;
7、系统调用返回指;
8、I/O参数;
9、当前工作目录和当前根目录,用于描述文件系统环境;
8、指向用户文件描述符表的指针;
10、限制字段;
11、umask;
为了说明进程的结构,首先需要了解系统的存储方案以及进程在内存中的布局。

在UNIX系统上,进程由三个逻辑段组成:text、data、stack。其中text段存放的是可执行代码,也就是进程的image文件,data段存放的是静态数据。这三个段都有自己的地址空间并构成了进程的地址空间,而每个进程的地址空间不可能有重叠的地方。因此,编译器则需要负责为每个image文件创建虚拟地址空间,在运行时则由系统将其映射到物理地址空间。
SYSTEM V内核将虚拟地址空间划分为区(region),也就是说,region中包含了虚拟地址空间。内核中包含一个region表,并为系统中每个活动的region分配一个表项。每个进程有一个per process region table,简称为pregion。pregion与内核region表的关系一如进程的file table和内核inode table的关系,如下图所示。需要注意的是,region的概念与OS的内存管理不是一个概念。

页和页表
所有可寻址的内存位置都是在页表中,且每个内存单元都可以使用(page number,byte offset in page)的对应关系来寻址。与内核把数据块分配给文件一样,内核将page分配给region,而且是以二级访问的形势分配——region包含一个页表的地址,该页表存放了该region对应的所有物理页的起始地址。这就是说,region中包含了物理地址空间,这样物理地址和虚拟地址就通过region对应起来。假设有一个pregion指向的region的虚拟地址从64K开始,那么要访问地址68432的内容则需要经过如下计算(假定一页大小为1K):
68432的物理地址 = PageTable[(68432 - 65536) / 1024] + (68432 - 65536) % 1024;
“+”前面用于计算在页表中的偏移量,而后面的表达式用于计算在一页中的偏移量。可以用以下公式概括寻址计算:
offsetInRegion = logicalAddr - startAddrOfRegion;
physicalAddr = PageTable[offsetInRegion / pageSize] + offsetInRegion % pageSize;
知道这些以后,我们就能够知道一个进程如何与其image文件关联起来的(更确切的说,应该是virtual地址空间和physical地址空间的对应关系),如下图所示:

内核的布局
虽然内核是在一个进程的上下文和中断上下文中执行的,但是内核的virtual内存映射是独立于所有进程的。内核的text段和data段是永久存在内存中(除非关机),所有的进程共享这两个段。当系统boot时,内核的text和data段被加载到内存中,同时会初始化必须的表和寄存器来建立虚拟和物理地址空间之间的映射关系。内核页表与进程所关联的页表是类似的,映射机制也与进程类似。很多机器将虚拟地址空间划分成多个等级,其中包括用户级和系统级。在系统级运行时可以访问所有的空间,而在用户级时只能访问用户级。每个等级都有自己的页表,这样仅仅需要修改存放页表寄存器就可以切换内核态与用户态。这样就可以使用两组寄存器三元组,每一个指向对应的物理页表。无论是何种系统,对于u area的处理与处理内核其他部分不同。前面说过,只有运行态的进程的u area可访问,这就暗示u area需要经常变化,每次都需要将同一个虚地址映射到不同的物理地址。
u area
每个进程都有一个私有的u area,而内核访问的时候却是通过同一个虚地址。因此进程需要根据运行的进程来使用正确的地址映射机制。进程只有在运行于内核态时才能访问u area,因为只有运行的进程的u area才可访问,而访问u area需要内核中关于u area的地址映射信息,而且内核一次只能访问一个u area。因此,u area是进程上下文的一部分。
进程的上下文(文境)
进程的上下文包括用户地址空间、寄存器、与该进程相关的内核数据结构。正式地说,包括用户级上下文、寄存器上下文和系统级上下文。用户级上下文包括进程的3个段,以及占用进程虚拟地址空间的共享内存。寄存器上下文包括以下部分:
1、PC寄存器(下一条要执行指令的虚拟地址);
2、PS处理器状态寄存器;
3、SP(栈指针)寄存器;
4、保存计算结果的通用寄存器组;
系统级上下文包括动态部分和静态部分。动态部分包括:
1、进程对应的进程表项——控制和状态信息;
2、进程的u area;
3、页表、region tables和pregion项——定义了虚地址到物理地址的转换,包含一个进程的三个段(即便有共享的region);
静态部分包括:
4、内核栈,虽然各进程共享内核代码段,但是每个进程都有自己独立的内核栈
5、一组context layer,以栈的形式组织;
如下图:

对于context layer栈,每当内核执行系统调用、处理中断以及上下文切换(切到其他进程)时都会进行压栈操作,而每次从中断处理、系统调用中返回以及切换上下文(切回本进程)时都会执行退栈操作,即:
Push context layer:execute system call,interrupt,switch context to another process;
Pop context layer:return from system call,return from interrupt handler,switch context back;
而每次的push、pop操作都会导致内核保存进程上下文。
保存进程上下文
每次push一个context layer时,内核都要保存一个进程的上下文。何时push一个context layer如前所示。对于中断和异常,执行的操作如下:
void inthand()
{
push当前的context layer;
确定中断源;
查找中断向量表;
调用对应的中断处理函数;
pop栈顶的context layer;
}
由于在inthand()中会禁用同优先级或者更低优先级的中断,此时只允许高优先级的中断发生。因此context layer的最大深度为系统支持的中断优先级数。而对于系统调用,也是通过中断来实现的,这样,inthand()的中断处理函数就是syscall(),如下:
ReturnResult SysCall(SysCallNo aSysCallNo)
{
根据aSysCallNo查找系统调用表项;
确定参数个数;
将参数从用户空间拷贝到u area;
为异常退出保存上下文;
执行系统调用;
if (系统调用执行出错)
{
将寄存器上下文中的register 0设置为error;
打开PS的carry位;
}
else
{
将寄存器上下文中的register 0、1设置为系统调用返回值;
}
}
从syscall()返回后,调用者会检查PS的carry位以判断返回值,并从相应的寄存器读取执行结果。
内核允许在4种情况下进行context switch:进程睡眠、退出、从系统调用或者中断处理返回但不是最适合运行。这样,通过禁止随意的上下文切换使内核的完整性和一致性得到维护。内核在一个进程调用完exit()之后必须进行context switch,因为它对于该进程已经没有什么可执行的了,因此要切换到其他进程。
context switch处理过程与系统调用和中断的处理类似,不同之处在于,context switch的时候pop的context layer是不同进程的,而不是同一个进程的:
1、确定是否需要context switch且允许切换;
2、保存旧的上下文;
3、查找最符合运行条件的进程;(调度器的职责)
4、恢复新进程的上下文;
写成伪代码则大致如下:
void ContextSwitch()
{
if (NeedSched() && ContextSwitchPermitted())
{
if (SaveContext())
{
// 在旧的进程上下文中
Process newProc = FindEligibleProc();
ResumeContext(newProc);
// 永远执行不到这里!!!
}
// else
// 从这里开始执行旧进程
}
}
该函数的奥妙就在于SaveContext()。它保存了进程的完整上下文,然后返回1。然而其中对一些信息作了特殊处理,它将0保存在register 0中,然后将其保存到旧进程的上下文中。然后,将register 1的值设置为1并返回该值。这样,当进程A调用ContextSwitch(),它将调用SaveContext(),该函数返回1,因此会查找新进程(假设为进程B)并继续执行,那么继续执行的将是B的上下文,因此ResumeContext()后永远执行不到。当scheduler最终又选择了A执行时,它会从SaveContext()保存的上下文开始执行,读取保存的register 0的值(0)存到register 0中并将其作为返回值返回。此时SaveContext()的返回值变成了0!于是就不会进入if块,从而达到注释掉的else部分继续执行A进程。
参考: The Design of The UNIX Operation System, by Maurice J. Bach
Understanding The Linux Kernel, 3rd edition, by Daniel P. Bovet, Marco Cesati
Copyleft (C) 2007, 2008 raof01. 本文可以用于除商业用途外的所有用途。若要用于商业用途,请与作者联系。
TAG