Linux内核中隐藏的兵法—无中生有

不管是什么领域,建造往往比销毁困难得多。建造一个大楼可能需要几年的时间,而销毁它也许就是几秒钟的事情。究其根本,因为建造是个熵减的过程,必须有来自系统外的能量输入。而创建中有一类问题显得更为特殊,那就是起源的问题。第一个生命如何诞生?人类又是如何起源的?诸如此类的问题,人类从未停止探究。。。

而今天我们要探究的主题是Linux中的进程创建。大部分的人可能都知道fork,都知道进程就是像细胞分裂一样分出来的。那么你是否清楚下面这些:为了分裂它需要准备什么?所谓分裂到底分了什么,又遗传了什么?遗传物质是如何传递的?第一个进程又是怎么来的?

接下来就让我们一起来探索这些问题吧!

什么是进程

定义及背景

讨论任何问题前我们都需要先明确定义,这样讨论才显得有意义。所以既然我们聊进程,就需要先知道什么是进程。

通常书中是这么定义进程:进程是程序执行的一个实例。你可以将进程看作描述进程已经执行到何种程度的数据结构的集合。

这样的定义过于简单,我们需要对其有更深的了解。进程其实是跟着多道程序设计的操作系统而产生的一个概念。早期的计算机并不支持多任务,只能有一个程序运行,但是这样的效率太低。于是从Intel 80286/80386起,CPU开始支持保护模式,操作系统也随之开始支持多任务。在单道程序时代CPU和内存都是你的,你想怎么造就怎么造。但是在多道程序时代就不行了,因为系统资源(CPU、内存等)需要共享,如果不进行相应的管理整个系统就乱套了。

于是,就抽象出了进程的概念,它担当了分配系统资源(CPU时间、内存等)的实体,对共享资源的访问提供隔离和保护。

进程就像人类,它们被创建,有自己的生命周期,可能处在几个不同的状态。类似地,进程之间有亲属关系,也有非亲属的关系。

进程描述符

为了管理进程,内核必须对进程状态进行清楚地描述。进程描述符的作用正在于此。task_struct是一个非常复杂的结构体,它包含了与一个进程相关的所有信息。即便是在早期的Linux 0.11版本中,它就已经包含如下这么多字段了:有最基本进程状态信息、有内存相关的信息、有文件系统相关的信息、局部描述符表ldt、任务状态段tss。。其中ldt放着进程的代码段和数据段的描述符,tss是用于记录进程切换时使用的CPU寄存器信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields */
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;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
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];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss;
};

tss_struct的结构如下,可以看到大部分都是寄存器的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};

注:本文的代码及叙述都是基于Linux 0.11

进程0

进程0就像亚当,它是所有进程的祖先。Linus就像上帝,他预先静态设置好了进程0的数据结构,然后在内核初始化阶段对它吹了一口气,进程0就活了。

如下所示,进程0的task_struct是在静态变量init_task中通过INIT_TASK宏预先设置好的。从这里我可以看出task_struct所在页面同时也是内核栈,栈指针是从页面末端开始的。

1
2
3
4
5
6
static union task_union init_task = {INIT_TASK,};

union task_union {
struct task_struct task;
char stack[PAGE_SIZE];
};

具体看下宏INIT_TASK的内容,它预先设置好了task_struct结构的各个字段,其中包含了ldt和tss。可以结合前面task_struct的各个字段,了解下各个初始值的含义。我们这里提几个特殊的

  • ldt是局部描述符表LDT,0x9f, 0xc0fa000x9f, 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选择符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define INIT_TASK \
/* state etc */ { 0,15,15, \
/* signals */ 0,{{},},0, \
/* ec,brk... */ 0,0,0,0,0,0, \
/* pid etc.. */ 0,-1,0,0,0, \
/* uid etc */ 0,0,0,0,0,0, \
/* alarm */ 0,0,0,0,0,0, \
/* math */ 0, \
/* fs info */ -1,0022,NULL,NULL,NULL,0, \
/* filp */ {NULL,}, \
{ \
{0,0}, \
/* ldt */ {0x9f,0xc0fa00}, \
{0x9f,0xc0f200}, \
}, \
/*tss*/ {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, \
{} \
}, \
}

