内核线程的fork与普通的fork的区别

2022-10-31 15:37:59 浏览数 (1)

我们在学习操作系统课程的时候,应该都学过fork的概念。fork是一个系统调用,用于将当前进程/线程分裂成完全相同的两个。

在网络上,很多关于fork的文章都大同小异,讲的都是很通用的fork的原理以及大致的过程。但是,大家有没有想过一个问题:用户程序调用fork()和内核下调用fork(),背后的逻辑是不一样的。咱平时如果没有真的去写操作系统的话,应该不会意识到这个问题。

虚拟地址空间分布

首先,我们先来了解一下进程的虚拟地址空间是怎么分布的:

虚拟地址空间的高地址部分为内核空间,低地址部分为用户空间。各个进程之间的内核空间是共享的,只有用户空间才是独占的。

每个进程都有1个内核栈,这个内核栈位于内核空间。并且,每个进程还有一个用户栈,位于低地址部分。

当进程陷入内核态的时候,将会使用内核栈进行处理,当返回用户态的时候,又会换回去,使用用户栈。

需要注意的是,用户栈在用户空间的映射是由操作系统指定的,父子进程的用户栈的虚拟地址是相同的。而父子进程的内核栈的虚拟地址则是不同的。

用户态进程调用fork()

网络上的文章一般描述的是用户态下的fork。用户态的fork是这样的一个过程:

首先,用户进程发起系统调用,陷入内核态。然后在fork系统调用的函数里面,操作系统将会初始化pcb、线程结构体、对用户空间的内存的拷贝,最后把子进程加入调度队列。

这里“内存拷贝”这一点就是关键所在,也是众多文章没有提及的部分。

在用户态的fork中,由于用户进程的栈空间位于就是位于用户空间之中,并且用户栈一般是位于操作系统指定的地址上,不同的进程的用户栈的基地址相同。又由于进程在返回用户态的时候,内核栈是空的,因此,我们只需要将用户空间进行拷贝,当子进程返回用户态的时候,自然就能执行。这是理所当然的事情。

内核态进程的fork

对于在内核态下运行进程而言,其具有在低地址空间的进程的栈,也具有高地址空间部分的内核栈。进程正常运行时,使用其低地址空间部分的栈,发起了系统调用之后,则会使用其高地址空间的内核栈。内核态进程的fork和用户进程的fork是相同的。

内核线程的fork

讲了这么久,这才轮到我们的主角:内核线程。内核线程的fork的过程与前面提到的两者是不同的。

首先,我们需要认识一下内核线程。内核线程是内核中的一些线程,他们共用同一个虚拟地址空间。并且,他们运行时所使用的栈只有内核栈。也就是说,父进程在系统调用返回的时候,并不会执行切换到用户栈的操作(因为根本不存在)!

那么,这样对我们的fork有什么影响呢?

必须拷贝内核栈

由于我们的内核线程只使用内核栈,那就意味着,fork()系统调用到来时,内核栈中除了系统调用的栈帧以外,还会有其他内容!我们必须拷贝内核栈!

如上图所示,如果是用户进程/内核进程的fork,由于其在发起fork()调用之前,他们一直工作在自己的用户空间的栈上,内核栈是空的。发起fork系统调用后,内核栈中才会被压入一个fork调用所在的栈帧。由于进程最终都要返回到其用户栈上,且离开内核的时候,内核栈必须为空。因此我们不需要拷贝内核栈的内容,只需要拷贝用户栈的内容。而用户栈就是位于用户空间内,因此对用户空间的整体拷贝就能完成整个操作。

而内核线程不存在用户栈,其所有运行操作都是在内核栈上进行的,因此在发起fork调用之后,fork调用所在的栈帧不是位于内核栈的底部。由于fork返回后,计算机需要执行内核栈中已有栈帧的内容,因此我们需要拷贝内核栈。

必须重写子进程的栈帧

看了上面之后,可能很多人就会觉得,那不就是直接拷贝内核栈,然后子进程返回的时候直接切换到新的内核栈不就好了吗?这就是一个很大的误区。

如果真的是直接拷贝栈,然后换栈的话,就必然会出错。

再回到文章开头的“虚拟地址空间分布”部分讲的,“父子进程的内核栈的虚拟地址是不同的”。这句话非常重要。内核栈一般是从slab分配器中分配得来的一块内存地址,而且我们也不能仿照对用户进程的操作那样,将每个内核线程的内核栈映射到相同的地址处(这显然是不可行的)。

