MIT 6.S081学习笔记

  课程主页
  xv6 book
  GDB User Manual
  MIT 6.S081 2020 操作系统 [中英文字幕]
  课程视频中文文字翻译版
  Xv6手册中文翻译—概览
  Xv6手册中文翻译
  Fall2020/6.S081-如何在 QEMU 中使用 gdb

文章目录

  • 课程和Lab架构
  • Lecture 1: Introduction and Examples
  • Lab: Xv6 and Unix utilities
  • Lecture 2: C and gdb
  • Lecture 3: OS Organization and System Calls
  • Lab: system calls
  • Lecture 4: Page Tables
  • Lecture 5: RISC-V Calling Convention and Stack Frames
  • Lab pgtbl: Page tables
  • Lecture 6: Isolation & System Call Entry/Exit
  • Lecture 7: Page Faults
  • Lab: traps
  • Lecture 9: Interrupts
  • Lab: Copy-on-Write Fork for xv6
  • Lecture 10: Multiprocessors and Locks
  • Lecture 11: Thread Switching
  • Lecture 13: Sleep & Wakeup
  • Lab: Multithreading
  • Lecture 14: File Systems
  • Lab: networking
  • Lecture 15: Crash Recovery
  • Lecture 16: File System Performance and Fast Crash Recovery
  • Lecture 17: Virtual Memory for Applications
  • Lab: locks
  • Lecture 20: Kernels and High-Level-Languages
  • Lab: file system
  • Lecture 21: Networking
  • Lecture 23: RCU
  • Lab: mmap
  • 课程总结
  • 附录——XV6源码中各个函数的作用
  • 附录——寄存器&设备&专有名词
  • 附录——一些疑问

课程和Lab架构


Lecture 1: Introduction and Examples

  课程主题:设计和实现操作系统
  OS的三大功能:多路复用、隔离和交互。