可以看到大部分的进程信息已经静态设置好了,不过现在它还是一个死的进程0,我们来看看内核是怎么给它吹了一口气让它活过来的。

下面是内核main函数的部分,注意main函数并不是内核程序运行的入口,最初运行的是系统引导程序、随后是系统进入保护模式完成一些初始化之后进入main函数。一开始系统使用的是临时堆栈,进入保护模式后堆栈段被设置为内核数据段(0x10),esp指向user_stack数组的末尾,刚进入main程序时系统仍然使用这个堆栈。

1
2
3
4
5
6
7
8
9
10
11
void main(void)
{
// ...
sched_init();
sti();
move_to_user_mode();
if (!fork()) { /* we count on this going ok */
init();
}
for(;;) pause();
}

sched_init()函数中做了进程0的部分初始化工作,首先是在全局描述符表GDT中设置了进程0的TSS和LDT描述符项,然后分别加载到tr和ldtr寄存器中。内核只有这一次是显式加载的LDT描述符,以后进程的LDT描述符在进程切换时CPU会根据TSS中的LDT选择符自动加载。

1
2
3
4
5
6
// 设置进程0的TSS和LDT描述符到GDT中
set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));
set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));
// 加载进程0的TSS到tr寄存器,LDT到ldtr寄存器
ltr(0);
lldt(0);

接下来的move_to_user_mode()便是最后吹的那口气了。这一部分的在前一篇讲内核启动与初始化的文章中已经详细讲过。它完成了两个重要的历史任务:从内核态转移到用户态,启动进程0。通过向堆栈中压入特定的值,手动模拟中断过程。

  • 压入用户态堆栈段选择符,0x17表示用户态局部表的数据堆栈段(此前ss是0x10)
  • 压入用户态堆栈指针,继承了执行move_to_user_mode之前的esp
  • 压入状态寄存器
  • 压入用户态代码段选择符,0x0f表示用户态局部表中的代码段(此前cs是0x08)
  • 压入偏移地址,就是下面标号1的位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#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" \
"movw %%ax,%%ds\n\t" \
"movw %%ax,%%es\n\t" \
"movw %%ax,%%fs\n\t" \
"movw %%ax,%%gs" \
:::"ax")

最后执行iret,系统从内核态转移到用户态,进程0正式活了过来。它的内核态堆栈是init_task结构中静态设置的,用户态堆栈段和代码段是也是在init_task的ldt中静态设置的,其基址是0,所以仍然是指向内核的代码段和数据段,只是特权级变成了用户级。用户态堆栈指针继承了之前的esp,所以仍然指向原来的位置。eip则手动设置为iret下一条指令的位置。所以iret之后就继续执行后面的代码了。

进程0的执行的任务很简单,就是fork出进程1,然后就进入死循环了。当系统没有其他可运行的进程时就会运行进程0,所以进程0又叫idle进程。

1
2
3
4
if (!fork()) {      /* we count on this going ok */
init();
}
for(;;) pause();

fork与系统调用

进程1的地位稍微有点特殊,它是从进程0上fork出来的,所以其代码段数据段是跟进程0一样的。至于后续的所有进程,都是进程1的子进程,因为都是进程1在fork之后execve的,所以其代码段和数据段不再是内核的代码和数据段。

接下来我们探究下fork是如何一分为二的,这要从fork系统调用定义的地方开始看起。

1
static inline _syscall0(int,fork)

_syscall0是个宏,内核定义了几个这样类似的宏,其中后面跟的那个数字表示参数的个数

1
2
3
4
5
6
7
#define _syscall0(type,name) \
...
#define _syscall1(type,name,atype,a) \
...
#define _syscall2(type,name,atype,a,btype,b) \
...
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \

我们来看下_syscall0的具体定义:

1
2
3
4
5
6
7
8
9
10
11
12
#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
system_call:
cmpl $nr_system_calls-1,%eax
ja bad_sys_call
push %ds
push %es
push %fs
pushl %edx
pushl %ecx # push %ebx,%ecx,%edx as parameters
pushl %ebx # to the system call
movl $0x10,%edx # set up ds,es to kernel space
mov %dx,%ds
mov %dx,%es
movl $0x17,%edx # fs points to local data space
mov %dx,%fs
call sys_call_table(,%eax,4)

fork系统调用内核堆栈情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
HIGH  +----------------------+
| | prev ss |
+----------------------+
| prev esp |
+----------------------+
| eflags |
+----------------------+
| | prev cs |
+----------------------+
| prev eip |<-- esp0
+----------------------+
| | ds |
+----------------------+
| | es |
+----------------------+
| | fs |
+----------------------+
| edx |
+----------------------+
| ecx |
+----------------------+
| ebx |
+----------------------+
| eip1 |<-- esp1
+----------------------+
| | gs |
+----------------------+
| esi |
+----------------------+
| edi |
+----------------------+
| ebp |
+----------------------+
| eax | nr
+----------------------+
| eip2 |<-- esp2
LOW +----------------------+

对于fork系统调用,会走到sys_fork这里,此时内核堆栈指针位于esp1处。find_empty_process函数只是获取一个空闲的进程编号(注意并不是pid),如果没有空闲的了直接跳到标号1处返回。然后将剩余的所有可编程寄存器也入栈,最后将新进程编号eax入栈。

1
2
3
4
5
6
7
8
9
10
11
12
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

接下来调用核心函数copy_process,刚进入该函数时内核堆栈指针位于esp2处。这个函数的参数有很多,这些其实都是前面压栈的寄存器。因为x86上C语言的调用约定是参数从右往左压栈,所以最后压栈的nr是第一个参数。之所以需要这么多参数,是因为父进程需要把所有这些寄存器的值都遗传给子进程,其余大部分信息则可以通过进程描述符获取。该函数首先分配一个空闲页作为新进程的task_struct结构体,然后将指针保存到task数组中。

1
2
3
4
5
6
7
8
9
10
11
12
13
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;

p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;

接下来就开始初始化子进程的进程描述符p。下面第一行将父进程的进程描述符直接复制给子进程,所以后面没有额外修改的字段默认都是跟父进程一样的。但毕竟子进程是一个独立的进程,需要修改必要的字段,如pid、父进程、时间片、信号、定时器、时间等。

1
2
3
4
5
6
7
8
9
10
11
*p = *current;  /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->father = current->pid;
p->counter = p->priority;
p->signal = 0;
p->alarm = 0;
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0;
p->cutime = p->cstime = 0;
p->start_time = jiffies;

接下来是初始化子进程的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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p; /* 内核堆栈指针 */
p->tss.ss0 = 0x10;
p->tss.eip = eip;
p->tss.eflags = eflags;
p->tss.eax = 0; /* 新进程的返回值是0 */
p->tss.ecx = ecx;
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
p->tss.ebp = ebp;
p->tss.esi = esi;
p->tss.edi = edi;
p->tss.es = es & 0xffff;
p->tss.cs = cs & 0xffff;
p->tss.ss = ss & 0xffff;
p->tss.ds = ds & 0xffff;
p->tss.fs = fs & 0xffff;
p->tss.gs = gs & 0xffff;
p->tss.ldt = _LDT(nr);
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));

接下来的copy_mem()函数开始拷贝内存。

1
2
3
4
5
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}

我们来看看其具体实现,可以看到它并没有直接复制内存页,而是copy_page_tables()复制了页表,而且只复制了数据段的页表。(因为Linux 0.11所有进程是共享页目录的,且Linux 0.11中代码段和数据段的基址是相同的,数据段长度不小于代码段,所以这里只需要复制数据段即可。)

