结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程

  • 以fork和execve系统调用为例分析中断上下文的切换
  • 分析execve系统调用中断上下文的特殊之处
  • 分析fork子进程启动执行时进程上下文的特殊之处
  • 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

fork分析

库函数fork能够从当前进程中创建一个子进程,其中父进程返回子进程的PID,子进程返回0。子进程得到与父进程用户级虚拟地址空间相同(但是独立)的一份拷贝,包括文本,数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。这就是意味着当父进程调用fork时候,子进程还可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大区别在于他们有着不同的PID。

接下来通过gdb调试来观察fork函数的调用关系是怎样的。

相关环境和操作详见https://www.cnblogs.com/litosty/p/12960285.html

在根文件镜像/home目录中创建callfork.c文件:

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main(int argc, char * argv[])
{
    int pid;
    /* fork another process */
    pid = fork();
    if (pid < 0) 
    { 
        /* error occurred */
        fprintf(stderr,"Fork Failed!");
        exit(-1);
    } 
    else if (pid == 0) 
    {
        /* child process */
        printf("This is Child Process!\n");
    } 
    else 
    {  
        /* parent process  */
        printf("This is Parent Process!\n");
        /* parent will wait for the child to complete*/
        wait(NULL);
        printf("Child Complete!\n");
    }
}

执行效果如下图,可以看到fork函数返回了两次,分别执行了条件语句中两条分支。

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

然后在qemu中启动系统,开始使用gdb进行调试,在以下函数处打上断点,观察其调用关系:

b __x64_sys_clone
b _do_fork
b copy_process
b dup_task_struct
b copy_thread_tls
b ret_from_fork
b wake_up_new_task

然后从__x64_sys_clone开始观察调用情况:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

可以看到__x64_sys_clone依次嵌套调用了__se_sys_clone、__do_sys_clone直到do_fork,开始真正进行fork的工作。

系统调用__x64_sys_clone的主要功能是通过_do_fork函数来完成。

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

可以看到在_do_fork中调用了copy_process(),copy_process()调用了copy_thread_tls

_do_fork函数完成的工作包括调用copy_process()复制父进程、获得pid、调用wake_up_new_task将子进程加入就绪队列等待调动等。其中copy_process函数复制父进程描述符task_struct并调用copy_thread_tls构造fork系统调用在子进程的内核堆栈。

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

子进程创建好了进程描述符、内核堆栈等,就可以通过wake_up_new_task(p)将子进程添加到就绪队列,使之有机会被调度执行,进程的创建工作就完成了,子进程就可以等待调度执行,然后子进程就可以返回到ret_from_fork。

fork的执行过程总结如下图:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

正常的一个系统调用都是陷入内核态,再返回到用户态,然后继续执行系统调用后的下一条指令。fork和其他系统调用不同之处是它在陷入内核态之后有两次返回,第一次返回到原来的父进程的位置继续向下执行,这和其他的系统调用是一样的。在子进程中fork也返回了一次,会返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态。

所以fork在切换上下文时主要是子进程的上下文比较特殊。pt_regs结构保存着进入内核前夕CPU各个寄存器的内容,这可是系统调用返回到用户空间的重要“现场”,对于刚刚出生的子进程,这些信息只能从父进程拷贝而来,也正因如此,父子进程才可以返回到用户空间的同一个地方。

子进程的pt_regs拷贝自父进程,但也要进行些“修补”。在copy_thread中,子进程在用户空间的返回值修改为0,同时修改进程用户空间的栈顶esp。

