《操作系统》by李治军 | 实验5.pre - switch_to 汇编代码详解

目录

【前言】

一、栈帧的处理

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 就会随之变化(esp 始终指向栈顶)。因此,函数中对大部分数据的访问都是基于帧指针 ebp 进行的。

>> 强烈推荐先看看这篇文章再继续:栈帧_yxysdcl的博客-CSDN博客

2. 为什么要处理栈帧

       现在我们知道,每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中保存着该函数所需要的各种信息。寄存器 ebp 指向当前栈帧的底部,寄存器 esp 指向当前栈帧的顶部。

       当调用一个函数时,就意味着要创建一个属于这个函数自己的栈帧。而进入该函数后这个栈帧就变成了当前栈帧,也就是说退出上一个函数的栈帧进入新的栈帧。所以要让 ebp 重新指向当前栈帧的底部,让 esp 重新指向当前栈帧的顶部,同时还要保存上一个函数的栈帧底部和栈帧顶部。

3. 执行 switch_to 前的内核栈

       现在我们应该清楚,在执行上面的 switch_to 汇编代码前,当前进程内核栈的情况应该如下图所示(我们以在 schedule 中调用 switch_to 为例)。此时还没有进入 switch_to 函数,所以 ebp 和 esp 应该分别指向 schedule 函数的栈帧底部和栈帧顶部(栈帧底部具体在哪儿就不用管了)。

       schedule() 调用 switch_to() 的时候会依次将 switch_to 的参数(从右至左):_LDT(next) 和 pnext,以及 switch_to 的返回地址:{   依次入栈。

4. 栈帧处理代码分析

接下来我们逐条分析 switch_to 中栈帧处理的部分。

pushl %ebp

>> 将 ebp 入栈 <<

       这条指令的目的就是保存 schedule() 的栈帧底部。因为现在进入了 switch_to 函数,也就是说要切换到 switch_to 的栈帧了,所以必须把上面函数的栈帧底部和栈帧顶部保存起来,这样 switch_to 结束返回时才能成功返回到之前调用函数的位置。ps:这里不用另外保存栈顶,因为上一个栈帧的顶部就是下一个的栈帧的底部(两栈帧相邻)。

所以 ebp 入栈后内核栈变为:(这里 switch_to 的栈帧底部和顶部是一样的)

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 中保存着 pnext(目标进程的 PCB),current 是当前进程的 PCB。这两句代码执行后,ebx 和 current 都指向目标进程的 PCB,eax 则指向当前进程的 PCB。PCB 切换完成!

四、TSS 内核栈指针的重写

1. 为什么要重写 TSS 中的内核栈

       执行 INT 0x80 中断之后,进程进入内核,这时要先找到内核栈的位置,将用户态下的 SS:ESP、CS:EIP、EFLAGS 都压入内核栈中保存下来,实现用户栈到内核栈的切换。以上的寻找内核栈和压栈操作都是系统自动完成的。也就是说系统会根据一些硬件寄存器(TR)知道这个哪个进程,以及该进程对应的内核栈在哪里,同时将用户态下的 SS:ESP、CS:EIP、EFLAGS 都压入内核栈中保存下来。

       虽然此时不再使用 TSS 进行进程切换,但是 Intel 的中断处理机制还是要保持,因为中断机制就是通过 TR 指向的 TSS 来找到当前进程的内核栈,并自动将用户栈等相关信息压入对应内核栈。也就是说,找到内核栈还得依靠 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. TSS 内核栈重写代码分析

movl tss,%ecx

>> 将 tss(TR 指向的 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 = PAGE_SIZE,所以 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 指向哪儿。

       所以完成内核栈的切换非常简单,就是将寄存器 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 就指向新进程的内核栈栈顶了ebp 指向的还是原来进程的内核栈(假设从进程 A 切换到进程 B)。到此正式完成了内核栈的切换!

六、LDT 的切换

1. LDT 切换代码分析

movl 12(%ebp), %ecx

>> 将 ebp 指针 + 12 指向的数据传递给了 ecx 寄存器 <<

       现在的内核栈(进程 A)中数据如下图所示。注意现在 ebp 指向的还是原来进程的内核栈,但 esp 指向的是新进程的内核栈。所以 ebp + 12 指向的就是 _LDT(next)(目标进程 - 进程 B 的 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

上面的 5 条指令执行前内核栈情况如图所示:

       可以看出进程 A 和进程 B 的内核栈整体结构是差不多的。因为任何一个进程进入内核,用户态下的 SS:ESP、CS:EIP、EFLAGS 等都会被自动压入内核栈中,所以内核栈栈底的内容都是该进程用户态下的相关信息(SS ~ CS)。而进程需要切换时通过 schedule() 进行调度找到目标进程,schedule() 退出前又调用了 switch_to 进行进程切换,这段过程对每个进程都是一样。所以从调用 switch_to 开始,_LDT(next) ~ eax 就依次入栈,每个进程都如此。

       回到这 5 条指令,此时已经切换了PCB、内核栈、LDT,剩用户栈还没有切换(通过 iret 实现)。esp 指向新进程的内核栈栈顶,但 ebp 还指向原来进程的内核栈。经过前 3 条出栈指令后,内核栈中情况如图所示:

popl %eax
popl %ebx
popl %ecx

       此时 esp 指向的进程 B 内核栈栈顶的 ebp 是什么?就是当前进程——进程 B 上一次调用 schedule() 时 schedule 的栈帧底部!如果忘了可以返回 “栈帧的处理” 看一看。现在继续执行下一条指令:

popl %ebp

执行后内核栈情况如下图所示,也就是回到了当时进程 B 停止时调用 schedule 的状态。

现在执行最后一条指令:

ret

       这条指令就是 switch_to 的返回指令,ret 就是要从内核栈中弹出一个 32 位数作为 EIP 并跳转去执行。这里的 ret 执行后弹出 schedule() 的  }  并执行,而这个 是 schedule() 的返回指令,所以会弹出 schedule() 函数的栈帧内容 —— 包括 pnext 和 _LDT(next),然后再执行一次 ret。之后进程 B 继续在内核中运行一段代码后,就会通过 iret 退出内核(系统调用只是进入内核溜达一圈)。

       这里我们假设进程 B 的内核栈和进程 A 的内核栈的结构是完全一样的(如下图所示),看看 switch_to 返回之后到底发生了些什么。

       switch_to 的 ret 从内核栈中弹出 schedule() 的  }  并执行。schedule() 的  }  弹出 pnext 和 _LDT(next),然后再执行一次 ret。这次的 ret 就会取出 ret_from_sys_call 的地址并跳转去执行。我们来看看 ret_from_sys_call 函数(在 system_call.s 中):

经过 ret_from_sys_call 的一系列出栈操作后,进程 B 的内核栈情况如下图所示:

       进入内核通过 INT 0x80,退出内核就通过 iret,最后 ret_from_sys_call 这个 iret 指令就实现内核栈到用户栈的切换。

       iret 将推入堆栈的段地址和偏移地址弹出,使程序返回到原来发生中断的地方。这里 iret 会执行一系列操作,源 CS ~ 源 SS 出栈。通过 PC 和 CS 返回进程 B 停止时的位置继续往下执行,通过 SS 和 SP 切换到用户栈。至此就完成了进程 A 到进程 B 的全部切换。

猜你喜欢

转载自blog.csdn.net/Amentos/article/details/130883670