北航操做系统实验2019:Lab4-1流程梳理

北航操做系统实验2019:Lab4-1流程梳理

前言

操做系统的实验课实在使人头秃。咱们须要在两周时间内学习相关知识、读懂指导书、读懂代码、补全代码、处理玄学bug和祖传bug,以及回答使人窒息的思考题。能够说,这门课的要求很是高,就我的感受,远比计算机组成实验课要难受。算法

一方面,想要达到细致理解操做系统每一个实现细节,很是困难,须要大量时间和经历的投入;但另外一方面,若是咱们可以理解了操做系统实现的每一个细节,咱们的水平也会有大幅度的提高。在这里,我记录下本次实验课下个人学习经历,若是有不对的地方,但愿可以指出,以求共同进步。函数

1、预备知识

在前面三个Lab的实验中,咱们成功的搭建起了操做系统的内核,创建了内存管理机制和进程调度机制。通常来讲,进程是给用户使用的,而用户没法直接对系统内核进行存取。另外一方面,进程与进程之间的虚拟地址互相独立,这使得两个进程之间的互相通讯变得困难。可是,用户会在有些状况下须要使用只有内核才能进行的操做。为了解决这个问题,操做系统设计了系统调用。学习

指导书上已有的知识,我在此再也不赘述。在进行实验以前,咱们须要稍微补习一点知识,主要是关于汇编函数方面的东西。这些知识,指导书或者其余地方都有,只不过比较零碎。我稍微汇集了一下这些知识,若是想要了解的更详细,能够深刻了解。操作系统

1. 汇编函数构造宏(include/asm/asm.h)

为了方便的像C语言同样构造函数,咱们的操做系统事先为咱们提供了函数的宏,咱们能够直接使用。这个宏的代码并不是由本校人员开发,应当是较为通用的定义方式。文件中为咱们提供了两种函数的宏,即叶函数(LEAF)和嵌套函数(NESTED)。设计

咱们把函数体中没有函数调用语句的函数称为叶函数,天然若是有函数调用语句的函数称为非叶函数。在MIPS 的调用规范中,进入函数体时会经过对栈指针作减法的方式为自身的局部变量、返回地址、调用函数的参数分配存储空间(叶函数没有后二者),在函数调用结束以后会对栈指针作加法来释放这部分空间,咱们把这部分空间称为栈帧(Stack Frame)。3d

——OS指导书指针

下面是宏的具体实现定义。能够看到,函数定义无非是声明一个全局符号,给定一个标签用于跳转和返回。调试

下面是文件中部分代码的引用。有些代码后面我没有写注释,是由于我本身也弄不太清楚,不敢乱讲,怕引发误会。若是有同窗明白,但愿能够给我讲讲。code

#define LEAF(symbol)                            \
        .globl  symbol;                         \声明"symbol"为全局变量
        .align  2;                              \下一个数据的地址空间按字对齐
        .type   symbol,@function;               \
        .ent    symbol,0;                       \告诉汇编器"symbol"函数的起始点,用于调试
        symbol:         .frame  sp,0,ra          提供一个名为"symbol"的标签,将跳转到此处

#define NESTED(symbol, framesize, rpc)          \
        .globl  symbol;                         \
        .align  2;                              \
        .type   symbol,@function;               \
        .ent    symbol,0;                       \
        symbol:         .frame  sp, framesize, rpc   肯定栈帧大小以及结束时的返回地址

#define END(function)                           \
        .end    function;                       \指出函数结尾,用于调试
        .size   function,.-function              在符号表中列出函数名和函数指令字节数

2.C函数和汇编函数的参数、返回值传递

有时候,咱们会不可避免的在C语言中调用汇编函数,也会在汇编语言中调用C函数。根据MIPS软件标准(ABI)的定义,函数的参数传递按照以下原则:blog

  • 若是函数参数个数≤4,则将参数依次存入a0-a3寄存器中,并在栈帧底部保留16字节的空间(即sp的值减去16),但并不必定使用这些空间。
  • 若是函数参数个数>4,则前4个参数依次存入a0-a3寄存器中,从第5个参数开始,依次在前4个参数预留空间以外的空间内存储,即没有寄存器去保存这些值。
  • 举例,若是一个C函数有6个参数,在汇编语言中须要调用的时候,应当将前4个参数存在a0-a3寄存器中,第5个参数存在16(sp)的位置,第6个参数存在20(sp)的位置。区间0-15的空间保留但不使用。

