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

Posted hambug

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程相关的知识,希望对你有一定的参考价值。

实验要求:

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

 

fork系统调用分析:

fork函数简介:

库函数fork是⽤户态创建⼀个⼦进程的系统调⽤API接⼝。fork函数将运行着的程序分成2个(几乎)完全一样的进程,每个进程都启动一个从代码的同一位置开始执行的线程。这两个进程中的线程继续执行,就像是两个用户同时启动了该应用程序的两个副本。fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。

分析fork系统调用和之前的其他的系统调用没有什么太大的区别,流程大抵如此:

⽤户态int $0x80或syscall指令触发系统调⽤ ---> int $0x80指令触发entry_INT80_32,syscall指令触发entry_SYSCALL_64并sysret --->iret返回系统调⽤

1.首先在系统调用表中查找fork对应的系统调用号

因为笔者的Linux系统为64位系统,为了方便体现,这里只给出64位系统下的分析:

 

 可以看到这里的三个系统调用都和fork函数有关,最重要的应当是57号系统调用__x64_sys_fork

 课上老师已经给出了该系统调用的源代码:

 

 可见该系统调用也是调用了 _do_fork函数,然后再去源代码中寻找 _do_fork函数的原型。

在kernel/fork.c中能找到函数的原型,函数的源代码比较复杂,有70多行,课上老师也给出了简化的代码:

1 long _do_fork(struct kernel_clone_args *args) 
2 {     //复制进程描述符和执⾏时所需的其他数据结构      
3   p = copy_process(NULL, trace, NUMA_NO_NODE, args);     
4   wake_up_new_task(p);//将⼦进程添加到就绪队列   
5   return nr;//返回⼦进程pid(⽗进程中fork返回值为⼦进程的pid)
6  }

这里的代码比较清晰明了,这个函数主要做了三件事:

一.调用copy_process函数,复制父进程的进程描述符和执行时所需的其他数据结构到子进程去,也是本次系统调用分析的重点

二.调用wake_up_new_task函数,将子进程添加到就绪队列当中去

三.返回子进程PID

可以看出其中的copy_process函数是系统调用作用的重点,继续在源代码中查找它的原型:

同样给出课上老师给出的简化版:

1 static __latent_entropy struct task_struct *copy_process( struct pid *pid,  int trace, int node, struct kernel_clone_args *args) 
2 {     
//复制进程描述符task_struct、创建内核堆栈等 3 p = dup_task_struct(current, node); 4 /* copy all the process information */ 5 shm_init_task(p); … // 初始化⼦进程内核栈和thread 6 retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p, args->tls); … 7 return p;//返回被创建的⼦进程描述符指针 8 }

可以看出这里比较重要的两个函数,一个用于复制父进程的进程描述符和创建子进程内核堆栈,一个用于初始化子进程内核栈和进程。其中最关键的就是 dup_task_struct复制当前进程(⽗进程)描述 符task_struct和copy_thread_tls初始化⼦进程内核栈。

课上同样也讲过这俩个函数的具体实现,总结下来,fork系统调用的大致流程如下:

总结来说,进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程 资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程 放⼊就绪队列,fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏

 

 2.实验验证:

上面已经介绍过整个系统调用的工作流程,现在开始进行实验验证:

首先先编写一个包含fork系统调用的c程序,将其编译,并展示运行效果:

这里张贴出相关代码:

 1 a#include<stdio.h>
 2 #include<unistd.h>
 3 int main()
 4 {
 5     pid_t pid;
 6     int count = 0;
 7     pid = fork();    //fork一个进程
 8     if(pid == 0) {               //pid为0,
 9         printf("this is child process, pid is %d\\n",getpid());//getpid返回的是当前进程的PID
10         count+=2;
11         printf("count = %d\\n",count);
12     } else if(pid > 0) {
13         printf("this is father process, pid is %d\\n",getpid());
14         count++;
15         printf("count = %d\\n",count);
16     } else {
17         fprintf(stderr,"ERROR:fork() failed!\\n");
18     }
19     return 0;
20 }

这个程序的功能很简单,就是fork出一个子进程,然后在子进程中运行不同的程序:

 

然后将编写好的程序复制进根目录系统的home目录下,并对其进行封装,然后在虚拟机上运行该程序:

 

 

 可见在QEMU虚拟机上该程序成功运行,然后再进行调试,对上文所述函数,依次打上断点:

 

 进行调试,验证实验效果:

 

 由此可见,fork系统调用的工作流程和上文分析的一致,得证。

 

 分析execve系统调用:

execve系统调用简介:

execve系统调⽤接⼝函数的函数原型如下:

int execve(const char *filename, char *const argv[],char *const envp[]); 

filename为可执⾏⽂件的名字,argv是以NULL结尾的命令⾏参数数 组,envp同样是以NULL结尾的环境变量数组(使⽤命令man execve,可查看其说明)

 

evecve工作流程:

 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() -> start_thread()。

 

 上下文环境切换流程:

先看一下再进行evecve系统调用时有哪些上下文环境:

 

  在布局⼀个新的⽤户态堆栈时,实际上是把命令⾏参数内容和环境变量的内容通过指针的⽅ 式传到系统调⽤内核处理函数,再创建⼀个新的⽤户态堆栈时会把这些char *argcv[]和char *envp[]等复制到⽤户态堆栈中,来初始化这个新的可执⾏程序的执⾏上下⽂环境。所以新 的程序可以从main函数开始把对应的参数接收过来,然后执⾏。 值得注意的是,在调⽤execve系统调⽤时,当前的执⾏环境是从⽗进程复制过来的, execve系统调⽤加载完新的可执⾏程序之后已经覆盖了原来⽗进程的上下⽂环境。execve 系统调⽤在内核中帮我们重新布局了新的⽤户态执⾏环境。 执⾏readelf -h可以查看ELF可执⾏⽂件⾸部信息,如下所示程序⼊⼝点Entry point address:0x804887f。如果是静态链接程序在execve系统调⽤加载完成后,堆栈上的返回地 址会修改为程序⼊⼝点的地址。当系统调⽤从内核态返回时,会从该地址0x804887f继续执 ⾏。

 evecve的特别之处:

当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内 核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉。当execve系统调⽤返回 时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。execve返回的是新的可执⾏ 程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需 要ld链接好动态链接库再从main函数开始执⾏。

 

中断上下文切换和进程上下文切换对比:

系统调用和中断的机制类似,可以看作一种特殊的中断。因为前面大量分析了各类系统调用,所以这里使用系统调用中上下文切换来代替中断上下文切换。

中断上下文切换:

中断上下⽂代表当前进程执⾏,所以中断上下⽂中的get_current可获 取⼀个指向当前进程描述符的指针,即指向被中断进程,相应的中断 上下⽂切换的信息存储于该进程的内核堆栈中。中断有多种类型,⽐ 如有不可屏蔽中断、可屏蔽中断、异常、陷阱(系统调⽤)等。
内核线程以进程上下⽂的形式运⾏在内核态,本质上还是进程,但它 有调⽤内核代码的权限,⽐如主动调⽤schedule()函数进⾏进程调 度。

进程上下文切换:

为了控制进程的执⾏,内核必须有能⼒挂起正在CPU上运⾏的进程,并 恢复执⾏以前挂起的某个进程。这种⾏为被称为进程切换,任务切换或 进程上下⽂切换。尽管每个进程可以拥有属于⾃⼰的地址空间,但所有 进程必须共享CPU及寄存器。因此在恢复⼀个进程执⾏之前,内核必须 确保每个寄存器装⼊了挂起进程时的值。进程恢复执⾏前必须装⼊寄存 器的⼀组数据,称为进程的CPU上下⽂。您可以将其想象成对CPU的某 时刻的状态拍了⼀张“照⽚”,“照⽚”中有CPU所有寄存器的值。同样进 程切换就是拍⼀张当前进程所有状态的⼤“照⽚”保存下来,其中就包括 进程的CPU上下⽂的⼩“照⽚”,然后将导⼊⼀张之前保存下来的其他进 程的所有状态信息恢复执⾏。

可以看出两类上下文切换,一类是一个进程内内核态和用户态的切换,而进程上下文切换往往是用户态内不同进程之间的切换。

 

以上是关于结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程的主要内容,如果未能解决你的问题,请参考以下文章

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

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

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

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

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

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