Lab: Xv6 and Unix utilities

  Boot xv6
  根据指令操作

  sleep
  使用system call实现。

  pingpong
  使用pipe实现。

  primes
  使用pipe+递归实现。

  find
  使用递归实现。

  xargs
  xargs没有成功完成,对C语言的特性和pipe、exec等命令了解得太少了。
  这里要吐槽一下,第一个lab难度有点大,差点把人给整劝退了:(

Lecture 2: C and gdb

  在qemu中使用gdb调试程序,参考Fall2020/6.S081 实验笔记(〇) Lab0: Preparation2

Lecture 3: OS Organization and System Calls

Lab: system calls

  System call tracing
  难点在于从用户态向内核态转换时,需要将trace后的参数存入寄存器,并修改其他系统调用函数使其输出相关信息,不过也挺有意思的,增加了对xv6的理解。思路如下:
  1.修改proc.h中proc结构,新增trace_num和trace_flag变量;
  2.在sysproc.c中新增sys_trace函数;
  3.修改fork;
  4.修改syscall;

  Sysinfo
  难点在于copyout的使用,差点把qemu整崩溃了。
  在调试过程中出现了panic: acquire的错误,原因在于统一UNUSED进程时没有释放对应进程的锁导致出错;出现了FAIL: sysinfo succeeded with bad argument错误,问题在于没有对copyout错误的情况作出处理。

Lecture 4: Page Tables

  PPN:Physical Page Number
  PTE:Page Table Entry
  三级页表是由CPU中的硬件部分MMU实现的

Lecture 5: RISC-V Calling Convention and Stack Frames

  gdb常用命令演示,stack结构,stack frame——函数调用所产生的信息;
MIT 6.S081学习笔记_第1张图片

  C中struct有各种不同的字段,这些字段在内存中依次存储,类似于数组,不过元素的类型可能有所不同;

Lab pgtbl: Page tables

  Speed up system calls
  在内存中使用一个新的page来存储用户进程的进程号,并设置用户态进程只能read该page,这样用户态进程在获取进程号的时候不用切换到内核态,实现了系统调用的加速。

  Print a page table
  根据标志位的不同来判断当前页表是否为第三级页表。

  Detecting which pages have been accessed
  debug程序的两种方式,第一种使用printf输出关键参数,第二种使用gdb进行调试,但目前gdb用得还不太熟练。
  debugtui enable指令,开启Text User Interface;

Lecture 6: Isolation & System Call Entry/Exit

  Trap code:即使从用户态进入内核态后,也不能向任意地址write/read数据,得根据page table来。

Lecture 7: Page Faults

  page table + page faults为kernel提供了一种灵活的机制来调整进程的page table。
  lazy allocation——在增大进程内存空间时,并没有实时分配物理内存,而是当访问分配的虚拟地址出现page fault的时候,kernel再为其分配内存,并重新执行该指令。

Lab: traps

  RISC-V assembly
  Little Endian(小端存储)& Big Endian(大端存储)
MIT 6.S081学习笔记_第2张图片
  Backtrace
  在C语言中使用inline(内联)汇编代码读取/写入寄存器。

  Alarm
  test1没有通过,总是报错"test1 failed: foo() executed fewer times than it was called",难受:(

Lecture 9: Interrupts

  三种类型的trap,系统调用、异常和中断(硬件中断和软件中断)。
  进入kernel,使用位于trampoline的程序保存环境,切换到内核pagetable,进入trap处理程序;
  trap处理程序根据trap的类型执行相应的动作,结束之后回到trampoline的后边部分汇编代码中,切换到用户pagetable,恢复环境,继续执行发生trap之前的指令流。

Lab: Copy-on-Write Fork for xv6

  Implement copy-on write
  Xv6中fork()是将父进程的内存空间复制给子进程,对于fork后执行exec的子进程来说,复制的内存空间直接被浪费掉了,效率很低。
  在fork的时候,子进程和父进程共用同一物理内存(read-only)。当某个进程尝试写入page的时候,会产生page fault,从而进入内核的trap处理程序;trap handler将copy该page(writeable),这样子进程和父进程就都可以使用各自的physical page了。问题:一个physical page可以被多个进程所引用,所以在释放physical page的时候需要技巧。
  最终没有通过usertests,badarg失败了,这个实验的细节太多了:(

Lecture 10: Multiprocessors and Locks

  critical section:临界区,各个线程共享操作的区域。
  spinlock:自旋锁,获取不到锁就一直等待。借助ISA中的原子化指令来完成,在RISC-V中是amoswap r, a,它将地址a对应的value和寄存器r中的value交换,并read此时r中的值。对应于spinlock的操作中,我们查看r的值是否为1,如果为1代表有进程正在使用该锁,如果为0则代表锁为空,可以使用该临界区,而交换到a的值1则不会对其造成影响。
  dead lock & lock ordering:code获取锁的路径必须一致,否则容易引发死锁。

Lecture 11: Thread Switching

  Xv6中一个进程内部只有一个线程,每一个用户进程在内核中都有一个对应的线程,用来为对应的进程执行系统调用。内核线程使用kernel stack中的内存空间运行。线程调度的基本流程是定时器中断进入trap处理程序,保存当前进程和对应内核线程的环境,切换到内核调度线程,该线程再恢复另一个内核线程的环境,内核线程恢复对应用户进程的环境,进入用户空间执行该进程。
MIT 6.S081学习笔记_第3张图片
  图片来源:xv6 book

Lecture 13: Sleep & Wakeup

  wait(),父进程在此处阻塞,等待任意一个子进程exit,free子进程的内存空间,所以wait一般来说是必须的。如果父进程退出了,子进程就会被划分给init进程,由init进程进行资源的回收。
  每个管道由一个结构体 pipe表示,它包含一个锁和一个数据缓冲区。nread和nwrite两个字段统计从缓冲区读取和写入的字节总数。缓冲区呈环形:buf[PIPESIZE-1]之后写入的下一个字节是buf[0]。计数不呈环形。这个约定使得实现可以区分满缓冲区(nwrite == nread+PIPESIZE)和空缓冲区(nwrite == nread),但这意味着对缓冲区的索引必须使用buf[nread % PIPESIZE],而不是使用bufnread。

Lab: Multithreading

  Uthread: switching between threads
  在多线程之间切换,主要理解ra和sp寄存器的作用,ra存储的是函数的返回地址,sp存储的是当前线程的执行流地址(sp从高到低存储函数调用);

  Using threads
  unix phread库的简单使用,重点在于使用锁的密度,过大会导致性能的下降,过小又会导致多线程下程序出错。

  Barrier
  锁的使用练习,需要掌握一定技巧,这对不够灵活的我来说无疑是雪上加霜:(
  同步屏障机制,多线程同步的一种机制,所有线程在code中某一点处同步,如果有其他线程没有达到设置的barrier point,则将其阻塞,直到所有线程到达后再将阻塞的线程唤醒。

Lecture 14: File Systems

  Buffer cache layer——buffer缓存使用双链表的形式,使用LRU策略缓存文件系统中的block,类似于cache的思路;
  super block——存储文件系统信息;
  inode——存储文件信息;
  bitmap——位图,一个block的大小,用来维护disk上空闲/使用block信息;

Lab: networking

  补充E1000网卡驱动的部分函数,复习一下driver的两大组成部分,由system call对设备I/O进行控制,以及对应的中断处理程序组成。
  E1000的软件开发手册,下面是对lab要求的详细翻译,原文链接。整个lab实际上实现难度不大,根据hint来完成即可,主要锻炼调试能力吧,lab traps中的backtrace很好用,此外,printf毫无疑问才是调试神器:)。
  发现一个关于此lab不错的博客,可以作为参考,「实验记录」MIT 6.S081 Lab11 networking。

  e1000_init()
  配置E1000采用DMA的方式从内存读/向内存写packet,由于packet的传输速度可能远大于E1000的传输速度,所以为E1000提供了多个可以write的buffer。E1000需要这些buffer在内存中以descriptors数组的形式存在。每个descriptors包含一个内存中的地址,E1000可以向该地址写入接收的packet。struct rx_desc描述了descriptors的格式。这个descriptors数组被称为接收环/队列,当NIC/driver达到数组的末端时,它将回到起点。e1000_init()使用mbufalloc为E1000进行DMA写入分配mbuf包缓存。同样的,也有一个传输环,driver将它想要E1000发送的packet放入其中。

  当net.c中的网络stack需要发送一个packet的时候,它会调用e1000_transmit()并向其传输一个包含着packet数据的mbuf,我们实现的传输代码必须向位于Tx环中一个descriptor中的packet设置一个指针。struct tx_desc描述了descriptor的格式。我们需要确保在E1000传输完该packet后(E1000设置descriptor中的位来表明传输完成),free该mbuf。

  当E1000从以太网中接收到每个packet后,它首先会以DMA的方式将该packet传输到下一个RX环descriptor指向的mbuf,然后产生一个中断。我们实现的e1000_recv()代码必须扫描RX环,将每个新packet的mbuf以调用net_rx()的方式传递到net.c中的网络stack中。然后我们需要分配一个新的mbuf,并将它放入descriptor,以至于当E1000在RX环中再次到达该descriptor时,它会发现一个新的mbuf,并可以将新packet放入其中。

  为了在内存中读和写descriptor环,我们的driver需要通过E1000的内存映射寄存器进行交互,当接收的packet可用得时候检测该寄存器,以及当driver在TX descriptor中补充了一些等待发送的packet时,通过内存映射寄存器告知E1000。全局变量regs存储的是指向E1000第一个控制寄存器的指针,我们的driver可以通过将regs作为数组的方式获得其他寄存器。特别的,我们需要使用E1000_RDT和E1000_TDT这两个索引。

  补充e1000_transmit()的一些提示:
  1.通过read E1000_TDT控制寄存器,找到E1000中TX环下一个packet的index;
  2.检查TX环是否溢出,如果1中get的descriptor的E1000_TXD_STAT_DD没有被设置,说明E1000还没有完成对应的前一次传输请求,返回一个error;
  3.否则,使用mbuffree()将该descriptor中被传输的mbuf释放掉(如果有的话);
  4.然后fill该descriptor,m->head指向packet在内存中的内容,m->len是packet的长度。设置必要的cmd flags(参考E1000手册的3.3节),隐藏指向mbuf的指针以供以后释放;
  5.最后,(1 + E1000_TDT) % TX_RING_SIZE更新环的位置;
  6.如果e1000_transmit成功的向环中添加了该mbuf,return 0;在失败的情况下(比如,没有可用的descriptor来传输该mbuf),返回-1,使得caller能够free该mbuf。

  补充e1000_recv()的一些提示:
  1.通过read E1000_RDT控制寄存器的值加1后模上RX_RING_SIZE,找到E1000中Rx环下一个等待接收packet的索引(如果有的话);
  2.然后通过检查descriptor status部分的E1000_RXD_STAT_DD位来确定新packet是否可用;
  3.如果可用,使用descriptor中的长度更新mbuf的m->len,使用net_rx()将mbuf发送到net.c中的网络stack;
  4.使用mbufalloc()分配一个新的mbuf来替代3中使用net_rx()发送的mbuf。将它数据指针(m->head)编程到描述符中(这里不是很明白什么意思),将descriptor的status位置为0;
  5.更新E1000_RDT寄存器的值,使其指向RX环上一个未被处理的descriptor;
  6.e1000_init()使用mbufs初始化RX环,浏览代码,看看它是如何实现的;
  7.某些时候packet的总数会超过环的大小(16),确保我们的代码可以处理这种情况;

  除此之外,还需要处理的问题是,xv6可能使用不止一个进程来访问E1000,或者当中断发生的时候正在内核线程中使用E1000。

Lecture 15: Crash Recovery

  Xv6文件系统基本架构:
MIT 6.S081学习笔记_第4张图片

Lecture 16: File System Performance and Fast Crash Recovery

  linux ext3日志系统,和xv6日志系统不同的是,ext3的日志中可以有多个transaction,同时内存中的buffer cache中还有一个正在活跃的transaction。当内存中transaction结束后,system call将立刻返回到用户空间(xv6是等待日志更新完磁盘中对应数据block后再返回,这样system call一直在等待),操作系统将transaction写入磁盘日志中无效的transaction空间。同时,日志中transaction对磁盘的更新可以是异步的,也就是说当内存中system call在更新当前活跃transaction的时候,日志中已经被提交的transaction可以自行对磁盘对应的block进行更新。

  ext3三大特性:
  1.异步调用,I/O并发——在内存中transaction被标记为提交后便返回system call,无需等待transaction被存入磁盘中的log并将元数据写入磁盘后再return,这样大大提升了系统性能,实现了I/O并发,即kernel write/read磁盘的同时,用户进程可以执行其他的指令流。
  2.批处理——ext3每隔5秒创建一个新的transaction buffer,将这5秒内的所有system call都记录在此次transaction中。

Lecture 17: Virtual Memory for Applications

  Garbage Collector
  除C、RUST外,其他语言基本上都有自己的GC机制,在C中,我们使用malloc分配内存后,还需要使用free显式的释放内存,而其它语言如Java等只用GC回收内存。
  Baker算法(实时增量GC算法),将heap from区域的root object复制到to区域(同时复制它指向其他object的指针,清除from中old root,这一步骤被称为forward[转发])。只有当process创建一个新object需要heap的空间时,才会继续将from中的object移动到to区域来,因此是实时增量GC算法,它将GC的开销分摊到了每一次创建新object的时候。

Lab: locks

  Memory allocator
  这个lab是在整个课程中做得最爽的一个lab了,对于结构体、地址、以及内存空间有了更为深刻的理解,以前的其他lab都是在根据hint按部就班的拼图,而这个lab中实现了真正意义上与machine进行交流,和她进行对话,很棒。Xv6将通过将空闲page地址放置在单链表的方式来实现page的free和alloc,在多核的context下,如果同时有多个CPU想要执行free/alloc操作,就必须等待该链表的lock被其他正在使用的CPU释放,效率很低。在此基础上对allocator进行优化,每个CPU拥有一个对应的空闲page链表,如果当前CPU的page使用完了,则获取其他CPU上空闲的page。
  首先使用数组的方式存储每个CPU的链表结构体:

struct kmem {
  struct spinlock lock;
  struct run *freelist;
  int page_count;  // the free page #
};

struct kmem* cpu_free_list[NCPU];

  初始化之前先分配一个空闲page,用来容纳每个CPU的free_list信息:

void
kinit()
{
    uint64 new_start = PGROUNDUP((uint64)end);
    uint64 kmem_element_addr = new_start;
    for (int i = 0; i < NCPU; i++) {
            cpu_free_list[i] = (struct kmem*) kmem_element_addr;
            cpu_free_list[i]->page_count = 0;
            cpu_free_list[i]->freelist = 0;
            initlock(&cpu_free_list[i]->lock, "kmem" + i);
            kmem_element_addr += 512;
    }

  freerange((void*) (new_start + PGSIZE), (void*)PHYSTOP);
}

  在kalloc时采取的策略如下,如果当前CPU的free_list为空,则从序号为0的CPU开始遍历,找到有多余page的CPU,将其free page和当前CPU对半分。

  Buffer cache
  buffer cache是在内存中对disk block的缓存,用于加快CPU执行速度和I/O并发的,Xv6中以双链表的形式维护该cache。同样的,在多核CPU的context下,容易造成竞争,效率比较低。因此采用hash table的形式来优化,降低锁的粒度,每个bucket有一个lock,而整体buffer cache就不用锁了。

Lecture 20: Kernels and High-Level-Languages

  C:对内存/寄存器的完全控制,权限很高;不利点在于容易出错;
  高级语言:抽象/安全/GC;不利点在于性能比较差,无法直接R/W寄存器;
  总结:使用C作为内核开发语言具有更好的性能,使用更少的内存;而其他如go等高级语言则具有更好的安全优势,不容易出现bug。

Lab: file system

  Large files
  扩充inode中存储的block num的数量。实现完成后运行usertests时一直出现virtio_disk_intr status的错误,参考博客[MIT 6.S081] Lab 9: file system。

  Symbolic links
  补充符号链接(软链接)的系统调用,输入的参数分别为target和path,target为待链接的inode,创建path下的inode,将target参数存储在inode下的data block中。

Lecture 21: Networking

  交换机和路由器
  数据链路层添加的以太网报头只在本地局域网有效,当报文到达交换机的时候,将被交换机替换为新的以太网报头。

  DMA环
  NIC将接收到的数据直接存入内存中,每当收到一个包就产生中断,CPU执行网卡接收驱动程序,将位于内存中的包交给网络stack处理,发送原理与之类似。

  中断
  在传统中断的情况下,每当NIC接收到一个包,就会产生中断进行导致CPU执行驱动处理程序,在NIC接收速率过高的情况下,过多的中断会降低当前主机处理包的速率(驱动接收程序因为中断一直在占用CPU时间)。

  轮询
  当NIC产生中断运行驱动接收程序后,进入网络stack的轮询处理程序,关闭中断,在loop中处理内存中的包,如果队列中没有新包了,则打开中断,退出轮询程序。

Lecture 23: RCU

  R/W Lock(读写锁)
  多个读者一个写者,使用原子化指令CAS(Compare And Swap)完成。缺陷在于写者一直在等待锁变为0,然后才能获取锁,如果有多个读者一直在获取锁(锁大于0),则写者会饥饿,等待所有读者释放锁后才能获取。此外,还有多核CPU缓存更新的问题。

  RCU
  RCU的核心思想是不允许写者就地修改被RCU保护的数据,而是创建数据的副本,原子化的提交写入,适合像单链表/树这样的数据结构。

  内存屏障(barrier)
  屏障之前/之后的code不能被编译器重新排序(如多级流水线CPU的数据风险,可由编译器对指令优化重排后解决),即屏障之前的指令只能在屏障前面,屏障之后的指令只能在屏障之后。

Lab: mmap

  将磁盘中的文件映射到当前进程的虚拟地址中,使用指针直接操作文件,避免read/write等系统调用的使用。
  实现策略是在proc结构体中记录VMA信息,包括虚拟地址起始地址,长度等,惰性分配。当产生page fault之后才读取文件到对应的虚拟地址,这样做的好处是可以快速映射大文件。

课程总结

  以Xv6和RISC-V为例,介绍了现代操作系统设计的基础理念:
  系统调用pipe、fork、exec等;延迟分配、写时拷贝、线程切换、自旋/睡眠锁、读写锁、RCU、文件系统、日志(事务&崩溃恢复)、软/硬链接、DMA、中断和轮询和内存映射文件等常见架构。

附录——XV6源码中各个函数的作用

  1.为给定的进程创建user page table并返回页表基地址 kenel/proc.c/proc_pagetale;

  2.在给定的页表上映射一对虚拟地址和物理地址 kenel/vm.c/mappages;

  3.当alloc参数为0时,kenel/vm.c/walk返回的是第三级page table上对应的PTE;

  4.kenel/vm.c/uvmunmap,当do_free为0时,将第三级page table上对应的PTE设置为invalid,即取消虚拟地址和物理地址的映射关系;do_free为1,在取消映射关系的同时还需要把对应的物理内存free;

  5.kenel/vm.c/uvmfree,将用户页表上的所有映射清除,并free对应的物理内存;并free第一、二级page table;

  6.kernel/vm.c/mappages,在pagetable中建立所提供虚拟地址va和物理地址pa之间的映射;

  7.kernel/vm.c/freewalk,递归的将所有页表free,默认第三级页表的映射关系已经解除(从这个函数可以看出前两级页表PTE和第三级PTE不同的是,前两级中flag只用valid有效,其他标志位均为0?)。

  8.struct mycpu:返回指向当前CPU的结构体指针,RISC-V将每个CPU编号为hartid并将其存储到每个cpu的tp寄存器中,再使用该hartid在mycpu结构体组成的数组进行索引,进而找到每个CPU对应的mycpu结构体。struct myproc:返回当前CPU正在运行的结构体信息。

附录——寄存器&设备&专有名词

  satp寄存器——每个CPU都有一个,用来存储页表根地址。
  PLIC——Platform Level Interrupt Control,管理外部设备中断,将中断路由到CPU
  UART——Universal Asynchronous Receiver/Transmitter,通用异步收发器。
  ext3——第三代扩展文件系统,是一个日志系统,常用于linux,与xv6中的log相似。
  scause寄存器——表明产生中断的原因。
MIT 6.S081学习笔记_第5张图片

附录——一些疑问

  1.Lab pgtbl中新增page为什么不直接在page table中添加,而是像TRAMPOLINE那样呢?

  2.TRAMPOLINE page的作用是什么?为什么每个新建的进程都有该page?
  A:trampoline page前半部分汇编代码负责进入trap之前保存相应寄存器,加载内核page;后半部分汇编代码负责恢复之前保存的寄存器,返回用户空间,恢复用户指令流的执行。当使用ecall指令从用户态转换到内核态的时候,ecall并不会切换page table,也就是说现在使用的仍然是用户页表,所以需要将trampoline和trapframe页映射到每个用户进程上。实际上,ecall只完成以下三个任务:
    1.将模式从用户态切换到内核态;
    2.将pc的值保存至sepc寄存器;
    3.跳转到stvec指向的地址,即trampoline开始的地方。

  3.同上,trapframe page?
  A:相当于容器,用于在trap发生时保存用户的寄存器和内核对应的信息,内核页表、栈等。和trampoline不同的是,每个用户进程都分配有一个自己的trapframe page,而trampoline是将进程的虚拟地址映射到内核的一个page上。

  4.将一个虚拟地址转换为物理地址的过程,为什么是先右移10位再左移12位?为什么不左移10位?

  5.lab pgtb中提到检测哪些page被访问了,可以用于垃圾回收机制,具体是怎样实现的呢?这里的问题在于,当read/write某个page后,PTE中对应的flag被永远的设置为1,怎么用于垃圾回收呢?
  A:可以设置一个定时器,操作系统在一定时间后清除被设置为1的access bit。

  6.stack frame中指向前一个frame指针的作用?
  A:在backtrace()中使用,当指令出错时,用于打印已经执行过的函数信息。

  7.block,即磁盘中的一个块指的是什么呢?
  Q:和内存中的页一个概念,操作系统read/write的最小单元,通常也是4096bytes。

  8.日志系统中,如果系统崩溃并重启,文件系统会在启动过程中恢复自己。如果日志被标记为包含一个完整的操作,那么恢复代码就会将写入的内容复制到它们在磁盘文件系统中的相应位置。如果在恢复过程中系统再次崩溃会发生什么?
  A:恢复代码只有在将日志中提交的事务全部写入磁盘后才会在日志中将未提交flag更改为已提交,如果恢复过程中系统再次崩溃,下一次重启后恢复代码将重新向磁盘提交事务。



To be a sailor of the world bound for all ports.

你可能感兴趣的:(学习,学习)