对Linux0.11 中 进程0 和 进程1分析

1. 背景

进程的创建过程无疑是最重要的操作系统处理过程之一,很多书和教材上说的最多的还是一些原理的部分,忽略了很多细节。比如,子进程复制父进程所拥有的资源,或者子进程和父进程共享相同的物理页面,拥有自己的地址空间,子进程创建后接受统一调度执行等等。

原理性的书籍更多地关注了进程创建过程中各个关键部分的功能,但由于过于抽象,很难理解,因此如果自己能够实际操作,实践这个过程就很重要,可以让那些看起来抽象的概念变的现实而容易理解,比如所谓的父进程的资源,父进程所拥有的物理页面,甚至父进程的地址空间等等,这些抽象的概念其实只要实际操作一次就更能有感性的认识。本人参考Linux0.11源代码实践了创建进程和调度,这个过程获益匪浅,这里把主要的学习成果结合实践总结一下。

 

2.  0号进程

子进程的创建是基于父进程的,因此一直追溯上去,总有一个进程是原始的,即没有父进程的。这个进程在Linux中的进程号是0,也就是传说中的0号进程(可惜很多理论书上对这个重要的进程只字不提)。

如果说子进程可以通过规范的创建进程的函数(如:fork())基于父进程复制创建,那么0号进程并没有可以复制和参考的对象,也就是说0号进程拥有的所有信息和资源都是强制设置的,不是复制的,这个过程我称为手工设置,也就是说0号进程是“纯手工打造”,这是操作系统中“最原始”的一个进程,它是一个模子,后面的任何进程都是基于0号进程生成的。

手工打造0号进程最主要包括两个部分:创建进程0运行时所需的所有信息,即填充0号进程,让它充满“血肉”;二是调度0号进程的执行,即让它“动”起来,只有动起来,才是真正意义上的进程,因为进程本身实际上是个动态的概念。

    不同的操作系统或者同一个操作系统的不同版本进程信息的内涵可能会有些细微的差距,但大体上关键的部分和逻辑是没有什么不同的,我这里只是基于Linux0.11的实现来描述进程创建的关键步骤和关键细节。

 

1)填充0号进程信息

       进程包括的内容非常复杂,但总的来说进程的信息都是由进程的描述符引导标识的,因此填充0号进程的过程逻辑上是以填充其描述符为牵引完成的(也有书将进程描述符称为进程控制块)。下面是Linux0.11版进程的描述符信息结构体:

struct task_struct {
       long state,counter,priority, signal;
       struct sigaction sigaction[32];
       long blocked; 
       int exit_code;
       unsigned long start_code,end_code,end_data,brk,start_stack;
       long pid,father,pgrp,session,leader;
       unsigned short uid,euid,suid,gid,egid,sgid;
       long alarm;
       long utime,stime,cutime,cstime,start_time;
       unsigned short used_math;
       int tty;    
       unsigned short umask;
       struct m_inode * pwd;
       struct m_inode * root;
       struct m_inode * executable;
       unsigned long close_on_exec;
       struct file * filp[NR_OPEN];
       struct desc_struct ldt[3];
       struct tss_struct tss;
};

可以看到进程描述符里的信息很多,大体上有几部分:

a. 进程的运行信息,如进程的当前状态(state),进程的各种时间片消耗记录(utime、stime等),进程的信号(signal)和优先级(priority)等。

b. 进程的基本创建信息,如进程号(pid),进程的创建用户(uid)等。

c. 进程的资源类信息,如使用的tty自设备号(tty),文件根目录i节点结构(root)等。

d. 进程执行和切换CPU需要使用的关键信息:局部描述符表(LDT)、任务状态段(TSS)信息。

 

这些信息并不是在进程创建的时候就全部确定的,大部分只是暂时赋一个初值,在运行的时候会动态更改,也有一些是要在进程运行前设置好的,才能保证进程被正确地执行起来。实际上,我们最需要填充的信息是那些使得操作系统可以顺利切换到0号进程的信息,最重要的显然是进程的LDT和TSS信息。TSS是CPU在切换任务时需要使用的信息,而LDT是局部描述符表,0号进程是第一个运行在用户态的进程,需要使用自己的LDT。TSS和LDT是保证不同进程之间相互隔离的重要机制。

实际上还有一个重要的信息不是放在进程本身的描述符里的,而是放在全局描述符表GDT中,因为所有的进程是由操作系统统一管理的,因此操作系统至少要保持对它们的索引,这种索引性质的信息放在操作系统内核的GDT中。对于Linux0.11来说,每个进程都有一个LDT和一个TSS描述符,而Linux2.4之后是每个CPU一个TSS描述符并存储在GDT中,而不是每个进程一个。当然这种区别会造成进程创建和切换过程中一些细节上的差异,但本质的部分和任务的切换过程并没有任何不同。

