哈工大操作系统实验5 基于内核栈切换的进程切换(可运行)

该篇文章是哈工大操作系统实验5的完成笔记,主要难点在于内核栈切换的五段论,其中包含了详细的步骤和相关代码,并有截图说明。实验内容我都成功通过了,但是因为内容较多,记录中难免会有疏忽,如有发现错误,欢迎大家留言和我联系。

文章较长,可以先收藏后观看,如果对大家有帮助,麻烦动动你可爱的小手帮忙点个赞哈。

理论知识

实验标题是基于内核栈切换的进程切换,其中可以拆解出两个关键词:内核栈进程切换,那么了解这两块相关的知识就很重要了哈。

  • 关于Linux0.11堆栈的相关情况可以查看注释书籍2.7章节——Linux系统中堆栈的使用方法,该章节详细介绍了进程的两种堆栈:用户态堆栈和内核态堆栈,实验标题的内核栈就是指这里的内核态堆栈。
  • 进程切换上一个实验涉及了进程切换的流程,但是没有涉及到具体的切换实现,这个就是由switch_to函数实现,这块在注释书籍2.5章节也有说明——Linux进程控制。

看完理论部分就可以开始编写代码实现了,编写代码主要完成如下三个部分:

image

Tips:这个实验要改动的地方还不少,每次在蓝桥实验环境里修改非常不方便。加之我又想把上一个实验的进程切换日志加上以便分析修改前后系统运行差别,修改量就更多了,于是就考虑自己搭建一个环境。

本来想在自己的电脑上搭建一个环境,后来一查资料,安装太多软件也挺麻烦的。于是想到一个办法:既然蓝桥实验环境都有了,就是改代码不太方便。那如果能本地改好再上传就好了,刚好蓝桥有提供上传和下载的功能。于是我把蓝桥环境上Linux0.11的代码下载下来,改完后再传上去运行试试看,这样就方便很多了。而且本地环境有编辑器、git版本管理,看代码什么的方便多了。

重写switch_to

改写swith_to函数

switch_to主要是用来切换进程的,包括切换内核栈、LDT、PCB等。

通过实验内容我们可以知道如果要改用内核栈切换进程,那么也需要处理LDT切换,所以switch_to的调用方式:

switch_to(pnext, _LDT(next));

其中两个参数:

  • pnext表示要切换的进程,指向PCB的首地址,也就是sched.h里的task_struct就是PCB结构;
  • _LDT(next)表示要切换的进程的LDT在GDT表中的选择子。

关于LDT:每个进程都有一个LDT,类似GDT的段描述符表。不同点在于GDT是全局的,LDT只针对单个进程,另外每个LDT在GDT表中都有一个对应的描述符,_LDT(next)就是指向这个描述符的选择子。

switch_to 原来在 include/linux/sched.h 的宏定义中,我们将那部分注释掉。

然后在 kernel/system_call.s 中添加以下 switch_to 汇编代码,我是添加在 device_not_available 函数之后。

