本文为学习操作系统源码
(低并发编程) 所作笔记,仅供学习参考,不做任何商业用途,若有侵权,请联系删除。
 
第二十五回
| 一个新进程的诞生(五) 通过fork看一次系统调用 
一个新进程的诞生(五)通过
fork 看一次系统调用 (qq.com) 
 
fork 函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static  _inline _syscall0(int ,fork)#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; \ } 
 
别急,我把它变成稍稍能看得懂的样子,就是这样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #define  _syscall0(type,name) \ type name(void) \ { \     volatile long __res; \     _asm { \         _asm mov eax,__NR_##name \         _asm int 80h \         _asm mov __res,eax \     } \     if  (__res >= 0) \         return (type) __res; \     errno = -__res; \     return -1; \ } 
 
所以,把宏定义都展开,其实就相当于定义了一个函数 。
1 2 3 4 5 6 7 8 9 10 11 12 int  fork (void )  {      volatile  long  __res;     _asm {         _asm mov eax,__NR_fork         _asm int  80 h         _asm mov __res,eax     }     if  (__res >= 0 )         return  (void ) __res;     errno = -__res;     return  -1 ; }
 
关键指令就是一个 0x80 号软中断的触发,int 80h 。
其中还有一个 eax 寄存器里的参数是
**__NR_fork,这也是个宏定义,值是  2**。
OK,还记得 0x80 号中断的处理函数么?这个是我们在 第18回
| 大名鼎鼎的进程调度就是从这里开始的  sched_init 
里面设置的。
1 set_system_gate(0x80 , &system_call);
 
看这个 system_call 的汇编代码,我们发现这么一行。
1 2 3 4 _system_call:     ...     call [_sys_call_table + eax*4 ]     ...
 
刚刚那个值就用上了,eax 寄存器里的值是 2,所以这个就是在这个
sys_call_table  表里找下标 2
位置处的函数,然后跳转过去。
那我们接着看 sys_call_table 是个啥。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,   sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,   sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,   sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,   sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,   sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,   sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,   sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,   sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,   sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,   sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,   sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,   sys_setreuid, sys_setregid };
 
看到没,就是各种函数指针组成的一个数组,说白了就是个系统调用函数表。
那下标 2 位置处是啥?从第零项开始数,第二项就是
sys_fork  函数!
至此,我们终于找到了 fork
函数,通过系统调用这个中断,最终走到内核层面的函数是什么,就是
sys_fork。
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 ,%esp1 :  ret
 
至于这个函数是什么,我们下一讲再说。
从这讲的探索我们也可以看出,操作系统通过系统调用 ,提供给用户态可用的功能,都暴露在
sys_call_table  里了。
系统调用统一通过 int 0x80 
中断来进入,具体调用这个表里的哪个功能函数,就由 eax 
寄存器传过来,这里的值是个数组索引的下标,通过这个下标就可以找到在
sys_call_table 这个数组里的具体函数。
同时也可以看出,用户进程调用内核的功能,可以直接通过写一句 int 0x80
汇编指令,并且给 eax 赋值,当然这样就比较麻烦。
所以也可以直接调用 fork 这样的包装好的方法,而这个方法里本质也是 int
0x80 以及 eax 赋值而已。
 
那我们再多说两句,刚刚定义 fork 的系统调用模板函数时,用的是
syscall0 ,其实这个表示参数个数为 0,也就是 sys_fork
函数并不需要任何参数。
所以其实,在 unistd.h 头文件里,还定义了 syscall0 ~ syscall3
一共四个宏。
1 2 3 4 #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) 
 
看都能看出来,其实 syscall1 
就表示有一个参数 ,syscall2 
就表示有两个参数 。
那这些参数放在哪里了呢?总得有个约定的地方吧?
我们看一个今后要讲的重点函数,execve ,是一个通常和
fork 在一起配合的变身函数,在之后的进程 1 创建进程 2
的过程中,就是这样玩的。
1 2 3 4 5 6 7 8 void  init(void ) {     ...      if  (!(pid=fork())) {         ...          execve("/bin/sh" ,argv_rc,envp_rc);         ...      } }
 
当然我们的重点不是研究这个函数的作用,仅仅把它当做研究 syscall3
的一个例子,因为它的宏定义就是 syscall3 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 execve("/bin/sh" ,argv_rc,envp_rc); _syscall3(int ,execve,const  char  *,file,char  **,argv,char  **,envp)#define  _syscall3(type,name,atype,a,btype,b,ctype,c) \ type name(atype a,btype b,ctype c) { \     volatile long __res; \     _asm { \         _asm mov eax,__NR_##name \         _asm mov ebx,a \         _asm mov ecx,b \         _asm mov edx,c \         _asm int 80h \         _asm mov __res,eax\     } \     if  (__res >= 0) \         return (type) __res; \     errno = -__res; \     return -1; \ } 
 