除了填充进程描述符的信息外,还需要在GDT中设置相关的项,即进程0的LDT和TSS选择符,这个工作是在sched_init()里完成的:

void sched_init(void){
...
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
       set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
...
ltr(0);
       lldt(0);
}

2)运行0号进程

   进程0是运行在用户态下的进程,因此就意味着进程0的运行过程实际上是一个从0级特权级到3级特权级切换的过程,使用的是CPU指令iret,模拟了中断调用的返回过程,具体执行过程由move_to_user_mode完成:

#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \
              "pushl $0x17\n\t" \
              "pushl %%eax\n\t" \
              "pushfl\n\t" \
              "pushl $0x0f\n\t" \
              "pushl $1f\n\t" \
              "iret\n" \
              "1:\tmovl $0x17,%%eax\n\t" \
...)

这个宏将进程0执行时的ss,esp,eflags.cs,eip信息全部压栈,待到执行iret指令时,CPU将这几项信息从栈中弹出加载到相应的寄存器中,这样就实现了进程0的启动执行。从这里也可以看出,进程0刚开始执行时几个关键寄存器的信息也是在其运行前事先设定好的,从进程描述符信息到执行信息均是人为设置,因此我称之为“纯手工打造的进程”。

      

3. 子进程的创建

       有了0号进程这个原始的进程,再来看子进程的创建就比较容易理解一些。除了0号进程外,其余的进程均使用系统调用fork()完成,其具体工作由内核态的_sys_fork实现

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
       "je 1f\n\t" \
       "movw %%dx,%1\n\t" \
       "xchgl %%ecx,_current\n\t" \
       "ljmp %0\n\t" \
       "cmpl %%ecx,_last_task_used_math\n\t" \
       "jne 1f\n\t" \
       "clts\n" \
       "1:" \
       ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
       "d" (_TSS(n)),"c" ((long) task[n])); \
}

最终的切换执行了一个ljmp操作,它的操作数是一个任务描述符,这会导致CPU执行一次任务切换,根据新进程的TSS信息将相关信息加载进cs,eip,eflags,ss,esp寄存器开始执行新的代码。当然由于先前拷贝的父进程的相关页面被设置为只读,子进程第一次执行到该页面时会触发页保护的异常,这时会触发写时复制操作,为子进程分配自己的相应页面。

      

附1:任务(task)和进程(process)的区别

    任务和进程很容易被人混淆,甚至在Linux中进程描述符结构体也是用task_struct表示,而不是process,这更让人有的时候搞不清楚。我个人认为,其实任务的概念更底层,可以认为是基于CPU的角度来考虑的,进程所处的层次更高一些,应当可以认为是操作系统一级的概念。

    任务关注点是一组程序操作,这组操作实现了某个功能,它最终会涉及到指令级别,我们说任务的切换最终需要关注的还是CPU的相关指令。

    进程的概念通常是指程序的执行,是动态的过程。进程除了包含其要运行的程序之外,还包括运行时的诸多信息,如运行时间,信号等等。


附2:

~内核开始运行时的第一个进程是0号进程,在0号进程中会对各种设备和运行时的环境进程初始化,包括(内存、中断、块设备、字符设备、终端、进程表)等。初始化完成后会将0号进程转移到用户模式运行,即0号进程的安全级由0转为3。
~ 然后在进程0中会调用fork()来创建1号进程,此时就有两个进程在运行了。在0号进程中会一直运行pause()挂起自身进程并呼叫进程调用函数。在 1号进程中会生成标准输入/输出/错误,然后会再次使用fork()生成2号进程,并使用wait()等待2号进程的终了。
~在2号进程中会将/etc/rc作为标准输入运行sh程序来初始化运行环境。
~1 号进程在等到2号进程终了后会继续运行。先关闭旧的标准输入/输出/错误,然后再生成新的。然后再调用fork()以登陆SHELL的形式生成新的进程 n,在1号进程中调用wait()等待进程n终了的同时做僵死子进程的清理工作(因为我们可以通过SHELL与系统交互生成很多的子进程在系统中运行)。 当等待的n号进程终了后,1号进程又会调用fork()生成新的进程nx,并重复以上的处理,在进程终了后再次生成新的进程。


你可能感兴趣的:(linux,struct,操作系统,进程,处理器)