结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
本次的博客的主要有以下内容:
- 分析execeve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断, 结合上下文切换和继承上下文切换分许linux系统的一般执行过程
一.execve系统调用接口函数简介
系统调用execve系统调用接口函数将命令行参数和环境变量传递给一个可执行程序的面函数. execve系统调用接口函数原型如下:
int execve(const char *filename, char *const argv[],char *constenvp[]);
filename为可执行文件的文件名, argv是以NULL结尾的命令行参数数组, envp同样是以NULL结尾的环境变量数组, 我们可以通过man execve
命令来查看其说明:
编程使用的exec系列库函数都是execve系统调用接口函数的封装接口
二.execve一个简单的例子
我们从上课的一个简化的shell程序入手
#include <stdio.h>
#include <stdlib.h>
#include <unistd.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 */
execlp("/bin/ls", "ls", NULL);
}else{
/* parent process */
/* parent will wait for the child to complete*/wait(NULL);
printf("Child Complete!");exit(0);
}
}
上面代码简化了shell程序执行ls
命令的过程. 首先fork有一个子进程, pid为0的分支是将来的子进程要执行的, 在子进程里调用execlp来加载可执行程序ls
那么在程序执行过程中命令行参数和环境变量是如何保存的?
当fork一个子进程时, 会生成子进程的进程描述符, 内核堆栈和用户堆栈等, 子进程时通过复制父进程的大部分内容来创建的.子进程通过execlp加载可执行程序时按照如图所示的结构重新布局用户态堆栈, 可以看到用户堆栈的栈顶就是main函数调用堆栈框架, 这就是程序的main函数起点的执行环境.
值得注意的是, 在调用execve系统调用时, 当前的执行环境是从父进程复制过来的, execve系统调用加载完新的可执行程序之后已经覆盖了原来的父进程的上下文. execve系统调用在内核中帮我们重新布局了新的用户态执行环境.
三. fork系统调用简介
同样的首先来看一段简单的代码:
int main(int argc, char * argv[])
{
int pid;
/* fork another process */pid = fork();
if (pid < 0){
/* error occurred */}
else if (pid == 0){
/* child process */
}else{
/* parent process */}
}
}
库函数fork是⽤户态创建⼀个⼦进程的系统调⽤API接⼝。对于判断fork函数的返回值,初学者可能会很迷惑,因为fork在正常执⾏后,if条件判断中除了if (pid < 0)异常处理没被执⾏,else if (pid == 0)和else两段代码都被执⾏了,这看起来确实匪夷所思。
实际上fork系统调⽤把当前进程⼜复制了⼀个⼦进程,也就⼀个进程变成了两个进程,两个进程执⾏相同的代码,只是fork系统调⽤在⽗进程和⼦进程中的返回值不同。可是从Shell终端输出信息看两个进程是混合在⼀起的,会让⼈误以为if语句的执⾏产⽣了错误。其实是if语句在两个进程中各执⾏了⼀次,由于判断条件不同,输出的信息也就不同。⽗进程没有打破if else的条件分⽀的结构,在⼦进程⾥⾯也没有打破这个结构,只是在Shell命令⾏下好像两个都输出了,好像打破了条件分⽀结构,实际上背后是两个进程。fork之后,⽗⼦进程的执⾏顺序和调度算法密切相关,多次执⾏有时可以看到⽗⼦进程的执⾏顺序并不是确定的。
通过这⼀段fork代码程序,我们可以在⽤户态创建⼀个⼦进程,就是调⽤系统调⽤fork,只要像前述深⼊理解系统调⽤的⽅法来追踪这个fork系统调⽤,就能进⼀步分析和理解进程创建的过程。
四. fork系统调用的执行过程
对于普通的系统调用, 首先会陷⼊内核态,从⽤户态堆栈转换到内核态堆栈,然后把相应的CPU关键的现场栈顶寄存器、指令指针寄存器、标志寄存器等保存到内核堆栈,保存现场。系统调⽤⼊⼝的汇编代码还会通过系统调⽤号执⾏系统调⽤内核处理函数,最后恢复现场和系统调⽤返回将CPU关键现场栈顶寄存器、指令指针寄存器、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到⽤户态int $0x80或syscall指令之后的下⼀条指令的位置继续执⾏
fork也是⼀个系统调⽤,和前述⼀般的系统调⽤执⾏过程⼤致是⼀样的。尤其从⽗进程的⻆度来看,fork的执⾏过程与前述描述完全⼀致,但问题是:fork系统调⽤创建了⼀个⼦进程,⼦进程复制了⽗进程中所有的进程信息,包括内核堆栈、进程描述符等,⼦进程作为⼀个独⽴的进程也会被调度,当⼦进程获得CPU开始运⾏时,它是从哪⾥开始运⾏的呢?从⽤户态空间来看,就是fork系统调⽤的下⼀条指令。但fork系统调⽤在⼦进程当中也是返回的,也就是说fork系统调⽤在内核⾥⾯变成了⽗⼦两个进程,⽗进程正常fork系统调⽤返回到⽤户态,fork出来的⼦进程也要从内核⾥返回到⽤户态。
总结来说, 进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列,fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂
五. execve与fork的特殊之处
-
fork
正常的一个系统调用都是陷入内核态, 再返回用户态, 然后继续执行系统调用后的下一跳指令. fork和其他系统调用的不同之处在于它在陷入内核态之后有两次返回, 第一次但会到原来的父进程的位置继续向下执行, 这和其他的系统调用是一样的, 在子进程中fork也返回一次, 会返回一个特定的点--ret_from_fork, 通过内核构造的堆栈黄精, 它可以正常系统调用返回到用户态, 所以它稍微特殊一点。
-
execve
当前的可执行程序在执行, 执行到execve系统调用时陷入内核态, 在内核里面用do_execve加载可执行文件, 把当前进程的可执行程序给覆盖掉. 当execve系统调用返回时, 返回的已经不是原来的那个可执行程序了, 而是新的可执行程序. execve返回的是新的课执行程序的起点.
六.Linux系统的一般执行过程
中断和中断返回有中断上下⽂的切换,CPU和内核代码中断处理程序⼊⼝的汇编代码结合起来完成中断上下⽂的切换。进程调度过程中有进程上下⽂的切换,⽽进程上下⽂的切换完全由内核完成,具体包括:从⼀个进程的地址空间切换到另⼀个进程的地址空间;从⼀个进程的内核堆栈切换到另⼀个进程的内核堆栈;还有进程的CPU上下⽂的切换。
中断上下⽂和进程上下⽂的⼀个关键区别是堆栈切换的⽅法。中断是由CPU实现的,所以中断上下⽂切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下⽂切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利⽤call/ret指令实现的。一个完整的linux系统运行过程,可描述如下:
1 正在运⾏的⽤户态进程X发⽣中断时(包括异常、系统调⽤等),CPU完成load cs:rip(entry of a specifific ISR),即跳转到中断处理程序⼊⼝。然后进入到中断上下文切换的步骤:
[1] swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了⼀个快照。
[2] rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现⽤户堆栈和内核堆栈的切换。
[3] save cs:rip/ss:rsp/rflflags:将当前CPU关键上下⽂压⼊进程X的内核堆栈,快速系统调⽤是由系统调⽤⼊⼝处的汇编代码实现的。
至此,完成了中断上下⽂切换,即从进程X的⽤户态到进程X的内核态。
2 中断处理过程中或中断返回前调⽤了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下⽂切换等。
3 switch_to调⽤了__switch_to_asm汇编代码做了关键的进程上下⽂切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(假定为进程Y)的内核堆栈,并完成了进程上下⽂所需的指令指针寄存器状态切换。之后开始运⾏进程Y。
4 中断上下⽂恢复,与1中断上下⽂切换相对应。注意这⾥是进程Y的中断处理过程中,⽽1中断上下⽂切换是在进程X的中断处理过程中,因为从用户态切换到内核态,会导致内核堆栈从进程X切换到进程Y了。
5 为了对应起⻅中断上下⽂恢复的最后⼀步,单独拿出来iret - pop cs:rip/ss:rsp/rflflags,从Y进程的内核堆栈中弹出1中对应的压栈内容。此时完成了中断上下⽂的切换,即从进程Y的内核态返回到进程Y的⽤户态。这里因sysret和iret的不同而略有差异。
6 继续运⾏⽤户态进程Y。