基于内核栈切换的进程切换

1、首先了结一些汇编常识

常见寄存器:
在这里插入图片描述
2、5种寻址方式
movb(8位)、movw(16位)、movl(32位)、movq(64位)

  • 寄存器寻址:

movl %eax, %edx
eax -> edx

  • 立即数寻址:

movl $0x123, %edx
数字->寄存器

  • 直接寻址:

movl 0x123, %edx
直接访问内存地址数据,edx = *(int32_t *)0x123;

  • 间接寻址:

movl (%ebx), %edx
%ebx 是个内存地址,(%ebx)指的是该地址中的数据,edx = (int32_t)ebx;

  • 变址寻址:

movl 4(%ebx), %edx
edx = (int32_t)(ebx+4);

3、堆栈操作

这篇文章文章讲得很到位:https://blog.csdn.net/candycat1992/article/details/8130508
总结如下:
总体看来,构建一个堆栈框架包含了以下几个步骤:

  • 如果要调用的函数有参数,将参数压入堆栈;
  • 用call指令调用子程序;
  • 此时子程序开始,将ebp寄存器压入栈:push ebp;
  • 将ebp的值设为esp(只是为了方便以后访问参数和局部变量):mov ebp, esp;
  • 若有局部变量,将esp的值减去相应的值。假设我们有3个DWORD类型的局部变量,则:sub esp, 12;
  • 若有需要保存的寄存器,将要保存的寄存器压栈。
    完成上述各个步骤后,堆栈的情况如下图:
    在这里插入图片描述

从上图可以看出来,若要访问第一个被压入的参数,假设将第一个参数的值放到eax中,我们可以使用:

mov eax, [ebp + 8]

因为call指令会自动将返回地址压入堆栈,因此紧邻ebp上方的堆栈值不是参数,所以最近的参数地址是ebp + 8,而不是ebp + 4。访问其他参数类似,如[ebp + 12]等等。

清理堆栈

在运行完函数的基本代码后,返回前我们需要清理堆栈以使函数能够正确返回,并恢复保存的寄存器值,还要警惕内存泄露问题。
清理堆栈的步骤如下:

  • 将之前保存的寄存器值以相反的顺序弹出堆栈;
  • 将esp的值设为ebp的值,以销毁局部变量:mov esp, ebp;

到了这里,esp的值已经指向了函数正确的返回地址。此时,若直接调用ret指令是可以达到返回调用函数的目的的。但我们可以发现之前被压入的参数并没有得到处理,仍然存在在堆栈中,当我们继续运行代码时,也没有人会为之前的函数”擦屁股“,即造成了内存泄露。解决这个问题有两个方法:

  • 一个简单的方法是在call指令后面紧跟一条add指令,将esp的值指向一个正确的地址。例如,如果我们之前压入了3个参数,那么应运行:add
    esp, 12;
  • 另一个更好的方法是是使用STDCALL调用规定,即修改子程序代码的ret指令。如对于上面的例子,应改写ret为:ret 12。

显然第二个方法更符合我们的习惯,因为”自己的事情自己做“,谁的函数谁的参数自己处理。

总结一下,汇编中一个子程序的代码长得像下面这个样子:

push ebp                        ;保存ebp
mov ebp, esp                    ;将ebp设为当前esp值
sub esp, 4*局部变量个数         ;局部变量
push eax                        ;将寄存器压栈
push esi
……

mov eax, [ebp + 8]	        ;得到第一个参数
mov edi, [ebp + 12]	        ;得到第二个参数
……

pop esi                         ;恢复寄存器值
pop eax
mov esp, ebp                    ;销毁局部变量
pop ebp                         ;恢复ebp的值

ret 4*被压入参数个数 

进入正文:进程切换 5 步论

上图:用户栈1 、 内核栈1 、内核栈的切换 、内核栈2 、用户栈2
在这里插入图片描述
实验目标:
要实现基于内核栈的任务切换,主要完成如下三件工作:

(1)重写 switch_to;
(2)将重写的 switch_to 和 schedule() 函数接在一起;
(3)修改现在的 fork()。

