mit 6.s081

  • 简介
  • xv6-book
    • chapter1 Operating system interfaces
    • chapter2 Operating system organization
      • Code:starting xv6, the first process and system call
    • chapter3 Page tables
      • Code:create an address space
      • Code:Physical memory allocator
      • Code:sbrk
      • Code:exec
      • lec4:Page tables
      • lec5:GDB,calling conventions and stack frames RISC-V
    • Chapter4 Traps and system calls
      • Code:Calling system calls
      • Code:System call arguments
      • lec6:Isolation & system call entry/exit
      • lec7:Page faults
    • Chapter 5:Interrupts and device drivers
  • lab
    • lab guidance
    • Lab 1: Xv6 and Unix utilities
      • prime
    • Lab 2: system calls
    • Lab 3: Page tables
    • Lab4: Traps

简介


  • 环境:ubuntu20.04、qemu(模拟RISC-V64)、gdb-multiarch。
  • 在RISC-V中,寄存器x0永远是0。
  • 在xv6中,所有的用户都是root。
  • 编译器可能会优化机器指令,所以要结合kernel.asm和函数对应的*.s文件。

xv6-book


chapter1 Operating system interfaces

  • 当用户程序调用一个system call,硬件提高特权等级(privilege level),并运行内核中的一段预先安排的代码。

    用户空间和内核空间的接口:system calls系统调用,Look and behave like function calls, but they aren’t

  • 内核为每个进程维护一个PID标识符

  • execsystem call会用调用的可执行文件(存储在文件系统中)覆盖进程的内存,但是会保留file table。exec不会返回,会从elf header中的entry开始执行。

  • 每个进程都有用来存放fd的私有空间file table,fd从0开始。每个fd都有对应的offset,每次调用readwrite都会从上一次地方开始。新分配的fd从最小的数字开始。

  • pipe有2个接口:read和write,当write end和所有引用write end的fd关闭时,pipe才会关闭,这时候read读完缓冲区所有内容后下一次read返回0。pipe在内核中实现,有缓冲区。
    mit 6.s081_第1张图片

  • mknod创建特殊文件(称为device设备),需要传入2个数字用于唯一标识一个kernel device。当打开一个device file,内核会把readwrite替换为kernel device implementation。

  • 当一个文件的link为0且没有fd应用它时,释放文件的inode和磁盘空间。

chapter2 Operating system organization

  • os需要满足三个需求:multiplexing,isolation and interaction(pipe)。

  • 对物理资源的抽象:进程、文件等。通过系统调用(ecall)从用户空间陷入内核空间(即从user mode转入supervisor mode)。

  • riscv的三种模式:machine mode,supervisor(kernel) mode and user mode。supervisor mode下可以执行privileged instructions,比如使能/失能中断、读/写satp寄存器。

    运行在内核空间(supervisor mode)的software/code称为kernel。

  • 宏内核(monolithic kernel)和微内核(micro kernel)。微内核中,在用户空间像进程一样运行的os服务(如file server)成为servers。

  • 进程的虚拟内存空间layout:
    mit 6.s081_第2张图片

heap堆在需要时通过malloc分配空间。在虚拟地址空间最顶处分配了一页trampoline(包含进出内核的代码)和一页trapframe(save/restore用户进程的状态)。

硬件使用39位寻址va,但是xv6只使用了38位,避免对设置了高位的va进行符号拓展。所以最大地址MAXVA(kernel/riscv.h:363)= 2^38-1 = 0x3fffffffff。

  • 内核为每个进程维护一个结构体proc(kernel/proc.h:86),保存进程状态,如p->state和p->pagetable。

  • 每个进程有两个栈:user stack和kernel stack,分别在用户空间和内核空间下使用。ecall指令进入内核空间,sret指令返回用户空间。

  • xv6中,一个进程包含一个线程和一个地址空间。

  • security:os必须假设进程的user-level代码会尽最大的可能去破坏内核或其他进程。在内核中可能存在bugs的地方设计safeguards,如:assertions、type checking和stack guard pages。

