《Linux内核设计的艺术:图解Linux操作系统架构设计与实现原理》——第3章 进程1的创建及执行 3.1 进程1的创建

本文涉及的产品
公网NAT网关,每月750个小时 15CU
简介: 本节书摘来自华章计算机《Linux内核设计的艺术:图解Linux操作系统架构设计与实现原理》一书中的第3章,第3.1节,作者:新设计团队著, 更多章节内容可以访问云栖社区“华章计算机”公众号查看。

第3章 进程1的创建及执行

现在,计算机中已经有了一个名副其实的、3特权级的进程——进程0。下面我们要详细讲解进程0做的第一项工作——创建进程1。

3.1 进程1的创建

进程0现在处在3特权级状态,即进程状态。正式开始运行要做的第一件事就是作为父进程调用fork函数创建第一个子进程——进程1,这是父子进程创建机制的第一次实际运用。以后,所有进程都是基于父子进程创建机制由父进程创建出来的。
3.1.1 进程0创建进程1
在Linux操作系统中创建新进程的时候,都是由父进程调用fork函数来实现的。该过程如图3-1所示。
执行代码如下:

//代码路径:init/main.c:
    …
static inline _syscall0(int,fork)       // 对应fork()函数
static inline _syscall0(int,pause)
static inline _syscall1(int,setup,void *,BIOS)
    …
void main(void)
{
    sti();
    move_to_user_mode();
    if (!fork()) {        /* we count on this going ok */
         init();
    }
/*
 *   NOTE!!   For any other task 'pause()' would mean we have to get a
 * signal to awaken, but task0 is the sole exception (see 'schedule()')
 * as task 0 gets activated at every idle moment (when no other tasks
 * can run). For task0 'pause()' just means we go check if some other
 * task can run, and if not we return here.
 */
    for(;;) pause();
}

image

从上面main.c的代码中对fork()的声明,可知调用fork函数;实际上是执行到unistd.h中的宏函数syscall0中去,对应代码如下:

//代码路径:include/unistd.h:
    …
#define __NR_setup    0    /* used only by init, to get system going */
#define __NR_exit    1
#define __NR_fork    2
#define __NR_read    3
#define __NR_write    4
#define __NR_open    5
#define __NR_close    6
    …
#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; \
}
    …
volatile void _exit(int status);
int fcntl(int fildes, int cmd, ...);
int fork(void);
int getpid(void);
int getuid(void);
int geteuid(void);
    …

//代码路径:include/linux/sys.h:
extern int sys_setup();
extern int sys_exit();
extern int sys_fork();     //对应system_call.s中的_sys_fork,汇编中对应C语言的函
                //数名在前面多加一个下划线"_" ,如C语言的sys_fork对应汇编的
                //就是_sys_fork
extern int sys_read();         
extern int sys_write();
extern int sys_open();
    …