目前 Linux 0.11 中工作的 schedule() 函数是首先找到下一个进程的数组位置 next,而这个 next 就是 GDT 中的 n,所以这个 next 是用来找到切换后目标 TSS 段的段描述符的,一旦获得了这个 next 值,直接调用上面剖析的那个宏展开 switch_to(next);就能完成如图 TSS 切换所示的切换了。

现在,我们不用 TSS 进行切换,而是采用切换内核栈的方式来完成进程切换,所以在新的 switch_to 中将用到当前进程的 PCB、目标进程的 PCB、当前进程的内核栈、目标进程的内核栈等信息。由于 Linux 0.11 进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),其中 PCB 位于这页内存的低地址,栈位于这页内存的高地址;另外,由于当前进程的 PCB 是用一个全局变量 current 指向的,所以只要告诉新 switch_to()函数一个指向目标进程 PCB 的指针就可以了。同时还要将 next 也传递进去,虽然 TSS(next)不再需要了,但是 LDT(next)仍然是需要的,也就是说,现在每个进程不用有自己的 TSS 了,因为已经不采用 TSS 进程切换了,但是每个进程需要有自己的 LDT,地址分离地址还是必须要有的,而进程切换必然要涉及到 LDT 的切换。

综上所述,需要将目前的 schedule() 函数(在 kernal/sched.c 中)做稍许修改,即将下面的代码:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c) 
    c = (*p)->counter, next = i; 

//......

switch_to(next);

修改为:

if ((*p)->state == TASK_RUNNING && (*p)->counter > c) 
    c = (*p)->counter, next = i, pnext = *p;

//.......

switch_to(pnext, LDT(next)); 

实现 switch_to 是本次实践项目中最重要的一部分。

由于要对内核栈进行精细的操作,所以需要用汇编代码来完成函数 switch_to 的编写。

这个函数依次主要完成如下功能:由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理 ebp 寄存器;接下来要取出表示下一个进程 PCB 的参数,并和 current 做一个比较,如果等于 current,则什么也不用做;如果不等于 current,就开始进程切换,依次完成 PCB 的切换、TSS 中的内核栈指针的重写、内核栈的切换、LDT 的切换以及 PC 指针(即 CS:EIP)的切换。

switch_to:
    pushl %ebp
    movl %esp,%ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx
    cmpl %ebx,current
    je 1f
! 切换PCB
    ! ...
! TSS中的内核栈指针的重写
    ! ...
! 切换内核栈
    ! ...
! 切换LDT
    ! ...
    movl $0x17,%ecx
    mov %cx,%fs
! 和后面的 clts 配合来处理协处理器,由于和主题关系不大,此处不做论述
    cmpl %eax,last_task_used_math 
    jne 1f
    clts

1:    popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
ret

虽然看起来完成了挺多的切换,但实际上每个部分都只有很简单的几条指令。完成 PCB 的切换可以采用下面两条指令,其中 ebx 是从参数中取出来的下一个进程的 PCB 指针,

movl %ebx,%eax
xchgl %eax,current

经过这两条指令以后,eax 指向现在的当前进程,ebx 指向下一个进程,全局变量 current 也指向下一个进程。

TSS 中的内核栈指针的重写可以用下面三条指令完成,其中宏 ESP0 = 4,struct tss_struct *tss = &(init_task.task.tss); 也是定义了一个全局变量,和 current 类似,用来指向那一段 0 号进程的 TSS 内存。

前面已经详细论述过,在中断的时候,要找到内核栈位置,并将用户态下的 SS:ESP,CS:EIP 以及 EFLAGS 这五个寄存器压到内核栈中,这是沟通用户栈(用户态)和内核栈(内核态)的关键桥梁,而找到内核栈位置就依靠 TR 指向的当前 TSS。

现在虽然不使用 TSS 进行任务切换了,但是 Intel 的这态中断处理机制还要保持,所以仍然需要有一个当前 TSS,这个 TSS 就是我们定义的那个全局变量 tss,即 0 号进程的 tss,所有进程都共用这个 tss,任务切换时不再发生变化