Code:starting xv6, the first process and system call

  1. powers on:初始化,运行存储在read-only memory中的boot loader。boot loader会把xv6 kernel下载到内存中(0x80000000)。然后xv6以machine mode运行_entry(kernel/entry.S:7)。

  2. entry.S:初始化栈使xv6可以运行C代码,然后跳转进入start()(kernel/start.c:21)。

  3. start.c:进行一些只能在machine mode运行的初始化并转到supervisor mode。比如:失能内存地址映射(往satp寄存器写0)、编程时钟芯片来初始化定时器中断。通过mret指令返回supervisor mode,这将导致pc寄存器指向main()(kernel/main.c:11)。

  4. main.c:初始化一些设备和子系统后,运行userinit()(kernel/proc.c:226)即创建第一个进程等待scheduler调度,里面嵌入了initcode.S(user/initcode.S:3)的RISC-V汇编代码,作用是将exec需要的参数加载到寄存器a0(=“/init\0”)和a1(=argv)中并把系统调用编号加载到寄存器a7中。最后调用ecall重新进入内核执行系统调用exec运行/init(user/init.c:15)。

    kernel在syscall(kernel/syscall.c:133)中使用寄存器a7中的数字执行对应系统调用。
    系统调用表(kernel/syscall.c:108)把SYS_EXEC映射为sys_exec
    proc.c中的uchar initcode[]就是initcode.S的十六进制代码。

chapter3 Page tables

  • satp寄存器
    CPU中的satp寄存器保存root page table的地址,页表存在内存中的某个位置。MMU根据satp查找页表,MMU不负责建立页表,而是os负责。如果需要的物理地址(PM)不在页表中,MMU引发page-fault exception,由os负责从磁盘中寻找需要的page并移动到物理内存中。

  • page table entry
    mit 6.s081_第3张图片

    在Sv39 RISC-V中,只使用了39bits用于地址映射(共512GB),高位25bits用于未来拓展。在39bit中,前27bit用于三级页表,后12bit为物理内存offset,用于索引一页4096(2^12)bytes中的偏移。每级页表有512项,索引下一级页表时,offset全为0。
    为了避免从物理内存移动PTEs的代价,RSIC-V CPU使用TLB(Translation Look-aside Buffer)用于缓存PTEs。

    如果只使用一级页表,需要2^27表项,需要占用很大的空间。
    如果一个进程用不到全部页表,多余的页表项(第2、3级页表)不会分配空间。

  • 物理内存指的是DRAM中的存储单元,ma的每个字节都有一个地址,称为物理地址。os初始化开启地址映射后,指令使用的地址是虚拟地址。

  • kernel address map
    mit 6.s081_第4张图片

    xv6为每个进程维护一张pagetable,同时为内核维护一张kernel pagetable(直接映射)。

    有2个地址没有直接映射:

    1. trampoline page:映射2次,1次高地址,1次直接映射。
    2. kernel stack page:每个进程都有自己的kstack,伴随一页guard page。guard page不会在物理地址中映射空间,栈溢出直接导致page-fault。

    如果kstack采用直接映射方式,guard page对应的物理地址将很难使用。

Code:create an address space

main()(kernel/main.c)中初始化地址映射。

  1. kinit():擦除从end到PHYSTOP的物理内存,并在每个free page开头写入struct run,指向kmem.freelist,即指向下一页free page。

  2. kvminit():创建PTE和proc stack,核心函数为mappages()(kernel/vm.c:138)和walk()(kernel/vm.c:81)。walk()用于寻找或创建pte。创建了NPROC(最多进程数)个proc stack(大小为一页)。

    PA2PTE:先右移12位清除offset,再左移10位为Flags预留位置。
    PTE2PA:先右移10位清除Flags,再左移12位为offset预留位置。
    copyout()和copyin()也在kernel/vm.c中,因为它们需要翻译va到pa。

  3. kvminithart():kernal pagetable写入satp
    寄存器并enable paging。

  4. 当改变页表时,需要invalid目前缓存的TLB entries,指令sfence.vma会flush目前CPU的TLB。xv6在kvminithart()中加载satp寄存器和在trampoline code(切换用户页表before返回用户空间)中执行sfence.vma

Code:Physical memory allocator

  • 每个free page除了struct run之外不存储任何其他内容。freelist被一个spin lock保护,list和lock放在一个结构体中(kmem)。

  • kinit()中初始化allocator,即初始化从end of kernelPHYSTOP的物理内存到freelist。一个PTE只能对应以4096字节对齐的一段物理内存,所以初始化页表时传入的va和pa都是以4096字节对齐。

Code:sbrk

  • xv6使用系统调用sbrk来缩小或增长进程内存,通过函数growproc(kernel/proc.c:253)调用uvmallocuvmdeallocuvmalloc(kernel/vm.c:221)调用kalloc分配物理内存和mappages添加PTEs到用户页表。uvmdealloc(kernel/vm.c:166)调用uvmunmap(调用walk查找PTEs)和kfree释放物理内存。