另外,task_struct结构中有一个thread成员,其为struct thread_struct类型,里面存放着进程在切换时系统空间堆栈的栈顶esp、下一条指令eip(进程再次被切换运行时,将从这里开始运行)等关键信息。在复制task_struct结构时,这些内容原封不动从父进程拷贝过来,现在子进程有自己的系统空间堆栈了,所以要适当的加以调整。copy_thread中将p->thread.esp设置成pt_regs结构的起始地址(,从调度器的角度来看,就好像这个子进程以前曾经进入内核运行过,而在内核中的任务处理完毕(因此进程系统空间堆栈恢复平衡,变成“空”堆栈)准备返回用户空间时被切换了;而p->thread.esp0则应指向系统空间堆栈的顶端,表示这个进程进入0级(内核空间)运行时,其堆栈的位置。最后,p->thread.eip被赋值为ret_from_fork,当子进程调度运行时(肯定先从系统空间运行),将从ret_from_fork处开始运行。

execve分析

execve系统调用的作用是运行另外一个指定的程序。它会把新程序加载到当前进程的内存空间内,当前的进程会被丢弃,它的堆、栈和所有的段数据都会被新进程相应的部分代替,然后会从新程序的初始化代码和 main 函数开始运行。同时,进程的 ID 将保持不变。

execve系统调用通常与 fork系统调用配合使用。从一个进程中启动另一个程序时,通常是先 fork一个子进程,然后在子进程中使用 execve变身为运行指定程序的进程。

Linux提供了exec函数族用于加载可执行文件,包括execl、execlp、execle、execv、execvp和execve等六个函数,他们的关系如下:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

这些函数的主要加载工作是通过do_execve()来完成的,内部核心调用关系是__x64_sys_execve -> do_execve() –>do_execveat_common() -> __do_execve_file -> exec_binprm()-> search_binary_handler() ->load_elf_binary() -> start_thread()。

在do_execve()中,首先获取待装载的可执行文件的相关信息,包括路径、环境变量、参数等,将这些信息拷贝到bprm缓冲区。准备好这些信息之后,调用search_binary_handler() 函数找到“代理人”来认领可执行文件,根据可执行文件的类型选择合适的load_elf_binary()函数。

load_elf_binary() 函数的功能是将可执行文件加载到内存并投入运行。在完成校验文件后,加载文件到内存并根据ELF文件中Program header table和Section header table映射到进程的地址空间,再判断是否需要动态链接,最后配置进程启动上下文环境start_thread()。

start_thread()通过设置eip指定子进程用户空间的main函数入口,通过设置esp制定用户空间堆栈的栈顶,这样当从内核空间返回到用户空间时,子进程将从可执行文件main入口开始执行,并通过栈顶esp获取main函数的参数。

execve的执行过程总结如下图:

结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程

execve的特殊之处在于当前的可执行程序在执行,执行到execve系统调用时陷入内核态,在内核里面用do_execve加载可执行文件,把当前进程的可执行程序给覆盖掉。当execve系统调用返回时,返回的不是原来的那个可执行程序,而是新的可执行程序。execve返回的是新的可执行程序执行的起点,静态链接的可执行文件也就是main函数的大致位置,动态链接的可执行文件还需要ld链接好动态链接库再从main函数开始执行。

所以execve切换中断上下文时,是在start_thread函数中将可执行文件的入口写进eip,将准备好argc以及argv之后用户空间堆栈的栈顶current->mm->start_stack写进esp,这样当从系统调用返回到子进程的用户空间中时,将从可执行文件的入口main函数开始执行,并且通过esp可以获取传递给main函数的argc和argv参数。

Linux操作系统一般执行过程

(1)正在运?的?户态进程X。

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

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

  • swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了?个快照。
  • rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调?是由系统调???处的汇编代码实现?户堆栈和内核堆栈的切换。
  • save cs:rip/ss:rsp/r?ags:将当前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/r?ags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完 成了中断上下?的切换,即从进程Y的内核态返回到进程Y的?户态。注意快速系统调?返回sysret与iret的处理略有不同。

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

参考资料

[1] https://blog.csdn.net/Always2015/article/details/45008785

[2] https://my.oschina.net/u/3857782/blog/1854570

[3] https://my.oschina.net/u/3857782/blog/1854572