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


    我们在学习操作系统课程的时候,应该都学过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没有区别了。

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

    代码的对应链接在这里:DragonOS/process.c at aa7dc4daa5e7f1cc165a9985773e2d2cb23a7281 · fslongjin/DragonOS · GitHub

    1. /**
    2. * @brief 重写内核栈中的rbp地址
    3. *
    4. * @param new_regs 子进程的reg
    5. * @param new_pcb 子进程的pcb
    6. * @return int
    7. */
    8. static int process_rewrite_rbp(struct pt_regs *new_regs, struct process_control_block *new_pcb)
    9. {
    10. uint64_t new_top = ((uint64_t)new_pcb) + STACK_SIZE;
    11. uint64_t old_top = (uint64_t)(current_pcb) + STACK_SIZE;
    12. uint64_t *rbp = &new_regs->rbp;
    13. uint64_t *tmp = rbp;
    14. // 超出内核栈范围
    15. if ((uint64_t)*rbp >= old_top || (uint64_t)*rbp < (old_top - STACK_SIZE))
    16. return 0;
    17. while (1)
    18. {
    19. // 计算delta
    20. uint64_t delta = old_top - *rbp;
    21. // 计算新的rbp值
    22. uint64_t newVal = new_top - delta;
    23. // 新的值不合法
    24. if (unlikely((uint64_t)newVal >= new_top || (uint64_t)newVal < (new_top - STACK_SIZE)))
    25. break;
    26. // 将新的值写入对应位置
    27. *rbp = newVal;
    28. // 跳转栈帧
    29. rbp = (uint64_t *)*rbp;
    30. }
    31. // 设置内核态fork返回到enter_syscall_int()函数内的时候,rsp寄存器的值
    32. new_regs->rsp = new_top - (old_top - new_regs->rsp);
    33. return 0;
    34. }

    小结

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

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

    欢迎加入DragonOS的开发

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

    项目官网:http://DragonOS.orgicon-default.png?t=M666http://dragonos.org/

    GitHub地址:GitHub - fslongjin/DragonOS: 一个64位的操作系统。An x86_64 operating system.一个64位的操作系统。An x86_64 operating system. Contribute to fslongjin/DragonOS development by creating an account on GitHub.https://github.com/fslongjin/DragonOS

    开发交流群:115763565

    转载请注明来源:内核线程的fork与普通的fork的区别 | | 龙进的博客

    欢迎关注我的公众号“灯珑”,让我们一起了解更多的事物~

  • 相关阅读:
    Java练习题第十九期:另类加法
    自从用上这几款软件,才发现原来苹果电脑可以这么6!
    Python 队里 list的常规操作 pop,insert,remove,index
    LM339模块电路故障查询
    未来已来:探索IT行业的革新与大模型技术的突破
    论文阅读_广义加性模型_GAMs
    github:配置ssh密钥
    编码踩坑——多线程可能带来意想不到的OOM
    职场题:有一件特别紧急的事,群众要办理,且联系不上领导,你怎么办?(1)
    计算机网络——网络层(路由选择协议、路由器工作原理、IP多播、虚拟专用网和网络地址转换)
  • 原文地址:https://blog.csdn.net/qq_34026204/article/details/126120046