对Linux 0.11内核main函数中fork()的执行分析
main
init/main.c
if (!fork()) { /* we count on this going ok */
///进程0调用时返回1,继续执行到PAUSE(),切换成进程1执行
///进程0 -> fork -> 返回1 -> for(;;)pause -> schedue() -> 进程1 -> switch_to_next() -> ljmp %0 -> 进程1的TSS -> EIP
/// ->COPEY_PROCESS中的参数 -> fork()时int80 自动压栈 -> int 80的下一行 -> 返回值0 (eax是0)
init(); // 在新建的子进程(任务1)中执行。
}
在main函数中调用fork()语句,如果返回的是1就进入if中执行init(),如果返回0就继续执行。
那fork()是怎么定义的呢?
在main.c的头部中有一行
static inline _syscall0(int, fork)
这里定义了一个内联函数 _syscall0(int, fork)
其中,_syscall0的定义是一个宏,
include/unistd.h
#define _syscall0(type,name) \
type name(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \ ///自动压栈 在内核栈压了5个
: "=a" (__res) \
: "0" (__NR_##name)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
也就是说,编译器在编译的时候遇到_syscall0(int, fork)时会自动将其替换成:
int fork()
{
long __res;
__asm__ volatile ("int $0x80" ///自动压栈 在内核栈压了5个
: "=a" (__res)
: "0" (__NR_##fork));
if (__res >= 0)
return (type) __res;
errno = -__res;
return -1;
}
这就是fork()的定义。
可以看到,fork()中是一段内嵌汇编。
当执行到int $0x80这个系统调用时,CPU会自动将ss,esp,eflags,cs,eip这5个寄存器压入栈中,并跳转到相应的处理函数。
__NR_##fork在include/unistd.h中有定义是 2
#define __NR_fork 2
第一个冒号: "=a" (__res)
是输出,即在这个系统调用执行完之后,会把存放在EAX中的返回值赋值到_res中。
第二个冒号:: "0" (__NR_##fork));
是输入,0代表同上寄存器,也是会将2赋值给eax。
另外,内嵌汇编语法如下:
asm("汇编语句模块"
:输出寄存器
:输入寄存器
:会被修改的寄存器);
system_call
在sched_init函数中,已经将0x80这个中断设置了相应的处理程序system_call()
kernel/sched.c
extern int system_call(void); // 系统调用中断处理程序
...
void sched_init(void){
...
set_system_gate(0x80,&system_call);
}
system_call的具体实现是:
kernel/system_call.s
system_call:
cmpl $nr_system_calls-1,%eax # 调用号如果超出范围的话就在eax中置-1并退出
ja bad_sys_call
push %ds # 保存原段寄存器值
push %es
push %fs
# 一个系统调用最多可带有3个参数,也可以不带参数。下面入栈的ebx、ecx和edx中放着系统
# 调用相应C语言函数的调用函数。这几个寄存器入栈的顺序是由GNU GCC规定的,
# ebx 中可存放第1个参数,ecx中存放第2个参数,edx中存放第3个参数。
# 系统调用语句可参见头文件include/unistd.h中的系统调用宏。
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 s pace
mov %dx,%ds
mov %dx,%es
# fs指向局部数据段(局部描述符表中数据段描述符),即指向执行本次系统调用的用户程序的数据段。
# 注意,在Linux 0.11 中内核给任务分配的代码和数据内存段是重叠的,他们的段基址和段限长相同。
movl $0x17,%edx # fs points to local data space
mov %dx,%fs5
# 下面这句操作数的含义是:调用地址=[_sys_call_table + %eax * 4]
# sys_call_table[]是一个指针数组,定义在include/linux/sys.h中,该指针数组中设置了所有72
# 个系统调用C处理函数地址。
call sys_call_table(,%eax,4) # 间接调用指定功能C函数
pushl %eax # 把系统调用返回值入栈
...
先查询保存在eax中的调用号是否超过了$nr_system_calls
中的规定值 72 ,超过了的话说明出错了,就跳转到bad_sys_call
。
确定调用号没有错误之后,就是将寄存器ds,es,fs,edx,ecx,ebx分别压栈。注意此时的栈中还留存有int 80时压入的ss,esp等5个寄存器。
之后会执行call sys_call_table(,%eax,4)
,其中,%eax的值是2。
sys_call_table定义:
include/linux/sys.h
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_fork正好是sys_call_table[2],即sys_call_table+2*4的位置。(4是每个数组项是4个字节)。
此时会跳转到sys_fork。需要注意的是call指令执行时也会压栈一个参数。也就是之后的copy_process()
中long none;
参数的来源。
sys_fork
sys_fork:
call find_empty_processt///栈动作
testl %eax,%eax # 在eax中返回进程号pid。若返回负数则退出。
js 1f
push %gs
pushl %esi
pushl %edi
pushl %ebp
pushl %eax
call copy_process
addl $20,%esp # 丢弃这里所有压栈内容。
1: ret
首先是调用find_empty_process找到一个空闲的进程号,并保存在eax中。
find_empty_process的具体内容会在下一节进行分析。
先检查一下eax中的进程号是否合法,如果不合法就退出。
之后就是压栈。
将gs,esi,edi,ebp,eax分别压入内核栈中。
最后调用copy_process函数。
find_empty_process
kernel/fork.c
int find_empty_process(void)
{
int i;
// 首先获取新的进程号。如果last_pid增1后超出进程号的整数表示范围,则重新从1开始
// 使用pid号。然后在任务数组中搜索刚设置的pid号是否已经被任何任务使用。如果是则
// 跳转到函数开始出重新获得一个pid号。接着在任务数组中为新任务寻找一个空闲项,并
// 返回项号。last_pid是一个全局变量,不用返回。如果此时任务数组中64个项已经被全部
// 占用,则返回出错码。
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++) // 任务0项被排除在外
if (!task[i])
return i;
return -EAGAIN;
}
find_empty_process将会遍历所有的task[]并确定一个没有使用过的进程号并返回。对于此时来说,由于只有一个进程0,所以返回的就是1。
Linux0.11内核最大只支持64个进程。
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)
{
这里copy_process的形参就是从int 80调用到现在为止压入的内核栈中的所有内容。
栈顶是sys_fork中最后一个压入的eax,即任务号。
依次类推,
其中有一个long none
是从system_call调用sys_fork时压入的eax寄存器。在这里并没有任何作用。
此时进程0开始为进程1创建task_struct,并将自己的task_struct中的内容复制给进程1(并做少许修改)。
struct task_struct *p;
int i;
struct file *f;
// 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。
// 然后将新任务结构指针放入任务数组的nr项中。其中nr为任务号,由前面
// find_empty_process()返回。接着把当前进程任务结构内容复制到刚申请到
// 的内存页面p开始处。
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
首先是创建一个task_struct的指针*p,并调用get_free_page()为其分配一个空闲的内存页。
并将保存在*current中的当前进程的task_struct赋值给*p。
task_struct的定义:
include/linux/sched.h
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
struct sigaction sigaction[32];
long blocked; /* bitmap of masked signals */
/* various fields */
int exit_code;
unsigned long start_code,end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
struct m_inode * executable;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss;
};
可以看到task的最后一项是struct tss_struct tss(task state segment 任务状态描述符)
tss_struct的定义:
include/linux/sched.h
struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};
可以看到,TSS中保存的都是一些寄存器的信息。
在IA32手册中,可以看到TSS的定义
一个任务被调度执行时,会自动地在当前任务和被调任务之间发生任务切换。切换时,当前任务的执行环境(称做任务状态或场境(context))保存进其 TSS 中,该任务随即被挂起。然后,处理器加载被调任务的场境到各个寄存器中,从刚加载的 EIP寄存器指向的指令处开始执行新任务。若该任务是系统上次初始化以来的首次执行,则 EIP 指向任务代码的第一条指令;否则,指向任务上次被挂起时执行的最后一条指令的下一条指令。
如果是由当前任务(调用任务)调用了被调度的任务(被调任务)而发生了任务切换,则把调用任务的 TSS 的选择子保存到被调任务的 TSS 中,以提供返回调用任务的链接。
IA-32-3中文版
在Linux 0.11中,每一个进程都会有一个TSS和LDT(局部描述符表),并将其存放于GDT中。从Linux2.4以后,全部进程使用同一个TSS,准确的说是,每个CPU一个TSS,在同一个CPU上的进程使用同一个TSS。
X86体系从硬件上支持任务间的切换。为此目的,它增设了一个新段:任务状态段(TSS),它和数据段、代码段一样也是一种段,记录了任务的状态信息。
与其它段一样,TSS也有描述它的结构:TSS描述符表,它记录了一个TSS的信息,同时还有一个TR寄存器,它指向当前任务的TSS。任务切换的时候,CPU会将原寄存器的内容写出到相应的TSS,同时将新TSS的内容填到寄存器中,这样就实现了任务的切换。
TSS在任务切换过程中起着重要作用,通过它实现任务的挂起和恢复。所谓任务切换是指挂起当前正在执行的任务,恢复或启动执行另一个任务。Linux任务切换是通过switch_to这个宏来实现的,它利用长跳指令,当长跳指令的操作数是TSS描述符的时候,就会引起CPU的任务的切换,此时,CPU将所有寄存器的状态保存到当前任务寄存器TR所指向的TSS段中,然后利用长跳指令的操作数(TSS描述符)找到新任务的TSS段,并将其中的内容填写到各个寄存器中,最后,将新任务的TSS选择符更新到TR中。这样系统就开始运行新切换的任务了。由此可见,通过在TSS中保存任务现场各寄存器状态的完整映象,实现了任务的切换。 task_struct中的tss成员就是记录TSS段内容的。当进程被切换前,该进程用tss_struct保存处理器的所有寄存器的当前值。当进程重新执行时,CPU利用tss恢复寄存器状
接下来的部分:
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid; // 新进程号。也由find_empty_process()得到。
p->father = current->pid; // 设置父进程
p->counter = p->priority; // 运行时间片值
p->signal = 0; // 信号位图置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; // 进程开始运行时间(当前时间滴答数)
// 再修改任务状态段TSS数据,由于系统给任务结构p分配了1页新内存,所以(PAGE_SIZE+
// (long)p)让esp0正好指向该页顶端。ss0:esp0用作程序在内核态执行时的栈。另外,
// 每个任务在GDT表中都有两个段描述符,一个是任务的TSS段描述符,另一个是任务的LDT
// 表描述符。下面语句就是把GDT中本任务LDT段描述符和选择符保存在本任务的TSS段中。
// 当CPU执行切换任务时,会自动从TSS中把LDT段描述符的选择符加载到ldtr寄存器中。
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p; // 任务内核态栈指针。
p->tss.ss0 = 0x10; // 内核态栈的段选择符(与内核数据段相同)
p->tss.eip = eip; ///int 80压入 // 指令代码指针
p->tss.eflags = eflags; // 标志寄存器
p->tss.eax = 0; ///important 写死,fork返回是0 // 这是当fork()返回时新进程会返回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; // 段寄存器仅16位有效
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描述符在GDT中)
p->tss.trace_bitmap = 0x80000000; // 高16位有效
可以看到,在复制当前进程的task_struct之后,还需要对子进程task_struct中的相关信息进行相应的改动。
比如将新进程的pid更改为从find_empty_process()中得到的pid号码。
新进程的父进程father改为父进程的pid。
p->tss.esp0 = PAGE_SIZE + (long) p; // 任务内核态栈指针。
p->tss.ss0 = 0x10; // 内核态栈的段选择符(与内核数据段相同)
这一段是将tss中的esp0寄存器指向get_free_page()申请到的页面的最末端。
ss0寄存器设置为0x10。即10000,低位的00代表0特权级,第三位的0代表GDT,高位的10代表第三项,即内核数据段。
在TSS中,有3个ss和3个esp,分别是ss0,ss1,ss2和esp0,esp1,esp2。
分别表示特权级0,1,2的栈的段选择子(段描述符)和偏移。
为什么从主内存区申请得来的用于保存任务数据结构的一页内存也能被设置成内核数据段中的数据呢,即tss.ss0为什么能被设置成0x10呢?这是因为用户内核态栈仍然属于内核数据空间。我们可以从内核代码段的长度范围来说明。在head.s程序的末端,分别设置了内核代码段和数据段的描述符,段长度都被设置成了16MB。这个长度值是Linux 0.12内核所能支持的最大物理内存长度(参见head.s,110行开始的注释)。因此,内核代码可以寻址到整个物理内存范围中的任何位置,当然也包括主内存区。每当任务执行内核程序而需要使用其内核栈时,CPU就会利用TSS结构把它的内核态堆栈设置成由tss.ss0和tss.esp0这两个值构成。在任务切换时,老任务的内核栈指针esp0不会被保存。对CPU来讲,这两个值是只读的。因此每当一个任务进入内核态执行时,其内核态堆栈总是空的。
p->tss.eip = eip; ///int 80压入
之后就是将eip赋值给tss.eip字段。这里的eip还是在int 80时压入的,指向下一行的代码,即if(_res>0)那里。因此,进程在经过调度开始运行的时候,会跳转到那一行开始执行。
p->tss.eax = 0;
这里把eax写入了0,这也是为什么当进程1运行的时候的fork()会返回0的原因所在。
kernel/fork.c
// 接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长,
// 并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为该新任务分配的用于
// 任务结构的内存页。
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}