父子进程的内核栈的虚拟地址的不同,使得我们必须重写栈帧中的内容。这是为什么呢?首先我们需要理解栈帧的结构:

当发生函数调用时,处理器会把当前当前函数的返回地址、栈基址寄存器的值压入栈中。返回地址指的是,被调用的函数返回时,将会从哪个位置开始执行。栈基址寄存器值则指的是当前栈帧的基地址。注意,不是内核栈的基地址,这是很多人的一个误区。

每个栈帧的大小是不相同的,处理器是通过这个值来区分不同的栈帧的。当要弹出一个栈帧时,处理器把这个值赋值给栈指针寄存器,这样就找到了上一个栈帧的起始地址。同样的,上一个栈帧的起始地址部分存的值,就是再上一个栈帧的起始的值。

明白了处理器是如何在栈帧之间跳转之后,我们就能明白为什么必须重写内核栈的栈帧了:直接拷贝内核栈后,新的内核栈中的每个栈帧内的“栈基址寄存器值”的内容仍然是父进程的内核栈的地址。因此我们需要重写这个值,让它指向新的内核栈中的对应地址,这样才是正确的。

重写的方法不难,但是有点绕口:

计算子线程栈帧中某个位置A栈基址寄存器值B相对于父线程的栈底的偏移量delta,然后使用子线程的栈底的地址C减去delta,得到子线程的该栈帧中的栈基址寄存器值D,并将D填写到位置A中。

然后,将D赋值给A,重复上述过程,直到子线程中的所有的栈基址寄存器值被重写。

最后,把子线程的fork()栈帧中的栈指针进行重写,子线程的内核栈就处理完成了。剩余的步骤就和普通的fork没有区别了。

重写的部分,比较拗口,因此在这里放对应的代码,帮助理解:

代码的对应链接在这里:https://github.com/fslongjin/DragonOS/blob/aa7dc4daa5e7f1cc165a9985773e2d2cb23a7281/kernel/process/process.c#L1030

代码语言:javascript复制
/**
 * @brief 重写内核栈中的rbp地址
 *
 * @param new_regs 子进程的reg
 * @param new_pcb 子进程的pcb
 * @return int
 */
static int process_rewrite_rbp(struct pt_regs *new_regs, struct process_control_block *new_pcb)
{

    uint64_t new_top = ((uint64_t)new_pcb)   STACK_SIZE;
    uint64_t old_top = (uint64_t)(current_pcb)   STACK_SIZE;

    uint64_t *rbp = &new_regs->rbp;
    uint64_t *tmp = rbp;

    // 超出内核栈范围
    if ((uint64_t)*rbp >= old_top || (uint64_t)*rbp < (old_top - STACK_SIZE))
        return 0;

    while (1)
    {
        // 计算delta
        uint64_t delta = old_top - *rbp;
        // 计算新的rbp值
        uint64_t newVal = new_top - delta;

        // 新的值不合法
        if (unlikely((uint64_t)newVal >= new_top || (uint64_t)newVal < (new_top - STACK_SIZE)))
            break;
        // 将新的值写入对应位置
        *rbp = newVal;
        // 跳转栈帧
        rbp = (uint64_t *)*rbp;
    }

    // 设置内核态fork返回到enter_syscall_int()函数内的时候,rsp寄存器的值
    new_regs->rsp = new_top - (old_top - new_regs->rsp);
    return 0;
}

小结

小结一下,内核线程由于其在fork返回之后,仍然使用内核栈,而父子线程的内核栈的地址不同,导致拷贝栈帧后,需要重写子进程内核栈中每个栈帧内保存的栈基址寄存器值,使其能够正常运行。

用户进程/内核进程的fork不需要这样操作的原因则是,他们在fork返回后,内核栈是空的。并且,平时运行的时候,具有独立的用户地址空间,运行时的用户栈都被映射到了相同的虚拟地址处,因此不需要重写也能正常运行。

欢迎加入DragonOS的开发

我发起了DragonOS操作系统项目,目前还处于起步阶段,欢迎感兴趣的朋友们加入!

项目官网:http://DragonOS.org

GitHub地址:https://github.com/fslongjin/DragonOS

转载请注明来源https://longjin666.cn/?p=1509

0 人点赞