Code:exec

  • exec(kernel/exec.c:13)系统调用使用文件系统里的一个elf文件(kernel/elf.h)来初始化一个地址空间的用户部分。

    1. namei():根据path获取文件inode,每个文件都由唯一的inode标识。
    2. readi():读取文件,判断是否为elf文件。
    3. proc_pagetable():创建一张没有映射的用户页表(没有user memory,有trampoline pages)。
    4. 调用uvmalloc(kernel/exec.c:52)为每个elf段分配内存并建立页表映射,调用loadseg(kernel/exec.c:10)加载每个段到内存中(使用walkaddr查找要分配的pa)。

      filesz可能小于memsz。

    5. 分配并初始化一页用户栈以及一页guard page。调用copyout把argv参数入栈,最后放入一个空指针。前三个参数分别为:假pc,argc指针和argv指针。guard page除了检测栈溢出,还可以检测参数过长。
    6. 初始化新进程proc结构体相关成员,释放旧进程的页表。
    7. 以上过程发生错误则释放新建的页表,然后exit -1返回旧进程。只有到最后一步才释放旧进程的页表,否则exit出错。

    elf文件中的地址或指令可能指向内核,所以需要做一系列的检查,防止破坏内核和用户空间的隔离。

  • xv6内核缺少malloc类似的allocator/动态内存分配器。

lec4:Page tables

  • 在MMU之前有对VM的cache,之后有对PM的cache。切换页表之后会flush TLB。多核CPU每核都有satp和TLB。

  • 物理内存地址layout由硬件决定,所以开机boot结束后,跳转到0x8000 0000也是由硬件决定(该地址需要人为写到boot中用于跳转)。

  • 卸载IO devices映射位置的指令,实际上是写到对应设备芯片或controller里。

  • 物理地址里可能有一部分(高地址空间)unused,取决于板子上的DRAM大小和xv6限制(128MB)。

lec5:GDB,calling conventions and stack frames RISC-V

  • calling convention
    RISC-V调用约定:函数调用时,使用a0-a7fa0-fa7来传递参数。当函数参数超过8个时,需要使用内存(栈)传参。
    mit 6.s081_第5张图片

  • stack frame
    调用函数时(由汇编,即编译器)创建一个栈帧,非叶子函数的汇编代码结构:减sp创建栈帧,主体代码,加sp恢复栈帧。栈帧由高地址向下(栈顶向栈底)增长,返回地址在栈顶,超过8个的函数参数会通过栈帧传递。sp指向栈底,fp指向栈顶,返回地址后保存上一个栈帧的fp。这2个寄存器帮助函数正确返回。
    mit 6.s081_第6张图片

