不管是什么领域,建造往往比销毁困难得多。建造一个大楼可能需要几年的时间,而销毁它也许就是几秒钟的事情。究其根本,因为建造是个熵减的过程,必须有来自系统外的能量输入。而创建中有一类问题显得更为特殊,那就是起源的问题。第一个生命如何诞生?人类又是如何起源的?诸如此类的问题,人类从未停止探究。。。
而今天我们要探究的主题是Linux中的进程创建。大部分的人可能都知道fork,都知道进程就是像细胞分裂一样分出来的。那么你是否清楚下面这些:为了分裂它需要准备什么?所谓分裂到底分了什么,又遗传了什么?遗传物质是如何传递的?第一个进程又是怎么来的?
接下来就让我们一起来探索这些问题吧!
什么是进程
定义及背景
讨论任何问题前我们都需要先明确定义,这样讨论才显得有意义。所以既然我们聊进程,就需要先知道什么是进程。
通常书中是这么定义进程:进程是程序执行的一个实例。你可以将进程看作描述进程已经执行到何种程度的数据结构的集合。
这样的定义过于简单,我们需要对其有更深的了解。进程其实是跟着多道程序设计的操作系统而产生的一个概念。早期的计算机并不支持多任务,只能有一个程序运行,但是这样的效率太低。于是从Intel 80286/80386起,CPU开始支持保护模式,操作系统也随之开始支持多任务。在单道程序时代CPU和内存都是你的,你想怎么造就怎么造。但是在多道程序时代就不行了,因为系统资源(CPU、内存等)需要共享,如果不进行相应的管理整个系统就乱套了。
于是,就抽象出了进程的概念,它担当了分配系统资源(CPU时间、内存等)的实体,对共享资源的访问提供隔离和保护。
进程就像人类,它们被创建,有自己的生命周期,可能处在几个不同的状态。类似地,进程之间有亲属关系,也有非亲属的关系。
进程描述符
为了管理进程,内核必须对进程状态进行清楚地描述。进程描述符的作用正在于此。task_struct是一个非常复杂的结构体,它包含了与一个进程相关的所有信息。即便是在早期的Linux 0.11版本中,它就已经包含如下这么多字段了:有最基本进程状态信息、有内存相关的信息、有文件系统相关的信息、局部描述符表ldt、任务状态段tss。。其中ldt放着进程的代码段和数据段的描述符,tss是用于记录进程切换时使用的CPU寄存器信息。
1 | struct task_struct { |
tss_struct的结构如下,可以看到大部分都是寄存器的信息。
1 | struct tss_struct { |
进程0
进程0就像亚当,它是所有进程的祖先。Linus就像上帝,他预先静态设置好了进程0的数据结构,然后在内核初始化阶段对它吹了一口气,进程0就活了。
如下所示,进程0的task_struct是在静态变量init_task中通过INIT_TASK宏预先设置好的。从这里我可以看出task_struct所在页面同时也是内核栈,栈指针是从页面末端开始的。
1 | static union task_union init_task = {INIT_TASK,}; |
具体看下宏INIT_TASK的内容,它预先设置好了task_struct结构的各个字段,其中包含了ldt和tss。可以结合前面task_struct的各个字段,了解下各个初始值的含义。我们这里提几个特殊的
- ldt是局部描述符表LDT,
0x9f, 0xc0fa00
和0x9f, 0xc0f200
分别表示进程0的代码段和数据段描述符,基址0x0,限长640K,G=1、D=1、DPL=3、P=1,TYPE则分别是0x0a和0x02。因为其基址是0,所以其实还是指向内核的代码段和数据段。 - tss是任务状态段
- 其中第2个字段的值
PAGE_SIZE+(long)&init_task
表示内核堆栈指针esp0,它指向init_task所在页的末尾 - 随后的ss初始化为0x10,表示是内核数据堆栈段选择符。
(long)&pg_dir
将cr3初始化为页目录所在地址- 接下来10个寄存器都初始化为0,其中包括用户态的eip和esp
- 6个段寄存器的值都初始化为0x17,表示用户态数据段选择符
_LDT(0)
表示进程0的LDT选择符
- 其中第2个字段的值
1 |
可以看到大部分的进程信息已经静态设置好了,不过现在它还是一个死的进程0,我们来看看内核是怎么给它吹了一口气让它活过来的。
下面是内核main函数的部分,注意main函数并不是内核程序运行的入口,最初运行的是系统引导程序、随后是系统进入保护模式完成一些初始化之后进入main函数。一开始系统使用的是临时堆栈,进入保护模式后堆栈段被设置为内核数据段(0x10),esp指向user_stack数组的末尾,刚进入main程序时系统仍然使用这个堆栈。
1 | void main(void) |
sched_init()
函数中做了进程0的部分初始化工作,首先是在全局描述符表GDT中设置了进程0的TSS和LDT描述符项,然后分别加载到tr和ldtr寄存器中。内核只有这一次是显式加载的LDT描述符,以后进程的LDT描述符在进程切换时CPU会根据TSS中的LDT选择符自动加载。
1 | // 设置进程0的TSS和LDT描述符到GDT中 |
接下来的move_to_user_mode()
便是最后吹的那口气了。这一部分的在前一篇讲内核启动与初始化的文章中已经详细讲过。它完成了两个重要的历史任务:从内核态转移到用户态,启动进程0。通过向堆栈中压入特定的值,手动模拟中断过程。
- 压入用户态堆栈段选择符,0x17表示用户态局部表的数据堆栈段(此前ss是0x10)
- 压入用户态堆栈指针,继承了执行
move_to_user_mode
之前的esp - 压入状态寄存器
- 压入用户态代码段选择符,0x0f表示用户态局部表中的代码段(此前cs是0x08)
- 压入偏移地址,就是下面标号1的位置
1 | #define move_to_user_mode() \ |
最后执行iret
,系统从内核态转移到用户态,进程0正式活了过来。它的内核态堆栈是init_task结构中静态设置的,用户态堆栈段和代码段是也是在init_task的ldt中静态设置的,其基址是0,所以仍然是指向内核的代码段和数据段,只是特权级变成了用户级。用户态堆栈指针继承了之前的esp,所以仍然指向原来的位置。eip则手动设置为iret
下一条指令的位置。所以iret
之后就继续执行后面的代码了。
进程0的执行的任务很简单,就是fork出进程1,然后就进入死循环了。当系统没有其他可运行的进程时就会运行进程0,所以进程0又叫idle进程。
1 | if (!fork()) { /* we count on this going ok */ |
fork与系统调用
进程1的地位稍微有点特殊,它是从进程0上fork出来的,所以其代码段数据段是跟进程0一样的。至于后续的所有进程,都是进程1的子进程,因为都是进程1在fork之后execve的,所以其代码段和数据段不再是内核的代码和数据段。
接下来我们探究下fork是如何一分为二的,这要从fork系统调用定义的地方开始看起。
1 | static inline _syscall0(int,fork) |
_syscall0
是个宏,内核定义了几个这样类似的宏,其中后面跟的那个数字表示参数的个数
1 |
我们来看下_syscall0
的具体定义:
1 |
这几个宏其实是内嵌了汇编的函数,只有一条int $0x80
指令,其中0x80是系统调用的中断号。分别有1个输出参数和输入参数。局部变量__res
作为输出参数绑定到eax寄存器上,用于接收返回值。输入参数__NR_fork
是系统调用的编号,每个系统调用都有一个独立的编号,同样绑定到寄存器eax上。
int
指令执行之后,CPU去IDT中找到对应的中断描述符,因为系统调用是实现为系统门(特权级DPL为3的陷阱门),所以可以在用户态调用,门描述符中找到中断例程所在的段的选择符及段内偏移,跳转到那里。对于系统调用,就是跳转到下面system_call:
处:
这样进程便通过系统调用进入了内核空间,int
指令引发的CPU的中断过程会自动将用户态原堆栈段ss、堆栈指针esp、状态寄存器eflags、原用户代码段cs、原用户代码eip入栈(注意:这里是入栈到内核堆栈)。那么CPU为什么要将这些寄存器压栈?因为它得记得回去的路。就如同函数调用要把返回地址压栈一样,中断也需要将返回地址压栈。又因为内核代码段跟用户代码段是不同的,所以原用户态代码段寄存器也需要压栈,另外内核态和用户态特权级不同,是使用独立的堆栈,所以用户态的堆栈段寄存器ss和堆栈指针esp也要压栈。因此刚进入内核空间时,内核堆栈指针位于下图的esp0位置。
system_call
开头这一段代码是所有系统调用通用的,它首先检查eax中的系统调用号是否超出范围,如正常就将段寄存器和通用寄存器压栈,然后重新设置ds、es为内核数据段,fs为用户数据段。最后通过系统调用号查表调用对应的处理函数。
1 | system_call: |
fork系统调用内核堆栈情况:
1 | HIGH +----------------------+ |
对于fork系统调用,会走到sys_fork
这里,此时内核堆栈指针位于esp1处。find_empty_process
函数只是获取一个空闲的进程编号(注意并不是pid),如果没有空闲的了直接跳到标号1处返回。然后将剩余的所有可编程寄存器也入栈,最后将新进程编号eax入栈。
1 | sys_fork: |
接下来调用核心函数copy_process
,刚进入该函数时内核堆栈指针位于esp2处。这个函数的参数有很多,这些其实都是前面压栈的寄存器。因为x86上C语言的调用约定是参数从右往左压栈,所以最后压栈的nr是第一个参数。之所以需要这么多参数,是因为父进程需要把所有这些寄存器的值都遗传给子进程,其余大部分信息则可以通过进程描述符获取。该函数首先分配一个空闲页作为新进程的task_struct结构体,然后将指针保存到task数组中。
1 | int copy_process(int nr,long ebp,long edi,long esi,long gs,long none, |
接下来就开始初始化子进程的进程描述符p。下面第一行将父进程的进程描述符直接复制给子进程,所以后面没有额外修改的字段默认都是跟父进程一样的。但毕竟子进程是一个独立的进程,需要修改必要的字段,如pid、父进程、时间片、信号、定时器、时间等。
1 | *p = *current; /* NOTE! this doesn't copy the supervisor stack */ |
接下来是初始化子进程的tss,注意到内核堆栈是指向task_struct所在页的末尾,p->tss.esp0 = PAGE_SIZE + (long) p;
。内核堆栈段选择符ss0是0x10,即为内核数据段。其余寄存器值都是拷贝的父进程,除了eax寄存器。可以看到子进程的eax被赋值为0,作为对比父进程的eax(即最后的返回值)是last_pid。这里的eax值其实就是fork函数最终的返回值,所以才有了fork对于子进程返回0,对于父进程返回子进程的pid。
p->tss.ldt = _LDT(nr)
是设置新进程的LDT选择符,以使进程切换时能够找到它的LDT。
1 | p->tss.back_link = 0; |
接下来的copy_mem()
函数开始拷贝内存。
1 | if (copy_mem(nr,p)) { |
我们来看看其具体实现,可以看到它并没有直接复制内存页,而是copy_page_tables()
复制了页表,而且只复制了数据段的页表。(因为Linux 0.11所有进程是共享页目录的,且Linux 0.11中代码段和数据段的基址是相同的,数据段长度不小于代码段,所以这里只需要复制数据段即可。)
(Linux 0.11中最多支持同时64个进程,每个进程的线性基址根据其进程编号*64MB得到,即下面代码中的nr * 0x4000000
。)
之前只是设置了tss中的ldt选择符,LDT表还是拷贝的父进程的。其中的用户代码段和数据段描述符并没有更新,在这里set_base()
用新的基址对其进行的了设置。
这里只是完成了写时复制的第一块拼图,并不是完整的写时复制机制,篇幅原因这里不再进行展开。(或许后面可以单独写一篇)
1 | int copy_mem(int nr,struct task_struct * p) |
我们继续把copy_process()
函数剩余部分先看完。接下来是增加引用计数,进程打开的所有文件、以及pwd、root、executable三个i节点。然后在GDT中设置子进程的TSS和LDT描述符项。最后将进程状态改为可运行状态,并返回子进程的pid。
1 | for (i=0; i<NR_OPEN;i++) |
前面讲都是系统调用进来的过程,剩下的就是系统调用返回的一个过程,注意这里入栈的eax就是fork的返回值。执行完具体的系统调用处理函数之后到这可能会发生调度行为,所以fork之后父进程与子进程到底哪个先运行是不确定的。
1 | pushl %eax |
如果是从用户态进行的系统调用,还会对进程进行信号的处理,因为不是我们今天关注的重点,这里不再展开。最后就是将之前压栈的所有寄存器出栈恢复,并iret
回到用户空间继续执行。此时内核堆栈又恢复到了最初干净的状态,cs/eip/ss/esp恢复为执行int
指令前的状态,继续执行用户态fork()
后面的程序。
1 | ret_from_sys_call: |
小结
简单回顾一下,进程的创建主要就是初始化进程描述符结构,包括其中的ldt和tss。内存的拷贝,因为写时复制机制只是拷贝了页表(Linux 0.11所有进程是共享页目录)。还要在GDT全局描述符表中设置进程的LDT和TSS描述符。
进程0作为所有进程的祖先比较特殊,它的进程描述符结构是静态设置好的,然后在初始化阶段将其LDT和TSS描述符设置到GDT中,并手动加载tr和ldtr。最后通过手动模拟中断过程,将其转移到用户态,并且设置其堆栈指针和eip指针。
后续的进程创建都是通过fork来完成的,进程描述符中的大部分信息都是直接复制的父进程。为了保持状态跟父进程一致,所有的可编程寄存器都作为参数传递给copy_process
,当然子进程作为一个独立的进程还是有一些地方跟父进程是不同的,尤其是内核堆栈指针、局部描述符表LDT以及返回值eax。
最初提出的那些问题,现在应该都能解答了吧。