0. 说明
该系列小结为我在2023年秋修读同济大学操作系统课程的笔记,我校的操作系统课程主要以Unix v6 为范例进行剖析,因此以下的所有内容只适用于Unix v6 操作系统内核。另外,我校操作系统课程组对于Unix v6 操作系统的原始代码进行了面向对象的重构,下面给出的所有代码均为重构过后的代码片段。
1. fork
1.1 fork系统调用简介与范例
fork 是一个系统调用,其主要功用为创建一个和当前进程 完全相同 的进程,新创建的进程为当前进程的子进程,二者的可交换部分完全相同,但彼此隔离,互不干扰,代码段共享。fork 的返回值是新创建的子进程的pid。
下面是一个在用户态下调用fork 系统调用的代码示例片段。
int a=0;
main( )
{
int i ;
while( ( i=fork( ) ) == -1 );
if(i)
{
a = a+1;
printf(“parent : a = %d\n”, &a);
}
else
{
a=a+4;
printf(“child : a = %d\n”, &a);
}
}
在上面的代码段中,我们首先通过fork 创建了一个子进程,其pid 存储在i 变量中。在fork 系统调用成功创建新进程并准备返回时(系统调用入口程序SystemCallEntrance函数),系统执行例行调度。此时,新创建的子进程和原进程都可能被调度上台,具体情况由0号进程决定。
-
假如原进程被先调度上台,则由于子进程创建成功,
pid势必不为0,进入if分支,将a的值改为1,并执行打印。在这之后原进程结束,子进程上台,进入else分支,将a的值改为4 (注意 :子进程和父进程的数据段相互隔离,因此子进程中a的值仍为0),并进行打印,应用程序的输出为:parent : a=1 child : a=4 -
假如子进程被先调度上台,则进入
else分支,修改a的值后打印。子进程结束后父进程被调度上台,进入if分支后修改a的值后执行打印,因此应用程序的输出为 :child : a=4 parent : a=1
问题: 看到这里大家想必会有一个疑问,为什么子进程上台之后会进入else 分支执行呢?
这是一个比较复杂的问题,我们需要读一读代码才能说清楚这件事。
1.2 fork 系统调用代码解释
用户态下执行fork 系统调用的钩子函数如下
int fork()
{
int res;
__asm__ __volatile__ ( "int $0x80":"=a"(res):"a"(2)); // 2#系统调用
if ( res >= 0 )
return res;
return -1;
}
这段代码很好理解,即使用int 0x80 指令让系统陷入内核后执行2号系统调用fork。
fork 系统调用的函数如下
int SystemCall::Sys_Fork()
{
ProcessManager& procMgr = Kernel::Instance().GetProcessManager();
procMgr.Fork();
return 0;
/* GCC likes it ! */
}
可以看到在系统调用函数中,我们获取了内核的ProcessManager 对象,并执行其Fork 方法,接下来我们我们进入Fork函数内部去看看。
void ProcessManager::Fork()
{
User& u = Kernel::Instance().GetUser();
Process* child = NULL;;
/* 为子进程分配空闲的 process 项 */
for ( int i = 0; i < ProcessManager::NPROC; i++ )
{
if ( this->process[i].p_stat == Process::SNULL )
{
child = &this->process[i];
break;
}
}
if ( child == NULL )
{
/* 没有空闲 process 表项,返回 */
u.u_error = User::EAGAIN;
return;
}
/* 调用 Newproc( )创建子进程,复制父进程图像 */
L:
if ( this->NewProc() )
{ /*
新建子进程被 Swtch( )选中上台运行,
*执行 Swtch( )函数逻辑,返回值是 1
*运行于 Newproc( )栈帧,返回地址是 if 语句。*/
u.u_ar0[User::EAX] = 0;
// 子进程 fork()系统调用返回 0
u.u_cstime = 0;
// 清 0 子进程的时间统计量
u.u_stime = 0;
u.u_cutime = 0;
u.u_utime = 0;
}
else
{ /* 子进程图像创建完毕后,父进程 Newproc( )返回 0 */
u.u_ar0[User::EAX] = child->p_pid; // 父进程 fork()系统调用的返回值是子进程的 pid
}
return;
}
在这个函数中,我们首先在process 表中查看有无空闲的表项,接着调用NewProc 函数。NewProc 函数的代码段很长,在此就不给出了,其主要功用是将父进程的可交换部分复制给子进程,且将子进程User 结构中的相对表指针u_MemoryDescriptor.m_UserPageTableArray 指向子进程的相对表。在完成了上述工作后,NewProc函数返回0。
我们先停一下理一理头绪,现在我们的子进程已经创建起来了,其核心栈中栈顶处的几个栈帧分别为NewProc, Fork 和Sys_Fork ,子进程User 结构中的u_srav 中存储的esp和ebp 指针分别指向的是核心栈顶的NewProc栈帧的顶和底。
当父进程的NewProc 返回时,其返回L 标签处的else 分支执行(NewProc的返回值为0),将新创建的子进程的pid存储在父进程核心栈中EAX 寄存器对应的区域以便我们在系统调用返回时能将返回值(即为子进程pid )带入EAX 寄存器中。
接下来Fork 函数返回,Sys_Fork 系统调用返回,在系统调用入口程序下半段执行例行调度Swtch 函数。
-
假如此时被调度上台的还是父进程,则父进程通过自己的
User结构中的u_srav找到Swtch栈帧,执行Swtch的后半段后将其栈帧撤销,在这之后,返回系统调用入口程序的后半部分执行,回到用户态,非常合理顺畅。 -
假如此时被调度上台的是我们新创建的子进程,则同样可以根据子进程的
User结构中的u_srav找到… 慢着!子进程核心栈中有Swtch吗,没有啊!子进程是被父进程创建出来的,其从来就没执行过,上哪去找Swtch栈帧?好,知道了这一点,让我们看看接下来会发生什么。尽管子进程中没有
Swtch栈帧,但Swtch下半段还是要走,我们将Swtch的返回值1熟练地放进EAX寄存器中,然后撤销核心栈中栈顶的那个栈帧,对应的汇编代码如下mov $1 %eax mox %ebp %esp pop %ebp ret那么此时我们撤销的栈帧是哪个倒霉蛋的呢?没错,就是
NewProc函数的栈帧!(这一点在上面提到过)换句话说,我们把NewProc的栈帧当作Swtch栈帧撤销掉了,并把Swtch的返回值1当作NewProc的返回值放入了EAX寄存器中,在这之后返回了Fork函数。Fork函数一看,嗯? 返回值是1?好好好,那么进入L标签的if标签执行,在这其中将0存储在父进程核心栈中EAX寄存器对应的区域。接下来Fork返回,Sys_Fork系统调用返回,恢复系统调用现场。用户态程序从EAX寄存器中拿到fork的返回值0, 随即进入到else分支中。if(i) #这里拿到的值为0 { ... } else #子进程进入这里执行! { a=a+4; printf(“child : a = %d\n”, &a); }
到这里,fork 系统调用大概就说完了,这其中我花了大段的篇幅解释了1.1 小节末尾处的那个问题,略过了很多细节,比如NewProc函数中父进程的可交换部分复制给子进程的过程,以及子进程的User结构中指向相对地址映射表的指针的获取过程等等。这些内容较为细碎,但理解难度不大,因此在此不再赘述。