Chapter4 Traps and system calls

  • 当发生system call,exception,interrupt时(统称为trap陷阱)CPU暂停当前指令,并转到解决上述事件的特殊代码中。内核可以只通过a single code path来解决所有的代码路径,然后判断三种情况执行对应代码(handler)。第一条handler指令通常是汇编,称为vector

  • Traps from user space
    trampoline(kernel/trampoline.S:16)被映射到用户页表和内核页表的相同位置(va最高处),当发生陷阱时,内核设置相关寄存器,然后进程以supervisor mode运行trampoline(此时仍然是用户页表)。并执行uservec(kernel/trampoline.S:16),usertrap(kernel/trap.c:37),usertrapret(kernel/trap.c:90),userret(kernel/trampoline.S:88)。

    1. 内核设置相关寄存器:stvec指向uservec()sepc保存进程的pc;scause保存表示trap原因的数字;sscratch指向p->trapframesstatus中的SIE bit控制设备终端使能,SPP bit表示trap来自user mode还是supervisor mode同时控制sret返回什么mode。

    2. uservec():保存32个通用寄存器到p->trapframe。从p->trapframe加载内核相关寄存器,此时切换到内核页表。跳转到usertrap()并且不会返回。

    3. usertrap():在stvec寄存器存入kernelvec()(kernelvec.S)的地址(以便interruptexception使用)。保存sepc的值到p->trapframe->epc,因为后面可能要调用yield()来切换进程(可能改变sepc)。读取scause寄存器判断trap类型:

      • 如果是system callp->trapframe->epc+4指向ecall的下一条指令,然后调用syscall()
      • 如果是interrupt则执行devintr(),定时器中断还会调用yield()
      • 如果是exception直接kill掉进程,exit(-1)。

      然后调用usertrapret()返回用户空间。

    4. usertrapret():设置相关寄存器为未来的trap from user space做准备。stvec指向uservec(),更新p->trapframe内核相关寄存器,更新sstatussepc。最后调用userret,传入2个参数:TRAPFRAME(在用户页表中的va)到a0和satp(即用户页表指针)到a1

    5. userret():切换用户页表,加载用户寄存器,sscratch保存TRAPFRAME,sret切换到user mode并根据pc运行进程中下一条指令。

  • Trap from kernel space
    stvec指向kernelvec(kernel/kernelvec.S:10),此时在内核空间中,所以satp指向内核页表,栈指针指向内核栈。

    1. kernelvec():保存32个寄存器到中断的内核线程中(xv6中CPU每个核心有1个线程),因为可能会切换到另一个线程。调用kerneltrap()
    2. kerneltrap(kernel/trap.c:134):调用devintr(kernel/trap.c:177)检查是否为device interrupts如果是则执行对应代码。如果不是,则为exception(发生在内核中的通常为致命错误),调用panic并停止执行。
    3. 如果是定时器中断,则调用yield切换另一个线程。返回kernelvec()
    4. kernelvec()(kernel/kernelvec.S:48):将之前保存的寄存器弹栈,执行sret
  • 从用户空间进入内核空间时,在usertrap()中有一段时间是内核正在执行但是stvec仍然指向uservec,这时候失能设备中断很重要。所以引发trap时,xv6会首先失能device interrupt。

  • Page-fault exceptions
    很多内核使用page fault来实现写时复制(copy-on-write,COW)fork。xv6中的fork是直接复制父进程到子进程的初始化物理内存,更高效的方法是通过页表权限和page fault来共享父进程的物理内存。

    发生以下情况时CPU发起一个page-fault exception:访问页表中没有映射的va、PTE_V标志位被清除的va、相关权限位被清除的va。RISC-V将page-fault分为三类:
    load page faults:load指令的va翻译失败。
    store page faults:store指令的va翻译失败。
    instruction page faults:pc寄存器中的地址翻译失败。

子进程写某一页时引发page-fault异常。在trap handler中分配新一页(或连续几页)物理内存并将访问的那页复制进去,同时允许读写。然后重新执行之前的指令。COW需要一个记录来决定什么时候可以释放物理内存,因为每一页物理内存可能被多个页表引用(如果进程引发store page fault并且此时对应的pa只被当前进程的页表引用,则不需要复制)。

  • lazy allocation
    当一个进程使用sbrk请求内存时,内核关注请求的空间大小,但不分配新的物理内存也不创建新的PTEs。当在新的va上引发page fault时才分配新的一页和添加地址映射。

  • demand paging
    现代内核中执行exec时为避免加载大的可执行文件而影响响应时间,不直接加载内容,而是创建页表并标记PTEs为invalid。当引发page fault时从硬盘/磁盘中读取内容并建立地址映射。

  • paging to disk
    在RAM中只存储一部分user pages,剩下的存储在硬盘/磁盘上的paging area(对应PTEs标记为invalid)。当引发page fault,从硬盘读取到RAM中,并修改PTEs指向RAM。

  • automatically extending stacks and memor-mapped files

  • 如果将内核映射到每个用户进程的页表里,可以:不需要特殊的trampoline、从用户空间陷入内核时减少开销、系统调用可以直接利用用户内存(比如允许内核直接解引用用户指针)。但是xv6没有这么做,为了防止用户指针的错误使用和避免考虑用户和内核va的重叠问题。

  • 商业os都会实现以上功能,并且尽可能用尽所有物理内存。但是xv6用尽内存时会返回错误或直接杀死当前进程,而不是驱逐其他进程的物理内存来交换需要的内容。

Code:Calling system calls

  • initcode.Sexec系统调用的参数加载到寄存器a0(“/init\0”)和a1(=argv),将系统调用编号加载到寄存器a7(=7,sys_exec)。ecall指令陷入内核并执行uservecusertrapsyscallsyscall使用a7的编号执行对应sys_*

  • sys_exec返回时,syscall会保存返回值到p->trapframe->a0,这是exec()的返回值。RISC-V中C语言函数返回值放在寄存器a0,系统调用返回负数表示error,返回0或正数表示success。

    Lab 2: system calls

