多进程的实现原理

时间:2022-09-14 16:39:24

一, 多进程程序示例

  test_fork.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void main()
{
    if (!fork())
    {
        printf("I'm child\n");
        exit(0);
    }   
    else
    {   
        wait(NULL);
        printf("I'm father\n");
        exit(0);
    }
}

  运行结果为:

    多进程的实现原理

  从中可以看出, fork()函数创建了一个子进程, 这个子进程和父进程的是一模一样的,  只不过当fork()返回0时, 就是是子进程, 所以就打印出I'm child, 而当fork()返回非0时, 就是父进程, 所以就打印出I'm father

  这是一个最简单的多进程代码. 下面的分析就是这段代码背后所做的事!

 

二, fork()函数的实现 -- 入口和出口

  和write()一样, 都是系统调用, fork()函数的也是一个宏实现的, 是在init/main()函数中定义的, 宏展开之后的代码为:

int fork()
{
    long __res;
    __asm__ volatile (
        "int $0x80"          // 汇编语句
        : "=a" (__res)       // 输出寄存器
        : "0" (__NR_fork);   // 输入寄存器
        if (__res >= 0)
            return (int)__res;
        errno = -__res;
        return -1;
    )
}

  fork()会调用0x80号中断, eax保存传递进来的参数__NR_fork, 中断的返回值也会保存在eax中. 前面的系统调用的实现中说到: 调用中断就是从IDT中断向量表中查找中断处理函数, 并进入内核态. 但是还有一个非常重要的过程就是: 还要通过TSS找到内核栈的位置并将当前用户态程序的ss, sp, eflags, cs, ip这5个寄存器的值保存在内核栈中, 只有这样, 才能在中断返回(执行iret指令)的时候, 紧接着执行int 0x80后面的指令. 

  以下面的程序为例:

void A()
{
    B();
    return;
}

void B()
{
    fork();
    return;
}

  假设: A()函数的地址是100, A()函数中return语句的地址为104, B()函数的地址是200, B()函数中return语句的地址为204, 那么, 在刚刚进入0x80中断的时候, 栈就是下面这个样子:

  多进程的实现原理

  接下来的分析是比较复杂的, 有比较多的函数, 但是现在这里建立一个宏观的理解: 最终一定是返回到用户态的!

 

三, fork()函数的实现 -- 子进程的创建

  0x80号中断的中断处理函数是system_call:

 1 system_call:
 2     cmpl $nr_system_calls-1,%eax
 3     ja bad_sys_call
 4     push %ds
 5     push %es
 6     push %fs
 7     pushl %edx
 8     pushl %ecx        # push %ebx,%ecx,%edx as parameters
 9     pushl %ebx        # to the system call
10     movl $0x10,%edx        # set up ds,es to kernel space
11     mov %dx,%ds
12     mov %dx,%es
13     movl $0x17,%edx        # fs points to local data space
14     mov %dx,%fs
15     call sys_call_table(,%eax,4)
16     pushl %eax
......

  system_call会根据eax中的值 (__NR_fork)查找sys_call_table中的中断处理函数, 查找到的是sys_fork, 所以再调用sys_fork. sys_fork是在kernel/system_call.s中.

  下面的函数是sys_fork的代码:

 1 sys_fork:
 2     call find_empty_process
 3     testl %eax,%eax
 4     js 1f
 5     push %gs
 6     pushl %esi
 7     pushl %edi
 8     pushl %ebp
 9     pushl %eax