而关于函数的返回值,MIPS ABI规定,返回值存在$v0寄存器中。某些特殊的状况下也会用到$v1寄存器,但不常见。想了解更多关于返回值的知识,请查阅书籍See MIPS Run Linux

3.栈帧方法宏(include/stackframe.h)

咱们在进行用户态和内核态之间的切换,或者进程之间的切换时,须要保存现场。所谓现场,就是include/trap.h中所定义的trap结构体,其中包含的信息有:

  • 32个寄存器的值
  • CP0部分寄存器的值
  • HI、LO两个乘除法寄存器的值
  • 程序的指令计数器PC

可是这个文件中只有结构体的定义,没有将数据存入结构体的操做。将寄存器中的值存入内存,显然要用汇编语言去完成。stackframe.h中定义了一些汇编函数的宏,方便咱们对现场进行存取操做。下面摘录了其中的宏,并做出相应的解释。

//TF_SIZE是Trapframe寄存器的字节大小
.macro STI                  //Set Interrupt,打开全局中断使能(容许中断)
.macro CLI                  //Close Interrupt,关闭全局中断使能(屏蔽中断)
.macro SAVE_ALL             //保存全部现场,将数据以Trapframe结构体形式存在sp为开头的空间中
.macro RESTORE_SOME         //恢复部分现场,此处的“部分”仅不包括sp的值
.macro RESTORE_ALL          //恢复全部现场,包括栈顶的位置
.macro RESTORE_ALL_AND_RET  //恢复现场并从内核态中返回
.macro get_sp               //获取栈顶位置,此函数会判断当前的状态是异常仍是中断,
                            //从而决定栈顶是TIMESTACK仍是KERNEL_SP。
                            //系统调用是编号为8的异常,进程切换是时钟中断信号。

2、系统调用机制的实现

按照指导书上的思路,咱们来梳理一下系统调用的流程:

  • 调用一个须要内核配合才能完成的函数,该函数会调用syscall_xxx函数(user/syscall_lib.c)
  • syscall_xxx函数会调用咱们写的汇编函数msyscall(user/syscall_wrap.S),该函数使用特权指令syscall
  • 此时CPU触发异常,陷入内核态,异常向量分发器检测到是系统调用(异常编号为8),进入handle_sys函数(lib/syscall.S),进行处理
  • handle_sys函数会进一步读取系统调用号,进行进一步分发,分发进C函数(lib/syscall_all.c),在C语言中进行处理。
  • 在内核态中处理完毕,返回用户态,并将返回值(位于$v0寄存器)传递回去,一层层回到调用处。

