进程的创建几乎调用了操作系统所有的功能模块,研究进程创建流程能让我们贯穿理解整个操作系统,并解开前面各个模块遗留问题的最好途径~Linux的进程创建分两种模式fork和fork+do_execve,下面我们先来看下第一种模式(由于文章过长,请根据标题阅读,可以先跳过二级或三级标题下的内容,这些都是分支详解,先把握一级标题下的主线流程~)
倚天剑------fork方式
首先我们来看下系统调用服务程序fork是什么样子的
.align 2
_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和_copy_process,我们先来看下第一个核心函数
int find_empty_process(void)
{
int i;
repeat:
if ((++last_pid)<0) last_pid=1;
for(i=0 ; i<NR_TASKS ; i++)
if (task[i] && task[i]->pid == last_pid) goto repeat;
for(i=1 ; i<NR_TASKS ; i++)
if (!task[i])
return i;
return -EAGAIN;
}
我们在笔记四的操作系统初始化曾经说过Linux保存进程信息的方式是通过一个struct task_struct * task[NR_TASKS]的指针数组,所以上面的代码实现的功能非常简单,首先生成一个目前任务数组中没有进程使用的pid,然后从任务数组中找一个空闲的未使用任务节点的索引并返回
现在我们看下第二个核心函数
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)
中断发生时候CPU帮我们压入的几个寄存器加上刚压入的几个寄存器,函数的调用参数已经全部在内核栈中了,下面我将再次庖丁解牛式的为大家分析这个函数
(1)为新进程的任务结构获取一个空闲页
p = (struct task_struct *) get_free_page();
首先获取一个空闲内存页用作新进程的TSS和内核栈(注意进程的TSS所占的内存页是在内核的段描述符和分页机制下获取的,释放的时候也必须在内核态完成;进程的任务结构和内核栈是公用一页的,一个用低地址,一个用高地址),所有任务都是从任务0拷贝出来的,任务0的TSS在哪呢?还记得我在笔记四中对于sched_init描述的吗?任务0的TSS是内核中的一个全局变量,通过编码的方式代替内存分配,即用一个页大小的字符数组代替内存页的分配,这是一个联合结构,低地址为任务结构(里面包含了进程的TSS结构),高地址为任务0的内核栈,具体做法如下所示,其他子进程的这种结构都要先申请一页空闲内存页
union task_union {
struct task_struct task;
char stack[PAGE_SIZE];
};
static union task_union init_task = {INIT_TASK,};
(2)复制父进程的任务结构
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
(3)对子进程的任务结构进行个性化设置
p->state = TASK_RUNNING;
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;
p->tss.back_link = 0;
(4)设置子进程的内核栈信息
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
上面这两步个性化设置要注意,每次从用户态进入内核态的时候,CPU自动加载的栈的选择子和栈顶指针就是读取这个的,你会发现这个就是我们刚才申请的内存页的尾部,这个相信很多操作系统书上都有描述
(5)设置子进程的返回环境
p->tss.eip = eip;
p->tss.eflags = eflags;
p->tss.eax = 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;
设置子进程的寄存器,这个是一个亮点,知道为什么fork子进程返回是0,而父进程是子进程pid吗?注意赋值右边的寄存器是调用参数不是当前进程现在的寄存器值,还记得我们压入的是什么调用参数吗?是父进程调用fork系统调用后内核栈保存的返回用户态的环境信息,所以当子进程被调度程序选中,ljmp后CPU读出的这些寄存器的值会直接让子进程直接回到用户态(和我们前面讨论的进程切换的时候还回到switch_to不同哦~),即父进程完成fork调用后的状态继续执行,而这边eax被设置成0(eax保存的是函数返回值),所以子进程从fork返回得到的结果是0,看不懂好好看下前面几篇笔记~
(6)设置子进程的局部段选择子
p->tss.ldt = _LDT(nr);
#define _LDT(n) ((((unsigned long) n)<<4)+(FIRST_LDT_ENTRY<<3))
这个宏为什么这样写?我们首先回忆下LDTR的结构,16位大小,只有高13位才是索引,FIRST_LDT_ENTRY<<3(低三位不用)是设置基础索引,即第一个任务的LDT的索引值,(unsigned long) n)<<4这边不应该和上面一样是3吗?为什么是4?别忘了,每个任务都有一个TSS和LDT,所以TSS和LDT的索引间隔是2,所以还要在多移一位,所以这边是4(如果又忘记了gdt表的布局,看下笔记四的最后一幅图)
(7)这个就是设置信号位图和数学协处理器,这个我不懂~
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__("fnsave %0"::"m" (p->tss.i387));
(8)拷贝父进程的页目录和页表结构
if (copy_mem(nr,p)) {
free_page((long) p);
return -EAGAIN;
}
这个又是个难点,认真看过我前面笔记的童鞋应该能参悟其中玄机,先来看下这个函数
int copy_mem(int nr,struct task_struct * p)
即用即学思想,下面我们来好好剖析下~
(8.1)获取父进程的代码段和数据段限长
code_limit=get_limit(0x0f);
data_limit=get_limit(0x17);
#define get_limit(segment) ({ \
unsigned long __limit; \
__asm__("lsll %1,%0\n\tincl %0":"=r" (__limit):"r" (segment)); \
__limit;})
原来就是根据输入的选择子取得对应的段描述符(lsll指令用法自行百度)。我们来看下我们输入的两个段描述符 0000 0000 0000 1111b和0000 0000 0001 0111b,取出两个的前13位,就是0000 0000 0000 1b和0000 0000 0001 0b,也就是1和2,再看看后3位,段选子的倒数第三位表明这个选择子是1-LDT还是0-GDT,很显然这边是LDT,最后两位是特权级3,级别最低是用户态;这边表示我要获取LDT表中的第1和第2项描述符,这两项对应什么东西呢?我们再看看我们的任务结构(省略大部分)
struct task_struct {
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
};
LDT一共有3项,LDT[1]和LDT[2]是什么?相信linus的注释大家都懂吧~而且人家变量名也很直观,code_limit和data_limit~
(8.2)获取父进程的代码段和数据段基地址
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
本来这个没什么好讲的,就是取出段描述符中的基址,由于基址的存放是分3部分的,所以要单独取每部分,然后拼接起来,段描述的结构我在Linux启动2中已经描述过了,不过为了照顾一小部分懒人,我还是剖析下吧
#define _get_base(addr) ({\
unsigned long __base; \
__asm__("movb %3,%%dh\n\t" \
"movb %2,%%dl\n\t" \
"shll $16,%%edx\n\t" \
"movw %1,%%dx" \
:"=d" (__base) \
:"m" (*((addr)+2)), \
"m" (*((addr)+4)), \
"m" (*((addr)+7))); \
__base;})
#define get_base(ldt) _get_base( ((char *)&(ldt)) )
Addr+7指向的是段描述的第8个字节对应Base Address 31:24,addr+4指向的是段描述的第5个字节对应Base Address 23:16,而最后的addr+2指向的是段描述符的第3、4字节,对应Base Address 15:00,所以拼接到一起就是Base Address 31:00
(8.3)设置子进程的代码段和数据段基地址
new_data_base = new_code_base = nr * 0x4000000;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
重点又来了,为啥新的任务的代码段和数据段基址是nr * 0x4000000,不是说了保护模式下每个进程的空间都是独立的,不同进程的线性地址可以相同,只要页目录和页表不同就可以了,现在这样即使相同的逻辑地址,转换出的线性地址都不一样,你前面说的不是坑人么~理论上如果进程的页目录和页表不同,段基地址是可以相同的,可惜遗憾的是,我们的linus大神为了简单处理,所有进程使用的都是位于内存0x0的_pg_dir这个页目录,我们前面讨论的前置条件就不满足了,那为啥是0x4000000呢?由于页目录最多有只有1024个目录项,而最多只有64个进程(#define NR_TASKS 64),每个进程最多也就能分配到16个页目录项,每个页目录项对应一个页表,每个页表对应1024块内存页,所以16个页目录项对应16*1024*4*1024==64MB==0x4000000,这个值就是每个进程的大小,即0进程使用0~15页目录项,1进程使用16~31页目录项,2进程使用32~47页目录项,以此类推。使用这个值的映射过程是怎么样呢?任务1的段基地址是0x400000,那么任务1的逻辑地址0x0的线性地址也是0x04000000,其前十位对应值为16,即页目录索引为16,表示使用任务1的页目录表,任务2的段基地址是0x800000,那么任务2的逻辑地址0x0的线性地址也是0x08000000,其前十位对应值为32,即页目录索引为32,表示使用任务2的页目录表,以此类推,现在明白段基地址为什么是nr * 0x4000000了吧~
(8.4)复制父进程的内存页对应的页目录项和页表项
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}
又是一个重点啊,这段代码看起来简单,但是蕴藏了一个重要的机制-----写时拷贝/写时复制,这个机制闪耀着linus大神天才般的思想~
int copy_page_tables(unsigned long from,unsigned long to,long size)
即用即学思想,继续剖析~
(8.4.1)计算目标目录项和源目录项的地址以及大小
from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
to_dir = (unsigned long *) ((to>>20) & 0xffc);
size = ((unsigned) (size+0x3fffff)) >> 22;
这个很简单,就是计算from地址的页目录项起始地址和to地址的页目录项起始地址,每个页目录项对应的内存空间是2^22 ==4M==1(页目录项)*1024(页表项)*4k(页大小),所以将size右移22位就是需要拷贝的页目录项个数
(8.4.2)判断源目录项和目的目录项是否存在
if (1 & *to_dir)
panic("copy_page_tables: already exist");
if (!(1 & *from_dir))
continue;
我们先看下循环的内容,上面两句的含义比较简单,如果目标页目录项已经存在则出错死机,如果源目录项不存在则表示当前页目录项不需要拷贝
(8.4.3)获取源页目录项对应的页表
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
(8.4.4)为目标页目录项分配一个内存页作为页表
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */
*to_dir = ((unsigned long) to_page_table) | 7;
(8.4.5)设置需要拷贝的页表项个数
nr = (from==0)?0xA0:1024;
(8.4.5)依次拷贝每个页表项,接下来我们看下循环内容
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
(8.4.6)保存源页表项的内容到this_page,如果页表项没内容,说明没有映射,也就不需要拷贝了
this_page = *from_page_table;
if (!(1 & this_page))
continue;
(8.4.7)设置写时拷贝条件之一
this_page &= ~2;
*to_page_table = this_page;
设置对应内存页的属性为只读(这个操作是写时拷贝的关键之一),并将其赋值给目标页表项,当子进程试图写此页的时候将触发页错误中断
(8.4.8)设置写时拷贝条件之二
if (this_page > LOW_MEM) {
*from_page_table = this_page;
this_page -= LOW_MEM;
this_page >>= 12;
mem_map[this_page]++;
}
如果对应的页表项对应的内存页是主内存区的,一般是用户代码段或者数据段所在的内存页,则增加对应内存页的引用次数(这个操作是写时拷贝的关键之二),以上就是循环内容。至于怎么运用这两个关键,我们将在稍后讲解。
(8.4.9)刷新父进程的分页映射
invalidate();
return 0;
在完成所有页目录项和页表项的拷贝之后,刷新页变换高速缓冲区,然后返回调用函数
PS:写时拷贝
两个关键是怎么构成写时拷贝技术的?由于我们拷贝的时候设置对应的内存页只读,所以当子进程开始执行的时候,我们可能会写入一些数据,而数据段对应的内存页是只读的,那么将会触发页错误中断_page_fault,我们将进入写时复制,将为子进程拷贝一套属于自己的数据页用于修改,独立于符进程的数据页,而代码段由于我们是只读的,不会触发页错误中断,也就不会发生写时拷贝,从而实现了共享代码段,这是多么牛X的思想。复制一个进程,我们不需要完全复制父进程的所有内存页,因为如果我们不改变内存页内容的话,是可以直接使用父进程的内存页的(共享的思想),只有当我们要修改的时候,我们才拷贝一份副本,然后将其修改成我们子进程的数据(一般需要修改的是栈所在的内存页,所以fork的进程的用户栈是在子进程试图操作父进程栈的时候触发写时拷贝生成的,任务0的用户栈参见笔记二的_stack_start介绍),代码共享,数据独立,这样做的结果就是创建子进程我们只花费了复制目录项和页表项的代价,这是多么快的速度,而且如果我们在fork之后调用do_exec,复制所有内存页是没有意义的,因为我们的原先内存页保存的数据段、代码段、栈等内容都将使用do_exec启动的新程序的内容,如果前面拷贝了父进程的内存页,则这边还要先释放这些拷贝过来的内存页,而写时复制操作则避免了这个过程。现在可以回到笔记九学习写时拷贝技术的实现了~
(9)增加父进程资源的引用计数
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++;
由于子进程和父进程共享所有资源,所以对父进程的所有的文件描述符的引用都要+1
(10)设置子进程在GDT表中的TSS和LDT段描述符,并将任务数组对应指针指向子进程的任务结构
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
task[nr] = p; /* do this last, just in case */
(11)返回子进程的pid
return last_pid;
父进程fork返回的是子进程的pid,现在知道为什么了吧?子进程启动的方式是通过系统调度函数schedule直接返回用户态,这个上面已经说明过了,父进程返回用户态是根据父进程自己的内核栈回溯回去的。
屠龙刀------fork+do_execve方式
fork的过程我们上面已经介绍过了,只不过我们将要修改后面的写时复制部分,因为这部分被do_execve代替了。这段代码太长这边就不贴了,直接进入剖析环节~
(1)禁止在内核调用do_execve函数
if ((0xffff & eip[1]) != 0x000f)
panic("execve called from supervisor mode");
变量eip指向内核栈中保存用户态寄存器eip的位置,eip[1]就是eip后面的一个栈值也就是CS用户态代码段选择子,这边判断是用户代码调用还是内核代码调用do_execve,如果是内核代码则出错死机
(2)初始化参数和环境串空间的页面指针数组
for (i=0 ; i<MAX_ARG_PAGES ; i++) /* clear page-table */
page[i]=0;
(3)取出文件的i节点,并判断文件是不是可执行文件
if (!(inode=namei(filename))) /* get executables inode */
return -ENOENT;
if (!S_ISREG(inode->i_mode)) { /* must be regular file */
iput(inode);
return -EACCES;
}
(4)查看文件的执行权限,判断当前进程是否有权限执行这个文件
i = inode->i_mode;
if (current->uid && current->euid) {
if (current->euid == inode->i_uid)
i >>= 6;
else if (current->egid == inode->i_gid)
i >>= 3;
} else if (i & 0111)
i=1;
if (!(i & 1)) {
iput(inode);
return -ENOEXEC;
}
(5)读出文件的第一块数据读入高速缓冲区
if (!(bh = bread(inode->i_dev,inode->i_zone[0]))) {
iput(inode);
return -EACCES;
}
关于Linux0.11的可执行文件介绍参见《Linux内核注释》
(6)对可执行文件头部进行检测
ex = *((struct exec *) bh->b_data); /* read exec-header */
brelse(bh);
if (N_MAGIC(ex) != ZMAGIC || ex.a_trsize || ex.a_drsize ||
ex.a_text+ex.a_data+ex.a_bss>0x3000000 ||
inode->i_size < ex.a_text+ex.a_data+ex.a_syms+N_TXTOFF(ex)) {
iput(inode);
return -ENOEXEC;
}
if (N_TXTOFF(ex) != BLOCK_SIZE)
panic("N_TXTOFF != BLOCK_SIZE. See a.out.h.");
文件的第一块数据就是exe文件的头部,我们将高速缓冲区的内容拷贝到struct exec结构中,然后释放高速缓冲块,并对文件头部进行检查,判断文件头和文件是否正常。
(7)复制环境变量串和参数串到参数和环境空间中
argc = count(argv);
envc = count(envp);
p = copy_strings(envc,envp,page,PAGE_SIZE*MAX_ARG_PAGES-4);
p = copy_strings(argc,argv,page,p);
如果会linux编程的应该知道execl的参数列表都是字符串,并且最后一个参数是NULL结尾,即我们do_execve的argv指向的是一个指针数组,数组的元素是指向的是字符串的指针,而且这个数组是以一个空指针作为结束标志,所以我们很容易计算出argv指针数组的长度(count调用),举个例子:
execl(“/bin/ls”,“ls”,“-al”,“/etc/passwd”,(char *)0)
下面我们将对copy_strings函数进行剖析,这个是实现用户态向内核态参数传递的核心。
(7.1)循环拷贝进程的用户态参数到内核,并重新构造进程的参数列表和用户态栈
while (argc-- > 0) {
循环完成所有参数拷贝就会退出,argc就是上面计算的argv指针数组的数组元素个数,下面来看看循环体内容
(7.2)从参数列表中获取一个指向参数的字符串指针,并保存到tmp中
if (!(tmp = (char *)get_fs_long(((unsigned long *) argv)+argc)))
panic("argc is wrong");
例如取出前面例子中的指向字符串“/etc/passwd”的指针
(7.3)计算获取到的字符串参数的长度
len=0; /* remember zero-padding */
do {
len++;
} while (get_fs_byte(tmp++));
(7.4)如果参数长度超过了进程为参数准备的存储范围就返回0
if (p-len < 0) /* this shouldn't happen - 128kB */
return 0;
(7.5)计算完成此次字符串拷贝后,剩余内存页个数
i = ((unsigned) (p-len)) >> 12;
p代表剩余大小,len表示本次字符串大小,右移12位表示除以4K,即一个内存页大小
(7.6)为保存本次字符串申请空闲内存页
while (i<MAX_ARG_PAGES && !page[i]) {
if (!(page[i]=get_free_page()))
return 0;
i++;
}
输入参数page是一个内存页管理数组,保存环境变量或者参数变量所在的内存页的起始地址,每一个数组元素对应一个内存页,而且分配是倒序的,即第一块保存变量的内存页的物理地址是保存在page数组的最后一个元素中的即初始偏移值p处的内存页。如下图所示:
如果页映射位图数组下标中大于剩余内存页个数的数组元素未被设置,说明需要为其分配一块内存页,并设置对应位图的物理内存页地址。本来按照正常的思维逻辑,我们会用一个变量来累计每次参数字符串的长度total_len +=len,然后用total_len来计算保存这么多变量所需内存页数N,然后看下page数组的最后的N个元素是不是都设置了,如果数组最后的N个元素起始几个没设置说明还未分配内存页,即上图深色的方块少了,就要申请空闲的内存页,即增加深色方块的个数。Linus大神在这边反其道而行之,算剩余大小而不是算需要多少空间,就是白色方块个数,那么剩余的方块都应该是深色,如果不是就要为其申请空闲内存页,之所以这么做是因为总大小都是要传进来的,不用白不用,何必要多弄个变量来保存总量
(7.7)拷贝参数字符串到对应内存页中
do {
--p;
if (!page[p/PAGE_SIZE])
panic("nonexistent page in exec.c");
((char *) page[p/PAGE_SIZE])[p%PAGE_SIZE] =
get_fs_byte(--tmp);
} while (--len);
(8)清空当前进程的信号位图,关闭当前进程打开的文件,然后清除执行时关闭文件句柄位图标志
for (i=0 ; i<32 ; i++)
current->sig_fn[i] = NULL;
for (i=0 ; i<NR_OPEN ; i++)
if ((current->close_on_exec>>i)&1)
sys_close(i);
current->close_on_exec = 0;
(9)释放当前进程的页目录项和页表项
free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
(9.1)释放页表结构就是分页映射的逆步骤
int free_page_tables(unsigned long from,unsigned long size)
{
unsigned long *pg_table;
unsigned long * dir, nr;
if (from & 0x3fffff)
panic("free_page_tables called with wrong alignment");
if (!from)
panic("Trying to free up swapper memory space");
size = (size + 0x3fffff) >> 22;
dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
for ( ; size-->0 ; dir++) {
if (!(1 & *dir))
continue;
pg_table = (unsigned long *) (0xfffff000 & *dir);
for (nr=0 ; nr<1024 ; nr++) {
if (1 & *pg_table)
free_page(0xfffff000 & *pg_table);
*pg_table = 0;
pg_table++;
}
free_page(0xfffff000 & *dir);
*dir = 0;
}
invalidate();
return 0;
}
根据段基地址,我们很容易找到第一个目录项,然后根据size我们很容易算出程序有多少个目录项,然后我们依次遍历程序的目录项,然后找到对应的页表,然后找到页表的页表项对应的内存页,并开始释放操作,只有释放了页表项对应的内存页,才能释放页表项,只有释放了页表的所有页表项,才能释放页表所在的内存页,才能释放对应的目录项
(10)什么协处理器的东东~反正我不懂,想懂的自己研究下~
if (last_task_used_math == current)
last_task_used_math = NULL;
current->used_math = 0;
(11)重新调整进程的局部段描述符
p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
根据执行文件重新调整进程代码段限长,并将前面拷贝的参数和环境参数的字符串空间的逻辑地址映射到数据段线性地址的末端,就是将(7.6)的配图的深色方块放到进程数据段的线性地址末端,即图中的初始偏移值p指向的线性地址为data_base += data_limit;
change_ldt返回的是进程数据段长度,所以上式等价于p += 64MB - 128kb,p在copy_strings后表示的是128k参数空间在完成参数和环境变量拷贝后剩余大小,即p指向参数和环境空间数据的起始地址(128kb范围内的起始地址),p - 128k == 参数和环境空间数据大小的负值,+64MB后,p此时是以数据段起始处为原点的偏移值,仍指向参数和环境空间数据开始处(64MB范围内的起始地址),即参数和环境空间数据的线性起始地址,这个也是进程用户栈的起始地址
(11.1)将进程的实际代码长度规整为页的整数倍
code_limit = text_size+PAGE_SIZE -1;
code_limit &= 0xFFFFF000;
(11.2)设置进程数据段限长为64MB
data_limit = 0x4000000;
(11.3)重新调整进程的代码段和数据段的局部段描述符
code_base = get_base(current->ldt[1]);
data_base = code_base;
set_base(current->ldt[1],code_base);
set_limit(current->ldt[1],code_limit);
set_base(current->ldt[2],data_base);
set_limit(current->ldt[2],data_limit);
取出进程的当前局部代码描述符段基址(还记得是多少吗?nr*0x4000000),并重新设置限长和基址,不过一般也就代码段限长会变
(11.4)将参数环境空间映射到进程数据段线性地址末端
__asm__("pushl $0x17\n\tpop %%fs"::);
data_base += data_limit;
for (i=MAX_ARG_PAGES-1 ; i>=0 ; i--) {
data_base -= PAGE_SIZE;
if (page[i])
put_page(page[i],data_base);
}
将我们前面的环境变量和参数变量字符串所在的内存页映射到我们新进程数据段的线性地址末端(上面的data_base是线性地址,page[i]里面保存的是实际物理地址)
(11.5)返回段限长64MB
return data_limit;
(12)在新的栈上创建进程的输入参数,并返回栈指针
p = (unsigned long) create_tables((char *)p,argc,envc);
最顶上的sp(参数p)上面保存的就是参数和环境空间字符串(这边未画出),这边先开辟envc+1和argc+1个指针空间(每个方块都是一个字符串指针),然后将这些指针指向最上面没画出来的参数和环境空间字符串,最后用argv和envp指向指针数组的起始地址,然后用argc保存参数个数,这样就完成了成了进程的用户栈的构建工作,和fork的写时拷贝获取一个内存页作为用户栈是不同的。
(13)修正当前进程堆结尾字段brk = a_text + a_data + a_bss
current->brk = ex.a_bss +
(current->end_data = ex.a_data +
(current->end_code = ex.a_text));
(14)设置进程的用户栈起始地址
current->start_stack = p & 0xfffff000;
(15)根据文件的i节点信息,将磁盘上的程序的代码和数据读入内存(都写入了所以不需要部分加载技术?)
i = read_area(inode,ex.a_text+ex.a_data);
iput(inode);
(16)清空一页bss段数据
i = ex.a_text+ex.a_data;
while (i&0xfff)
put_fs_byte(0,(char *) (i++));
BSS(Block Started by Symbol)通常是指用来存放程序中未初始化的全局变量和静态变量的一块内存区域。特点是:可读写的,在程序执行之前BSS段会自动清0。以后有人问你哪些变量会被自动初始化应该知道是什么了吧?
(17)设置程序入口地址和栈指针
eip[0] = ex.a_entry; /* eip, magic happens :-) */
eip[3] = p; /* stack pointer */
return 0;
注意,这边修改的是内核栈上保存的用户态寄存器的值,修改了eip和用户栈的esp,这意味着我们修改了返回的用户态环境,不再是我们do_execve时保存的那个用户态环境了,那么当当前进程从内核态返回用户态时,执行的就是我们新启动的进程的代码了,即ex.a_entry指向的指令。
以上就是创建进程的秘密,以前很多不太明白的地方是不是一下子就恍然大悟了?Linux0.11内核核心难点部分已经基本讲解完了,剩余部分大家可以根据个人兴趣自行阅读~