可以看出,参数 a 被放在了 ebx 寄存器,参数 b 被放在了 ecx
寄存器,参数 c 被放在了 edx 寄存器 。
第二十六回 |
一个新进程的诞生(六) fork中进程基本信息的复制 
一个新进程的诞生(六)fork
中进程基本信息的复制 (qq.com) 
 
sys_fork  函数干了啥。
还是个汇编代码,但我们要关注的地方不多。
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
 
先是
find_empty_process ,就是找到空闲的进程槽位。
然后 copy_process ,就是复制进程。
那妥了,这个方法的意思非常简单,因为存储进程的数据结构是一个 task[64]
数组,这个是在之前 第18回
| 大名鼎鼎的进程调度就是从这里开始的  sched_init 
函数的时候设置的。
 
就是先在这个数组中找一个空闲的位置,准备存一个新的进程的结构
task_struct ,这个结构之前在 一个新进程的诞生(三)如果让你来设计进程调度 
也简单说过了。
1 2 3 4 5 6 7 struct  task_struct  {     long  state;     long  counter;     long  priority;     ...     struct  tss_struct  tss ; }
 
通过 copy_process 
这个名字我们知道,就是复制原来的进程,也就是当前进程。
当前只有一个进程,就是数组中位置 0 处的
init_task.init ,也就是零号进程,那自然就复制它咯。
好了,以上只是我们的猜测,有了猜测再看代码会非常轻松,我们一个个函数看。
先来 find_empty_process 。
1 2 3 4 5 6 7 8 9 10 11 12 13 long  last_pid = 0 ;int  find_empty_process (void )  {     int  i;     repeat:         if  ((++last_pid)<0 ) last_pid=1 ;         for (i=0  ; i<64  ; i++)             if  (task[i] && task[i]->pid == last_pid) goto  repeat;     for (i=1  ; i<64 ; i++)         if  (!task[i])             return  i;     return  -EAGAIN; }
 
一共三步,很简单。
第一步 ,判断 ++last_pid
是不是小于零了,小于零说明已经超过 long 的最大值了,重新赋值为
1,起到一个保护作用,这没什么好说的。
第二步 ,一个 for 循环,看看刚刚的 last_pid 在所有
task[]
数组中,是否已经被某进程占用了。如果被占用了,那就重复执行,再次加一,然后再次判断,直到找到一个
pid 号没有被任何进程用为止。
第三步 ,又是个 for 循环,刚刚已经找到一个可用的 pid
号了,那这一步就是再次遍历这个 task[]
试图找到一个空闲项,找到了就返回素组索引下标。
最终,这个方法就返回 task[]
数组的索引,表示找到了一个空闲项 ,之后就开始往这里塞一个新的进程吧。
来看 copy_process  方法。
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 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 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 = *current;       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 ;           p->utime = p->stime = 0 ;     p->cutime = p->cstime = 0 ;     p->start_time = jiffies;     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 ;     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));     if  (copy_mem(nr,p)) {         task[nr] = NULL ;         free_page((long ) p);         return  -EAGAIN;     }     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;         return  last_pid; }
 
艾玛,这也太多了!
别急,大部分都是 tss
结构的复制,以及一些无关紧要的分支,看我简化下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 int  copy_process (int  nr, ...)  {     struct  task_struct  p  =           (struct  task_struct *) get_free_page();     task[nr] = p;     *p = *current;     p->state = TASK_UNINTERRUPTIBLE;     p->pid = last_pid;     p->counter = p->priority;     ..     p->tss.edx = edx;     p->tss.ebx = ebx;     p->tss.esp = esp;     ...     copy_mem(nr,p);     ...     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;     return  last_pid; }
 
这个函数本来就是 fork 的难点了,所以我们慢慢来。
首先 get_free_page 
会在主内存末端申请一个空闲页面,就是遍历 mem_map[]
这个数组,找出值为零的项,就表示找到了空闲的一页内存 。然后把该项置为
1,表示该页已经被使用。最后,算出这个页的内存起始地址,返回。
 
然后,拿到的这个内存起始地址,就给了 task_struct 结构的 p。
1 2 3 4 5 6 7 int  copy_process (int  nr, ...)  {     struct  task_struct  p  =           (struct  task_struct *) get_free_page();     task[nr] = p;     *p = *current;     ... }
 
