进程的创建过程
------基于Linux0.11源码分析
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中,而不是每个进程一个。当然这种区别会造成进程创建和切换过程中一些细节上的差异,但本质的部分和任务的切换过程并没有任何不同。
下面是Linux0.11手动填充进程0的进程描述符信息的宏:
#define INIT_TASK \
{ 0,15,15, \
0,{{},},0, \
0,0,0,0,0,0, \
0,-1,0,0,0, \
0,0,0,0,0,0, \
0,0,0,0,0,0, \
0, \
-1,0022,NULL,NULL,NULL,0, \
{NULL,}, \
{ \
{0,0}, \
{0x9f,0xc0fa00}, \
{0x9f,0xc0f200}, \
}, \
{ 0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\
0,0,0,0,0,0,0,0, \
0,0,0x17,0x17,0x17,0x17,0x17,0x17, \
_LDT(0),0x80000000, {} \
}, \
}
除了填充进程描述符的信息外,还需要在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);
}
可以看到,在进程0的TSS和LDT描述符信息设置到GDT中后,立刻设置了TR寄存器和LDTR寄存器,为即将运行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实现:
_sys_fork:
call _find_empty_process
testl %eax,%eax
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call _copy_process
addl $20,%esp
1: ret
可以看到,一个进程的创建主要有两个步骤:一是找到一个空闲进程资源(find_empty_process),Linux0.11来说可以同时运行的进程数目是64个,是有限的,因此需要先得到一个空闲的进程表中的一项用来索引即将创建的进程信息;第二个主要步骤就是复制(copy_process),这个函数具体来实现子进程基于父进程的复制创建。
主要包括的步骤和内容是:
1) 为新进程在内存中分配一个物理页,将新进程的描述符信息填充在该页的开头,并设置新进程的描述符里各项信息;
2) 拷贝父进程的页表,使得它们共同指向相同的物理页,同时将父进程的各个页表属性改为只读,这样将来可以使用写时复制机制。
3) 在GDT中设置该进程项的TSS和LDT选择符。
Linux0.11版本子进程内容的设置主要内容就是这些,当然不同版本会有不同,在改进执行性能上也会有改进,但这个版本体现出来的最基本创建过程基本上反映了操作系统创建进程的主要过程。
4. 子进程的运行
子进程在创建好后并不能立即执行,至少需要一次调度,而这个调度到子进程的运行过程就完全不需要像进程0那样人为在栈上设置信息然后用iret方式,而是执行的任务的切换过程。不考虑进程调度的各个算法和选择细节,最终负责完成切换操作的函数如下:
#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寄存器开始执行新的代码。当然由于先前拷贝的父进程的相关页面被设置为只读,子进程第一次执行到该页面时会触发页保护的异常,这时会触发写时复制操作,为子进程分配自己的相应页面。
符:任务(task)和进程(process)的区别
任务和进程很容易被人混淆,甚至在Linux中进程描述符结构体也是用task_struct表示,而不是process,这更让人有的时候搞不清楚。我个人认为,其实任务的概念更底层,可以认为是基于CPU的角度来考虑的,进程所处的层次更高一些,应当可以认为是操作系统一级的概念。
任务关注点是一组程序操作,这组操作实现了某个功能,它最终会涉及到指令级别,我们说任务的切换最终需要关注的还是CPU的相关指令。
进程的概念通常是指程序的执行,是动态的过程。进程除了包含其要运行的程序之外,还包括运行时的诸多信息,如运行时间,信号等等。