movl tss,%ecx
addl $4096,%ebx
movl %ebx,ESP0(%ecx)

定义 ESP0 = 4 是因为 TSS 中内核栈指针 esp0 就放在偏移为 4 的地方,看一看 tss 的结构体定义就明白了。

完成内核栈的切换也非常简单,和我们前面给出的论述完全一致,将寄存器 esp(内核栈使用到当前情况时的栈顶位置)的值保存到当前 PCB 中,再从下一个 PCB 中的对应位置上取出保存的内核栈栈顶放入 esp 寄存器,这样处理完以后,再使用内核栈时使用的就是下一个进程的内核栈了。

由于现在的 Linux 0.11 的 PCB 定义中没有保存内核栈指针这个域(kernelstack),所以需要加上,而宏 KERNEL_STACK 就是你加的那个位置,当然将 kernelstack 域加在 task_struct 中的哪个位置都可以,但是在某些汇编文件中(主要是在 kernal/system_call.s 中)有些关于操作这个结构一些汇编硬编码,所以一旦增加了 kernelstack,这些硬编码需要跟着修改,由于第一个位置,即 long state 出现的汇编硬编码很多,所以 kernelstack 千万不要放置在 task_struct 中的第一个位置,当放在其他位置时,修改 kernal/system_call.s 中的那些硬编码就可以了

KERNEL_STACK = 12
movl %esp,KERNEL_STACK(%eax)
! 再取一下 ebx,因为前面修改过 ebx 的值
movl 8(%ebp),%ebx
movl KERNEL_STACK(%ebx),%esp

ask_struct 的定义:

// 在 include/linux/sched.h 中
struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack;
//......

由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化,需要将原来的 #define INIT_TASK { 0,15,15, 0,{{},},0,… 修改为 #define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,…,即在 PCB 的第四项中增加关于内核栈栈指针的初始化。

再下一个切换就是 LDT 的切换了,指令 movl 12(%ebp),%ecx 负责取出对应 LDT(next)的那个参数,指令 lldt %cx 负责修改 LDTR 寄存器,一旦完成了修改,下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离。

最后一个切换是关于 PC 的切换,和前面论述的一致,依靠的就是 switch_to 的最后一句指令 ret,虽然简单,但背后发生的事却很多:schedule() 函数的最后调用了这个 switch_to 函数,所以这句指令 ret 就返回到下一个进程(目标进程)的 schedule() 函数的末尾,遇到的是},继续 ret 回到调用的 schedule() 地方,是在中断处理中调用的,所以回到了中断处理中,就到了中断返回的地址,再调用 iret 就到了目标进程的用户态程序去执行,和书中论述的内核态线程切换的五段论是完全一致的。

这里还有一个地方需要格外注意,那就是 switch_to 代码中在切换完 LDT 后的两句,即:

! 切换 LDT 之后
movl $0x17,%ecx
mov %cx,%fs

这两句代码的含义是重新取一下段寄存器 fs 的值,这两句话必须要加、也必须要出现在切换完 LDT 之后,这是因为在实践项目 2 中曾经看到过 fs 的作用——通过 fs 访问进程的用户态内存,LDT 切换完成就意味着切换了分配给进程的用户态内存地址空间,所以前一个 fs 指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,所以就需要用这两条指令来重取 fs。

不过,细心的读者可能会发现:fs 是一个选择子,即 fs 是一个指向描述符表项的指针,这个描述符才是指向实际的用户态内存的指针,所以上一个进程和下一个进程的 fs 实际上都是 0x17,真正找到不同的用户态内存是因为两个进程查的 LDT 表不一样,所以这样重置一下 fs=0x17 有用吗,有什么用?要回答这个问题就需要对段寄存器有更深刻的认识,实际上段寄存器包含两个部分:显式部分和隐式部分,如下图给出实例所示,就是那个著名的 jmpi 0, 8,虽然我们的指令是让 cs=8,但在执行这条指令时,会在段表(GDT)中找到 8 对应的那个描述符表项,取出基地址和段限长,除了完成和 eip 的累加算出 PC 以外,还会将取出的基地址和段限长放在 cs 的隐藏部分,即图中的基地址 0 和段限长 7FF。为什么要这样做?下次执行 jmp 100 时,由于 cs 没有改过,仍然是 8,所以可以不再去查 GDT 表,而是直接用其隐藏部分中的基地址 0 和 100 累加直接得到 PC,增加了执行指令的效率。现在想必明白了为什么重新设置 fs=0x17 了吧?而且为什么要出现在切换完 LDT 之后?