于是乎,一个进程结构 task_struct
就在内存中有了一块空间,但此时还没有赋值具体的字段。别急。
首先将这个 p 记录在进程管理结构 task[] 中。
然后下一句 p =  current
很简单,就是把当前进程,也就是 0 号进程的 task_struct
的全部值都复制给即将创建的进程
p ,目前它们两者就完全一样了。
嗯,这就附上值了,就完全复制之前的进程的 task_struct
而已,很粗暴。
最后的内存布局的效果就是这样。
 
然后,进程 1 和进程 0
目前是完全复制的关系,但有一些值是需要个性化处理的,下面的代码就是把这些不一样的值覆盖掉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 int  copy_process (int  nr, ...)  {     ...     p->state = TASK_UNINTERRUPTIBLE;     p->pid = last_pid;     p->counter = p->priority;     ..     p->tss.edx = edx;     p->tss.ebx = ebx;     p->tss.esp = esp;     ...     p->tss.esp0 = PAGE_SIZE + (long ) p;     p->tss.ss0 = 0x10 ;     ... }
 
不一样的值,一部分是
state ,pid ,counter 
这种进程的元信息 ,另一部分是 tss 
里面保存的各种寄存器的信息,即上下文 。
第二十七回
| 一个新进程的诞生(七) 透过fork来看进程的内存规划 
一个新进程的诞生(七)透过
fork 来看进程的内存规划 (qq.com) 
 
copy_mem() function
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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 );     new_code_base = nr * 0x4000000 ;     new_data_base = nr * 0x4000000 ;     set_base(p->ldt[1 ],new_code_base);     set_base(p->ldt[2 ],new_data_base);          old_code_base = get_base(current->ldt[1 ]);     old_data_base = get_base(current->ldt[2 ]);     copy_page_tables(old_data_base,new_data_base,data_limit);     return  0 ; }
 
新进程 LDT 表项的赋值,以及页表的拷贝 。
LDT的赋值 
那我们先看 LDT 表项的赋值,要说明白这个赋值的意义,得先回忆一下我们在
第九回
| Intel 内存管理两板斧:分段与分页  刚设置完页表时说过的问题。
程序员给出的逻辑地址最终转化为物理地址要经过这几步骤。
 
而我们已经开启了分页,那么分页机制的具体转化是这样的。
 
不考虑段限长的话,32 位的 CPU 线性地址空间应为
4G。现在只有四个页目录表,也就是将前 16M 的线性地址空间,与 16M
的物理地址空间一一对应起来了。
 
把这个图和全局描述符表 GDT
联系起来,这个线性地址空间,就是经过分段机制(段可能是 GDT 也可能是
LDT)后的地址,是这样对应的。
 
我们给进程 0 准备的 LDT 的代码段和数据段,段基址都是 0,段限长是
640K。给进程 1,也就是我们现在正在 fork
的这个进程,其代码段和数据段还没有设置。
所以第一步,局部描述符表 LDT
的赋值 ,就是给上图中那两个还未设置的代码段和数据段赋值。
其中段限长 ,就是取自进程 0 设置好的段限长,也就是
640K。
1 2 3 4 5 6 int  copy_mem (int  nr,struct  task_struct * p)  {     ...     code_limit = get_limit(0x0f );     data_limit = get_limit(0x17 );     ... }
 
而段基址 有点意思,是取决于当前是几号进程,也就是 nr
的值。
1 2 3 4 5 6 int  copy_mem (int  nr,struct  task_struct * p)  {     ...     new_code_base = nr * 0x4000000 ;     new_data_base = nr * 0x4000000 ;     ... }
 
这里的 0x4000000 等于 64M。
也就是说,今后每个进程通过段基址的手段,分别在线性地址空间中占用 64M
的空间(暂不考虑段限长),且紧挨着。
接着就把 LDT 设置进了 LDT 表里。
1 2 3 4 5 6 int  copy_mem (int  nr,struct  task_struct * p)  {     ...     set_base(p->ldt[1 ],new_code_base);     set_base(p->ldt[2 ],new_data_base);     ... }
 
最终效果如图。
 
页表的复制 
1 2 3 4 5 int  copy_mem (int  nr,struct  task_struct * p)  {     ...          copy_page_tables(old_data_base,new_data_base,data_limit) }
 
原来进程 0
有一个页目录表 和四个页表 ,将线性地址空间的
0-16M 原封不动映射到了物理地址空间的 0-16M。
 