fn_ptr sys_call_table[]={sys_setup, sys_exit, sys_fork, sys_read,//sys_fork对应_sys_call_table的第三项
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
…
syscall0展开后,看上去像下面的样子:
int fork(void)             //参看2.5节、2.9节、2.14节有关嵌入汇编的代码注释
{ 
long __res; 
__asm__ volatile ("int $0x80"     // int 0x80是所有系统调用函数的总入口,fork()是其中// 之一,参看2.9节的讲解及代码注释
    : "=a" (__res)         //第一个冒号后是输出部分,将_res赋给eax
    : "0" (__NR_ fork));     //第二个冒号后是输入部分,"0":同上寄存器,即eax,
    // __NR_ fork就是2,将2给eax
    if (__res >= 0)         // int 0x80中断返回后,将执行这一句
    return (int) __res; 
    errno= -__res; 
    return -1; 
}
//重要:别忘了int 0x80导致CPU硬件自动将ss、esp、eflags、cs、eip的值压栈!参看2.14节的讲//解及代码解释

int 0x80的执行路线很长,为了清楚起见,将大致过程图示如下(见图3-2)。

image

详细的执行步骤如下:
先执行: "0" (__NR_ fork)这一行,意思是将fork 在sys_call_table[]中对应的函数编号__NR_ fork(也就是2)赋值给eax。这个编号即sys_fork()函数在sys_call_table中的偏移值。
紧接着就执行"int $0x80" ,产生一个软中断,CUP从3特权级的进程0代码跳到0特权级内核代码中执行。中断使CPU硬件自动将SS、ESP、EFLAGS、CS、EIP这5个寄存器的数值按照这个顺序压入图3-1所示的init_task中的进程0内核栈。注意其中init_task结构后面的红条,表示了刚刚压入内核栈的寄存器数值。前面刚刚提到的move_to_user_mode这个函数中做的压栈动作就是模仿中断的硬件压栈,这些压栈的数据将在后续的copy_process()函数中用来初始化进程1的TSS。
值得注意,压栈的EIP指向当前指令"int $0x80"的下一行,即if (__res >= 0) 这一行。这一行就是进程0从 fork函数系统调用中断返回后第一条指令的位置。在后续的3.3节将看到,这一行也将是进程1开始执行的第一条指令位置。请记住这一点!
根据2.9节讲解的sched_init函数中set_system_gate(0x80,&system_call)的设置,CPU自动压栈完成后,跳转到system_call.s中的_system_call处执行,继续将DS、ES、FS、EDX、ECX、EBX 压栈(以上一系列的压栈操作都是为了后面调用copy_process函数中初始化进程1中的TSS做准备)。最终,内核通过刚刚设置的eax的偏移值“2”查询 sys_call_table[],得知本次系统调用对应的函数是sys_fork()。因为汇编中对应C语言的函数名在前面多加一个下划线“_”(如C语言的sys_fork()对应汇编的就是_sys_fork),所以跳转到 _sys_fork处执行。
点评
一个函数的参数不是由函数定义的,而是由函数定义以外的程序通过压栈的方式“做”出来的,是操作系统底层代码与应用程序代码写作手法的差异之一;需要对C语言的编译、运行时结构非常清晰,才能彻底理解。运行时,C语言的参数存在于栈中。模仿这个原理,操作系统的设计者可以将前面程序所压栈的值,按序“强行”认定为函数的参数;当call这个函数时,这些值就可以当做参数使用。
上述过程的执行代码如下:

//代码路径:kernel/system_call.s:
    …
_system_call:            # int 0x80——系统调用的总入口
    cmpl $nr_system_calls-1,%eax
    ja bad_sys_call
    push %ds    #下面6个push都是为了copy_process()的参数,请记住
    #压栈的顺序,别忘了前面的int 0x80还压了5个寄存器的值进栈
    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)    # eax是2,可以看成call (_sys_call_table + 2×4)就是
                    # _sys_fork的入口
    pushl %eax
    movl _current,%eax
    cmpl $0,state(%eax)        # state
    jne reschedule
    cmpl $0,counter(%eax)        # counter
    je reschedule
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 
    …
 _sys_fork:            #sys_fork函数的入口
     …

call _sys_call_table(,%eax,4)中的eax是2,这一行可以看成call _sys_call_table + 2×4(4的意思是_sys_call_table[]的每一项有4字节),相当于call _sys_call_table[2](见图3-1的左中部分),就是执行sys_fork。
注意:call _sys_call_table(,%eax,4)指令本身也会压栈保护现场,这个压栈体现在后面copy_process函数的第6个参数long none。
对应代码如下:

//代码路径:kernel/system_call.s:
    …
_system_call:
    …
_sys_fork:
    call _find_empty_process    #调用find_empty_process()
    testl %eax,%eax        #如果返回的是-EAGAIN(11),说明已有64个进程在运行
    js 1f
    push %gs            #5个push也作为copy_process()的参数初始
    pushl %esi
    pushl %edi
    pushl %ebp
    pushl %eax
    call _copy_process        #调用copy_process()
    addl $20,%esp
1:    ret
    …

3.1.2 在task[64]中为进程1 申请一个空闲位置并获取进程号
开始执行sys_fork()。
前面2.9节介绍过,在sched_init()函数中已经对task[64]除0项以外的所有项清空。现在调用find_empty_process()函数为进程1获得一个可用的进程号和task[64]中的一个位置。图3-3标示了这个调用的效果。

image

