精华内容
下载资源
问答
  • 2021-07-28 10:17:35

    写在开头

    由于本人汇编基础基本为0,所以吐血花了将近10小时,整理了网上所有相关的知识点,彻底搞清楚了linux系统进程的问题。博客同样适用于汇编基础不好的同学,讲的非常清楚,请耐心观看,相信你也肯定大有收获

    这里是个人写的完整源码哈工大操作系统课程及实验完结体会

    实验目的

    深入理解进程和进程切换的概念;
    综合应用进程、CPU管理、PCB、LDT、内核栈、内核态等知识解决实际问题;
    开始建立系统认识。
    

    实验内容

    本次实验包括如下内容:

    • 编写汇编程序switch_to:
    • 完成主体框架;
    • 在主体框架下依次完成PCB切换、内核栈切换、LDT切换等;
    • 修改fork(),由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。
    • 修改PCB,即task_struct结构,增加相应的内容域,同时处理由于修改了task_struct所造成的影响。
    • 用修改后的Linux 0.11仍然可以启动、可以正常使用。

    总览

    本实验可以分为两个主要部分:

    一部分是对应switch_to五段论中的中间段——内核栈的切换,主要是对switch_to代码的修改。
    (原来的switch_to是基于TSS切换和长跳转指令来切换到下一个线程);

    另一部分则在更改进程切换方式的情况下,如何修改fork()使其配合新的进程切换方式。
    (因为fork()需要在新建一个子线程后,切换到子线程执行,而切换方式在上一部分改变了)

    本文旨在将实现的整个流程讲清楚,在switch_to的注释和内核栈视角下的五段论务必详细观看,在理解整个流程起的作用非常大。

    寄存器介绍

    • eip,用来存储CPU要读取指令的地址,CPU通过EIP寄存器读取即将要执行的指令

    • ebp是基址指针寄存器;处理栈帧,即处理 ebp 寄存器

    • esp是堆栈指针寄存器;这里已经进入内核态,所示是内核栈指针

    一、编写switch_to(),40%

    新的switch_to()函数是系统调用函数,所以要将函数重写在汇编文件system_call.s。这个函数依次主要完成如下功能:

    首先我们要明确以下几点:

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

    以下代码的p,pnext都是PCB的首地址,也就是sched.h里的task_struct就是PCB,如果看不明白偏移的话,可以看看task_struct是长什么样的。而且,查看task_struct可知,tss_struct在task_struct的最后一个位置。
    每个进程需要有自己的 LDT,地址分离地址还是必须要有的,而进程切换必然要涉及到 LDT 的切换。
    switch_to(pnext, LDT(next))

    先给出一个大体框架

    
    switch_to:
        pushl %ebp  	!这两句作用是把c函数整个压入栈,
        movl  %esp,%ebp !函数的参数和局部变量都是通过ebp的值加上一个偏移量来访问
        pushl %ecx		!接下来会用到这三个寄存器
        pushl %ebx
        pushl %eax
        movl  8(%ebp),%ebx !取出从右向左第二个参数pnext,放入ebx寄存器中
        cmpl  %ebx,current !比较pnext和current,与之前的pnext初始化为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
    
    

    下面逐条解释:

    1.PCB的切换

    PCB 的切换可以采用下面两条指令,由上面代码可知,其中ebx是从参数中取出来的下一个进程的 PCB 指针

    // PCB的切换
    movl  %ebx,%eax  !movl的用法:把32位的EAX寄存器值传送给32位的EBX寄存器值
    xchgl %eax,current  !xchgl的用法:交换eax和current的值
    /*ebx是下一个进程的PCB首地址,current是当前进程PCB首地址*/
    
    

    经过这两条指令以后

    • eax 指向现在的当前进程
    • ebx指向下一个进程
    • 全局变量 current 也指向下一个进程

    2.TSS 中的内核栈指针的重写

    由于CPU原有的CPU机制,TSS结构仍需要保留,但在切换进程时可以让所有进程共用tss0。现在ebx为pnext,即PCB位置。加上4K后即为内核栈的位置。所以在tss0的ESP0偏移位置就保存了内核栈的指针。

    重写可以用下面三条指令完成,其中宏
    struct tss_struct *tss = &(init_task.task.tss);
    也是定义了一个全局变量,和 current 类似,用来指向那一段 0 号进程的 TSS 内存。此时唯一的tss的目的就是:在中断处理时,能够找到当前进程的内核栈的位置。

    movl tss,%ecx	!把tss存到ecx当中
    addl $4096,%ebx	!现在ebx为pnext,即PCB位置。加上4K后即为内核栈的位置
    movl %ebx,ESP0(%ecx)  !把内核栈的位置存入tss
    

    定义 ESP0 = 4 是因为 TSS 中内核栈指针 esp0 就放在偏移为 4 的地方,看一看 tss 的结构体定义就明白了
    在这里插入图片描述

    加入tss的定义:

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

    3.内核栈的切换

    切换内核栈就是将寄存器esp的值放在PCB中,再从下一个进程的PCB中取出保存的内核栈栈顶放在当前的esp中。

    首先要在task_struck中添加kernelstack的声明:

    // 在 include/linux/sched.h 中
    struct task_struct {
        long state;
        long counter;
        long priority;
        long kernelstack;	//第4个位置,偏移为12
    //......
    

    在init_task中添加kernelstack的初始值

    #define INIT_TASK { 0,15,15, 0,{{},},0,...
    

    改为

     #define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,...
    

    在system_call.s中定义一些常量:

    ESP0 =4
    KERNEL_STACK = 12 #添加实验指导需要的常量
    
    state       = 0        # these are offsets into the task-struct.
    counter     = 4
    priority    = 8
    kernelstack = 12       # 添加定义
    signal      = 16
    sigaction   = 20
    blocked = (33*16+4)   #由于添加了一个long,所以偏移量加4
    

    然后加入切换内核栈指针的代码:

    • eax 指向现在的当前进程
    • ebx指向下一个进程
    • 全局变量 current 也指向下一个进程
     # KERNEL_STACK = 12
    
     movl %esp,KERNEL_STACK(%eax) # 将当前栈顶指针存到当前PCB的kernelstack位置
     movl 8(%ebp),%ebx #!取出从右向左第二个参数pnext,放入ebx寄存器中。再取一下ebx,因为前面修改过ebx的值
     movl KERNEL_STACK(%ebx),%esp #将下一个进程的PCB中的kernelstack取出,赋给esp,完成切换
    

    4.LDT的切换

     movl    12(%ebp),%ecx #负责取出对应 LDT(next)的那个参数
     lldt    %cx #指令 lldt %cx 负责修改 LDTR 寄存器
     #finish
    

    5.PC 指针(即 CS:EIP)的切换

    关于 PC 的切换,和前面论述的一致,依靠的就是 switch_to 的最后一句指令 ret

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

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

    FS的作用。通过FS操作系统才能访问进程的用户态内存。这里LDT切换完成意味着切换到了新的用户态地址空间,所以需要重置FS。

    二、修改fork.c——内核栈的复制加切换

    准则:把进程的用户栈和内核栈通过内核栈中的 SS:ESP,CS:IP 关联在一起,方式:压栈。但由于 fork() 这个叉子的含义就是要让父子进程共用同一个代码、数据和堆栈,故为内核栈的复制加切换

    不难想象,对 fork() 的修改就是对子进程的内核栈的初始化,在 fork() 的核心实现 copy_process 中,

    p = (struct task_struct *) get_free_page();	//创建PCB
    

    在这里插入图片描述
    用来完成申请一页内存作为子进程的 PCB,而 p 指针加上页面大小就是子进程的内核栈位置,所以语句

    long * krnstack;
    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() 的进程的内核栈中,就是父进程的内核栈中,所以上面给出的指令不就是将父进程内核栈中的前五个内容拷贝到子进程的内核栈中,图中所示的关联不也就是一个拷贝吗?

    
    *(--krnstack) = ds & 0xffff;
    *(--krnstack) = es & 0xffff;
    *(--krnstack) = fs & 0xffff;
    *(--krnstack) = gs & 0xffff;
    *(--krnstack) = esi;
    *(--krnstack) = edi;
    *(--krnstack) = edx;
    
    // 用户栈的消息
    *(--krnstack) = (long) first_return_kernel;
    
    *(--krnstack) = ebp;
    *(--krnstack) = ecx;
    *(--krnstack) = ebx;
    *(--krnstack) = 0;
    
    p->kernelstack = krnstack;
    

    接下来的工作就需要和 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;
    

    回答问题:

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

    • eax =0为了与父进程区分开 copy_process(),成功初始化进程copy_process后赋值eax得到

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

    • 让eax=0 这段代码中的ebx和ecx来自copy_process()的形参,是段寄存器。fork函数决定,让父子的内核栈在初始化时完全一致

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

    • ebp是用户栈地址,一定要设置,不设置子进程就没有用户栈了
    1:  popl %eax  //这四个寄存器是一开始进入保存所用的寄存器以及通用寄存器
        popl %ebx
        popl %ecx
        popl %ebp
    ret  	<——————————
    

    现在到了 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 中。

    first_return_from_kernel: 
            popl %edx 
            popl %edi 
            popl %esi 
            popl %gs 
            popl %fs 
            popl %es 
            popl %ds 
            iret
    

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

    p->kernelstack = stack;
    

    整体的栈空间内存图如下所示:
    在这里插入图片描述

    三、修改sched.h和sched.c

    之前的进程控制块(pcb)中是没有保存内核栈信息的寄存器的,所以需要在sched.h中的task_struct(也就是pcb)中添加kernelstack,而宏 KERNEL_STACK 就是你加的那个位置,但kernelstack 千万不要放置在 task_struct 中的第一个位置,有结构体硬编码的要求,要放在其他位置,然后修改 kernal/system_call.s 中的那些硬编码就可以了。

    task_struct 的定义:

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

    基于堆栈的切换程序要做到承上启下:

    • 承上:基于堆栈的切换,要用到当前进程(current指向)与目标进程的PCB,当前进程与目标进程的内核栈等
      • Linux 0.11 进程的内核栈和该进程的 PCB 在同一页内存上(一块 4KB 大小的内存),其中 PCB 位于这页内存的低地址,栈位于这页内存的高地址
    • 启下:要将next传递下去,虽然 TSS(next)不再需要了,但是 LDT(next)仍然是需要的。

    修改 schedule() 函数(在 kernal/sched.c 中),代码如下:

    if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
    c = (*p)->counter, next = i, pnext = *p;
    
    //.......
    
    switch_to(pnext, _LDT(next)); //修改switch_to,实验中的LDT缺少了一个下划线,这里要给它补上,不然会报错
    
    //同时在schedule中要添加一个全局变量
    struct task_struct *pnext = &(init_task.task);
    

    感谢

    更多相关内容
  • 实验内容 本次实践项目就是将 Linux 0.11 中采用的 TSS 切换部分去掉,取而代之的是...修改 fork(),由于是基于内核栈切换,所以进程需要创建出能完成内核栈切换的样子。 修改 PCB,即 task_struct 结构,增加相应的

    实验内容

    本次实践项目就是将 Linux 0.11 中采用的 TSS 切换部分去掉,取而代之的是基于堆栈的切换程序。具体的说,就是将 Linux 0.11 中的 switch_to 实现去掉,写成一段基于堆栈切换的代码。

    本次实验包括如下内容:

    • 编写汇编程序 switch_to
    • 完成主体框架;
    • 在主体框架下依次完成 PCB 切换、内核栈切换、LDT 切换等;
    • 修改 fork(),由于是基于内核栈的切换,所以进程需要创建出能完成内核栈切换的样子。
    • 修改 PCB,即 task_struct 结构,增加相应的内容域,同时处理由于修改了 task_struct 所造成的影响。
    • 用修改后的 Linux 0.11 仍然可以启动、可以正常使用。
    • (选做)分析实验 3 的日志体会修改前后系统运行的差别。

    具体的说,就是将 Linux 0.11 中的 switch_to 实现去掉,写成一段基于堆栈切换的代码。

    要实现基于内核栈的任务切换,主要完成如下三件工作:

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

    实验步骤

    1.修改schedule与switch_to

    • 先来了解一下linux0.11下是如何实现内核栈切换的

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

    在这里插入图片描述
    在这里插入图片描述
    但是本实验要求用栈切换
    思考:为什么能用栈切换呢?进程切换其实就是修改当前PCB,下一个进程的PCB,当前进程的内核栈,下一个进程的内核栈等信息,这些信息保存在tss中,其实这些信息也可以保存在内核栈,这样就直接可以用栈实现切换了

    虽然 TSS(next)不再需要了,但是 LDT(next)仍然是需要的,也就是说,现在每个进程不用有自己的 TSS 了,因为已经不采用 TSS 进程切换了,但是每个进程需要有自己的 LDT,地址分离地址还是必须要有的,而进程切换必然要涉及到 LDT 的切换。

    • 修改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));
    

    修改函数内部 位置如下
    在这里插入图片描述

    由于用到了pnext,所以需要定义pnext并初始化

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

    位置如下
    在这里插入图片描述

    • 实现switch_to
      思考:switch_to完成的功能是什么呢?
      linux0.11中的switch_to是通过tss来实现,tss实现的是?tss中保存了各种寄存器的值,在进程交换的时候,把CPU的各种寄存器的值拍到原tss中,然后将新的tss里面的值扣到CPU各个寄存器里。现在我们不借助tss,直接用内核栈来实现,其实完成的切换内容差不多是一样的
      由于是 C 语言调用汇编,所以需要首先在汇编中处理栈帧,即处理 ebp 寄存器;接下来要取出表示下一个进程 PCB 的参数,并和 current 做一个比较,如果等于 current,则什么也不用做;如果不等于 current,就开始进程切换,依次完成 PCB 的切换TSS 中的内核栈指针的重写内核栈的切换LDT 的切换以及 PC 指针(即 CS:EIP)的切换

    由于要对内核栈进行精细的操作,所以需要用汇编代码来完成函数 switch_to 的编写。,而switch_to函数是一个系统调用,故写在/kernel/system_call.s中

    .align 2
    switch_to:
        //因为该汇编函数要在c语言中调用,所以要先在汇编中处理栈帧
    	pushl %ebp
    	movl %esp,%ebp
    	pushl %ecx
    	pushl %ebx
    	pushl %eax
    
        //先得到目标进程的pcb,然后进行判断
        //如果目标进程的pcb(存放在ebp寄存器中) 等于   当前进程的pcb => 不需要进行切换,直接退出函数调用
        //如果目标进程的pcb(存放在ebp寄存器中) 不等于 当前进程的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
    	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
    
    

    位置图如下
    在这里插入图片描述

    下面来解释一下各部分交换如何实现的:

    思考:在执行switch_to之前,栈里应该是什么样子呢?
    在这里插入图片描述

    	movl 8(%ebp),%ebx
    	cmpl %ebx,current
    

    所以movl 8(%ebp),%ebx,就是将pnext的值赋值给ebx,也即是下一个PCB的参数,然后cmpl %ebp,current即是比较两者是否相等

    PCB 的切换
    完成 PCB 的切换可以采用下面两条指令,其中 ebx 是从参数中取出来的下一个进程的 PCB 指针,

    movl %ebx,%eax
    xchgl %eax,current
    

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

    TSS 中的内核栈指针的重写

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

    struct tss_struct *tss = &(init_task.task.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),所以需要我们额外添加。在(/oslab/linux0.11/include/linux/sched.h)中找到结构体task_struct的定义,对其进行如下修改:

    include/linux/sched.h 文件

    long kernelstack;
    

    位置图

    在这里插入图片描述

    由于这里将 PCB 结构体的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化, 需要修改 #define INIT_TASK,即在 PCB 的第四项中增加关于内核栈栈指针的初始化

    #define INIT_TASK \
    /* state etc */	{ 0,15,15,\
    /* signals */	0,{{},},0, \
    ...
    改为:
    #define INIT_TASK \
    /* state etc */	{ 0,15,15, PAGE_SIZE+(long)&init_task,\
    /* signals */	0,{{},},0, \
    ...,
    
    

    同时在(system_call.s)中定义KERNEL_STACK = 12 并且修改汇编硬编码,修改代码如下:

    ESP0 = 4
    KERNEL_STACK = 12
    
    state   = 0     # these are offsets into the task-struct.
    counter = 4
    priority = 8
    kernelstack = 12
    signal  = 16
    sigaction = 20      # MUST be 16 (=len of sigaction)
    blocked = (37*16)
    

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

    	movl 12(%ebp),%ecx
    	lldt %cx
    	movl $0x17,%ecx
    	mov %cx,%fsmovl 12(%ebp),%ecx
    	lldt %cx
    

    switch_to 代码中在切换完 LDT 后的两句,即:

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

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

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

    到这里 switch_to的功能就完成了

    2.修改fork.c

    和书中论述的原理一致,就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESPCS:IP 关联在一起。

    修改 fork() 的核心工作就是要形成如下图所示的子进程内核栈结构。

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

    • 修改copy_process()函数 /kernel/fork.c

      • 注释tss进程切换片段
      • 添加代码
        在这里插入图片描述
        copy_process()函数中添加
      	long *krnstack;
          krnstack = (long)(PAGE_SIZE +(long)p);
      
      	*(--krnstack) = ss & 0xffff;
      	*(--krnstack) = esp;
      	*(--krnstack) = eflags;
      	*(--krnstack) = cs & 0xffff;
      	*(--krnstack) = eip;
      
      	*(--krnstack) = ds & 0xffff;
          *(--krnstack) = es & 0xffff;
          *(--krnstack) = fs & 0xffff;
          *(--krnstack) = gs & 0xffff;
          *(--krnstack) = esi;
          *(--krnstack) = edi;
          *(--krnstack) = edx;
      
      	*(--krnstack) = (long) first_return_kernel;
      
      	*(--krnstack) = ebp;
      	*(--krnstack) = ecx;
      	*(--krnstack) = ebx;
      	*(--krnstack) = 0;
      
      	p->kernelstack = 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_kernel(属于系统调用,而且是一段汇编代码)写在kernel/system_call.s头文件里面:

    首先需要将first_return_kernel设置在全局可见:

    .globl switch_to,first_return_kernel
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7OXbaPIg-1642342567292)(C:/Users/22064/AppData/Roaming/Typora/typora-user-images/image-20220116202135917.png)]

    将具体的函数实现放在system_call.s头文件里面:

    .align 2
    first_return_kernel:
    	 popl %edx
    	 popl %edi
    	 popl %esi
    	 pop %gs
    	 pop %fs
    	 pop %es
    	 pop %ds
    	 iret
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QMsLiR7g-1642342567292)(C:/Users/22064/AppData/Roaming/Typora/typora-user-images/image-20220116202241417.png)]

    最后要记得是在 kernel/fork.c 文件里使用了 first_return_kernel 函数,所以要在该文件里添加外部函数声明

    extern void first_return_kernel(void);
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rOWY9yOs-1642342567293)(C:/Users/22064/AppData/Roaming/Typora/typora-user-images/image-20220116202406354.png)]
    编译运行
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P36SgUKw-1642342567295)(C:/Users/22064/AppData/Roaming/Typora/typora-user-images/image-20220116203058846.png)]
    参考文章:
    操作系统实验五 基于内核栈切换的进程切换(哈工大李治军)
    哈工大-操作系统-HitOSlab-李治军-实验4-基于内核栈切换的进程切换

    展开全文
  • 试验四-基于内核栈切换的进程切换,回答问题: (1)为什么要加 4096; (2)为什么没有设置 tss 中的 ss0。 答: (1)由于Linux 0.11进程的内核栈和该进程的PCB在同一页内存上(一块4KB大小的内存),其中PCB位于...
  • 现在的 Linux 0.11 采用 TSS(后面会有详细论述)和一条指令就能完成任务切换,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要 200 多个时钟周期。 而通过堆栈实现任务切换可能要更快,而且采用堆栈...

    研究本文,最好先看看《任务状态段TSS及TSS描述符、局部描述符表LDT及LDT描述符》
    https://blog.csdn.net/MJ_Lee/article/details/104419980/
    本文改于文章https://blog.csdn.net/weixin_41761478/article/details/99777145?spm=1001.2014.3001.5501

    一、分析基于内核栈切换的进程切换是个什么样子:
    1
    TSS作用
    段描述符描述内存代码段或内存数据段。
    找到当前内核栈:进程内核栈在线性地址空间中的地址是由该任务的TSS段中的ss0和esp0两个字段指定的,依靠TR寄存器就可以找到当前进程的TSS。也就是说,当从用户态进入内核态时,CPU会自动依靠TR寄存器找到当前进程的TSS,然后根据里面ss0和esp0的值找到内核栈的位置,完成用户栈到内核栈的切换。TSS是沟通用户栈和内核栈的关键桥梁,这一点在改写成基于内核栈切换的进程切换中相当重要!
    目前 Linux 0.11 中工作的 schedule() 函数是首先找到下一个进程的数组位置 next(),而这个 next() 就是用来找到切换后目标 TSS 段的段描述符的,一旦获得了这个 next 值,直接 switch_to(next)就能完成切换(进行TSS覆盖)。
    但是
    原有的Linux 0.11采用基于TSS和一条指令,虽然简单,但这指令的执行时间却很长,在实现任务切换时大概需要200多个时钟周期。而通过堆栈实现任务切换可能要快,而且采用堆栈的切换还可以使用指令流水的并行化优化技术,同时又使得CPU的设计变得简单。所以无论是Linux还是Windows,进程/线程的切换都没有使用Intel提供的这种TSS切换手段,而都是通过堆栈实现的。
    图 1为基于 TSS 的进程切换--------------即多个TSS的进程切换
    在这里插入图片描述
    CPU里面还有TSS寄存器

    2
    中断进入内核
    从用户态进入内核发生了什么?
    当执行int 0x80 这条语句时由用户态进入内核态时,CPU会自动按照SS、ESP、EFLAGS、CS、EIP的顺序,将这几个寄存器的值压入到内核栈中,由于执行int 0x80时还未进入内核,所以压入内核栈的这五个寄存器的值是用户态时的值,其中EIP为int 0x80的下一条语句 “=a” (__res),这条语句的含义是将eax所代表的寄存器的值放入到_res变量中。所以当应用程序在内核中返回时,会继续执行 “=a” (__res) 这条语句。这个过程完成了进程切换中的第一步,通过在内核栈中压入用户栈的ss、esp建立了用户栈和内核栈的联系,形象点说,即在用户栈和内核栈之间拉了一条线,形成了一套栈。

    内核栈的具体样子
    在用户态执行int 0x80将SS、ESP、EFLAGS、CS、EIP入内核栈。(保存用户态现场于内核栈,因为在内核栈切换,所有这里用户态现场不保存于用户栈)
    在system_call中,也是在内核中将DS、ES、FS、EDX、ECX、EBX入内核栈。(EDX、ECX、EBX作为系统调用的参数FS系统调用实验曾依靠fs获取用户地址空间(用户数据段)中的数据)

    extern int system_call(void); // 系统调用中断处理程序
    【指令格式】IRET
    【指令功能】IRET(interrupt return)中断返回,中断服务程序的最后一条指令。IRET指令将推入堆栈的段地址和偏移地址弹出,使程序返回到原来发生中断的地方。其作用是从中断中恢复中断前的状态,具体作用有如下三点:
    1.恢复IP(instruction pointer):IP←((SP)+1:(SP)),
    SP←SP+2
    2.恢复CS(code segment):CS←((SP)+1:(SP)),
    SP←SP+2
    3.恢复中断前的PSW(program status word),即恢复中断前的标志寄存器的状态。
    FR←((SP)+1:(SP)),SP←SP+2
    4.恢复ESP(返回权限发生变化)
    5.恢复SS(返回权限发生变化)
    以上操作按顺序进行。
    在这里插入图片描述

    bad_sys_call:
    	movl $-1,%eax       # eax 中置-1,退出中断
    	iret
    
    system_call:
    	cmpl $nr_system_calls-1,%eax    # 调用号如果超出范围的话就在eax中置-1并退出
    	ja bad_sys_call                 #
    	push %ds                        # 保存原段寄存器值
    	push %es
    	push %fs
    # 一个系统调用最多可带有3个参数,也可以不带参数。下面入栈的ebx、ecx和edx中放着系统
    # 调用相应C语言函数的调用函数。这几个寄存器入栈的顺序是由GNU GCC规定的,
    # ebx 中可存放第1个参数,ecx中存放第2个参数,edx中存放第3个参数。
    # 系统调用语句可参见头文件include/unistd.h中的系统调用宏。
    	pushl %edx
    	pushl %ecx		
    	pushl %ebx		
    	movl $0x10,%edx		
    	mov %dx,%ds
    	mov %dx,%es
    # fs指向局部数据段(局部描述符表中数据段描述符),即指向执行本次系统调用的用户程序的数据段。
    # 注意,在Linux 0.11 中内核给任务分配的代码和数据内存段是重叠的,他们的段基址和段限长相同。
    	movl $0x17,%edx		
    	mov %dx,%fs
    # 下面这句操作数的含义是:调用地址=[_sys_call_table + %eax * 4]
    # sys_call_table[]是一个指针数组,定义在include/linux/sys.h中,该指针数组中设置了所有72个系统调用C处理函数地址。
    	call sys_call_table(,%eax,4)        # 间接调用指定功能C函数
    	pushl %eax                          # 把系统调用返回值入栈
    # 下面几行查看当前任务的运行状态。如果不在就绪状态(state != 0)就去执行调度程序。如果该
    # 任务在就绪状态,但其时间片已用完(counter = 0),则也去执行调度程序。例如当后台进程组中的
    # 进程执行控制终端读写操作时,那么默认条件下该后台进程组所有进程会收到SIGTTIN或SIGTTOU
    # 信号,导致进程组中所有进程处于停止状态。而当前进程则会立刻返回。
    	movl current,%eax                   # 取当前任务(进程)数据结构地址→eax
    	cmpl $0,state(%eax)		# state
    	jne reschedule
    	cmpl $0,counter(%eax)		# counter
    	je reschedule
    
    
    
    
    # 以下这段代码执行从系统调用C函数返回后,对信号进行识别处理。其他中断服务程序退出时也
    # 将跳转到这里进行处理后才退出中断过程,例如后面的处理器出错中断int 16.
    ret_from_sys_call:
    # 首先判别当前任务是否是初始任务task0,如果是则不比对其进行信号量方面的处理,直接返回。
    	movl current,%eax		# task[0] cannot have signals
    	cmpl task,%eax
    	je 3f                   # 向前(forward)跳转到标号3处退出中断处理
    # 通过对原调用程序代码选择符的检查来判断调用程序是否是用户任务。如果不是则直接退出中断。
    # 这是因为任务在内核态执行时不可抢占。否则对任务进行信号量的识别处理。这里比较选择符是否
    # 为用户代码段的选择符0x000f(RPL=3,局部表,第一个段(代码段))来判断是否为用户任务。如果不是
    # 则说明是某个中断服务程序跳转到上面的,于是跳转退出中断程序。如果原堆栈段选择符不为
    # 0x17(即原堆栈不在用户段中),也说明本次系统调用的调用者不是用户任务,则也退出。
    	cmpw $0x0f,CS(%esp)		# was old code segment supervisor ?
    	jne 3f
    	cmpw $0x17,OLDSS(%esp)		# was stack segment = 0x17 ?
    	jne 3f
    # 下面这段代码用于处理当前任务中的信号。首先取当前任务结构中的信号位图(32位,每位代表1种
    # 信号),然后用任务结构中的信号阻塞(屏蔽)码,阻塞不允许的信号位,取得数值最小的信号值,
    # 再把原信号位图中该信号对应的位复位(0),最后将该信号值作为参数之一调用do_signal().
    # do_signal()在kernel/signal.c中,其参数包括13个入栈信息。
    	movl signal(%eax),%ebx          # 取信号位图→ebx,1位代表1种信号,共32个信号
    	movl blocked(%eax),%ecx         # 取阻塞(屏蔽)信号位图→ecx
    	notl %ecx                       # 每位取反
    	andl %ebx,%ecx                  # 获得许可信号位图
    	bsfl %ecx,%ecx                  # 从低位(0)开始扫描位图,看是否有1的位,若有,则ecx保留该位的偏移值
    	je 3f                           # 如果没有信号则向前跳转退出
    	btrl %ecx,%ebx                  # 复位该信号(ebx含有原signal位图)
    	movl %ebx,signal(%eax)          # 重新保存signal位图信息→current->signal.
    	incl %ecx                       # 将信号调整为从1开始的数(1-32)
    	pushl %ecx                      # 信号值入栈作为调用do_signal的参数之一
    	call do_signal                  # 调用C函数信号处理程序(kernel/signal.c)
    	popl %eax                       # 弹出入栈的信号值
    3:	popl %eax                       # eax中含有上面入栈系统调用的返回值
    	popl %ebx
    	popl %ecx
    	popl %edx
    	pop %fs
    	pop %es
    	pop %ds
    	iret
    

    在执行schedule前将ret_from_sys_call压栈,因为schedule是c函数,所以在c函数末尾的},相当于ret指令,将会弹出ret_from_sys_call作为返回地址,跳转到ret_from_sys_call执行。
    总之,在系统调用结束后,将要中断返回前,内核栈的样子如下:
    栈图1
    在这里插入图片描述
    进程切换五段论
    基于内核栈实现进程切换的基本思路:当进程由用户态进入内核时,会引起堆栈切换,用户态的信息会压入到内核栈中,包括此时用户态执行的指令序列EIP。由于某种原因,该进程变为阻塞态,让出CPU,重新引起调度时,操作系统会找到新的进程的PCB,并完成该进程与新进程PCB的切换。如果我们将内核栈和PCB关联起来,让操作系统在进行PCB切换时,也完成内核栈的切换,那么当中断返回时,执行IRET指令时,弹出的就是新进程的EIP,从而跳转到新进程的用户态指令序列执行,也就完成了进程的切换。
    这个切换的核心是构建出内核栈的样子,要在适当的地方压入适当的返回地址,并根据内核栈的样子,编写相应的汇编代码,精细地完成内核栈的入栈和出栈操作,在适当的地方弹出适当的返回地址,以保证能顺利完成进程的切换。同时完成内核栈和PCB的关联,在PCB切换时,完成内核栈的切换。

    二、实验--------作出基于内核栈切换的进程切换的样子
    1、实验目标
    要实现基于内核栈的任务切换,主要完成如下三件工作:
    (1)重写 switch_to;
    (2)将重写的 switch_to 和 schedule() 函数接在一起;
    (3)修改现在的 fork()。

    2、找到当前进程的PCB和新进程的PCB--------修改schedule()函数
    当前进程的PCB
    当前进程的PCB是用一个全局变量current指向的(在sched.c中定义) ,所以current即指向当前进程的PCB
    新进程的PCB
    为了得到新进程的PCB,我们需要对schedule()函数做如下修改:

    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)); 
    
    

    这样,pnext就指向下个进程的PCB。
    下面接着分析栈
    在schedule()函数中,当调用函数switch_to(pent, _LDT(next))时,会依次将参数2 _LDT(next)、参数1 pnext、返回地址}压栈。当执行switch_to的返回指令ret时,就回弹出schedule()函数的}执行schedule()函数的返回指令}。关于执行switch_to时内核栈的样子,在后面改写switch_to函数时十分重要。
    此处将跳入到switch_to中执行时,内核栈的样子如下:
    栈 图2
    在这里插入图片描述
    3、完成PCB的切换
    4、根据PCB完成内核栈的切换
    5、切换运行资源LDT
    这些工作都将由改写后的switch_to完成
    将Linux 0.11中原有的switch_to实现去掉,写成一段基于堆栈切换的代码。由于要对内核栈进行精细的操作,所以需要用汇编代码来实现switch_to的编写,既然要用汇编来实现switch_to,那么将switch_to的实现放在system_call.s中是最合适的。这个函数依次主要完成如下功能:由于是c语言调用汇编,所以需要首先在汇编中处理栈帧,即处理ebp寄存器;接下来要取出表示下一个进程PCB的参数,并和current做一个比较,如果等于current,则什么也不用做;如果不等于current,就开始进程切换,依次完成PCB的切换、TSS中的内核栈指针的重写、内核栈的切换、LDT的切换以及PC指针(即CS:EIP)的切换。
    switch_to(system_call.s)的基本框架如下:

      switch_to:
            pushl %ebp
            movl %esp,%ebp          !保存下面压栈ecx,ebx,eax前的esp值,压栈ecx,ebx,eax后esp会增加12字节
            pushl %ecx
            pushl %ebx
            pushl %eax
            movl 8(%ebp),%ebx       !8(%ebp)即ss:[ebp+8],就是把栈图2中的指针pnext置给ebx
            cmpl %ebx,current
            je 1f                   !如果是切换到当前进程就不用切PCB、TSS中的内核栈指针的重写、内核栈的切换、LDT的切换以及PC指针(即CS:EIP)的切换
        ! 切换PCB
           movl %ebx,%eax           ! movl 8(%ebp),%ebx  !8(%ebp)即ss:[ebp+8],就是把栈图2中的指针pnext置给ebx,这里即再把pnext置给eax
           xchgl %eax,current       !这里最终把pnext置给current,current是结构体类型可以说是pcb类型,实现了切换PCB
        ! TSS中的内核栈指针的重写
           movl tss,%ecx            !extern tss_struct tss所有进程都共用这个tss
           addl $4096,%ebx
           movl %ebx,ESP0(%ecx)     !ESP0=4即movl cs:[ecx+4],ebx,就是把tss中的 long esp0(;//特权级0的堆栈指针字段)置为cs:[pnext+4096],cs:[pnext+4096]是要切换去的进程的栈顶。
                                    !是在更新全局变量tss的成员特权级0的堆栈指针字段
        ! 切换内核栈
           movl %esp,KERNEL_STACK(%eax)KERNEL_STACK(%eax)即cs:[eax+12],就是task_struct即PCB的成员long kernelstack,是在保存切换前哪个进程的esp
           movl 8(%ebp),%ebx        !8(%ebp)即ss:[ebp+8],就是把栈图2中的指针pnext置给ebx
           movl KERNEL_STACK(%ebx),%esp !把esp更新为下一个进程的esp
        ! 切换LDT
            movl 12(%ebp),%ecx      !就是把栈图2中的_LDT(next)置给ecx
            lldt %cx                !LDT(Load Local Descriptor Table)	加载局部描述符表,即更新局部描述符表
    
            cmpl %eax,last_task_used_math 
            jne 1f
            clts
        
        1:   popl %eax
            popl %ebx
            popl %ecx
            popl %ebp
        ret
    
    

    代码:
    1)完成PCB的切换

    movl %ebx,%eax
    xchgl %eax,current
    

    2)TSS中的内核栈指针的重写
    如前所述,当从用户态进入内核态时,CPU会自动依靠TR寄存器找到当前进程的TSS,然后根据里面ss0和esp0的值找到内核栈的位置(TSS中ss0和esp0是内核栈段地址和内核栈栈顶),完成用户栈到内核栈的切换。虽然此时不使用TSS进行进程切换了,但是Intel的中断处理机制还是要保持。
    所以每个进程仍然需要一个TSS,操作系统需要有一个当前TSS。
    这里采用的方案是让所有进程共用一个TSS(这里使用0号进程的TSS),因此需要定义一个全局指针变量tss(放在system_call.s中),即0号进程的tss,所有进程都共用这个tss,任务切换时不再发生变化。
    在这里插入图片描述

    //下面是数学协处理器使用的结构,主要用于保存进程切换时i387的执行状态信息。
    struct i387_struct {
    	long	cwd;// 控制字(Control word)
    	long	swd;// 状态字(Status word) 
    	long	twd;// 标记字(Tag word) 
    	long	fip;//协处理器代码指针。
    	long	fcs;//协处理器代码段寄存器。
    	long	foo;//内存操作数的偏移位置。
    	long	fos;//内存操作数的段值。
    	long	st_space[20];	 // 8个10字节的协处理器累加器 
    };
    struct tss_struct 
    {
           long back_link;  //先前任务连接字段            /* 16 high bits zero  back背  link纽带*/     
           
           long esp0;//特权级0的堆栈指针字段 (0,1,2感觉有三套栈)
           long ss0; //特权级0的堆栈指针字段        /*高16位为零*/ 
           
           long esp1;//特权级1的堆栈指针字段
           long ss1; //特权级1的堆栈指针字段        /* 16 high bits zero */
           
           long esp2;//特权级2的堆栈指针字段
           long ss2;//特权级2的堆栈指针字段         /* 16 high bits zero */
           
           long cr3;  //CR3控制寄存器字段
           long eip;  //指令指针EIP字段
           long eflags;//标志寄存器EFLAGS字段
           
           long eax,ecx,edx,ebx;//通用寄存器字段。
           long esp;//通用寄存器字段
           long ebp;//通用寄存器字段
           long esi;//通用寄存器字段
           long edi;//通用寄存器字段
           
           long es;//段选择符字段          /* 16 high bits zero */
           long cs;//段选择符字段          /* 16 high bits zero */
           long ss;//段选择符字段           /* 16 high bits zero */
           long ds;//段选择符字段           /* 16 high bits zero */
           long fs;//段选择符字段           /* 16 high bits zero */
           long gs;//段选择符字段           /* 16 high bits zero */
           
           long ldt; //LDT段选择符字段。            /* 16 high bits zero */
           long trace_bitmap;  //I/O位图基地址字段  /* bits: trace 0, bitmap 16-31  trace追踪bitmap位图*/
           struct i387_struct i387;//数学协处理器使用的结构        
    };
    extern tss_struct tss
     /*1动态字段。当任务切换而被挂起时,处理器会更新动态字段的内容。这些字段包括:
            1)通用寄存器字段。用于保存EAX、ECX、EDX、EBX、ESP、EBP、ESI和EDI寄存器的内容。
            2)段选择符字段。用于保存ES、CS、SS、DS、FS和GS段寄存器的内容。  
            3)标志寄存器EFLAGS字段。在切换之前保存EFLAGSo
            4)指令指针EIP字段。在切换之前保存EIP寄存器内容。
            5)先前任务连接字段。含有前一个任务TSS段选择符(在调用、中断或异常激发的任务切换时更新)。 
               该字段(通常也称为后连接字段(Back link field))允许任务使用IRET指令切换到前一个任务。
     2.静态字段。处理器会读取静态字段的内容,但通常不会改变它们。这些字段内容是在任务被创建时设置的。这些字段有:  
            1)LDT段选择符字段。含有任务的LDT段的选择符。
            2)CR3控制寄存器字段。含有任务使用的页目录物理基地址。控制寄存器CR3通常也被称为页目    
               录基地址寄存器 PDBR (Page directory base register)
            3)特权级0、1和2的堆栈指针字段。这些堆栈指针由堆栈段选择符(SSO、SS1和SS2)和栈中偏     
               移量指针(ESPO、ESPUD ESP2)组成。注意,对于指定的一个任务,这些字段的值是不变的。 
               因此,如果任务中发生堆栈切换,寄存器SS和ESP的内容将会改变
            4)调试陷阱(Debug Trap) T标志字段。该字段位于字节0x64比特0处。当设置了该位时,处理器 
               切换到该任务的操作将产生一个调试异常
            5)I/O位图基地址字段。该字段含有从TSS段开始处到I/O许可位图处的16位偏移值
              如果使用了分页机制,那么在任务切换期间应该避免处理器操作的TSS段中(前104字节中)含有内 
              存页边界。如果TSS这部分包含内存页边界,那么该边界处两边的页面都必须同时并且连续存在于内存中。 
              另外,如果使用了分页机制,那么与原任务TSS和新任务TSS相关的页面,以及对应的描述符表表项应 该是可读写的。   */                                                                                                                                                                                                                                                                                                                                                          
    

    虽然所有进程共用一个tss,但不同进程的内核栈是不同的,所以在每次进程切换时,需要更新tss中esp0的值,让它指向新的进程的内核栈,并且要指向新的进程的内核栈的栈底,即要保证此时的内核栈是个空栈,帧指针和栈指针都指向内核栈的栈底。
    这是因为新进程每次中断进入内核时,其内核栈应该是一个空栈。为此我们还需要定义变量ESP0 等于4,这是TSS中内核栈指针esp0的偏移值,以便可以找到esp0。具体实现代码如下:

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

    为什么要加4096?
    因为Linux 0.11进程的内核栈和该进程的PCB在同一页内存上(一块4KB大小的内存),其中PCB位于这页内存的低地址,栈则位于这页内存的高地址。一开始内核栈无内容,应指向内存页的最顶端。
    3、内核栈的切换

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

    在schedule.h中将struct task_struct修改如下:

    struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack;    #add
    ......
    }
    

    同时在system_call.s中定义KERNEL_STACK = 12 并且修改汇编硬编码,修改代码如下:(已给出)

    ESP0        = 4
    KERNEL_STACK    = 12
    
    ......
    
    state   = 0     # these are offsets into the task-struct.
    counter = 4
    priority = 8
    kernelstack = 12
    signal  = 16
    sigaction = 20      # MUST be 16 (=len of sigaction)
    blocked = (37*16)
    

    switch_to中的实现内核栈的切换代码如下:(对照栈理解)

    movl %esp,KERNEL_STACK(%eax)          
    movl 8(%ebp),%ebx
    movl KERNEL_STACK(%ebx),%esp
    

    改为Intel 汇编格式

    movl [%eax + KERNEL_STACK],esp   ![%eax + KERNEL_STACK]即cs:[%eax + 12]
    movl ebx,[ebp+8]                  
    movl esp,[ebx+KERNEL_STACK]      
    

    INIT_TASK用于设置第1个任务表,第一个PCB
    由于这里将 PCB 结构体(task_struct)的定义改变了,所以在产生 0 号进程的 PCB 初始化时也要跟着一起变化,需要将原来的 #define INIT_TASK { 0,15,15, 0,{{},},0,…
    修改为 #define INIT_TASK { 0,15,15,PAGE_SIZE+(long)&init_task, 0,{{},},0,…,
    即在 PCB 的第四项中增加关于内核栈栈指针的初始化。
    4、LDT的切换
    switch_to中实现代码如下:

    movl 12(%ebp),%ecx
    lldt %cx               ! LLDT(Load Local Descriptor Table)	加载局部描述符表
    

    一旦修改完成,下一个进程在执行用户态程序时使用的映射表就是自己的LDT表了,地址分离实现了。

    三、利用IRET指令完成用户栈的切换
    1
    栈帧结构:
    在这里插入图片描述
    在这里插入图片描述
    注:pnext和_LDT(next)反了,switch_to栈帧在switch_to最后都出栈了即恢复如下:
    在这里插入图片描述
    继续执行schedule,但schedule只剩一个 }。}执行后返回调用schedule()函数的
    2
    system_call.s的框架

    .....
    .....
    bad_sys_call:
    	movl $-1,%eax       # eax 中置-1,退出中断
    	iret
    .....
    .....
    
    reschedule:
    	pushl $ret_from_sys_call        # 将ret_from_sys_call返回地址压入堆栈,当调度程序schedule返回时就从ret_from_sys_call出继续执行。
    	jmp schedule
    ....
    ....
    system_call:
                    ....
                    ....
    	ja bad_sys_call
                    .....
                    .....
    	call sys_call_table(,%eax,4)            # 间接调用指定功能C函数
                    .....
                    .....
    	            movl current,%eax          # 取当前任务(进程)数据结构地址→eax
    	            cmpl $0,state(%eax)		   # state
    	            jne reschedule
    	            cmpl $0,counter(%eax)	   # counter
    	            je reschedule
    ret_from_sys_call:
                    .....
                    .....
    	            iret
    

    PC的切换
    对于被切换出去的进程,当它再次被调度执行时,根据被切换出去的进程的内核栈的样子,switch_to的最后一句指令ret会弹出switch_to()后面的指令}作为返回返回地址继续执行,从而执行}从schedule()函数返回,将弹出ret_from_sys_call作为返回地址执行ret_from_sys_call,在ret_from_sys_call中进行一些处理,最后执行iret指令,进行中断返回,将弹出原来用户态进程被中断地方的指令作为返回地址,继续从被中断处执行。

    四、关联用户栈和内核栈
    fork()
    对于得到CPU的新的进程,我们就是要把进程的用户栈、用户程序和其内核栈通过压在内核栈中的 SS:ESP,CS:IP 关联在一起。
    另外,由于 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 最有意思,代表返回值是0,与父进程区分
    *(--krnstack) = 0;
    
    

    现在到了switch_to()中的 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 的核心代码:

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

    所以 edx 等寄存器的值也应该先初始化到子进程内核栈,即 krnstack 中。
    后别忘了将存放在 PCB 中的内核栈指针修改到初始化完成时内核栈的栈顶,即:

     p->kernelstack = krnstack;
    
    

    fork.c中copy_process()的具体修改如下:

    long *krnstack;
    p = (struct task_struct *) get_free_page();
    krnstack = (long)(PAGE_SIZE +(long)p);
     *(--krnstack) = ss & 0xffff;
     *(--krnstack) = esp;
     *(--krnstack) = eflags;
     *(--krnstack) = cs & 0xffff;
     *(--krnstack) = eip;
     *(--krnstack) = ds & 0xffff;
     *(--krnstack) = es & 0xffff;
     *(--krnstack) = fs & 0xffff;
     *(--krnstack) = gs & 0xffff;
     *(--krnstack) = esi;
     *(--krnstack) = edi;
     *(--krnstack) = edx;
     *(--krnstack) = (long)first_return_from_kernel;
     *(--krnstack) = ebp;
     *(--krnstack) = ecx;
     *(--krnstack) = ebx;
     *(--krnstack) = 0;
     p->kernelstack = krnstack;
     ......
    
    

    最后,注意由于switch_to()和first_return_from_kernel都是在system_call.s中实现的,要想在schedule.c和fork.c中调用它们,就必须在system_call.s中将这两个标号声明为全局的,同时在引用到它们的.c文件中声明它们是一个外部变量。

    具体代码如下:

    system_call.s中的全局声明

    .globl switch_to
    .globl first_return_from_kernel
    

    对应.c文件中的外部变量声明:

    extern long switch_to;
    extern long first_return_from_kernel;
    

    五进行实验
    改------make------run
    在这里插入图片描述

    展开全文
  • 目录总览第一部分、switch_to相关一、改写switch_to二、配合switch_to...to四、PCB结构如下第二部分、fork()相关一、为什么要修改fork()二、内核栈视角下的五段论2.1中断进入阶段(第一阶段)2.2 调用schedule引起PCB切

    总览

    本实验可以分为两个主要部分:

    一部分是对应switch_to五段论中的中间段——内核栈的切换,主要是对switch_to代码的修改。
    (原来的switch_to是基于TSS切换和长跳转指令来切换到下一个线程);

    另一部分则在更改进程切换方式的情况下,如何修改fork()使其配合新的进程切换方式
    (因为fork()需要在新建一个子线程后,切换到子线程执行,而切换方式在上一部分改变了)

    本文旨在将实现的整个流程讲清楚,在switch_to的注释内核栈视角下的五段论务必详细观看,在理解整个流程起的作用非常大。

    第一部分、switch_to相关

    一、改写switch_to

    switch_to原来在include/linux/sched.h的宏定义中,我们将那部分注释掉并在kernel/system_call.s中添加以下switch_to汇编代码

    首先我们要明确以下几点:
    1、switch_to主要是用来切换进程的,包括切换内核栈、LDT、PCB等
    2、由于每个任务都有一个LDT,LDT是映射表,避免不同进程的符号地址产生错乱;
    3、以下代码的p,pnext都是PCB的首地址,也就是sched.h里的task_struct就是PCB,如果看不明白偏移的话,可以看看task_struct是长什么样的。而且,查看task_struct可知,tss_struct在task_struct的最后一个位置。
    4、我们将来以这样的方式调用switch_to:

    switch_to(pnext, _LDT(next));
    它含有两个参数

    
    .align 2
    switch_to:
    
        
    	pushl %ebp
    	movl %esp,%ebp
    	pushl %ecx
    	pushl %ebx
    	pushl %eax
    
    	# (1)判断要切换的进程和当前进程是否是同一个进程
    	movl 8(%ebp),%ebx	/*%ebp+8就是从右往左数起第二个参数,也就是*pnext*/
    	cmpl %ebx,current	/* 如果当前进程和要切换的进程是同一个进程,就不切换了 */
    	je 1f
    	/*先得到目标进程的pcb,然后进行判断
        如果目标进程的pcb(存放在ebp寄存器中) 等于   当前进程的pcb => 不需要进行切换,直接退出函数调用
        如果目标进程的pcb(存放在ebp寄存器中) 不等于 当前进程的pcb => 需要进行切换,直接跳到下面去执行*/
    
    
    	# (2)切换PCB
    	movl %ebx,%eax
    	xchgl %eax,current
    	/*ebx是下一个进程的PCB首地址,current是当前进程PCB首地址*/
    
    
    	# (3)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从最低地址开始扩展*/
    
    
    	# (4)切换内核栈
    	# KERNEL_STACK代表kernel_stack在PCB表的偏移量,意思是说kernel_stack位于PCB表的第KERNEL_STACK个字节处,注意:PCB表就是task_struct
    	movl %esp,KERNEL_STACK(%eax)	/* eax就是上个进程的PCB首地址,这句话是将当前的esp压入旧PCB的kernel_stack。所以该句就是保存旧进程内核栈的操作。*/
    	movl 8(%ebp),%ebx		/*%ebp+8就是从左往右数起第一个参数,也就是ebx=*pnext ,pnext就是下一个进程的PCB首地址。至于为什么是8,请查看附录(I)*/
    	movl KERNEL_STACK(%ebx),%esp	/*将下一个进程的内核栈指针加载到esp*/
    
    
    	# (5)切换LDT
    	movl 12(%ebp),%ecx         /* %ebp+12就是从左往右数起第二个参数,对应_LDT(next) */
    	lldt %cx                /*用新任务的LDT修改LDTR寄存器*/
    	/*下一个进程在执行用户态程序时使用的映射表就是自己的 LDT 表了,地址空间实现了分离*/
    
    	# (6)重置一下用户态内存空间指针的选择符fs
    	movl $0x17,%ecx
    	mov %cx,%fs
    	/*通过 fs 访问进程的用户态内存,LDT 切换完成就意味着切换了分配给进程的用户态内存地址空间,
    	所以前一个 fs 指向的是上一个进程的用户态内存,而现在需要执行下一个进程的用户态内存,
    	所以就需要用这两条指令来重取 fs。但还存在一个问题,就是为什么固定是0x17呢?详见附录(II)*/
    	
    
    # 和后面的 clts 配合来处理协处理器,由于和主题关系不大,此处不做论述
        cmpl %eax,last_task_used_math
        jne 1f
        clts
    
    
    1:  popl %eax
    	popl %ebx
    	popl %ecx
    	popl %ebp
    ret
    

    二、配合switch_to修改的补充

    2.1 开放switch_to

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

    .globl switch_to
    

    即可使switch_to被外面访问到。

    2.2改写task_struct

    原来的进程切换是基于TSS切换和长跳转指令的,没有内核栈,也就是说PCB表内没有记录内核栈信息%esp的字段。所以,我们要为PCB增加这么一个字段kernelstack,里面记录了内核栈的栈顶指针,也就是%esp里会放置的内容

    struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack;
    ...
    }
    
    

    我们把它添加在第4个位置。避免需要更改太多硬编码。但还是有一个硬编码需要修改。

    /*在kernal/system_call.s里*/
    /*#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*/
    

    2.3 ESP0和KERNEL_STACK 以及一些参数的设置

    /*在system_call.s下*/
    ESP0 = 4	#此处新添
    KERNEL_STACK = 12	#此处新添
    
    state	= 0		# these are offsets into the task-struct.
    counter	= 4
    priority = 8
    signal	= 16	#此处修改
    sigaction = 20	#此处修改
    blocked = (37*16)	#此处修改
    

    1、定义 ESP0 = 4 是因为 内核栈指针 esp0 就放在进程0的TSS 中偏移为 4 的地方
    2、KERNEL_STACK = 12的意思非常明显了,我们上面刚刚设过kernelstack放在task_struct第4个位置。
    其它三个修改意思不明,网上是这么修改的。

    2.4 全局变量tss的声明

    虽然我们不再使用tss切换来切换进程,但是在中断的时候,要找到内核栈位置,并将用户态下的 SS:ESP,CS:EIP 以及 EFLAGS这五个寄存器压到内核栈中,这是沟通用户栈(用户态)和内核栈(内核态)的关键桥梁,而找到内核栈位置就依靠 TR 指向的当前 TSS。
    现在虽然不使用 TSS 进行任务切换了,但是 Intel 的这态中断处理机制还要保持,所以仍然需要有一个当前 TSS,这个 TSS就是我们定义的那个全局变量 tss,即 0 号进程的 tss,所有进程都共用这个 tss,任务切换时不再发生变化。

    总的来说,我们声明tss为0进程的TSS,该tss里面含有当前进程的一些信息(SS:ESP,CS:EIP 以及 EFLAGS),这些信息作为沟通用户态和内核态的关键桥梁

    2.5 在sche.c声明switch_to

    由于我们将要在schedule()调用switch_to,所以需要在sche.c声明一下这个外部函数。

    /*添加在文件头部附近*/
    extern long switch_to(struct task_struct *p, unsigned long address);
    

    三、在schedule()调用switch_to

    /*以下修改都在sched.c上进行*/
    /*(1)在sched.c中要添加一个全局变量PCB,它的含义时下一个切换进程的PCB,但默认初始化为进程0的PCB*/
    struct task_struct *pnext = &(init_task.task);
    
    /* (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;
    
    /*(3)修改调用switch_to
    switch_to(next);
    	修改为
    */
    switch_to(pnext, _LDT(next));
    

    四、PCB结构如下

    经过上面的讨论,我们知道PCB结构如下,其中task_struct结构可以进入include/linux/sche.h进行查看
    在这里插入图片描述

    第二部分、fork()相关

    一、为什么要修改fork()

    在开始修改fork()之前,我们需要明确为什么要修改fork()?

    fork()是用来创建子进程的,而创建子进程意味着必须要该线程的一切都配置好了,以后直接切换就可以执行。而由于我们上面修改了进程切换方式,所以原来的配置不管用了。所以我们需要根据切换方式的改变,来改变这些配置

    二、内核栈视角下的五段论

    由于fork()的时候要配置子进程内核栈,所以我们先要明确内核栈里面应该有什么。

    2.1中断进入阶段(第一阶段)

    下面是关于从用户态中断进入内核态的阶段,内核栈的情况。

    中断进入,是switch_to的第一阶段,就是int指令或其它硬件中断的中断处理入口,核心工作就是记录当前程序在用户态执行时的信息,如当前使用的用户栈、当前程序的执行位置、当前执行的现场信息等。

    (1)其中用户栈地址SS:ESP和PC指针信息CS:EIP以及EFLAGS已经由中断处理硬件自动压入当前线程的内核栈了。
    这时内核栈如下:
    在这里插入图片描述
    (2)当执行系统调用int 0x80从而执行到system_call(),又会把当前执行的现场信息压入内核栈。啥现场信息?就是一堆用户态寄存器的值,我们可以看看system_call.s的代码:

    #在kernel/system_call.s下
    system_call:
        cmpl $nr_system_calls-1,%eax
        ja bad_sys_call
        push %ds
        push %es
        push %fs
        pushl %edx
        pushl %ecx      # push %ebx,%ecx,%edx as parameters
        pushl %ebx      # to the system call
        movl $0x10,%edx        # set up ds,es to kernel space
        mov %dx,%ds
        mov %dx,%es
        movl $0x17,%edx        # fs points to local data space
        mov %dx,%fs
        call sys_call_table(,%eax,4)
        pushl %eax
        movl current,%eax
        cmpl $0,state(%eax)        # state
        jne reschedule
        cmpl $0,counter(%eax)      # counter
        je reschedule
    ret_from_sys_call:
           以下略,是从系统调用返回的代码
    

    ①执行system_call开头时按顺序压入ds、es、fs、gs、edi、esi、edx、ecx、ebx、eax这些用户态的寄存器现场信息。
    此时内核栈如下:
    在这里插入图片描述
    ②在system_call中执行完相应的系统调用sys_call_xx后,又将函数的返回值eax压栈。(以上代码17行)
    ③若引起进程调度,则跳转执行reschedule。否则则执行ret_from_sys_call。reschedule如下

    reschedule:
          pushl $ret_from_sys_call
          jmp schedule
    

    我们当然是考虑调度的这类情况,因为我们正在讨论switch_to五段论。在reschedule中,我们把ret_from_sys_call的地址压入内核栈,然后执行schedule()调度函数。
    此时内核栈如下:

    在这里插入图片描述

    2.2 调用schedule引起PCB切换(第二阶段)

    当在schedule()执行switch_to前会进行参数压栈以及下一条指令压栈

    schedule(){
    ……
    switch_to(pnext,_LDT(next));
    }
    

    内核栈会压入参数_LDT(next)、pnext,然后压入它的下一条指令的位置,也就是 “}”
    此时内核栈如下:
    在这里插入图片描述

    进入switch_to后,将一些调用者寄存器压栈,以便后续使用这些寄存器。

    switch_to:
            pushl %ebp
            movl %esp,%ebp
            pushl %ecx
            pushl %ebx
            pushl %eax
    

    此时内核栈如下
    在这里插入图片描述

    2.3切换内核栈(第三阶段)

    这个阶段内核栈变化了。一个进程被挂起,也意味着其内核栈被挂起,这时切换到一个之前被挂起的进程,并将其内核栈的栈顶地址加载到esp。
    但是,我们要注意一点:任何进程被挂起,都要经过上面的过程,也就意味着内核栈的结构没变,但是里面的内容变了。

    2.4 中断返回前(第四阶段)

    (1)中断返回前的处理
    ①切换内核栈后,后续经过一些和内核栈无关的操作后,最后在switch_to弹出eax、ebx、ecx、ebp

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

    在这里插入图片描述
    ②switch_to的ret
    1、执行到switch_to 的ret会弹出 “}”并返回到schedule()的 “}”处执行。
    2、在C函数的“}” 处,会自动弹出参数pnext 和_LDT(next),并弹出ret_from_sys_call的地址作为EIP开始执行ret_from_sys_call。
    内核栈情况如下:
    在这里插入图片描述
    (3)ret_from_sys_call的最后弹出了一系列用户态寄存器信息

    ret_from_sys_call:
    	……
    3:      popl %eax
            popl %ebx
            popl %ecx
            popl %edx
            pop %fs
            pop %es
            pop %ds
            iret
    

    此时内核栈信息如下:
    在这里插入图片描述

    2.5 中断返回(第五阶段)

    然后再执行iret指令,该指令就可以设置好用户栈(SS:ESP)、EFLAGS、用户指令执行位置(CS:EIP)到相应寄存器,然后就直接跳转回用户态执行。这条硬件指令和int 0x80对应来看就很容易理解了。

    iret就是interrupt return ,中断返回。

    当返回到用户态执行时,内核栈就空了。大功告成!

    三、修改fork()

    3.1 主体修改

    由之前学习可知,修改fork()就是修改fork.c的copy_process()。

    经过上面的讨论,我们知道不再采用TSS切换,所以把tss切换相关的都注释掉。

    	/** 很容易看出来下面的部分就是基于tss进程切换机制时的代码,所以将此片段要注释掉
    
    	p->tss.back_link = 0;
    	p->tss.esp0 = PAGE_SIZE + (long) p;
    	p->tss.ss0 = 0x10;
    	...
    	*/
    

    接下来的核心任务就是设计一个子进程的内核栈。话不多说,直接上代码。

    /*在fork.c的copy_process()的第一行添加以下代码即可*/
    long *krnstack;
    p = (struct task_struct *) get_free_page();
    krnstack = (long)(PAGE_SIZE +(long)p);
     *(--krnstack) = ss & 0xffff;
     *(--krnstack) = esp;
      *(--krnstack) = eflags;
     *(--krnstack) = cs & 0xffff;
     *(--krnstack) = eip;
     *(--krnstack) = ds & 0xffff;
     *(--krnstack) = es & 0xffff;
     *(--krnstack) = fs & 0xffff;
     *(--krnstack) = gs & 0xffff;
     *(--krnstack) = esi;
     *(--krnstack) = edi;
     *(--krnstack) = edx;
     *(--krnstack) = (long)first_return_from_kernel;
     *(--krnstack) = ebp;
     *(--krnstack) = ecx;
     *(--krnstack) = ebx;
     *(--krnstack) = 0;//eax,到最后作为fork()的返回值,即子进程的pid
     p->kernelstack = krnstack;
     ......
    

    子进程内核栈图如下:
    在这里插入图片描述
    是不是几乎和上面的内核栈一模一样?是那就对了!到最后也是设置好用户态的寄存器然后返回到用户态,原理是一样的。至于为什么多了几个esi、edi、gs寄存器?因为fork()是完全复制父进程的内容到子进程,所以所有寄存器都要复制过去。
    综上,经过switch_to和first_return_from_kernel的弹出,一共弹出了eax(0)、ebx、ecx、edx、edi、esi、gs、fs、es、ds10个寄存器,用户态的寄存器内容已经完全弹到相应的寄存器后,就可以执行iret了。

    3.2 first_return_from_kernel的编写

    1、这个first_return_from_kernel对应的是上面的ret_from_sys_call。

    /*在system_call.s下*/
    .align 2
    first_return_from_kernel:
        popl %edx
        popl %edi
        popl %esi
        pop  %gs
        pop  %fs
        pop  %es
        pop  %ds
        iret
    

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

    /*在system_call.s头部附近*/
    .globl first_return_from_kernel
    

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

    /*在fork.c头部附近*/
    extern void first_return_from_kernel(void);
    

    完结

    如果没有其它错误的话,make all后系统如果可以运行就成功了。

    附录(I)

    在这里插入图片描述

    解析:
    对应switch_to(pnext, _LDT(next))
    1、图中参数1就是pnext,参数2就是_LDT(next)
    2、图中当前栈帧就是对应switch_to()的栈帧,而调用过程栈帧就是schedule()的栈帧,因为是schdule()调用switch_to的,

    附录(II)

    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 之后?

    关于问题的解析:新设fs后,会去再次查GDT表获得描述符,从而得到用户态内存基地址,而不是用之前查的记录,换句话来说,就是更新了用户态的基地址

    致谢:

    参考了网上多篇博客才弄清楚全部流程。
    [1]https://blog.csdn.net/weixin_41761478/article/details/99777145?utm_medium=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-1.control&depth_1-utm_source=distribute.pc_relevant.none-task-blog-BlogCommendFromBaidu-1.control
    [2]https://www.cnblogs.com/XiangfeiAi/p/4758401.html
    [3]https://blog.csdn.net/qq_41708792/article/details/89637248
    [4]https://blog.csdn.net/xubing716/article/details/53412647
    [5]https://blog.csdn.net/jump_into_zehe/article/details/106038473

    展开全文
  • 内核栈指针重写指令中有宏定义ESP0,所以在上面需要提前定义好 ESP0 = 4,(是因为TSS中内核栈指针ESP0放在偏移为4的地方) 修改以后的fork()要使得父子进程共享同一块内存空间、堆栈和数据代码块。 首先需要将...
  • 操作系统任务切换过程有两种,一种是基于TSS(任务状态段)的切换,这种切换需要使用一个长跳转指令,需要很多的mov,而且不能进行指令流水(分解成微指令),造成执行起来很慢。执行过程如下:使用TR(描述符表...
  • 构建出内核栈,要在适当的地址压入适当的返回地址,并根据内核栈的样子,编写相应的汇编代码,精细地完成内核栈的入栈和出栈操作,在适当的地方弹出正确的返回地址,以保证能顺利完成进程切换。同时,还要完成内核...
  • 实验5 基于内核栈切换的进程切换 实验目的 深入理解进程和进程切换的概念; 综合应用进程、CPU 管理、PCB、LDT、内核栈、内核态等知识解决实际问题; 开始建立系统认识。 实验内容 现在的 Linux 0.11 采用 TSS 和...
  • OS需要为进程切换做的那些东西   在linux 0.11中,进程切换非常简单!只需要一句ljmp就能够做到,因为其中需要做的所有东西intel都已经让cpu帮你做好了,但不幸的是,这一条指令非常耗时,大概需要两百个时钟周期...
  • 1 内核级线程模型 1.1 内核级线程概念、图示 用户级线程的缺点:如果一个用户级线程在内核中阻塞,则这个进程的所有用户...1.2 内核级线程的切换五段论 1.3 内核级线程的创建 1.4 Linux操作系统中的多进程视图 2 L...
  • Linux0.11操作系统(哈工大李治军老师)实验楼实验5-基于内核栈切换的进程切换 任务 在linux0.11中实现基于内核栈切换的进程切换 简介 现在的 Linux 0.11 采用 TSS(后面会有详细论述)和一条指令就能完成任务切换,...
  • 把Linux-0.11进程切换的方式改为基于内核栈切换,而不是基于TSS切换(因为太耗时)。 要实现基于内核栈的任务切换,主要完成如下三件工作: 重写 switch_to; 将重写的 switch_to 和 schedule() 函数接在一起; 修改...
  • 实验4-基于内核栈切换的进程切换 实验内容请查看蓝桥云课实验说明 参考文献: 1.哈工大实验“官方”github仓库 2.蓝桥云课-操作系统原理与实践
  • 操作系统实验之基于内核栈切换的进程切换
  • 进程切换的六段论 1 中断进入内核 2 找到当前进程的PCB和新进程的PCB 3 完成PCB的切换 4 根据PCB完成内核栈的切换 5 切换运行资源LDT 6 利用IRET指令完成用户栈的切换 1. 原有的基于TSS的...
  • 进程切换流程 首先先了解一下进程切换的流程。开始的时候一个进程(or线程,在这里不加以区分,简单的认为二者的区别只是在是否产生内存映射表的切换,其他方面相同)A正在运行,CPU通过取指执行的方式一点点的去...
  • 实验要求与实验指导见 实验楼。 实验环境为 配置本地实验环境。...  基于内核栈实现进程切换的大致过程如下:   当系统发生中断从用户态进入内核态时,CPU 通过 TR 寄存器找到 TSS 的位置,根据 TSS ...
  • 分析: TSS作用 找到当前内核栈:进程内核栈在线性地址空间中的地址是由该任务的TSS段中的ss0和esp0两个字段指定的,依靠TR寄存器就...TSS是沟通用户栈和内核栈的关键桥梁,这一点在改写成基于内核栈切换进程切...
  • 综合应用进程、CPU 管理、PCB、LDT、内核栈、内核态等知识解决实际问题; 开始建立系统认识 实验内容: 现在的 Linux 0.11 采用 TSS(后面会有详细论述)和一条指令就能完成任务切换,虽然简单,但这指令的执行时间...
  • 既保留了并发的优点,也避免了进程切换代价,切换耗时更短,完成切换更快。 (1)引入yield yield让当前线程从运行态进入就绪态,实现线程的切换,从而让线程可交替执行。 (2)线程 当线程之间使用yield()的...
  • 本实验是操作系统之进程与线程 - 网易云课堂的配套实验,推荐大家进行实验之前先学习相关课程: L10 用户级线程 L11 内核级线程 L12 核心级线程实现实例 L13 操作系统的那棵树 Tips:点击上方文字中的超链接...
  • 但是,这样的切换方式效率太低,所以后来Linux和Windows都改成采用基于内核栈切换这种方式。由于TSS机制比较简单,所以大部分精力用于讲解基于内核栈机制。 以下的基于内核栈方式是通过修改Linux0.11的基于TSS...
  • 根据老师的实验,要把这种基于tss切换的方式换成现代的基于内核栈切换的方式。       基于堆栈的切换,核心就是要先在切换前保存进程的栈,然后切换到另一个进程的栈,但因
  • 基于内核栈切换的进程切换 实验目的:将linux-0.11中采用的TSS切换部分去掉,取而代之的是基于堆栈的切换程序,写成一段基于堆栈切换的代码 要实现基于内核栈的任务切换,主要完成如下三件工作 重写switch_to 将...
  • 实验环境:基于内核栈切换的进程切换 实验本质:将 Linux 0.11 中采用的 TSS 切换部分去掉,取而代之的是基于堆栈的切换程序。具体的说,就是将 Linux 0.11 中的 switch_to实现去掉,写成一段基于堆栈切换的代码。 ...
  • 哈工大操作系统实验---lab4:基于内核栈进程切换

    千次阅读 多人点赞 2019-04-28 14:23:46
    将Linux0.11中采用的基于TSS进程切换去掉,取而代之的是基于堆栈的切换程序,具体地说,也就是将进程切换函数schedule()函数中的switch_to()函数从原本的基于TSS切换改写成基于堆栈的切换。 编写汇编程序switch_t.....

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 28,791
精华内容 11,516
关键字:

基于内核栈的进程切换