哈工大操作系统实验四——内核栈的切换

回顾内核栈切换的五步

  1. 用户程序调用Int 0x80中断 进入内核 通过将5个东西(ss,sp,eflags,cs,ip)压入内核栈(这里是硬件实现),
    然后手写push压入当前程序现场(其他有用的寄存器值)
    建立起和内核栈的连接
    思考
    (1)这是一种什么连接?
    我把用户栈的地址push在内核栈里,当我要返回用户栈时,我就pop出去,这样子用户地址,就和内核地址关联起来了
    (2)存放在内核地址的哪里?即内核栈的栈顶和栈底存在哪里?
    使用TSS切换时,存放在TSS里;具体见2.4节的图
    内核栈切换模式,存放在PCB里;具体见2.5节的task_struck代码
    这是设计时就规定的。
  2. 进入内核运行,且出现一些需要切换进程的情况,如sys_write,那么需要找到 切换过去执行的下一个进程
    找哪一个最好——调度算法,此处不考虑
    在代码中的体现是得到一个next,然后调用swich_to(next)完成切换
  3. 利用next完成内核栈的切换
  4. 根据切换后的内核栈 手写pop指令恢复之前用push压入的程序执行现场
  5. 用iret恢复之前被Int压入的寄存器内容
    内核栈切换五步

开始实验

0、说明

(1)linux0.11 不支持内核级线程,但是进程和内核级线程非常像,只是没有资源切换。(进程=内核级线程(指令序列)+内存资源
所以,以下源码分析里都是用PCB,理解成TCB也没问题。
(2)汇编为AT&T格式
(3)要实现基于内核栈的任务切换,主要完成如下三件工作:

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

1、修改schedule() 函数

将目前的 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));

在kernel/sche.c中添加声明

extern long switch_to(struct task_struct *p, unsigned long address);

在个sche.c文件的schedule()函数中添加pnext,如下图:

struct task_struct* pnext = &(init_task.task);

schedule()函数中添加pnext

1.1 schedule()函数的作用(为什么要修改schedule()函数?)

schedule()函数的作用是实现进程调度(调度=选一个+切换),
在schedule()中调用switch_to()实现进程切换

1.2 为什么要为switch_to()增加pnext参数?

原linux0.11内核利用TSS完成切换,传给switch_to()函数的内容只有next(作为task[]的index,取出的内容是指向下一个线程的PCB的指针)。
我们的目标是将其修改为用内核栈的切换方式,根据老师上课的分析,在利用switch_to()实现切换时我们会用到当前进程的 PCB、目标进程的 PCB、当前进程的内核栈、目标进程的内核栈等信息。(switch_to后面会修改)
在linux0.11中,进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),其中 PCB 位于这页内存的低地址,栈位于这页内存的高地址。见下图。
即,知道PCB,就等于知道PCB+内核栈。
内核栈和PCB在同一页内存里
当前进程的PCB是由一个全局变量current指向,
所以只需要目标进程的PCB就行,这里我们传入一个指针参数 pnext 指向下一个PCB。
LDT(next)怎么用的 还不知道,后续添加

2、实现switch_to()函数

2.1 switch_to()函数的作用

前面的schedule()主要是找到了next,并传递进来。
(1)因此,本函数最重要的是实现进程切换(=切换当前PCB指针,切换内核栈,切换用户栈,切换用户态的CS:IP);
(2)现在虽然不使用 TSS 进行任务切换了,但是 Intel 的这种中断处理机制还要保持,所以要完成TSS 中的内核栈指针的重写
(3)每个进程有自己的LDT,所以还要完成LDT 的切换不确定是否正确,后续修正
综上,进程切换等价于,依次完成 PCB 的切换、TSS 中的内核栈指针的重写、内核栈的切换、LDT 的切换以及 PC 指针(即 CS:EIP)的切换。

(4)另外,由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理 ebp 寄存器;
(5)为了保证程序的健壮性,还需完成额外的比较。下一个进程PCB等于 current,则什么也不用做;如果不等于 current,就开始进程切换,

最后总结一下,switch_to()的代码结构、执行顺序:
在kernel/system_call.s中添加switch_to()

2.2 开始切换前的准备

这是switch_to()最开始的代码

switch_to:
    pushl %ebp
    movl %esp,%ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx
    cmpl %ebx,current
    je 1f
	!…………
	
ret

(1)为什么要在最初压栈这么多寄存器呢?
根据汇编知识我们知道,当一个C函数被编译为汇编代码时,会先压栈各种寄存器,
此处是C函数(schedule)调用一个汇编函数(switch_to),汇编函数不会再被展开了,所以需要手动用汇编指令保存现场(就是几个寄存器)。

(2)然后需要判断一下找到的next是否和当前的current是同一进程,
特别注意一下这行代码,作用效果是将ebx赋为switch_to()传递进来的参数pnext:

movl 8(%ebp),%ebx

这里涉及到了C语言函数的汇编调用过程,核心就是函数栈帧的变化,
一般来说,C函数的第一个参数就在ebp+8的位置,
ebp+0一般保存原ebp(如果用主函数和被调用函数来描述函数调用的过程,这里的原ebp就是主函数的ebp),
ebp+4一般保存调用函数指令的下一条指令的地址,就是被调函数返回后,开始执行的第一条指令的地址。
而为什么是+4呢?ebp寄存器是32位的寄存器,同时我们默认是按字节编址,所以需要4个字节来存放这32位的内容。
可以参考这篇文章
挖个坑,有时间的话就写一篇关于函数调用过程中栈的变化
(3)如果相等的话,就不用切换内核栈,就把刚刚压进来的参数pop出去。

 cmpl %ebx,current
    je 1f
	!…………
	!…………
	
	1:popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
ret

2.3 完成PCB的切换

实质上就是切换全局变量current
完成 PCB 的切换可以采用下面两条指令,其中 ebx 是从参数中取出来的下一个进程的 PCB 指针,
(就是上一节分析的指令 movl 8(%ebp),%ebx )

movl %ebx,%eax
xchgl %eax,current

经过这两条指令以后,eax 指向现在的当前进程ebx 指向下一个进程全局变量 current 也指向下一个进程
后续会用到这两个寄存器的值,可以特别注意一下。

2.4 TSS中的内核栈指针的重写

做了两件事:保留TTS,使修改其中指向内核栈的指针

虽然我们不用TSS进行切换了,但是 Intel 的这种中断处理机制还要保持,所以仍然需要有一个当前 TSS,这个 TSS 被全局变量init_task.task.tss定义,指向0号进程的tss,所有进程都共用这个 tss,任务切换时不再发生变化。

在使用TSS切换内核栈时,每个进程的内核栈的指针,被保存在栈顶偏移为4的位置,见下图(右)。
栈顶的位置在哪儿呢?
前文说过,每个进程的PCB和它的内核栈共同构成一页4KB的内存,内核栈的栈顶指针esp0指向高地址,所以esp0 = ebx(页基址,2.3节说了ebx 指向下一个进程PCB,就是下图中的p) + 4096(4KB),
因此存放的位置是,tss(0号进程的tss基址) + ESP0(偏移地址,宏定义为4) esp0指针与TSS结构
因此这部分代码为

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

ESP宏定义在kernel/system_call.s文件中添加

ESP0 = 4

tss宏定义在kernel/sched.c文件中添加

struct tss_struct *tss = &(init_task.task.tss);

2.5 完成内核栈的切换

核心就是,把原进程的内核栈的sp存到它PCB里,
从新的进程的PCB里拿出它的sp,赋值给cpu寄存器esp

但是,现在会出现什么问题呢?
2.4节说了,在TSS切换模式里,esp被规定为存在tss+4处;
那么,在内核栈切换模式里,esp被存在哪里呢? 很自然的想到,esp被存在PCB里。

2.5.1 在PCB中增加esp的定义

但是,实现内核栈切换模式是我们要完成的任务,原linux0.11的代码没有考虑这个问题,所以源代码中定义PCB的结构体task_struck里,没有存放esp的位置;
因此,我们要自己修改task_struck,加上esp。
注意不要放在task_struct 的第一个位置,原因:
在某些汇编文件中(主要是在 kernal/system_call.s 中)有些关于操作这个结构一些汇编硬编码,所以一旦增加了 kernelstack,这些硬编码需要跟着修改,由于第一个位置,即 long state 出现的汇编硬编码很多,所以 kernelstack 千万不要放置在 task_struct 中的第一个位置

task_struct 在 include/linux/sched.h 中定义