10     call copy_process
11     addl $20,%esp
12 1:    ret

  sys_fork首先会调用find_empty_process函数, 函数的返回值是保存在寄存器eax中, 通过test %eax,%eax来然后判断eax的值, 如果eax的值为负, 那么就返回. 否则就继续调用copy_process函数. 

  find_empty_process函数是在kernel/fork.c中定义的:

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;
}

  这个函数的作用是为新进程取得不重复的进程号last_pid, if语句的判断决定了last_pid的值一定不为负值. 注意到返回值并不是进程号last_pid, 而是找到的空闲任务数组项的下标i, 这个i就是子进程的任务数组下标值. 而返回值是被保存在eax中的. 

  继续看sys_fork函数. 这个函数最后会调用copy_process, copy_process也是在kernel/fork.c中定义的:

 1 int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
 2         long ebx,long ecx,long edx,
 3         long fs,long es,long ds,
 4         long eip,long cs,long eflags,long esp,long ss)
 5 {
 6     struct task_struct *p;
 7     int i;
 8     struct file *f;
 9 
10     p = (struct task_struct *) get_free_page();
11     if (!p)
12         return -EAGAIN;
13     task[nr] = p;
14     *p = *current;    /* NOTE! this doesn't copy the supervisor stack */
15     p->state = TASK_UNINTERRUPTIBLE;
16     p->pid = last_pid;
17     p->father = current->pid;
18     p->counter = p->priority;
19     p->signal = 0;
20     p->alarm = 0;
21     p->leader = 0;        /* process leadership doesn't inherit */
22     p->utime = p->stime = 0;
23     p->cutime = p->cstime = 0;
24     p->start_time = jiffies;
25     p->tss.back_link = 0;
26     p->tss.esp0 = PAGE_SIZE + (long) p;
27     p->tss.ss0 = 0x10;
28     p->tss.eip = eip;
29     p->tss.eflags = eflags;
30     p->tss.eax = 0;
31     p->tss.ecx = ecx;
32     p->tss.edx = edx;
33     p->tss.ebx = ebx;
34     p->tss.esp = esp;
35     p->tss.ebp = ebp;
36     p->tss.esi = esi;
37     p->tss.edi = edi;
38     p->tss.es = es & 0xffff;
39     p->tss.cs = cs & 0xffff;
40     p->tss.ss = ss & 0xffff;
41     p->tss.ds = ds & 0xffff;
42     p->tss.fs = fs & 0xffff;
43     p->tss.gs = gs & 0xffff;
44     p->tss.ldt = _LDT(nr);
45     p->tss.trace_bitmap = 0x80000000;
46     if (last_task_used_math == current)
47         __asm__("clts ; fnsave %0"::"m" (p->tss.i387));
48     if (copy_mem(nr,p)) {
49         task[nr] = NULL;
50         free_page((long) p);
51         return -EAGAIN;
52     }
53     for (i=0; i<NR_OPEN;i++)
54         if ((f=p->filp[i]))
55             f->f_count++;
56     if (current->pwd)
57         current->pwd->i_count++;
58     if (current->root)
59         current->root->i_count++;
60     if (current->executable)
61         current->executable->i_count++;
62     set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
63     set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
64     p->state = TASK_RUNNING;    /* do this last, just in case */
65     return last_pid;
66 }

  这个函数的功能就是创建子进程. 有必要对copy_process函数做一些解释: 

    1, copy_process函数的参数那么多, 从哪儿来的? 根据C语言函数的调用原理, 函数的参数是保存在栈中的, 所以这些参数肯定从内核栈中的来的. 再根据前面的system_call和sys_fork中那么多的pushl指令, 可以很明显得发现: copy_process的参数就是当前父进程的各个寄存器的值!

    2, 注意到最后5个参数, 这5个参数就是最前面 "fork()函数的实现 -- 入口和出口"中的那个图中的内核栈中的5个参数. 所以, 子进程知道了当前进程用户栈的位置, 而且是和父进程共享用户栈的. 最后当子进程从内核态返回的时候, 一定是紧接着fork()语句后面继续执行!

    3, 第10行, get_free_page()函数的作用是从内存中找到一页(4KB)的内存作为子进程的内核栈, 返回值是这一页内存的起始地址. 这个值被赋给了p, 而p的类型是task_struct类型, 起始task_struct类型就是之前说到的PCB -- 保存进程的各种信息的数据结构. 所以一个进程的PCB是被保存在这个进程所在的内核栈的最底地址处.

    4, 第26行中PAGE_SIZE的值为4096, PAGE_SIZE+(long) p 的值就是子进程内核栈的最高地址处, 把这个地址赋给p->tss.esp0, 这意味着初始化好了子进程的内核栈. 

    5, 注意到, 当前进程的寄存器的值全部被赋值到了PCB中tss这个数据项中, 这个也非常重要, 在第62行的函数set_tss_desc()将根据这个tss值来设置子进程的TSS段描述符. 接下来的set_idt_desc()函数也会设置好子进程的LDT段描述符. 只有这样才能够让子进程访问到申请的这段地址. 

    6, 从第15行开始, 这么多的赋值语句就是把当前进程的所有信息全部保存到子进程的PCB中, 但是有一个例外, 就是在第30行, 并不是像其它语句一样, 而是把p->tss.eax的值设置为了0. 再看前面fork()函数的定义, 正好eax就是fork()的返回值. 所以在子进程中fork()的返回值就是0. 这就验证了前面的test_fork程序

    7, 注意到最后返回的是子进程号: last_pid, 而且根据前面的判断, last_pid的值一定不是负值! 

  所以, 这个函数的主要功能就是申请内存空间并创建内核栈, 并和父进程的用户栈关联在一起! 最后就一步一步ret, 回到用户态了. 所以对于父进程, fork()的返回值就是last_pid -- 子进程号pid, 验证了前面的test_fork程序.

 