在find_empty_process()函数中,内核用全局变量last_pid来存放系统自开机以来累计的进程数,也将此变量用作新建进程的进程号。内核第一次遍历task[64],“&&”条件成立说明last_pid已被使用,则++last_pid,直到获得用于新进程的进程号。第二次遍历task[64],获得第一个空闲的i,俗称任务号。
现在,两次遍历的结果是新的进程号last_pid就是1,在task[64]中占据第二项。图3-3标示了这个结果。
因为Linux 0.11的task[64]只有64项,最多只能同时运行64个进程,如果find_empty_process()函数返回-EAGAIN,意味着当前已经有64个进程在运行,当然这种情况现在还不会发生。执行代码如下:

//代码路径:kernel/fork.c:
    …
long last_pid=0;
    …
 int find_empty_process(void)        //为新创建的进程找到一个空闲的位置,NR_TASKS是64
{
    int i;

repeat:
    if ((++last_pid)<0) last_pid=1;    //如果++后last_pid溢出,则置1
    for(i=0;i<NR_TASKS;i++)        //现在,+ + 后last_pid为1。找到有效的last_pid
         if (task[i] && task[i]->pid== last_pid) goto repeat;
    for(i=1;i<NR_TASKS;i++)        //返回第一个空闲的i
         if (!task[i])
               return i;
    return -EAGAIN;            // EAGAIN是11
}

进程1的进程号及在task [64] 中的位置确定后,正在创建的进程1就等于有了身份。接下来,在进程0的内核栈中继续压栈,将5个寄存器值进栈,为调用copy_process()函数准备参数,这些数据也是用来初始化进程1的TSS。注意:最后压栈的eax的值就是find_empty_process()函数返回的任务号,也将是copy_process()函数的第一个参数int nr。
压栈结束后,开始调用copy_process()函数,如图3-4中第二步所示。

image

3.1.3 调用copy_process函数
进程0已经成为一个可以创建子进程的父进程,在内核中有“进程0的task_struct”和“进程0的页表项”等专属进程0的管理信息。进程0将在copy_process()函数中做非常重要的、体现父子进程创建机制的工作:
1)为进程1创建task_struct,将进程0的task_struct的内容复制给进程1。
2)为进程1的task_struct、tss做个性化设置。
3)为进程1创建第一个页表,将进程0的页表项内容赋给这个页表。
4)进程1共享进程0的文件。
5)设置进程1的GDT项。
6)最后将进程1设置为就绪态,使其可以参与进程间的轮转。
现在调用copy_process()函数!
在讲解copy_process()函数之前,值得提醒的是,所有的参数都是前面的代码累积压栈形成的,这些参数的数值都与压栈时的状态有关。执行代码如下:

//代码路径:kernel/fork.c:
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)
//注意:这些参数是int 0x80、system_call、sys_fork多次累积压栈的结果,顺序是完全一致的
{
       struct task_struct *p;
       int i;
       struct file *f;

       //在16 MB内存的最高端获取一页,强制类型转换的潜台词是将这个页当task_union用,参看2.9节
       p= (struct task_struct *) get_free_page();
       if (!p)
         return -EAGAIN;
       task[nr]= p;    //此时的nr就是1,潜台词是将这个页当task_union用,参看2.9节
       …
}

进入copy_process()函数后,调用get_free_page()函数,在主内存申请一个空闲页面,并将申请到的页面清零,用于进程1的task_struct及内核栈。
按照get_free_page()函数的算法,是从主内存的末端开始向低地址端递进,现在是开机以来,操作系统内核第一次为进程在主内存申请空闲页面,申请到的空闲页面肯定在16 MB主内存的最末端。
执行代码如下:

//代码路径:mm/memory.c:
unsigned long get_free_page(void)    //遍历mem map[],找到主内存中(从高地址开始)第一个空闲页面
{                    //参看前面的嵌入汇编的代码注释
register unsigned long __res asm("ax");

__asm__("std;repne;scasb\n\t"    //反向扫描串(mem map[]),al(0)与di不等则
    //重复(找引用对数为0的项)
         "jne 1f\n\t"            //找不到空闲页,跳转到1
         "movb $1,1(%%edi)\n\t"    //将1赋给edi + 1的位置,在mem map[]中,
    //将找到0的项的引用计数置为1
         "sall $12,%%ecx\n\t"        // ecx算数左移12位,页的相对地址
         "addl %2,%%ecx\n\t"        // LOW MEN + ecx,页的物理地址
         "movl %%ecx,%%edx\n\t"
         "movl $1024,%%ecx\n\t"
         "leal 4092(%%edx),%%edi\n\t"    //将edx + 4 KB的有效地址赋给edi
         "rep;stosl\n\t"            //将eax(即"0"(0))赋给edi指向的地址,目的是页面清零
         "movl %%edx,%%eax\n"
         "1:"
         :"=a" (__res)
         :"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
         "D" (mem_map + PAGING_PAGES-1)    //edx,mem map[]的最后一个元素
         :"di","cx","dx");        //第三个冒号后是程序中改变过的量
return __res;
}

