现在,计算机中已经有了一个名副其实的、3特权级的进程——进程0。下面我们要详细讲解进程0做的第一项工作——创建进程1。
进程0现在处在3特权级状态,即进程状态。正式开始运行要做的第一件事就是作为父进程调用fork函数创建第一个子进程——进程1,这是父子进程创建机制的第一次实际运用。以后,所有进程都是基于父子进程创建机制由父进程创建出来的。
3.1.1 进程0创建进程1
在linux操作系统中创建新进程的时候,都是由父进程调用fork函数来实现的。该过程如图3-1所示。
执行代码如下:
从上面main.c的代码中对fork()的声明,可知调用fork函数;实际上是执行到unistd.h中的宏函数syscall0中去,对应代码如下:
int 0x80的执行路线很长,为了清楚起见,将大致过程图示如下(见图3-2)。
详细的执行步骤如下:
先执行: "0" (__nr_ fork)这一行,意思是将fork 在sys_call_table[]中对应的函数编号__nr_ fork(也就是2)赋值给eax。这个编号即sys_fork()函数在sys_call_table中的偏移值。
紧接着就执行"int $0x80" ,产生一个软中断,cup从3特权级的进程0代码跳到0特权级内核代码中执行。中断使cpu硬件自动将ss、esp、eflags、cs、eip这5个寄存器的数值按照这个顺序压入图3-1所示的init_task中的进程0内核栈。注意其中init_task结构后面的红条,表示了刚刚压入内核栈的寄存器数值。前面刚刚提到的move_to_user_mode这个函数中做的压栈动作就是模仿中断的硬件压栈,这些压栈的数据将在后续的copy_process()函数中用来初始化进程1的tss。
值得注意,压栈的eip指向当前指令"int $0x80"的下一行,即if (__res >= 0) 这一行。这一行就是进程0从 fork函数系统调用中断返回后第一条指令的位置。在后续的3.3节将看到,这一行也将是进程1开始执行的第一条指令位置。请记住这一点!
根据2.9节讲解的sched_init函数中set_system_gate(0x80,&system_call)的设置,cpu自动压栈完成后,跳转到system_call.s中的_system_call处执行,继续将ds、es、fs、edx、ecx、ebx 压栈(以上一系列的压栈操作都是为了后面调用copy_process函数中初始化进程1中的tss做准备)。最终,内核通过刚刚设置的eax的偏移值“2”查询 sys_call_table[],得知本次系统调用对应的函数是sys_fork()。因为汇编中对应c语言的函数名在前面多加一个下划线“_”(如c语言的sys_fork()对应汇编的就是_sys_fork),所以跳转到 _sys_fork处执行。
点评
一个函数的参数不是由函数定义的,而是由函数定义以外的程序通过压栈的方式“做”出来的,是操作系统底层代码与应用程序代码写作手法的差异之一;需要对c语言的编译、运行时结构非常清晰,才能彻底理解。运行时,c语言的参数存在于栈中。模仿这个原理,操作系统的设计者可以将前面程序所压栈的值,按序“强行”认定为函数的参数;当call这个函数时,这些值就可以当做参数使用。
上述过程的执行代码如下:
call _sys_call_table(,%eax,4)中的eax是2,这一行可以看成call _sys_call_table + 2×4(4的意思是_sys_call_table[]的每一项有4字节),相当于call _sys_call_table[2](见图3-1的左中部分),就是执行sys_fork。
注意:call _sys_call_table(,%eax,4)指令本身也会压栈保护现场,这个压栈体现在后面copy_process函数的第6个参数long none。
对应代码如下:
3.1.2 在task[64]中为进程1 申请一个空闲位置并获取进程号
开始执行sys_fork()。
前面2.9节介绍过,在sched_init()函数中已经对task[64]除0项以外的所有项清空。现在调用find_empty_process()函数为进程1获得一个可用的进程号和task[64]中的一个位置。图3-3标示了这个调用的效果。
在find_empty_process()函数中,内核用全局变量last_pid来存放系统自开机以来累计的进程数,也将此变量用作新建进程的进程号。内核第一次遍历task[64],“&&”条件成立说明last_pid已被使用,则++last_pid,直到获得用于新进程的进程号。第二次遍历task[64],获得第一个空闲的i,俗称任务号。
现在,两次遍历的结果是新的进程号last_pid就是1,在task[64]中占据第二项。图3-3标示了这个结果。
因为linux 0.11的task[64]只有64项,最多只能同时运行64个进程,如果find_empty_process()函数返回-eagain,意味着当前已经有64个进程在运行,当然这种情况现在还不会发生。执行代码如下:
进程1的进程号及在task [64] 中的位置确定后,正在创建的进程1就等于有了身份。接下来,在进程0的内核栈中继续压栈,将5个寄存器值进栈,为调用copy_process()函数准备参数,这些数据也是用来初始化进程1的tss。注意:最后压栈的eax的值就是find_empty_process()函数返回的任务号,也将是copy_process()函数的第一个参数int nr。
压栈结束后,开始调用copy_process()函数,如图3-4中第二步所示。
3.1.3 调用copy_process函数
进程0已经成为一个可以创建子进程的父进程,在内核中有“进程0的task_struct”和“进程0的页表项”等专属进程0的管理信息。进程0将在copy_process()函数中做非常重要的、体现父子进程创建机制的工作:
1)为进程1创建task_struct,将进程0的task_struct的内容复制给进程1。
2)为进程1的task_struct、tss做个性化设置。
3)为进程1创建第一个页表,将进程0的页表项内容赋给这个页表。
4)进程1共享进程0的文件。
5)设置进程1的gdt项。
6)最后将进程1设置为就绪态,使其可以参与进程间的轮转。
现在调用copy_process()函数!
在讲解copy_process()函数之前,值得提醒的是,所有的参数都是前面的代码累积压栈形成的,这些参数的数值都与压栈时的状态有关。执行代码如下:
进入copy_process()函数后,调用get_free_page()函数,在主内存申请一个空闲页面,并将申请到的页面清零,用于进程1的task_struct及内核栈。
按照get_free_page()函数的算法,是从主内存的末端开始向低地址端递进,现在是开机以来,操作系统内核第一次为进程在主内存申请空闲页面,申请到的空闲页面肯定在16 mb主内存的最末端。
回到copy_process函数,将这个页面的指针强制类型转换为指向task_struct的指针类型,并挂接在task[1]上,即task[nr] = p。nr就是第一个参数,是find_empty_process函数返回的任务号。
请注意,c语言中的指针有地址的含义,更有类型的含义!强制类型转换的意思是“认定”这个页面的低地址端就是进程1的task_struct的首地址,同时暗示了高地址部分是内核栈。了解了这一点,后面的p->tss.esp0 = page_size + (long) p就不奇怪了。
task_struct是操作系统标识、管理进程的最重要的数据结构,每一个进程必须具备只属于自己的、唯一的task_struct。
效果如图3-5(为了方便阅读,我们把2.9节的图2-20复制在下面)所示。
task_union的设计颇具匠心。前面是task_struct,后面是内核栈,增长的方向正好相反,正好占用一页,顺应分页机制,分配内存非常方便。而且操作系统设计者肯定经过反复测试,保证内核代码所有可能的调用导致压栈的最大长度都不会覆盖前面的task_struct。因为内核代码都是操作系统设计者设计的,可以做到心中有数。相反,假如这个方法为用户进程提供栈空间,恐怕要出大问题了。
接下来的代码意义重大:
current是指向当前进程的指针;p是进程1的指针。当前进程是进程0,是进程1的父进程。将父进程的task_struct复制给子进程,就是将父进程最重要的进程属性复制给了子进程,子进程继承了父进程的绝大部分能力。这是父子进程创建机制的特点之一。
进程1的task_struct的雏形此时已经形成了,进程0的task_struct中的信息并不一定全都适用于进程1,因此还需要针对具体情况进行调整。初步设置进程1的task_struct如图3-6所示。从p->开始的代码,都是为进程1所做的个性化调整设置,其中调整tss所用到的数据都是前面程序累积压栈形成的参数。
这两行代码为第二次执行fork()中的if (__res >= 0) 埋下伏笔。这个伏笔比较隐讳,不太容易看出来,请读者一定要记住这件事!
调整完成后,进程1的task_struct如图3-7所示。
3.1.4 设置进程1的分页管理
intel 80x86体系结构分页机制是基于保护模式的,先打开pe,才能打开pg,不存在没有pe的pg。保护模式是基于段的,换句话说,设置进程1的分页管理,就要先设置进程1的分段。
一般来讲,每个进程都要加载属于自己的代码、数据。这些代码、数据的寻址都是用段加偏移的形式,也就是逻辑地址形式表示的。cpu硬件自动将逻辑地址计算为cpu可寻址的线性地址,再根据操作系统对页目录表、页表的设置,自动将线性地址转换为分页的物理地址。操作系统正是沿着这个技术路线,先在进程1的64 mb线性地址空间中设置代码段、数据段,然后设置页表、页目录。
1.在进程1的线性地址空间中设置代码段、数据段
调用copy_mem()函数,先设置进程1的代码段、数据段的段基址、段限长,提取当前进程(进程0)的代码段、数据段以及段限长的信息,并设置进程1的代码段和数据段的基地址。这个基地址就是它的进程号nr*64 mb。设置新进程ldt中段描述符中的基地址,如图3-8中的第一步所示。
2.为进程1创建第一个页表并设置对应的页目录项
在linux 0.11中,每个进程所属的程序代码执行时,都要根据其线性地址来进行寻址,并最终映射到物理内存上。通过图3-9我们可以看出,线性地址有32位,cpu将这个线性地址解析成“页目录项”、“页表项”和“页内偏移”;页目录项存在于页目录表中,用以管理页表;页表项存在于页表中,用以管理页面,最终在物理内存上找到指定的地址。linux 0.11中仅有一个页目录表,通过线性地址中提供的“页目录项”数据就可以找到页目录表中对应的页目录项;通过这个页目录项就可以找到对应的页表;之后,通过线性地址中提供的“页表项”数据,就可以在该页表中找到对应的页表项;通过此页表项可以进一步找到对应的物理页面;最后,通过线性地址中提供的“页内偏移”落实到实际的物理地址值。
调用copy_page_tables()函数,设置页目录表和复制页表,如图3-8中第二步和第三步所示,注意其中页目录项的位置。
进入copy_page_tables()函数后,先为新的页表申请一个空闲页面,并把进程0中第一个页表里面前160个页表项复制到这个页面中(1个页表项控制一个页面4 kb内存空间,160个页表项可以控制640 kb内存空间)。进程0和进程1的页表暂时都指向了相同的页面,意味着进程1也可以操作进程0的页面。之后对进程1的页目录表进行设置。最后,用重置cr3的方法刷新页变换高速缓存。进程1的页表和页目录表设置完毕。
执行代码如下(为了更容易读懂,我们在源代码中做了比较详细的注释):
进程1此时是一个空架子,还没有对应的程序,它的页表又是从进程0的页表复制过来的,它们管理的页面完全一致,也就是它暂时和进程0共享一套内存页面管理结构,如图3-10所示。等将来它有了自己的程序,再把关系解除,并重新组织自己的内存管理结构。
3.1.5 进程1共享进程0的文件
返回copy_process()函数中继续调整。设置task_struct中与文件相关的成员,包括打开了哪些文件 p->filp[20]、进程0的“当前工作目录i 节点结构”、“根目录i 节点结构”以及“执行文件i 节点结构”。虽然进程0中这些数值还都是空的,进程0只具备在主机中正常运算的能力,尚不具备与外设以文件形式进行交互的能力,但这种共享仍有意义,因为父子进程创建机制会把这种能力“遗传”给子进程。
对应的代码如下:
3.1.6 设置进程1在gdt中的表项
之后把进程1的tss和ldt,挂接在gdt中,如图3-11所示,注意进程1在gdt中所占的位置。
3.1.7 进程1处于就绪态
将进程1的状态设置为就绪态,使它可以参加进程调度,最后返回进程号1。请注意图3-11中间代表进程的进程条,其中,进程1已处在就绪态。执行代码如下:
至此,进程1的创建工作完成,进程1已经具备了进程0的全部能力,可以在主机中正常地运行。
进程1创建完毕后,copy_process()函数执行完毕,返回sys_fork()中 call _copy_process()的下一行执行,执行代码如下:
由于当前进程是进程0,所以就跳转到标号3处,将压栈的各个寄存器数值还原。图3-12表示了init_task中清栈的这一过程。值得注意的是popl %eax这一行代码,这是将前面刚刚讲解过的pushl %eax压栈的进程1的进程号,恢复给cpu的eax,eax的值为“1”。
之后,iret中断返回,cpu硬件自动将int 0x80的中断时压的ss、esp、eflags、cs、eip的值按压栈的反序出栈给cpu对应寄存器,从0特权级的内核代码转换到3特权级的进程0代码执行,cs:eip指向fork( )中int 0x80的下一行 if (__res > =0)。
对应的执行代码如下:
在讲述执行if (__res >= 0)前,先关注一下: " =a" (__res)。这行代码的意思是将__res的值赋给eax,所以if (__res >= 0)这一行代码,实际上就是判断此时eax的值是多少。我们刚刚介绍了,这时候eax里面的值是返回的进程1的进程号 1,return (type) __res将“1”返回。
回到3.1.1节中fork()函数的调用点if (!fork( ))处执行,!1为“假”,这样就不会执行到init()函数中,而是进程0继续执行,接下来就会执行到for(;;) pause( )。
图3-12形象地表示了上述过程。