【Linux进程切换】源码理解Linux进程切换一般过程

Linux进程切换一般过程

  • fork系统调用过程
    • 和普通系统系统调用对比
    • _do_fork系统调用流程概述
    • fork系统调用实验
  • execve系统调用
    • 和普通系统系统调用对比
  • 进程切换
    • 进程切换时机
    • 进程上下⽂
    • 进程切换过过程
    • 核心代码
  • 中断上下文和进程上下文对比
  • Linux系统的一般执行过程(含中断与进程切换)

实验环境

OS Linux cj-virtual-machine 5.3.0-51-generic
虚拟机 QEMU
内核版本 5.3.4
调式方法 GDB

PS:调试环境安装请看上一篇博客汇编级理解Linux系统调用

fork系统调用过程

【Linux进程切换】源码理解Linux进程切换一般过程_第1张图片

和普通系统系统调用对比

正常的⼀个系统调⽤都是陷⼊内核态,再返回到⽤户态,然后继续执⾏系统调⽤后的下⼀条指令。

fork和其他系统调⽤不同之处是它在陷⼊内核态之后有两次返回,第⼀次返回到原来的⽗进程的位置继续向下执⾏,这和其他的系统调⽤是⼀样的。

在⼦进程中fork也返回了⼀次,会返回到⼀个特 定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调⽤返回到⽤户态

_do_fork系统调用流程概述

源码在/linux/kernel/fork.c目录下,由于代码太多,只是大概了解

long _do_fork(struct kernel_clone_args *args) {    
	.....
    //复制进程描述符和执⾏时所需的其他数据结构       
    p = copy_process(NULL, trace, NUMA_NO_NODE, args);    
    ......
    wake_up_new_task(p);//将⼦进程添加到就绪队列    
    .......
    return nr;//返回⼦进程pid(⽗进程中fork返回值为⼦进程的pid)
 }
  • _do_fork
    • copy_process 复制进程描述符和执⾏时所需的其他数据结构
      • dup_task_struct 复制进程描述符task_struct、创建内核堆栈等
      • copy_thread_tls 初始化⼦进程内核栈和thread
    • wake_up_new_task 将⼦进程添加到就绪队列
  • 系统调用返回
    【Linux进程切换】源码理解Linux进程切换一般过程_第2张图片
    总的来说,进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如图所示复制进程描述符及相关进程 资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程 放⼊就绪队列,fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏。

普通系统调用和fork子进程内核堆栈对比
【Linux进程切换】源码理解Linux进程切换一般过程_第3张图片【Linux进程切换】源码理解Linux进程切换一般过程_第4张图片
fork系统调用子进程的内核堆栈和普通系统调用堆栈相比多了一个,inactive_task_frame,该结构主要用于进程切换过程。

fork系统调用实验

  1. 编写程序,使用fork() 函数

    #include 
    #include 
    #include 
    #include 
    #include 
    
    int main(int argc, char* argv[])
    {
        int pid;
    
        pid = fork();
        if(pid<0)
        {
          //error
           fprintf(stderr,"For Failed");
           exit(-1);
        }
        else if(pid==0)
        {
           //child
           printf("this is child process \n");
        }
        else
        {
           //parent
           printf("this is Parent process \n");
           wait(NULL);
           printf("child complete \n");
        }
        return 0;
    }
    
    
  2. 编译后执行

    gcc -o fork fork.c -static
    ./fork
    在这里插入图片描述

  3. 反汇编objdump -S fork -o fork.s,查看fock.s中使用的系统调用为56号,查/linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl表得到内核函数__x64_sys_clone
    /linux/kernel/fork.c中,发现,__x64_sys_clone是调用了内核中的_do_fork函数。

    在这里插入图片描述
    【Linux进程切换】源码理解Linux进程切换一般过程_第5张图片

  4. 开启虚拟机,在__x64_sys_clone_do_forkcpoy_processdup_task_structcopy_thread_tls下断点,shell下运行fork可执行文件,查看此时函数栈
    【Linux进程切换】源码理解Linux进程切换一般过程_第6张图片
    在这里插入图片描述
    【Linux进程切换】源码理解Linux进程切换一般过程_第7张图片
    【Linux进程切换】源码理解Linux进程切换一般过程_第8张图片
    【Linux进程切换】源码理解Linux进程切换一般过程_第9张图片
    【Linux进程切换】源码理解Linux进程切换一般过程_第10张图片
    在这里插入图片描述

  5. 结果
    在这里插入图片描述

execve系统调用

图示
【Linux进程切换】源码理解Linux进程切换一般过程_第11张图片

和普通系统系统调用对比

当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉。当execve系统调⽤返回 时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。

execve返回的是新的可执⾏程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需 要ld链接好动态链接库再从main函数开始执⾏。
Linux系统⼀般会提供了execl、execlp、execle、execv、execvp和execve等6个⽤以加载执⾏ ⼀个可执⾏⽂件的库函数,这些库函数统称为exec函数,差异在于对命令⾏参数和环境变量参数 的传递⽅式不同。

exec函数都是通过execve系统调⽤进⼊内核,对应的系统调⽤内核处理函数为sys_execve__x64_sys_execve,它们都是通过调⽤do_execve来具体执⾏加载可执⾏⽂件的 ⼯作。