.align 2
/* kernel/system_call.s */
switch_to: # swith_to的调用方式:switch_to(pnext, _LDT(next)); 有两个参数。

    # (1)函数内要使用的寄存器在函数开头都要进行压栈,这个是汇编常见的处理方式
    pushl %ebp          # 将ebp压栈
    movl %esp,%ebp      # esp赋值给ebp,方便后面计算
    pushl %ecx          # 将ecx压栈
    pushl %ebx          # 将ebx压栈
    pushl %eax          # 将eax压栈
    
    # (2)判断要切换的进程和当前进程是否是同一个进程
    movl 8(%ebp),%ebx    /* %ebp+8就是从右往左数起第二个参数,也就是pnext */
    cmpl %ebx,current    /* 如果当前进程和要切换的进程是同一个进程,就不切换了 */
    je 1f
    /* 先得到目标进程的pcb,然后进行判断
        如果目标进程的pcb(存放在ebp寄存器中) 等于   当前进程的pcb => 不需要进行切换,直接退出函数调用
        如果目标进程的pcb(存放在ebp寄存器中) 不等于 当前进程的pcb => 需要进行切换,直接跳到下面去执行 */

    # (3)切换PCB
    movl %ebx,%eax      /* ebx是下一个进程的PCB首地址,current是当前进程PCB首地址 */
    xchgl %eax,current  /* 执行后,curent指向pnext,eax指向旧进程的PCB首地址 */

    # (4)TSS中的内核栈指针的重写。
    # 虽然不用TSS切换了,但是还是要遵循CPU的规则——进程在用户态和内核态切换时,会在内核态堆栈和用户态堆栈之间切换,办法就是所有进程共享进程0的TSS。
    movl tss,%ecx           /* %ecx里面存的是tss段的首地址,在后面我们会知道,tss段的首地址就是进程0的tss的首地址,
                                根据这个tss段里面的内核栈指针找到内核栈,所以在切换时就要更新这个内核栈指针。也就是说,
                                任何正在运行的进程内核栈都被进程0的tss段里的某个指针指向,我们把该指针叫做内核栈指针。 */
    addl $4096,%ebx         /* 未加4KB前,ebx指向下一个进程的PCB首地址,加4096后,相当于为该进程开辟了一个“进程页”,ebx此时指向进程页的最高地址 */
    movl %ebx,ESP0(%ecx)    /* 将内核栈底指针放进tss段的偏移为ESP0(=4)的地方,作为寻找当前进程的内核栈的依据 */
    /* 由上面一段代码可以知道们的“进程页”是这样的,PCB由低地址向上扩展,栈由上向下扩展。
        也可以这样理解,一个进程页就是PCB,我们把内核栈放在最高地址,其它的task_struct从最低地址开始扩展 */
    
    # (5)切换内核栈
    # KERNEL_STACK代表kernelstack在PCB表的偏移量,意思是说kernelstack位于PCB表的第KERNEL_STACK个字节处,注意:PCB表就是task_struct
    movl %esp,KERNEL_STACK(%eax)    /* eax就是上个进程的PCB首地址,这句话是将当前的esp保存旧PCB的kernelstack。所以该句就是保存旧进程内核栈的操作。 */
    movl 8(%ebp),%ebx               /* %ebp+8就是从左往右数起第一个参数,也就是ebx=*pnext ,pnext就是下一个进程的PCB首地址。至于为什么是8,请查看后续说明。 */
    movl KERNEL_STACK(%ebx),%esp    /* 将下一个进程的内核栈指针加载到esp */

    # (6)切换LDT
    movl 12(%ebp),%ecx  /* %ebp+12就是从左往右数起第二个参数,对应_LDT(next) */
    lldt %cx            /* 用新任务的LDT修改LDTR寄存器 */
    /* 下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离 */
    
    # (7)重置一下用户态内存空间指针的选择符fs
    movl $0x17,%ecx     # 0x17=0001_0111B:DPL=3;从LDT表中寻找段描述符;索引号为2,即LDT[2]。可以参考include/linux/sched.h 123~127行进程的LDT初始化。
    mov %cx,%fs
    /* 段寄存器有一个外部不可见的高速缓存,缓存了段的基地址等信息,虽然fs值为都0x17,
        不过重新赋值前高速缓存内存储的是上一个进程的段信息,重复赋值一下fs,高速缓存就会刷新,就变成新进程的了。
        可以参考后续图示说明。 */
    
    # 和后面的 clts 配合来处理协处理器,由于和主题关系不大,此处不做论述
    cmpl %eax,last_task_used_math
    jne 1f
    clts

    # (8)函数返回前恢复暂存的寄存器信息,即函数开头push的那些寄存器。
1:  popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
	ret

虽然看了注释数据和实验提示,但是一开始一直没有明白这个切换的程序运行流程——是怎么切换到另外一个进程上的?后面经过反复思考后终于明白了通过内核栈切换的程序运行流程,理解这个流程后代码瞬间全都理解了。下面说自己的理解:

每个进程都有保存自己的运行时信息(各个寄存器eax、ebx、ss0、esp0、cs、eip等等),在进程切换时和CPU进行换入换出,在旧的切换方式中这个信息保存在TSS中。

虽然不用TSS了,但是进程运行时信息在切换的时候依旧需要保存,这个信息保存在哪里呢?就是用户进程的内核态堆栈(内核栈)。

那么问题又来了,内核态堆栈内保存的这些信息怎么运用起来的?其实明白了就很简答,把esp指向到新进程的内核栈地址,然后运用ret指令和iret指令的特性——在执行这两个指令时会从堆栈内弹出数据,只要在堆栈内按规则放好数据,就可以通过ret和iret切换到另外一个进程运行。

具体切换如图所示:

image

代码步骤的一些补充说明:

(1) 函数开头寄存器压栈

函数内要使用的寄存器在函数开头都要进行压栈,而后在函数返回前前进行出栈,这个是汇编常见的处理方式。

