一, 多进程程序示例
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的进程切换改成基于栈的进程切换.