回到copy_process函数,将这个页面的指针强制类型转换为指向task_struct的指针类型,并挂接在task[1]上,即task[nr] = p。nr就是第一个参数,是find_empty_process函数返回的任务号。
请注意,C语言中的指针有地址的含义,更有类型的含义!强制类型转换的意思是“认定”这个页面的低地址端就是进程1的task_struct的首地址,同时暗示了高地址部分是内核栈。了解了这一点,后面的p->tss.esp0 = PAGE_SIZE + (long) p就不奇怪了。
点评
task_struct是操作系统标识、管理进程的最重要的数据结构,每一个进程必须具备只属于自己的、唯一的task_struct。

//代码路径:kernel/fork.c:
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)
{
    …
    if (!p)
         return -EAGAIN;
    task[nr]= p;    //此时的nr就是1

/* current指向当前进程的task_struct的指针,当前进程是进程0。下面这行的意思:将父进程的task_struct赋给子进程。这是父子进程创建机制的重要体现。这行代码执行后,父子进程的task_struct将完全一样*/
    *p= *current;    /* NOTE! this doesn't copy the supervisor stack */
               /*重要!!注意指针类型,只复制task_struct,并未将4 KB都复制,即进程0的内核栈并未复制*/
    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;
    p->tss.back_link= 0;        //开始设置子进程的TSS
    …
}

效果如图3-5(为了方便阅读,我们把2.9节的图2-20复制在下面)所示。

image

点评
task_union的设计颇具匠心。前面是task_struct,后面是内核栈,增长的方向正好相反,正好占用一页,顺应分页机制,分配内存非常方便。而且操作系统设计者肯定经过反复测试,保证内核代码所有可能的调用导致压栈的最大长度都不会覆盖前面的task_struct。因为内核代码都是操作系统设计者设计的,可以做到心中有数。相反,假如这个方法为用户进程提供栈空间,恐怕要出大问题了。
接下来的代码意义重大:

*p= *current;    /* NOTE! this doesn't copy the supervisor stack */

current是指向当前进程的指针;p是进程1的指针。当前进程是进程0,是进程1的父进程。将父进程的task_struct复制给子进程,就是将父进程最重要的进程属性复制给了子进程,子进程继承了父进程的绝大部分能力。这是父子进程创建机制的特点之一。
进程1的task_struct的雏形此时已经形成了,进程0的task_struct中的信息并不一定全都适用于进程1,因此还需要针对具体情况进行调整。初步设置进程1的task_struct如图3-6所示。从p->开始的代码,都是为进程1所做的个性化调整设置,其中调整TSS所用到的数据都是前面程序累积压栈形成的参数。
执行代码如下:

//代码路径:kernel/fork.c:
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)
{
    …
    p->start_time= jiffies;
    p->tss.back_link= 0;    //开始设置子进程的TSS
    p->tss.esp0= PAGE_SIZE + (long) p;    //esp0是内核栈指针,参看上面的注释及2.9.1节
    p->tss.ss0= 0x10;    //0x10就是10000,0特权级,GDT,数据段
    p->tss.eip= eip;    //重要!就是参数的EIP,是int 0x80压栈的,指向的是:if(__res >= 0)
    p->tss.eflags= eflags;
    p->tss.eax= 0;        //重要!决定main()函数中if (!fork())后面的分支走向
    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);        //挂接子进程的LDT
    p->tss.trace_bitmap= 0x80000000;
    if (last_task_used_math== current)
         __asm__(„clts;fnsave %0"::"m" (p->tss.i387));
    …
}

点评

p->tss.eip= eip;
p->tss.eax= 0;

这两行代码为第二次执行fork()中的if (__res >= 0) 埋下伏笔。这个伏笔比较隐讳,不太容易看出来,请读者一定要记住这件事!

image

调整完成后,进程1的task_struct如图3-7所示。

image

image

