当用户编写一个程序或一段代码后,通过编译链接生成可执行文件(这里这一具体过程在前一篇博文中已经具体介绍过了,这里不再赘述),这里假设这一可执行文件的名字为str1,当用户在操作系统的shell终端界面中输入一条指令./str1,shell程序便会响应并解析这条指令,经解析得知,用户现在要执行str1这个程序,于是shell将调用fork函数开始创建进程,接下来会产生int 0x80软中断,软中断产生后,system_call函数会对此响应,并最终执行call sys_call_table(,%eax,4)这行代码,这一过程为查询系统调用表,其中%eax寄存器中存储的为系统调用号。通过查询系统调用表之后,最终映射到函数sys_fork中。之后,调用do_fork函数,分别为str1进程申请一个可用的进程号和在进程槽task[64]中为该进程申请一个空闲的位置。
然后通过调用copy_process函数,准备将当前shell进程的管理结构复制给str1。首先,在主内存中为用户进程str1申请一个页面的空间,然后将这个申请到的空闲页面与之前申请到的进程槽task[64]中的相应的空闲项相挂接。这里需要指出的是,这个内存页面除了用来存储str1自身的管理结构之外,还用来存储str1进程的“内核栈”数据。
这里涉及到系统的两个状态——“用户态”和“内核态”。
当进程运行在内核态时,可以执行指令集中的任何指令,并且可以访问系统中任何存储器。
当进程运行在用户态时,不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。用户程序必须通过系统调用接口间接地访问内核代码和数据。
之前申请到的空闲页面被用来存储进程管理结构的数据和内核栈的数据,它们被放在同一个页面内,分别处于同一页面的两端。当用户程序执行时,通常进程处于用户态,这时候用户程序会致使多少数据需要压栈,操作系统的设计者是不可能预知的。所以就根据程序执行时的具体需要,在主内存中分配页面,以此来承载栈数据。但是,一旦内核程序开始执行,即处于内核态,那么内核程序运行时会有多少数据需要压栈,系统设计者是可以预知的,尤其可以预知最多会有多少数据需要压栈。所以,设计者敢于将内核栈的数据与进程管理结构的数据放在同一个页面内,将来用户进程str1进入内核态的时候,压栈的数据将向着str1管理结构所在的位置不断地积累,但绝对不可能覆盖掉进程管理结构的数据。
copy_process函数还有一项非常重要的任务,即将当前运行进程shell的进程管理结构task_struct,复制到刚才新申请的空闲页面的起始端。这就是str1的task_struct的雏形。这里有人会问,如果将str1的task_struct设置成与shell的一样,那新建的用户进程怎么去执行用户的程序?那岂不是和shell执行一样的程序了?这里此处先埋下一个伏笔,这个问题留到后面介绍完页目录项和页表项之后进行解释。
下面调用copy_mm函数为新建的用户进程str1在线性地址空间中指定一个位置。这里提一下,对普通用户程序而言,能够接触到的只有逻辑地址,即在所有用户程序看来,它们的地址范围都是0~64MB这个空间内;而内核根据不同的用户程序所在的进程将其对应成线性地址,并将此线性地址解析成“页目录项”、“页表项”、“页内偏移”,最终映射到物理页面内。这里对str1进程设置的线性地址范围是专供内核使用的。
下面调用copy_page_tables函数把shell进程对应页表的全部表项复制给str1进程,并为此新建一套页目录项。正因为str1进程的页表信息是由其父进程shell复制过来的,所以str1进程现在暂时先与其父进程shell共享相同的内存页面,将来str1有了属于自己的程序后,再另行调整。这里的调整指的是将这些建立好的内存映射、页表项关系全部解除。那么会有人有疑问,既然后面子进程有了自己的程序之后这种关系要解除,为什么之前还要这样将父进程的页表项内容不加修改的复制到子进程的页表中呢?
这个意义是重大的,因为有一个对str1进程独立运行来说非常关键的步骤需要基于上述复制页表和设置页目录项的操作来解决。我们知道,在str1创建并执行之初,该进程并没有对应的执行程序,这就需要加载程序,于是就要调用execve函数。然而,调用execve函数的这行代码又在哪里呢?很显然,只能在它的父进程,即shell程序中,这就意味着,str1进程一旦开始执行,必须有能力执行shell程序里面的代码,这样才能开展以后的加载工作,直到它自己的子程序加载完毕。要想具备这种能力,只有一个办法,就是让str1进程在创建之初就能够共享shell程序所占用的页面,一旦执行,共享能力马上生效。而这里的复制页表和设置页目录项的工作就是为str1能够具备这个能力所做的准备工作。str1和shell的线性地址肯定不同,但是通过复制页表和设置页目录项,就可以使不同的线性地址映射到相同的物理地址上,以此来实现共享。
在调整完str1进程中与文件相关的结构之后,就要建立str1进程与全局描述符表GDT的关联。将用户进程str1的任务状态描述符表TSS,以及局部数据描述符表LDT挂接在全局描述符表的指定位置。所有进程都要把这两套描述符表挂接在全局描述符表中,这样在进程切换时,系统就可以通过全局描述符表找到当前进程的LDT和TSS,并把相关的信息保存在当前进程的LDT和TSS中。同时,还可以找到即将切换到的进程的TSS和LDT,并用它们里面存储的数据信息来设置局部数据寄存器和任务状态寄存器。可见,现在所做的挂接工作是系统能够进行进程间切换的最根本的保障。
然后将str1进程的状态设置为“就绪态”,至此,这个用户进程的管理结构就创建完毕了。
shell创建完str1进程的管理结构之后,通过shell自身的代码继续执行,最终会切换到str1进程去执行,由str1进程来加载自己对应的程序。str1进程开始执行后,会调用execve函数,为了支持str1程序加载的准备工作更好的完成,系统将一些数据进行了压栈保存。首先execve函数将文件的路径名、参数、环境变量压栈,以此来支持将来参数和环境变量的加载。创建str1用户进程时,参数和环境变量的具体信息是由shell程序自身的代码来提供的,任何一个用户进程所对应的参数和环境变量都是由创建它的父进程来提供的。随后,产生软中断,映射到一个系统调用函数sys_execve中执行,硬件将EIP的值压栈,这个值决定中断程序结束后执行哪一条指令。sys_execve中要做的最重要的事情,就是把EIP值“所在栈空间的地址值”压栈。这里注意,Intel CPU是不能通过指令直接修改EIP寄存器的值,有了前面的压栈,操作系统就可以通过这个值找到栈中的EIP值,修改栈中的EIP的值。
然后,进入do_execve函数,先做外围准备工作,即为管理str1进程参数和环境变量所占用的页面做准备。在此过程中有一个小插曲,就是需要在中间时刻暂停外围准备工作,转而将str1进程的可执行文件的i节点由硬盘中读入到内存中,读入这个i节点的目的就是为了对这个str1文件进行检测,看其是否具备载入条件。之后再继续回去做外围准备工作,把参数和环境变量的个数统计出来。具体过程如下:
然后,对读取的i节点进行分析,通过对i节点中“文件属性”的分析,可以得知这个str1文件是不是一个“常规文件”。因为只有是常规文件,即,除了块设备文件、字符设备文件、目录文件、管道文件等特殊文件之外的文件,才有被载入的可能。同时还要检测当前进程是否有执行权限,并对用户ID等信息进行设置。
接下来要对可执行文件的文件头进行检测,文件头中记载了这个可执行程序的代码段长度和数据段长度等关键的属性信息,这些信息直接影响到可执行程序将来被加载入内存后是否能够正常执行,而且若这个可执行程序确实能够执行,那么这个文件头中的信息将引导系统对可执行程序的加载。因为可执行程序也是以数据块的形式存储在虚拟盘上的,所以它的文件头,正常情况下在它的第一个逻辑块内。系统将这个文件头从硬盘上读入到缓冲块内,先对这个文件头进行备份,然后根据文件头和i节点提供的信息,对可执行文件进行检测,判断可执行程序是否能被加载。
当经过前面的文件检测,系统可以确定,shell程序确实具备加载的能力,接下来要把前面统计的参数和环境变量及其相关辅助信息拷贝到内存指定的页面中去。到这里,加载str1程序的外围准备工作和对str1这个可执行文件的检测工作就全部结束了。接下来,要根据实际情况,对str1进程的管理结构进行调整。先要更改该文件的executable字段,这个字段就是str1进程对应的可执行文件的i节点,目前str1该字段对应的是创建其的shell进程的可执行文件。str1进程创建时,由于shell管理机构中的信息全部都复制给了str1进程,所以这里面也一定包括shell进程与其可执行文件的关系,现在str1进程就要拥有自己的程序了,也一定会对应一个属于自己的可执行文件的i节点,所以原来这个关系已经没有必要继续维护下去了,这就要先把它与shell可执行文件的关系解除掉,这里具体表现为释放这个文件的i节点,并把str1这个可执行文件的i节点与str1进程挂接上。
PS:这里注意,并不是当str1要加载新程序时就会把所有之前与文件的关系都解除掉。str1加载子程序后也会用到的关系是不会被解除的。
接着调整str1进程管理结构,将与文件和信号相关的数据信息进行调整,与信号相关的信息全部清0,这是因为每个进程都会接受各自的信号,这些都是进程私有的信息,彼此之间不能交叉使用,所以str1把shell进程的信号继承下来毫无意义,而且由于信号还会影响到进程的执行状态,继承下来有可能造成混乱,因此全部清空。
下面释放str1进程原先的代码段和数据段所占用的物理页面。这里主要有两个原因:1、str1将要对应的程序与shell程序肯定有所不同,但是str1进程现在与shell进程正在共享着相同的页面和管理页表,所以要把这个共享关系解除掉。2、str1与shell进程共享页面,共享就意味着这些页面必须是“只读”的,现在str1由于加载新程序不再需要这些页面中的程序了,如果还共享着原来shell程序的页面,一来毫无意义,二来也会影响到shell程序对这些页面的应用,所以关系也要解除。
然后,根据之前拷贝进内存的str1可执行文件的文件头所提供的信息重新设置str1程序代码段的描述符以及重新设置数据段描述符。具体表现为,代码段限长被设置为与str1.c文件的代码段长度相同。数据段长度设置为64MB,之后再将参数与环境变量所在的物理页面映射到str1这个进程的线性地址空间内。
接着创建环境变量和参数的指针表,并将其地址存放到环境变量和参数所在的主内存区的页面中。
然后对str1用户进程管理结构中的brk、start_stack等信息进行设置,这些信息都是直接或者间接从str1可执行文件的文件头中采集出来的,这与从shell进程对应的可执行文件中采集出来的程序肯定是不一样的,所以这里必须重新设置,以支持str1进程在加载子程序后运行。到此为止,为str1程序加载所做的工作中,针对str1进程管理结构自身的属性进行调整的工作就全部做完了。
系统现在开始为str1程序的加载做最后的准备工作,即对EIP的值进行调整,使之指向str1程序的第一条指令处,这将导致软中断服务程序执行完毕后,系统会从str1程序起始位置开始执行,并会发生缺页中断。另外,为了保证str1执行后能够使用栈空间,也要对栈顶指针进行设置,即对esp进行设置,使其指向str1的栈底。前面已经介绍过,系统将EIP值“所在栈空间的地址值”压栈,这个地址值将成为这里所进行设置的基础,系统将分别用str1程序起始地址值和当前进程栈顶位置值,来对EIP和栈顶指针ESP进行设置,设置完毕后,通过sys_execve函数中的ret指令,将重新设置的EIP值载入eip寄存器,并指引代码的执行。(此处原理参见这篇文章)
到此,为str1程序的加载所需要做的准备工作就全部完成了,接下来,str1程序就可以加载了。