须要填写的文件:

  • user/syscall_wrap.S

    只须要念一句咒语:syscall就好。固然,考虑到MIPS的习惯,能够move v0, a0,这样后面取出系统调用号也能够在v0中取。

  • lib/syscall.S

    TODO项有三:

    • 取出EPC,计算一个合理的值,再存回去。合理的值是什么呢?若是syscall不在延迟槽里面,合理的值天然只能是顺位的下一条指令EPC+4啦。而咱们写的函数里面,显然没有把syscall放在延迟槽,因此就是EPC+4。
    • 将系统调用号存入寄存器a0。系统调用号是咱们函数的第一个参数。根据MIPS ABI,第一个参数放在a0寄存器中。然而,a0寄存器的值从存入到使用没有发生变化。因此,只要你前面没有瞎写,这一步彻底能够不用操做。若是你前面写了move v0, a0,也能够从TF_REG2中读取,但显得没有必要。
    • 在当前栈指针分配6个参数的存储空间,并将6个参数安置到指望的位置。前四个参数存在a0-a3寄存器,后两个参数(预设代码已经帮你取出,存在t三、t4寄存器)存在16(sp)和20(sp)的位置就行。

    注:第2、3、四个参数的值没有改变过,于是也不须要修改。系统调用号寄存器a0虽然用于计算相对位置,可是此后的调用函数根本没有用到,只是起到一个占位的做用(指导书所言),于是也能够不用修改a0的值,将错就错,不会影响。

  • lib/syscall_all.c

    此处须要实现四个函数,按照文件中的函数顺序来介绍。

    /* Overview:
     *        这个函数容许当前进程释放CPU。
     * Post-Condition:
     *      取消运行当前进程。这个函数永远也不会返回。(?)
     */
    void sys_yield(void)
    {
        // your code here
        /* 直接使用咱们以前写的sched_yield函数便可。
         * 不过,须要在KERNEL_SP和TIMESTACK上作一点准备工做,
         * 由于当前进程处于内核态,保存的现场在KERNEL_SP - sizeof(struct Trapframe),
         * 可是env_run中所使用的进程切换机制中,
         * bcopy从TIMESTACK - sizeof(struct Trapframe)的位置进行复制
         * 于是咱们要把现场复制到TIMESTACK栈区。
         */
    }
    /* Overview:
     *      分配一页内存,并映射到进程envid空间中的虚拟地址va,加上权限perm。
     *      可能的反作用是,若是va已经和一个页面p构建了映射,那么页面p就会被解除映射。
     * Pre-Condition:
     *      perm的PTE_V(有效)位必须为1,而PTE_COW(写时复制)位必须为0。其余位随意。
     * Post-Condition:
     *      返回值0是成功映射,返回值小于0便是出错。
     *      注意va必须小于UTOP,以及env可能会调整本身和子进程的地址空间。
     */
    int sys_mem_alloc(int sysno, u_int envid, u_int va, u_int perm)
    {
            // Your code here.
            struct Env *env;
            struct Page *ppage;
            int ret;
            ret = 0;
          /* 首先将上方注释里的全部须要判断的状况所有判断完。
           * 包括va的范围,perm的部分位,envid是否合法。
           * 进行页面分配(page_alloc)和页面插入(page_insert)的时候也会报错,注意返回值。
           * 各类负数返回值的意义在include/mmu.h中,此后再也不赘述调用函数的返回值。
           * /
    }
    /* Overview:
     *        将源进程地址空间中的相应内存映射到目标进程的相应地址空间的相应虚拟内存中去,
     *      而且附加保护位perm。perm的限制和sys_mem_alloc中同样。
     *      (也许咱们应该加上只读页面不可映射为可写页面的判断?)
     * Post-Condition:
     *      返回值0表明成功,小于0表明报错。
     * Note:
     *      不能对UTOP以上的内存进行操做。
     */
    int sys_mem_map(int sysno, u_int srcid, u_int srcva, u_int dstid, u_int dstva, u_int perm)
    {
            int ret;
            u_int round_srcva, round_dstva;
            struct Env *srcenv;
            struct Env *dstenv;
            struct Page *ppage;
            Pte *ppte;
    
            ppage = NULL;
            ret = 0;
            round_srcva = ROUNDDOWN(srcva, BY2PG);
            round_dstva = ROUNDDOWN(dstva, BY2PG);
          //此处将两个虚拟地址按页进行对齐,映射时应当使用以上两个地址。
    
            // your code here
          /* 首先判断srcva,dstva,perm,srcid,dstid是否合法,
           * 而后在源进程的地址空间中找到所需的页面,插入到目标进程的地址空间中。
           * 主要是使用page_lookup和page_insert两个函数不能出错。
           */
            return ret;
    }
    /* Overview:
     *      解除envid进程空间中虚拟地址va所绑定的页面。
     *      (若是va自己就没绑定页面,函数不做任何操做,算做成功)
     * Post-Condition:
     *      返回值0表明成功,小于0表明出错。
     *      不能解除UTOP地址以上空间的映射。
     */
    int sys_mem_unmap(int sysno, u_int envid, u_int va)
    {
            // Your code here.
            int ret = 0;
            struct Env *env;
          /* 首先判断va,envid是否合法,而后page_remove便可,没有技术含量。
           * 注意page_remove自己具备判断地址是否绑定的功能,因此无需画蛇添足。
           */
            return ret;
    }

3、进程间通讯机制(IPC)

IPC 是微内核最重要的机制之一,目的是使得两个进程之间能够通信,须要经过系统调用来实现。通信最直观的一种理解就是交换数据。

两个进程之间之因此无法相互交换数据,是由于各个进程的地址空间相互独立。咱们在以前写的函数,正是为了实现地址空间之间的沟通。而沟通两个进程,天然须要一个权限凌驾两个进程之上的存在来进行操做,即内核态。

在Lab3使用的进程控制块(struct Env)中,有部分值用于本次实验的进程间通讯,代码以下:

// Lab 4 IPC
    u_int env_ipc_value;    // 传递的数据值
    u_int env_ipc_from;     // 发送者的进程id
    u_int env_ipc_recving;  // 进程是否阻塞,从而可以接收。0为不能接收,1为能够接收。
    u_int env_ipc_dstva;    // 接收物理页面的虚拟地址
    u_int env_ipc_perm;     // 接收页面的保护位

IPC的操做,本质是在内核态中对这些部分进行赋值。咱们须要填的两个函数位于lib/syscall_all.c中。

/* Overview:
 *      这个函数使得调用进程能够接收其余进程发送的信息。更准确地说,
 *      这个函数能够标记当前进程,使得其余进程能够向其发送信息。
 * Pre-Condition:
 *      dstva必须合法(NULL也是合法的)。
 * Post-Condition:
 *      这个系统调用函数会将当前进程状态置为NOT RUNNABLE,并释放CPU。
 */
void sys_ipc_recv(int sysno, u_int dstva)
{
        /* 首先判断dstva是否合法。而后,置recving位为1,给dstva赋值,
         * 设置进程状态为阻塞,而且从新调用sys_yield。
         * 因为咱们的算法采用了两个链表,因此当进程为阻塞时,应当从就绪链表中移出。
         * 不过若是你采用了这种写法,就必须得另想办法终止当前进程。
         * 由于哪怕进程不在sched_list里面,只要时间片没用光,依然可能继续运行。
         * 这样程序就会出错。能够选择不删除不插入,yield函数遇到NOT RUNNABLE就跳过。
         */
}

/* Overview:
 *      Try to send 'value' to the target env 'envid'.
 *      将value传给目标进程envid。
 *      若是目标进程还没有处于可接收状态,返回值应当为-E_IPC_NOT_RECV。
 *      其余状况下,发送成功后,目标进程的IPC部分数据应当按照以下规则更新:
 *      env_ipc_recving设置为0,防止多余的接收。
 *      env_ipc_from设置为发送进程的id。
 *      env_ipc_value设置为函数参数value。
 *      目标进程须要标记为RUNNABLE,以便从新运行。
 * Post-Condition:
 *      返回值0表明成功,小于0表明出错。
 *
 * Hint: 你惟一须要调用的函数只有envid2env()。
 */
int sys_ipc_can_send(int sysno, u_int envid, u_int value, u_int srcva, u_int perm)
{

        int r;
        struct Env *e;
        struct Page *p;
        Pte *ppte;

        /* 判断envid是否合法,目标进程是否处于可接收状态。
         * 这个函数貌似是残缺的,srcva和perm没有使用,也没有映射物理页面。
         * 只是单纯的传递一个值value而已。很迷。
         * 一样须要注意,设置为就绪后是否加入就绪状态链表。取决于我的程序。
         */
        return 0;
}

4、思考题分享参考

此处只是分享个人见解,不保证答案的正确性和完备性。

Thinking 4.1 思考并回答下面的问题:

  • 内核在保存现场的时候是如何避免破坏通用寄存器的?

    内核保存现场的方法,是将全部通用寄存器、CP0寄存器、当前PC值保存到栈里。可是,通用寄存器的值却非一成不变、彻底保存。k0、k1两个寄存器由中断/自陷程序保留,这两个寄存器的值得不到保证。内核使用k0、k1两个寄存器保存用户栈、取出内核栈,再进行保存,从而维护了大多数通用寄存器的值。

  • 系统陷入内核调用后能够直接从当时的a0-a3参数寄存器中获得用户调用msyscall留下的信息吗?

    能够。内核保存现场的过程当中没有破坏a0-a3参数寄存器的值,只改变过k0, k1, v0的值。

  • 咱们是怎么作到让sys开头的函数“认为”咱们提供了和用户调用msyscall时一样的参数的?

    参数的传递依赖于a0-a3参数寄存器和栈。只要咱们保证a0-a3参数寄存器不变,栈可以以本来的样子复制到内核栈空间中,就可以让sys开头的函数认为参数相同。

  • 内核处理系统调用的过程对Trapframe作了哪些更改?这种修改对应的用户态的变化是?

    处理过程当中,内核改变了Trapframe中寄存器v0的值,用于在用户态中传递系统调用函数的返回值。此外,内核改变了EPC的值,使得程序返回用户态后可以从正确的位置继续执行。

系统调用号 对于系统调用syscall_cgetc,它传入msyscall函数的系统调用号的数字值应该是?

打开文件user/syscall_lib.h,能够看到系统调用号的数值是常量SYS_cgetc。

打开文件include/unistd.h,能够读到__SYSCALL_BASE = 9527,SYS_cgetc = 9527+14 = 9541。

因此系统调用号的数字值应当是9541

相关文章
相关标签/搜索