// ……
struct task_struct {
    long state; 
    long counter;
    long priority;
    long kernelstack; //加上定义的esp
//......
2.5.2 给esp赋值

加上esp的定义后,就可以把ebx的值赋给它了,但请注意:
kernelstack放的位置要 与 KERNEL_STACK定义的常数对应,这里默认long占4个字节,所以KERNEL_STACK=4*3=12

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

! 2.3节提到,**eax** 指向现在的**当前进程****ebx** 指向**下一个进程**
movl %esp,KERNEL_STACK(%eax) 
movl KERNEL_STACK(%ebx),%esp
2.5.3 修改PCB初始化过程

由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化
在include/linux/sched.h中,将

/* linux/sched.h */
#define INIT_TASK { 0,15,15, 0,{{},},0,... 

修改为

/* linux/sched.h */
#define INIT_TASK \
/* state etc */ { 0,15,15,PAGE_SIZE+(long)&init_task,\
/* signals */   0,{{},},0, \
......
}

2.6 LDT 的切换

指令movl 12(%ebp),%ecx 负责取出对应 LDT(next)的那个参数,(参见2.2节开始切换前压入的参数)
指令lldt %cx负责修改 LDTR 寄存器,
一旦完成了修改,下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离。
最后两句代码一定要写,且仅能写在此处,2.8节解释

! 切换LDT
    mov 12(%ebp), %ecx
    lldt %cx			! 覆盖
    movl $0x17,%ecx
    mov %cx,%fs

2.7 用户态 PC 的切换

通过switch_to的最后一句指令 ret 实现。
但是具体的返回过程很复杂:
(1)schedule() 函数的最后调用了这个 switch_to 函数,所以这句指令 ret 就返回到下一个进程(目标进程)的 schedule() 函数的末尾,遇到的是};
最初调用schedule() 的肯定是原进程,当返回的时候,执行现场就是目标进程了,它横跨了两个进程,是这样吗???
(2)'}'被编译为ret,继续 ret 回到调用的 schedule() 地方,是在中断处理中调用的,所以回到了中断处理中,就到了中断返回的地址;
(3)再调用 iret 将之前Int中断存入的5个重要寄存器的值pop到CPU当前的CS:IP,SS:SP中,就实现了跳转到目标进程的用户态程序去执行。
核心:抓住函数返回,就是回到之前调用它的代码 的下一行去执行

2.8 重置段寄存器fs的值

switch_to 代码中在切换完 LDT 后的两句很特殊、很重要,即:

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

(1)fs的作用——通过 fs 访问进程的用户态内存(在实验2添加系统调用时用过),LDT 切换完成就意味着切换了分配给进程的用户态内存地址空间,
所以前一个 fs 指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,所以就需要用这两条指令来重取 fs。
(2)不过,再深入一点我们会发现:fs 是一个选择子,
即 fs 是一个指向描述符表项的指针,这个描述符才是指向实际的用户态内存的指针,所以上一个进程和下一个进程的 fs 实际上都是 0x17,
所以,真正找到不同的用户态内存是因为两个进程查的 LDT 表不一样
但是在2.6节,我们已经完成了 LDT 表的切换,它们查的LDT表就应该不一样了,为什么还要重置fs,且只能在这里重置呢?
(3)首先我们要明确以下两点:

i. LDT表是在内存中, 访问内存对CPU来说是比较慢的动作, 效率不高。
ii. 段描述符的格式很奇怪, 一个数据要分三个地方存, 所以CPU 要把这些七零八落的数拼合成一个完整数据也是要花时间的。

所以,为了提高获取段信息的效率,设计者对GDT(LDT)率先使用缓存技术, 将段信息用一个寄存器缓存,这就是段描述符缓冲寄存器。
(很好理解,就是增加一级cache提高访问速度,类比L1-cache、L2-cache、TLB)
对程序员而言这个寄存器是不可见、不可编程控制的。CPU每次将混乱存放的段选择符信息整理成直接可用的形式后, 会将其存入段描述符缓冲寄存器,以后每次访问相同的段时, 就直接读取该段寄存器对应的段描述符缓冲寄存器。
既然是缓存, 就一定要有个失效时间。很巧的是,这个缓存还真没有准确的失效时间,(不知道会不会被替换出去);
但是有一点可以肯定,只要往段寄存器中赋值, CPU 就会更新段描述符缓冲寄存器。例如,在保护模式下加载选择子(即使新选择子的值和之前段寄存器中老的选择子相同), CPU 就会重新访问全局描述符表, 再将获取的段信息重新放回段描述符缓冲寄存器
因此,即使重置前后fs的值不变,我们也要重置一下,以更新缓存。
本节参考资料《操作系统真象还原》

3、修改fork()

为什么要修改fork()?
fork()是根据父进程创建一个子进程,然后调度执行。
而进程的创建可以理解为,初始化一些东西,实现PC切过去就能开始正常执行。
而切过去,又发生了什么呢?
PCB、内核栈都发生了变化。PCB的初始化,已经由系统定义好了,内核栈的位置由PCB指出,这再2.5节也解决了,所以现在,我们要完成的是初始化内核栈里的东西,内核栈和用户栈的关连。
那么内核栈里有什么呢?我们重走内核栈切换5步,看一下内核栈都发生了什么。

3.1

参考这篇博客第二部分fork()相关、内核栈视角下的五段论讲得很清楚
《哈工大操作系统实验四——基于内核栈切换的进程切换(极其详细)》

参考链接
实验指导书——蓝桥云课
强烈推荐~《哈工大操作系统实验四——基于内核栈切换的进程切换(极其详细)》
《操作系统原理、实现与实践 (李治军,刘宏伟)》

  • 33
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值