假设硬盘上有个可执行文件str1,用户在shell界面上输入指令./str1 程序,shell会响应并解析这条指令,便开始创建用户进程。
注意:在Linux操作系统创建新进程时,都是由父进程调用fork函数来实现的。Linux的理念就是由进程创建进程,而在此时父进程是shell。
一、创建进程
给新进程造壳
这一部分主要的任务是给str1申请进程号,获取空闲页构造task_struct并挂载到task[64]上。
shell会调用fork函数开始创建进程,产生int 0x80中断,最终映射到sys_fork()函数中,调用find_empty_process()函数,为str1申请一个可用的进程号,在task[64]中为该进程申请一个空闲位置,也就是告诉内核,虽然我进程还没创建,但是我得先占地方。效果如图所示:
注意:
内核会用一个全局变量last_pid来存放系统自开机以来累计的进程数,并将此变量的值作为新建进程的进程号。task[64]是用来管理所有进程的“账本”,在Linux0.11中数组大小为64,意味着最多同时的运行64个进程,每个进程可以拥有64M的线性地址空间,共4GB。所以创建一个新进程需要在task [64]这个账本上登记,利用task [64]数组我们可以确定str1进程处于哪一个64MB的线性地址空间。在准备条件做好以后,接着执行copy_process()函数。该函数的第一个工作就是为str1进程申请一个页面,该页面用来承载进程的task_struct和内核栈。在之前的博客(执行MAIN函数到激活进程0)中有提到task_struct和进程的内核栈共用一个数据结构task_union。
根据前面确定的task[64]中的项号,把该进程的task_struct挂接到对应task[64]中,每创建一个进程,都要把task_struct指针载入,这样系统如果要查找进程,只要查找task[64],就可以顺着指针找到唯一的task_struct。
往新进程中填内容
前面我们只是把str1进程的task_struct的空壳构造好了,但是里面并没有内容。在Linux的设计思想中,父进程创建子进程时,就需要把自己的 task_struct结构赋值给子进程,就是将父进程最重要的进程属性赋值给子进程,因此子进程能拥有父进程的绝大部分能力。执行代码*p=*current,current指向当前进程(父进程shell),p指向子进程str1。但由于每个子进程的task_struct结构中数据信息是不一样的,所以还需要对该结构进行个性化设置,比如修改进程号,父进程号,时间片等等。
接着还要设置子进程的TSS。TSS字段是为了进程间切换而设计的,进程在执行时会用到各种寄存器,所以在进程切换时绝不是简单的一个跳转,而是需要将一整套寄存器的值随之切换,这样才能保证进程执行的正确性。
注意: Linux0.11中便在每个task_struct设计TSS来保存现场,保存当前各个寄存器的状态,当切换回该进程时,再用TSS中数据恢复各个寄存器的数据。
调用copy_mem()函数为进程分段。确定线性地址空间,即设置str1的代码段和数据段的段基址、段限长。根据task[64]中的项号确定str1进程的段基址(确定是第几个64M地址空间),并参考段基址来设置str1进程中LDT的地址,最后将父进程的LDT内容复制过来。在复制task_struct结构时,把LDT也复制过来了,这是因为在str1开始执行时总要执行代码,但是一开始还没有加载属于自己的程序,所以只能和父进程共用代码,并沿用了父进程的段限长。
注意:在Linux0.11中GDT里会存放所有进程的LDT和TSS,每个进程都有一对对应的LDT和TSS。在LDT中存放的是该进程的数据段描述符(用来找到该进程的数据)和代码段描述符(用来找到该进程的代码)。当创建一个新进程时,需要设置该进程的TSS和LDT,并将它们挂接到GDT中。在切换进程时,需要GDT中获取新进程的TSS和LDT存放到对应寄存器(TSR和LDTR)中,并将当前进程的TSS和LDT保存到GDT中。效果如图所示
为新进程分页。分段处理完之后,接着进行分页。分页是建立在分段的基础上,分段时用段基址和段限长分别为分页确定了从哪里开始复制页面表项信息,复制到哪里去,以及复制多少这三件事情。
前面我们提到,str1创建后还没有自己的程序,需要和父进程共享程序,这一点在分页时表现为和shell进程共享页面,即为str1进程另起一套页目录表项和页表项,使之指向共同的页面。
注意:为新进程创建页表时,还要调用get_free_page()申请空闲页面,这些也表现是不让进程直接使用的,并没有映射到进程的线性地址空间,所以进程无法访问这些页面。此时申请页面是在内核中执行的,处于内核的线性地址空间,因此内核具备访问它的能力。
继承文件。分段,分页完成后,还有文件继承的问题要处理,shell打开的文件,它的子进程一并继承,具体表现为文件的引用计数和i节点引用计数累加,将来子进程要打开这些文件时,就可以直接操作,不需要重新打开。
将新进程的TSS和LDT挂接到GDT。
注意:TSS和LDT对进程的保护至关重要,绝对不允许进程执行时干扰到其他进程。操作系统会始终监视不让一进程随意跳到其他进程:
1假设用户希望利用段内跳转跳到其他进程,即跳转值超过段限长。在LDT中记录了进程的段基址和段限长,每执行一条指令,硬件都会检查是否超过范围,若超过,则直接报错。
2假设用户希望强行进行段间跳转。在Linux0.11中每个进程都有自己的LDT,此时进程处于3态,若要实现段间跳转则需要修改LDTR的值(执行LLDT才可修改),而该指令属于0态指令,所以用户无法改变LDT,那么便无法直接跳到其他进程。
创建str1进程的过程到此结束,现在需要把它的状态设为就绪态。
二、进程的加载与运行
关系解绑
创建str1时我们说到它共享了一些shell进程打开的文件,利用父进程的程序为子进程做好了一部分准备工作。但现在它要加载自己的程序了,所以有些关系要解绑。并且str1进程现在与shell进程正在共享着相同的页面,现在str1要加载自己的程序了,所以同样也需要解绑页面共享关系。假设程序内容如下。
由于子进程的代码和数据的长度和父进程不一定相同,所以需要根据程序的长度来重新设置LDT。接着还需要调整该进程的task_struct中的一些变量,具体不再详细说明,最后再调整EIP和ESP,使得中断返回后,能直接从str1程序开始位置执行。
前面我们已经介绍过,str1进程与shell进程解除共享页面的关系,控制的页表也已经释放,意味着页目录表项的内容为0,在str1程序一开始执行时将产生缺页中断。
利用缺页中断从硬盘获取程序内容
缺页中断信号产生后,page_fault这个服务程序将对此进行响应,并调用call_do_no_page调用到缺页中断处理程序_do_no_page中去执行。接着就要从主存中申请空闲页面,然后从硬盘上把str1程序加载进来。申请空闲页时需要在内存管理结构mem_map中登记。将str1程序从硬盘加载到这个新分配的页面中,每次加载4KB内容,同时将这页内存映射到str1进程的线性地址空间内,映射完毕后str1进程才能执行加载的程序。这个程序是大于一个页面的,所以如果需要新的代码,就会不断产生缺页中断,加载需要执行的程序。到此加载过程结束。
程序运行
前面讲程序从硬盘搬进内存以后,程序可以开始运行,压栈动作产生(调用foo函数)。str1中的foo函数被递归调用,在foo函数中使用一个大小为2048的字符数组,每调用一次foo函数,str1的用户栈(esp)就向下增长2048个字节(两次压栈就超过了一个页面的总空间4KB)。在第二次调用foo时,MMU解析线性地址时,发现新的页表项P位为-,因此再次产生缺页中断,准备申请新的页面。程序继续执行,如此反复进行“压栈、若表项P位为0、缺页中断、分配物理内存、压栈…”。当程序执行完毕,函数返回导致进程清栈,ESP向上收缩。
注意:在清栈时,之前映射到栈线性地址空间的物理页面并没有被释放,因为程序执行时内核不在执行状态,因此执行过程中废弃的页面内核无法检测,CPU中也没有专门的功能电路来管理此事,因此清栈后的页面并没有被释放。
三、进程的退出
str1进程调用exit()函数进行退出,最终会映射到sys_exit函数去执行,并调用do_exit函数来处理str1退出的相关事务。进程退出包含两个方面:
第一,释放进程代码和数据占用的物理内存并解除与str1这个可执行文件的关系,这一点由str1进程自己负责。
进入do_exit函数后,调用free_page_tables将str1程序所占用的页面释放掉,包括前面提到的已清栈但尚未释放的内存页面,这些页面仍然保存str1进程的垃圾数据,但解除了映射关系后,str1进程无法找到这些页面。
解除与该进程对应可执行文件的关系,具体表现为将与父进程共享的文件释放掉,然后内核将str1进程设置为僵死状态,并给父进程shell发出子进程退出信号。到此为止str1对退出所做的善后工作已经完毕,str1进程将切换到其他进程去执行。
第二、释放str1进程的管理结构task_struct所占用的物理内存并解除与task[64]的关系,这一点由父进程shell负责。
SHELL进程接收到str1发送的信号而被唤醒,即设置为就绪态,之后切换到SHELL进程去执行。SHELL进程执行进入内核后,内核将释放str1进程的task_struct所占用的页面,并解除与task[64]的关系,这样str1就彻底从系统中退出了。