结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
一、实验要求
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二、上下文切换
1 进程上下文切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复执行以前挂起的某个进程,这种被称为进程切换,任务切换或进程上下文切换。尽管每个进程可以拥有属于自己的地址空间,但所有进程必须共享CPU及寄存器。因此在恢复某个进程执行之前,内核必须确保每个寄存器装载了挂起进程时的值。进程恢复执行前必须装载寄存器的数组数据,称为进程的CPU上下文。您可以将其想象成对CPU的某时刻的状态拍了张“照片”, “照片”中有CPU所有寄存器的值。同样进程切换就是拍张当前进程所有状态的“照片”保存下来,其中就包括进程的CPU上下文的“照片”,然后将导入之前保存下来的其他进程的所有状态信息恢复执行。
进程切换就是变更进程上下文
• 最核新的是某个关键寄存器的保存与变换。
• CR3寄存器代表进程页目录表,即地址空间、数据。
• 内核堆栈栈顶寄存器sp代表进程内核堆栈(保存函数调用历史),进程描述符(最后的成员`thread`是关键)和内核堆栈存储于连续存取区域中,进程描述符存在内核堆栈的低地址,栈从高地址向低地址增长,因此通过栈顶指针寄存器还可以获取进程描述符的起始地址。
• 指令指针寄存器ip代表进程的CPU上下文,即要执行的下条指令地址。
这些寄存器从一个进程的状态切换到另一个进程的状态,进程切换的关键上下文就算完成了。
2 linux-5.4.34进程切换核心代码分析
linux-5.4.34进程切换过程在逻辑上并没有根本性的变化,但是代码实现方式有较大的改变,我们以x86-64体系结构为例具体分析如下。 我们来看context_switch,kernel/sched/core.c,尽管代码变化较大,但还是可以看到进程地址空间mm的切换和进程关键上下⽂的切换switch_to。
进程关键上下文的切换swtich_to,arch/x86/include/asm/switch_to.h:
下面的switch_to_asm是一段汇编代码,其中有内核堆栈栈顶指针RSP寄存器的切换,有jmp switch_to,但是没有了thread.ip及标号1的位置,关键的指令指针寄存器RIP是怎么切换的呢?
这时需要注意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进程的内
核堆栈栈顶数据。
((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
3 进程上下文与中断上下文
进程上下文切换时需要保存要切换进程的相关信息(如thread.sp与thread.ip),这与中断上下文的切换是不同的。中断是在一个进程当中从进程的用户态到进程的内核态,或从进程的内核态返回到进程的用户态,而切换进程需要在不同
的进程间切换。但一般进程上下文切换是嵌套到中断上下文切换中的,如前述系统调用作为一种中断先陷内核,即发生中断保存现场和系统调用处理过程。其中调用了schedule函数发生进程上下文切换,当系统调用返回到用户态时会恢复现场,因此完成了保存现场和恢复现场,即完成了中断上下文切换。
请注意理清中断上下文和进程上下文两者之间的关系。
中断上下文和进程上下文的一个关键区别是堆栈切换的方法。中断是由CPU实现的,所以中断上下文切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下文切换过程中最关键
的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利用call/ret指令实现的。
4 fork中断上下文切换
先来看fork子进程的内核堆栈,从struct fork_frame可以看出它是在struct pt_regs的基础上增加了struct inactive_task_frame。 对照下__switch_to_asm汇编代码中压栈和出栈的寄存器,是不是完全一致,就栈顶
多了一个ret_addr,在fork子进程中存储的就是父进程的起始点ret_from_fork。

fork子进程的内核堆栈示意图中struct pt_regs就是内核堆栈中保存的中断上下文, struct inactive_task_frame就是fork子进程的进程上下文。__switch_to_asm汇编代码中完成内核堆栈切换后的代码,正好与structinactive_task_frame对应出栈,最后的__switch_to函数的最后ret正好出栈的是ret_addr,即子进程的起始点ret_from_fork。
我们再看下fork执行流程
在linux中,我们可以通过fork系统调用来处理进程创建的任务。对于进程的创建, 可以sys_clone, sys_vfork,以及sys_fork.这些系统调用的内部都使用了do_fork.函数。对于do_fork函数, 会copy tast_struct, 设置内核堆栈, 并且对一些特定的数据结构进行修改。其中里面还有copy_thread 函数, 会设置这个进程的cs和ip。这个是在进程的thread_info中保持的。这里的ip设置成了ret_from_fork函数(在ret_from_frok里面有一个jmp system_exit). 后面,fork系统调用本身可以进入到之前系统调用的部分讲的system_exit部分。 这样fork 系统调用在这里就会有一个进程调度的时机。schedule 对比自己写的多道时间片轮转的问题,进程调度大致的流程是,找stask_struct链表, 找里面可以用的进程,找到以后, 找里面保持的ip, 这里就是刚才设置的ret_from_fork, 从这里开始, jmp 到system_exit, 就可以ret restore_all, 恢复到父进程那个位置的代码,开始执行。
5 fork子进程启动执行时进程上下文的特殊之处
fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1)在父进程中,fork返回新创建子进程的进程ID;
2)在子进程中,fork返回0;
3)如果出现错误,fork返回一个负值;
创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。此时,两个进程都从fork开始往下执行,只是pid不同。
6 execve系统调用中断上下文的特殊之处
execve系统调用的执行过程如下:
-
陷入内核
-
加载新的可执行文件并进行可执行性检查
-
将新的可执行文件映射到当前运行进程的进程空间中,并覆盖原来的进程数据
-
将EIP的值设置为新的可执行程序的入口地址。如果可执行程序是静态链接的程序,或不需要其他的动态链接库,则新的入口地址就是新的可执行文件的main函数地址;如果可执行程序还需要其他的动态链接库,则入口地址是加载器ld的入口地址
-
返回用户态,程序从新的EIP出开始继续往下执行。至此,老进程的上下文已经被新的进程完全替代了,但是进程的PID还是原来的。从这个角度来看,新的运行进程中已经找不到原来的对execve调用的代码了,所以execve函数的一个特别之处是他从来不会成功返回,而总是实现了一次完全的变身。
三、Linux系统的一般执行过程
Linux系统的一般执行过程分析
一般情况:当前系统正在进行,有一个用户态进程X,需要切换到用户态进程Y(进程策略决定):
1.正在运行的用户态进程X
2.发生中断——save cs:eip/esp/eflags(current) to kernel stack :当前CPU上下⽂压⼊进程X的内核堆栈。然后 load cs:eip(entry of a specific ISR(中断服务例程的入口,对于系统调用就是 system_call)) 和ss:esp(point to kernel stack).//加载当前进程内核堆栈相关信息,跳转到中断处理程序,即中断执⾏路径的起点。这些保存和加载都是CPU自动完成。
3.SAVE_ALL //保存现场,此时完成了中断上下⽂切换,即从进程X的⽤户态到进程X的内核态。
4.中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下文所需的 EIP等 寄存器状态切换。详细过程如前述内容。
5.标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过,就是next以前做过prev,因此可以从标号1继续执行)
6.restore_all //Y进程从它的中断中恢复现场,与(3)中保存现场相对应。注意是进程Y的中断处理过程中,(3)中保存现场是在进程X的中断处理过程中,因为内核堆栈从进程X切换到进程Y了。
7.iret - pop cs:eip/ss:esp/eflags from kernel stack//从Y进程的内核堆栈中弹出2)中硬件完成的压栈内容。此时完成了中断上下文的切换,即从进程Y的内核态返回到进程Y的⽤户态。
8.继续运行用户态进程Y//执行发生中断时间点的下一条指令。
关键:中断上下文的切换(中断和中断返回时CPU进行上下文切换)和进程上下文的切换(进程调度过程中,从一个进程的内核堆栈切换到另一个进程的内核堆栈)
Linux系统执行过程中的几个特殊情况
1.通过中断处理过程中的调度时机,用户态进程与内核线程之间互相切换和内核线程之间互相切换,与最一般的情况非常类似,只是内核线程运行过程中发生中断没有进程用户态和内核态的转换;
2.内核线程主动调用schedule(),只有进程上下文的切换,没有发生中断上下文的切换,与最一般的情况略简略;//用户态进程不能主动调用
3.fork:创建子进程的系统调用在子进程中的执行起点(next_ ip = ret_ from_ fork)返回用户态,进程返回不是从标号1开始执行,直接跳转到ret_ from_fork执行然后返回到用户态;
4.加载一个新的可执行程序后返回到用户态的情况,如execve,只是中断上下文在execve系统调用内部被修改了;
在linux中,我们可以通过fork系统调用来处理进程创建的任务。对于进程的创建, 可以sys_clone, sys_vfork,以及sys_fork. 这些系统调用的内部都使用了do_fork.函数。对于do_fork函数, 会copy tast_struct, 设置内核堆栈, 并且对一些特定的数据结构进行修改。其中里面还有copy_thread 函数, 会设置这个进程的cs和ip。这个是在进程的thread_info中保持的。这里的ip设置成了ret_from_fork函数(在ret_from_frok里面有一个jmp system_exit). 后面,fork系统调用本身可以进入到之前系统调用的部分讲的system_exit部分。 这样fork 系统调用在这里就会有一个进程调度的时机。schedule 对比自己写的多道时间片轮转的问题,进程调度大致的流程是,找stask_struct链表, 找里面可以用的进程,找到以后, 找里面保持的ip, 这里就是刚才设置的ret_from_fork, 从这里开始, jmp 到system_exit, 就可以ret restore_all, 恢复到父进程那个位置的代码,开始执行。
浙公网安备 33010602011771号