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

一、实验要求

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

  • 以fork和execve系统调用为例分析中断上下文的切换

  • 分析execve系统调用中断上下文的特殊之处

  • 分析fork子进程启动执行时进程上下文的特殊之处

  • 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

二、实验环境

  • Ubuntu 18.04
  • VMware workstation Pro 14

三、实验过程

  1、fork系统调用概念

fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。调用fork之后,数据、堆、栈有两份,代码仍然为一份但是这个代码段成为两个进程的共享代码段都从fork函数中返回。fork给父进程返回子进程pid,给其拷贝出来的子进程返回0,这也是他的特点之一,一次调用,两次返回,所以与一般的系统调用处理流程也必定不同。

  2、验证fork的效果

fork系统调用用来创建子进程。通过一个简单的程序验证一下fork的行为:

对以上代码进行编译并执行后,可以看到两个分支下的语句都被打印了出来。

这是因为两条语句是由父子两个进程分别执行的。这正是fork系统调用的特殊之处:一处调用,两处返回。下面我们结合源码,对该系统调用的实现进行分析。

  3、查看fork的系统调用号

在查看fork的源码之前我们先看一下fork系统调用号和入口地址等信息,以防不时之需。

 

 

可以看到fork库函数对应的系统调用号为57,入口地址为___x64_sys_fork。

 

 

  4、分析fork.c源码

fork的具体实现位于kernel/fork.c源文件中,还有与之相关的vfork,clone系统调用也一同定义在该文件中。先来看一下其对应的系统调用定义函数的实现:

 

可以看到,fork的具体工作交由__do_fork完成。截取关键代码如下:

 

 

 

 可以观察到,__do_fork函数主要完成了调用copy_process函数来实现复制父进程、获得pid、调用wake_up_new_task将子进程加入就绪队列等待调度执行等操作。在Linux中,除了0号进程由手工创建外,其他进程都是通过复制已有进程创建而来,而这正是fork的主要工作,具体的任务交由copy_process完成。copy_process函数又是相当繁杂的一个函数(500多行代码),我们同样只截取关键代码,很多异常检查代码被省略。

 

 

 

 

 

 

 

 

 

 

 

 

从以上代码,我们可以观察到,copy_process函数主要实现了调用dup_task_struct函数去复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源、调⽤copy_thread_tls初始化子进程内核栈、设置子进程pid等操作。

其中copy_thread_tls所做的工作是关键。系统执行fork系统调用之后,会由内核态返回两次:一次返回到父进程,这与一般的系统调用返回流程别无二致;而另一次则返回到子进程,为了实现这一点,就需要为子进程构造出合适的执行上下文,也就是初始化其内核栈和进程描述符的thread字段。这正是copy_thread_tls的任务。

 

 

 

 

从以上代码,我们可以观察到,struct task_struct thread字段保存了进程的部分硬件上下文信息,包括一些关键的CPU寄存器,如sp等。我们可以看到copy_thread_tls函数中,将thread.sp字段设置成了fork_frame起始地址,这将是子进程内核栈的栈顶位置。

对于子进程的内核栈,使用fork_frame进行填充,其定义在arch/x86/include/asm/switch_to.h头文件中:

 

 

 

 

 

 fork_frame在系统调用寄存器结构体pt_regs的基础上,增加了inactive_task_frame结构体,我们再来看一下inactive_task_frame的结构定义:

 

 

 其中,ret_addr指定了子进程返回时的执行地址,其被设置为ret_from_fork。这样,初始化完成的子进程内核栈的布局便如下图所示。子进程被加入就绪队列后,就可以正常地参与到进程的调度切换过程中了。整个内核堆栈的内存情况如下图所示:

 

   5、execve系统调用概念

execve系统调用于为进程载入执行镜像。前述的fork主要用于创建新进程,但并没有为进程指定新任务,而这正是exec的功能。所以fork一般于execve相互配合启动一个新程序。用户态函数库提供了exec函数族来通过execve系统调用加载执行一个可执行文件,它们的差异在于对命令行参数和环境变量参数的传递方式不同。64位下,execve系统调用号为56,函数入口为__x64_sys_execve。

该系统调用的实现位于fs/exec.c中:

 

 

 

 

 

 可以观察到,以上代码调用了do_execve,后者调用了do_execveat_common,最终的工作由__do_execve_file完成。截取关键代码:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 __do_execve_file函数的主要功能是从文件中载入ELF可执行文件并执行。其中exec_binprm实际执行了文件。后者的关键是调用search_binary_handler,这是真正替换进程镜像的地方。

 

 

 

 

 

 

 

execve系统调用的过程总结如下:

  1. execve系统调用陷入内核,并传入命令行参数和shell上下文环境

  2. execve陷入内核的第一个函数:do_execve,该函数封装命令行参数和shell上下文

  3. do_execve调用do_execveat_common,后者进一步调用__do_execve_file,打开ELF文件并把所有的信息一股脑的装入linux_binprm结构体

  4. __do_execve_file中调用search_binary_handler,寻找解析ELF文件的函数

  5. search_binary_handler找到ELF文件解析函数load_elf_binary

  6. load_elf_binary解析ELF文件,把ELF文件装入内存,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈),修改进程的数据段代码段

  7. load_elf_binary调用start_thread修改进程内核堆栈(特别是内核堆栈的ip指针)

  8. 进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境

 

 

四、实验总结

  1、对比fork、execve和普通的系统调用

 系统调用可以视为一种特殊的中断,老的32位linux就是采用int 0x80中断指令进入内核,因此自然涉及中断上下文,也就是切换到用户内核栈,同时保存相关的寄存器使得中断结束后能够正常返回。

  而fork系统调用特殊之处在于他创建了一个新的进程,且有两次返回。对于fork的父进程来说,fork系统调用和普通的系统调用并无两样。但是对fork子进程来说,需要设置子进程的进程上下文环境,这样子进程才能从fork系统调用后返回。

  而对于execve而言,由于execve使得新加载可执⾏程序已经覆盖了原来⽗进程的上下⽂环境,而原来的中断上下文就是保存的是原来的、被覆盖的进程的上下文,因此需要修改原来的中断上下文,使得系统调用返回后能够指向现在加载的这个可执行程序的入口,比如main函数的地址。

  2、以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程

    1)中断上下文

为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行

跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处于用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括CPU寄存器、内核堆栈、硬件中断参数等。

对同一个CPU来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。同样道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。

中断是由软硬件触发中断,查找IDT表内相应中断门,SAVE_ALL宏在栈中保存中断处理程序可能会使用的所有CPU寄存器(eflags、cs、eip、ss、esp已由硬件自动保存),并将栈顶地址保存到eax寄存器中来形成。然后中断处理程序调用do_IRQ(pt_regs*)函数,查找irq_desc数组来执行具体的中断逻辑。

    2)进程上下文

进程则是资源拥有的基本单位,进程切换是由内核实现的,所以进程上下⽂切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利⽤call/ret指令实现的。 切换进程需要在

不同的进程间切换。但⼀般进程上下⽂切换是嵌套到中断上下⽂切换中的,⽐如前述系统调⽤作为⼀种中断先陷⼊内核,即发⽣中断保存现场和系统调⽤处理过程。其中调⽤了schedule函数发⽣进程上下⽂切换,当系统调⽤返回到⽤户态时会恢复现场,⾄此完成了保存现场和恢复现场,即完成了中断上下⽂切换。 

进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。因此进程的上下文切换就比系统调用时多了一步:在保存当前进程的内核状态和CPU寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。

    3)分析linux系统的一般执行过程

首先是正在运行的用户态进程发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序入口。

中断上下文切换,具体包括如下几点:
 1.swapgs指令保存现场即保存当前CPU寄存器状态。
 2.rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。
 3.save cs:rip/ss:rsp/rflags:将当前CPU关键上下文压入中断进程的内核堆栈,快速系统调用是由系统调用入口处的汇编代码实现的。
 此时完成了中断上下文切换,即从中断进程的用户态到内核态。

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

switch_to调用了__switch_to_asm汇编代码做了关键的进程上下文切换。将当前进程的内核堆栈切换到进程调度算法选出来的next进程的内核堆栈,

并完成了进程上下文所需的指令指针寄存器状态切换。之后开始运行切换进程。中断上下文恢复,与中断上下文切换相对应。

 

posted @ 2020-06-12 16:58  Benjamin&Annie  阅读(258)  评论(0编辑  收藏  举报