整体的调⽤的递进关系为:

  • sys_execve()或__x64_sys_execve -> // 内核处理函数
  • do_execve() –> // 系统调用函数
  • do_execveat_common() -> // 系统调用函数
  • __do_execve_file ->
  • exec_binprm()-> // 根据读入文件头部,寻找该文件的处理函数
  • search_binary_handler() ->
  • load_elf_binary() -> // 加载elf文件到内存中
  • start_thread() // 开始新进程

进程切换

进程切换时机

  • ⽤户进程上下⽂中主动调⽤特定的系统调⽤进⼊中断上下⽂,系统调⽤返回 ⽤户态之前进⾏进程调度。
  • 内核线程或可中断的中断处理程序,执⾏过程中发⽣中断进⼊中断上下⽂, 在中断返回前进⾏进程调度。
  • 内核线程主动调⽤schedule函数进⾏进程调度

进程上下⽂

  • ⽤户地址空间:包括程序代码、数据、⽤户堆栈等。 (CR3寄存器代表进程⻚⽬录表,即地址空间、数据)
  • 控制信息:进程描述符(thread)、内核堆栈(sp寄存器)等。
  • 进程的CPU上下⽂,相关寄存器的值(指令指针寄存器ip代表进程的CPU上下⽂)。

进程切换过过程

  • 切换⻚全局⽬录(CR3)以安装⼀个新的地址空间,这样不同进程的虚拟地 址如0x8048400(32位x86)就会经过不同的⻚表转换为不同的物理地址。
  • 切换内核态堆栈和进程的CPU上下⽂,因为进程的CPU上下⽂提供了内核执 ⾏新进程所需要的所有信息,包含所有CPU寄存器状态。

核心代码

((last) = __switch_to_asm((prev), (next)));
      ENTRY(__switch_to_asm)     
      pushq    %rbp     
      pushq    %rbx     
      pushq    %r12     
      pushq    %r13     
      pushq    %r14     
      pushq    %r15     
      /* switch stack */     
      movq    %rsp, TASK_threadsp(%rdi)     
      movq    TASK_threadsp(%rsi), %rsp   
      popq    %r15     
      popq    %r14     
      popq    %r13     
      popq    %r12     
      popq    %rbx     
      popq    %rbp     
      jmp    __switch_to END(__switch_to)

__switch_to_asm是在C代码中调⽤的,也就是使⽤call指令,⽽这段汇编的结尾是jmp __switch_to, __switch_to函数是C代码最后有个return,也就是ret指令。将__switch_to_asm和__switch_to结合起来,正好是call指令和ret指令的配对出现。

call指令压栈RIP寄存器到进程切换前的prev进程内核堆栈;⽽ret指令出栈存⼊RIP 寄存器的是进程切换之后的next进程的内核堆栈栈顶数据。

由此完成了进程的切换。

中断上下文和进程上下文对比

中断上下文的切换

中断是由CPU实现的,所以中断上下⽂切换过程中最关键的栈顶寄存器sp和指令指针寄存器 ip 是由CPU协助完成的。

进程上下文的切换

进程切换是由内核实现的(且一般情况下,进程上下文切换嵌套在中断中),所以进程上下⽂切换过程最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针 寄存器ip的切换是在内核堆栈切换的基础上巧妙利⽤call/ret指令实现的。

Linux系统的一般执行过程(含中断与进程切换)

一般函数调用框架
【Linux进程切换】源码理解Linux进程切换一般过程_第12张图片
(1)正在运⾏的⽤户态进程X。

(2)发⽣中断(包括异常、系统调⽤等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序⼊⼝。

(3)中断上下⽂切换,具体包括如下⼏点:

  • swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了⼀个快照。
  • rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现⽤户堆栈和内核堆栈的切换。
  • save cs:rip/ss:rsp/rflags:将当前CPU关键上下⽂压⼊进程X的内核堆栈,快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现的。

此时完成了中断上下⽂切换,即从进程X的⽤户态到进程X的内核态。

(4)中断处理过程中或中断返回前调⽤了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下⽂切换等。

(5)switch_to调⽤了__switch_to_asm汇编代码做了关键的进程上下⽂切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下⽂所需的指令指针寄存器状态切换。之后开始运⾏进程Y(这⾥进程Y曾经通过以上步骤被切换出去,因此可以从switch_to下⼀⾏代码继续执⾏)。

(6)中断上下⽂恢复,与(3)中断上下⽂切换相对应。注意这⾥是进程Y的中断处理过程中,⽽(3)中断上下⽂切换是在进程X的中断处理过程中,因为内核堆栈从进程X 切换到进程Y了。

(7)为了对应起⻅,中断上下⽂恢复的最后⼀步单独拿出来(6的最后⼀步即是7)iret - pop cs:rip/ss:rsp/rflags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完 成了中断上下⽂的切换,即从进程Y的内核态返回到进程Y的⽤户态。注意快速系统调⽤返回sysret与iret的处理略有不同。

(8)继续运⾏⽤户态进程Y。

你可能感兴趣的:(Linux)