我想在介绍进程切换之前,先引入中断的相关知识,它是我们理解进程切换的重要前提,也是Linux操作系统的核心机制。
中断的类型
• 硬件中断(Interrupt),也称为外部中断,就是CPU的两根引脚(可屏蔽中断和不可屏蔽中断)的电平信号。CPU在执行每条指令后检测这两根引脚的电平,如果是高电平,说明有中断请求,CPU就会中断当前程序的执行去处理中断。一般外设都是以这种方式来通知CPU的,如时钟、键盘、硬盘等。
• 软件中断/异常(Exception),也称为内部中断,包括除零错误、系统调用、调试断点等,在CPU执行指令过程中发生的各种特殊情况统称为异常。异常会导致程序无法继续执行,而跳转到CPU预设的处理函数。异常分为如下3种:
(1)故障(Fault):故障就是有问题了,但可以恢复到当前指令。例如,除0错误、缺页异常等。
(2)退出(Abort):简单说是不可恢复的严重故障,导致程序无法继续运行,只能退出了。例如,连续发生故障(double fault)。
(3)陷阱(Trap):程序主动产生的异常,在执行当前指令后发生。前面研究的系统调用(int 0x80)以及调试程序时设置断点的指令(int 3)都属于这类。简单说就是程序自己要借用中断这种机制进行跳转,所以在有些书中也称为“自陷”。从CPU的视角看,其处理机制与其他中断处理方式并无区别,我们可以以系统调用为例以此类推到一般的中断机制。
快速系统调用
值得注意的是syscall和sysenter快速系统调用指令,在CPU内部与中断处理的机制差异较大,但是从系统角度看,快速系统调用沿用了传统中断方式的系统调用的处理过程,而且快速系统调用的提出为了加速CPU处理传统中断方式的系统调用的速度。因此,从系统的角度看syscall和sysenter快速系统调用指令也可以认为是一种特殊的中断处理。
进程调度的时机
还是要从中断说起,因为进程调度的时机很多都与中断相关。中断有很多种,都是程序执行过程中的强制性转移,转移到操作系统内核相应的处理程序。中断在本质上都是软件或者硬件发生了某种情形而通知处理器的行为,处理器进而停止正在运行的当前进程,对这些通知做出相应反应,即转去执行预定义的中断处理程序(内核代码入口),这就需要从进程的指令流里切换出来。
中断能起到暂停当前进程指令流(Linux内核中称为thread)转去执行中断处理程序的作用,中断处理程序是与当前进程指令流独立的内核代码指令流。从用户程序的角度看进程调度的时机一般都是中断处理后和中断返回前的时机点进行,只有内核线程可以直接调用schedule函数主动发起进程调度和进程切换。
中断处理后,会检查一下是否需要进程调度。需要则切换进程(本质上是切换两个进程的内核堆栈和thread),不需要则一路顺着函数调用堆栈正常中断返回,这样就自然回到原进程继续运行了。
Linux中进程调度的时机
Linux内核通过schedule函数实现进程调度,schedule函数负责在运行队列中选择一个进程,然后把它切换到CPU上执行。所以调用schedule函数一次就是进程调度一次,有机会调用schedule函数的时候就是进程调度的时机。schedule函数见kernel/sched/core.c,调用schedule函数的时机主要分为两类:
(1)中断处理过程中的进程调度时机,中断处理过程中会在适当的时机检测need_resched标记,决定是否调用schedule()函数。比如在系统调用内核处理函数执行完成后且系统调用返回之前就会检测need_resched标记决定是否调用schedule()函数。
(2)内核线程主动调用schedule(),如内核线程等待外设或主动睡眠等情形下,或者在适当的时机检测need_resched标记,决定是否主动调用schedule函数。
CPU运行的三种上下文环境
(1)运行于用户态,执行用户进程上下文。
(2)运行于内核空间,处于中断(中断处理程序ISR,包括系统调用处理过程)上下文代表当前进程执行在内核态。(上半部)
(3)运行于内核空间,处于进程(内核线程)上下文。(下半部)
应用程序通过系统调用陷入内核,或者当外部设备产生中断信号时,CPU就会调用相应的中断处理程序(包括系统调用处理程序),此时CPU处于中断上下文。
中断上下文代表当前进程执行,所以中断上下文中的get_current可获取一个指向当前进程描述符的指针,即指向被中断进程,相应的中断上下文切换的信息存储于该进程的内核堆栈中。
内核线程以进程上下文的形式运行在内核态,本质上还是进程,但它有调用内核代码的权限,比如主动调用schedule()函数进行进程调度。
结合CPU运行的三种上下文环境,这里再简单总结一下进程调度时机。
• 用户进程上下文中主动调用特定的系统调用进入中断上下文,系统调用返回用户态之前进行进程调度。
• 内核线程或可中断的中断处理程序,执行过程中发生中断进入中断上下文,在中断返回前进行进程调度。
• 内核线程主动调用schedule函数进行进程调度。
以上第一种和第二种情况可以统一起来,中断处理程序执行过程主动调用schedule函数进行进程调度,与前述两类调度时机对应。
对内核线程的补充:Linux内核中没有操作系统原理中定义的线程概念。从内核的角度看,不管是进程还是内核线程都对应一个task_struct数据结构,本质上都是进程。Linux系统在用户态实现的线程库pthread是通过在内核中多个进程共享一个地址空间实现的。
进程调度策略
进程调度策略就是从就绪队列中选择一个进程的策略方法。一般来说就是挑最重要的、最需要的(最着急的)、等了最长时间的(排队)等。进度调度可以分为两个层次,一个是策略一个算法。
• 进程调度策略:首先要考虑这个策略的整体目标,是追求资源利用率最高,还是追求响应最及时,或是追求其他的特定目标。为了满足特定下的这些目标,就需要找对应的方法或机制作为对策,这就是进程调度策略,显然进程调度策略站的层次更高。
• 进程调度算法:接下来考虑如何实现进程调度策略来达成设定的目标。是用数组、链表、图,还是树来存储就绪进程呢?在加入就绪队列时就排序,还是调度时再排序?时间复杂度可以接受吗?这些具体的实现就是进程调度算法需要考虑的问题。
为了满足不同进程的的需要,首先就要给进程分类。从不同的视角看,进程可以有多种不同的分类方式。这里选取两种和调度相关的分类方式。
• 按消耗资源的类型对进程分类
◦ I/O消耗型进程。典型的像需要大量文件读写操作的或网络读写操作的,如文件服务器的服务进程。这种进程的特点就是CPU负载不高,大量时间都在等待读写数据。
◦ 计算消耗型进程。典型的像视频编码转换、科学计算等。这种进程的特点就是CPU占用率几乎为100%,但没有太多I/O读写操作。
在实际的进程调度中要综合考虑这两种类型的进程,通过组合以达到较高的资源利用率。
• 按响应时效对进程分类
◦ 批处理进程。此类进程不需要人机交互,在后台运行,需要占用大量的系统资源,但是能够忍受响应延迟,比如编译器。
◦ 交互式进程。此类进程有大量的人机交互,因此进程不断地处于睡眠状态,等待用户输入,典型的应用比如编辑器Vim。此类进程对系统响应时间要求比较高,否则用户会感觉系统反应迟缓。
◦ 实时进程。实时进程对调度延迟的要求最高,这些进程往往执行非常重要的操作,要求立即响应并执行。比如视频播放软件或飞机飞行控制系统,很明显这类程序不能容忍长时间的调度延迟,轻则影响电影放映效果,重则机毁人亡。
Linux调度策略
根据进程的不同分类,Linux采用不同的调度策略。早期很多用户共享同一台小型机,调度算法追求吞吐率、利用率、公平性;现在的个人电脑更强调人机交互响应速度;而很多自动控制场合使用的嵌入式系统更强调实时性。当前Linux系统的解决方案是,对于实时进程,Linux采用FIFO(先进先出)或者Round Robin(时间片轮转)的调度策略。对其他进程,当前Linux采用CFS(Completely Fair Scheduler)调度器,核心思想是“完全公平”。这个设计理念不仅大大简化了调度器的代码复杂度,还对各种调度需求的提供了更完美支持。
Linux系统中常用的几种调度策略为SCHED_NORMAL、SCHED_FIFO、SCHED_RR、SCHED_BATCH。其中SCHED_NORMAL是用于普通进程的调度类,而SCHED_FIFO和SCHED_RR是用于实时进程的调度类,优先级高于SCHED_NORMAL。内核中根据进程的优先级来区分普通进程与实时进程,Linux内核进程优先级为0~139,数值越高,优先级越低,0为最高优先级。实时进程的优先级取值为0~99;而普通进程只具有nice值,nice值映射到优先级为100~139。子进程会继承父进程的优先级。对于实时进程,Linux系统会尽量使其调度延时在一个时间期限内,但是不能保证总是如此,不过正常情况下已经可以满足比较严格的时间要求了。
SCHED_FIFO和SCHED_RR
实时进程的优先级是静态设定的,而且始终大于普通进程的优先级。因此只有当就绪队列中没有实时进程的情况下,普通进程才能够获得调度执行机会。实时进程采用两种调度策略:SCHED_FIFO和SCHED_RR。SCHED_FIFO采用先进先出的策略,对于所有相同优先级的进程,最先进入就绪队列的进程总能优先获得调度,直到其主动放弃CPU。SCHED_RR(Round Robin)采用更加公平的轮转策略,比FIFO多一个时间片,使得相同优先级的实时进程能够轮流获得调度,每次运行一个时间片。
SCHED_NORMAL
Linux 2.6之后的内核版本中,SCHED_NORMAL使用的是Linux 2.6.23版本内核中引入的CFS(Complete Fair Scheduler)调度管理程序。如果同时运行只有两个相同优先级的进程,它们分到的CPU时间各是50%。如果优先级不同,比如有两个进程,对应的nice值分别为0(普通进程)和+19(低优先级进程),那么普通进程将会占有19/20×100%的CPU时间,而低优先级进程将会占有1/20×100%的CPU时间(按优先级占不同比例的时间,具体数值只做举例说明,Linux内核中计算出来的数值会不一样)。这样每个进程能够分配到的CPU时间占有比例跟系统当前的负载(所有处于运行态的进程数以及各进程的优先级)有关,同一个进程在本身优先级不变的情况下分到的CPU时间占比会根据系统负载变化而发生变化,即与时间片没有一个固定的对应关系。
CFS算法对交互式进程的响应较好,由于交互式进程基本处于等待事件的阻塞态中,执行的时间很少,而计算类进程在执行的时间会比较长。如果计算类进程正在执行时,交互式进程等待的事件发生了,CFS马上就会判断出交互式进程在之前时间段内执行的时间很少,那么CFS将会立即使交互式的进程占有CPU开始执行,因此系统总是能及时响应交互式进程。
CFS进程调度算法
CFS(Complete Fair Scheduler)即为完全公平调度算法,其基本原理是基于权重的动态优先级调度算法。每个进程使用CPU的顺序由进程已使用的CPU虚拟时间(vruntime)决定,已使用的虚拟时间越少,进程排序就越靠前,进程再次被调度执行的概率也就越高。每个进程每次占用CPU后能够执行的时间(ideal_runtime)由进程的权重决定,并且保证在某个时间周期(__sched_period)内运行队列里的所有进程都能够至少被调度执行一次。
Linux采用红黑树(rb_tree)来存储就绪进程指针,当进程插入就绪队列时根据vruntime排序,调度时只需选择最左的叶子节点即可。
进程上下文切换
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复执行以前挂起的某个进程。这种行为被称为进程切换,任务切换或进程上下文切换。尽管每个进程可以拥有属于自己的地址空间,但所有进程必须共享CPU及寄存器。因此在恢复一个进程执行之前,内核必须确保每个寄存器装入了挂起进程时的值。进程恢复执行前必须装入寄存器的一组数据,称为进程的CPU上下文。您可以将其想象成对CPU的某时刻的状态拍了一张“照片”,“照片”中有CPU所有寄存器的值。同样进程切换就是拍一张当前进程所有状态的大“照片”保存下来,其中就包括进程的CPU上下文的小“照片”,然后将导入一张之前保存下来的其他进程的所有状态信息恢复执行。
进程上下文
- 用户地址空间:包括程序代码、数据、用户堆栈等。
- 控制信息:进程描述符、内核堆栈等。
- 进程的CPU上下文,相关寄存器的值。
进程执行环境的切换大致分为两大步:
(1)从就绪队列中选择一个进程(pick_next_task),也就是由进程调度算法决定选择哪一个进程作为下一个进程(next);
static void __sched notrace __schedule(bool preempt)
{
struct task_struct *prev, *next;
...
next = pick_next_task(rq, prev, &rf);
...
rq = context_switch(rq, prev, next, &rf);
...
}
(2)完成进程上下文切换context_switch,进程上下文包含了进程执行需要的所有信息。
/*
* context_switch - switch to the new MM and the new thread's register state.
*/
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next);
...
/*
* kernel -> kernel lazy + transfer active
* user -> kernel lazy + mmgrab() active
*
* kernel -> user switch + mmdrop() active
* user -> user switch
*/
if (!next->mm) { // to kernel
...
} else { // to user
...
}
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
进程切换就是变更进程上下文
最核心的是几个关键寄存器的保存与变换。
• 进程页目录表(页表),即地址空间、数据。
• 内核堆栈栈顶寄存器sp代表进程内核堆栈(保存函数调用历史),进程描述符(最后的成员thread是关键)和内核堆栈存储于连续存取区域中,进程描述符存在内核堆栈的低地址,栈从高地址向低地址增长,因此通过栈顶指针寄存器还可以获取进程描述符的起始地址。
• 指令指针寄存器代表进程的CPU上下文,即要执行的下条指令地址。
这些寄存器从一个进程的状态切换到另一个进程的状态,进程切换的关键上下文就算完成了。
进程切换
每个进程描述符包含一个类型为thread_struct的thread成员变量,只要进程被切换出去,内核就把其CPU上下文保存在这个结构体变量thread和内核堆栈中。thread_struct数据结构包含部分CPU寄存器的状态,另外一些寄存器的状态存储在内核堆栈中。
在实际代码中,每个进程切换基本由两个步骤组成。
• 切换页全局目录(CR3)以安装一个新的地址空间,这样不同进程的虚拟地址,如0x8048400(32位x86)就会经过不同的页表转换为不同的物理地址。
• 切换内核态堆栈和进程的CPU上下文,因为进程的CPU上下文提供了内核执行新进程所需要的所有信息,包含所有CPU寄存器状态。
linux-3.18.6进程切换核心代码分析
由于linux-5.4.34进程切换的代码与linux-3.18.6相比变化较大,而linux-3.18.6的进程切换与第一个实验mykernel内核范例代码一致,且易于理解,因此我们首先分析32位x86体系结构下linux-3.18.6进程切换核心代码。
schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换。context_switch首先调用switch_mm切换CR3,然后调用宏switch_to来进行CPU上下文切换。
context_switch部分关键代码
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
...
if (unlikely(!mm)) { /* 如果被切换进来的进程的mm为空切换,内核线程mm为空 */
next->active_mm = oldmm; /* 将共享切换出去进程的active_mm */
atomic_inc(&oldmm->mm_count); /* 有一个进程共享,所有引用计数加一 */
/* 将per cpu变量cpu_tlbstate状态设为LAZY */
enter_lazy_tlb(oldmm, next);
} else /* 普通mm不为空,则调用switch_mm切换地址空间 */
switch_mm(oldmm, mm, next);
...
/* 这里切换寄存器状态和栈 */
switch_to(prev, next, prev);
...
}
进程的内核堆栈及CPU上下文的切换
为了阅读方便,将上述代码简化为如下伪代码。
pushfl
pushl %ebp //s0 准备工作
prev->thread.sp=%esp //s1
%esp=next->thread.sp //s2
prev->thread.ip=$1f //s3
push next->thread.ip //s4
jmp _switch_to //s5
1f:
popl %%ebp //s6,与s0对称
pop
函数调用堆栈框架
堆栈存储了进程所有的函数调用历史,所以剩下的只要顺着堆栈返回上一级函数即可。由于__switch_to是被schedule()函数调用的,而schedule()函数又在其他中断(系统调用)处理过程中被调用,比如sys_exit()中,所以先返回到next进程上次切换让出CPU时的schedule()函数中,然后返回到调用schedule()的中断(系统调用)处理过程中。系统调用是在用户空间通过int 0x80触发的,所以通过iret将中断上下文返回到系统调用被触发的地方,接着继续执行用户空间的代码。这样就回到了next进程的用户空间代码。注意由于此时的返回路径是根据next堆栈中保存的返回地址来返回的,所以肯定会返回到next进程中。
中断上下文和进程上下文
进程上下文切换时需要保存要切换进程的相关信息(如thread.sp与thread.ip),这与中断上下文的切换是不同的。中断是在一个进程当中从进程的用户态到进程的内核态,或从进程的内核态返回到进程的用户态,而切换进程需要在不同的进程间切换。但一般进程上下文切换是嵌套到中断上下文切换中的,比如前述系统调用作为一种中断先陷入内核,即发生中断保存现场和系统调用处理过程。其中调用了schedule函数发生进程上下文切换,当系统调用返回到用户态时会恢复现场,至此完成了保存现场和恢复现场,即完成了中断上下文切换。而本节前述内容主要关注进程上下文切换,请注意理清中断上下文和进程上下文两者之间的关系。
linux-5.4.34进程切换核心代码分析
linux-5.4.34进程切换过程在逻辑上并没有根本性的变化,但是代码实现方式有较大的改变,我们以x86-64体系结构为例具体分析一下。
首先看context_switch,见kernel/sched/core.c,尽管代码变化较大,但还是可以看到进程地址空间mm的切换和进程关键上下文的切换switch_to。
swtich_to
进程关键上下文的切换swtich_to,见arch/x86/include/asm/switch_to.h。
其中的__switch_to_asm是一段汇编代码,见arch/x86/entry/entry_64.S,这段汇编代码与3.18.6的汇编代码结构是相似的,有内核堆栈栈顶指针RSP寄存器的切换,有jmp __switch_to,但是没有了thread.ip及标号1的位置,关键的指令指针寄存器RIP是怎么切换的呢?
((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
这里需要对函数调用堆栈框架的深入理解才能发现端倪,注意__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进程的内核堆栈栈顶数据。
如果还没有理解的话,我们可以看看fork之后子进程被调度执行的情况。
fork子进程的起点ret_from_fork
先来看fork子进程的内核堆栈,从struct fork_frame可以看出它是在struct pt_regs的基础上增加了struct inactive_task_frame。
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
…
unsigned long r8;
unsigned long ax;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_ax;
/* Return frame for iretq */
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
/* top of stack page */
}
struct inactive_task_frame {
#ifdef CONFIG_X86_64
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
#else
unsigned long flags;
unsigned long si;
unsigned long di;
#endif
unsigned long bx;
/*
* These two fields must be together. They form a stack frame header,
* needed by get_frame_pointer().
*/
unsigned long bp;
unsigned long ret_addr;
};
struct fork_frame {
struct inactive_task_frame frame;
struct pt_regs regs;
对照一下__switch_to_asm汇编代码中压栈和出栈的寄存器,是不是完全一致,就栈顶多了一个ret_addr,在fork子进程中存储的就是子进程的起始点ret_from_fork。
fork子进程的内核堆栈示意图中struct pt_regs就是内核堆栈中保存的中断上下文,struct inactive_task_frame就是fork子进程的进程上下文。__switch_to_asm汇编代码中完成内核堆栈切换后的代码,正好与struct inactive_task_frame对应一一出栈,最后的__switch_to函数的最后ret正好出栈的是ret_addr,即子进程的起始点ret_from_fork。
中断上下文和进程上下文的一个关键区别是堆栈切换的方法。中断是由CPU实现的,所以中断上下文切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下文切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利用call/ret指令实现的。
Linux系统的一般执行过程
可以想象一下Linux系统的整体运行过程。其中最基本和一般的场景是:正在运行的用户态进程X切换到用户态进程Y的过程。
以32位x86系统结构linux-3.18.6为例,以系统调用作为特殊的中断简要总结如下。
(1)正在运行的用户态进程X。
(2)发生中断(包括异常、系统调用等),CPU完成以下动作。
• save cs:eip/ss:esp/eflags:当前CPU上下文压入进程X的内核堆栈。
• load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack):加载当前进程内核堆栈相关信息,跳转到中断处理程序,即中断执行路径的起点。
(3)SAVE_ALL,保存现场,此时完成了中断上下文切换,即从进程X的用户态到进程X的内核态。
(4)中断处理过程中或中断返回前调用了schedule函数,其中的switch_to做了关键的进程上下文切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下文所需的EIP等寄存器状态切换。详细过程见前述内容。
(5)标号1,即前述3.18.6内核的swtich_to代码第50行“”1:\t“ ”(地址为switch_to中的“$1f”),之后开始运行进程Y(这里进程Y曾经通过以上步骤被切换出去,因此可以从标号1继续执行)。
(6)restore_all,恢复现场,与(3)中保存现场相对应。注意这里是进程Y的中断处理过程中,而(3)中保存现场是在进程X的中断处理过程中,因为内核堆栈从进程X切换到进程Y了。
(7)iret - pop cs:eip/ss:esp/eflags,从Y进程的内核堆栈中弹出(2)中硬件完成的压栈内容。此时完成了中断上下文的切换,即从进程Y的内核态返回到进程Y的用户态。
(8)继续运行用户态进程Y。
x86-64系统结构linux-5.4.34的中断(系统调用)处理过程
(1)正在运行的用户态进程X。
(2)发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序入口。
(3)中断上下文切换,具体包括如下几点:
• swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了一个快照。
• rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调用是由系统调用入口处的汇编代码实现用户堆栈和内核堆栈的切换。
• save cs:rip/ss:rsp/rflags:将当前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/rflags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完成了中断上下文的切换,即从进程Y的内核态返回到进程Y的用户态。注意快速系统调用返回sysret与iret的处理略有不同。
(8)继续运行用户态进程Y。
通过分别总结分析32位x86系统结构linux-3.18.6和x86-64系统结构linux-5.4.34的中断(系统调用)处理过程及其中的进程上下文切换,大致上可以想象出Linux系统中的一般执行过程在Linux系统中反复执行,其中的关键点如下。
• 中断和中断返回有中断上下文的切换,CPU和内核代码中断处理程序入口的汇编代码结合起来完成中断上下文的切换。
• 进程调度过程中有进程上下文的切换,而进程上下文的切换完全由内核完成,具体包括:从一个进程的地址空间切换到另一个进程的地址空间;从一个进程的内核堆栈切换到另一个进程的内核堆栈;还有进程的CPU上下文的切换。
• 通过系统调用的形式为进程提供各种服务。
• 通过中断服务程序为I/O、内存管理等硬件的正常工作提供各种服务。
• 通过内核线程为系统提供动态的维护服务,以及完成中断服务中可延时处理的任务。
Linux系统执行过程中的几种特殊情况
(1)通过中断处理过程中的调度时机,内核线程之间互相切换。与最一般的情况非常类似,只是内核线程在运行过程中发生中断,没有进程用户态和内核态的转换。比如两个内核线程之间切换,没有用户态与内核态的切换。
(2)用户进程向内核线程的切换。比最一般的情况更简略,内核线程不需要从内核态返回到用户态,如果该内线线程是直接调用schedule主动让出CPU的,该内核线程被重新调度执行时也就没有中断上下文恢复现场的问题。
(3)内核线程向用户进程的切换。如果是内核线程主动调用schedule函数,只有进程上下文的切换,没有发生中断上下文切换。它与最一般的情况也更简略,但用户进程从内核态返回到用户态时依然需要中断上下文恢复现场返回用户态。
(4)创建的子进程第一次执行时的执行起点较为特殊,需要人为地创建了一个进程上下文环境作为起始点。比如fork一个子进程时,子进程不是从schedule函数中完成进程CPU关键上下文之后开始执行的,而是从ret_from_fork开始执行的。
(5)加载一个新的可执行程序的execve系统调用返回到用户态的情况也较为特殊,人为地创建了一个中断上下文的现场。比如execve系统调用加载新的可执行程序,在execve系统调用处理过程中修改了触发该系统调用保存的中断上下文现场,使得返回到用户态的位置修改为新程序的elf_entry或者ld动态连接器的起点地址。
Linux操作系统的构架
对于操作系统的目的,需要把握两个分界线。
• 对底层来说,与硬件交互,管理所有的硬件资源。
• 对上层来说,通过系统调用为系统程序和应用程序提供一个良好的执行环境。
Linux操作系统的整体构架如图所示。
Linux系统的执行路径
最后,我想以分析ls命令的执行过程来结束本章内容。
ls是最简单也是最复杂的命令。当用户输入ls并按回车键后,在Linux操作系统中发生的。整体过程如图所示。
如图左侧为主线,右侧则是涉及的各种操作系统知识的汇总。如果读者可以清晰地理解图中的问题与相关概念,那说明你对Linux运行机制已经有了较为深入的理解。
接下来我们从CPU的视角来看这一过程。
• CPU在运行其他进程时,Shell进程在等待获取用户输入,处于阻塞等待状态。当用户输入ls回车后,导致键盘控制器向CPU发出中断信号。
• CPU检测到键盘中断信号,转去中断处理程序,中断处理程序将Shell进程由等待状态转为就绪状态,被唤醒置于就绪队列。
• CPU从键盘中断处理程序返回前,也就是中断处理结束前会检测是否需要进程调度,交互式进程被唤醒后vruntime较低,被优先调度的Shell进程很可能会恢复执行,Shell程序会调用fork系统调用和exec系统调用。
• CPU执行Shell进程调用fork系统调用,结果是创建了一个子进程,这期间可能进程调度Shell进程被挂起,子进程得以执行,在子进程中调用exec系统调用加载了ls可执行程序,exec系统调用返回CPU开始执行子进程中的ls程序。
• CPU执行ls进程的效果就是输出当前目录下的目录和文件,这时ls进程终止,Shell进程又进入等待用户输入的状态,系统发生进程调度CPU去执行其他进程。
以上内容为中科大软件学院《Linux操作系统分析》课后总结,感谢孟宁老师的倾心教授,老师讲的太好啦(^_^)
参考资料:《庖丁解牛Linux内核分析》 孟宁 编著