那么新诞生的这个进程
2,也需要一套映射关系的页表,那我们看看这些页表是怎么建立的。
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 38 39 40 int  copy_page_tables (unsigned  long  from,unsigned  long  to,long  size)  {     unsigned  long  * from_page_table;     unsigned  long  * to_page_table;     unsigned  long  this_page;     unsigned  long  * from_dir, * to_dir;     unsigned  long  nr;     from_dir = (unsigned  long  *) ((from>>20 ) & 0xffc );     to_dir = (unsigned  long  *) ((to>>20 ) & 0xffc );     size = ((unsigned ) (size+0x3fffff )) >> 22 ;     for ( ; size-->0  ; from_dir++,to_dir++) {         if  (!(1  & *from_dir))             continue ;         from_page_table = (unsigned  long  *) (0xfffff000  & *from_dir);         to_page_table = (unsigned  long  *) get_free_page()         *to_dir = ((unsigned  long ) to_page_table) | 7 ;         nr = (from==0 )?0xA0 :1024 ;         for  ( ; nr-- > 0  ; from_page_table++,to_page_table++) {             this_page = *from_page_table;             if  (!(1  & this_page))                 continue ;             this_page &= ~2 ;             *to_page_table = this_page;             if  (this_page > LOW_MEM) {                 *from_page_table = this_page;                 this_page -= LOW_MEM;                 this_page >>= 12 ;                 mem_map[this_page]++;             }         }     }     invalidate();     return  0 ; }
 
现在进程 0 的线性地址空间是 0 - 64M,进程 1 的线性地址空间是 64M -
128M。我们现在要造一个进程 1 的页表,使得进程 1 和进程 0
最终被映射到的物理空间都是 0 - 64M ,这样进程 1
才能顺利运行起来,不然就乱套了。
 
假设现在正在运行进程 0,代码中给出一个虚拟地址 0x03,由于进程 0 的
LDT 中代码段基址是 0,所以线性地址也是 0x03,最终由进程 0
页表映射到物理地址 0x03 处。
假设现在正在运行进程 1,代码中给出一个虚拟地址 0x03,由于进程 1 的
LDT 中代码段基址是 64M,所以线性地址是 64M + 3,最终由进程 1
页表映射到物理地址也同样是 0x03 处。
 
即,进程 0 和进程 1 目前共同映射物理内存的前 640K
的空间。 
至于如何将不同地址通过不同页表映射到相同物理地址空间,很简单,举个刚刚的例子。
刚刚的进程 1 的线性地址 64M + 0x03 用二进制表示是:
0000010000_0000000000_000000000011
刚刚的进程 0 的线性地址 0x03 用二进制表示是:
0000000000_0000000000_000000000011
根据分页机制的转化规则,前 10 位表示页目录项,中间 10
位表示页表项,后 12 位表页内偏移。 
进程 1 要找的是页目录项 16 中的第 0 号页表
进程 0 要找的是页目录项 0 中的第 0 号页表
那只要让这俩最终找到的两个页表里的数据一模一样即可。
我居然会认为权威书籍写错了... 
由于理解起来非常简单,但代码中的计算就非常绕,所以我们就不细致分析代码了,只要理解其最终的作用就好。
OK,本章的内容就讲完了,再稍稍展开一个未来要说的东西。还记得页表的结构吧?
 
其中 RW 位表示读写状态,0
表示只读(或可执行),1表示可读写(或可执行)。当然,在内核态也就是 0
特权级时,这个标志位是没用的。
那我们看下面的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int  copy_page_tables (unsigned  long  from,unsigned  long  to,long  size)  {     ...     for ( ; size-->0  ; from_dir++,to_dir++) {         ...         for  ( ; nr-- > 0  ; from_page_table++,to_page_table++) {             ...             this_page &= ~2 ;             ...             if  (this_page > LOW_MEM) {                 *from_page_table = this_page;                 ...             }         }     }     ... }
 
~2 表示取反,2 用二进制表示是 10,取反就是 01,其目的是把 this_page
也就是当前的页表的 RW
位置零,也就是是把该页变成只读 。
而 *from_page_table = this_page
表示又把源页表也变成只读 。
也就是说,经过 fork
创建出的新进程,其页表项都是只读的,而且导致源进程的页表项也变成了只读。
这个就是写时复制 的基础,新老进程一开始共享同一个物理内存空间,如果只有读,那就相安无事,但如果任何一方有写操作,由于页面是只读的,将触发缺页中断,然后就会分配一块新的物理内存给产生写操作的那个进程,此时这一块内存就不再共享了。
第三部分一个新进程的诞生完结 
一个新进程的诞生
完结撒花!!! (qq.com) 
 
写时复制 
写时复制就这么几行代码,麻烦你先看看再
BB 行吗? (qq.com)