结合中断上下文切换和进程上下文切换分析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函数返回了两次,分别执行了条件语句中两条分支。
然后在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开始观察调用情况:
可以看到__x64_sys_clone依次嵌套调用了__se_sys_clone、__do_sys_clone直到do_fork,开始真正进行fork的工作。
系统调用__x64_sys_clone的主要功能是通过_do_fork函数来完成。
可以看到在_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系统调用在子进程的内核堆栈。
子进程创建好了进程描述符、内核堆栈等,就可以通过wake_up_new_task(p)将子进程添加到就绪队列,使之有机会被调度执行,进程的创建工作就完成了,子进程就可以等待调度执行,然后子进程就可以返回到ret_from_fork。
fork的执行过程总结如下图:
正常的一个系统调用都是陷入内核态,再返回到用户态,然后继续执行系统调用后的下一条指令。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等六个函数,他们的关系如下:
这些函数的主要加载工作是通过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的执行过程总结如下图:
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 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。
参考资料
[1] https://blog.csdn.net/Always2015/article/details/45008785