HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告

实验要求与实验指导见 实验楼。
实验环境为 配置本地实验环境。

一、实验目标

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

二、实验内容和结果

0. 进程切换过程

  基于内核栈实现进程切换的大致过程如下:

  当系统发生中断从用户态进入内核态时,CPU 通过 TR 寄存器找到 TSS 的位置,根据 TSS 中保存的 ss0:esp0的值切换到内核栈,并自动将用户栈的 ssespeflagscseip的值保存在内核栈中。中断处理完成后,此时若调度函数找到了需要切换的进程,此时就该将进程的其他寄存器信息也保存至内核栈中,然后切换到目的进程的 PCB、内核栈、LDT,最后从目的进程的内核栈中恢复寄存器的值,并从中断返回,此时 iret将弹出目的进程的 cs:eip,从而能跳转到目的进程中继续执行,这样就完成了进程的切换。

1. 在 system_call.s 重写 switch_to()

  在原 Linux-0.11 中,schedule()函数将找出目的进程,然后 switch_to()函数进行 tss 的切换。用内核栈切换时,switch_to()函数将需要切换 PCB、内核栈、LDT。所以该函数需要两个进程的 PCB、内核栈、LDT等信息,需要通过汇编语言来实现精准控制。

  在 kernel/sched.c中,Linux-0.11 将进程的 PCB 和内核栈定义在了一起:

    union task_union {
        struct task_struct task;
        char stack[PAGE_SIZE];
    };

  且在 kernel/fork.c:copy_process()函数中为 PCB 分配了一页内存,即进程的内核栈和该进程的 PCB 在同一页 4KB 大小的内存上,其中 PCB 位于这页内存的低地址,内核栈位于这页内存的高地址。另外,kernel/sched.c中定义了一个全局变量 current 一直指向当前进程的 PCB,于是 switch_to()函数需要的参数为:目的进程的 PCB 指针、目的进程的 LDT。故函数的外部原型定义如下,这个定义需要添加到 kernel/sched.c头部:

extern void switch_to(struct task_struct *pnext, unsigned long ldt);

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

  函数调用同样需要栈来传递参数、保存寄存器值和返回地址,栈帧结构如下:

HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告_第1张图片

  可以知道两个参数的位置分别在 8(%ebp) 和 12(%ebp) 处。

  代码框架大致如下:

switch_to:
    pushl %ebp
    movl %esp,%ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx # 获取参数 pnext
    cmpl %ebx,current
    je 1f
    # 切换PCB
    ...
    # TSS中的内核栈指针的重写
    ...
    # 切换内核栈
    ...
    # 切换LDT
    ...
    movl $0x17,%ecx
    mov %cx,%fs
    cmpl %eax,last_task_used_math
    jne 1f
    clts
1:  popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
    ret

1. 切换 PCB

  Linux-0.11 当前正在运行的任务的 PCB 指针为 current,需要切换的下一个进程的 PCB 指针为 pnext,在上述代码中被保存到 ebx 寄存器中。所以这里需要把 ebx 寄存器的值放在 current中:

    movl %ebx,%eax       # eax = pnext
    xchgl %eax,current   # current = pnext;eax 保存旧进程PCB

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

  上述提到,发生中断时 CPU 将通过 TR 寄存器找到 TSS 然后从用户栈切换到内核栈,所以 TSS 仍然需要保留。但此时系统不再通过 TSS 进行任务切换,所以不需要每个进程都保留 TSS,只需要保留一份,用于中断时从用户栈进入内核栈,于是把它设为 struct tss_struct *tss = &(init_task.task.tss),在 kernel/sched.c处定义。而不同进程的内核栈位置是不同的,所以每次进程切换时,同时需要更新唯一 TSS 中的 ss0:esp0的值,从而使它一直指向当前进程的内核栈。TSS 的存储格式(部分)如下图,可知esp0 的位置为 tss + 4

HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告_第2张图片

    movl tss,%ecx        # ecx = init_task.task.tss
    addl $4096,%ebx      # ebx = pnext 内核栈的栈顶
    movl %ebx,4(%ecx)    # 把tss中内核栈指针esp0设为 pnext 的内核栈的栈顶

3. 切换内核栈

  当前 PCB 中没有记录内核栈顶的状态变量,需要额外添加。

  进程的 PCB 和进程 0 定义 include/linux/sched.h中,在这里添加一个 kernelstack变量表示进程的内核栈栈顶位置:

struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack; /* add */
    long signal;
    struct sigaction sigaction[32];
    long blocked;
    ...
}

  任务数据结构改了之后,需要对 INIT_TASK的宏定义页进行修改,使 kernelstack = PAGE_SIZE+(long)&init_task

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

  最后修改 kernel/system_call.s中定义的变量偏移值:

state = 0   # these are offsets into the task-struct.
counter = 4
priority = 8
KERNEL_STACK = 12 # add
signal = 16
sigaction = 20
blocked = (33*16+4)

  这样一个变量就添加完成了,在 switch_to()中根据这个变量记录的位置进行内核栈的切换:

    # 切换内核栈
    movl %esp, KERNEL_STACK(%eax)  # 将esp(内核栈栈顶位置)保存到旧进程PCB
    movl 8(%ebp), %ebx  # 再使ebx = pnext
    movl KERNEL_STACK(%ebx), %esp  # 再从 pnext 取出内核栈栈顶位置,这样esp就是切换后进程的内核栈

4. 切换 LDT

  切换 LDT 只需要使用 lldt指令改变 LDTR 寄存器的值即可:

    movl 12(%ebp),%ecx   # 获取的参数ldt(next)
    lldt %cx            # 修改 LDTR 寄存器(注意 lldt 的参数是段选择符是16位)

  综上,完整的 switch_to代码如下:

.align 2
switch_to:
    pushl %ebp
    movl %esp, %ebp
    pushl %ecx
    pushl %ebx
    pushl %eax
    movl 8(%ebp),%ebx    # ebx = pnext
    cmpl %ebx,current
    je 1f
    # 切换PCB
    movl %ebx,%eax       # eax = pnext
    xchgl %eax,current   # eax=old_current, current=pnext
    # TSS中的内核栈指针的重写
    movl tss,%ecx        # ecx = init_task.task.tss
    addl $4096,%ebx      # ebx = the top of pnext kernel stack
    movl %ebx,4(%ecx)    # 把tss中内核栈指针esp0设为的内核栈的栈顶
    # 切换内核栈
    movl %esp, KERNEL_STACK(%eax)  # 将寄存器esp(内核栈使用到当前情况时的栈顶位置)的值保存到当前PCB中
    movl 8(%ebp), %ebx 	           # 再取一下ebx,因为前面修改了ebx的值. ebx=current(pnext)
    movl KERNEL_STACK(%ebx), %esp  # 再从 pnext 取出内核栈栈顶位置,这样esp就是切换后进程的内核栈
    # 切换LDT
    movl 12(%ebp),%ecx   # 获取switch_to()的参数ldt(next)
    lldt %cx            # 修改 LDTR 寄存器
    # 加载切换后进程的用户数据空间
    movl $0x17,%ecx      # 0x17 都是fs,但需要查的LDT表不一样。为了刷新FS寄存器的隐藏部分:段基地址和段限长
    mov %cx,%fs
    cmpl %eax,last_task_used_math  # 处理数学协处理器
    jne 1f
    clts
1:
    popl %eax
    popl %ebx
    popl %ecx
    popl %ebp
    ret

2. 修改 sched.c

  首先需要将 switch_to()的外部原型定义需要添加到 kernel/sched.c头部:

extern void switch_to(struct task_struct *pnext, unsigned long ldt);

  schedule()需要为 switch_to()提供两个参数pnextldt,后者找到 next 即可,而前者还需要添加一个 PCB 指针,为了让系统无事可做时去执行任务0,所以将它初始化为 struct task_struct *pnext = &(init_task.task)。更改后的代码如下:

void schedule(void)
{
    int i,next,c;
    struct task_struct ** p;
    struct task_struct *pnext = &(init_task.task); // add
    ...
    while (1) {
        ...
        while (--i) {
            if (!*--p)
                continue;
            if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
                // change
                // c = (*p)->counter, next = i;
                c = (*p)->counter, next = i, pnext = *p;
        }
    }
    // change
    //switch_to(next);
    switch_to(pnext, _LDT(next));
}

  新的 switch_to()函数将在 kernel/system_call.s实现,所以需要在 sched.c头部加入外部函数声明。此外,还需要新增一个 tss_struct指针指向进程 0 的 tss:

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

  sched.c修改完成。

3. 修改 fork.c

  copy_process()函数是 fork()的主要处理过程,这个函数将设置子进程的 PCB 及 TSS,使子进程与父进程共用数据段和代码段。所以先设置子进程的内核栈内容为:

    long *kernelstack = (long *)(PAGE_SIZE + (long)p);
    *(--kernelstack) = ss & 0xffff;
    *(--kernelstack) = esp;
    *(--kernelstack) = eflags;
    *(--kernelstack) = cs & 0xffff;
    *(--kernelstack) = eip;

  这样当子进程开始执行时,iret指令将从上述位置获取 cs:eip的值继续执行。

  在用 TSS 进行任务切换时,TSS 会存储进程执行期间各寄存器的值,当恢复到这个进程执行时,寄存器的值会从 TSS 中恢复;现在使用内核栈进行任务切换时,进程的寄存器的值需要从内核栈中恢复,因此这里也需要将各个寄存器的值保存到栈中。

  结合 copy_process()函数的参数和设置子进程的 TSS 的代码就能发现哪些寄存器的信息是必须保存的:

  1. CPU 执行中断指令压入的用户栈地址 ss:esp、标志寄存器 eflags、返回地址 cs:eip;

  2. 刚进入 system_call()时入栈的寄存器 ds、es、fs、edx、ecx、ebx;

  3. 调用copy_process()前入栈的 gs、esi、edi、ebp、eax 值。

  接下来要结合 switch_to()函数进行考虑:假设要切换的目的进程是这个刚新建的进程,当 switch_to()把 PCB、内核栈、LDT 切换完毕后,会跳转到标号 “1” 执行:

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

  它将依次恢复寄存器 eax、ebx、ecx、ebp 的值。所以在内核栈的栈顶需要按顺序保存它们的值。

    *(--kernelstack) = ebp;
    *(--kernelstack) = ecx;
    *(--kernelstack) = ebx;
    *(--kernelstack) = 0;  // 即 eax。子进程需要将 eax 设为 0
    p->kernelstack = (long)kernelstack;  // 新进程内核栈初始化完毕后,记录栈顶位置

  此时子进程构建的内核栈空间如下:

HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告_第3张图片

  其中标号 ① 为上述 ret的目的地址,之后程序将跳转到目的地址处的代码继续执行。那么目的地址的代码需要执行的是:继续将栈中保存的寄存器的值恢复到寄存器中。于是在 system_call.s中添加一个函数,用来继续恢复寄存器的值:

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

  实验指导中提供的这段代码将剩下的寄存器的值都恢复完了,并通过 iret指令恢复到用户态。根据这段代码的顺序,子进程构建时内核栈的内容得以完善。

  于是修改后的 copy_process()函数的代码如下:

int copy_process(...)
{
    struct task_struct *p;
    int i;
    struct file *f;
    long *kernelstack; // add
    p = (struct task_struct *) get_free_page();
    ...
    /* add */
    kernelstack = (long *)(PAGE_SIZE + (long)p);
    *(--kernelstack) = ss & 0xffff;
    *(--kernelstack) = esp;
    *(--kernelstack) = eflags;
    *(--kernelstack) = cs & 0xffff;
    *(--kernelstack) = eip;
    *(--kernelstack) = ds & 0xffff;
    *(--kernelstack) = es & 0xffff;
    *(--kernelstack) = fs & 0xffff;
    *(--kernelstack) = gs & 0xffff;
    *(--kernelstack) = esi;
    *(--kernelstack) = edi;
    *(--kernelstack) = edx;
    *(--kernelstack) = (long)first_return_from_kernel;
    *(--kernelstack) = ebp;
    *(--kernelstack) = ecx;
    *(--kernelstack) = ebx;
    *(--kernelstack) = 0;   /* eax */
    p->kernelstack = (long)kernelstack;
    /* added */
    // 删除下列操作 TSS 的代码
    /*
    p->tss.back_link = 0;
    ...
    p->tss.trace_bitmap = 0x80000000;
    */
    ...
    return last_pid;
}

  于是,子进程的内核栈构建完成,如下图所示:

HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告_第4张图片

  在 fork.c首部,还需要添加 first_return_from_kernel()的外部函数原型定义:

    extern void first_return_from_kernel(void);

  最后还需要在 system_call.s中将两个新增的函数设为全局的:

.globl first_return_from_kernel, switch_to

4. 修改 sched.h

  现在 switch_to()函数已经在 kernel/system_call.s中实现,这里的宏定义直接去掉;然后需要修改上述提到的 PCB 的结构,最后添加上 tss 的原型定义。代码如下:

// change
struct task_struct {
    long state;
    long counter;
    long priority;
    long kernelstack; /* add */
    long signal;
    struct sigaction sigaction[32];
    long blocked;
    ...
}

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

// add
extern struct tss_struct *tss; // add

// delete switch_to
/*
#define switch_to(n) {\
 ... \
 }
 */

5. 实验结果

  改完后,重新编译启动,运行成功。

HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告_第5张图片

三、实验总结

TSS 进行进程切换

  在 Linux-0.11 中,进程的切换依靠 TSS 的切换。每一个进程都有一个 TSS,里面包含了几乎所有寄存器的快照。CPU 有一个 TR 寄存器指向当前进程的 TSS 结构体的内存位置。

  intel 提供了多条指令可以执行切换 TSS 的操作:

  1. 当前任务对 GDT 中的 TSS 描述符执行 JMP 或 CALL 指令;
  2. 当前任务对 GDT 或 LDT 中的任务门门描述符执行 JMP 或 CALL 指令;
  3. 中断或异常向量指向 IDT 表中的任务门描述符;
  4. 当 EFLAGS 中的 NT 标志置位时当前任务执行 IRET 指令。

  Linux-0.11 使用 ljmp指令来执行进程切换,其工作过程为(实验楼的指导与《Linux-0.11内核完全注释》一书表述不一致,此处参考注释一书):

  1. 根据 ljmp的操作数取得目的进程的 TSS 段选择符;
  2. 根据 TR 寄存器存放的段选择符找到当前进程的 TSS 的地址,将 CPU 中的寄存器值存放到这个 TSS 结构中;
  3. 将目的进程的 TSS 段选择符和描述符加载到任务寄存器 TR;
  4. 将目的进程的 TSS 的对应的值复制到寄存器上;
  5. 根据更新后的 cs:eip的值执行目的进程。

  如下图所示:

HIT Linux-0.11 实验五 基于内核栈切换的进程切换 实验报告_第6张图片

四、问题

回答下面三个题:

1. 问题 1

针对下面的代码片段:

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

回答问题:

(1)为什么要加 4096;

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

(1).
Linux-0.11 中进程的 PCB 和内核栈在同一页内存上,PCB 在低地址,内核栈在高地址,一页的大小为 4K = 4096。之前 ebx 寄存器保存的是 pnext 指针的值,是代切换进程的 PCB 的地址,加上 4096 后就到内核栈的栈顶位置了。

(2). 经过改动后,所有进程共用一个 tss,tss.ss0 在初始化 INIT_TASK 时已设置,无需再次设置。

2. 问题 2

针对代码片段:

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

回答问题:

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

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

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

(1).
子进程第一次执行时 eax = 0。把它设置为0后,子进程中 fork()的返回值为0,与父进程区分开了。将它设为 0 的代码为 *(--kernelstack) = 0,上面已有解释。

(2).
这段代码的 ebx 和 ecx 来自 copy_process()的参数,根据内核栈往前探究可知它来自 system_call,它们的含义是通用寄存器,用来保存系统调用存放的参数。这里是为了完全复制父进程的上下文且配合 switch_to()的弹栈过程而设计。

(3).
这段代码的 ebp 来自 copy_process()的参数,由 sys_fork()函数将其压栈。这里是为了完全复制父进程的上下文且配合 switch_to()的弹栈过程而设计。所以不能不设置。

3. 问题 3

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

重新设置 fs 是为了为了刷新 fs 寄存器的隐藏部分:段基地址和段限长,下次用 fs 访问用户数据段时不必再次查询 GDT 表,提高了执行效率。由于当前任务已经改变,如果在切换 LDT 前旧重新设置,可能 fs 的隐藏部分就是上一个进程的用户空间内存的基地址和段限长。

你可能感兴趣的:(OS,and,Linux)