值得一提的是因为esp随着压栈和出栈操作值会发生变化,为了方便操作,在 pushl ebp 后将 esp 的赋值给 ebp,后续通过 ebp 就容易定位到栈里的 pnext 和 _LDT(next) 参数了。

执行完这步后,当前进程的内核栈情况:

image

(2) 判断要切换的进程和当前进程是否是同一个进程

8(%ebp) 就是 ebp+8,参考上面一步图示,就是 pnext 的位置了。

(3) 切换PCB

Linux0.11中current是全局变量,指向了当前进程的PCB首地址,这步就是将 current 切换为下一个进程的PCB首地址,即pnext。

(4) TSS中的内核栈指针的重写

虽然不用TSS切换了,但是还是要遵循CPU的规则——进程在用户态和内核态切换时,堆栈会在内核态堆栈和用户态堆栈之间切换,内核态堆栈和用户态堆栈的地址CPU是到进程对应的TSS中获取的。但是现在进程都没有自己的TSS了,怎么办呢?解决办法就是所有进程共享进程0的TSS。

(5) 切换内核栈

这步是关键,前面有给到图示说明了:简单理解就是保存旧的,载入新的。这步完成后,内核栈就是使用新进程的内核栈了。

(6) 切换LDT

每个进程都有自己的LDT,切换进程也要切换LDT。12(%ebp) 就是 ebp+12,参考第一步图示,就是 _LDT(next) 的位置了。

(7) 重置一下用户态内存空间指针的选择符fs

段寄存器有一个外部不可见的高速缓存,缓存了段的基地址等信息,虽然fs值为都0x17,不过重新赋值前高速缓存内存储的是上一个进程的段信息,重复赋值一下fs,高速缓存就会刷新,就变成新进程的了。

实验报告问题3有更加详细回到了这个问题,可以到那里查看。

image

(8) 函数返回前恢复暂存的寄存器信息

和1步骤呼应,函数开头有push,结尾就要有对应pop。

改写task_struct

switch_to函数修改了,相关的程序也需要进行修改,首先是在 task_struct 结构中声明一个变量 kernelstack 用来保存当前进程内核栈指针。

/* sched.h */
struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack; // 保存当前进程内核栈指针
    ...
}

对应的INIT_TASK修改:

/* sched.h */
/* #define INIT_TASK { 0,15,15, 0,{{},},0,... 修改为 */
#define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,...
/* 也就是增加了PAGE_SIZE+(long)&init_task */

一些硬编码修改:

/* system_call.s */
state	= 0		# these are offsets into the task-struct.
counter	= 4
priority = 8
signal	= 12+4          # 此行修改
sigaction = 16+4		# 此行修改
blocked = (33*16+4)     # 此行修改

这里的变量表示在task-struct结构中的偏移量,因为kernel_stack增加在priority后面,所以signal、sigaction、blocked都需要加4。

声明ESP0和KERNEL_STACK

switch_to函数中用到的 ESP0 和 KERNEL_STACK 设置,可以放在

/* system_call.s头部设置*/
ESP0 = 4	# 表示在TSS中ESP0的偏移量,这个可以参考TSS结构   # 新增加
KERNEL_STACK = 12	# 表示在task-struct结构中的偏移量       # 新增加

state	= 0		# these are offsets into the task-struct.
counter	= 4
  1. 定义 ESP0 = 4 是因为 内核栈指针 esp0 就放在进程0的TSS 中偏移为 4 的地方;
  2. KERNEL_STACK = 12的意思非常明显了,我们上面刚刚设过kernel_stack放在task_struct第4个位置。

其实 KERNEL_STACK 可以放在 priority = 8 之后,并且用小写方式表示,看起来更加顺序整齐,但是因为实验的内容使用了这种方式,我们遵照实验内容处理即可。

声明进程0的tss

/* sched.c */
struct task_struct * task[NR_TASKS] = {&(init_task.task), };
struct tss_struct *tss = &(init_task.task.tss);                 # 新增加

switch_to和schedule()接在一起

开放switch_to

在system_call.s的头部附近添加以下代码:

/* system_call.s */
.globl device_not_available, coprocessor_error
.globl switch_to    # 新增加

在sche.c声明switch_to

/* sche.c */
extern int system_call(void);
extern long switch_to(struct task_struct *p, unsigned long address); // 新增加

在schedule()调用switch_to