3.1.4 设置进程1的分页管理
Intel 80x86体系结构分页机制是基于保护模式的,先打开pe,才能打开pg,不存在没有pe的pg。保护模式是基于段的,换句话说,设置进程1的分页管理,就要先设置进程1的分段。
一般来讲,每个进程都要加载属于自己的代码、数据。这些代码、数据的寻址都是用段加偏移的形式,也就是逻辑地址形式表示的。CPU硬件自动将逻辑地址计算为CPU可寻址的线性地址,再根据操作系统对页目录表、页表的设置,自动将线性地址转换为分页的物理地址。操作系统正是沿着这个技术路线,先在进程1的64 MB线性地址空间中设置代码段、数据段,然后设置页表、页目录。
1.在进程1的线性地址空间中设置代码段、数据段
调用copy_mem()函数,先设置进程1的代码段、数据段的段基址、段限长,提取当前进程(进程0)的代码段、数据段以及段限长的信息,并设置进程1的代码段和数据段的基地址。这个基地址就是它的进程号nr*64 MB。设置新进程LDT中段描述符中的基地址,如图3-8中的第一步所示。
执行代码如下:

//代码路径:kernel/fork.c:
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)
{
    …
    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++)    //下面将父进程相关文件属性的引用计数加1,表明父子进程共享文件
    …
}

image

//代码路径:include/linux/sched.h:
    …
#define _set_base(addr,base) \    //用base设置addr,参看2.9节段描述符图及代码注释
__asm__("movw %%dx,%0\n\t" \    
         "rorl $16,%%edx\n\t" \
         "movb %%dl,%1\n\t" \
         "movb %%dh,%2" \
       ::"m" (*((addr) + 2)), \
          "m" (*((addr) + 4)), \
          "m" (*((addr) + 7)), \
          "d" (base) \
       :"dx")
    …
#define set_base(ldt,base) _set_base( ((char *)&(ldt)) , base )
    …
#define _get_base(addr) ({\    //获取addr段基址,参看_set_base ,参看2.9节段描述                        //符图及代码注释
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)) )
#define get_limit(segment) ({ \
unsigned long __limit; \
__asm__("lsll %1,%0\n\tincl %0":"=r" (__limit):"r" (segment)); \
                    //取segment的段限长,给__limit
__limit;})

//代码路径:kernel/fork.c:
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);     //0x0f即1111:代码段、LDT、3特权级
    data_limit=get_limit(0x17);    //0x17即10111:数据段、LDT、3特权级

    //获取父进程(现在是进程0)的代码段、数据段基址
    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;//现在nr是1,0x4000000是64 MB
    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)) {
         free_page_tables(new_data_base,data_limit);
         return -ENOMEM;
    }
    return 0;
}

2.为进程1创建第一个页表并设置对应的页目录项
在Linux 0.11中,每个进程所属的程序代码执行时,都要根据其线性地址来进行寻址,并最终映射到物理内存上。通过图3-9我们可以看出,线性地址有32位,CPU将这个线性地址解析成“页目录项”、“页表项”和“页内偏移”;页目录项存在于页目录表中,用以管理页表;页表项存在于页表中,用以管理页面,最终在物理内存上找到指定的地址。Linux 0.11中仅有一个页目录表,通过线性地址中提供的“页目录项”数据就可以找到页目录表中对应的页目录项;通过这个页目录项就可以找到对应的页表;之后,通过线性地址中提供的“页表项”数据,就可以在该页表中找到对应的页表项;通过此页表项可以进一步找到对应的物理页面;最后,通过线性地址中提供的“页内偏移”落实到实际的物理地址值。

image

调用copy_page_tables()函数,设置页目录表和复制页表,如图3-8中第二步和第三步所示,注意其中页目录项的位置。
执行代码如下:

//代码路径:kernel/fork.c:
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创建第一个页表、复制进程0的页表,设置进程1的页目录项
    if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
         free_page_tables(new_data_base,data_limit);
         return -ENOMEM;
    }
    return 0;
}

进入copy_page_tables()函数后,先为新的页表申请一个空闲页面,并把进程0中第一个页表里面前160个页表项复制到这个页面中(1个页表项控制一个页面4 KB内存空间,160个页表项可以控制640 KB内存空间)。进程0和进程1的页表暂时都指向了相同的页面,意味着进程1也可以操作进程0的页面。之后对进程1的页目录表进行设置。最后,用重置CR3的方法刷新页变换高速缓存。进程1的页表和页目录表设置完毕。
执行代码如下(为了更容易读懂,我们在源代码中做了比较详细的注释):

