从linux0.11源码分析fork函数的作用
fork函数最早出现在main.c
的main
方法中
void main(void)
{
...
sched_init();
...
if (!fork()) {
init(); // 在新建的子进程(任务1)中执行。
}
...
}
头文件在unistd.h
里面,
int fork(void);
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; \
}
展开后就是
int fork(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_fork)); \
if (__res >= 0) \
return (type) __res; \
errno = -__res; \
return -1; \
}
__NR_fork
也位于该头文件中
#define __NR_fork 2
现在调用了中断(0x80),调用中断就会执行相应的中断服务程序,
kernel/sched.c
中的sched_init
方法中就对0x80号中断进行了配置,就是说发生该中断就会调用system_call
方法。
void sched_init(void)
{
...
set_system_gate(0x80,&system_call);
}
system_call
方法并不在我们的C代码中,而是在汇编代码里面
位于kernel/system_call.s
里面,
咱们只看重点,首先发生中断,先把寄存器中的值压栈保存数据或者也可以叫保存环境上下文,然后切换到内核态,然后去到sys_call_table
表里面找函数
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 space
mov %dx,%ds
mov %dx,%es
# fs指向局部数据段(局部描述符表中数据段描述符),即指向执行本次系统调用的用户程序的数据段。
# 注意,在Linux 0.11 中内核给任务分配的代码和数据内存段是重叠的,他们的段基址和段限长相同。
movl $0x17,%edx # fs points to local data space
mov %dx,%fs
# 下面这句操作数的含义是:调用地址=[_sys_call_table + %eax * 4]
# sys_call_table[]是一个指针数组,定义在include/linux/sys.h中,该指针数组中设置了所有72
# 个系统调用C处理函数地址。
call sys_call_table(,%eax,4) # 间接调用指定功能C函数
pushl %eax # 把系统调用返回值入栈
# 下面几行查看当前任务的运行状态。如果不在就绪状态(state != 0)就去执行调度程序。如果该
# 任务在就绪状态,但其时间片已用完(counter = 0),则也去执行调度程序。例如当后台进程组中的
# 进程执行控制终端读写操作时,那么默认条件下该后台进程组所有进程会收到SIGTTIN或SIGTTOU
# 信号,导致进程组中所有进程处于停止状态。而当前进程则会立刻返回。
movl current,%eax # 取当前任务(进程)数据结构地址→eax
cmpl $0,state(%eax) # state
jne reschedule
cmpl $0,counter(%eax) # counter
je reschedule
# 以下这段代码执行从系统调用C函数返回后,对信号进行识别处理。其他中断服务程序退出时也
# 将跳转到这里进行处理后才退出中断过程,例如后面的处理器出错中断int 16.
ret_from_sys_call:
# 首先判别当前任务是否是初始任务task0,如果是则不比对其进行信号量方面的处理,直接返回。
movl current,%eax # task[0] cannot have signals
cmpl task,%eax
je 3f # 向前(forward)跳转到标号3处退出中断处理
# 通过对原调用程序代码选择符的检查来判断调用程序是否是用户任务。如果不是则直接退出中断。
# 这是因为任务在内核态执行时不可抢占。否则对任务进行信号量的识别处理。这里比较选择符是否
# 为用户代码段的选择符0x000f(RPL=3,局部表,第一个段(代码段))来判断是否为用户任务。如果不是
# 则说明是某个中断服务程序跳转到上面的,于是跳转退出中断程序。如果原堆栈段选择符不为
# 0x17(即原堆栈不在用户段中),也说明本次系统调用的调用者不是用户任务,则也退出。
cmpw $0x0f,CS(%esp) # was old code segment supervisor ?
jne 3f
cmpw $0x17,OLDSS(%esp) # was stack segment = 0x17 ?
jne 3f
# 下面这段代码用于处理当前任务中的信号。首先取当前任务结构中的信号位图(32位,每位代表1种
# 信号),然后用任务结构中的信号阻塞(屏蔽)码,阻塞不允许的信号位,取得数值最小的信号值,
# 再把原信号位图中该信号对应的位复位(置0),最后将该信号值作为参数之一调用do_signal().
# do_signal()在kernel/signal.c中,其参数包括13个入栈信息。
movl signal(%eax),%ebx # 取信号位图→ebx,每1位代表1种信号,共32个信号
movl blocked(%eax),%ecx # 取阻塞(屏蔽)信号位图→ecx
notl %ecx # 每位取反
andl %ebx,%ecx # 获得许可信号位图
bsfl %ecx,%ecx # 从低位(位0)开始扫描位图,看是否有1的位,若有,则ecx保留该位的偏移值
je 3f # 如果没有信号则向前跳转退出
btrl %ecx,%ebx # 复位该信号(ebx含有原signal位图)
movl %ebx,signal(%eax) # 重新保存signal位图信息→current->signal.
incl %ecx # 将信号调整为从1开始的数(1-32)
pushl %ecx # 信号值入栈作为调用do_signal的参数之一
call do_signal # 调用C函数信号处理程序(kernel/signal.c)
popl %eax # 弹出入栈的信号值
3: popl %eax # eax中含有上面入栈系统调用的返回值
popl %ebx
popl %ecx
popl %edx
pop %fs
pop %es
pop %ds
iret
sys_call_table
位于linux/sys.h
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, ... };
因为__NR_fork
的值是2,所以从表里面获取的就是sys_fork
函数,该函数也位于汇编代码里面,
首先find_empty_process
找到一个可用的进程id,注意这里的%eax
里面的值是就是我们刚才找到的id,接者调用copy_process
sys_fork:
call find_empty_process
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
copy_process
定义在kernel/fork.c
里面,贴出代码能看懂的人就看,看不懂的不用在意,就是把父进程资源复制到子进程中,设置子进程的内存页等信息,如果父进程调用fork结束,就会返回__res
,因为__res
与%eax
寄存器是绑定的,寄存器里面存的就是进程的pid,因为copy_process
的返回值就是last_pid
,也就是子进程pid,注意这里是父进程返回,子进程还没执行呢,但是子进程也会返回__res
,__res
的值就是寄存器%eax
的值。
睁大眼睛看代码力里有这么一段p->tss.eax = 0;
,父进程已经把子进程%eax
寄存器中的值设置为0了。明白为什么使用fork时,返回0就代表是子进程了吧。
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;
// 首先为新任务数据结构分配内存。如果内存分配出错,则返回出错码并退出。
// 然后将新任务结构指针放入任务数组的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 */
// 随后对复制来的进程结构内容进行一些修改,作为新进程的任务结构。先将
// 进程的状态置为不可中断等待状态,以防止内核调度其执行。然后设置新进程
// 的进程号pid和父进程号father,并初始化进程运行时间片值等于其priority值
// 接着复位新进程的信号位图、报警定时值、会话(session)领导标志leader、进程
// 及其子进程在内核和用户态运行时间统计值,还设置进程开始运行的系统时间start_time.
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; // 指令代码指针
p->tss.eflags = eflags; // 标志寄存器
p->tss.eax = 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位有效
// 如果当前任务使用了协处理器,就保存其上下文。汇编指令clts用于清除控制寄存器CRO中
// 的任务已交换(TS)标志。每当发生任务切换,CPU都会设置该标志。该标志用于管理数学协
// 处理器:如果该标志置位,那么每个ESC指令都会被捕获(异常7)。如果协处理器存在标志MP
// 也同时置位的话,那么WAIT指令也会捕获。因此,如果任务切换发生在一个ESC指令开始执行
// 之后,则协处理器中的内容就可能需要在执行新的ESC指令之前保存起来。捕获处理句柄会
// 保存协处理器的内容并复位TS标志。指令fnsave用于把协处理器的所有状态保存到目的操作数
// 指定的内存区域中。
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
// 接下来复制进程页表。即在线性地址空间中设置新任务代码段和数据段描述符中的基址和限长,
// 并复制页表。如果出错(返回值不是0),则复位任务数组中相应项并释放为该新任务分配的用于
// 任务结构的内存页。
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
// 如果父进程中有文件是打开的,则将对应文件的打开次数增1,因为这里创建的子进程会与父
// 进程共享这些打开的文件。将当前进程(父进程)的pwd,root和executable引用次数均增1.
// 与上面同样的道理,子进程也引用了这些i节点。
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++;
// 随后GDT表中设置新任务TSS段和LDT段描述符项。这两个段的限长均被设置成104字节。
// set_tss_desc()和set_ldt_desc()在system.h中定义。"gdt+(nr<<1)+FIRST_TSS_ENTRY"是
// 任务nr的TSS描述符项在全局表中的地址。因为每个任务占用GDT表中2项,因此上式中
// 要包括'(nr<<1)'.程序然后把新进程设置成就绪态。另外在任务切换时,任务寄存器tr由
// CPU自动加载。最后返回新进程号。
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; /* do this last, just in case */
return last_pid;
}