/* 以下修改都在sched.c文件schedule函数中进行 */
/* (1)在 
    struct task_struct ** p;
    这行之后,添加一个全局变量pnext,它的含义是指向下一个切换进程的PCB开始位置,但默认初始化为进程0的PCB。
*/
struct task_struct *pnext = &(init_task.task); // 表示要切换到进程,默认为进程0

/* (2)将pnext赋为下一个进程的PCB
	 if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
        c = (*p)->counter, next = i;
    修改为
*/
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    c = (*p)->counter, next = i, pnext = *p;    // 要切换的进程赋值给pnext

/*(3)修改调用switch_to
    switch_to(next);
	修改为
*/
switch_to(pnext, _LDT(next));

fork相关

内核级线程切换五段论

fork()是用来创建子进程的,其中关键函数copy_process中设置了tss相关的信息,因为要改成内核栈切换,所以需要构建出内核栈的信息了,那么内核栈的内容到底是啥呢?这个就涉及到实验说的内核级线程切换五段论

来一个图就更清晰了:

image

修改copy_process

知道了内核栈保存的信息后,只需要在进程的内核栈依次构建这些信息就可以。

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
		long ebx,long ecx,long edx,
		long fs,long es,long ds,
		long eip,long cs,long eflags,long esp,long ss)
{
	p->cutime = p->cstime = 0;
	p->start_time = jiffies;

	/***************(1)初始化内核栈开始***************/
    long *krnstack;                             // 内核栈指针
    krnstack = (long *)(PAGE_SIZE +(long)p);      // 内核栈指针指向PCB所在内存物理页栈顶(一个物理页4K)。
	// p = (struct task_struct *) get_free_page();用来完成申请一页内存作为子进程的 PCB,
    // 而 p 指针加上页面大小就是子进程的内核栈位置,所以语句 krnstack = (long *) (PAGE_SIZE + (long) p); 就可以找到子进程的内核栈位置

	// first_return_from_kernel 函数最后 iret 指令的出栈数据
    *(--krnstack) = ss & 0xffff;
    *(--krnstack) = esp;
    *(--krnstack) = eflags;
    *(--krnstack) = cs & 0xffff;
    *(--krnstack) = eip;

	// iret 回到进程用户态之前,需要恢复一下执行现场,主要就是 edx,edi,esi,gs,fs,es,ds 寄存器的恢复
	// 实际上就是 first_return_from_kernel 函数做的事情。
    *(--krnstack) = ds & 0xffff;
    *(--krnstack) = es & 0xffff;
    *(--krnstack) = fs & 0xffff;
    *(--krnstack) = gs & 0xffff;
    *(--krnstack) = esi;
    *(--krnstack) = edi;
    *(--krnstack) = edx;

	// switch_to 最后 ret 返回指令要用到的,ret 指令默认弹出一个 EIP 操作
    *(--krnstack) = (long)first_return_from_kernel;

	// swtich_to 函数中的 ret 前的弹栈操作
    *(--krnstack) = ebp;
    *(--krnstack) = ecx;
    *(--krnstack) = ebx;
    *(--krnstack) = 0;   // eax保存了系统调用的返回值,fork()返回0表示是子进程。为什么eax是返回值可以查看 unistd.h 文件 syscall 系列函数。

	// 在 PCB 中的 kernelstack 中保存这个栈指针 krnstack,
	p->kernelstack= krnstack;
    /***************初始化内核栈结束***************/

	/* (2)注释TSS相关的
	p->tss.back_link = 0;
	p->tss.esp0 = PAGE_SIZE + (long) p;
	p->tss.ss0 = 0x10;
	p->tss.eip = eip;
	p->tss.eflags = eflags;
	p->tss.eax = 0;
	p->tss.ecx = ecx;
	p->tss.edx = edx;
	p->tss.ebx = ebx;
	p->tss.esp = esp;
	p->tss.ebp = ebp;
	p->tss.esi = esi;
	p->tss.edi = edi;
	p->tss.es = es & 0xffff;
	p->tss.cs = cs & 0xffff;
	p->tss.ss = ss & 0xffff;
	p->tss.ds = ds & 0xffff;
	p->tss.fs = fs & 0xffff;
	p->tss.gs = gs & 0xffff;
	p->tss.ldt = _LDT(nr);
	p->tss.trace_bitmap = 0x80000000;
	*/

	...
}

first_return_from_kernel的编写

copy_process函数使用了first_return_from_kernel这个函数地址,因此我们要编写这个函数。