(Linux 0.11中最多支持同时64个进程,每个进程的线性基址根据其进程编号*64MB得到,即下面代码中的nr * 0x4000000。)

之前只是设置了tss中的ldt选择符,LDT表还是拷贝的父进程的。其中的用户代码段和数据段描述符并没有更新,在这里set_base()用新的基址对其进行的了设置。

这里只是完成了写时复制的第一块拼图,并不是完整的写时复制机制,篇幅原因这里不再进行展开。(或许后面可以单独写一篇)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int copy_mem(int nr,struct task_struct * p)
{
unsigned long old_data_base,new_data_base,data_limit;
unsigned long old_code_base,new_code_base,code_limit;

code_limit=get_limit(0x0f);
data_limit=get_limit(0x17);
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
if (old_data_base != old_code_base)
panic("We don't support separate I&D");
if (data_limit < code_limit)
panic("Bad data_limit");
new_data_base = new_code_base = nr * 0x4000000;
p->start_code = new_code_base;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
printk("free_page_tables: from copy_mem\n");
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}
return 0;
}

我们继续把copy_process()函数剩余部分先看完。接下来是增加引用计数,进程打开的所有文件、以及pwd、root、executable三个i节点。然后在GDT中设置子进程的TSS和LDT描述符项。最后将进程状态改为可运行状态,并返回子进程的pid。

1
2
3
4
5
6
7
8
9
10
11
12
13
for (i=0; i<NR_OPEN;i++)
if ((f=p->filp[i]))
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case */
return last_pid;

前面讲都是系统调用进来的过程,剩下的就是系统调用返回的一个过程,注意这里入栈的eax就是fork的返回值。执行完具体的系统调用处理函数之后到这可能会发生调度行为,所以fork之后父进程与子进程到底哪个先运行是不确定的。

1
2
3
4
5
6
pushl %eax
movl current,%eax
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule

如果是从用户态进行的系统调用,还会对进程进行信号的处理,因为不是我们今天关注的重点,这里不再展开。最后就是将之前压栈的所有寄存器出栈恢复,并iret回到用户空间继续执行。此时内核堆栈又恢复到了最初干净的状态,cs/eip/ss/esp恢复为执行int指令前的状态,继续执行用户态fork()后面的程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
ret_from_sys_call:
movl current,%eax # task[0] cannot have signals
cmpl task,%eax
je 3f
cmpw $0x0f,CS(%esp) # was old code segment supervisor ?
jne 3f
cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ?
jne 3f
movl signal(%eax),%ebx
movl blocked(%eax),%ecx
notl %ecx
andl %ebx,%ecx
bsfl %ecx,%ecx
je 3f
btrl %ecx,%ebx
movl %ebx,signal(%eax)
incl %ecx
pushl %ecx
call do_signal
popl %eax
3: popl %eax
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret

小结

简单回顾一下,进程的创建主要就是初始化进程描述符结构,包括其中的ldt和tss。内存的拷贝,因为写时复制机制只是拷贝了页表(Linux 0.11所有进程是共享页目录)。还要在GDT全局描述符表中设置进程的LDT和TSS描述符。

进程0作为所有进程的祖先比较特殊,它的进程描述符结构是静态设置好的,然后在初始化阶段将其LDT和TSS描述符设置到GDT中,并手动加载tr和ldtr。最后通过手动模拟中断过程,将其转移到用户态,并且设置其堆栈指针和eip指针。

后续的进程创建都是通过fork来完成的,进程描述符中的大部分信息都是直接复制的父进程。为了保持状态跟父进程一致,所有的可编程寄存器都作为参数传递给copy_process,当然子进程作为一个独立的进程还是有一些地方跟父进程是不同的,尤其是内核堆栈指针、局部描述符表LDT以及返回值eax。

最初提出的那些问题,现在应该都能解答了吧。

-------------本文结束感谢您的阅读-------------

欢迎关注我的其它发布渠道