结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
一、fork进程分析
1.1理论分析
什么是fork:fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。调用fork之后,数据、堆、栈有两份,代码仍然为一份但是这个代码段成为两个进程的共享代码段都从fork函数中返回。
通过fork系统调用我们可以创建进程,下面我们就梳理一下fork的大概过程,主要关注进程上下文切换已经相关函数的作用。最后在我们之前构建的gdb环境中跑一跑以验证。
进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列, fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏:
1.2 fork的过程
进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列, fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏:
系统调用的过程在实验二我们已经了解了——通过某个系统调用执行该系统调用的内核函数,现在我们直接来看fork对应的内核函数_do_fork。它主要调用了两个关键函数:copy_process和wake_up_new_task。其中copy_process完成复制⽗进程、获得pid,wake_up_new_task将⼦进程加⼊就绪队列等待调度执⾏。一个个来看:
copy_process:
它会用当前进程的一个副本来创建新进程并分配pid。它会复制寄存器中的值、所有与进程环境相关的部分,每个clone标志。新进程的实际启动由调用者来完成。
dup_task_struct:
copy_process会调用函数dup_task_struct。dup_task_struct复制当前进程(⽗进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源
copy_thread_tls :
初始化⼦进程内核栈、设置⼦进程pid等
wake_up_new_task :
⼦进程创建好了进程描述符、内核堆栈等,就可以将⼦进程添加到就绪队列,使之有机会被调度执⾏,进程的创建⼯作就完成了,⼦进程就可以等待调度执⾏,⼦进程的执⾏从这⾥设定的ret_from_fork开始
1.2实验过程:
1.编写fork 示例代码,显示子进程和父进程的pid
#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; pid = fork(); if(pid<0) { //error fprintf(stderr,"For Failed"); exit(-1); } else if(pid==0) { //child printf("---------------------------------------------------------- \n"); printf("this is child process,my pid is %d \n",getpid()); } else { //parent printf("---------------------------------------------------------- \n"); printf("this is Parent process, my pid id %d \n",getpid()); printf("child's pid is %d \n",pid); } return 0; }
在系统中使用 gcc -o fork fork.c -static 编译fork.c的代码,生成可执行文件,然后,使用 ./fork 运行代码
可以清楚的看到代码中,创建了一个新的进程,并且向父进程返回了子进程的pid,同时子进程运行同样的程序副本,返回了0
针对程序中的代码,开启qemu虚拟机进行gdb调试,首先在~/rootf/home文件夹下创建fork.c代码,同时使用gcc进行编译,开启gdb调试
cd ~/roofs/home #拷贝上面的fork代码 nano fork.c gcc -o fork fork.c -static #pwd=~/rootfs cd ../ find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz #pwd=~ cd ../ qemu-system-x86_64 -kernel linux-5.4.1/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s
在gdb调试的时候,给,__x64_sys_clone
,_do_fork
,cpoy_process
,dup_task_struct
,copy_thread_tls 打
断点,qemu下运行fork
可执行文件
实验发现,fork的代码运行的确是调用了序号为 56 的系统调用为内核函数 __x64_sys_clone
在 /linux/kernel/fork.c 中,发现, __x64_sys_clone 是调用了内核中的 _do_fork 函数。
二、execve系统调用
图示
2.1和普通系统系统调用对比
当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉
。当execve系统调⽤返回 时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。
execve返回的是新的可执⾏程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需 要ld链接好动态链接库再从main函数开始执⾏。
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() -> // 加载elf文件到内存中
- start_thread() // 开始新进程
三、进程切换
3.1进程切换时机
- ⽤户进程上下⽂中主动调⽤特定的系统调⽤进⼊中断上下⽂,系统调⽤返回 ⽤户态之前进⾏进程调度。
- 内核线程或可中断的中断处理程序,执⾏过程中发⽣中断进⼊中断上下⽂, 在中断返回前进⾏进程调度。
- 内核线程主动调⽤schedule函数进⾏进程调度
3.2进程上下⽂
- ⽤户地址空间:包括程序代码、数据、⽤户堆栈等。 (
CR3
寄存器代表进程⻚⽬录表,即地址空间、数据) - 控制信息:进程描述符(
thread
)、内核堆栈(sp
寄存器)等。 - 进程的CPU上下⽂,相关寄存器的值(指令指针寄存器
ip
代表进程的CPU上下⽂)。
3.3进程切换过过程
- 切换⻚全局⽬录(
CR3
)以安装⼀个新的地址空间,这样不同进程的虚拟地 址如0x8048400
(32位x86)就会经过不同的⻚表转换为不同的物理地址。 - 切换内核态堆栈和进程的CPU上下⽂,因为进程的CPU上下⽂提供了内核执 ⾏新进程所需要的所有信息,包含所有CPU寄存器状态。
进程切换的代码:
((last) = __switch_to_asm((prev), (next))); ENTRY(__switch_to_asm) pushq %rbp pushq %rbx pushq %r12 pushq %r13 pushq %r14 pushq %r15 /* switch stack */ movq %rsp, TASK_threadsp(%rdi) movq TASK_threadsp(%rsi), %rsp popq %r15 popq %r14 popq %r13 popq %r12 popq %rbx popq %rbp jmp __switch_to END(__switch_to)
__switch_to_asm是在C代码中调⽤的,也就是使⽤call指令,⽽这段汇编的结尾是jmp __switch_to, __switch_to函数是C代码最后有个return,也就是ret指令。将__switch_to_asm和__switch_to结合起来,正好是call指令和ret指令的配对出现。
call指令压栈RIP寄存器到进程切换前的prev进程内核堆栈;⽽ret指令出栈存⼊RIP 寄存器的是进程切换之后的next进程的内核堆栈栈顶数据。
由此完成了进程的切换。
3.4中断上下文和进程上下文对比
中断上下文的切换
中断是由CPU实现的,所以中断上下⽂切换过程中最关键的栈顶寄存器sp
和指令指针寄存器 ip
是由CPU协助完成的。
进程上下文的切换
进程切换是由内核实现的(且一般情况下,进程上下文切换嵌套在中断中),所以进程上下⽂切换过程最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp
实现的,指令指针 寄存器ip的切换是在内核堆栈切换的基础上巧妙利⽤call/ret
指令实现的。
四、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。