/* system_call.s文件 switch_to 之后 */
.align 2
first_return_from_kernel:
    popl %edx
    popl %edi
    popl %esi
    pop  %gs
    pop  %fs
    pop  %es
    pop  %ds
    iret

然后把first_return_from_kernel声明为全局函数。

/* system_call.s */
.globl switch_to,first_return_from_kernel # 和前面switch_to放在一起就可以了

由于在copy_process()里面调用到first_return_from_kernel,所以声明要使用这个外部函数:

/*在fork.c头部附近*/
extern void write_verify(unsigned long address);
extern void first_return_from_kernel(void);         # 新增加

分析修改前后系统运行差别

修改后用上一个实验的日志进行记录分析,在时间片为15的情况下,分析得到如下结果:

image

我把上一个实验时间片为15的结果摘录在这里,方便进行对比。

image

感觉性能上并没有什么差别,但是使用内核栈切换进程的IO Burst都为0了,暂时没有搞懂什么原因。

实验报告

问题1

针对下面的代码片段:

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

回答问题:

(1)为什么要加 4096;

4096 字节等于 4KB。由于 Linux 0.11 进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),其中 PCB 从这页内存的低地址开始,栈从这页内存的高地址开始,并且向下扩展。ebx 指向下一个进程的 PCB 的开始地址,偏移 4KB 之后,便能指向该进程的内核栈顶部。

image

(2)为什么没有设置 tss 中的 ss0。

因为 tss.ss0 默认设置为 0x10,已经处于内核态中,而且现在所有的进程都共用一个 tss,因此不需要每次切换都去设置。tss.ss0的默认设置可以参考 sched.h 的 131 行。

问题2

针对代码片段:

*(--krnstack) = ebp;
*(--krnstack) = ecx;
*(--krnstack) = ebx;
*(--krnstack) = 0;

回答问题:

(1)子进程第一次执行时,eax=?为什么要等于这个数?哪里的工作让 eax 等于这样一个数?

eax = 0,目的是为了将父子进程区分开,改写的 copy_process 函数中 *(–krnstack) = 0; ,该代码经过出栈操作后,将 0 赋值给 eax 寄存器,最后经过 iret 指令后,在用户态就能得到调用 fork() 函数的返回值,等于 0 就代表是子进程。(即可以使用这样的代码 if (!fork()) {…})

(2)这段代码中的 ebx 和 ecx 来自哪里,是什么含义,为什么要通过这些代码将其写到子进程的内核栈中?

ebx 和 ecx 来自 copy_process() 函数的参数,这些参数来自调用 copy_proces() 的进程的内核栈中,就是父进程的内核栈中,所以对于 fork() 函数而言,子进程是父进程内核栈数据的拷贝,就是要让父子进程共用同一个代码、数据和堆栈。这样可以保证切换到子进程用户态运行时,子进程和父进程处于同样的环境。

(3)这段代码中的 ebp 来自哪里,是什么含义,为什么要做这样的设置?可以不设置吗?为什么?

ebp 来自于父进程,保存的是父进程用户栈基地址指针。即在 fork 刚刚执行完 copy_process 的时候,它的用户栈是父进程的用户栈,而非自己的用户栈。当子进程进行其他操作时,造成需要的栈将要与父进程不同了,才会创建自己的用户栈。这么做的好处时当一些子进程什么都不做,系统不用分配额外的空间。这就是 Copy On Write。

问题3

为什么要在切换完 LDT 之后要重新设置 fs=0x17?而且为什么重设操作要出现在切换完 LDT 之后,出现在 LDT 之前又会怎么样?

为什么要重新设置 fs=0x17?因为段寄存器有高速缓存,保存了段基址等信息。切换到新任务的LDT后,如果不重新设置fs,那么fs段寄存器高速缓存内存储的还是旧任务LDT段描述符的信息。

为什么要出现在切换完 LDT 之后?切换完 LDT 之后,LDTR 寄存器存储的是新任务的LDT选择子,此时设置 fs=0x17,就会到新任务的LDT表中去取描述符,会刷新段寄存器高速缓存的内容。

出现在 LDT 之前又会怎么样?如果在之前,因为 LDTR 寄存器存储的是旧任务的LDT选择子,那么还是会到旧任务的LDT表中去取段描述符,就没有什么效果。

一图胜千言:

image

参考资料

从以下博文得到了不少帮助,特此表示感谢。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

晴空闲雲

感谢家人们的投喂

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值