结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
一、基础知识
1、用户空间与内核空间
操作系统采用虚拟存储器,对于32位的操作系统而言,其寻址空间为4G(即2^32)。
为保证内核的安全,操作系统将虚拟空间划分为两部分:
(1)内核空间:存放内核代码和数据
(2)用户空间:存放用户程序的代码和数据
针对Linux操作系统,将最高的1G字节(0xC0000000 - 0xFFFFFFFF)供内核使用,称为内核空间;将较低的3G字节(0x00000000-0xBFFFFFFF)供各个进程使用,称为用户空间。
虚拟空间分配如下图所示:
每个进程可以通过系统调用进入内核。
2、内核态和用户态
(1)常规划分
a. 内核态:
当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。
当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。
b. 用户态:
当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)
(2)内核态的进一步划分
上下文简单来说就是一个环境。
内核态根据上下文环境可以进行进一步细分:
a. 内核态,运行于进程上下文,内核代表进程运行于内核空间。
b. 内核态,运行于中断上下文,内核代表硬件运行于内核空间。
c. 用户态,运行于用户空间。
3、进程上下文
一般程序在用户空间执行,当一个程序调用了系统调用或者触发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行“并处于进程上下文。
进程上下文实际上是进程执行全过程的静态描述。
(1)上文:把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为上文
(2)正文:把正在执行的指令和数据在寄存器和堆栈中的内容称为正文
(3)下文:把待执行的指令和数据在寄存器与堆栈中的指令。
具体来说,进程上下文包括:
(1)计算机系统中与执行该进程有关的各种寄存器的值(例如通用寄存器、程序计数器PC、程序状态字寄存器PS等)
(2)程序段在经过编译过后形成的机器指令代码集
(3)数据集
(4)各种堆栈值PCB结构
当发生进程调度时,进行进程切换就是上下文切换。操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。
4、中断上下文
硬件通过中断触发信号,导致内核调用中断处理程序,进入内核空间。在这个过程中,硬件的一些变量和参数需要传递给内核,内核通过这些参数进行中断处理。
(1)中断上文:硬件传递过来的参数和内核需要保存的一些其他环境(主要是当时被中断的进程的环境)
(2)中断下文:执行在内核空间的中断服务程序
5、进程上下文和中断上下文
当工作在用户态的进程想要访问某些内核才能访问的资源时,必须通过系统调用或者中断切换到内核态,由内核代为执行。
进程上下文和中断上下文就是完成用户态和内核态切换所进行的操作总称。
(1)进程上下文主要是异常处理程序和内核线程。内核之所以进入进程上下文是因为进程自身的一些工作需要在内核中做。
当一个进程在执行时,CPU中的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够得到切换时的状态执行下去。在Linux中,当前进程上下文军保存在进程的任务数据结构中。
(2)中断上下文是由于硬件发生中断时会触发中断信号请求,请求系统处理中断,执行中断服务子程序。
在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。
6、系统调用
系统调用(system call)利用陷阱(trap),是异常(Exception)的一种,从用户态进?内核态。
系统调用具有以下功能和特性:
把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,?户态进程不用直接与硬件设备打交道。
极?地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产?安全隐患,可能引起系统崩溃。
使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接?(api)代替了,不会有紧密的关系,便于在不同系统间移植。
一般的系统调用过程:
涉及到2个堆栈:用户态堆栈和内核态堆栈。
用户态进入内核态的中断上下文切换包括3部分:cpu硬件保存的寄存器状态+系统调用号+SAVE_ALL保存的寄存器,组成pt_regs数据结构。
内核态退出到用户态的中断上下文切换包括2部分:restore_all(还原SAVE_ALL保存的寄存器)+iret(还原cpu硬件保存的寄存器)。
二、fork系统调用
fork系统调用用于创建一个新进程,称为子进程,它与进程(称为系统调用fork的进程)同时运行,此进程称为父进程。创建新的子进程后,两个进程将执行fork()系统调用之后的下一条指令。子进程使用相同的pc(程序计数器),相同的CPU寄存器,在父进程中使用的相同打开文件。它不需要参数并返回一个整数值。下面是fork()返回的不同值。一个现有进程可以调用fork函数创建一个新进程。由fork创建的新进程被称为子进程(child process)。fork函数被调用一次但返回两次。两次返回的唯一区别是子进程中返回0值而父进程中返回子进程ID。因此,可以通过返回值来判定该进程是父进程还是子进程
子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。UNIX将复制父进程的地址空间内容给子进程,因此,子进程有了独立的地址空间。fork调用一次,返回两次,这两个返回分别带回它们各自的返回值,其中在父进程中的返回值是子进程的进程号,而子进程中的返回值则返回 0。
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系统调用
Linux系统?般会提供了execl、 execlp、 execle、 execv、 execvp和execve等6个?以加载执??个可执??件的库函数,这些库函数统称为exec函数,差异在于对命令?参数和环境变量参数的传递?式不同。 exec函数都是通过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函数开始执?。
- 通过查看堆栈可发现
execve
系统调用的堆栈情况如下:
四、Linux系统的一般执行过程
进程调度的时机?般都是中断处理后和中断返回前的时机点进行,只有内核线程可以直接调?schedule函数主动发起进程调度和进程切换。
进程调度的时机主要根据中断上下文的切换是还是进程上下文的切换是分为两类
中断上下文发生的进程调度是指:用户进程上下?中主动调?特定的系统调用进?中断上下?,系统调用返回用户态之前进行进程调度。或者内核线程或可中断的中断处理程序,执行过程中发?中断进?中断上下文,在中断返回前进行进程调度。
进程上下文发生的进程调度是指内核线程主动调?schedule函数进?进程调度。
中断上下?和进程上下?的?个关键区别是堆栈切换的方法。中断是由CPU实现的,所以中断上下?切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下?切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利?call/ret指令实现的。
以系统调用作为特殊的中断简要总结如下(64位下):
(1)正在运行的?户态进程X。
(2)发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a speci?c ISR),即跳转到中断处理程序??。
(3)中断上下文切换,具体包括如下?点:
swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了?个快照。
rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调?是由系统调用入口处的汇编代码实现?户堆栈和内核堆栈的切换。
save cs:rip/ss:rsp/r?ags:将当前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/r?ags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完 成了中断上下?的切换,即从进程Y的内核态返回到进程Y的?户态。注意快速系统调?返回sysret与iret的处理略有不同。
(8)继续运行用户态进程Y。