结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
环境准备,参考上一篇博客:深入理解Linux系统调用:write/writev
系统调用号
首先查看fork和execve的系统调用号(64位系统):
cat ~/linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl
由此可见:fork和execve的系统调用号分别为:57、59
fork调用特殊之处
库函数fork是⽤户态创建⼀个子进程的系统调用API接口。既涉及中断上下文切换有设计进程上下文切换。
1.编写程序 Test_Fork.c ,使用fork() 函数:
#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("This is Child process! "); } else { //parent printf("This is Parent process! "); wait(NULL); printf("child complete "); } return 0; }
2.编译后执行
gcc -o Test_Fork Test_Fork.c -static ##一定要静态编译 ./Test_Fork ##运行
运行结果:
3.编写汇编程序Write-asm.c,触发write系统调用:
写Test_Fork-asm.c之前,还需要从反汇编Test_Fork来获取一些信息:
objdump -S Test_Fork >Test_Fork.S #反汇编
从Test_Fork.S汇编代码中得知,入口地址0x38:
所以fork的系统调用号十六进制是:0x38(十进制56号,是clone()系统调用)。
4. gdb跟踪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
从_do_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:
5.查看系统调用过程:
由此可见:
__x64_sys_clone
是调用了内核中的 _do_fork
函数。
进入系统调用entry_SYSCALL:
6. fork进程上下文切换
fork创建了一个子进程,涉及进程的上下文切换:子进程复制了父进程中所有的进程上下文信息,包括内核堆栈、进程描述符等,子进程作为一个独立的进程也会被调度。
当子进程获得CPU开始运行时,它是从哪里开始运行的呢?从用户态空间来看,就是fork系统调用的下⼀条指令(参见上面小程序的输出结果)。
但fork系统调用在子进程当中也是返回的,也就是说fork系统调用在内核里面变成了父子两个进程,父进程正常fork系统调用返回到用户态,fork出来的子进程也要从内核里返回到用户态。
对于子进程来讲,fork系统调用在内核处理程序中是从何处开始执行的呢?
创建⼀个进程是复制当前进程的信息,就是通过_do_fork函数来创建了⼀个新进程。⽗进程和⼦进程的绝⼤部分信息是完全⼀样的,但是有些信息是不能⼀样的,⽐如 pid 的值和内核堆栈。还有将新进程链接到各种链表中,要保存进程执⾏到哪个位置,有⼀个thread数据结构记录进程执⾏上下⽂的关键信息也不能⼀样,否则会发⽣问题。fork⼀个⼦进程的过程中,复制⽗进程的资源时采⽤了Copy OnWrite(写时复制)技术,不需要修改的进程资源⽗⼦进程是共享内存存储空间的。
_do_fork函数主要完成了调⽤copy_process()复制⽗进程、获得pid、调⽤wake_up_new_task将⼦进程加⼊就绪队列等待调度执⾏等。
copy_process()是创建⼀个进程的主要的代码。copy_process函数主要完成了调⽤dup_task_struct复制当前进程(⽗进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时⼦进程置为就绪态)、采⽤写时复制技术逐⼀复制所有其他进程资源、调⽤copy_thread_tls初始化⼦进程内核栈、设置⼦进程pid等。其中最关键的就是dup_task_struct复制当前进程(⽗进程)描述符task_struct和copy_thread_tls初始化⼦进程内核栈。接下来具体看dup_task_struct和copy_thread_tls。
copy_thread_tls负责构造fork系统调⽤在⼦进程的内核堆栈,也就是fork系统调⽤在⽗⼦进程各返回⼀次,⽗进程中和其他系统调⽤的处理过程并⽆⼆致,⽽在⼦进程中的内核函数调⽤堆栈需要特殊构建,为⼦进程的运⾏准备好上下⽂环境。
task_struct数据结构的最后是保存进程上下⽂中CPU相关的⼀些状态信息的关键数据结构thread
⼦进程创建好了进程描述符、内核堆栈等,就可以通过wake_up_new_task(p)将⼦进程添加到就绪队列,使之有机会被调度执⾏,进程的创建⼯作就完成了,⼦进程就可以等待调度执⾏,子进程的执行从这里设定的ret_from_fork开始。
总结来说,进程的创建过程⼤致是⽗进程通过fork系统调⽤进⼊内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采⽤写时复制技术)、分配⼦进程的内核堆栈并对内核堆栈和thread等进程关键上下⽂进⾏初始化,最后将⼦进程放⼊就绪队列,fork系统调⽤返回;⽽⼦进程则在被调度执⾏时根据设置的内核堆栈和thread等进程关键上下⽂开始执⾏。
execve调用特殊之处
有6种不同的exec函数可以使用,他们的差别主要是对命令行参数和系统变量参数的传递方式不同,exec函数都是通过execve系统调用进入内核,对应的系统调用内核处理函数为sys_execve或__x64_sys_execve,这俩函数最终都是通过调用do_execve来具体执行加载可执行文件的工作。
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。
int execl(const char *pathname, const char *arg1, ... /* (char*)0 */ ); int execlp(const char *filename, const char *arg1, ... /* (char*)0 */ ); int execle(const char *pathname, const char *arg1, ... /* (char*)0, char * const *envp */); int execv(const char *pathname, char * const argv[]); int execvp(const char *filename, char * const argv[]); int execve(const char *pathname, char * const argv[], char * const envp[]);
整体的调用关系为:
sys_execve()或__x64_sys_execve
-> do_execve() //读取128字节的文件头部,以此判断可执行文件的类型
–>do_execveat_common()
-> __do_execve_file
-> exec_binprm()
-> search_binary_handler() //去搜索和匹配合适的可执行文件装载处理过程
->load_elf_binary() //ELF文件由load_elf_binary()负责装载
-> start_thread() //由load_elf_binary()调用负责创建新进程的堆栈
search_binary_handler()函数会搜索Linux支持的可执行文件类型队列,让各种可执行程序的处理程序前来认领和处理。如果类型匹配,则调用load_binary函数指针所指向的处理函数来处理目标映像文件。在ELF文件格式中,处理函数是load_elf_binary函数,
load_elf_binary() 函数可以校验可执行文件并加载文件到内存,根据ELF文件中Program header table和Section header table映射到进程的地址空间;判断是否需要动态链接,配置进程启动的上下文环境start_thread。
execve特殊之处在于:当前的可执⾏程序在执⾏,执⾏到execve系统调⽤时陷⼊内核态,在内核⾥⾯⽤do_execve加载可执⾏⽂件,把当前进程的可执⾏程序给覆盖掉。当execve系统调⽤返回时,返回的已经不是原来的那个可执⾏程序了,⽽是新的可执⾏程序。execve返回的是新的可执⾏程序执⾏的起点,静态链接的可执⾏⽂件也就是main函数的⼤致位置,动态链接的可执⾏⽂件还需要ld链接好动态链接库再从main函数开始执⾏。
Linux一般执行过程总结
1、进程调度的时机
1)中断:中断在本质上都是软件或者硬件发⽣了某种情形⽽通知处理器的⾏为,处理器进⽽停⽌正在运⾏的当前进程,对这些通知做出相应反应,即转去执⾏预定义的中断处理程序(内核代码⼊⼝),这就需要从进程的指令流⾥切换出来
中断能起到暂停当前进程指令流(Linux内核中称为thread)转去执⾏中断处理程序的作⽤,中断处理程序是与当前进程指令流独⽴的内核代码指令流。从⽤户程序的⻆度看进程调度的时机⼀般都是中断处理后和中断返回前的时机点进⾏,只有内核线程可以直接调⽤schedule函数主动发起进程调度和进程切换。
- 硬中断:也称为外部中断,就是CPU的两根引脚(可屏蔽中断和不可屏蔽中断)的电平信号。
- 软中断/异常:也称为内部中断,包括除零错误、系统调⽤、调试断点等,在CPU执⾏指令过程中发⽣的各种特殊情况统称为异常。异常会导致程序⽆法继续执⾏,⽽跳转到CPU预设的处理函数。包括“故障、退出、陷阱(系统调用)
2)schedule函数:Linux内核通过schedule函数实现进程调度,schedule函数负责在运⾏队列中选择⼀个进程,然后把它切换到CPU上执⾏。
调⽤schedule函数的时机主要分为两类:
- 中断处理过程中的进程调度时机,中断处理过程中会在适当的时机检测need_resched标记,决定是否调⽤schedule()函数
- 内核线程主动调⽤schedule(),如内核线程等待外设或主动睡眠等情形下,或者在适当的时机检测need_resched标记,决定是否主动调⽤schedule函数。
2、进程上下文切换
为了控制进程的执⾏,内核必须有能⼒挂起正在CPU上运⾏的进程,并恢复执⾏以前挂起的某个进程。这种⾏为被称为进程切换,任务切换或进程上下⽂切换。尽管每个进程可以拥有属于⾃⼰的地址空间,但所有进程必须共享CPU及寄存器。因此在恢复⼀个进程执⾏之前,内核必须确保每个寄存器装⼊了挂起进程时的值。进程恢复执⾏前必须装⼊寄存器的⼀组数据,称为进程的CPU上下⽂。
上下文
一般来说,CP任何时刻都处于以下三种情况之一:
- 运⾏于⽤户态,执⾏⽤户进程上下⽂。
- 运⾏于内核空间,处于进程(内核线程)上下⽂。
- 运⾏于内核空间,处于中断(中断处理程序ISR,包括系统调⽤处理过程)上下⽂。
进程上下文包含了进程执行需要的所有信息:
- ⽤户地址空间:包括程序代码、数据、⽤户堆栈等。
- 控制信息:进程描述符、内核堆栈等
- 进程的CPU上下⽂,相关寄存器的值
进程切换就是变更进程上下文,最核心的是几个关键寄存器的的保存与变换:
- CR3寄存器代表进程⻚⽬录表,即地址空间、数据。
- 内核堆栈栈顶寄存器sp代表进程内核堆栈(保存函数调⽤历史),进程描述符(最后的成员thread是关键)和内核堆栈存储于连续存取区域中,进程描述符存在内核堆栈的低地址,栈从⾼地址向低地址增⻓,因此通过栈顶指针寄存器还可以获取进程描述符的起始地址。
- 指令指针寄存器ip代表进程的CPU上下⽂,即要执⾏的下条指令地址。
3.系统调用
(1)一般的系统调用过程:
涉及到2个堆栈:用户态堆栈和内核态堆栈。
用户态进入内核态的中断上下文切换包括3部分:cpu硬件保存的寄存器状态+系统调用号+SAVE_ALL保存的寄存器,组成pt_regs数据结构。
内核态退出到用户态的中断上下文切换包括2部分:restore_all(还原SAVE_ALL保存的寄存器)+iret(还原cpu硬件保存的寄存器)。
(2)fork系统调用:
struct task_struct init_task为0号进程,它是内核代码写死的,除此之外,所有其他进程的初始化都是通过_do_fork复制父进程的方式初始化的。1号进程kernel_init和2号进程kthreadd都是在start_kernel最后由rest_init()函数通过调用kernel_thread()函数创建的,而kernel_thread最终是调用_do_fork函数。用户态程序通过fork系统调用创建一个进程最终也是通过_do_fork来完成的。
(3)execve系统调用:
总结
中断上下文:硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境(主要是被中断的进程的环境)。
进程上下文:就是一个进程传递给内核的那些参数和CPU的所有寄存器的值、进程的状态以及堆栈中的内容,也就进程在进入内核态之前的运行环境。所以在切换到内核态时需要保存当前进程的所有状态,即保存当前进程的上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
上下文简单说来就是一个环境,相对于进程而言,就是进程执行时的环境。相对于中断而言就是中断执行时的环境。
一个进程的上下文可以分为三个部分: 用户级上下文、寄存器上下文以及系统级上下文:
(1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
(2)寄存器上下文: 通用寄存器、程序指令指针寄存器(EIP)、处理器状态寄存器(EFLAGS)、当前程序的栈顶指针(ESP);
(3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
进程上下文切换分为进程调度时和系统调用时两种切换,消耗资源不同:
进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。
系统调用时,进行的模式切换(mode switch)与进程切换比较起来容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。
中断和中断返回有CPU上下文的切换,中断上下文的切换还是在同一个进程中的
进程上下文的切换,是从一个进程的内核堆栈切换到另一个进程的内核堆栈