Code:System call arguments

  • kernel trap code保存用户寄存器到当前进程的trapframe,所以切换到内核空间后仍然可以找到它们。argint,argaddr,argfd都调用了argrawp->trapframe(在allocproc()中分配新进程时,指向新进程的TRAPFRAME的pa)中找到第n个系统调用参数。

  • 有些系统调用的参数包含指针,带来2个问题:

    1. 用户可能传入一个错误或有害的指针,可能指向内核内存而不是用户内存。
    2. 内核和用户空间的页表映射不同,所以内核不能使用普通指令从用户提供的地址load或store。因此内核提供fetchaddrfetchstr(kernel/syscall.c:12)函数从用户空间复制数据到内核,分别调用了copyincopyinstr(kernel/vm.c:372)。

lec6:Isolation & system call entry/exit

lec7:Page faults

Chapter 5:Interrupts and device drivers

lab


lab guidance

  • 难度:Easy(小于1h),Moderate(1-2h),Hard(>2h,不需要很多代码,但是需要一些技巧)。

  • print

  • gdb调试:在一个终端开启gdb servermake qemu-gdb,在另一个终端开启gdb clientgdb or riscv64-linux-gnu-gdb(ubuntu使用gdb-multiarch),会根据xv6-riscv/.gdbinit自动配置)。

    如果出现类似下面的警告:
    warning: File "/xv6-riscv/.gdbinit" auto-loading has been declined by your 'auto-load safe-path' set to "$debugdir:$datadir/auto-load". To enable execution of this file add add-auto-load-safe-path /xv6-riscv/.gdbinit line to your configuration file "~/.gdbinit".
    按照提示做,在~/.gdbinit文件里添加add-auto-load-safe-path /xv6-riscv/.gdbinit

  • 查看kernel.asm,同时编译器编译内核后,会给每个user program生成对应的.asm。

  • 内核崩溃时,报错信息里有pc寄存器的值,然后查看kernel.asm在哪个函数出错,或者运行addr2line -e kernel/kernel pc-value

  • ctrl-a c进入qemu的monitor,可以查看虚拟机的信息。info mem查看页表(使用cpu指令选择info mem查看哪个核,make qemu CPUS=1模拟单核)。

  • gdb中输入layout split上下分层显示。

  • xv6中的系统调用以sys_开头,源码在kernel/sysproc.ckernel/syscall.h定义了系统调用编号,kernel/syscall.c:108转为宏定义SYS_*

Lab 1: Xv6 and Unix utilities

  • ctrl + a x退出qemu,xv6没有ps命令,但是可以输入ctrl + p,内核会输出每个进程的信息。

prime

  • 并发模型:

    1. 基于共享内存和锁的并发模型:在单核时代,基本使用这种方法。但是在多核和分布式情况下,可能并不适用。
    2. csp模型:通过通信来共享内存。
  • 任务:使用pipes编写素数筛的并发版本。使用pipe和fork建立pipeline,父进程投喂2-35进入pipeline。对于每个素数,安排一个进程通过pipe从left neighbor进程读取,然后通过另一个pipe写入right neighbor进程。

  • 提示:

    1. 关闭不需要的fd,否则在到达35之前会耗尽xv6的资源。
    2. 父进程投喂完后需要等待pipeline完成,
    3. 当write end of pipe关闭时,read读完pipe中所有数据后下一次read会返回0。
    4. 当需要时才在pipeline中创建子进程。
  • xv6资源有限,不能一次创建11个pipe,所以在循环里关闭不需要的管道再创建新的管道

    int p[11][2];
    int data_p,data_n;
    
    pipe(p[0]); pipe(p[1]);
    for( int i=2; i<36; i++ )// feed
        write(p[0][1], &i, 4);
    
    for( int i=0; i<11; i++ ){
        if( fork() == 0 ){ // parent
            close unuse pipe
            print prime and sieve
            close used pipe
        }
        else{ //parent
            close unuse pipe
            create new pipe for next child
        }
    }
    

Lab 2: system calls

  • 调用syscall时,内核执行usys.S对应的汇编,把SYS_*系统调用编号加载到寄存器a7然后执行ecallecall指令里会调用syscall(kernel/syscall.c:135),根据系统调用编号执行对应sys_*函数(调用fork等syscall时实际在执行sys_*)。在sys_*里执行对应功能或调用对应*函数。

Lab 3: Page tables

  • 需要在struct proc中添加额外内容。

Lab4: Traps

你可能感兴趣的:(risc-v)