实验要求
结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
一、进程上下文切换和中断上下文的切换
- CPU上下文切换
CPU 寄存器,是 CPU 内置的容量小、但速度极快的内存。而程序计数器,则是用来存储CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此也被叫做 CPU 上下文CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。根据任务的不同,CPU 的上下文切换就可以分为几个不同的场景,也就是进程上下文切换、线程上下文切换以及中断上下文切换。
- 进程上下文切换
Linux 按照特权等级,把进程的运行空间分为内核空间和用户空间。进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行时,被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。从用户态到内核态的转变,需要通过系统调用来完成。内核空间具有最高权限,可以直接访问所有资源;用户空间只能访问受限资源,不能直接访问内存等硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。
进程是由内核来管理和调度的,进程的切换只能发生在内核态。所以,进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。因此,进程的上下文切换过程中,在保存当前进程的内核状态和 CPU 寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。进程上下文的切换,是从一个进程的内核堆栈切换到另一个进程的内核堆栈
- 中断上下文切换
为了快速响应硬件的事件,中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。跟进程上下文不同,中断是在?个进程当中从进程的?户态到进程的内核态,或从进程的内核态返回到进程的?户态,其中断上下?切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip 是由CPU协助完成的,?进程切换是在不同的进程间进行切换,完全由内核来实现。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。
中断上下文,其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。对同一个 CPU 来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。同样道理,由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。另外,中断上下文切换也需要消耗 CPU,切换次数过多也会耗费大量的 CPU,甚至严重降低系统的整体性能。所以,当中断次数过多时,就需要注意去排查它是否会给你的系统带来严重的性能问题。
二、fork系统调用的中断上下文的切换
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
int main (){
pid_t pid;
pid = fork();
if (pid < 0){
printf("error in fork!\n");
}
else if (pid == 0){
printf("child process, process id is %d\n",getpid());
}
else{
printf("parent process, process id is %d\n",getpid());
}
return 0;
}
我们通过以上代码创建子进程时,if条件判断中除了if (pid < 0)异常处理没被执?,else if (pid == 0)和else两段代码都被执?了,实际上fork系统调?把当前进程?复制了?个?进程,也就?个进程变成了两个进程,两个进程执?相同的代码,其实是if语句在两个进程中各执?了?次,由于判断条件不同,输出的信息也就不同。查看fork.c的代码如下。
/* * Create a kernel thread. */ pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags) { return _do_fork(&args); } SYSCALL_DEFINE0(fork) { return _do_fork(&args); } SYSCALL_DEFINE0(vfork) { return _do_fork(&args); } SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp, { return _do_fork(&args) }
可以看出,fork函数通过do_fork()函数创建进程,父进程创建一个子进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程号。我们可以通过fork返回的值来判断当前进程是子进程还是父进程。进程形成了链表,父进程的fpid指向子进程的进程id,因为子进程没有子进程,所以其fpid为0。
父进程调用fork,因为这是一个系统调用,会导致int软中断,进入内核空间,内核根据系统调用号,调用sys_fork系统调用,而sys_fork系统调用则是通过clone系统调用实现的。clone函数中会调用do_fork函数。do_fork函数主要完成了调?copy_process()复制?进程、获得pid、调?wake_up_new_task将?进程加?就绪队列等待调度执?等,copy_process函数主要完成了调?dup_task_struct复制当前进程(?进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时?进程置为就绪态)、采?写时复制技术逐?复制所有其他进程资源、调?copy_thread_tls初始化?进程内核栈、设置?进程pid等。其中最关键的就是dup_task_struct复制当前进程(?进程)描述符task_struct和copy_thread_tls初始化?进程内核栈。
二、execve系统调用的中断上下文
int execve(const char *filename, char *const argv[],char *const envp[]);
#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); } }
execve系统调用的执行过程:陷入内核 -> 用do_execve加载新的可执行文件并进行可执行性检查 -> 将新的可执行文件映射到当前运行进程的进程空间中,并覆盖原来的进程数据 -> 将EIP的值设置为新的可执行程序的入口地址。如果可执行程序是静态链接的程序,或不需要其他的动态链接库,则新的入口地址就是新的可执行文件的main函数地址;如果可执行程序还需要其他的动态链接库,则入口地址是加载器ld的入口地址 -> 返回用户态,程序从新的EIP出开始继续往下执行。
当execve系统调?返回时,返回的已经不是原来的那个可执?程序了,?是新的可执?程序。execve返回的是新的可执? 程序执?的起点,静态链接的可执??件也就是main函数的?致位置,动态链接的可执??件还需要ld链接好动态链接库再从main函数开始执?。整体的调?关系为sys_execve()或__x64_sys_execve -> do_execve() –> do_execveat_common() -> __do_execve_?le -> exec_binprm()-> search_binary_handler() -> load_elf_binary() -> start_thread()。
1. 正在运?的?户态进程X。
2. 发?中断(包括异常、系统调?等),CPU完成load cs:rip(entry of a speci?c ISR),即跳转到中断处理程序??。
3. 中断上下?切换,包括swapgs指令保存现场、加载当前进程内核堆栈栈顶地址到RSP寄存器、:将当前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. iret - pop cs:rip/ss:rsp/r?ags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完 成了中断上下?的切换,即从进程Y的内核态返回到进程Y的?户态。
8. 继续运??户态进程Y。