6、fork
开始修改 fork() 了,和书中论述的原理一致,就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESP,CS:IP 关联在一起。

另外,由于 fork() 这个叉子的含义就是要让父子进程共用同一个代码、数据和堆栈,现在虽然是使用内核栈完成任务切换,但 fork() 的基本含义不会发生变化。

将上面两段描述联立在一起,修改 fork() 的核心工作就是要形成如下图所示的子进程内核栈结构。
在这里插入图片描述

不难想象,对 fork() 的修改就是对子进程的内核栈的初始化,在 fork() 的核心实现 copy_process 中,p = (struct task_struct *) get_free_page();用来完成申请一页内存作为子进程的 PCB,而 p 指针加上页面大小就是子进程的内核栈位置,所以语句 krnstack = (long *) (PAGE_SIZE + (long) p); 就可以找到子进程的内核栈位置,接下来就是初始化 krnstack 中的内容了。

*(--krnstack) = ss & 0xffff;
*(--krnstack) = esp;
*(--krnstack) = eflags;
*(--krnstack) = cs & 0xffff;
*(--krnstack) = eip;

这五条语句就完成了上图所示的那个重要的关联,因为其中 ss,esp 等内容都是 copy_proces() 函数的参数,这些参数来自调用 copy_proces() 的进程的内核栈中,就是父进程的内核栈中,所以上面给出的指令不就是将父进程内核栈中的前五个内容拷贝到子进程的内核栈中,图中所示的关联不也就是一个拷贝吗?

接下来的工作就需要和 switch_to 接在一起考虑了,故事从哪里开始呢?回顾一下前面给出来的 switch_to,应该从 “切换内核栈” 完事的那个地方开始,现在到子进程的内核栈开始工作了,接下来做的四次弹栈以及 ret 处理使用的都是子进程内核栈中的东西

1: popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
ret

为了能够顺利完成这些弹栈工作,子进程的内核栈中应该有这些内容,所以需要对 krnstack 进行初始化:

*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
// 这里的 0 最有意思。
*(--krnstack) = 0;

现在到了 ret 指令了,这条指令要从内核栈中弹出一个 32 位数作为 EIP 跳去执行,所以需要弄一个函数地址(仍然是一段汇编程序,所以这个地址是这段汇编程序开始处的标号)并将其初始化到栈中。我们弄的一个名为 first_return_from_kernel 的汇编标号,然后可以用语句 *(–krnstack) = (long) first_return_from_kernel; 将这个地址初始化到子进程的内核栈中,现在执行 ret 以后就会跳转到 first_return_from_kernel 去执行了。

想一想 first_return_from_kernel 要完成什么工作?PCB 切换完成、内核栈切换完成、LDT 切换完成,接下来应该那个“内核级线程切换五段论”中的最后一段切换了,即完成用户栈和用户代码的切换,依靠的核心指令就是 iret,当然在切换之前应该回复一下执行现场,主要就是 eax,ebx,ecx,edx,esi,edi,gs,fs,es,ds 等寄存器的恢复.

下面给出了 first_return_from_kernel 的核心代码,当然 edx 等寄存器的值也应该先初始化到子进程内核栈,即 krnstack 中。

popl %edx
popl %edi
popl %esi
pop %gs
pop %fs
pop %es
pop %ds
iret

后别忘了将存放在 PCB 中的内核栈指针修改到初始化完成时内核栈的栈顶,即:

p->kernelstack = stack;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值