总结: 一个进程的创建过程是:

  (1) 用户程序通过调用fork()系统调用, 陷入到内核态

  (2) 父进程在内核态下获取子进程的进程号作为fork()的返回值

  (3) 为子进程申请地址空间,

  (4) 初始化子进程内核栈

  (5) 将子进程的内核栈和当前进程的用户栈关联起来

  (6) 设置子进程PCB中eax的值为0作为子进程fork()的返回值

  (7) 把当前进程的所有信息(不包括eax)全部复制给子进程

 

四, 进程的切换

  刚才分析了fork()函数, 再来看wait()函数. 和fork()类似, 就不详细展开了, 最后会执行到kernel/exit.c的sys_waitpid()函数中的这一段代码:

 1 if (flag) {
 2         if (options & WNOHANG)
 3             return 0;
 4         current->state=TASK_INTERRUPTIBLE;
 5         schedule();
 6         if (!(current->signal &= ~(1<<(SIGCHLD-1))))
 7             goto repeat;
 8         else
 9             return -EINTR;
10     }

  第4行的意思是把当前进程(父进程)设置为可中断睡眠状态, 然后再调用schedule()函数.这个函数在kernel/sched.c中, 这个函数就是所谓操作系统的调度算法的实现. 这个函数比较复杂, 主要功能就是从就绪队列中找一个进程, 然后切换进程. 调度算法不是这篇文章的重点, 暂时不分析.

  在执行test_fork程序的时候, schedule()就会找到父进程通过fork()创建的子进程, 这时全局变量current仍然指向父进程, 而next就是子进程的那个任务数组的下标值. 最后schedule() 就会调用switch()函数, 而switch()函数是一个宏, 将这个宏展开之后的主要内容是:

switch_to(n)
{
    struct 
    {
        long a,b;
    };
    __asm__(
......
"movw %%dx,%1\n\t" "ljmp %0\n\t" : : "m"(*&__tmp.a), "m"(*&__tmp.b), "d"(_TSS(n) )

  其中, _TSS也是一个宏:

#define TSS(n)(((unsigned long) n) << 4) + (FIRST_TSS_ENTRY << 3))

  而FIRST_TSS_ENTRY是:

#define FIRST_TSS_ENTRY 4

  最主要的就是一个指令ljmp指令, 这个是实现进程切换的指令. 具体的说,在设计“Intel架构”(即x86系统结构)时,每个任务(进程或线程)都对应一个独立的TSS,TSS就是内存中的一个结构体,里面包含了几乎所有的CPU寄存器的映像(在前面的copy_process()函数的最后就是设置tss)。有一个任务寄存器(Task Register,简称TR)指向当前进程对应的TSS结构体,所谓的TSS切换就将CPU中几乎所有的寄存器都复制到TR指向的那个TSS结构体中保存起来,同时找到一个目标TSS,即要切换到的下一个进程对应的TSS,将其中存放的寄存器映像“扣在”CPU上,就完成了执行现场的切换,如下图所示。

  多进程的实现原理

  Intel架构不仅提供了TSS来实现任务切换,而且只要一条指令就能完成这样的切换,即上面的ljmp指令。具体的工作过程是:

    (1)首先用TR中存取的段选择符在GDT表中找到当前TSS的内存位置,由于TSS是一个段,所以需要用段表中的一个描述符来表示这个段,和在系统启动时论述的内核代码段是一样的,那个段用GDT中的某个表项来描述。此处的TSS也是用GDT中的某个表项描述,而TR寄存器是用来表示这个段用GDT表中的哪一项来描述,所以TR和CS、DS等寄存器的功能是完全类似的。

    (2)找到了当前的TSS段(就是一段内存区域)以后,将CPU中的寄存器映像存放到这段内存区域中,即拍了一个快照。

    (3)存放了当前进程的执行现场以后,接下来要找到目标进程的现场,并将其扣在CPU上,找目标TSS段的方法也是一样的,因为找段都要从一个描述符表中找,描述TSS的描述符放在GDT表中,所以找目标TSS段也要靠GDT表,当然只要给出目标TSS段对应的描述符在GDT表中存放的位置——段选择子就可以了,仔细想想系统启动时那条著名的jmpi 0, 8指令,这个段选择子就放在ljmp的参数中,实际上就jmpi 0, 8中的8。

    (4)一旦将目标TSS中的全部寄存器映像扣在CPU上,就相当于切换到了目标进程的执行现场了,因为那里有目标进程停下时的CS:EIP ,所以此时就开始从目标进程停下时的那个CS:EIP处开始执行,现在目标进程就变成了当前进程,所以TR需要修改为目标TSS段在GDT表中的段描述符所在的位置,因为TR总是指向当前TSS段的段描述符所在的位置。

  上面给出的这些工作都是一句长跳转指令“ljmp 段选择子:段内偏移”,在段选择子指向的段描述符是TSS段时CPU解释执行的结果,所以基于TSS进行进程/线程切换的switch_to实际上就是上面那一句ljmp指令.

  GDT表的结构如下图所示,所以第一个TSS表项,即0号进程的TSS表项在第4个位置上,4<<3,即4 * 2^3,相当于TSS在GDT表中开始的位置(以字节为单位),TSS(n)找到的是进程n的TSS位置,所以还要再加上n<<4,即n * 2^4,因为每个进程对应有1个TSS和1个LDT,每个描述符的长度都是8个字节,所以是乘以16,其中LDT的作用就是上面论述的映射表. TSS(n)=n*2^4+4*2^3,得到就是进程n(切换到的目标进程)的TSS选择子,将这个值放到dx寄存器中,并且又放置到结构体tmp中32位长整数b的前16位,现在64位tmp中的内容是前32位为空,这个32位数字是段内偏移,就是jmpi 0, 8中的0;接下来的16位是n16+48,这个数字是段选择子,就是jmpi 0, 8中的8,再接下来的16位也为空。所以swith_to的核心实际上就是“ljmp 空, n16+48”,现在和前面给出的基于TSS的进程切换联系在一起了。

  多进程的实现原理

 

  由于现在的操作系统几乎都不使用tss来实现指令的跳转, 所以后面的实验内容就是把基于tss的进程切换改成基于栈的进程切换.