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, ForkSys_Fork ,子进程User 结构中的u_srav 中存储的espebp 指针分别指向的是核心栈顶的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结构中指向相对地址映射表的指针的获取过程等等。这些内容较为细碎,但理解难度不大,因此在此不再赘述。