结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
环境搭建:参考上一篇:深入理解Linux系统调用:write/writev
一、理论基础分析
1.中断上下文和进程上下文切换
内核空间和用户空间是操作系统重要的理论知识,用户程序运行在用户空间,内核功能模块运行在内核空间,二者是空间是不能互相访问的,内核空间和用户空间指其代码和数据存放内存空间。用户态的程序要想访问内核空间,须使用系统调用。当用户空间的应用程序通过系统调用进入内核空间时,就会涉及到上下文的切换。用户空间和内核空间具有不同的地址映射、通用寄存器和专用寄存器组以及堆栈区,而且用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行。
进程上下文:就是一个进程传递给内核的那些参数和CPU的所有寄存器的值、进程的状态以及堆栈中的内容,也就进程在进入内核态之前的运行环境。所以在切换到内核态时需要保存当前进程的所有状态,即保存当前进程的上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。
中断上下文:硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境(主要是被中断的进程的环境)。
上下文简单说来就是一个环境,相对于进程而言,就是进程执行时的环境。相对于中断而言就是中断执行时的环境。
一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。
(1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
(2)寄存器上下文: 通用寄存器、程序指令指针寄存器(EIP)、处理器状态寄存器(EFLAGS)、当前程序的栈顶指针(ESP);
(3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
进程上下文切换分为进程调度时和系统调用时两种切换,消耗资源不同:
进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。
系统调用时,进行的模式切换(mode switch)与进程切换比较起来容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。
中断和中断返回有CPU上下文的切换,中断上下文的切换还是在同一个进程中的
进程上下文的切换,是从一个进程的内核堆栈切换到另一个进程的内核堆栈
2.系统调用
系统调用(system call)利用陷阱(trap),是异常(Exception)的一种,从用户态进⼊内核态。
系统调用具有以下功能和特性:
把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,⽤户态进程不用直接与硬件设备打交道。
极⼤地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产⽣安全隐患,可能引起系统崩溃。
使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接⼝(api)代替了,不会有紧密的关系,便于在不同系统间移植。
一般的系统调用过程:
涉及到2个堆栈:用户态堆栈和内核态堆栈。
用户态进入内核态的中断上下文切换包括3部分:cpu硬件保存的寄存器状态+系统调用号+SAVE_ALL保存的寄存器,组成pt_regs数据结构。
内核态退出到用户态的中断上下文切换包括2部分:restore_all(还原SAVE_ALL保存的寄存器)+iret(还原cpu硬件保存的寄存器)。
3.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来完成的。
4. execve系统调用:
二、fork调用特殊之处
库函数fork是⽤户态创建⼀个⼦进程的系统调⽤API接⼝。既涉及中断上下文切换有设计进程上下文切换。
先来看这么一个小程序:
#include#include #include int main(int argc, char * argv[]) { int pid; printf("parent1\n"); /* fork another process */ pid = fork(); printf("parent2\n"); # fork()子程序在这里开始执行 if (pid < 0) { /* error occurred */ } else if (pid == 0) { /* child process */ printf("child\n"); } else { /* parent process */ printf("parent3\n"); } }
fork在正常执⾏后,if条件判断中除了if (pid < 0)异常处理没被执⾏,else if (pid == 0)和else两段代码都被执⾏了,这看起来确实匪夷所思。
实际上fork系统调⽤把当前进程⼜复制了⼀个⼦进程,也就⼀个进程变成了两个进程,两个进程执⾏相同的代码,只是fork系统调⽤在⽗进程和⼦进程中的返回值不同。可是从Shell终端输出信息看两个进程是混合在⼀起的,会让⼈误以为if语句的执⾏产⽣了错误。其实是if语句在两个进程中各执⾏了⼀次,由于判断条件不同,输出的信息也就不同。⽗进程没有打破if else的条件分⽀的结构,在⼦进程⾥⾯也没有打破这个结构,只是在Shell命令⾏下好像两个都输出了,好像打破了条件分⽀结构,实际上背后是两个进程。fork之后,⽗⼦进程的执⾏顺序和调度算法密切相关,多次执⾏有时可以看到⽗⼦进程的执⾏顺序并不是确定的。
fork中断上下文切换:
从父进程的角度看,fork的执行过程跟一般的系统调用一样:⽤户态有⼀个int $0x80或syscall指令触发系统调⽤,跳转到系统调⽤⼊⼝的汇编代码。int $0x80指令触发entry_INT80_32并以iret返回系统调⽤,syscall指令触发entry_SYSCALL_64并sysret或iret返回系统调⽤。系统调⽤陷⼊内核态,从⽤户态堆栈转换到内核态堆栈,然后把相应的CPU关键的现场栈顶寄存器、指令指针寄存器、标志寄存器等保存到内核堆栈,保存现场。系统调⽤⼊⼝的汇编代码还会通过系统调⽤号执⾏系统调⽤内核处理函数,最后恢复现场和系统调⽤返回将CPU关键现场栈顶寄存器、指令指针寄存器、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到⽤户态int $0x80或syscall指令之后的下⼀条指令的位置继续执⾏。
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函数开始执⾏。
四、进程切换和系统的一般执行过程:
1、进程调度的时机
1)中断:中断在本质上都是软件或者硬件发⽣了某种情形⽽通知处理器的⾏为,处理器进⽽停⽌正在运⾏的当前进程,对这些通知做出相应反应,即转去执⾏预定义的中断处理程序(内核代码⼊⼝),这就需要从进程的指令流⾥切换出来
中断能起到暂停当前进程指令流(Linux内核中称为thread)转去执⾏中断处理程序的作⽤,中断处理程序是与当前进程指令流独⽴的内核代码指令流。从⽤户程序的⻆度看进程调度的时机⼀般都是中断处理后和中断返回前的时机点进⾏,只有内核线程可以直接调⽤schedule函数主动发起进程调度和进程切换。
中断的类型:
- 硬中断:也称为外部中断,就是CPU的两根引脚(可屏蔽中断和不可屏蔽中断)的电平信号。
- 软中断/异常:也称为内部中断,包括除零错误、系统调⽤、调试断点等,在CPU执⾏指令过程中发⽣的各种特殊情况统称为异常。异常会导致程序⽆法继续执⾏,⽽跳转到CPU预设的处理函数。包括“故障、退出、陷阱(系统调用)
2)schedule函数:Linux内核通过schedule函数实现进程调度,schedule函数负责在运⾏队列中选择⼀个进程,然后把它切换到CPU上执⾏。
调⽤schedule函数的时机主要分为两类:
- 中断处理过程中的进程调度时机,中断处理过程中会在适当的时机检测need_resched标记,决定是否调⽤schedule()函数
- 内核线程主动调⽤schedule(),如内核线程等待外设或主动睡眠等情形下,或者在适当的时机检测need_resched标记,决定是否主动调⽤schedule函数。
2、上下文
一般来说,CP任何时刻都处于以下三种情况之一:
- 运⾏于⽤户态,执⾏⽤户进程上下⽂。
- 运⾏于内核空间,处于进程(内核线程)上下⽂。
- 运⾏于内核空间,处于中断(中断处理程序ISR,包括系统调⽤处理过程)上下⽂。
3、简单总结进程调度时机
- ⽤户进程上下⽂中主动调⽤特定的系统调⽤进⼊中断上下⽂,系统调⽤返回⽤户态之前进⾏进程调度。
- 内核线程或可中断的中断处理程序,执⾏过程中发⽣中断进⼊中断上下⽂,在中断返回前进⾏进程调度。
- 内核线程主动调⽤schedule函数进⾏进程调度。
- 中断处理程序执⾏过程主动调⽤schedule函数进⾏进程调度,与前述两类调度时机对应
4、Linux调度策略
Linux系统中常⽤的⼏种调度策略为
- SCHED_NORMAL:引⼊的CFS(Complete Fair Scheduler)调度管理程序。
- SCHED_FIFO:采⽤先进先出的策略,对于所有相同优先级的进程,最先进⼊就绪队列的进程总能优先获得调度,直到其主动放弃CPU
- SCHED_RR:采⽤更加公平的轮转策略,⽐FIFO多⼀个时间⽚,使得相同优先级的实时进程能够轮流获得调度,每次运⾏⼀个时间⽚。
- SCHED_BATCH
SCHED_NORMAL是⽤于普通进程的调度类,
SCHED_FIFO和SCHED_RR是⽤于实时进程的调度类,优先级⾼于SCHED_NORMAL
CFS即为完全公平调度算法,其基本原理是基于权重的动态优先级调度算法。每个进程使⽤CPU的顺序由进程已使⽤的CPU虚拟时间(vruntime)决定,已使⽤的虚拟时间越少,进程排序就越靠前,进程再次被调度执⾏的概率也就越⾼。每个进程每次占⽤CPU后能够执⾏的时间(ideal_runtime)由进程的权重决定,并且保证在某个时间周期(__sched_period)内运⾏队列⾥的所有进程都能够⾄少被调度执⾏⼀次。
5、进程上下文切换
为了控制进程的执⾏,内核必须有能⼒挂起正在CPU上运⾏的进程,并恢复执⾏以前挂起的某个进程。这种⾏为被称为进程切换,任务切换或进程上下⽂切换。尽管每个进程可以拥有属于⾃⼰的地址空间,但所有进程必须共享CPU及寄存器。因此在恢复⼀个进程执⾏之前,内核必须确保每个寄存器装⼊了挂起进程时的值。进程恢复执⾏前必须装⼊寄存器的⼀组数据,称为进程的CPU上下⽂。
进程上下文包含了进程执行需要的所有信息:
- ⽤户地址空间:包括程序代码、数据、⽤户堆栈等。
- 控制信息:进程描述符、内核堆栈等
- 进程的CPU上下⽂,相关寄存器的值
进程切换就是变更进程上下文,最核心的是几个关键寄存器的的保存与变换:
- CR3寄存器代表进程⻚⽬录表,即地址空间、数据。
- 内核堆栈栈顶寄存器sp代表进程内核堆栈(保存函数调⽤历史),进程描述符(最后的成员thread是关键)和内核堆栈存储于连续存取区域中,进程描述符存在内核堆栈的低地址,栈从⾼地址向低地址增⻓,因此通过栈顶指针寄存器还可以获取进程描述符的起始地址。
- 指令指针寄存器ip代表进程的CPU上下⽂,即要执⾏的下条指令地址。
进程切换关键环节示意图: