目录
【前言】
一、栈帧的处理
1. 什么是栈帧
2. 为什么要处理栈帧
3. 执行 switch_to 前的内核栈
4. 栈帧处理代码分析
二、PCB 的比较
1. 根据 PCB 判断进程切换与否
2. PCB 比较代码分析
三、PCB 的切换
1. 什么是 PCB 的切换
2. PCB 切换代码分析
四、TSS 内核栈指针的重写
1. 为什么要重写 TSS 中的内核栈
2. 内核栈重写代码分析
五、内核栈的切换
1. 如何完成内核栈切换
2. 内核栈切换代码分析
六、LDT 的切换
1. LDT 切换代码分析
七、用户栈的切换
1. switch_to 退出代码分析
【前言】
在李治军老师的《操作系统》课程的实验 5(基于内核栈切换的进程切换)中,需要完成 switch_to 函数的汇编代码编写。代码很容易获取,网上的资源非常多,但是拿到了看不懂……
所以本文章将会对 switch_to 的代码进行逐条分析,希望能帮助理解基于内核栈的进程切换的整体流程。
switch_to() 完整汇编代码:
.align 2
switch_to:
//因为该汇编函数要在c语言中调用,所以要先在汇编中处理栈帧
pushl %ebp
movl %esp,%ebp
pushl %ecx
pushl %ebx
pushl %eax
//将ebp+8指向的数据(目标进程的PCB)传递给ebx,然后进行判断:
//如果目标进程的pcb <<等于>> 当前进程的pcb => 不需要进行切换,直接退出函数调用
//如果目标进程的pcb <<不等于>> 当前进程的pcb => 需要进行切换,直接跳到下面去执行
movl 8(%ebp),%ebx
cmpl %ebx,current
je 1f
/** 执行到此处,就要进行真正的基于堆栈的进程切换了 **/
// 切换PCB
movl %ebx,%eax
xchgl %eax,current
// 重写TSS中内核栈的指针
movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)
// 切换内核栈
movl %esp,KERNEL_STACK(%eax)
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp
// 切换LDT
movl 12(%ebp),%ecx
lldt %cx
// 切换 LDT 之后
movl $0x17,%ecx
mov %cx,%fs
// 这一段先不用管
cmpl %eax,last_task_used_math
jne 1f
clts
// 现在进入新进程的内核栈工作了,所以接下来做的四次弹栈以及ret处理使用的都是新进程内核栈中的东西
1: popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
一、栈帧的处理
1. 什么是栈帧
大多数 CPU 上的程序实现都是通过使用栈来支持函数调用操作。栈被用来传递函数参数、存储返回地址、临时保存寄存器原有值以备恢复以及用来存储局部数据。单个函数调用操作所使用的栈部分被称为栈帧结构。
栈帧结构的两端由两个指针来指定:① ebp:用作帧指针(指向栈帧底部);② esp:用作栈指针(指向栈帧顶部)。在函数执行过程中,肯定会有数据的入栈和出栈,而栈指针 esp 就会随之移动。因此,函数中对大部分数据的访问都是基于帧指针 ebp 进行的。
>> 强烈推荐先看看这篇文章再继续:栈帧_yxysdcl的博客-CSDN博客
2. 为什么要处理栈帧
现在我们知道,每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中保存着该函数所需要的各种信息。寄存器 ebp 指向当前栈帧的底部,寄存器 esp 指向当前栈帧的顶部。
当调用一个函数时,就意味着要创建一个属于这个函数自己的栈帧,而进入该函数后这个栈帧就变成了当前栈帧,所以要让 ebp 指向这个栈帧的底部,让 esp 指向这个栈帧的顶部,同时还要保存好上一个函数的栈帧底部和栈帧顶部。
3. 执行 switch_to 前的内核栈
现在我们应该清楚,在执行上面的 switch_to 汇编代码前,当前进程内核栈的情况应该如下图所示(我们以在 schedule() 中调用 switch_to 为例),此时还没有进入 switch_to 函数,所以 ebp 和 esp 应该分别指向 schedule 函数的栈帧底部和栈帧顶部。
4. 栈帧处理代码分析
接下来我们逐条分析 switch_to 中栈帧处理的部分。
pushl %ebp
>> 将 ebp 入栈 <<
这条指令的目的就是保存 schedule() 的栈帧底部。因为现在进入了 switch_to 函数,也就是说要有 switch_to 自己的栈帧了,所以必须把上面函数的栈帧底部和栈帧顶部保存起来,之后才能修改 ebp 和 esp 的指向。这里不用另外保存栈顶,因为上一个栈帧的顶部就是下一个的栈帧的底部(两栈帧相邻)。ebp 入栈后内核栈变为:(esp 会随着入栈/出栈自动变化)
movl %esp,%ebp
>> 将 esp 中内容传递给 ebp <<
原来 ebp 指向 schedule 函数的栈帧底部,但这句代码执行完后,ebp 和 esp 就都指向刚刚压入的 ebp 位置,也就是 switch_to 函数的栈帧底部。
pushl %ecx
pushl %ebx
pushl %eax
>> 将 ecx、ebx、eax 依次入栈 <<
入栈后内核栈如下图所示。可以看到现在 ebp 指向 switch_to() 的栈帧底部,esp 指向 switch_to() 的栈帧顶部,而且上一个函数 schedule 的栈帧底部指针就保存在调用的 switch_to 函数的栈帧底部位置,之后 switch_to 结束时就要通过这个 ebp 返回 schedule。现在栈帧就处理完毕了!
二、PCB 的比较
1. 根据 PCB 判断进程切换与否
① 目标进程的 PCB = 当前进程的 PCB => 无需进行切换,直接退出函数调用
② 目标进程的 PCB ≠ 当前进程的 PCB => 需要进行切换,接着进行切换操作
2. PCB 比较代码分析
movl 8(%ebp),%ebx
>> 将 ebp 指针 + 8 指向的数据传递给了 ebx 寄存器 <<
Linux 0.11 内核栈的地址顺序从上往下,是由高到低的。所以 ebp + 8 指向的就是 pnext(目标进程的 PCB ),所以 ebx 现在就存储着 pnext。
cmpl %ebx,current
je 1f
>> 比较 ebx 中的内容和 current <<
ebx 中保存着目标进程的 PCB,current 是当前进程的 PCB。
① 如果两个进程的 PCB 相同,则跳转到 1f 位置处,switch_to 接下来的代码也不用执行,不会进行进程切换。
② 如果两个进程的 PCB 不同,则继续执行 switch_to 接下来的代码进行进程切换。
三、PCB 的切换
1. 什么是 PCB 的切换
切换 PCB 就是要让 current 切换为目标进程的 PCB。
2. PCB 切换代码分析
movl %ebx,%eax
xchgl %eax,current
>> 将 ebx 中数据置给 eax ,再交换 eax 和 current 的内容 <<
执行前 ebx 中保存着目标进程的 PCB,current 是当前进程的 PCB。这两句代码执行后,ebx 和 current 都指向目标进程的 PCB,eax 则指向当前进程的 PCB,切换完成!
四、TSS 内核栈指针的重写
1. 为什么要重写 TSS 中的内核栈
执行 INT 0x80 中断之后,进程进入内核,要先找到内核栈的位置。而系统需要根据一些硬件寄存器(TR)知道这个哪个进程,以及该进程对应的内核栈在哪里。同时还会将用户态下的 SS:ESP、CS:EIP、EFLAGS 都压入内核栈中保存下来。
也就是说,要先找到当前进程的内核栈,才能从用户栈切换到内核栈。而找到内核栈还得依靠 TR 指向的当前 TSS。虽然此时不再使用 TSS 进行进程切换,但是 Intel 的中断处理机制还是要保持,因为中断机制就是通过 TR 指向的 TSS 来找到当前进程的内核栈,并自动将用户栈等相关信息压入对应内核栈。所以每个进程仍然需要一个TSS,这样系统才能并通过 TSS 中的内核栈指针 esp0 找到当前进程的内核栈。
这里采用的方案是让所有进程共用一个TSS(即 0 号进程的TSS),并且这个 TSS 指向当前进程。在 sched.c 中定义的全局变量 struct tss_struct *tss = &(init_task.task.tss); 就是 0 号进程的 TSS,所有的进程都共用这个 TSS,任务切换时再发生变化。
这个唯一的 TSS 的目的就是:在中断处理时,帮助 CPU 找到当前进程的内核栈的位置
2. 内核栈重写代码分析
movl tss,%ecx
>> 将 tss(当前进程的 TSS)赋给 ecx 寄存器 <<
所以现在 ecx 也保存了当前进程的 TSS。
addl $4096,%ebx
>> ebx + 4096 <<
ebx 本来指向目标进程的 PCB,执行该指令后,ebx 就指向目标进程的内核栈。Linux 0.11 中进程的 PCB 和内核栈在同一页内存上(即一块 4KB 大小的内存)。其中 PCB 位于这页内存的低地址,内核栈位于这页内存的高地址。也就是说,低地址空间 base 用来存放进程的 PCB,而 base + PAGE_SIZE 则作为该进程的内核栈的栈底。
为什么偏移量是 4096 ?因为 4096 = 4KB = 一页内存大小,所以 ebx 加 4096 就可以得到内核栈的地址。
movl %ebx,ESP0(%ecx)
>> 将 ebx 中内容(目标进程的内核栈地址)复制到 ecx + ESP0 指向的位置 <<
ecx 指向当前进程的 TSS,而 ESP0 = 4。我们再看 tss_struct 的定义,发现偏移为 4 的地方就是 TSS 中的内核栈指针 esp0,所以 ecx + ESP0 对应位置就是 tss 中的内核栈指针 esp0。将目标进程的内核栈地址赋给 esp0,实现了 tss 的重写。
五、内核栈的切换
1. 如何完成内核栈切换
上一步只是修改了 tss 中的内核栈指针 esp0,帮助 CPU 找到当前进程的内核栈以进行相关操作,但并没有实际切换内核栈,因为 esp 还是指向的当前进程的内核栈栈顶。
完成内核栈的切换非常简单,就是将寄存器 esp(内核栈使用到当前情况时的栈顶位置)的值保存到当前进程 PCB 中的对应位置,再从目标进程 PCB 中的对应位置取出保存的内核栈栈顶放入 esp 寄存器中。这样处理完后,再通过 esp 使用内核栈时使用的就是目标进程的内核栈了。
2. 内核栈切换代码分析
movl %esp,KERNEL_STACK(%eax)
>> 将 esp 中内容保存到 eax + KERNEL_STACK 位置 <<
eax 指向当前进程的 PCB(如果忘了可以返回 “PCB 的切换” 去看看),而 KERNEL_STACK 的数值没有明确定义,因为 Linux 0.11 中 PCB 的定义里并没有保存内核栈指针这个域(kernelstack),所以需要我们自己找位置加上这个定义,而宏 KERNEL_STACK 就是我们添加的那个位置。添加位置不同,KERNEL_STACK 的值也会不同。
但是不管 KERNEL_STACK 的值是什么,eax + KERNEL_STACK 就是当前进程的 PCB 中对应存储内核栈指针的位置。所以这条指令实现了将寄存器 esp(内核栈使用到当前情况时的栈顶位置)的值保存到当前进程 PCB 中的对应位置。
movl 8(%ebp),%ebx
>> 将 ebp 指针 + 8 指向的数据传递给了 ebx 寄存器 <<
ebp + 8 指向的还是 pnext(目标进程的 PCB ),所以现在 ebx 存储着目标进程的 PCB。
movl KERNEL_STACK(%ebx),%esp
>> 将 ebx + KERNEL_STACK 位置的内容保存到 esp 中 <<
ebx + KERNEL_STACK 位置的内容就是目标进程的 PCB 中对应存储内核栈指针的位置,所以这条指令实现了从目标进程 PCB 中的对应位置取出保存好的内核栈栈顶放入 esp 寄存器中,现在的 esp 就指向新进程的内核栈栈顶了。到此正式完成了内核栈的切换!
六、LDT 的切换
1. LDT 切换代码分析
movl 12(%ebp), %ecx
>> 将 ebp 指针 + 12 指向的数据传递给了 ecx 寄存器 <<
当前内核栈中数据如下图所示,注意现在 ebp 指向的还是原来进程的内核栈,但 esp 指向的是新进程的内核栈。所以 ebp + 12 指向的就是 _LDT(next)(目标进程的 LDT ),这条指令就是负责取出对应 LDT(next) 的那个参数,这里暂时不用深入理解。
lldt %cx
这条指令负责修改 LDTR 寄存器。一旦完成了修改,下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间就实现了分离。
movl $0x17,%ecx
mov %cx,%fs
这两条指令在 LDT 切换完成之后,作用是重新取一下段寄存器 fs 的值。这两句指令必须要加、且必须出现在切换完 LDT 之后。因为 fs 的作用——通过 fs 访问进程的用户态内存,而 LDT 切换完成就意味着切换了分配给进程的用户态内存地址空间,所以前一个 fs 指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,所以需要用这两条指令来重取 fs。
七、用户栈的切换
1. switch_to 退出代码分析
cmpl %eax,last_task_used_math
jne 1f
clts
在 PCB、内核栈和 LDT 切换完成之后还有上面这段代码,我们暂时忽略不管,直接来到最后的 4 条出栈指令和 ret :
popl %eax
popl %ebx
popl %ecx
popl %ebp
ret
上面的指令执行前内核栈情况如图所示:
可以看出进程 A 和进程 B 的内核栈整体结构是差不多的。因为任何一个进程进入内核后,用户态下的 SS:ESP、CS:EIP、EFLAGS 都会被自动压入内核栈中,所以内核栈栈底的内容都是该进程用户态下的相关信息(SS ~ CS)。而进程需要切换时通过 schedule() 进行调度找到目标进程,schedule() 退出前又调用了 switch_to 进行进程切换,这段过程对每个进程都是一样。所以从调用 switch_to 开始,_LDT(next) ~ eax 就依次入栈,每个进程都如此。
回到这 5 条指令,此时已经切换了PCB、内核栈、LDT,剩用户栈还没有切换。esp 指向新进程的内核栈栈顶,但 ebp 还指向原来进程的内核栈。经过前 3 条出栈指令后,内核栈中情况如图所示:
popl %eax
popl %ebx
popl %ecx
此时 esp 指向的当前进程内核栈栈顶的 ebp 是什么?就是当前进程——进程 B 上一次调用 schedule() 时 schedule 的栈帧底部!所以现在继续执行下一条指令:
popl %ebp
执行后内核栈情况如图所示,也就是回到了进程 B 停止时调用 schedule 时的状态。
现在执行最后一条指令:
ret
这条指令就是 switch_to 的返回指令,执行之后就会弹出 schedule() 的 } 并执行,而这个 } 就是 schedule() 的返回指令。之后在内核中运行一段代码后,就会退出内核(因为系统调用只是进入内核溜达一圈)。进入内核通过 INT 0x80,退出内核就通过 iret,这 iret 指令就实现用户栈的切换。iret 会执行一系列的操作,其中源 CS ~ 源 SS 也会出栈,而 SS、SP 指向用户栈,就切换到了进程 B 的用户栈,接下来就从进程 B 停止时的位置开始继续往下执行。至此就完成了进程 A 到进程 B 的全部切换。