//代码路径:mm/memory.c:
…
#define invalidate()\
__asm__("movl %%eax,%%cr3"::"a" (0))    //重置CR3为0
…

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;

/* 0x3fffff是4 MB,是一个页表的管辖范围,二进制是22个1,||的两边必须同为0,所以,from和to后22位必须都为0,即4 MB的整数倍,意思是一个页表对应4 MB连续的线性地址空间必须是从0x000000开始的4 MB的整数倍的线性地址,不能是任意地址开始的4 MB,才符合分页的要求*/
    if ((from&0x3fffff) || (to&0x3fffff))
         panic("copy_page_tables called with wrong alignment");

/*一个页目录项的管理范围是4 MB,一项是4字节,项的地址就是项数×4,也就是项管理的线性地址起始地址的M数,比如:0项的地址是0,管理范围是0~4 MB,1项的地址是4,管理范围是4~8 MB, 2项的地址是8,管理范围是8~12MB……>>20就是地址的MB数,&0xffc就是&111111111100b,就是4 MB以下部分清零的地址的MB数,也就是页目录项的地址*/
    from_dir= (unsigned long *) ((from>>20) & 0xffc);     /* _pg_dir= 0 */
    to_dir= (unsigned long *) ((to>>20) & 0xffc); 
    size= ((unsigned) (size + 0x3fffff)) >> 22;            // >> 22是4 MB数
    for(;size-->0;from_dir++,to_dir++) {
         if (1 & *to_dir)
               panic("copy_page_tables: already exist");
         if (!(1 & *from_dir))
               continue;

//*from_dir是页目录项中的地址,0xfffff000&是将低12位清零,高20位是页表的地址
         from_page_table= (unsigned long *) (0xfffff000 & *from_dir);
         if (!(to_page_table= (unsigned long *) get_free_page()))
               return -1;    /* Out of memory, see freeing */
         *to_dir= ((unsigned long) to_page_table)|7;//7即111,参看1.3.5节的注释
         nr= (from==0)?0xA0:1024;               //0xA0 即160,复制页表的项数, 
         for (;nr-- > 0;from_page_table++,to_page_table++){    //复制父进程页表
               this_page= *from_page_table;
               if (!(1 & this_page))
                     continue;
               this_page &= ~2;//设置页表项属性,2是010,~2是101,代表用户、只读、存在
               *to_page_table= this_page;
               if (this_page > LOW_MEM) {    //1 MB以内的内核区不参与用户分页管理
                     *from_page_table= this_page;
                     this_page -= LOW_MEM;
                     this_page >>= 12;
                     mem_map[this_page]++;    //增加引用计数,参看mem_init
               }
         }
    }
    invalidate();        //用重置CR3为0,刷新"页变换高速缓存"
    return 0;
}

进程1此时是一个空架子,还没有对应的程序,它的页表又是从进程0的页表复制过来的,它们管理的页面完全一致,也就是它暂时和进程0共享一套内存页面管理结构,如图3-10所示。等将来它有了自己的程序,再把关系解除,并重新组织自己的内存管理结构。

image

3.1.5 进程1共享进程0的文件
返回copy_process()函数中继续调整。设置task_struct中与文件相关的成员,包括打开了哪些文件 p->filp[20]、进程0的“当前工作目录i 节点结构”、“根目录i 节点结构”以及“执行文件i 节点结构”。虽然进程0中这些数值还都是空的,进程0只具备在主机中正常运算的能力,尚不具备与外设以文件形式进行交互的能力,但这种共享仍有意义,因为父子进程创建机制会把这种能力“遗传”给子进程。
对应的代码如下:

//代码路径:kernel/fork.c:
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)
{
    …
         return -EAGAIN;
    }
    for (i=0; i<NR_OPEN;i++)//下面将父进程相关文件属性的引用计数加1,表明父子进程共享文件
         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));    //设置GDT中与子进
                                //程相关的项,参看sched.c
    …
}

3.1.6 设置进程1在GDT中的表项
之后把进程1的TSS和LDT,挂接在GDT中,如图3-11所示,注意进程1在GDT中所占的位置。

image

执行代码如下:

//代码路径:kernel/fork.c:
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)
{
    …        
         current->executable->i_count++;
    set_tss_desc(gdt + (nr<<1) + FIRST_TSS_ENTRY,&(p->tss));    //设置GDT中与子进
                                //程相关的项,参看sched.c
    set_ldt_desc(gdt + (nr<<1) + FIRST_LDT_ENTRY,&(p->ldt));    
p->state= TASK_RUNNING;    /* do this last, just in case */    //设置子进程为就绪态
…
}

3.1.7 进程1处于就绪态
将进程1的状态设置为就绪态,使它可以参加进程调度,最后返回进程号1。请注意图3-11中间代表进程的进程条,其中,进程1已处在就绪态。执行代码如下:

//代码路径:kernel/fork.c:
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)
{
    …
    p->state= TASK_RUNNING;/* do this last, just in case */    //设置子进程为就绪态
    return last_pid;
}

至此,进程1的创建工作完成,进程1已经具备了进程0的全部能力,可以在主机中正常地运行。
进程1创建完毕后,copy_process()函数执行完毕,返回sys_fork()中 call _copy_process()的下一行执行,执行代码如下:

//代码路径:kernel/system_call.s:
    …
_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    //copy_process返回至此,esp+=20就是esp清20字节的栈,也就是清前面压的gs、esi、
1:    ret        //edi、ebp、eax,注意:内核栈里还有数据。返回_system_call中的pushl %eax执行
    …
清_sys_fork压栈的5个寄存器的值,就是清前面压的gs、esi、edi、ebp、eax,也就是copy_process( )的前5个参数。注意:eax对应的是copy_process( )的第一个参数nr,就是copy_process( )的返回值last_pid,即进程1的进程号。然后返回_system_call中的call _sys_call_table(,%eax,4)的下一行pushl %eax处继续执行。
先检查当前进程是否是进程0。注意:pushl %eax这行代码,将3.1.6节中返回的进程1的进程号压栈,之后到_ret_from_sys_call:处执行。
执行代码如下:
//代码路径:kernel/system_call.s:
    …
_system_call:
    …
    call _sys_call_table(,%eax,4)
    pushl %eax        #sys_fork返回到此执行,eax是copy_process()的返回值last_pid
    movl _current,%eax    #当前进程是进程0
    cmpl $0,state(%eax)    # state 
    jne reschedule        #如果进程0不是就绪态,则进程调度
    cmpl $0,counter(%eax)    # counter
    je reschedule        #如果进程0没有时间片,则进程调度
ret_from_sys_call:
    movl _current,%eax    # task[0] cannot have signals
    cmpl _task,%eax
    je 3f            #如果当前进程是进程0,跳到下面的3:处执行。当前进程是进程0!
    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        #如果是进程0,则直接跳到这个地方执行,将7个寄存器的值出栈给CPU
    popl %ebx
    popl %ecx
    popl %edx
    pop %fs
    pop %es
    pop %ds
    iret            #CPU硬件将int 0x80的中断时压的ss、esp、eflags、cs、eip的值                    #出栈给CPU对应寄存器,
…                # CS:EIP指向fork()中int 0x80的下一行if(__res >=0)处执行

由于当前进程是进程0,所以就跳转到标号3处,将压栈的各个寄存器数值还原。图3-12表示了init_task中清栈的这一过程。值得注意的是popl %eax这一行代码,这是将前面刚刚讲解过的pushl %eax压栈的进程1的进程号,恢复给CPU的eax,eax的值为“1”。
之后,iret中断返回,CPU硬件自动将int 0x80的中断时压的ss、esp、eflags、cs、eip的值按压栈的反序出栈给CPU对应寄存器,从0特权级的内核代码转换到3特权级的进程0代码执行,CS:EIP指向fork( )中int 0x80的下一行 if (__res > =0)。
对应的执行代码如下:

//代码路径:include/unistd.h:
int fork(void) 
{ 
    long __res; 
__asm__ volatile ("int $0x80"     
    : "=a" (__res)     //__res的值就是eax,是copy_process()的返回值last_pid (1)
    : "0" (__NR_ fork));         
    if(__res >= 0)        //iret后,执行这一行!__res就是eax,值是1
    return (int) __res;     //返回1!
    errno= -__res; 
    return -1; 
}

在讲述执行if (__res >= 0)前,先关注一下: " =a" (__res)。这行代码的意思是将__res的值赋给eax,所以if (__res >= 0)这一行代码,实际上就是判断此时eax的值是多少。我们刚刚介绍了,这时候eax里面的值是返回的进程1的进程号 1,return (type) __res将“1”返回。
回到3.1.1节中fork()函数的调用点if (!fork( ))处执行,!1为“假”,这样就不会执行到init()函数中,而是进程0继续执行,接下来就会执行到for(;;) pause( )。
执行代码如下:

//代码路径:init/main.c:
    …
void main(void)
{
    sti();
    move_to_user_mode();
    if (!fork()) {    //fork的返回值为1,if(!1)为假/* we count on this going ok */
         init();    //不会执行这一行        
    }
    …
    for(;;) pause();    //执行这一行!
}

图3-12形象地表示了上述过程。

image

相关实践学习
高可用应用架构
欢迎来到“高可用应用架构”课程,本课程是“弹性计算Clouder系列认证“中的阶段四课程。本课程重点向您阐述了云服务器ECS的高可用部署方案,包含了弹性公网IP和负载均衡的概念及操作,通过本课程的学习您将了解在平时工作中,如何利用负载均衡和多台云服务器组建高可用应用架构,并通过弹性公网IP的方式对外提供稳定的互联网接入,使得您的网站更加稳定的同时可以接受更多人访问,掌握在阿里云上构建企业级大流量网站场景的方法。 学习完本课程后,您将能够: 理解高可用架构的含义并掌握基本实现方法 理解弹性公网IP的概念、功能以及应用场景 理解负载均衡的概念、功能以及应用场景 掌握网站高并发时如何处理的基本思路 完成多台Web服务器的负载均衡,从而实现高可用、高并发流量架构
相关文章
|
2天前
|
NoSQL Linux 程序员
【linux进程信号(一)】信号的概念以及产生信号的方式
【linux进程信号(一)】信号的概念以及产生信号的方式
|
2天前
|
Linux
【linux进程间通信(一)】匿名管道和命名管道
【linux进程间通信(一)】匿名管道和命名管道
|
2天前
|
Java Shell Linux
【linux进程控制(三)】进程程序替换--如何自己实现一个bash解释器?
【linux进程控制(三)】进程程序替换--如何自己实现一个bash解释器?
|
2天前
|
算法 Linux Shell
【linux进程(二)】如何创建子进程?--fork函数深度剖析
【linux进程(二)】如何创建子进程?--fork函数深度剖析
|
2天前
|
存储 Linux Shell
【linux进程(一)】深入理解进程概念--什么是进程?PCB的底层是什么?
【linux进程(一)】深入理解进程概念--什么是进程?PCB的底层是什么?
|
3天前
|
消息中间件 Unix Linux
Linux的学习之路:17、进程间通信(1)
Linux的学习之路:17、进程间通信(1)
18 1
|
3天前
|
存储 安全 Linux
Linux的学习之路:9、冯诺依曼与进程(1)
Linux的学习之路:9、冯诺依曼与进程(1)
18 0
|
8天前
|
算法 Linux 调度
深入理解Linux内核的进程调度机制
【4月更文挑战第17天】在多任务操作系统中,进程调度是核心功能之一,它决定了处理机资源的分配。本文旨在剖析Linux操作系统内核的进程调度机制,详细讨论其调度策略、调度算法及实现原理,并探讨了其对系统性能的影响。通过分析CFS(完全公平调度器)和实时调度策略,揭示了Linux如何在保证响应速度与公平性之间取得平衡。文章还将评估最新的调度技术趋势,如容器化和云计算环境下的调度优化。
|
10天前
|
监控 Linux
linux监控指定进程
请注意,以上步骤提供了一种基本的方式来监控指定进程。根据你的需求,你可以选择使用不同的工具和参数来获取更详细的进程信息。
14 0
|
11天前
|
消息中间件 监控 Linux
Linux进程和计划任务管理
通过这些命令和工具,你可以有效地管理Linux系统中的进程和计划任务,监控系统的运行状态并保持系统的稳定和可靠性。 买CN2云服务器,免备案服务器,高防服务器,就选蓝易云。百度搜索:蓝易云
102 2